拓冰建站拓冰建站
首页 / 资讯中心 / 正文

支付回调丢单?MyBatis连接池配置引发的生产事故复盘

大半夜接到运营电话说支付成功的订单在系统里查不到我当时第一反应是支付平台回调没成功。结果一查回调日志显示我们给支付平台返回了success支付单状态也更新了可订单表里就是没有数据。钱扣了订单没了用户直接投诉到客服。这事儿最后定位下来根子居然在MyBatis连接池的配置上而且中间还踩了一连串让我到现在都心有余悸的坑。这篇文章就完整复盘一下这次事故从现场排查到根因定位再到最后的修复和沉淀希望能帮后来人少走一段弯路。1. 事故现场钱付了订单却没进库1.1 从客服工单到数据库排查那天晚上是月初属于我们业务的常规高峰。开始只是零星几个客服工单说付款成功但没有订单我以为是用户网速慢没刷出来让客服先安抚。结果不到半小时工单量直接翻了十倍运营那边也炸了说后台查不到任何新订单数据。我心里咯噔一下这绝对不是一个用户的问题了。我赶紧连上生产数据库先查订单表。SELECT * FROM t_order WHERE create_time 2024-... LIMIT 10结果返回空。再查支付流水表支付平台的回调记录一条不少很多都已经回调成功了。我又用日志系统搜了一下订单号发现我们的后端接口在处理支付回调的时候日志打了order created success还打印了insert语句的参数。SQL执行了日志也打了成功为什么数据库里没有记录这时候唯一的解释就是事务回滚了。MySQL的InnoDB引擎下insert语句没提交之前一切都可能被回滚掉。而日志里显示的成功只是SQL执行成功并不代表事务提交成功。我立刻去翻异常日志果然在订单创建的日志前后看到了一行刺眼的报错[ERROR] 2024-...- 15ms org.springframework.transaction.interceptor.TransactionInterceptor - Application exception overridden by rollback requirement org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only看到这个异常我算是明白了事务确实回滚了。但为什么事务会被标记为rollback-only再往前翻发现真正的原因是连接池里拿不到连接了[ERROR] ... com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Connection is not available, request timed out after 5000ms1.2 明明写了成功库表却没有一行数据这个链路现在彻底清晰了支付平台回调过来我们的接口先更新了支付单状态然后调用订单创建服务。好巧不巧订单创建需要执行多条SQL每条SQL都要从连接池拿连接但连接池已经满了。等了5秒还没等到连接HikariCP直接抛了异常。而我们的代码把Service层的异常给catch住了只打了error日志没有向上抛。控制器拿到一个null返回值后依然按照处理成功的逻辑返回了success给支付平台。支付平台收到success就认为回调处理成功不再重试。结果就是支付单状态更新成功、订单没创建成功、事务整体回滚、数据查无此单。又因为Transactional的存在同一个事务里之前执行SQL用的是同一个数据库连接这个连接一开始是拿到的但后面某条SQL再次去拿连接时连接池已经没有空闲连接了。数据库连接不像线程池那样想等就能等HikariCP等不到就抛异常异常导致事务标记rollback-only最终所有操作全部回滚。2. 根因深挖MyBatis和连接池是怎么捅出这个篓子的2.1 先从MyBatis的SQL执行链路说起很多人天天用MyBatis但对一条SQL的完整生命周期并不清楚。我用大白话带大家过一遍控制器调用Mapper接口MyBatis动态代理生成一个SqlSessionSqlSession通过Executor组件去执行SQL真正执行SQL前需要从数据源里拿一个数据库连接。Spring整合MyBatis之后这个数据源通常就是连接池我们项目用的是HikariCP。整个链路可以简化成Controller - Service (Transactional) - Mapper接口 - SqlSession - Executor - 从连接池获取Connection - 执行SQL - 返回结果 - 事务提交/回滚 - 归还Connection到连接池连接池就是那个接水管的角色。连接池里有固定数量的水管业务每次要用就借一根用完就还。如果所有水管都被借走且没人还新的请求就只能排队等着等超过connectionTimeout就报错。MyBatis本身如果没有额外配置默认用的是PooledDataSource它内部有一个简易连接池但性能不如HikariCP和Druid。我们项目是Spring Boot默认的数据源就是HikariCP生产环境一直用的它所以这次背锅的也是HikariCP。2.2 连接池默认参数到底有多够用Spring Boot 2.x默认情况下HikariCP的maximum-pool-size是10minimum-idle是10connection-timeout默认30秒。听起来10个连接不少了对吧但对一个支付回调高频接口来说这10个连接可能瞬间就被打满。我先说一下当时的情况。支付回调进来后Service层要做几件事先查一遍订单是否存在防重复通知、再更新支付单状态、再插入订单主表、插入订单明细表、更新库存。这还只是一单的逻辑而且每一单都开了事务意味着从第一个SQL到最后一个SQL这个连接一直被占着。高峰期如果同时进来几十个支付回调每个都占着一个连接执行一堆SQL连接池很快满。更糟的是当时数据库里有几条慢SQL比如订单查询接口里一个多表join没走索引查询时间直接到秒级。慢SQL把连接池里的连接占了更久池子就更紧张了。等连接池被打满后后面的回调请求就只能在池外排队等。HikariCP的connection-timeout默认是30秒理论上等30秒可能还有机会但我们的生产配置里把connection-timeout调成了5秒本意是快速失败避免请求线程长时间挂着。结果呢5秒一过HikariCP直接把异常抛出来后面的逻辑全部跟着完蛋。2.3 真正致命的一环异常被吞 事务静默回滚如果说连接池打满是天灾那异常被吞就是人祸这才是这次事故最要命的地方。看下面的伪代码// 错误写法catch了异常但是不抛出 PostMapping(/pay/callback) public String payCallback(RequestBody PayCallbackRequest request) { try { payService.processPayCallback(request); return success; } catch (Exception e) { log.error(处理支付回调异常, e); // 只打日志没有 rethrow return success; // 还是返回success } }这段代码看起来像是兜底实际上就是灾难本身。支付平台回调接口的语义是你返回success我就认为处理完成不再重试你返回非success我会按照策略重试。而这里不管成功失败都返回success等于告诉支付平台我处理好了但实际上订单是没落库的。与此同时payService.processPayCallback内部用了Transactional方法执行过程中SQL异常触发了事务回滚Spring在处理回滚逻辑时发现事务已经被标记为rollback-only。到了方法出口Spring尝试提交事务但发现事务状态是rollback-only就抛出了UnexpectedRollbackException。这个异常又恰好被外层catch住了又只打日志继续返回success。这就形成了一个完整的丢单闭环连接池被打满某条SQL拿不到连接抛出异常异常导致事务回滚回滚过程又被外层catch吞掉支付平台收到success停止重试最终结果是用户钱扣了订单没了。这个闭环不打破任何一个环节丢单问题就还会再次发生。3. 修复方案除了调参数还得管住异常和事务3.1 连接池参数调整首先是把连接池参数调到符合业务实际负载的水平。这里不讲玄学直接给出我们当时调整后的配置spring: datasource: hikari: # 池中最小空闲连接数 minimum-idle: 20 # 池中最大连接数 maximum-pool-size: 50 # 获取连接超时时间单位毫秒这里设3秒 connection-timeout: 3000 # 连接空闲最大时长超出后被回收10分钟 idle-timeout: 600000 # 连接最大存活时长30分钟 max-lifetime: 1800000 # 连接池向数据库申请连接时执行的校验SQL connection-test-query: SELECT 1这里几个参数怎么定我简单说一下我的逻辑。maximum-pool-size我参考的是高峰期并发数和单请求平均执行SQL的耗时。我们支付回调高峰QPS大概在200左右单次回调内部要执行6条SQL平均耗时80ms左右。理论上一个连接一秒能跑12条SQL一个请求需要6条也就是一个连接一秒钟能扛2个请求。那么50个连接就能扛100 TPS再加上其他接口也要用连接50个连接已经比较富余了。如果后续业务量再涨就继续加大。connection-timeout设成3秒是希望快速失败而不是无限等待。一旦连接池打满与其让请求线程都卡在等待上不如直接让新请求快速失败并触发告警这样我们能在第一时间感知到异常。max-lifetime要特别说一下它一定要小于数据库侧对空闲连接的回收时间。MySQL默认的wait_timeout是8小时如果max-lifetime大于8小时那空闲连接就可能被MySQL主动断开但连接池不知道下次拿到这个连接执行SQL时就会报Connection is not available或者CommunicationsException。HikariCP建议max-lifetime设为比数据库wait_timeout短几分钟到几十分钟我们设成30分钟是安全的。3.2 从源头管住消耗连接池的慢SQL参数调完之后最关键的还是要看那些慢SQL不然连接池调再大也顶不住。当时排查慢SQL的方式很简单打开MySQL慢查询日志把long_query_time设为1秒然后跑一段时间看日志。很快就发现一个高频接口的查询SELECT * FROM t_order o LEFT JOIN t_order_detail d ON o.order_id d.order_id LEFT JOIN t_goods g ON d.goods_id g.goods_id WHERE o.user_id ? ORDER BY o.create_time DESC LIMIT 10;问题很明显t_order_detail的order_id字段没建索引t_goods的goods_id虽然有索引但多表join还是慢。这个查询单次可能只要几百毫秒但在高并发下叠加起来连接就被它占住了。我们的优化很简单给t_order_detail.order_id和t_order.goods_id都加上索引把多表join拆成两次单表查询先查订单列表再根据订单id批量查明细和商品热门商品加上Redis缓存减少数据库压力。这样调整之后单次查询耗时从几百毫秒降到了几十毫秒连接占用时间大幅缩短连接池压力小了很多。3.3 异常绝不能静默吞掉这恐怕是这次事故里最让我刻骨铭心的一条教训。说真的代码写得好不好是次要的最关键的是异常处理策略不能糊涂。支付回调这种核心接口处理原则应该是处理成功返回success处理失败必须返回失败让支付平台重试。正确的做法是这样PostMapping(/pay/callback) public ResponseEntityString payCallback(RequestBody PayCallbackRequest request) { try { payService.processPayCallback(request); return ResponseEntity.ok(success); } catch (BusinessException e) { log.error(业务处理失败: {}, e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(retry); } catch (Exception e) { log.error(系统异常回调处理失败, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(retry); } }返回非success给支付平台触发它的重试机制。这里要注意如果支付宝、微信的接口要求返回一个特定的格式我们也要保证失败时不返回那个成功标识。同时Service层的异常处理我也做了调整。Transactional方法内的异常应该尽量向上抛不要在Service内部默默catch掉。如果确实需要catch做一些补偿操作那就用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()显式标记回滚。Transactional(rollbackFor Exception.class) public void processPayCallback(PayCallbackRequest request) { // 幂等校验 PayRecord record payRecordMapper.selectByPayId(request.getPayId()); if (record ! null) { return; } try { // 业务处理... orderMapper.insert(order); } catch (Exception e) { log.error(处理支付回调异常, e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw e; // 关键异常必须抛出去 } }除此之外还加了一条铁律支付回调接口一旦返回失败必须触发告警程序员必须在5分钟内收到短信或者电话。不然失败重试虽然保证了数据最终一致但如果一直失败业务还是会卡住。3.4 事务边界短事务 独立事务隔离还有一个细节当时也让我反复琢磨就是Transactional的隔离级别和事务边界问题。连接池里的连接最怕被长时间占用。如果一个事务里既查了接口又调了远程服务甚至做了文件IO这个连接就长时间不归还很容易把连接池拖垮。比如某个方法里先查库存再调库存服务再更新数据库整个事务可能要等远程服务响应这个过程连接一直在手里拿着。我们的原则后来改成事务内只做数据库操作且尽量短远程调用、消息发送、文件操作全部放在事务外面。如果一个事务里确实需要远程调用我会把事务拆开用REQUIRES_NEW让远程调用前后的数据库操作各走各的事务。这样说大家可能有点抽象我举个简单例子更新支付单状态和插入订单这两个必须在一个事务里保证要么都成功要么都失败。但调用库存服务又是一个独立的事情不能和支付回调绑在同一个事务里否则库存服务慢的话连接池就危险了。这种场景可以拆成两个独立事务中间的补偿由MQ或者定时任务解决。4. 这类问题怎么快速定位我的排查工具与监控实践4.1 日志是第一现场这次事故排查时日志起了决定性作用。我的建议是核心业务接口的日志必须包含请求ID、订单号、支付流水号这样任何一个异常都能通过订单号串联起完整链路。具体点说我会在入口处生成一个traceId放入MDCMapped Diagnostic Context然后所有日志都带上这个traceId。排查问题时只需要拿着订单号搜索日志就能把整个处理链路拉出来MDC.put(traceId, UUID.randomUUID().toString());另外线上环境一定要打开MyBatis的SQL日志。我见过不少项目为了省日志空间把SQL日志关掉结果出了问题只能靠猜。SQL日志打出来至少能定位到哪条SQL执行了但没提交再配合异常日志和事务回滚日志根因基本就浮出水面了。4.2 线程Dump看到等待真相如果一个接口看起来卡住了闻到连接池的味道最直接的方法就是抓线程Dump。Linux下执行jstack -l {pid} threaddump.txt然后把线程Dump下载下来搜索关键词HikariCP或者getConnection。你大概率会看到一堆线程停在这里http-nio-8080-exec-12 #24 daemon prio5 os_prio0 cpu... java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(java.base17/Native Method) at java.util.concurrent.locks.LockSupport.park(java.base17) at com.zaxxer.hikari.util.ConcurrentBag.await(ConcurrentBag.java:183) at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:156)看到HikariPool.getConnection这个栈顶就说明线程在等连接池的连接。结合栈里WAITING线程的数量和连接池配置基本实锤是连接池被打满。线程Dump还有个好处是能看出每个线程现在正在执行什么SQL。如果看到某几个线程长时间停在一条SQL上那慢SQL基本就跑不掉了。4.3 连接池指标监控事后我加了一套连接池监控。Spring Boot Actuator配合Prometheus Grafana能实时看到HikariCP的指标。在application.yml里加上management: endpoints: web: exposure: include: health,info,metrics,prometheus metrics: enable: hikaricp: true然后Grafana面板上重点看这几个指标hikaricp_connections_active活跃连接数接近最大值时说明池子紧张hikaricp_connections_idle空闲连接数长时间为0说明池子一直在满负荷运转hikaricp_connections_pending等待连接的线程数大于0说明已经开始排队hikaricp_connections_timeout连接获取超时次数只要大于0就要立刻报警。监控不是万能的但没有监控就是裸奔。我把hikaricp_connections_timeout大于0设成了P1告警直接电话通知到人就是为了防止再出现等5秒没等到连接、异常被吞这一类悄无声息的事故。4.4 数据库侧的联合排查连接池的问题光看应用层还不够数据库侧的视角也必须有。我常用的SQL是查information_schema.processlist看看此时此刻数据库上有多少个活跃连接、都来自哪些应用IP、正在执行什么SQLSELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command ! Sleep ORDER BY time DESC LIMIT 100;这个查询能直接看到耗时最长的SQL和最占连接的源头。同时我建议开启MySQL慢查询日志并且把阈值设成1~2秒高并发期间用mysqldumpslow工具扫一遍很快就能找出需要优化的SQL。如果项目里接入了MyBatis拦截器也可以在拦截器里统一统计SQL执行耗时超过阈值就打warning日志。这个比数据库慢查询日志更灵活因为它能看到应用层的完整耗时比如连接获取时间和SQL执行时间分别花了多少。5. MyBatis连接池避坑清单个人整理5.1 连接池的常见经典坑下面这张表是我这些年遇到过的连接池经典问题基本覆盖了日常生产环境80%的情况坑位典型报错根因解决方案连接池耗尽Connection is not available, request timed out after 3000ms连接数不够、慢SQL占用连接太久、连接泄漏调大连接数、优化慢SQL、排查泄漏、设置超时告警连接失效Communications link failure、Connection reset数据库重启、网络闪断、连接空闲超时被服务端断开设置max-lifetime小于wait_timeout开启connection-test-query使用前校验连接泄漏Connection is not available但活跃连接持续逼近上限代码里拿到连接后没有finally关闭或者事务异常后没回滚代码Review加连接池泄漏检测HikariCP开启leak-detection-threshold事务内远程调用连接长时间占用池子被打满事务方法里做了HTTP/RPC调用事务一直不提交把远程调用移出事务短事务原则MyBatis一级缓存误用查询结果不更新同一个SqlSession里多次查询第二次命中一级缓存了解一级缓存生命周期必要时在事务方法间clearCache()参数错配连接被数据库提前断开max-lifetime大于数据库wait_timeout统一两边配置留出余量5.2 说清MyBatis缓存和连接池的关系排查过程中有个同事问了一句是不是MyBatis的缓存把订单缓存住了所以查不到。这里顺便给大家理一下MyBatis一级缓存是SqlSession级别的默认开启二级缓存是Mapper级别的默认关闭。我们用的Spring集成MyBatis每次请求都会新建一个SqlSession查询和插入基本不会跨请求共享一级缓存。所以在这个丢单场景里缓存根本不背锅背锅的纯粹是连接池和异常处理。不过话说回来如果项目里开了二级缓存并且缓存了订单列表这种业务数据确实可能出现数据不一致的假象这时候需要设置合理的缓存过期时间或者干脆不用二级缓存。我自己在业务系统里一般都会关掉二级缓存因为收益有限、坑还多。5.3 一套亲测可用的连接池参数模板最后把我们现在生产环境用的连接池模板贴出来大家可以直接复制参考但一定要结合自己业务的并发模型调整spring: datasource: url: jdbc:mysql://xxx:3306/xxx?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: xxxx password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 20 maximum-pool-size: 50 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1 leak-detection-threshold: 30000leak-detection-threshold这个参数值得单独说HikariCP中如果一个连接从池子里借出去超过这个毫秒数没归还就会打印一条Connection leak detection警告日志明确指出是哪个线程借的。这个对排查连接泄漏极其有用。我个人在实际操作中的体会是连接池参数没有一套放之四海而皆准的值最靠谱的方式是拿压测数据来定。上线前用jmeter或wrk模拟高并发观察连接池的active、pending指标找到拐点再给参数留出30%到50%的余量这样才叫真的稳。另外这类问题大概率不会只出现一次连接池的监控、告警、日志、慢SQL优化任何一个环节缺失都可能在下一次大促时让你重新交学费。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门