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

事故现场

上周线上炸了。

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

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

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

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

4个反常识的现象:

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

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

应急处理

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

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

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

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

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

kotlin 复制代码
@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,重新上线:

kotlin 复制代码
@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方法打断点,发现出问题的请求都会走到这里:

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

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 复制代码
protected boolean isExistingTransaction(Object transaction) {
    DataSourceTransactionObject txObject = (DataSourceTransactionObject)transaction;
    return txObject.hasConnectionHolder() && txObject.getConnectionHolder().isTransactionActive();
}

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

问题来了:如果上一个业务用完连接后,事务没提交也没回滚,ConnectionHolder的事务标记还在。下一个业务通过doGetTransactionTransactionSynchronizationManager拿到这个ConnectionHolder,就被当成已存在的事务了。

被污染的连接是怎么来的

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

java 复制代码
@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的事务标记还在

这个连接就被污染了。

复用导致的问题

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

java 复制代码
@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;
        }
    }
}

看起来没问题,对吧?但当这个方法获取到被污染的连接时:

  1. getTransaction判断isExistingTransaction为true
  2. 走进handleExistingTransaction方法
  3. 根据事务传播行为,可能会加入现有事务,而不是创建新事务
  4. 最后commit时,因为不是事务发起者,不会真正提交

processCommit的判断

继续debug到commit方法,发现会执行processCommit去完成提交:

scss 复制代码
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()根本没执行。

完整的问题链路

  1. SomeService.handleSpecialCase()特殊分支没提交事务
  2. 方法结束,ConnectionHolder归还到TransactionSynchronizationManager,但事务标记isTransactionActive()还是true
  3. PaymentService.createOrder()调用doGetTransaction()
  4. doGetTransactionTransactionSynchronizationManager拿到被污染的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: SELECT 1
      validation-timeout: 3000
      # 从池子取连接前先测试
      connection-init-sql: SET autocommit=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修复后,我跟同事也分享了一下。记录了问题现象、排查过程、根本原因、解决方案和预防措施。

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

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

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

相关推荐
看见繁华1 小时前
C++ 设计模式&设计原则
java·c++·设计模式
爱笑的眼睛112 小时前
超越AdamW:优化器算法的深度实现、演进与自定义框架设计
java·人工智能·python·ai
qq_336313932 小时前
java基础-stream流练习
java·开发语言·python
用户497357337982 小时前
【轻松掌握通信协议】C#的通信过程与协议实操 | 2024全新
后端
草莓熊Lotso2 小时前
C++11 核心精髓:类新功能、lambda与包装器实战
开发语言·c++·人工智能·经验分享·后端·nginx·asp.net
断剑zou天涯2 小时前
【算法笔记】树状数组IndexTree
java·笔记·算法
Paddy哥2 小时前
java 经典循环依赖解决
java
2 小时前
TIDB——PD(placement Driver)
java·数据库·分布式·tidb·
TG:@yunlaoda360 云老大2 小时前
配置华为云国际站代理商OBS跨区域复制时,如何编辑委托信任策略?
java·前端·华为云