ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

订单超时未支付自动关闭:从定时任务到分布式补偿的完整实践

订单超时未支付自动关闭:从定时任务到分布式补偿的完整实践 1. 为什么超时未支付自动关闭这件事做起来比想象中麻烦得多先还原一个真实场景。某次大促压测结束后我照常核对运维报表发现订单表里躺着三千多条待支付状态的订单其中最早的已经超过支付时限将近两个小时。按产品需求超时未支付的订单应该在15分钟后自动关闭并释放库存但这些订单显然没被处理。查了一圈定时任务日志里干干净净没有任何报错调度平台显示任务正常运行可它就是没干活。这类问题在电商系统里非常典型。订单自动关闭机制表面上就是一个定时任务每隔几分钟扫一次超时订单改个状态把库存加回去但真正落地时会撞上一连串问题分布式环境下任务重复执行怎么办扫到一半应用重启了事务怎么收场关闭订单和用户刚好在最后一秒完成支付这两个操作撞在一起谁优先库存回滚和支付回调之间的竞态条件怎么处理性能上订单表几千万行数据怎么扫才不至于把数据库拖垮这篇文章就把这套机制的完整设计思路、踩坑过程和最终落地方案串起来讲一遍。内容适合正在做订单系统、对定时任务设计有困惑的后端开发者也适合准备做电商系统架构设计、想提前避坑的读者。我会按自己实际开发中的推进顺序来写先讲清楚这块业务到底难在哪再给方案选型接着拆核心实现细节最后集中讲线上踩过的那些坑和对应的处理办法。2. 需求拆解看似只有一个关闭订单实际背后藏着四个子问题2.1 第一个问题扫描范围怎么界定才不至于把全表拖下水订单自动关闭的第一直觉做法是写个定时任务每5分钟执行一次UPDATE order SET status closed WHERE status pending AND create_time now() - 15 min。这个SQL单看没问题但放在真实数据量下就有隐患。订单表到一定规模后都是千万级起步create_time上的普通索引在范围更新场景下会放大扫描成本如果同时还要join订单明细表、库存表去回滚数据库连接很容易被打满。更隐蔽的是如果订单表做了分库分表这个扫描全部门店的写法就必须改成遍历所有分片任何一个分片响应慢整个任务都会卡住。所以设计上必须确认的第一件事扫描维度是什么。按创建时间按支付超时时间按店铺维度分批还是按分片并发扫这个决定直接影响后面所有实现。2.2 第二个问题关闭订单和用户正在支付撞车了怎么办这是整个需求里最容易被忽略、也最容易出P0故障的点。假设用户14:00创建订单支付时限15分钟14:14:58用户点下了支付按钮此时定时任务恰好扫到了这条订单把它置为已关闭、库存回滚。用户那边支付通道返回成功支付回调过来更新订单状态——订单已经关了回调怎么处理钱扣了货没发用户投诉就来了。这不是极端假设是真实发生过的线上事故。所以订单关闭机制必须和支付状态机设计成一个整体关闭前要判断支付状态、关闭动作要有幂等保护、支付回调进来时如果发现订单已关闭要有补偿路径。2.3 第三个问题关闭动作本身失败了怎么保证最终一致定时任务扫描订单、改状态、回滚库存这三步不是天然原子操作。假设状态改成功了库存回滚时数据库连接超时相当于订单关了但库存没释放时间一长库存就被吃掉了。这个问题的本质是定时任务不能只做触发还要设计执行确认和失败重试。要么用本地事务保证状态改和库存回滚同生共死要么接受最终一致用另外的重试机制把失败的补偿动作捞回来。两者取舍取决于库存操作是不是在同一个数据库里、有没有跨服务调用。2.4 第四个问题分布式部署下任务重复执行怎么收敛定时任务部署在多节点上调度框架如果没做好任务分发可能出现两个节点同时扫到同一批订单、同时去改状态、同时回滚库存。单看每次执行似乎没毛病但叠加库存回滚的幂等性没做好时就会出现库存多加了一次、订单状态被覆盖成错误值的问题。解决思路无非两条调度层保证同一时刻只有一个节点在跑或者执行层把每个订单的处理设计成幂等操作。实际项目中两条都要做因为调度层的保证只是概率性的执行层的幂等才是兜底。3. 技术选型对比从轮询扫描到延迟消息各自的适用边界在哪里3.1 轮询扫描型定时任务简单直接但要做到聪明地扫最常见的方案就是固定间隔扫描订单表。用XXL-Job、Elastic-Job这类分布式调度框架或者干脆写个Spring Scheduled定时方法每分钟跑一次把超时订单捞出来逐条处理。这种方案的优点是架构简单、定位问题容易、和现有业务代码耦合度低。缺点是延迟不精确。假设扫描间隔是60秒一个订单可能在第14分01秒就被扫到关闭也可能到第15分59秒才被扫到用户感知的支付时限在60秒左右浮动。对绝大多数电商场景来说这个误差可以接受但如果你做的是秒杀、抢购这类高时效业务用户最后10秒提交支付的比例很高这个误差会导致大量明明能支付成功却被关闭的客诉。所以轮询扫描方案的核心不在于用不用而在于怎么扫。后面第四节我会重点讲一个更稳的扫描设计。3.2 延迟队列与时间轮延迟更精确但落地时工程复杂度上来了如果要精确到秒级关闭就该上延迟队列。用RabbitMQ的延迟消息、RocketMQ的定时消息或者用Redis的Sorted Set做时间轮把订单创建这个事件扔进队列设定15分钟后触发关闭动作。这个方案的优点是触发时间可控、精度高缺点是链路变长、问题定位变难。你得考虑消息积压、消息丢失、消费者重启后未消费的消息怎么办、消息补偿怎么设计。3.3 业务数据库的懒关闭查询时实时判断适合低频场景还有一种思路是关闭动作不主动做而是在用户查询订单、发起支付、操作订单时实时判断是否超时超时就顺手关掉。这种懒关闭能省掉定时任务但对未支付订单占用的库存是失控的因为没人查它它就永远占着库存。所以这个方案只适合关闭动作无副作用、不涉及库存占用的低频业务电商订单核心链路基本不适用。三种方案对比下来我的选择是主链路用轮询扫描保证最终关闭关键节点上叠加支付前的实时超时校验两类机制互相兜底。延迟消息作为后续优化方向不在第一版引入。理由很现实方案一的链路最短出现问题时能从订单表直接反推任务执行过程适合快速上线验证。等业务量级大到轮询扫描确实扛不住时再迁到延迟消息体系也不迟。4. 核心实现一套可落地的订单扫描关闭方案4.1 扫描任务的总体流程设计先看我最终采用的执行流程再看每一步的关键细节。整个任务分成四个阶段分片扫描、逐单判定、关闭动作、对账补偿。第一阶段任务启动后根据配置的分片数把订单表的主键范围拆成N段每个分片独立扫描分片之间互不干扰。第二阶段每个分片内按订单创建时间倒序捞出一批待处理订单逐条做超时判定。第三阶段对确定超时的订单执行关闭逻辑这个逻辑里包含状态校验、支付状态二次确认、库存回滚三个动作。第四阶段记录每次执行的成功数、失败数、失败原因失败数据进入补偿表由另一个低频任务重试。这个流程设计有一个核心指导思想扫描和关闭分离。扫描只负责找出候选订单关闭才是真正改数据的动作。好处是扫描逻辑可以做得很快、很轻一旦某个订单关闭失败不影响整批扫描继续往下走。4.2 扫描SQL怎么写才不会被慢查询拖死大部分订单关闭任务的性能瓶颈都出在扫描SQL上。我踩过的坑是一开始直接用SELECT * FROM orders WHERE status 0 AND create_time ? LIMIT 1000order表数据量到百万级别后这个查询经常跑到一秒以上因为status 0的选择性太差了索引基本失效。后来改成两段式扫描。第一段只查主键SELECT id FROM orders WHERE status 0 AND create_time ? ORDER BY create_time LIMIT 500注意这里只查id列走覆盖索引速度能快一个数量级。第二段再用查到的id列表去批量查完整订单数据进行具体的超时判定和关闭动作。这样即使订单表上只有create_time一个索引第一段也能高效工作。分页问题也要考虑。LIMIT 500没问题但如果一次要处理几万条就不能用LIMIT 500 OFFSET 10000这种深分页写法性能会断崖式下降。正确做法是记住上一次扫描到的最大的id下一轮用WHERE id ? AND create_time ?继续往后扫用id当游标。4.3 关闭订单的原子性状态机校验前置而不是靠UPDATE碰运气关闭订单最怕的就是并发把状态改乱。我设计的关闭逻辑不是直接UPDATE orders SET status closed WHERE id ?而是先查一次完整订单状态做三层校验全部通过才执行关闭。三层校验依次是订单当前状态必须是待支付支付系统查询结果显示没有进行中的支付单订单创建时间加上支付时限确实已经过期。只有这三条同时成立才允许进入关闭流程。这里有一个非常关键的设计关闭时用的UPDATE语句要带状态条件类似UPDATE orders SET status closed WHERE id ? AND status pending。返回的影响行数为1说明关闭成功为0说明状态被其他动作改了这时候要立刻放弃关闭并告警。这就是乐观锁的思路用一条带条件的UPDATE代替先查再改再判断既保证了原子性又避免了分布式锁的引入。三层校验完成后才执行库存回滚。我把状态更新和库存回滚放在同一个本地事务里事务内先改订单状态再调库存服务回滚最后提交。如果库存回滚失败整个事务回滚订单保持待支付状态任务重试时再走一遍完整流程。这样设计牺牲了一点点吞吐量换来了最核心的一致性保证订单要么关闭且库存释放要么保持原样等待重试。4.4 补偿机制关闭失败的订单怎么捞回来即使做了上面的设计仍然可能出现关闭失败的情况库存服务临时不可用、数据库连接池打满、事务超时。所以我把每次关闭失败的数据单独插入一张关闭补偿表记录订单号、失败原因、失败时间、重试次数。后台另有一个低频补偿任务每5分钟扫一次补偿表把重试次数小于3的失败记录重新执行关闭逻辑。重试次数超过3次的发告警通知人工介入。补偿表里的数据在重试成功后删除同时记录一条关闭成功的日志备查。这个补偿表的价值在于他把关闭任务是否可靠从赌运气变成了可观测、可干预。每次执行完我只需要核对扫描总数、成功数、失败数就能判断系统有没有在正常工作。5. 分布式环境下的三座大山重复执行、库存回滚幂等、多节点任务分发5.1 重复执行任务框架决定了谁在跑业务层决定了同一个订单会不会被跑两次单节点部署时定时任务天然不会重复执行。但一旦上了多节点调度框架默认策略可能是每个节点都执行这就直接导致同一条订单被多个节点同时扫描、同时处理。我用的调度框架支持分片广播和单机路由两种模式。订单关闭任务选的是单机路由即同一时刻只有一台机器执行完整扫描流程。即便如此我依然在关闭逻辑里保留了乐观锁的UPDATE条件判断。原因很简单调度框架只能保证正常情况下单机执行但遇到节点网络抖动、任务超时重跑这类异常重复执行照样可能发生。业务层的幂等判断是最后一道防线不能省。5.2 库存回滚的幂等这是整个方案里最需要抠细节的地方订单关闭后要释放库存但库存服务可能因为网络原因没收到请求或者收到了请求但处理超时任务重试时又发了一次释放请求。如果库存服务没有做幂等库存就被多加了。两种常见幂等做法一是用订单号释放类型作为唯一键库存服务记录每次释放操作重复请求直接被拒绝二是用逻辑库存占用库存双字段模型释放操作是占用量减X可用量加X天然具备加法语义的可重入性。我建议两种叠加使用唯一键做硬性拦截双字段模型做底层兜底。这里要提醒一点不要试图在订单服务里靠关闭前查一次库存是否已释放来判断要不要调库存接口。数据库查询存在时间差并发场景下这个判断永远不可靠。信幂等键不信前置查询。5.3 日志与监控分布式任务排查问题的唯一抓手分布式环境下排查定时任务问题不能靠看某台机器日志必须建立链路级的执行日志。我在每次扫描执行中打印了三个关键时间点任务开始时间、扫描完成时间、关闭动作完成时间。每个订单的关闭动作都会打印一条包含订单号、耗时、结果的结构化日志。监控指标上我重点关注单次任务总耗时、每批扫描的订单数、关闭成功率、补偿表堆积量。其中补偿表堆积量是最敏感的指标只要它持续上涨说明关闭链路某个环节出了问题必须马上查。6. 线上事故复盘那些让定时任务看起来正常但没干活的隐藏原因6.1 事故一任务倒是跑了但事务一直没提交数据全回滚了某次线上排查任务日志显示扫描了2000条订单全部处理成功但订单表里一条都没关。查到最后发现是关闭逻辑里一个远程调用没有设置超时时间默认是无限等待。某个下游服务偶发hang住导致整个事务一直不提交数据库连接被占满后续的任务全部排队看起来就像任务挂了。这个问题的教训是两个所有远程调用必须显式设置超时关闭事务里不应该包含任何远程调用事务内只做本地数据库操作远程调库存的动作挪到事务提交之后去做配合补偿机制保证最终一致。我把这个改造做完之后类似问题再也没有出现过。6.2 事故二分片数固定数据倾斜导致部分分片永远扫不完早期我把订单表按主键ID范围分成4个固定分片每个分片一个线程扫描。问题是主键ID和数据量并不均匀旧数据集中在ID较小的分片新数据集中在ID较大的分片结果1号分片每次扫几十条就结束了4号分片要扫几万条单次任务被拖到十几分钟。后来改成动态分片每次任务执行前先查一次订单表的MIN(id)和MAX(id)再均分成N个区间。这样即使数据分布变化每次的分片边界都是基于当前数据量重新计算的整体扫描时间基本稳定。6.3 事故三任务执行时间和用户支付高峰重叠挤爆了订单库连接池这是调度时间选择的教训。一开始我把关闭任务定在每个整点后的第5分钟执行恰好和整点支付高峰错开得不够彻底。扫描订单的大查询和用户实时下单的写请求争抢连接池导致订单库整体响应变慢。后来我把任务时间调整到支付低峰期比如凌晨和午后的固定时段大幅降低了扫描对线上订单链路的影响。对于必须高频执行的扫描我改用只读从库执行扫描SQL主库只承担最终的关闭更新动作这样写请求的压力几乎不受影响。7. 上线后的效果与优化空间延迟、吞吐量、准确率三个维度的实测数据7.1 实测表现单次任务耗时、关闭准确率、延迟分布方案上线稳定运行后我记录了一组实测数据单次扫描任务平均耗时从最早的12分钟降到40秒左右关闭动作的成功率维持在99.97%以上失败的数据都能在下一轮重试中补偿成功订单从超时到实际关闭的时间差控制在扫描间隔2秒以内绝大多数场景下用户感知完全符合预期。这个数据说明一个事情轮询扫描方案并没有想象中那么笨。只要把扫描SQL优化好、分片设计合理、关闭动作精简它在千万级订单表上的表现完全够用。7.2 后续优化方向延迟消息替代轮询但改动面不小如果要进一步缩短关闭延迟、降低扫描频率可以考虑把订单创建事件同步发到延迟消息队列15分钟后自动触发关闭。这个方案的改动点主要在订单创建逻辑要增加发消息动作新增一个延迟消息的消费服务消费服务的幂等逻辑要和现有关闭任务的幂等逻辑复用。我评估过从当前轮询方案迁到延迟消息大概需要一到两周的改造量收益是扫描频率可以从分钟级降到秒级数据库压力进一步下降。但如果业务上没有强时效要求轮询方案再扛一两年问题不大。7.3 一个容易被忽略的细节关闭订单和支付回调的最终一致性时序最后补充一个方案里必须明确的规则支付回调进来时如果发现订单已经被关闭必须走退款流程而不是直接改订单状态为已支付。这个规则要在支付系统的对接文档里写明同时在代码里做好注释防止后来维护的人误改逻辑。订单关闭机制真正复杂的地方不在关闭本身而在于它和支付、库存、补偿、重试这些周边系统的协作边界。把这些边界理清楚写清楚比堆多少并发优化都重要。我自己走完这一轮下来最大的体会是定时任务设计没有银弹选型时要盯着业务的可接受延迟、数据的一致性和排查问题的便捷度这三个点反复权衡。能用一个简单的方案解决就绝不上复杂架构这个原则在订单关闭这个场景里体现得特别明显。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进