第五章:[高级] 企业级核心特性


第五章:[架构] 多租户与数据架构实战

摘要: 跨越了基础的 CRUD 和单表插件后,本章将视角拉升至 系统架构 层面。我们将攻克企业级 SaaS 应用的三大核心难题:如何通过 多租户插件 实现行级数据隔离?如何利用 流式查询 解决百万级数据导出时的内存溢出(OOM)问题?以及如何通过 动态数据源 实现数据库的读写分离与分库分表。

本章学习路径

  1. SaaS 隔离:配置 TenantLineInnerInterceptor,实现无感知的行级多租户隔离。
  2. 内存救星:掌握 MyBatis 原生 ResultHandler 流式查询,能够低内存处理百万级数据。
  3. 读写架构:集成 dynamic-datasource,通过注解灵活切换主从库,分摊数据库压力。

5.1. 多租户架构 (Multi-Tenancy)

痛点背景:在开发 SaaS(软件即服务)平台时,我们需要将一套系统卖给多个公司(租户)使用。数据存储通常有两种方案:

  1. 独立数据库:每个租户一个库,成本极高,维护困难。
  2. 共享数据库:所有租户共用一张表,通过 tenant_id 字段区分。

方案 2 是主流,但开发极其痛苦。程序员必须在 每一条 SQL(查询、更新、删除)后面都手动加上 WHERE tenant_id = ?。一旦漏写,A 公司就能看到 B 公司的数据,这是 最高级别的安全事故

解决方案
Mybatis-Plus 提供了 多租户插件 (TenantLineInnerInterceptor)。它能在 SQL 执行前,自动在 WHERE 子句中拼接 tenant_id 条件,让开发者感觉像是在操作单租户系统一样。

5.1.1. 准备工作

步骤 1:数据库变更
我们需要在 tb_user 表中添加租户字段。

1
2
-- 添加租户 ID 字段
ALTER TABLE `tb_user` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户ID';

步骤 2:实体类变更
注意:实体类中 不需要 添加 tenantId 属性!因为这个字段是由插件自动管理的,业务代码通常不需要感知它(除非你需要手动读取它)。当然,为了调试方便,加上也无妨,但我们演示“无感隔离”,故暂不添加。

5.1.2. 配置多租户插件

我们需要实现 TenantLineHandler 接口,告诉 MP 如何获取当前登录用户的租户 ID。

文件路径: src/main/java/com/example/mpstudy/config/MybatisPlusConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Configuration
public class MybatisPlusConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

// [核心] 添加多租户插件
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {

// 1. 获取当前租户 ID
@Override
public Expression getTenantId() {
// 真实场景:从 ThreadLocal / Header / Token 中获取
// 这里为了演示,固定返回租户 1001
return new LongValue(1001);
}

// 2. 指定租户字段名 (默认就是 tenant_id,可省略)
@Override
public String getTenantIdColumn() {
return "tenant_id";
}

// 3. 过滤不需要隔离的表
// 比如:系统配置表、字典表是所有租户共享的,不需要加 tenant_id
@Override
public boolean ignoreTable(String tableName) {
return "sys_config".equalsIgnoreCase(tableName);
}
}));

// 添加其他插件 (分页等)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

5.1.3. 隔离效果验证

我们不需要修改任何业务代码,直接运行之前的查询测试。

文件路径: src/test/java/com/example/mpstudy/TenantTest.java

1
2
3
4
5
6
7
8
9
10
11
@Test
void testTenantIsolation() {
System.out.println("----- 多租户隔离测试 -----");

// 1. 执行普通查询
// 我们并没有设置 tenant_id 条件
List<UserDO> users = userMapper.selectList(null);

// 2. 观察日志
// 预期:MP 会自动加上 WHERE tenant_id = 1001
}

插入时的黑科技:当你执行 userMapper.insert(new UserDO()) 时,插件也会 自动tenant_id 字段填充为 1001,你甚至都不需要在 Java 对象里 set 这个值。


5.2. 海量数据流式查询 (Stream Query)

痛点背景:业务方提出需求:“我要导出所有用户的 Excel,大概有 100 万条。”
新手做法:List<User> list = userMapper.selectList(null);后果:JVM 瞬间尝试将 100 万个对象加载到堆内存,直接报 OOM (Out Of Memory) 错误,服务崩溃。

解决方案
MyBatis 原生支持 流式查询。配合 MP,我们可以通过 ResultHandler 接口,让数据“像水流一样”一条条流过内存,处理完一条扔一条,内存占用极低

5.2.1. 自定义流式 Mapper 方法

MP 的 BaseMapper 没有直接暴露流式接口,我们需要在自定义 Mapper 中声明。

文件路径: src/main/java/com/example/mpstudy/mapper/UserMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
public interface UserMapper extends BaseMapper<UserDO> {

/**
* 流式查询所有用户
* 注意:返回值必须是 void
* 数据通过 ResultHandler 回调处理
*/
@Select("SELECT * FROM tb_user")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000)
@ResultType(UserDO.class)
void selectAllStream(ResultHandler<UserDO> handler);
}

原理解析

  • fetchSize = 1000:这是 JDBC 的黑魔法。它告诉数据库驱动:“不要一次把 100 万条全给我,每次给我 1000 条。”
  • ResultSetType.FORWARD_ONLY:游标只向前滚动,节省资源。

5.2.2. 业务层调用实战

在 Service 层,我们通过回调函数处理每一条数据。

文件路径: src/test/java/com/example/mpstudy/StreamTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void testStreamQuery() {
System.out.println("----- 开始流式导出 -----");

// 模拟 Excel 写入器
AtomicInteger count = new AtomicInteger(0);

// 调用流式接口
userMapper.selectAllStream(resultContext -> {
// 这里的代码,每查出一行数据就会被执行一次
UserDO user = resultContext.getResultObject();

// 模拟写入 Excel
// ExcelWriter.write(user);

// 计数
if (count.incrementAndGet() % 1000 == 0) {
System.out.println("已处理 " + count.get() + " 条数据,内存依然平稳...");
}
});

System.out.println("导出完成,总共处理: " + count.get());
}

效果:无论数据库里有 1 万条还是 1000 万条数据,这段代码对 JVM 内存的占用始终维持在极低水平(仅取决于 fetchSize 的大小),彻底解决了 OOM 问题。


5.3. 动态数据源 (Dynamic Datasource)

在第四章中我们学习了单数据源的配置。但在企业级架构中,读写分离(主从架构)是提升数据库并发能力的必经之路。

MP 生态提供了 dynamic-datasource,它是目前 Java 界最优秀的动态数据源组件。

5.3.1. 依赖与配置

步骤 1:引入依赖

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>4.3.0</version>
</dependency>

步骤 2:配置主从库
application.yml 的配置结构发生完全变化,不再使用 spring.datasource,而是 spring.datasource.dynamic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
datasource:
dynamic:
primary: master # 默认主库
strict: true
datasource:
master: # 主库 (写)
url: jdbc:mysql://127.0.0.1:3306/mp_master
username: root
password: root
slave_1: # 从库 (读)
url: jdbc:mysql://127.0.0.1:3306/mp_slave_1
username: root
password: root

5.3.2. 注解控制路由 (@DS)

在 Service 层,我们可以通过注解精确控制方法走哪个库。

文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// [类级别] 默认走主库,确保写操作安全
@DS("master")
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO> implements UserService {

// 继承的 save/update/remove 均会使用类上的 @DS("master")

// [方法级别] 读操作走从库,分摊压力
@Override
@DS("slave_1")
public UserDO getById(Serializable id) {
return super.getById(id);
}

// [高级] 如果有多个从库,可以使用 @DS("slave") 配合负载均衡策略
// dynamic-datasource 支持轮询、随机等策略
}

注意事项

  1. 事务失效问题:如果在同一个 @Transactional 方法中切换数据源,切换会失效。因为 Spring 事务一旦开启,连接就绑定了。
    • 对策:尽量不要在事务内切换数据源,或者使用分布式事务(Seata)。
  2. 主从延迟:刚写入主库的数据,立刻去从库查可能查不到。
    • 对策:对于一致性要求高的查询(如支付后的状态查询),强制加上 @DS("master") 走主库。

5.4. 本章总结与架构速查

5.4.1. 场景化速查

场景一:SaaS 多租户隔离

  • 方案TenantLineInnerInterceptor
  • 代码:实现 getTenantId() 返回当前用户租户,实现 ignoreTable() 排除字典表。
  • 效果:所有 SQL 自动拼接 WHERE tenant_id = ?

场景二:导出 500 万行订单数据

  • 方案:MyBatis 流式查询 (ResultHandler)
  • 代码@Options(fetchSize = 1000, resultSetType = FORWARD_ONLY)
  • 效果:内存占用恒定,不会 OOM。

场景三:数据库 CPU 飙高,读多写少

  • 方案:读写分离 (dynamic-datasource)
  • 代码:配置 master / slave,写操作 @DS("master"),读操作 @DS("slave")
  • 效果:将 80% 的查询流量引流到从库,保护主库。

5.4.2. 核心避坑指南

  1. 多租户下的“漏网之鱼”

    • 现象:手写 XML SQL 时,发现没有自动拼接 tenant_id
    • 原因:MP 的插件主要拦截 MP 自动生成的 SQL。对于手写 SQL,如果 SQL 结构极其复杂,JSqlParser 可能解析失败导致无法添加条件。
    • 对策:手写 XML 时,务必手动加上 AND tenant_id = #{tenantId},不要完全依赖插件。
  2. 流式查询连接未关闭

    • 现象:流式查询执行一半报错 Connection is closed
    • 原因:流式查询必须在事务中执行,或者保持连接打开。
    • 对策:给 Service 方法加上 @Transactional(readOnly = true)