1. 问题背景与现象描述
那天早上刚到公司,就收到测试同事发来的紧急邮件:"生产环境订单支付模块出现异常,部分用户支付成功后状态未更新"。作为负责支付系统的开发人员,我立即登录服务器查看日志,发现确实存在大量支付成功但订单状态仍显示"待支付"的情况。
这个问题最早出现在2026年3月10日凌晨2:15左右,错误率约为8.3%。从监控系统可以看到,支付回调接口的响应时间从平时的50ms飙升到了800ms以上。更棘手的是,这个问题是间歇性出现的,并非所有支付请求都会失败,给排查带来了很大难度。
2. 初步排查与日志分析
2.1 日志收集与筛选
首先,我使用ELK日志系统收集了问题时间段的所有相关日志。通过以下查询条件筛选出异常请求:
code复制timestamp:[2026-03-10T02:00:00 TO 2026-03-10T03:00:00]
AND app_name:"payment-service"
AND level:ERROR
发现了大量类似错误:
code复制[ERROR] 2026-03-10 02:15:23.456 [http-nio-8080-exec-7] c.x.p.PaymentCallbackController - 更新订单状态失败,订单号:ORD20260310021523456,支付单号:PAY20260310021523456,错误:数据库连接超时
2.2 数据库连接池分析
查看应用监控发现,数据库连接池的使用情况异常:
- 最大连接数:100
- 活跃连接数:98(平时峰值不超过40)
- 等待获取连接的线程数:32
这明显是数据库连接泄漏的典型症状。进一步检查连接池配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 100
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
配置看起来没有问题,但实际使用中出现了连接未正确释放的情况。
3. 深入问题定位
3.1 代码审查发现问题
重点检查了支付回调处理逻辑,发现以下问题代码片段:
java复制@Transactional
public void handlePaymentCallback(PaymentNotify notify) {
// 查询支付记录
PaymentRecord record = paymentDao.queryByTradeNo(notify.getTradeNo());
// 更新支付记录状态
record.setStatus(notify.getStatus());
paymentDao.update(record);
// 更新订单状态
Order order = orderDao.queryByOrderNo(record.getOrderNo());
order.setStatus(OrderStatus.PAID);
orderDao.update(order);
// 发送订单支付成功事件
eventPublisher.publishEvent(new OrderPaidEvent(order.getOrderNo()));
// 这里缺少事务提交后的连接释放
}
问题在于:
- 方法使用了@Transactional注解,但内部调用了多个DAO操作
- 其中一个DAO操作可能抛出异常,但没有被捕获处理
- 事件发布如果在事务提交后失败,会导致事务回滚但连接未释放
3.2 复现与验证
为了验证这个猜想,我编写了测试用例模拟高并发场景:
java复制@Test
public void testPaymentCallbackConcurrency() throws InterruptedException {
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.execute(() -> {
try {
// 模拟事件发布失败
when(eventPublisher.publishEvent(any())).thenThrow(new RuntimeException("Event publish failed"));
paymentService.handlePaymentCallback(testNotify);
} catch (Exception e) {
// 忽略异常
} finally {
latch.countDown();
}
});
}
latch.await();
// 检查连接池状态
assertThat(hikariPool.getActiveConnections()).isLessThan(10);
}
测试结果证实了我们的猜想:当事件发布失败时,虽然事务会回滚,但数据库连接没有被正确释放,最终导致连接池耗尽。
4. 解决方案设计与实施
4.1 短期修复方案
为了快速恢复生产环境,我们采取了以下紧急措施:
- 重启应用服务,释放所有数据库连接
- 增加数据库连接池监控告警,当活跃连接数超过80%时触发
- 临时增加连接池大小到150(不推荐长期使用)
4.2 长期解决方案
针对代码层面的问题,我们实施了以下修复:
4.2.1 事务拆分
将支付状态更新和订单状态更新拆分为两个独立的事务:
java复制public void handlePaymentCallback(PaymentNotify notify) {
// 更新支付记录状态(独立事务)
updatePaymentStatus(notify);
// 更新订单状态(独立事务)
updateOrderStatus(notify.getTradeNo());
// 发送事件(非事务)
try {
eventPublisher.publishEvent(new OrderPaidEvent(notify.getOrderNo()));
} catch (Exception e) {
log.error("事件发布失败,但不影响主流程", e);
}
}
@Transactional
public void updatePaymentStatus(PaymentNotify notify) {
// 支付记录更新逻辑
}
@Transactional
public void updateOrderStatus(String tradeNo) {
// 订单状态更新逻辑
}
4.2.2 连接泄漏防护
添加连接泄漏检测配置:
yaml复制spring:
datasource:
hikari:
leak-detection-threshold: 5000 # 5秒
4.2.3 添加重试机制
对于事件发布等非核心操作,添加异步重试:
java复制@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void publishOrderPaidEvent(String orderNo) {
eventPublisher.publishEvent(new OrderPaidEvent(orderNo));
}
5. 经验总结与最佳实践
5.1 事务使用注意事项
- 避免在事务方法中执行非数据库操作(如远程调用、消息发布等)
- 事务范围应尽可能小,只包含必要的数据库操作
- 对于可能失败的非事务操作,应考虑异步处理或补偿机制
5.2 连接池监控指标
建议监控以下关键指标:
| 指标名称 | 健康阈值 | 检查频率 |
|---|---|---|
| 活跃连接数 | < 80%最大连接数 | 每分钟 |
| 等待线程数 | < 10 | 每分钟 |
| 连接获取平均时间 | < 100ms | 每分钟 |
| 连接最大使用时间 | < 最大生命周期80% | 每小时 |
5.3 代码审查清单
对于涉及数据库操作的代码,审查时应检查:
- 是否所有资源都有正确的释放逻辑(try-with-resources或finally块)
- 事务范围是否合理,是否包含非数据库操作
- 是否有适当的异常处理和重试机制
- 是否考虑了并发场景下的资源竞争问题
这次事故让我深刻认识到,数据库连接这种宝贵资源的管理不容忽视。特别是在微服务架构下,一个服务的连接泄漏可能会影响整个系统的稳定性。后续我们计划在CI/CD流程中加入静态代码分析,自动检测潜在的资源泄漏风险。