1. 事故现场还原:支付成功但订单消失的诡异现象
上周五下午3点,我们的支付系统突然爆发大规模异常。用户反馈支付成功后订单列表却显示空白,更诡异的是部分用户尝试修改订单时系统提示"获取锁超时"。作为值班开发,我第一时间查看了数据库监控,发现有几个事务已经持续了2个多小时没有提交,锁住了订单表的关键行。
通过分析线程堆栈,我们定位到这些挂起的事务都来自同一个业务服务。但奇怪的是,该服务的日志显示所有操作都正常完成,甚至打印了"事务提交成功"的记录。最让人困惑的是,这个问题并非100%复现——大约70%的请求会失败,但剩下的30%却能正常完成订单创建。
2. 应急处理与问题定位
2.1 紧急恢复措施
面对线上事故,我们立即执行了标准应急流程:
- 首先通过Kill命令终止了长时间运行的事务
- 对受影响用户进行补偿处理
- 紧急重启了相关服务实例
重启后系统暂时恢复正常,但这只是治标不治本。我们需要找到根本原因,防止问题再次发生。
2.2 代码审查发现问题
对比最近一周的代码变更,我们发现新上线的优惠券核销服务中存在可疑代码片段:
java复制@Service
public class CouponService {
@Autowired
private SqlSession sqlSession;
public void redeemCoupon(Long userId, Long couponId) {
// 手动开启事务
sqlSession.getConnection().setAutoCommit(false);
// 核销优惠券
couponMapper.updateStatus(couponId, "USED");
// 特殊条件判断
if (isSpecialUser(userId)) {
// 这里直接return导致事务泄漏!
return;
}
// 正常提交
sqlSession.commit();
}
}
这段代码的问题在于:当遇到特殊用户时直接return,既没有提交事务也没有回滚,导致连接带着未提交的事务回到了连接池。
3. 深度技术解析:MyBatis连接池的工作原理
3.1 连接池的核心机制
MyBatis默认使用连接池管理数据库连接,其工作流程如下:
- 应用启动时创建一定数量的连接放入池中
- 当需要执行SQL时从池中获取连接
- 使用完毕后将连接归还池中而非真正关闭
- 后续请求可以复用这些连接
这种机制虽然提高了性能,但也带来了状态污染的风险——如果一个连接带着未提交的事务被归还,下一个使用者将继承这个"脏"状态。
3.2 Spring事务管理的实现细节
Spring通过TransactionSynchronizationManager实现事务管理,关键点在于:
- 使用ThreadLocal存储ConnectionHolder
- 事务传播行为决定了如何处理现有连接
- 只有新创建的事务才会真正执行commit
当我们的代码泄漏事务时,会导致:
- 连接带着active事务标记被归还
- 后续请求获取到被污染的连接
- 由于不是新事务,实际commit被跳过
4. 问题复现与原理验证
4.1 搭建测试环境复现问题
为了验证我们的分析,我搭建了最小复现环境:
- 配置HikariCP连接池,最大连接数设为5
- 编写模拟泄漏事务的Controller
- 使用JMeter模拟并发请求
测试结果完美复现了线上现象:约70%的请求失败,且失败请求都复用了同一个连接。
4.2 关键源码分析
通过调试Spring事务源码,我们发现关键逻辑在AbstractPlatformTransactionManager:
java复制protected void processCommit(DefaultTransactionStatus status) {
if (status.isNewTransaction()) {
// 只有新事务才会真正提交
doCommit(status);
}
// 其他情况只是释放资源
}
当获取到被污染的连接时,isNewTransaction()返回false,导致实际提交被跳过。
5. 解决方案与最佳实践
5.1 立即修复方案
对于已发现的问题代码,我们进行了标准化改造:
java复制public void redeemCoupon(Long userId, Long couponId) {
TransactionStatus status = null;
try {
// 使用声明式事务
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
couponMapper.updateStatus(couponId, "USED");
if (isSpecialUser(userId)) {
// 确保事务提交
transactionManager.commit(status);
return;
}
transactionManager.commit(status);
} catch (Exception e) {
if (status != null && !status.isCompleted()) {
transactionManager.rollback(status);
}
throw e;
}
}
5.2 长期预防措施
为了防止类似问题再次发生,我们实施了以下改进:
5.2.1 连接池健康配置
yaml复制spring:
datasource:
hikari:
connection-timeout: 3000
validation-timeout: 1000
max-lifetime: 1800000
connection-init-sql: SET autocommit=1
connection-test-query: SELECT 1
关键配置说明:
connection-init-sql确保每次获取连接时重置状态- 合理的超时设置防止连接泄漏
- 定期验证连接有效性
5.2.2 监控体系建设
我们增加了以下监控项:
- 长事务监控(超过30秒告警)
- 连接池使用率监控
- 锁等待超时监控
- 事务成功率监控
对应的Prometheus告警规则示例:
yaml复制- alert: LongRunningTransaction
expr: avg_over_time(transaction_duration_seconds[1m]) > 30
for: 2m
labels:
severity: critical
annotations:
summary: "Long running transaction detected"
6. 经验总结与避坑指南
6.1 事务管理黄金法则
- 明确边界:每个事务方法必须有清晰的开始和结束
- 异常处理:catch块必须包含rollback,finally块确保资源释放
- 避免手动控制:尽量使用@Transactional而非编程式事务
- 传播行为:理解PROPAGATION_REQUIRED与PROPAGATION_REQUIRES_NEW的区别
6.2 连接池使用注意事项
- 状态清理:配置connection-init-sql重置连接状态
- 合理配置:根据QPS设置合适的maxPoolSize和minIdle
- 监控指标:重点关注active和idle连接数变化
- 泄漏检测:启用leakDetectionThreshold定位未关闭连接
6.3 调试复杂事务问题的技巧
- 日志增强:开启Spring事务DEBUG日志
properties复制logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc=DEBUG
- 数据库层面检查:
sql复制-- 查看活跃事务
SELECT * FROM information_schema.innodb_trx;
-- 查看锁等待
SELECT * FROM performance_schema.events_waits_current;
- 线程转储分析:当出现死锁时,通过jstack分析线程阻塞点
7. 扩展思考:分布式事务的挑战
虽然本文讨论的是单数据库事务,但在微服务架构下,分布式事务更为复杂。对于需要跨服务事务的场景,建议考虑:
- Saga模式:将大事务拆分为多个本地事务,通过补偿机制保证最终一致
- TCC模式:Try-Confirm-Cancel三阶段提交
- 本地消息表:通过消息队列实现最终一致
每种方案都有其适用场景和trade-off,需要根据业务特点谨慎选择。