ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

高并发订单超时关闭的三种高效方案:原理、对比与选型建议

高并发订单超时关闭的三种高效方案:原理、对比与选型建议 做订单系统的朋友十有八九都被“超时关闭”这件事恶心过。订单创建了、用户不付款系统得在30分钟后把订单置为已取消把库存还回去把优惠券解冻运气好还要给用户发一条“您的订单已超时关闭”的通知。单量小的时候一个定时任务扫表就完事了。可一旦流量上来日订单几十万上百万数据库一有压力简单方案立刻翻车扫表慢、关闭不及时、重复处理、锁冲突……每一件都够你在大半夜被电话叫醒。这篇文章就聊聊高并发场景下订单超时关闭的3种高效方案定时任务批量扫描、基于延迟消息队列的实现、基于Redis过期监听和时间轮的实现。我会把每种的原理、代码骨架、适用场景和踩过的坑都写出来最后给出选型建议。适合正在做交易系统、订单系统或者被“超时关闭”这个需求逼到想骂人的同学参考。1. 订单超时关闭的本质为什么并发场景下这个问题会变得棘手1.1 需求拆解超时关闭到底在“关”什么很多人把超时关闭想简单了以为就是“把订单状态改成CANCELLED”。实际上一个订单超时之后要做的是一连串的后续动作修改订单状态从待付款变成已取消回滚库存把预占的库存加回来解冻优惠券让用户还能继续用解冻支付渠道的预授权如果做了冻结支付扣减用户的“未支付次数”或风控计数写日志、发通知、做数据统计。任何一个环节失败都会产生数据不一致。比如你改了订单状态但库存没回滚那用户明明没付款库存却被占着过两天老板问你为什么热销商品库存紧缺你查半天发现全是“幽灵订单”占着库存。而且这个需求有一个天然特点它不是“用户主动触发”的而是“时间到了自动触发”的。也就是说系统需要有一个机制在未来的某个时间点主动找到这些订单并完成一系列状态变更。在高并发场景下这个“主动找到”要面对的数据量很大而“状态变更”又要面对并发冲突和重复执行的问题。1.2 并发场景下的三大痛点第一海量数据扫描。假设日订单量50万30分钟内未支付的订单占比30%那就是15万条待关闭数据。定时任务要在一个时间窗口里把这些数据捞出来如果一次select全表扫轻则慢查询拖垮数据库重则直接打到主库影响正常下单链路。第二重复执行。定时任务跑了第一遍发现一批订单还没关闭开始处理处理到一半下一批任务又触发了又把同一批订单捞出来一次。如果代码里没有做幂等两个线程同时执行UPDATE order SET status CANCELLED WHERE id ? AND status PAYING第二个线程虽然更新不到但后续的“回滚库存”“解冻优惠券”可能重复执行库存扣两遍优惠券核销两遍直接出生产事故。第三关闭不及时。用户下单后很久才看到订单被关闭或者本应该在第30分钟关闭结果系统在35分钟才扫到。如果是秒杀场景商品有限超时关闭晚了一分钟意味着想买的用户要多等一分钟用户体验直线下降。这就是为什么不能拿“一个定时任务 一个update语句”草草交差。要看你所处的业务量级选择合适的方案。2. 方案一定时任务批量扫描最朴素的方案2.1 实现思路这个方案的核心就是一个定时任务每隔一段时间比如每30秒或每分钟去数据库里查“创建时间早于当前时间减30分钟且状态为待付款”的订单然后批量调用关闭逻辑。架构上可以拆成两层调度层用Spring自带的Scheduled或者用xxl-job、ElasticJob这类分布式调度框架执行层则是扫描关闭的Service逻辑。其本质是“由时间间隔驱动的轮询”。你不需要引入任何额外组件用数据库的索引和SQL条件就能完成。这也是它最大的优势简单、直观、容易排查问题。一个团队里哪怕是新人看一上午代码也能搞明白。2.2 一个基础的代码骨架下面是一段典型的Spring Boot实现。省略了部分无关代码核心逻辑如下Component public class OrderTimeoutJob { private static final Duration TIMEOUT Duration.ofMinutes(30); private static final int BATCH_SIZE 500; Resource private OrderMapper orderMapper; Resource private OrderCloseService orderCloseService; Scheduled(fixedDelayString ${order.timeout.scan.interval:30000}) public void scanTimeoutOrders() { ListOrder orders orderMapper.selectTimeoutOrders( LocalDateTime.now().minus(TIMEOUT), BATCH_SIZE ); for (Order order : orders) { try { orderCloseService.closeOrder(order.getId()); } catch (Exception e) { log.error(close order failed, orderId{}, order.getId(), e); } } } }对应的SQL大概是SELECT id, user_id, status, create_time FROM t_order WHERE status 0 AND create_time #{deadline} ORDER BY create_time ASC LIMIT #{limit}这里有个关键点按主键或创建时间排序然后LIMIT固定批次大小。千万不要一次select全表去处理否则你会在凌晨3点被告警电话叫醒。2.3 并发场景下的问题定时任务批量扫描在低并发阶段没问题但到了高并发阶段问题会集中暴露数据库压力持续存在。不管有没有过期订单定时任务每次都会执行一次范围查询。凌晨高峰期无所谓但白天本来就流量大任务一跑慢SQL直接把数据库CPU拉高影响正常读写。扫描与处理耦合。处理一个订单可能要回滚库存、调用外部接口、发MQ消息耗时可能从几十毫秒到几百毫秒。如果扫描出的500个订单里有一批处理得很慢下一个调度周期很快就来了线程池被占满任务堆积。关闭延迟不可控。假设扫描间隔是30秒最短的关闭延迟是30秒最长可能是30秒处理时间。如果中间赶上GC或数据库抖动延迟会拉到分钟级。严格意义上这不是“超时关闭”而是“差不多的时候关闭”。分页偏移量问题。如果你用了LIMIT offset, batch每次扫描都会丢掉之前处理过的数据或者重复扫描同一批数据导致“永远处理不完”的假象。正确做法是每次记住最大ID用WHERE id ?来滚动扫描或者直接用LIMIT batch配合ORDER BY id ASC处理完一批再从上一批完成位置继续。2.4 改进方向分批扫描、分表与异步处理如果项目已有基础设施定时扫描方案也可以做得更稳。我自己在项目里实践过几个改进点分批异步落库扫描线程只负责“找出待关闭订单ID”把每个ID包装成任务丢给线程池执行扫描线程不参与具体关闭逻辑。这样即使某个订单处理很慢也不会拖慢整个扫描链路。分库分表场景下并行扫描如果订单表分了16张表可以按表并行扫描降低单表压力也摊薄扫描耗时。扫描频率分级普通订单5分钟扫一次高优先级订单比如秒杀订单用独立的、更短周期的任务去扫描。这就是“不同业务不同SLA”的思路。用create_time索引SQL一定要走到idx_create_time_status (create_time, status)这类覆盖索引上避免回表。这也是最容易被忽略的性能点。改进后的定时扫描并不过时它在中小规模系统里依然是主流。只是要注意这个方案的瓶颈是“数据量上去了之后扫描成本会越来越高”所以早晚会需要引入下一个方案。3. 方案二延迟消息队列让消息“到时间自己飞回来”3.1 原理用消息中间件的延迟能力替代轮询延迟消息队列的思路很直接用户下单时我往消息队列里发一条“订单超时检测”消息并指定这条消息在30分钟后才能被消费。消费端收到消息后去数据库查这个订单的状态如果还是“待付款”就执行关闭。相比定时扫描这个方案最大的变化是不再主动遍历数据而是让“单个订单”在到期时自己触发后续动作。这样一来数据库的压力从“全表范围扫描”变成了“单条/少量按ID查询”并发越高这个优势越明显。在基础组件选型上国内用得最多的就是RabbitMQ和RocketMQ。RabbitMQ靠“死信队列”或官方延迟插件实现RocketMQ原生支持延迟消息但延迟级别是预定义的比如1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h固定级别适合时间粒度不敏感的业务。3.2 用RabbitMQ死信队列实现延迟消息这里给一个我验证过的RabbitMQ实现利用“消息TTL 死信交换机”。先把消息发到一个没有消费者的队列设置消息的expiration 30分钟消息过期后会被投递到死信交换机再由死信交换机路由给真正的消费队列。创建配置大概是这样的Configuration public class RabbitDelayConfig { Bean public Queue delayQueue() { return QueueBuilder.durable(order.delay.queue) .ttl(30 * 60 * 1000) .deadLetterExchange(order.delay.exchange) .deadLetterRoutingKey(order.timeout.close) .build(); } Bean public Queue closeQueue() { return QueueBuilder.durable(order.close.queue).build(); } Bean public DirectExchange delayExchange() { return new DirectExchange(order.delay.exchange); } Bean public Binding closeBinding() { return BindingBuilder.bind(closeQueue()) .to(delayExchange()) .with(order.timeout.close); } }下单时发送延迟消息public void onOrderCreated(Order order) { String message JsonUtils.toJson(new TimeoutCloseMessage(order.getId())); rabbitTemplate.convertAndSend(order.delay.exchange, order.created, message); }消费端监听关闭队列收到消息后处理关闭RabbitListener(queues order.close.queue) public void onTimeoutClose(TimeoutCloseMessage message) { Long orderId message.getOrderId(); orderCloseService.closeOrder(orderId); }这套方案的优点很明显不需要频繁扫库关闭时间精确到消息过期那一刻数据库压力远小于定时扫描。但这里有一个我在生产环境中踩过的坑RabbitMQ死信队列中队列头部的消息过期后才会被投递到死信交换机如果队列里有一条消息因处理失败一直不被消费后面的消息即使过期了也不会被投递出去。这会导致订单关闭被阻塞表现为某段时间超时关闭大面积延迟。3.3 可靠性保障消息丢失与重复消费延迟消息方案最担心的就是“消息丢了”。一个订单创建了延迟消息没发出去或者发出去了队列崩了那这个订单永远不会被自动关闭。所以实践中一定要有兜底机制本地消息表发消息前先在同一数据库事务里写一条timeout_task记录发消息成功后更新状态。如果消息发送失败或者不确定是否发送成功定时任务扫这张任务表把没有确认发送的记录重新发一次。消费幂等消费端处理关闭时不能只看消息来了就关闭要先查订单状态通过UPDATE ... WHERE status 待付款这种方式保证幂等。如果消费端重复收到同一条消息第二次更新影响行数为0直接跳过。最终兜底扫描即使有了延迟消息我依然会保留一个低频定时任务比如每5分钟跑一次专门扫描“确实超时但还没关闭”的订单。这是所有方案的止血包我建议永远不要去掉。3.4 适用场景与坑点总结这个方案适合“用户下单量大、已经有MQ基础设施、团队愿意为可靠性多花功夫”的系统。如果你所在的公司连RabbitMQ或RocketMQ都没有不建议为了超时关闭单独引入一套MQ运维成本和排障成本会让你想哭。坑点再补几个RocketMQ的延迟级别是固定的如果需求是“精确到秒”的延时它并不适合RabbitMQ延迟插件的版本兼容性要提前测试别上了生产发现插件和broker版本不匹配延迟消息的消息量会积压在队列里如果队列堆积严重监控面板会很难看要用单独的队列/交换机来隔离避免跟其他业务消息互相影响。4. 方案三Redis过期监听与时间轮轻量但需要精细控制4.1 Redis Keyspace Notifications过期事件当闹钟用Redis有个特性叫Keyspace Notifications你可以监听某个key过期的事件。思路是用户下单时把一个以order:timeout:{orderId}为key的键写入Redis设置过期时间为30分钟。开启notify-keyspace-events Ex然后订阅过期事件。当key过期时Redis会发布一条过期事件客户端收到后去关闭订单。Redis配置需要设置notify-keyspace-events Ex这里E表示key事件x表示过期事件。代码里用Spring Data Redis的监听器Component public class OrderTimeoutRedisListener { Resource private OrderCloseService orderCloseService; Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); return container; } PostConstruct public void registerListener() { redisMessageListenerContainer.addMessageListener( (message, pattern) - { String expiredKey message.toString(); if (expiredKey.startsWith(order:timeout:)) { Long orderId Long.valueOf(expiredKey.substring(order:timeout:.length())); orderCloseService.closeOrder(orderId); } }, new PatternTopic(__keyevent*__:expired) ); } }这个方案看着简单但有两个硬伤。第一Redis的过期策略是“惰性删除 定期删除”过期事件并不是在当前时间精准触发的而是当key被访问或定期删除扫描到它时才会发布事件。实际延迟可能有一两秒也可能更长。第二Redis在maxmemory策略下如果内存满了触发淘汰可能直接把key踢掉此时也会发布过期事件吗答案是否定的淘汰事件和过期事件是不同的事件类型。如果内存满了把订单超时key淘汰了但没有真正执行关闭订单就成了僵尸订单。4.2 时间轮纯本地的定时调度方案如果说Redis方案等于“把超时时间交给Redis管理”那时间轮就是“在应用内存里自己管理一堆未来的定时任务”。时间轮算法最早出现在Netty的HashedWheelTimer里核心思想是把时间划分为一个个“槽位”每个槽位放一批到期的任务。指针每走一个时间间隔就跳到下一个槽位把该槽位上的任务取出来执行。分层时间轮则用多级时间轮来扩大可表示的时间范围类比钟表的时分秒。用Java的话可以直接用Netty的HashedWheelTimerHashedWheelTimer timer new HashedWheelTimer( new DefaultThreadFactory(timeout-timer), 1, TimeUnit.SECONDS, 512); // 下单时注册一个延迟30分钟的关闭任务 timer.newTimeout(timeout - { orderCloseService.closeOrder(order.getId()); }, 30, TimeUnit.MINUTES);这看起来特别简洁但要注意HashedWheelTimer是单线程执行的。如果某个订单关闭逻辑执行时间过长会阻塞后面的所有到期任务。所以我实际使用时会在任务回调里把真正的关闭操作丢给业务线程池timer.newTimeout(timeout - { closeTaskExecutor.execute(() - orderCloseService.closeOrder(order.getId())); }, 30, TimeUnit.MINUTES);时间轮的优点是不依赖外部组件纯应用内调度适合单机场景、进程内任务数量可控的环境。缺点是任务在内存里进程重启任务就没了。如果服务刚好在订单创建后的第20分钟重启这个订单的超时任务就丢了必须靠兜底扫描找回来。4.3 两种子方案的共性问题不管是Redis过期监听还是时间轮都会面临一个共同问题触发消息本身不代表订单一定需要关闭。收到触发信号后消费端必须做一次状态校验public void closeOrder(Long orderId) { // 乐观锁更新只有PAYING状态才能改成CANCELLED int affected orderMapper.updateStatus( orderId, OrderStatus.PAYING.getValue(), OrderStatus.CANCELLED.getValue() ); if (affected 0) { // 订单可能已支付忽略 return; } // 更新成功后再做回滚库存、解冻优惠券等后续操作 stockService.rollback(orderId); couponService.unfreeze(orderId); }这个UPDATE ... WHERE status PAYING就是关键它保证了只有一次才能成功执行关闭天然解决并发重复触发的问题。我之前见过一个团队只判断了“订单状态不是待付款就不关闭”没有用乐观锁结果两个消息同时到达库存被回滚了两次。生产环境血的教训。4.4 适用场景与注意点Redis过期跟监听方案适合有一定Redis基础、对关闭延迟要求不是特别苛刻的场景。比如秒杀场景下订单量极大但真正会产生超时关闭的订单比例很低用Redis Key事件做触发加上兜底扫描做补偿这是一种性价比很高的组合。时间轮方案更适合单机应用内部需要大量定时调度的场景比如直播间的礼物过期、聊天室消息清理。如果是分布式部署的订单系统不建议直接使用内存时间轮作为唯一方案因为任务分布在多台机器上如果一台机器挂了那台机器上的任务就没人处理。要么配合分布式任务调度框架要么只把它当成兜底方案的加速器。5. 三种方案对比与选型建议5.1 综合对比我从时效性、数据库压力、可靠性、实现复杂度和扩展性几个维度做个对比维度定时任务批量扫描延迟消息队列Redis过期监听/时间轮关闭时效取决于扫描频率最快几十秒最慢几分钟可精确到消息TTL延迟通常在秒级Redis方案有数秒延迟时间轮理论毫秒级数据库压力每次全表范围扫描压力最大单条/少量按ID查询压力最小单条按ID查询压力较小可靠性依赖任务调度数据源稳定依赖MQ需要消息不丢和兜底依赖Redis或内存进程重启/淘汰会丢事件实现复杂度最低一张表和定时任务中等需要MQ和消息表中等需要监听配置或时间轮组件扩展性水平扩展需要分布式调度最好消费端可随意扩容Redis方案需注意集群广播时间轮不适合分布式从表格可以看出来没有哪个方案是绝对的银弹。定时扫描胜在简单延迟消息胜在时效与解耦Redis和时间轮胜在轻量但需要更多细节控制。5.2 选型决策建议结合我自己的项目经验给几条比较实际的选型思路日订单量小于10万、团队没有MQ基础设施直接用定时任务批量扫描做好分批和索引优化就够了。不要为了“优雅”而上复杂组件后期维护成本比开发成本高得多。日订单量几十万、已经有MQ优先上延迟消息队列同时保留低频兜底扫描。这是目前中大型交易系统里最主流的组合。秒杀/限时抢购场景因为订单创建瞬间并发极高超时关闭触发比较集中对DB压力最敏感。建议用延迟消息或Redis过期监听做触发再用状态乐观锁做幂等配合兜底扫描把“漏网之鱼”捞出来。单机应用、进程内存可控可以用时间轮做本地调度但一定要接受“进程重启丢任务”的现实并做好启动时的重建扫描。5.3 组合思路不要只用一种方案这里想多说一句。很多技术方案被诟病不靠谱其实不是方案本身不行而是你拿它当了唯一方案。我现在的系统里超时关闭其实是多级组合的主链路用RocketMQ延迟消息保证大部分订单在30分钟前后几秒内被关闭同时有一个5分钟一次的兜底扫描专门处理“消息丢了”“MQ积压”“状态异常”的订单订单关闭操作全程用UPDATE ... WHERE status PAYING做幂等并且通过本地消息表记录关闭结果失败的重试机制独立运行。这套组合下来线上几乎没再出现过“订单超时了不关闭”的情况。可靠性从来不是靠一个组件撑起来的而是靠主链路、兜底、幂等三层互保。6. 实战中的坑与经验6.1 幂等与状态校验是最重要的防线网上很多文章上来就是讲RabbitMQ怎么配、Redis监听怎么写但很少人强调触发机制只是一半另一半是触发后怎么保证只关一次、不关错订单。我建议所有订单关闭入口不管走哪个触发链路都统一调用同一个OrderCloseService.closeOrder(orderId)在这个方法入口做状态机校验。订单状态从PAYING到CANCELLED中间不允许从PAID再变回CANCELLED。把这个规则写成数据库更新条件而不是写if (status PAYING)再update因为两个步骤之间可能有并发修改订单状态。你要做的是一步到位UPDATE t_order SET status 2, update_time NOW() WHERE id #{orderId} AND status 0如果返回值是1才继续执行后续的“回滚库存、解冻优惠券”等操作。如果返回值是0说明订单状态已经不是待付款直接忽略不要做任何后续动作。6.2 补偿定时任务一切触发方案的止血包不管选延迟消息、Redis监听还是时间轮都一定要保留一个低频扫描任务做最终一致性兜底。这个兜底任务只做两件事找出“创建时间超过30分钟、状态仍为待付款”的订单把它们的ID批量丢给关闭服务。因为主链路已经把99%的订单处理掉了兜底任务每次扫到的量很少不会对DB造成明显压力。但它能保证即使主链路全部宕机只要兜底任务在订单最终也会被关闭。我给它起名叫“止血包”意思是“平时它最闲出事它救命”。6.3 监控指标别等用户反馈才发现有问题超时关闭是一个典型的“后置链路”问题往往是用户来投诉“我拍下没付款但库存显示被占着”才会暴露。所以要主动监控这些指标每一分钟成功关闭的订单数关闭失败的订单数和失败原因分布从订单创建到实际关闭的时间差观察是否符合预期延迟消息队列的消费积压情况兜底扫描每次扫到的订单数量如果这个数字突然变大说明主链路出了严重问题。这些指标直接打到监控系统里配置合理的告警阈值。比如“延迟超过5分钟的关闭数量大于100”就报警。好多年了我依然记得第一次通过告警找到问题根源的成就感——不出半个小时就定位到是死信队列被堵了而不是等用户来骂。6.4 扩展性从单体到分布式的平滑演进最后聊一下扩展性。很多小团队一开始用定时扫描后来流量大了想平滑迁移到延迟消息方案又怕迁移过程中出问题。我建议这样演进第一阶段保持定时扫描不动先把订单表索引优化好扫描频率降下来比如从每30秒降到每5分钟保证基本可用。第二阶段引入MQ延迟消息新订单延迟消息走MQ关闭操作还是复用同一个关闭Service。此时定时扫描继续跑但只作为兜底。第三阶段观察MQ链路稳定运行一段时间后把定时扫描频率进一步降低或者只保留每天凌晨的全量数据对账把订单主链路的关闭完全交给MQ。整个过程最好是“加组件、不删逻辑”每一步都可以灰度验证。不要想着某一天晚上重构一把梭把定时任务删了、全面切换MQ。那样一旦踩坑恢复成本极高。再补充一个细节如果你用的是分布式任务调度框架比如xxl-job、ElasticJob定时扫描任务要多实例部署但要注意任务分片策略。不要用Scheduled直接部署在多台机器上否则每一台都会扫一遍全表SQL查询量直接翻N倍。正确做法是配置分片参数比如按用户ID模10分片每台机器只扫属于自己的那部分订单。这个坑我见得太多了很多人上线多实例后第一反应是“数据库突然变慢”一查全是重复扫描任务。我在实际操盘订单超时关闭这个需求时最大的体会是方案本身并不复杂复杂的是边界情况——消息丢了怎么办、重复消费怎么办、进程重启怎么办、数据库慢怎么办。只要你把“幂等”和“兜底”这两件事做好无论用哪种方案都不会出大问题。如果非要给一个优先级我会说先用定时扫描跑通业务再逐步引入延迟消息最后用兜底扫描和状态乐观锁来保证底部安全。系统是在演进中变得更稳的不是一次设计出来的。
返回列表