
做秒杀项目之前我一直觉得它就是量大、并发高、扛住就行。真上手之后才发现难的不是把 Redis 和消息队列接上去而是把一整套异步下单流程想清楚、把边界条件处理好。这套东西如果只是照着网上的架构图画一遍上线必出事。这篇就结合我自己做过的秒杀类项目把异步下单这套业务逻辑从头到尾梳理一遍请求进来怎么挡、库存怎么扣、消息怎么发、订单怎么落库、失败怎么补偿每一步为什么这么做参数怎么定实际会踩什么坑尽量一次说透。1. 秒杀场景到底在挑战什么1.1 一次秒杀的完整业务链路秒杀从用户视角看很简单开抢时间一到点按钮下单付款。但站在系统设计角度一次秒杀背后至少串了十几步业务动作用户身份校验和登录态校验活动状态校验是否已开始、是否已结束商品和场次信息校验库存预检创建订单扣减库存生成支付单触发支付流程支付回调后更新订单状态超时未支付的话还要自动关单并回补库存用户感知到的下单成功只是这串动作里最早完成的几环后面一大半都是异步在跑。所以做秒杀系统的第一课就是搞清楚哪些动作必须在请求线程里同步完成哪些事情完全可以放出去慢慢做。我见过不少团队第一次做秒杀直接把普通下单接口改造一下就上结果一到流量峰值数据库连接池被打满慢查询一堆用户端超时率飙升。问题不在于代码有 bug而在于把不该同步做的事都同步做了——比如在请求线程里去写订单表、去 update 库存表。这些操作在低并发下毫无感知在高并发下就是雪崩点。1.2 同步下单为什么扛不住普通电商的下单接口大概是校验 → 落库 → 扣库存 → 返回订单号这样的流程全程同步。用户量少的时候完全没毛病一个请求进来数据库写一条订单、更新一下库存事务一提交返回结果结束。但秒杀场景完全不一样。核心特征是三件事瞬时流量极高开抢的头几秒QPS 往往是平时的几十上百倍资源极其有限库存可能只有几千件但请求量可能是几十万甚至上百万有效流量占比极低真正能下单成功的人可能只有不到 1%其余 99% 都是陪跑。用同步下单的模型去处理这种流量等于让每一个进来的人都去数据库排队做完整链路。数据库连接是有限的事务并发是有限的热点行上的行锁竞争更是灾难。几百个人同时在抢同一件商品时更新同一行库存数据库的行锁会把绝大多数事务排队响应时间成倍拉长最后连正常用户的请求也被拖死。所以一个核心思路就浮现了把请求处理跟业务处理解耦。用户在请求线程里只做最轻量的事——校验、拦截、预扣库存、返回排队结果而把订单创建、库存落账、状态流转这些相对重的操作丢到异步链路里慢慢执行。这就是异步下单的核心出发点。1.3 异步化的两条主线真正的异步化设计在我看来要同时抓两条主线。第一条是流量削峰。秒杀请求进来后先在网关、在 Redis 层做多级拦截把绝大多数无效请求挡在最前面只有真正够资格的请求才进入后端的下单链路。这样落到数据库的压力就变得非常可控。第二条是操作解耦。把接单和做单拆成两个环节。接单环节尽量快、尽量简单只做状态预占做单环节放到底层异步执行由消息队列和消费者去完成真正的数据落库。两个环节之间通过消息队列衔接哪怕做单环节故障了接单环节也能照常收件不会造成用户侧大面积失败。这两条主线放在一起就是典型的预扣库存 异步建单模式。后面所有细节都是围绕这两条主线展开。2. 整体方案设计一次秒杀请求的完整旅程2.1 组件选型与协作分工先把我这边用的整体组件分工列一下不涉及具体版本号只讲角色分工组件扮演角色核心职责接入网关第一道防线鉴权、风控、接口限流应用服务下单入口业务控制层活动校验、库存预扣、生成订单消息Redis状态与库存中心活动状态、库存原子预扣、用户已购标记消息队列异步通道承接订单创建任务削峰填谷数据库最终数据一致性存储订单表、库存表、用户购买记录定时任务补偿与闭环未支付超时关单、库存回补、对账这些组件没有一个是多余的。网关负责把机器流量和异常请求挡在外面Redis 负责在内存里做高频库存预扣扛住瞬时读写消息队列负责把扣库存成功和建订单成功两个动作解耦数据库只接受可控的写入流量保证最终一致性。2.2 一次成功请求的七个阶段拿一个最经典的流程来说一次成功的秒杀请求会经历以下阶段用户点抢购按钮请求到达网关网关做限流和鉴权用户是否登录、是否命中黑白名单、QPS 是否超限应用服务校验活动状态活动是否开始、是否结束、商品是否可抢在 Redis 里做库存预扣同时写入用户已购标记这一步用 Lua 脚本保证原子性预扣成功将订单创建任务投递到消息队列返回给用户已进入排队或抢购成功的提示消费端异步创建订单记录更新数据库库存更新用户购买状态完成闭环。注意第 5 步和第 6 步之间有先后关系先返回用户结果再投递消息还是先投递消息再返回我在实践中倾向于先投递、后响应。因为一旦你先告诉用户抢到了但消息投递失败或者消费失败用户实际没有订单体验更差。先投递消息并确认发送成功再响应前端配合上失败重试机制可靠性会高很多。当然这会轻微增加响应延迟如果对延迟极其敏感可以改为先响应、后投递但必须要有完善的补偿机制兜底。2.3 同步与异步的边界划分很多新手会问是不是所有操作都要异步当然不是。异步是有代价的——链路变长、排查变难、一致性保障变复杂。我一般按这个原则划分必须同步的活动校验、库存预扣、限流、幂等拦截。这些是准入判断如果不在这里拦住后面异步链路会收到海量非法请求白白浪费资源。可以异步的订单落库、库存最终扣减、后续状态流转、通知推送、积分赠送。这些操作对实时性要求没那么高做晚了不会导致用户感知的重大问题前提是最终能完成。必须要做但放异步的超时关单、库存回补、对账。这些属于兜底动作利用定时任务或者延迟消息来做不需要用户在线等待。这条边界线我建议每个团队在设计之初就画清楚。因为边界一模糊后面代码很大概率会乱——有人觉得库存扣减也可以异步有人觉得订单必须同步建好最后实现出来的系统逻辑不统一出 bug 的时候特别难排查。3. 核心逻辑拆解库存预扣、消息投递与订单落库3.1 为什么库存预扣要用 Lua 脚本先聊库存预扣。在秒杀系统里库存预扣是整个异步下单流程的准入闸门也是最容易出超卖的地方。超卖的本质是多个并发请求同时读到库存为 1然后各自执行扣减最后扣成了负数。要解决这个问题必须保证检查库存是否充足和扣减库存这两个动作是原子的。单条 Redis 命令做不到在应用层用先 get 再 decr也做不到中间一定会存在并发窗口。解决办法就是 Lua 脚本。Redis 执行 Lua 脚本期间不会被其他命令插入天然具备原子性。我们可以把整个检查加扣减逻辑写进脚本-- 参数KEYS[1] 为库存 keyKEYS[2] 为用户已购标记 key -- 参数ARGV[1] 为购买数量ARGV[2] 为用户 id if redis.call(sismember, KEYS[2], ARGV[2]) 1 then return -1 -- 该用户已购直接拦截 end local stock tonumber(redis.call(get, KEYS[1])) if not stock then return -2 -- 库存 key 不存在活动可能未初始化 end if stock tonumber(ARGV[1]) then return -3 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) redis.call(sadd, KEYS[2], ARGV[2]) return 0 -- 预扣成功这段脚本同时完成了三件事去重、检查库存、扣减库存。返回三种错误码方便上层定位问题-1 是重复抢购-2 是活动数据没初始化-3 是库存已抢完。用 Lua 脚本的好处不只是原子性还在于性能。脚本在 Redis 里执行不需要在应用层和 Redis 之间来回多次网络请求一次调用就完成所有判断和扣减。在秒杀场景下省掉的网络往返时间是相当可观的。3.2 预扣成功之后订单消息怎么保证不丢库存预扣成功了接下来就要把创建订单的任务丢进消息队列。这里有一个非常关键的点消息投递不能保证 100% 不丢失但我们必须把丢失概率降到无限接近零。我的做法是同步发送 失败重试 本地消息表。具体来说应用服务在预扣成功之后先在本地的消息表中插入一条待发送的订单消息记录然后尝试同步发送到消息队列队列确认收到后再把本地消息表里的状态更新为已发送如果发送失败状态保持待发送由定时任务扫描并重试直到成功。为什么要加本地消息表因为只靠 MQ 的发送确认机制不够。应用服务投递消息时可能出现网络抖动导致 MQ 侧没收到也可能 MQ 收到了但响应超时应用侧以为自己没发出去。本地消息表是一个很原始但极其可靠的兜底方案把扣库存成功和记录一条待发送消息放进同一个本地事务要么一起成功要么一起回滚从源头避免了扣了库存但没发消息这种严重不一致。等消息真正进入队列消费端还会有消费失败的可能。所以队列的配置上我会强调两点消费失败要重试重试要有上限超过上限进入死信队列。死信队列里的消息由人工或补偿任务接管处理不能让它静静地躺在队列里冒充成功。3.3 消费端幂等与订单去重异步消费最大的坑之一就是重复消费。消息队列的至少一次投递语义决定了消费端理论上会收到重复消息。如果消费端不做幂等一条消息被消费两次就会产生两个相同的订单库存被扣两次账就算不清了。订单去重怎么设计最实用的方案是唯一键 去重表。订单表设计时给用户 ID 商品 ID 秒杀场次 ID建唯一索引。消费端收到消息后先尝试插入订单记录如果插入成功说明这条消息是第一次被处理如果插入时撞了唯一约束说明之前已经处理过了直接返回成功不再做任何业务处理这就是幂等。-- 秒杀订单表示例 CREATE TABLE seckill_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, session_id BIGINT NOT NULL COMMENT 秒杀场次, status TINYINT NOT NULL COMMENT 订单状态, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_user_sku_session (user_id, sku_id, session_id) );注意这里的关键设计唯一约束建在用户 商品 场次三个字段上而不是订单号上。订单号每次生成的都不一样靠它做去重等于没做。只有业务维度上的唯一性才能真正拦截重复。更稳妥的做法是额外建一张去重表记录所有已消费的消息 ID。消费消息时先查去重表如果消息 ID 已存在直接跳过否则处理业务再插入去重记录。这张表的插入操作跟业务操作放在同一个本地事务里保证不会出现业务执行了但去重记录没插入的漏洞。3.4 最终一致性订单状态与库存回补异步下单做得再多最终都要面对一个现实不是所有预扣成功的用户都会真正支付。用户抢到之后可能反悔、可能支付超时这些库存必须还回去否则下一次活动库存就不准了。我的方案是这样订单创建成功之后状态是待支付,系统给用户一个支付时间窗口我一般设 15 分钟也可以按业务调整。定时任务每隔一段时间扫描一次秒杀订单表找出创建时间超过 15 分钟且状态仍为待支付的订单执行关单操作将订单状态改成已取消同时把库存回补到 Redis 和数据库。这里有个细节要注意关单和回补之间也要保证一致性。如果订单取消了但库存没回补会造成库存泄漏如果库存回补了但订单没取消用户付款后发现订单没了更严重。我的做法是同一个事务里先改订单状态再回补数据库库存Redis 库存通过延迟双删或者重新加载的方式来同步尽量不把 Redis 回补放在核心事务里。另外还要处理一个边界用户刚好在超时边缘发起支付。定时任务准备关单用户支付回调也到了两边同时操作怎么保证不冲突实务上我会对订单行加乐观锁用当前状态是否为待支付作为更新条件谁先更新成功谁生效。关单成功的话支付回调会看到订单已关闭引导用户走退款流程支付成功的话关单任务会更新失败库存也不会误回补。这种边界看起来很细小一旦不做线上就会出库存对了但订单关了或者订单支付了但库存没扣的乱账。4. 实操落地关键代码、参数与排查技巧4.1 下单入口的核心流程代码一个简化但完整表达逻辑的 Java 伪代码如下public Result seckill(Long userId, Long skuId, Long sessionId) { // 1. 活动校验时间窗、状态 SeckillActivity activity activityService.getById(sessionId); if (activity null || activity.getStatus() ! ActivityStatus.ONGOING.getCode()) { return Result.fail(活动未开始或已结束); } // 2. 用户限购校验比如每个用户限购1件 if (seckillOrderMapper.checkUserBought(userId, skuId, sessionId) 0) { return Result.fail(您已参与过该活动); } // 3. Redis Lua 库存预扣 Long result stockService.deductStock(skuId, userId, 1); if (result ! 0L) { return Result.fail(手慢了商品已抢完); } try { // 4. 生成订单号 插入本地消息表 String orderNo orderIdGenerator.generate(userId, skuId, sessionId); localMessageService.savePendingMessage(orderNo, userId, skuId, sessionId); // 5. 投递消息到 MQ mqProducer.sendOrderMessage(orderNo, userId, skuId, sessionId); localMessageService.markSent(orderNo); } catch (Exception e) { // 发送失败不阻塞主线程由定时任务补偿发送 log.error(send order message failed, orderNo{}, orderNo, e); } return Result.success(抢购成功正在生成订单); }这段代码有几个点值得展开讲第一活动校验必须先于库存预扣。如果活动没开始或已结束根本没必要去碰 Redis白浪费集群资源。第二用户限购校验在应用层做一层Redis 的 Lua 脚本里也做了一层。应用层查的是数据库表能看到用户历史购买记录Redis 脚本里查的是本次活动的已购标记。两层校验互相补充数据源不同拦截的维度也不同。第三本地消息表跟 MQ 投递的关系前面已经讲了。这里强调一下sendOrderMessage 抛异常时我选择 catch 住不让它影响主线程。因为消息表已经有记录了定时任务会兜底重发。如果在这里把异常抛给上层用户会看到一个本不该出现的失败页面——明明库存都扣成功了。4.2 消费端处理订单的核心代码消费端伪代码如下public void handleOrderMessage(OrderMessage message) { // 本地事务创建订单 更新库存 插入去重记录 Transactional public void process(OrderMessage message) { if (deduplicateService.existed(message.getMessageId())) { return; // 幂等返回 } // 创建订单记录 SeckillOrder order buildOrder(message); seckillOrderMapper.insert(order); // 数据库库存扣减使用条件更新防止超卖 int rows stockMapper.deductStockWithCondition( message.getSkuId(), message.getSessionId(), 1); if (rows 0) { throw new RuntimeException(库存扣减失败触发重试); } // 记录去重表 deduplicateService.record(message.getMessageId()); } }这里有个非常容易忽略但很重要的地方数据库库存扣减必须用条件更新。UPDATE seckill_stock SET stock stock - 1 WHERE sku_id ? AND session_id ? AND stock 0;为什么不能简单写成 stock stock - 1因为如果 Redis 预扣没拦住比如 Lua 脚本有 bug 或者 Redis 里的库存跟数据库库存不一致数据库扣减就可能把库存扣成负数。加上 stock 0 这个条件保证只在有货的时候才扣一次最多扣到 0从数据库层面再给超卖上一道保险。返回受影响行数为 0 时说明数据库里已经没货了代码里直接抛异常触发消息重试——但这种异常其实不应该出现因为 Redis 预扣已经拦过了。万一出现了说明 Redis 和数据库数据不一致这恰恰是我们要在监控里重点盯的指标。4.3 定时补偿任务的配置参考补偿任务看似简单其实参数配置很讲究。我提供一个相对稳妥的参考扫描频率每 30 秒一次避免频繁扫表给数据库带来压力时间窗口扫描创建时间超过 15 分钟且状态为待支付的订单每次批量大小建议 500 条避免单次事务过大、锁时间过长重试上限关单操作失败后标志位加 1超过 5 次进入人工处理队列补偿线程数单独用一个线程池不要跟订单消费线程池共用。还有一点很容易被忽略定时任务扫表的时间段要错峰。我之前踩过坑把定时任务跟数据统计任务放在同一分钟跑结果两个大查询互相争抢数据库资源整库 CPU 报警。后来把任务调度改成分散时间点执行问题立刻消失。4.4 排查链路设计一张订单从成功到异常怎么查异步系统最让运维头疼的就是排查问题。用户说我抢到了但没有订单你从入口查到出口链路那么长很难定位在哪一环断了。所以从设计一开始就要建立完整的链路追踪。我的做法是在请求入口生成一个全局的 traceId在应用日志、MQ 消息体、消费日志里都带上这个 traceId。用户反馈问题时只要拿到当时的 traceId就能一次性把整条链路串起来看。如果没有这个机制碰到问题只能靠猜效率极低。另外我会把关键状态变更都落一个流水表。比如预扣成功消息已投递消息已消费订单已创建订单已支付订单已取消这些动作每一条变更都记录在案。排查问题时流水表配合 traceId基本能把整个生命周期还原出来。这个流水表在后续做对账、做统计、做运维分析时也都是现成的数据资产。5. 实际踩过的坑与排查实录5.1 超卖问题Redis 和数据库库存不一致我遇到过一个典型的超卖事故。某个大型活动上线后数据库库存扣成了负数系统却还在放单。查下来根因是Redis 预扣修改了库存但数据库扣减在某个时间点批量失败消息重试时又因为已存在的订单触发了幂等拦截导致数据库库存永远没有真正扣减成功而 Redis 侧已经卖完关闭入口了。两边数据不一致后续活动数据全是错的。这个问题的教训是Redis 预扣和数据库扣减必须有一个可对账的机制。我后来加了一个每小时的定时对账任务比较 Redis 库存和数据库库存的差值一旦超过阈值就直接告警。同时数据库扣减失败的消息不能只靠 MQ 重试还要在本地消息表里留一个待扣减的状态由补偿任务扫描处理确保数据库扣减最终一定成功。5.2 用户重复下单的漏网之鱼早期版本只在 Redis 层做了用户已购标记没有在订单表建唯一约束。结果有次 Redis 集群发生超时部分预扣请求走了降级逻辑绕过了 Lua 脚本直接放行。那一次活动里有极少数用户下出了两笔订单。事后我把订单表的用户 商品 场次唯一索引补上又从应用层加了一道基于数据库的限购查询。三层防护之后这个口子基本被堵死了。重复下单这种事单靠一层防护永远是不够的必须应用层 Redis 数据库三层都设卡点才能把概率压到可以忽略的程度。这里也要提醒一句降级逻辑不是不能有但降级路径一定要经过评估。很多事故都是因为降级方案没想清楚把本该拦截的流量放了进来。降级应该是减少核心链路的风险而不是绕过核心链路的规则。5.3 消息积压秒杀瞬间的消费瓶颈秒杀活动开抢那一瞬间消息队列里会涌入大量订单创建消息。如果消费线程池太小或者消费逻辑里有慢 SQL队列积压就会迅速上涨用户看到抢购成功但订单迟迟不生成体验非常差。我一般这样处理消费端按商品拆分队列或者按消息分区消费让不同商品的订单可以并行处理消费线程池单独调优核心线程数和最大线程数根据压测结果定不要盲目开大消费逻辑里禁止做耗时的外部调用比如远程接口、批量 RPC这些一律拆出去异步执行。另外一个容易被忽略的点是消费端的数据库连接池配置。如果订单消费并发上来了但数据库连接池只有十几个连接消费线程大部分时间都在等待连接队列积压照样下不去。压测时一定要把数据库连接池、线程池、队列积压三者放在一起看。5.4 定时任务关单与用户支付的竞态这个坑前面已经提过。第一次上线时定时任务关闭超时订单、回补库存跟用户刚好在超时边缘支付成功的情况冲突导致出现过用户已支付但订单被取消的客诉。后来引入订单状态乐观锁才把这个竞态彻底解决。我再强调一次凡是涉及订单状态流转的操作都要带上当前状态作为更新条件这是状态机设计的基本功。比如更新订单状态的 SQL 可以写成UPDATE seckill_order SET status 已取消 WHERE id ? AND status 待支付;先判断当前状态是否还是待支付再执行更新。如果更新行数为 0说明订单状态已经被别人改过了当前操作直接放弃。这种写法在支付回调、关单、退款等场景里全部通用能省掉大量的锁竞争和逻辑判断。5.5 压测时最容易忽视的三分钟做秒杀系统的人上线前多少都会压测。但我见过好多人压测只压接口的 QPS忽略了三个关键点压 Redis 预扣的并发极限而不是应用接口的 QPS压消息队列的积压恢复能力先灌几十万条消息再消费看消费端能不能扛住压定时任务的稳定性连续跑几轮看有没有资源争抢和死锁。这三项不压上线后出问题的概率非常高。我自己的经验是跑一遍全链路压测 故障演练,比如人为让某个 MQ 节点宕机观察系统是否还能自愈比空谈架构设计有用得多。压测还有一个细节压测数据和生产数据要隔离。我用过单独的活动场次来做全链路压测压完后把 Redis 里的测试数据、数据库里的测试订单全部清干净。如果没有隔离压测垃圾数据混进生产环境后面排查问题会非常痛苦。6. 最后一点体会做秒杀异步下单这套东西我个人最大的感受是技术上没有什么玄学难点全在细节。库存预扣用什么脚本、消息投递怎么确认、消费端怎么幂等、关单回补怎么保证一致、链路怎么追踪这些每一条单独拿出来都不难理解但全部串在一起、还要在高并发下稳定运行就需要一套完整的逻辑和严格的边界划分。如果只让我留下一条建议那就是先把业务状态流转图画清楚再写代码。从用户请求到订单终态把每一个状态、每一条转移路径、每一个失败分支都列出来画图的时候就能发现很多隐藏的边界问题。画图的过程往往比写代码的时间更值钱。另外异步系统的线上排查永远是靠 traceId、流水、日志和对账任务说话。趁早把链路追踪和流水记录建起来后面会省下非常多的时间。不要等到出了事故再去补那时候的代价往往是几倍甚至几十倍的加班。希望这篇梳理能帮你把秒杀异步下单的逻辑理清楚。这套方案的每一环都可以继续深挖比如库存扣减的热 Key 治理、消息队列的延迟队列用法、订单分库分表后的幂等方案都值得单独写一篇。后面我再找时间把这些专题陆续补上也欢迎同行随时交流。