MyBatis 多数据源常见难题:事务开始了,为何数据源还没选出来?
摘要: MyBatis 或 MyBatis-Plus 项目接入动态数据源后,同样会遇到事务先启动、路由键后出现的时序问题。MetaLite ORM 不靠业务注解提前猜库,而是用
PENDING → BOUND状态机把事务延迟到第一次 DAO 操作,并在事务内强制读主库;本文同时说明跨数据源与嵌套调用边界。
单数据源项目中,事务开始是一件相对直接的事:进入业务方法,Spring 从固定 DataSource 获取连接,然后开启事务。
到了动态数据源场景,顺序可能反过来。
业务方法刚进入时,只知道"接下来需要事务",却还不知道具体会访问哪个库。真正的路由信息可能来自用户 ID、租户、城市或 DAO 的业务参数,要到第一次数据访问时才能确定。
这时如果过早绑定默认数据源,后续路由就可能失效;如果不绑定,又不能保证同一事务内的所有操作使用同一连接边界。
MetaLite 的选择是:先记录事务意图,再由第一次 DAO 操作决定真实 DataSource 并启动 Spring 本地事务。
一、动态路由改变了事务的正常顺序
普通事务的思维顺序通常是:
text
进入事务方法
→ 获取固定 DataSource
→ 开启事务
→ 执行 DAO
→ 提交或回滚
动态路由需要的是:
text
进入事务回调
→ 标记"等待绑定"
→ 第一次 DAO 根据业务参数路由
→ 得到真实 DataSource
→ 开启事务
→ 后续 DAO 必须使用同一 DataSource
→ 提交或回滚
难点不只是"切换数据源",而是必须让路由决定和事务决定成为同一个决定。
如果路由组件选择了 A 库,事务管理器却绑定了 B 库,代码表面运行在事务中,实际一致性已经不可控。
二、PENDING 与 BOUND 两个状态解决什么问题
MetaLite 使用 TransactionContext 在当前线程保存事务状态,核心状态为:
java
public enum State {
PENDING, // 等待绑定 DataSource
BOUND, // 已绑定并开启事务
NONE // 无事务
}
执行事务回调前,TransactionManager 并不立即创建事务管理器,而是先写入 PENDING:
java
TransactionContext.setPending(transactionSettings);
QueryToMasterSwitch.open();
此时系统只保存隔离级别、传播行为和超时等事务设置,还没有 DataSource、事务管理器和事务状态。
这一步表达的是:
当前代码必须运行在事务语义下,但真实数据库连接要等待路由结果。
三、第一次 DAO 操作才真正启动事务
DAO 获取写库 JdbcTemplate 时,会先调用自身的 DbRouter:
java
Collection<String> dbNames = jdbcTemplateMap.keySet();
String dbName = dbRouter.writeRoute(dbNames);
JdbcTemplate jdbcTemplate = jdbcTemplateMap.get(dbName);
路由完成后,再检查事务上下文:
java
if (TransactionContext.isPending()) {
TransactionContext.bindAndStartTransaction(
jdbcTemplate.getDataSource()
);
}
bindAndStartTransaction 使用路由选中的 DataSource 创建 Spring DataSourceTransactionManager,再应用此前保存的事务配置:
java
state.txManager = new DataSourceTransactionManager(dataSource);
DefaultTransactionDefinition def =
new DefaultTransactionDefinition();
def.setIsolationLevel(settings.getIsolationLevel().value());
def.setPropagationBehavior(
settings.getPropagationBehavior().value()
);
def.setTimeout(settings.getTimeout());
state.txStatus = state.txManager.getTransaction(def);
state.state = State.BOUND;
从这一刻开始,事务由 PENDING 进入 BOUND,并记录唯一绑定的 DataSource。
四、事务中的第一次操作如果是查询怎么办
读写分离场景还有一个常见问题:事务开始后的查询应该走从库还是主库?
MetaLite 在进入事务回调时打开 QueryToMasterSwitch。获取读库时只要发现开关已打开,就转到写库选择逻辑:
java
if (readMap.isEmpty() || QueryToMasterSwitch.isOpen()) {
return getWriteJdbcTemplate(groupName, dbRouter);
}
因此,即使事务中的第一次 DAO 操作是查询,它也会:
- 按写库规则完成路由;
- 绑定主 DataSource;
- 在该 DataSource 上开启事务;
- 后续读写继续使用同一事务边界。
这样可以避免事务刚写入主库后,又从存在复制延迟的从库读取旧数据。
它牺牲了一部分事务内读请求的从库分流能力,换取明确的读己之写语义。
五、后续 DAO 为什么必须校验同一个 DataSource
第一次 DAO 已经绑定 DataSource 后,后续每次写库选择仍会执行路由。
如果新路由结果不是已绑定实例,源码会直接抛出异常:
java
if (jdbcTemplate.getDataSource() != boundDataSource) {
throw new RuntimeException(
"本地事务无法跨数据源"
);
}
这条限制非常重要。
本地 DataSourceTransactionManager 只能管理一个 DataSource。发现跨库时明确失败,比悄悄让第二个库游离在事务之外更安全。
如果业务确实需要一个操作修改多个独立数据源,应显式选择分布式事务、可靠消息、事务补偿或业务状态机,而不是把本地事务包装成跨库能力。
六、提交、回滚与没有访问数据库的情况
事务回调的完整结构如下:
java
try {
result = callback.doTransaction();
TransactionContext.commit();
} catch (Throwable ex) {
TransactionContext.rollback();
throw wrap(ex);
} finally {
QueryToMasterSwitch.close();
TransactionContext.clear();
}
只有状态已经进入 BOUND,commit() 和 rollback() 才真正操作 Spring 事务。
如果回调中没有触发任何 DAO,状态会一直停留在 PENDING。此时框架记录警告,不会创建一个没有实际数据访问的事务,也不需要提交。
finally 中无论成功失败都会关闭强制读主开关并清理 ThreadLocal,避免事务状态污染线程池中的下一次请求。
七、当前实现不是 @Transactional 的透明替代
这套能力通过编程式入口使用:
java
transactionManager.doInTransaction(() -> {
userDao.updateById(userId, update);
operateLogDao.insert(logEntity);
return null;
});
它底层使用 Spring 的 DataSourceTransactionManager 和事务定义,但当前入口不是一个自动套用在任意方法上的 @Transactional 注解。
这项区别必须说清楚。编程式入口让"何处建立延迟绑定上下文"非常明确,但也意味着业务代码需要主动进入回调。
八、ThreadLocal 带来的三个真实边界
事务状态保存在 ThreadLocal,因此当前实现存在明确范围:
1. 不能跨线程自动传播
在事务回调中把 DAO 操作提交给另一个线程,子线程不会自动继承当前 TransactionContext,也不能共享原线程中的 JDBC 事务。
2. 不能把传播枚举理解为完整嵌套语义
TransactionSettings 会把传播行为传给真正创建的 Spring 事务,但外层的 PENDING/BOUND 状态本身只有一个 ThreadLocal 槽位。
再次嵌套调用 doInTransaction 会覆盖当前状态,因此不能宣称已经实现了标准 Spring 事务的全部嵌套和挂起语义。当前更安全的使用方式是由最外层业务边界统一建立一次事务回调。
3. 只管理单个本地 DataSource
路由到其他 DataSource 时会明确失败。跨服务、跨数据库的一致性仍然属于分布式事务或业务补偿范围。
九、延迟绑定的核心不是"晚一点开启"
延迟绑定真正解决的是三件事:
- 让业务路由先决定真实数据源;
- 让事务管理器绑定同一个决定;
- 让后续 DAO 无法悄悄越过这个边界。
动态数据源系统最危险的情况,不是直接报错,而是"看起来有事务,实际操作落在不同连接或不同库中"。
明确的 PENDING → BOUND 状态机,把这种隐式风险转成了可检查、可失败的工程约束。
下一篇将回到 Spring Bean 生命周期:为什么业务代码在 @PostConstruct 中调用 RPC 时,AOP 已经触发,部分处理器 Bean 却可能尚未创建完成。
框架简介:元界 MetaLite --- 下一代企业级 Java 微服务技术底座
作者简介:基于 Spring 体系 15 年企业级开发经验,专注于通过企业级生产环境落地的工程思维和架构思想打造下一代Java 微服务技术底座
完整文档与源码 :Gitee 搜索 MetaLite (gitee.com/MetaLite)