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)

相关推荐
orient3 小时前
ForkJoin 框架源码深度解析:工作窃取 + RecursiveTask 实战
后端
karry_k3 小时前
Skill 到底是什么?从提示词、脚本到多智能体工作流的完整指南
java·人工智能·后端
人间凡尔赛4 小时前
eBPF + WebAssembly 正在重写服务网格数据平面:2026 云原生架构的“去 Sidecar“革命
后端·云原生·架构
啊哈一半醒4 小时前
Go 语言 Context 全方位详解:原理、实战与避坑
开发语言·后端·golang
站大爷IP4 小时前
被 `@staticmethod` 和 `@classmethod` 坑惨了:继承链里它们真的会“变脸”
后端
orient4 小时前
CompletableFuture 源码深度解析:6 大 API + 链式编排实战
后端
Zane19944 小时前
map 比推导式快"是真的吗?一文讲透 map、filter、reduce 与 lambda 的真实性能与设计取舍
后端·python
字节跳动数据库4 小时前
火山引擎 RDS MySQL 向量索引:把高性能向量检索带到 MySQL 上
人工智能·后端·mysql
拖孩4 小时前
用 AI 重解千年观音灵签,做了一个微信小程序,每天摇一摇,命运给你回应
前端·后端·微信小程序