支付成功订单却没了?MyBatis连接池的坑我踩了

事故现场

上周线上炸了。

支付业务出了问题,用户支付成功,但订单表没数据。更诡异的是,修改订单时有时也会提示获取锁超时。

DBA看了一眼数据库连接,发现几个事务一直没提交,锁着订单表的几行数据。

排查半天,最后发现是某个业务接口忘了提交事务。

按理说,事务没提交应该很容易发现。但这个bug就比较隐蔽。

几个反常识的现象:
  • 业务代码正常执行完毕 没有任何报错,日志也正常打印。
  • 日志显示事务已提交 commit**方法被调用了,但数据库里没数据。
  • 偶尔会成功 大部分时候失败,但偶尔能正常插入订单。

这种情况让人摸不着头脑。代码没报错,日志也正常,为啥数据就是不入库?

应急处理

线上出问题,那肯定先恢复业务再说。

最快的办法就是重启应用,强制释放这些连接。

重启完,支付功能恢复正常。但问题还是要找到

重启只能暂时缓解,得找到到底哪个业务出了问题。

对比了最近的上线记录,发现有个新业务刚上线。检查代码,果然发现了问题:

scss 复制代码
@Service
publicclassSomeService{

    publicvoidhandleSpecialCase(){
        // 开启事务
        sqlSession.connection.setAutoCommit(false);

        // 执行SQL
        mapper.insert(data);

        // 特殊情况下,忘记commit了!
        if (specialCondition) {
            // 某些情况下会return,但没commit
            return;
        }

        sqlSession.commit();
    }
}

特殊分支直接return了,commit没执行。

快速修复

立即补上commit,重新上线:

scss 复制代码
@Service
publicclassSomeService{

    publicvoidhandleSpecialCase(){
        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方法打断点,发现出问题的请求都会走到这里:

less 复制代码
publicfinal 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,会发现:

java 复制代码
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会检查这个连接:

scss 复制代码
protectedbooleanisExistingTransaction(Object transaction){
    DataSourceTransactionObject txObject = (DataSourceTransactionObject)transaction;
    return txObject.hasConnectionHolder() && txObject.getConnectionHolder().isTransactionActive();
}

如果连接上有事务标记(isTransactionActive()),就认为是已存在的事务,不会创建新事务。

问题来了:如果上一个业务用完连接后,事务没提交也没回滚,ConnectionHolder的事务标记还在。

下一个业务通过doGetTransaction从TransactionSynchronizationManager拿到这个ConnectionHolder,就被当成已存在的事务了。

被污染的连接是怎么来的

回顾前面应急处理时找到的那段demo代码:

scss 复制代码
@Service
publicclassSomeService{

    publicvoidhandleSpecialCase(){
        // 开启事务
        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的事务标记还在

这个连接就被污染了。

复用导致的问题

其他业务刚好复用到了这个被污染的连接:

scss 复制代码
@Service
publicclassPaymentService{

    publicvoidcreateOrder(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去完成提交:

scss 复制代码
privatevoidprocessCommit(DefaultTransactionStatus status)throws TransactionException {
    try {
        boolean beforeCompletionInvoked = false;

        try {
            prepareForCommit(status);
            triggerBeforeCommit(status);
            triggerBeforeCompletion(status);
            beforeCompletionInvoked = true;

            if (status.hasSavepoint()) {
                status.releaseHeldSavepoint();
            }
            // 关键判断:只有新事务才真正提交
            elseif (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()根本没执行。

完整的问题链路
  1. SomeService.handleSpecialCase()特殊分支没提交事务
  2. 方法结束,ConnectionHolder归还到TransactionSynchronizationManager,但事务标记isTransactionActive()还是true
  3. PaymentService.createOrder()调用doGetTransaction()
  4. doGetTransaction从TransactionSynchronizationManager拿到被污染的ConnectionHolder
  5. isExistingTransaction判断为true,认为已有事务
  6. handleExistingTransaction流程,加入现有事务(isNewTransaction = false
  7. 业务代码执行完,调用commit
  8. processCommit中判断isNewTransaction()为false,跳过doCommit
  9. 数据没入库,ConnectionHolder继续待在TransactionSynchronizationManager,继续污染下一个业务

为什么偶尔会成功?

因为TransactionSynchronizationManager是ThreadLocal实现的,每个线程有独立的资源。

如果支付请求分配到一个干净的线程(没有被污染的ConnectionHolder),就能正常工作。但只要分配到被污染的线程,就会出问题。

这就是为什么这个bug这么难发现:

  • 不是100%复现(取决于线程池调度)
  • 没有报错信息
  • 日志看起来正常

预防措施

经过这次事故,我们加了几个预防措施。

1. 连接池健康检查

配置连接池的连接校验:

yaml 复制代码
spring:
  datasource:
    hikari:
      connection-test-query:SELECT1
      validation-timeout:3000
      # 从池子取连接前先测试
      connection-init-sql:SETautocommit=1

connection-init-sql会在每次从池子取连接时重置状态,避免被污染的连接影响业务。

2. 监控告警

增加数据库长事务监控:

sql 复制代码
-- 查找执行超过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,就可能导致线上事故。

3. 监控要覆盖到数据库层

应用层日志正常,不代表数据库层没问题。

必须要有数据库层面的监控:

  • 慢查询
  • 长事务
  • 锁等待
  • 连接数
4. 源码不能只看,要调试

看源码很多时候看不出问题。这次是真的打断点一步步走,才发现getTransaction方法里有个isExistingTransaction的判断。

遇到诡异问题,直接上断点调试。看调用栈,看变量值,比看文档快多了。

写在最后

这个bug修复后,我跟同事也分享了一下。记录了问题现象、排查过程、根本原因、解决方案和预防措施。

不是为了甩锅,是为了让团队其他人避免踩同样的坑。

技术债不可怕,可怕的是踩坑了还不总结。

生产环境的每一次事故,都是学习的机会。

相关推荐
ikoala13 小时前
Codex 怎么买、怎么充值?先把这两套计费搞清楚
前端·javascript·后端
前端Hardy14 小时前
一个时代结束了:npm 终于对 install 脚本下手了
前端·javascript·后端
damaoyou14 小时前
Cog3DRangeImagePlaneEstimatorTool完全指南
后端
Nturmoils14 小时前
分页别写太顺手,LIMIT 背后还有排序和边界
数据库·后端
神奇小汤圆14 小时前
国产版“Codex”初体验,智谱ZCode很强啊!
后端
站大爷IP14 小时前
Python里的“赋值”到底是什么意思?
后端
鹅城剑仙15 小时前
Spring Boot 微服务架构设计与最佳实践
spring boot·后端·微服务
Full Stack Developme16 小时前
Spring Integration 教程
java·后端·spring
爱勇宝16 小时前
AI 时代,前端工程师的话语权正在下降?
前端·后端
kymjs张涛16 小时前
一个月,纯VibeCoding,全平台云笔记APP
前端·javascript·后端