先给你吃颗定心丸:这句 SqlSession was not registered for synchronization because synchronization is not active 不是所有环境里的致命错误。很多项目里它只是日志被调到 DEBUG 后出现的一行提示,代码照样跑得飞快。但是,如果你是在做批量更新、读写分离、或者多个 Mapper 操作需要“要么全成功、要么全回滚”的业务里看到它,那就不能只是“哦,有意思”就划过去,因为它背后暴露的,往往是 Spring 事务没有生效,MyBatis 的 SqlSession 正在脱离事务管理,每一次 Mapper 调用都可能单独提交,一致性风险已经埋下了。
我遇到这行日志的次数,不比 NullPointerException 少。这篇文章会老老实实拆清楚:这是哪段代码打出来的、它为什么出现、什么时候可以无视、什么时候必须修,以及几种最实际的修复方式。你可以直接对照自己的日志上下文来判断,不用再去搜一堆模棱两可的回答。连我在排查初期都因为搜到 no server suitable for synchronization found 这种完全不相干的 NTP 时间同步报错而分心过,所以这次一并帮你把弯路标出来。
1. 先定位:这行日志到底是谁打印的
1.1 它来自 MyBatis-Spring,不是 MyBatis 核心
很多同事第一反应是去 Google 搜“MyBatis synchronization”,结果先看到 no server suitable for synchronization found,以为系统时间同步出问题,或者数据库主从同步出问题。我第一次排查时也被误导过,折腾了十分钟才发现这行日志和 NTP、数据库复制都没关系。
它真正的来源是 org.mybatis.spring.SqlSessionUtils 这个类。只要你在 Spring Boot 项目里使用了 mybatis-spring-boot-starter,或者手动引入了 mybatis-spring,并且通过 SqlSessionTemplate 执行 Mapper 方法,就可能走进这段逻辑。具体打日志的方法在我的环境里是 SqlSessionUtils.registerSessionHolder(),在 MyBatis-Spring 2.x 里它通常是 DEBUG 级别,但正是因为 Spring Boot 全家桶常常把 org.mybatis.spring 调低日志级别,这行提示才会出现在控制台。
1.2 直接堆栈 vs 日志上下文,先分清谁在报警
不要只看一行日志就下结论,先看它前后有没有异常堆栈。
如果你看到的完整日志大致是这样:
text复制Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@36a2f7a1] was not registered for synchronization because synchronization is not active
Closing SqlSession
这是无事务环境下的正常活动日志。DefaultSqlSession@36a2f7a1 就是默认的 SqlSession 实现,后面的 @ 加对象哈希是一串自动生成的编号,没有业务含义。它创建了、用了、关闭了,全程没有进入 Spring 的事务同步注册流程,所以打印这句话。
如果你看到的是:
text复制org.springframework.transaction.TransactionSystemException: Could not commit JDBC transaction
Caused by: java.sql.SQLException: ...
那才是需要处理的错误。前一句只是提示,后面的异常才是事故。你真正要分析的是异常栈,而不是把前面那句“同步未激活”当成根本原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring 事务和 SqlSession 的绑定机制,为什么需要 synchronization
2.1 原生的 MyBatis 使用方式,本来是一手交钱一手交货
为了讲清楚 synchronization,我先说下没有 Spring 时 MyBatis 是怎么工作的。传统用法是你手动拿到一个 SqlSession:
java复制try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
mapper.updateName(1, "张三");
session.commit();
}
在这段代码里,session 你自己开,commit() 你自己调,close 你自己管。如果中间调了两个 Mapper 方法,它们在同一个 SqlSession 里,也用同一个数据库连接。连接打开期间有事务边界,要么一起提交,要么一起回滚,这个模型很容易理解。
但把 MyBatis 塞进 Spring 后,我们不能让每个业务方法都自己管理 SqlSession。那样的话,每个 Service 方法都得写一堆 try-catch-commit-rollback,和 Spring 声明式事务完全脱节。而且 Spring 的 @Transactional 希望把一个事务边界内的多个 DAO 调用放进同一个数据库连接,你手动开 session 根本没法参与。
2.2 SqlSessionTemplate 和 Mapper 的动态代理,才是幕后主角
mybatis-spring 给出的方案是 SqlSessionTemplate。它不是你自己 new 出来的 DefaultSqlSession,而是一个线程安全的代理对象,内部通过 JDK 动态代理拦截所有 Mapper 方法。业务代码里注入 Mapper 接口,而 Mapper 接口在 Spring 容器中的实际实现,最终都会调用 SqlSessionTemplate。
当 Mapper 方法被执行时,真正干活的是 org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor。它会做几件事:
- 调用
SqlSessionUtils.getSqlSession()获取一个 SqlSession。 - 执行真正的 SQL 方法。
- 如果是非事务场景,执行完就
commit(true),把当前操作自动提交掉。 - 在 finally 中关闭 SqlSession。
也就是说,Spring 帮我们省略了 open/commit/close 的样板代码。代价是:如果事务没开启,每个 Mapper 方法默认就是“各干各的”,每次都是独立自动提交。
2.3 TransactionSynchronizationManager:Spring 事务同步的中央登记处
那 synchronization 是什么?Spring 有一个内部组件叫 TransactionSynchronizationManager,它维护了当前线程上的事务资源。事务开始后,会往一个 ThreadLocal 里放当前激活的资源、事务同步器列表、事务是否激活等状态。
当 MyBatis 的 Mapper 方法在事务执行期间被调用时,SqlSessionUtils 会先看看当前线程是否已经有绑定好的 SqlSession。如果有,直接复用;如果没有,就创建一个新的 SqlSession,然后把它绑定到当前线程的资源里,并注册一个 SqlSessionSynchronization。这样当事务提交或回滚时,Spring 会回调这个同步器,让 SqlSession 跟着做 commit、rollback、close。
我在实际代码里经常给同事打一个比方:Spring 事务就像一整桌商务宴请,TransactionSynchronizationManager 是前台服务员。你进餐厅时,服务员会记录你坐在几号桌(绑定资源)。等到结账时,他会根据是正常吃完还是掀桌,决定统一买单(commit)还是一起不买单(rollback)。而 SqlSession was not registered for synchronization 的意思就是:服务员发现你根本不在任何一个宴请订单里,只当你是路过吃个工作餐的,吃完单份付款,不需要记账。
2.4 没有注册同步,SqlSession 到底经历了什么
具体代码逻辑可以看 SqlSessionUtils.registerSessionHolder() 分支。伪代码大致如下:
java复制if (TransactionSynchronizationManager.isSynchronizationActive()) {
// 当前有 Spring 事务同步在运行
// 创建 SqlSessionHolder
// 绑定资源到当前线程
// 注册 SqlSessionSynchronization
// 标记 holder 已随事务同步
} else {
// 当前没有事务同步
logger.debug("SqlSession [] was not registered for synchronization because synchronization is not active");
}
所以这句话本身只是在告诉你:当前线程不够资格进 Spring 事务的“席面”,MyBatis 只能临时开个会话把 SQL 干完,然后马上关闭。
注意,这并不代表数据库操作失败了。在非事务场景下,SqlSessionTemplate 内部会在方法正常返回后执行 sqlSession.commit(true)。这个 true 表示强制提交,把当前未提交的操作写入数据库。然后 finally 里再 close,连接还回连接池。整个过程对调用方是透明的。
3. 什么时候只是噪音,什么时候其实已经出问题了
3.1 安全情况一:单条 SQL、只读查询、日志噪音
如果你的业务是一个只读接口,或者一次只写一条数据,那这行日志基本可以放心。比如:
java复制public User getUserById(Long id) {
return userMapper.selectById(id);
}
这段代码执行期间没有事务,SqlSessionTemplate 新开了一个 SqlSession,执行完 select,自动处理提交(虽然 select 无所谓提交),close。日志里出现那句提示,不影响结果。你只需要把它理解成“这次 Mapper 调用是自助餐,单独结算”,不必惊慌。
同样的,如果原来没有这条日志,你只是把 logging.level.org.mybatis=DEBUG 打开了,那它出现是预期行为。把它关掉或改回 INFO,眼不见心不烦。
3.2 危险情况一:循环写数据但期望整体回滚
这是最常见的坑。很多同学会写类似代码:
java复制@Service
public class OrderService {
private final OrderMapper orderMapper;
public void batchCreate(List<Order> orders) {
for (Order order : orders) {
orderMapper.insert(order);
}
}
}
如果列表里有 100 个订单,第 50 个插进去后发现第 51 个违反唯一约束,那么前 50 个已经提交了,因为每个 orderMapper.insert(order) 都是独立的新 SqlSession,执行完就自动提交。最后用户看到的是“批量创建失败”,但数据库里已经残了一部分数据。
这时候日志里大概率就有这句 SqlSession was not registered for synchronization。它不是说 MyBatis 坏了,而是说:你的 Spring 事务没有覆盖这个批量方法,导致没有统一的回滚边界。
3.3 危险情况二:一个业务里前面改A,后面改B,中间出了错
假设你的方法里先调 accountMapper.decrease() 再调 orderMapper.create()。没有事务时,前者扣款成功了,后者抛异常,最终结果是钱扣了但订单没生成。这种业务如果依赖事务保证原子性,而这行日志出现,说明事务边界根本没建立。
为什么开发阶段不容易发现?因为很多开发环境数据量小、没有并发,单条语句执行成功后就看不到问题。直到上线后某条数据异常触发回滚诉求,才发现已经无法回滚。
3.4 危险情况三:一级缓存失效,同一次请求内重复查库
MyBatis 默认开启一级缓存,作用域是 SqlSession。在同一个事务同一个 SqlSession 中,重复执行同一条查询可以不访问数据库,直接返回缓存结果。但如果没有事务,每次 Mapper 调用都新建 SqlSession 然后关闭,一级缓存等于被“切碎”了。
我在排查性能问题时亲眼见过一个 Service 里循环 100 次调用 selectById,由于没有 @Transactional,日志里 100 次都在创建、关闭 SqlSession,数据库被查了 100 次。后来在 Service 方法上加了事务,因为同一个事务内复用同一个 SqlSession,一级缓存兜住了重复查询,数据库压力直接降了一个数量级。
这也是“无事务 + 这句日志”背后最容易被忽视的隐性成本,不只是原子性问题,还有连接复用和缓存复用问题。
4. 实战修复:怎样让 SqlSession 进入 Spring 事务同步
4.1 修复前先确认事务管理器存在
你加再多 @Transactional,如果 Spring 容器里没有事务管理器,注解也不会生效。在 Spring Boot 项目里,只要引入了 spring-boot-starter-jdbc 或 spring-boot-starter-data-jpa,并且数据源存在,Spring Boot 会自动配置一个 DataSourceTransactionManager。MyBatis-Spring 的 SqlSessionFactoryBean 也会把 MyBatis 的 Environment 里的事务工厂指向 SpringManagedTransactionFactory,让 SqlSession 使用 Spring 管理的连接。
但是在老式 XML 配置或者手动构建 Spring 容器的项目里,你可能少配了:
xml复制<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
或者少了开启注解事务的开关:
xml复制<tx:annotation-driven/>
Spring Boot 环境里,如果你自定义了 DataSource,但没有把事务管理器交给 Spring 管理,也可能出现同样问题。所以第一步永远先确认事务管理器 bean 存在且被正确注入。
4.2 标准修法一:业务方法上加 @Transactional
最直接的办法是在需要事务的业务方法上声明事务边界:
java复制@Service
public class OrderService {
private final OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public void batchCreate(List<Order> orders) {
for (Order order : orders) {
orderMapper.insert(order);
}
}
}
为什么要写 rollbackFor = Exception.class?因为 Spring 默认只对 RuntimeException 和 Error 回滚。如果方法里抛的是 IOException 这种受检异常,你又没指定回滚规则,事务会正常提交,这是另一个容易踩的坑。批量任务我习惯显式写 rollbackFor = Exception.class,让所有异常都触发回滚,省得后续因为一张日志表超时要查半天。
加了注解后,方法执行时的事务链路会变成:事务拦截器启动事务 -> TransactionSynchronizationManager 激活同步 -> MyBatis 第一次调用 Mapper 时创建 SqlSession 并注册 synchronization -> 后续 Mapper 调用复用同一个 SqlSession -> 方法正常返回则提交,异常则回滚。此时你再观察 debug 日志,会看到:
text复制Creating a new SqlSession
Registering transaction synchronization for SqlSession
而不是那句 was not registered。
4.3 标准修法二:解决自调用问题
这里有很关键的一课。如果你把带事务的方法写在同一个类内部,并且通过 this 调它,事务是不会生效的。
java复制@Service
public class OrderService {
public void outerMethod() {
// some logical...
this.innerTransactionalMethod(); // 自调用
}
@Transactional
public void innerTransactionalMethod() {
orderMapper.insert(order);
}
}
Spring 的 @Transactional 是通过 AOP 代理实现的。它只对“从外部进入代理对象”的方法生效。this.innerTransactionalMethod() 调用的是目标对象本体,代理完全不知道这回事,于是事务根本没开。这种情况日志里照样会出现 SqlSession was not registered for synchronization because synchronization is not active,而且特别迷惑人,因为注解明明写了。
标准的解法是把内部方法拆到另一个 Spring bean 里,由外部 bean 注入并调用:
java复制@Service
public class OrderService {
private final OrderTxService orderTxService;
public void outerMethod() {
orderTxService.innerTransactionalMethod();
}
}
@Service
public class OrderTxService {
@Transactional
public void innerTransactionalMethod() {
orderMapper.insert(order);
}
}
如果不想拆类,还有一个更省事的办法:在当前 Service 里注入 TransactionTemplate,用编程式事务代替注解。
java复制@Service
public class OrderService {
private final TransactionTemplate transactionTemplate;
public void outerMethod() {
transactionTemplate.executeWithoutResult(status -> {
orderMapper.insert(order);
orderMapper.updateStock(order);
});
}
}
TransactionTemplate 是在方法内部直接通过事务管理器拉起事务,所以不依赖外部代理,也不存在自调用问题。关键是你要确保 TransactionTemplate 这个 bean 已经存在。在 Spring Boot 里我通常这样手动配置:
java复制@Configuration
public class TxConfig {
@Bean
public TransactionTemplate transactionTemplate(
PlatformTransactionManager transactionManager) {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setTimeout(30);
return template;
}
}
4.4 标准修法三:异步任务和并行流,事务不会跨线程
线程和事务的绑定关系是另一个容易误解的点。Spring 事务同步基于 ThreadLocal,子线程默认拿不到父线程的事务资源。你如果在主线程方法上加了 @Transactional,然后内部用 new Thread 或者线程池去执行 Mapper 操作,子线程里照样没有事务,这句日志还是会出来。
例如:
java复制@Transactional
public void batchAsync() {
orderService.executor.execute(() -> orderMapper.update(order));
}
子线程里的 orderMapper.update(order) 和主线程的 Spring 事务一点关系都没有。子线程会自己开一个独立的数据库连接,再独立提交。要解决,要么让每个子线程任务自己开启事务,要么把需要一起回滚的数据操作集中到同一个业务线程里完成,别走异步。
“异步化和事务天然有冲突”,这句话我建议写进团队规范。想用异步又想强一致,通常会引入事务消息、本地消息表等方案,那不是单纯 @Transactional 能覆盖的。
4.5 不推荐的方案:单纯关日志
如果只是批量方法崩溃后出现一句日志,而业务本身不需要原子性,最简单其实可以不改代码,只在配置里降低这个类的日志级别:
yaml复制logging:
level:
org.mybatis.spring.SqlSessionUtils: ERROR
这样它最多输出 ERROR 及以上级别的信息,DEBUG 提示就不会再刷屏。类似地,logback 可以单独配置:
xml复制<logger name="org.mybatis.spring.SqlSessionUtils" level="ERROR"/>
但我个人不推荐新手一上来就这样做。先把日志级别调低当然能遮住噪音,可如果哪天批量操作真的出现数据不一致,你排查时会少一个关键线索。更稳的做法是先把业务方法梳理清楚,能用事务就用事务,确实不需要事务的地方,再考虑降日志。
4.6 先观察再动手的排查清单
按下面的顺序走一遍,能节省大量排查时间:
- 确认这段日志所在的线程栈,是不是自己业务代码直接调用的 Mapper。
- 查看方法上有没有
@Transactional。 - 确认事务注解所在的类是否被 Spring 管理,不是
new出来的普通对象。 - 确认是否通过代理对象调用,不是同类
this自调用。 - 确认调用链路没有跨线程。
- 打开
org.springframework.transaction.interceptor.TransactionInterceptor的 DEBUG 日志,看事务有没有真正启动。 - 最后再查事务管理器 bean 是否存在于容器中。
5. 高频问题速查:日志场景对照表
我把实际排查中碰到过的场景做成一张表,你拿着自己的上下文对一下,比看十篇文章都直接。
| 场景 | 日志上下文 | 有无风险 | 处理方式 |
|---|---|---|---|
| Controller 直接调 Mapper 做单条查询 | 出现 was not registered,无异常 |
低 | 可忽略;如果想走事务就加 Service 层 |
| Service 方法无事务,循环 Mapper 写数据 | 每写一条都出现日志,最终可能部分成功 | 高 | 方法加 @Transactional(rollbackFor = Exception.class) |
Service 方法有 @Transactional,但方法内部 self-call 触发事务方法 |
日志出现,事务没生效 | 高 | 拆分到另一个 bean,或用 TransactionTemplate |
| 主线程有事务,子线程里执行 Mapper | 子线程日志出现 was not registered |
中高 | 子线程内重新开启事务或调整设计 |
@Async 方法直接操作 Mapper |
每次都新建 SqlSession,日志出现 | 中 | 在异步方法内部自己加事务 |
| 批量接口偶尔中途失败,前面几条已经落库 | 日志出现,且前端报错 | 高 | 确认方法是否漏了事务注解,排查代理链路 |
| 只在 DEBUG 日志下出现,线上 ERROR 日志没有 | 无害 | 低 | 不需要处理,或整体调日志级别 |
| 从网上复制别人的 MyBatis 配置后出现 | 排查是否用了两个数据源/两个事务管理器 | 中 | 配置主事务管理器,并指定 Mapper 使用哪个 SqlSessionFactory |
这张表里出现频率最高的问题其实是前三个。尤其是第三类“自调用”,很多人代码里明明有事务注解却一直不生效,最后发现是同类调同类,AOP 代理绕过去了。这类问题尤其隐蔽,因为如果只看最终数据结果,可能碰巧没遇到异常,直到某个特殊分支才炸出来。
6. 再深入一点:MyBatis 的 SqlSession 创建日志到底隐藏了哪些细节
6.1 DEBUG 模式下你能看到什么
当问题出现时,我建议你把 MyBatis 相关日志打开,观察完整的会话生命周期。通常你要配置的是:
yaml复制logging:
level:
org.mybatis: DEBUG
org.mybatis.spring.SqlSessionUtils: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
然后看控制台输出。非事务调用长这样:
text复制Creating a new SqlSession
Registering transaction synchronization for SqlSession
等等,开头我是不是说非事务不会注册?上面这段是事务开启后的日志。非事务时你看到的更可能是:
text复制Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@xxxx] was not registered for synchronization because synchronization is not active
Closing SqlSession
注意“Creating a new SqlSession”和“Closing SqlSession”是成对出现的。只要你是无事务调用,每执行一个 Mapper 方法,几乎就会创建一次新的 SqlSession,然后马上关闭。如果业务是循环调用多个 Mapper,你会看到大量这样的创建关闭日志。反过来,如果一段业务里多次调用 Mapper,但只出现一次“Creating a new SqlSession”,没有反复关闭,说明它们被同一个事务约束住了。
6.2 为什么“有事务”不等于“一定注册同步”
还有一个细节值得点明:同步激活不仅是“有事务”那么简单。SqlSessionUtils 判断的是 TransactionSynchronizationManager.isSynchronizationActive(),这个状态通常在 Spring 事务生命周期内才为 true。如果你自己直接用原生 JDBC 开了一个数据库连接,自己 setAutoCommit(false),再手动把连接塞给某个工具类执行 SQL,这种“手动事务”并不会激活 Spring 的事务同步。只要你没有通过 Spring 的 PlatformTransactionManager 去开启事务,MyBatis 就感知不到同步机制,依然会打那句日志。
这个现象在单元测试里经常出现。有人手动写了 @BeforeEach 打开连接,执行两条 SQL,然后手动 rollback,全程没有 Spring 事务参与,那它自然不会被注册。要让 MyBatis 自动跟随事务,正确姿势是让代码走 Spring 的 @Transactional 或 TransactionTemplate,而不是自己手搓连接。
6.3 使用 PROPAGATION 时是否还会出现
@Transactional 的传播行为不同,也会影响这条日志。默认 PROPAGATION_REQUIRED,如果当前已有事务,直接加入;如果没有,新建事务。加入现有事务时,MyBatis 会在同一个 SqlSession 里继续执行,同步状态依然激活。
如果是 PROPAGATION_REQUIRES_NEW,Spring 会挂起外层事务,创建新事务,新事务里同样会激活同步。所以正常情况下不会出现这条日志。真正需要警惕的是 PROPAGATION_NOT_SUPPORTED,它明确表示当前方法不参与事务。如果你在某个 Service 方法上用了 NOT_SUPPORTED,那 Mapper 的执行环境就是无同步状态,日志自然会出现。这种情况不算程序 bug,往往是为了避免长事务主动设计的,比如某些耗时导出操作不想占着事务连接,那就更应该主动把这个类或方法的日志调到合理级别,避免误导同事。
6.4 默认执行器类型的影响
如果你手动配置了时设置了 defaultExecutorType 为 BATCH,Session 的提交策略会更严格。在 BATCH 模式下,非事务的单个 Mapper 调用同样会出现那句日志,并且执行完由 SqlSessionTemplate 的拦截器做 commit(true)。你可能希望一批 SQL 攒着一起执行,但它实际上不会自动批次提交多个方法。
所以如果你使用 BATCH Executor 做高吞吐批量写入,建议把循环写入的 Service 方法包进事务,让同一个 SqlSession 复用同一个缓冲区,否则每次方法调用都会触发 flush,批量的效果大打折扣。这也是我当年踩过的一个深坑,日志里全是同步未注册,我还纳闷为什么批量写的性能没有提升。
7. 我自己是怎么看待这行日志的
排查过那么多次 MyBatis 事务问题后,我的经验是:不要一看到英文日志就慌,也别机械地搜索同一句话。把这行日志当成“当前线程有没有 Spring 事务同步”的指示灯。灯亮说明事务边界建立得很好,灯不亮说明每个 Mapper 操作在“吃独食”。对大多数读操作和单条写操作,吃独食没问题;对需要原子性的业务流程,必须想办法让它亮起来。
如果你确定业务上根本没有事务诉求,那么这行日志长期存在也无妨,顶多占用一点日志空间。你需要做的只是把 SqlSessionUtils 这个 logger 的级别调高,然后该干嘛干嘛。如果你发现批量更新、扣款+下单、失败回滚这类逻辑里频繁出现,请老老实实从事务管理器、代理入口、调用线程三个方向查起。日志本身不会说谎,它只是在提醒你:有一个 SqlSession 没被 Spring 的宴席接待,它正在自己买单,你自己看着办。
