上周线上炸了。
支付业务出了问题,用户支付成功,但订单表没数据。更诡异的是,修改订单时有时也会提示获取锁超时。
DBA看了一眼数据库连接,发现几个事务一直没提交,锁着订单表的几行数据。
排查半天,最后发现是某个业务接口忘了提交事务。按理说,事务没提交应该很容易发现。但这个bug就比较隐蔽。
几个反常识的现象
- 业务代码正常执行完毕:没有任何报错,日志也正常打印。
- 日志显示事务已提交:commit方法被调用了,但数据库里没数据。
- 偶尔会成功:大部分时候失败,但偶尔能正常插入订单。
这种情况让人摸不着头脑。代码没报错,日志也正常,为啥数据就是不入库?
应急处理
线上出问题,那肯定先恢复业务再说。最快的办法就是重启应用,强制释放这些连接。
重启完,支付功能恢复正常。但问题还是要找到,重启只能暂时缓解,得找到到底哪个业务出了问题。
对比了最近的上线记录,发现有个新业务刚上线。检查代码,果然发现了问题:
@Service
public class SomeService{
public void handleSpecialCase(){
// 开启事务
sqlSession.connection.setAutoCommit(false);
// 执行SQL
mapper.insert(data);
// 特殊情况下,忘记commit了!
if (specialCondition) {
// 某些情况下会return,但没commit
return;
}
sqlSession.commit();
}
}
特殊分支直接return了,commit没执行。
快速修复
立即补上commit,重新上线:
@Service
public class SomeService{
public void handleSpecialCase(){
try {
sqlSession.connection.setAutoCommit(false);
mapper.insert(data);
if (specialCondition) {
sqlSession.commit(); // 补上!
return;
}
sqlSession.commit();
} catch (Exception e) {
sqlSession.rollback();
throw e;
}
}
}
上线后验证,问题解决。
事后复盘
问题虽然解决了,但还是很奇怪:为啥一个业务的事务没提交,会影响到其他完全不相关的支付业务?
周末花了半天debug源码,终于搞清楚了。
断点打在 getTransaction
首先在Spring的 getTransaction 方法打断点,发现出问题的请求都会走到这里:
public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException {
// 使用默认的事务定义
TransactionDefinition def = (definition != null ? definition : TransactionDefinition.withDefaults());
// 关键:获取当前事务对象
Object transaction = doGetTransaction();
boolean debugEnabled = logger.isDebugEnabled();
// 判断是否是已存在的事务
if (isExistingTransaction(transaction)) {
// 发现已存在的事务,直接返回
return handleExistingTransaction(def, transaction, debugEnabled);
}
// 创建新事务的逻辑...
}
问题就出在 doGetTransaction 这个方法。
doGetTransaction会复用连接
点开 doGetTransaction,会发现:
protected Object doGetTransaction(){
DataSourceTransactionObject txObject = new DataSourceTransactionObject();
txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
// 关键:从TransactionSynchronizationManager获取连接
ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(this.obtainDataSource());
txObject.setConnectionHolder(conHolder, false);
return txObject;
}
TransactionSynchronizationManager.getResource 会从连接池拿连接。如果拿到一个被污染的连接(上一个事务没提交),ConnectionHolder 里就带着上一个事务的状态。
isExistingTransaction的判断
然后 isExistingTransaction 会检查这个连接:
protected boolean isExistingTransaction(Object transaction){
DataSourceTransactionObject txObject = (DataSourceTransactionObject)transaction;
return txObject.hasConnectionHolder() && txObject.getConnectionHolder().isTransactionActive();
}
如果连接上有事务标记( isTransactionActive() ),就认为是已存在的事务,不会创建新事务。
问题来了:如果上一个业务用完连接后,事务没提交也没回滚,ConnectionHolder 的事务标记还在。下一个业务通过 doGetTransaction从TransactionSynchronizationManager 拿到这个 ConnectionHolder,就被当成已存在的事务了。
被污染的连接是怎么来的
回顾前面应急处理时找到的那段demo代码:
@Service
public class SomeService{
public void handleSpecialCase(){
// 开启事务
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 执行SQL
mapper.insert(data);
// 特殊分支:直接return,没commit!
if (specialCondition) {
return;
}
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
}
当走到特殊分支return时:
ConnectionHolder 已经被标记为有事务( isTransactionActive() = true )
- 但事务既没
commit,也没 rollback
- 方法结束后,连接归还到
TransactionSynchronizationManager
ConnectionHolder 的事务标记还在
这个连接就被污染了。
复用导致的问题
其他业务刚好复用到了这个被污染的连接:
@Service
public class PaymentService{
public void createOrder(Order order){
// 手动开启事务
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 插入订单
orderMapper.insert(order);
// 提交事务
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
}
看起来没问题,对吧?但当这个方法获取到被污染的连接时:
getTransaction 判断 isExistingTransaction为true
- 走进
handleExistingTransaction 方法
- 根据事务传播行为,可能会加入现有事务,而不是创建新事务
- 最后commit时,因为不是事务发起者,不会真正提交
processCommit的判断
继续debug到commit方法,发现会执行 processCommit 去完成提交:
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
try {
prepareForCommit(status);
triggerBeforeCommit(status);
triggerBeforeCompletion(status);
beforeCompletionInvoked = true;
if (status.hasSavepoint()) {
status.releaseHeldSavepoint();
}
// 关键判断:只有新事务才真正提交
else if (status.isNewTransaction()) {
if (status.isDebug()) {
logger.debug(“Initiating transaction commit”);
}
// 真正执行数据库commit
doCommit(status);
}
// 如果不是新事务,什么都不做!
} catch (UnexpectedRollbackException ex) {
// ...
}
} finally {
cleanupAfterCompletion(status);
}
}
因为 status.isNewTransaction() 返回false(这是个加入的事务,不是新事务),所以 doCommit(status) 根本不会执行。真正的 connection.commit() 根本没执行。
完整的问题链路

SomeService.handleSpecialCase() 特殊分支没提交事务
- 方法结束,
ConnectionHolder 归还到 TransactionSynchronizationManager,但事务标记 isTransactionActive() 还是true
PaymentService.createOrder() 调用 doGetTransaction()
doGetTransaction从TransactionSynchronizationManager 拿到被污染的 ConnectionHolder
isExistingTransaction 判断为true,认为已有事务
- 走
handleExistingTransaction 流程,加入现有事务( isNewTransaction = false )
- 业务代码执行完,调用commit
processCommit 中判断 isNewTransaction() 为false,跳过 doCommit
- 数据没入库,
ConnectionHolder 继续待在 TransactionSynchronizationManager,继续污染下一个业务
为什么偶尔会成功?
因为 TransactionSynchronizationManager 是ThreadLocal实现的,每个线程有独立的资源。如果支付请求分配到一个干净的线程(没有被污染的 ConnectionHolder),就能正常工作。但只要分配到被污染的线程,就会出问题。
这就是为什么这个bug这么难发现:
- 不是100%复现(取决于线程池调度)
- 没有报错信息
- 日志看起来正常
预防措施
经过这次事故,我们加了几个预防措施。
1. 连接池健康检查
配置连接池的连接校验:
spring:
datasource:
hikari:
connection-test-query: SELECT 1
validation-timeout: 3000
# 从池子取连接前先测试
connection-init-sql: SET autocommit=1
connection-init-sql 会在每次从池子取连接时重置状态,避免被污染的连接影响业务。
2. 监控告警
增加数据库长事务监控:
-- 查找执行超过30秒的事务
SELECT *
FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 30;
配置告警规则,超过30秒的事务立即报警。
踩坑总结
这次事故让我明白了几点。
1. 连接池不只是性能优化
之前我一直觉得连接池就是提升性能的。这次才懂,连接池还会带来状态复用的坑。一个连接的问题,会影响后面所有复用这个连接的业务。理解这些底层机制,是做好后端开发的重要一课,你可以在 后端 & 架构 板块找到更多深入探讨。
2. 事务管理要写清楚
手动管理事务,一定要写清楚commit和rollback:
- commit写在try块末尾
- rollback写在catch块
- finally块关闭资源
别偷懒省这几行代码。一个遗漏的commit,就可能导致线上事故。对于 Java 开发者来说,养成良好的事务管理习惯至关重要。
3. 监控要覆盖到数据库层
应用层日志正常,不代表数据库层没问题。必须要有数据库层面的监控:
4. 源码不能只看,要调试
看源码很多时候看不出问题。这次是真的打断点一步步走,才发现 getTransaction 方法里有个 isExistingTransaction 的判断。遇到诡异问题,直接上断点调试。看调用栈,看变量值,比看文档快多了。
写在最后
这个bug修复后,我跟同事也分享了一下。记录了问题现象、排查过程、根本原因、解决方案和预防措施。不是为了甩锅,是为了让团队其他人避免踩同样的坑。技术债不可怕,可怕的是踩坑了还不总结。生产环境的每一次事故,都是学习的机会。
如果你在项目中遇到过类似的“灵异事件”,或者对数据库连接管理有更多心得,欢迎到 云栈社区 与其他开发者一起交流讨论。