MyBatis多数据源常见难题-事务开始了为何数据源还没选出来

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 操作是查询,它也会:

  1. 按写库规则完成路由;
  2. 绑定主 DataSource;
  3. 在该 DataSource 上开启事务;
  4. 后续读写继续使用同一事务边界。

这样可以避免事务刚写入主库后,又从存在复制延迟的从库读取旧数据。

它牺牲了一部分事务内读请求的从库分流能力,换取明确的读己之写语义。

五、后续 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();
}

只有状态已经进入 BOUNDcommit()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)

相关推荐
Zane19942 小时前
去重用 set 到底能快多少?实测差距接近三百倍
后端·python
未秃头的程序猿2 小时前
一次秒杀把服务打挂了,我用Sentinel规则配置化救了回来
java·后端·spring cloud
Zane19942 小时前
明明没删字段,反序列化却报错:都是隐式 serialVersionUID 惹的祸
java·后端
Gopher_HBo2 小时前
beego启动流程
后端
fliter2 小时前
Go Map 详解:键值对实际上是如何存储的
后端
万物智能2 小时前
设备树DTS-【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端·算法
CadeCode2 小时前
Oracle 逗号拼接字段处理
数据库·后端·性能优化
geovindu2 小时前
CSharp: State Pattern
开发语言·后端·c#·.net·状态模式·行为模式
山岚的运维笔记2 小时前
mysql 专业笔记 -- 第 1 章:MySQL 入门
运维·数据库·笔记·后端·学习·mysql·dba
fliter2 小时前
我们如何通过优化 1.1.1.1 的 DNS 缓存节省 100 TB 内存
后端