多租户、读写分离与多数据源设计
在前几篇中,我们已经逐步完成了:
- tio-boot + jOOQ 基础整合
- 事务管理
- Codegen 强类型升级
- Record / POJO CRUD
- 批量、分页、动态 SQL
- PostgreSQL UPSERT、returning
- 多表查询、DTO 投影、聚合统计
- JSONB、窗口函数、CTE 与 PostgreSQL 高级 SQL
- 审计字段、乐观锁、数据权限与企业级 Repository 设计
- 测试策略、SQL 日志、性能诊断与生产排障
到这里,这套方案已经具备了非常完整的单库工程能力。
但只要系统继续往企业级演进,就迟早会遇到下面几类架构问题:
- 不同租户的数据如何隔离
- 查询流量越来越大,如何把读压力分出去
- 一套系统需要连多个数据库,怎么优雅管理
- 一个业务请求里,如何选择正确的数据源
- 事务在多数据源场景下应该怎么理解
- Repository 层如何在不失控的情况下支持多数据源路由
这正是本文的主题:
如何在 tio-boot + jOOQ 中实现多租户、读写分离与多数据源设计。
本文会系统讲清:
- 多租户的几种常见模式
- 基于
tenant_id的单库多租户设计 - 数据源级多租户设计
- 读写分离的基本原则
- 多数据源路由上下文
- 多
DSLContext/ 多DataSource管理 - 事务在多数据源场景下的边界
- Repository 层如何优雅适配
- 生产落地时最容易踩的坑
一、为什么这一篇是“架构演进篇”
前面的内容,主要都建立在一个前提之上:
默认只有一个数据库、一个数据源、一个
DSLContext。
这个前提在系统早期通常是成立的。
但随着业务增长,常见演进路径通常会变成:
1. 从单租户走向多租户
不同客户的数据需要隔离。
2. 从单库读写走向读写分离
读流量越来越大,不希望都压在主库。
3. 从单数据源走向多数据源
例如:
- 主业务库
- 审计库
- 报表库
- 历史归档库
- 第三方同步库
一旦进入这一步,数据访问层就不能再只考虑“怎么写 SQL”,而必须考虑:
- SQL 应该在哪个库执行
- 当前线程应该使用哪个
DSLContext - 同一个事务能不能跨库
- 怎么避免业务代码到处
if/else切数据源
所以这一篇讨论的是:
从“单库数据访问设计”升级到“多库架构设计”。
二、多租户:先明确三种主流模式
在落地之前,必须先把多租户模式讲清楚。
因为“多租户”不是一个唯一答案,而是至少有三种不同路线。
2.1 模式一:共享数据库,共享表,用 tenant_id 区分
也就是:
- 所有租户在同一个库
- 所有租户共享同一套表
- 每张业务表增加
tenant_id
例如:
CREATE TABLE system_admin (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
login_name VARCHAR(64),
password VARCHAR(64)
);
查询时始终带:
where tenant_id = ?
优点
- 架构最简单
- 成本最低
- 运维最容易
- 非常适合中小规模 SaaS 系统
缺点
- 所有租户共享库资源
- 超大租户可能拖累其他租户
- 租户隔离主要依赖应用层正确拼条件
这是最常见、也是最推荐优先考虑的起步方案。
2.2 模式二:共享数据库,不同 schema
例如:
tenant_a.system_admintenant_b.system_admin
优点
- 逻辑隔离更强
- 表结构仍相对统一
缺点
- schema 数量多了以后运维复杂
- 代码生成、迁移、变更都更麻烦
- 路由和管理复杂度高于
tenant_id模式
这种方案理论上可行,但在 Java 应用层工程上通常没有 tenant_id 方案那么简单实用。
2.3 模式三:每个租户独立数据库
也就是:
- 租户 A 一套库
- 租户 B 一套库
- 每个租户独立数据源
优点
- 隔离最强
- 大客户可独立扩缩容
- 备份、迁移、恢复更灵活
缺点
- 运维成本最高
- 应用层路由复杂
- 多租户数量一多,数据源管理压力很大
这通常适用于:
- 大客户独立部署
- 金融、政企等高隔离要求场景
- 明确存在“超级大租户”
2.4 本文重点关注哪两类
在 tio-boot + jOOQ 里,最适合重点讲的通常是两类:
1. tenant_id 单库多租户
因为它最常见,最适合多数业务。
2. 数据源级多租户
因为它和“多数据源路由”本质一致,可扩展到大租户独库场景。
三、单库多租户:基于 tenant_id 的推荐设计
这是最实用的一种方式。
核心思路很简单:
每张租户业务表都带
tenant_id,所有查询默认自动拼上tenant_id = 当前租户条件。
3.1 表结构设计建议
推荐每张租户表都显式带:
tenant_id BIGINT NOT NULL
例如:
CREATE TABLE system_admin (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
login_name VARCHAR(64),
password VARCHAR(64),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false
);
并为高频查询建立联合索引,例如:
CREATE INDEX idx_system_admin_tenant_login
ON system_admin(tenant_id, login_name);
或者:
CREATE INDEX idx_system_admin_tenant_deleted_id
ON system_admin(tenant_id, deleted, id DESC);
为什么推荐联合索引带 tenant_id
因为一旦所有查询都带租户条件,tenant_id 就会变成最常见过滤维度之一。
如果索引里没有它,性能很容易下降。
3.2 定义租户上下文
和前面审计上下文、数据权限上下文一样,多租户也推荐有统一上下文。
package demo.jooq.tenant;
public class TenantContext {
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static Long getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
请求进入系统后,在认证、网关或拦截器阶段把租户 id 放进去:
TenantContext.setTenantId(tenantId);
请求结束后清理。
3.3 在 Repository 层统一拼租户条件
最重要的是不要让每个 DAO 自己记得去写:
.where(SYSTEM_ADMIN.TENANT_ID.eq(TenantContext.getTenantId()))
而应该沉淀成 Repository 层通用能力。
例如在 BaseRepository 中加:
package demo.jooq.repository;
import org.jooq.Condition;
import org.jooq.TableField;
import org.jooq.impl.DSL;
import demo.jooq.tenant.TenantContext;
public abstract class TenantBaseRepository extends BaseRepository {
protected Condition tenantCondition(TableField<?, Long> tenantField) {
Long tenantId = TenantContext.getTenantId();
if (tenantId == null) {
return DSL.falseCondition();
}
return tenantField.eq(tenantId);
}
}
然后具体查询:
.where(tenantCondition(SYSTEM_ADMIN.TENANT_ID))
.and(SYSTEM_ADMIN.DELETED.eq(false))
这样租户过滤会更一致。
3.4 插入时自动填充 tenant_id
查询时要自动带租户条件,插入时同样要自动带租户值。
例如:
public Integer insertAdmin(String loginName, String password) {
Long tenantId = demo.jooq.tenant.TenantContext.getTenantId();
return ctx()
.insertInto(SYSTEM_ADMIN)
.set(SYSTEM_ADMIN.TENANT_ID, tenantId)
.set(SYSTEM_ADMIN.LOGIN_NAME, loginName)
.set(SYSTEM_ADMIN.PASSWORD, password)
.returning(SYSTEM_ADMIN.ID)
.fetchOne(SYSTEM_ADMIN.ID);
}
这样可以避免业务层忘记传租户 id。
3.5 为什么租户条件必须下沉到底层
因为如果租户条件只放在 Service 层或 Controller 层,很容易出现:
- 某个新查询漏加
- 某个统计接口绕过
- 某个导出功能忘记限制
多租户条件和数据权限一样,越往底层沉淀,越不容易漏。
当然,复杂场景仍可能需要更上层参与,但 Repository 层统一条件是最实用的一步。
四、数据源级多租户:每个租户一套 DataSource
当租户越来越大,或者隔离要求更强时,单纯 tenant_id 已经不够。
这时更常见的是:
按租户路由到不同数据源。
本质上,它和多数据源设计是同一问题。
4.1 核心思路
- 系统里维护多个
DataSource - 每个数据源对应一个租户库,或一个租户分组库
- 当前请求根据租户 id 选择对应数据源
- 基于该数据源创建并使用对应
DSLContext
4.2 不要在业务代码里手工 if/else 切库
最糟糕的写法就是:
if (tenantId == 1) {
use ds1
} else if (tenantId == 2) {
use ds2
}
然后这种逻辑散落在各个 DAO、Service 里。
正确思路是:
通过上下文 + 路由器,统一决定当前线程该用哪个数据源。
五、多数据源设计的核心:RoutingContext
无论是多租户独库,还是读写分离,还是多个业务库,底层都需要一个“当前线程数据源路由上下文”。
例如:
package demo.jooq.datasource;
public class DataSourceContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
public static void set(String key) {
CURRENT.set(key);
}
public static String get() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}
这里的 key 可以是:
masterslavetenant_1001auditreport
它的本质就是:
当前线程这次数据库操作,应该落到哪个数据源。
六、构建多 DataSource / 多 DSLContext 注册中心
接下来需要一个统一管理器,负责:
- 保存多个
DataSource - 保存多个
DSLContext - 根据 key 返回对应的
DSLContext
6.1 一个简单的 DslContextRegistry
package demo.jooq.datasource;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jooq.DSLContext;
public class DslContextRegistry {
private final Map<String, DSLContext> registry = new ConcurrentHashMap<>();
public void add(String key, DSLContext dsl) {
registry.put(key, dsl);
}
public DSLContext get(String key) {
return registry.get(key);
}
public DSLContext require(String key) {
DSLContext dsl = registry.get(key);
if (dsl == null) {
throw new IllegalStateException("No DSLContext found for key: " + key);
}
return dsl;
}
}
这个注册中心非常重要,因为后续所有路由都会用到它。
6.2 在配置类中初始化多个数据源
示意写法:
package demo.jooq.config;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.impl.DSL;
import com.alibaba.druid.pool.DruidDataSource;
import com.litongjava.annotation.AConfiguration;
import com.litongjava.annotation.Initialization;
import com.litongjava.jfinal.aop.AopManager;
import demo.jooq.datasource.DslContextRegistry;
@AConfiguration
public class MultiDataSourceConfig {
@Initialization
public void init() {
DslContextRegistry registry = new DslContextRegistry();
DruidDataSource masterDs = new DruidDataSource();
masterDs.setUrl("jdbc:postgresql://127.0.0.1:5432/app_master");
masterDs.setUsername("postgres");
masterDs.setPassword("123456");
DruidDataSource slaveDs = new DruidDataSource();
slaveDs.setUrl("jdbc:postgresql://127.0.0.1:5432/app_slave");
slaveDs.setUsername("postgres");
slaveDs.setPassword("123456");
DSLContext masterDsl = DSL.using(masterDs, SQLDialect.POSTGRES);
DSLContext slaveDsl = DSL.using(slaveDs, SQLDialect.POSTGRES);
registry.add("master", masterDsl);
registry.add("slave", slaveDsl);
AopManager.me().addSingletonObject(DslContextRegistry.class, registry);
}
}
这里先不追求最复杂,而是先把核心结构立起来。
七、封装一个路由 DSLContext 提供器
有了 DataSourceContext 和 DslContextRegistry,接下来就需要一个统一入口:
当前代码执行时,到底取哪个
DSLContext。
7.1 RoutingDslContextProvider
package demo.jooq.datasource;
import org.jooq.DSLContext;
public class RoutingDslContextProvider {
private final DslContextRegistry registry;
public RoutingDslContextProvider(DslContextRegistry registry) {
this.registry = registry;
}
public DSLContext getCurrent() {
String key = DataSourceContext.get();
if (key == null) {
key = "master";
}
return registry.require(key);
}
}
默认走 master,如果线程上下文显式指定了别的 key,就走对应数据源。
7.2 在 AOP 容器中注册
RoutingDslContextProvider provider = new RoutingDslContextProvider(registry);
AopManager.me().addSingletonObject(RoutingDslContextProvider.class, provider);
这样 Repository 就可以统一依赖它,而不是直接依赖某个固定的 DSLContext。
八、改造 BaseRepository:支持多数据源路由
原来 BaseRepository 里可能直接注入一个单例 DSLContext。 多数据源场景下,需要改成依赖路由提供器。
8.1 路由版 BaseRepository
package demo.jooq.repository;
import org.jooq.DSLContext;
import com.litongjava.annotation.Inject;
import demo.jooq.datasource.RoutingDslContextProvider;
import demo.jooq.tx.TransactionContext;
public abstract class BaseRepository {
@Inject
private RoutingDslContextProvider routingProvider;
protected DSLContext ctx() {
DSLContext txDsl = TransactionContext.get();
if (txDsl != null) {
return txDsl;
}
return routingProvider.getCurrent();
}
}
这里有个关键点
如果当前线程已经在事务中,那么优先使用事务绑定的 DSLContext,而不是重新路由。
这点非常重要,因为事务场景必须保证同一线程复用同一连接。
九、读写分离:最常见的多数据源场景
多数据源里,最常见的就是读写分离。
基本结构通常是:
master:写库slave:读库
9.1 最基础的读写分离原则
通常约定:
- insert / update / delete / upsert 走
master - 普通 select 走
slave
但这里要立刻强调一件事:
读写分离不是“所有查询都能去从库”。
后面会专门讲这个坑。
9.2 定义读写路由工具类
package demo.jooq.datasource;
public class ReadWriteContext {
public static void useRead() {
DataSourceContext.set("slave");
}
public static void useWrite() {
DataSourceContext.set("master");
}
public static void clear() {
DataSourceContext.clear();
}
}
业务入口或 AOP 可以在执行读操作前:
ReadWriteContext.useRead();
写操作前:
ReadWriteContext.useWrite();
执行完后清理。
9.3 更好的方式:注解 + AOP 路由
为了避免手工在业务代码里写:
ReadWriteContext.useRead();
...
ReadWriteContext.clear();
推荐加一个注解,例如:
package demo.jooq.datasource;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface ReadOnlyRoute {
}
然后用 AOP 拦截器处理。
9.4 读库路由拦截器示意
package demo.jooq.datasource;
import com.litongjava.jfinal.aop.AopInterceptor;
import com.litongjava.jfinal.aop.AopInvocation;
public class ReadOnlyRouteInterceptor implements AopInterceptor {
@Override
public void intercept(AopInvocation inv) {
try {
ReadWriteContext.useRead();
inv.invoke();
} finally {
ReadWriteContext.clear();
}
}
}
然后把 @ReadOnlyRoute 和这个拦截器绑定起来。
这样 Service 或 Repository 某些只读方法就可以写成:
@ReadOnlyRoute
public List<SystemAdmin> listAdmins() {
return repository.findAll();
}
十、读写分离最关键的现实问题:主从延迟
这是读写分离里必须讲透的问题。
假设业务流程是:
- 写入主库
- 立刻查询刚写的数据
- 但查询走了从库
如果主从同步有延迟,就可能出现:
- 明明刚写成功
- 却查不到
这就是典型的:
主从延迟导致读到旧数据
10.1 哪些场景不适合马上走从库
例如:
- 注册成功后立刻查用户详情
- 刚修改密码后立刻校验
- 刚创建订单后立刻返回完整订单
- 写后读一致性要求高的接口
这些场景通常都应该:
强制读主库
10.2 经验原则
可以把接口简单分成两类:
1. 强一致读
写后立刻读、必须看到最新数据。 这种读应该走主库。
2. 最终一致读
例如:
- 后台列表
- 报表
- 搜索
- 非关键详情页
这类读可以走从库。
所以不要简单理解成:
- select 一律 slave
- update 一律 master
真实项目里,这个判断比语法分类更重要。
十一、多数据源事务:要先明确边界
这是多数据源设计里最容易误解的部分。
前面我们实现的 TransactionManager,本质上是:
基于一个 DataSource 获取连接,然后在这个连接上控制事务。
所以它天然是:
- 单数据源事务
- 单连接事务
11.1 这意味着什么
意味着如果一个业务方法里:
- 先操作
master - 再操作
audit - 再操作另一个租户库
这些操作默认并不在同一个本地事务里。
也就是说:
当前这套事务管理,不是分布式事务。
这点必须说清楚。
11.2 多数据源事务的三种思路
1. 尽量避免跨库事务
这是最推荐的。
让一个业务事务尽量只落一个数据源。
2. 最终一致性
例如:
- 主业务库事务成功
- 审计日志异步补偿
- 通过消息、任务保证最终一致
这是企业里最常见的方式。
3. 分布式事务
理论上可以做,但复杂度高、代价大,通常不建议轻易引入。
11.3 本系列推荐的态度
在 tio-boot + jOOQ 这种轻量体系里,更推荐:
优先单库事务,跨库场景尽量通过业务拆分和最终一致解决。
而不是一上来就追求重型分布式事务框架。
十二、让事务管理支持指定数据源
虽然不建议一个事务跨多个数据源,但一个事务明确绑定某个数据源还是很常见的。
例如:
- 某个 Service 明确只操作
master - 某个租户独库 Service 明确只操作
tenant_1001
这时可以扩展 TransactionManager。
12.1 路由事务管理器示意
package demo.jooq.tx;
import java.sql.Connection;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.impl.DSL;
import demo.jooq.datasource.DslContextRegistry;
import javax.sql.DataSource;
public class RoutingTransactionManager {
private final java.util.Map<String, DataSource> dataSourceMap;
private final SQLDialect dialect;
public RoutingTransactionManager(java.util.Map<String, DataSource> dataSourceMap, SQLDialect dialect) {
this.dataSourceMap = dataSourceMap;
this.dialect = dialect;
}
public <T> T tx(String key, TxCallable<T> callable) {
DataSource ds = dataSourceMap.get(key);
if (ds == null) {
throw new IllegalStateException("No DataSource found for key: " + key);
}
Connection conn = null;
try {
conn = ds.getConnection();
conn.setAutoCommit(false);
DSLContext txDsl = DSL.using(conn, dialect);
TransactionContext.set(txDsl);
T result = callable.call();
conn.commit();
return result;
} catch (Exception e) {
if (conn != null) {
try {
conn.rollback();
} catch (Exception ignore) {
}
}
throw new RuntimeException(e);
} finally {
TransactionContext.clear();
if (conn != null) {
try {
conn.setAutoCommit(true);
conn.close();
} catch (Exception ignore) {
}
}
}
}
@FunctionalInterface
public interface TxCallable<T> {
T call() throws Exception;
}
}
这样业务层就可以显式指定:
routingTxManager.tx("master", () -> {
...
return result;
});
或者:
routingTxManager.tx("tenant_1001", () -> {
...
return result;
});
十三、多租户独库与读写分离如何组合
到了这里,你会发现:
- 多租户独库
- 读写分离
- 多业务库
本质上都可以统一抽象成:
当前线程路由到哪个 key 对应的
DSLContext
例如:
tenant_1001_mastertenant_1001_slavetenant_1002_mastertenant_1002_slaveauditreport
这样你的路由上下文就不只是一个简单的 master/slave,而是一个更通用的路由 key。
13.1 推荐把路由 key 设计成可组合字符串
例如:
String key = tenantId + ":" + role;
其中:
- tenantId 代表租户
- role 代表 master / slave
例如:
1001:master1001:slave
这样扩展性会更好。
13.2 不要让业务代码感知过多路由细节
虽然底层 key 可以很灵活,但业务代码最好不要到处拼:
DataSourceContext.set("1001:slave");
更推荐封装成:
TenantRoute.useTenantRead(1001L);
TenantRoute.useTenantWrite(1001L);
或者通过 AOP 与上下文自动完成。
这样代码不会失控。
十四、Repository 层的推荐设计方式
到多数据源阶段,Repository 设计就更重要了。
14.1 Repository 仍然应该只关心 ctx()
一个健康的 Repository,不应该关心:
- 当前是 master 还是 slave
- 当前是 tenant_1001 还是 tenant_1002
- 当前是不是报表库
它只应该关心:
ctx()
至于这个 ctx() 返回哪个 DSLContext,交给:
- 路由上下文
- 路由提供器
- 事务上下文
去决定。
这就是分层价值。
14.2 BaseRepository 不要暴露太多底层路由细节
如果 Repository 方法签名里到处都是:
findById(String dsKey, Integer id)
那就说明设计已经开始泄漏底层架构细节了。
除非是特别底层的管理工具类,一般业务 Repository 不建议这样写。
十五、一个多数据源可落地的完整结构建议
下面给出一个比较推荐的整体结构。
15.1 上下文层
TenantContextDataSourceContextReadWriteContext
负责记录当前线程:
- 当前租户
- 当前路由 key
- 当前读/写偏好
15.2 基础设施层
DslContextRegistryRoutingDslContextProviderRoutingTransactionManager
负责:
- 管理多个
DSLContext - 根据 key 取当前上下文的
DSLContext - 在指定数据源上开启事务
15.3 Repository 层
BaseRepositoryTenantBaseRepository- 各业务
XxxRepository
Repository 只通过:
ctx()
拿当前可用的 DSLContext。
15.4 Service 层
负责:
- 事务边界
- 决定强一致读还是最终一致读
- 决定是否走主库
- 组合多 Repository
十六、一个示意性的完整代码结构
下面给一个简化版示意,帮助把几个核心对象串起来。
16.1 路由上下文
package demo.jooq.datasource;
public class DataSourceContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
public static void set(String key) {
CURRENT.set(key);
}
public static String get() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}
16.2 DSLContext 注册中心
package demo.jooq.datasource;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jooq.DSLContext;
public class DslContextRegistry {
private final Map<String, DSLContext> map = new ConcurrentHashMap<>();
public void add(String key, DSLContext dsl) {
map.put(key, dsl);
}
public DSLContext require(String key) {
DSLContext dsl = map.get(key);
if (dsl == null) {
throw new IllegalStateException("No DSLContext found for key: " + key);
}
return dsl;
}
}
16.3 路由提供器
package demo.jooq.datasource;
import org.jooq.DSLContext;
public class RoutingDslContextProvider {
private final DslContextRegistry registry;
public RoutingDslContextProvider(DslContextRegistry registry) {
this.registry = registry;
}
public DSLContext getCurrent() {
String key = DataSourceContext.get();
if (key == null) {
key = "master";
}
return registry.require(key);
}
}
16.4 BaseRepository
package demo.jooq.repository;
import org.jooq.DSLContext;
import com.litongjava.annotation.Inject;
import demo.jooq.datasource.RoutingDslContextProvider;
import demo.jooq.tx.TransactionContext;
public abstract class BaseRepository {
@Inject
private RoutingDslContextProvider provider;
protected DSLContext ctx() {
DSLContext txDsl = TransactionContext.get();
return txDsl != null ? txDsl : provider.getCurrent();
}
}
16.5 示例 Repository
package demo.jooq.repository;
import static demo.jooq.gen.tables.SystemAdmin.SYSTEM_ADMIN;
import java.util.List;
import demo.jooq.gen.tables.pojos.SystemAdmin;
public class SystemAdminRepository extends BaseRepository {
public List<SystemAdmin> findAll() {
return ctx()
.selectFrom(SYSTEM_ADMIN)
.fetchInto(SystemAdmin.class);
}
public int updatePassword(Integer id, String password) {
return ctx()
.update(SYSTEM_ADMIN)
.set(SYSTEM_ADMIN.PASSWORD, password)
.where(SYSTEM_ADMIN.ID.eq(id))
.execute();
}
}
这样 Repository 完全不感知底层多数据源细节。
十七、几个最容易踩的坑
17.1 不要把“多租户”和“数据权限”混为一谈
两者相关,但不是一回事。
多租户
核心是:
- 不同租户之间的数据隔离
数据权限
核心是:
- 同一租户内部,不同用户能看哪些数据
很多项目两者会同时存在,但应该分层处理,不要混成一团。
17.2 读写分离不是“select 全走从库”
真正应该判断的是:
- 这次读是否必须看到最新数据
而不是 SQL 语法是不是 select。
17.3 单库事务不能天然跨多个数据源
如果当前事务管理器是基于单个 DataSource 实现的,就不要误以为:
- 一次 Service 调用里操作多个库
- 就自动具备原子性
这通常是不成立的。
17.4 多租户独库后,数据源数量不能无限增长
如果租户数非常多,给每个租户都长期维持一个连接池,成本可能很高。
这时就要进一步考虑:
- 租户分组
- 热租户与冷租户分层
- 动态创建与回收数据源
- 连接池上限控制
也就是说,大规模独库多租户本身就是一个更高阶的话题。
17.5 路由上下文一定要清理
只要用 ThreadLocal 保存:
- 租户
- 数据源 key
- 读写标记
就必须在请求结束、AOP 结束后及时 clear()。
否则线程复用时,极容易出现串租户、串库、串路由的严重问题。
这是必须高度重视的。
十八、本篇总结
这一篇把 tio-boot + jOOQ 从“单库工程能力”继续推进到了“多库架构能力”。
通过本文,我们完成了:
- 理解多租户的三种主流模式
- 设计基于
tenant_id的单库多租户方案 - 设计基于数据源路由的多租户独库方案
- 理解读写分离的核心原则与主从延迟问题
- 引入
DataSourceContext、DslContextRegistry、RoutingDslContextProvider - 改造 BaseRepository 以支持多数据源路由
- 理解多数据源事务边界与最终一致性思路
- 建立多租户、读写分离、多数据源三者统一抽象的思维方式
一句话总结:
多租户、读写分离、多数据源,本质上都是“让当前线程在正确的时机拿到正确的
DSLContext”。
到这里,这套 tio-boot + jOOQ 体系已经从:
- 基础整合
- SQL 能力
- PostgreSQL 高级能力
- 企业治理能力
- 测试与排障能力
继续扩展到了:
- 多租户
- 读写分离
- 多数据源架构能力
这已经非常接近一个成熟企业项目的数据访问底座。
