资讯动态

秒杀系统异步下单全链路设计:Redis预扣与MQ削峰实战

发布时间:2026/10/13 16:14:34 来源:尧图企业网站定制
1. 项目概述为什么秒杀系统必须走异步下单做过电商业务的开发者十有八九都遇到过秒杀场景。商品数量有限、瞬间涌入大量用户、并发请求动辄上万这时候如果还按照普通的“同步下单、同步扣库存、同步返回结果”链路去做系统会在第一波流量冲击下直接被打垮。我接手过某商城的模拟秒杀项目为避免敏感信息这里用模拟项目X代称一开始用同步方案压测QPS到500左右数据库连接池就爆了订单接口响应时间从几十毫秒飙升到几十秒大量请求超时重试库存数据错乱。后来把下单链路全面改造成异步化压测结果才恢复正常。所谓异步下单核心思路就一句话让用户请求先进入一个稳定可靠的缓冲通道后台再从容地逐步处理最终把结果推给用户。它解决的问题不是“能不能扛住高并发”这一个点而是同时解决了三件事削峰填谷保护下游存储、提升接口响应速度、保证最终库存扣减的正确性。我梳理这份业务逻辑是想把秒杀异步下单这条链路上的每个环节拆开讲清楚包括用户发起请求后到底发生了什么、库存在哪里被真正扣掉、消息队列怎么选、消费者端怎么保证幂等、以及失败订单如何补偿。适合正在搭建秒杀系统的开发者、准备做高并发场景面试复盘的同学以及想做技术方案评审但还没系统梳理过全链路的人看。2. 整体链路设计与核心思路拆解2.1 同步下单为什么扛不住先看传统同步下单长什么样。用户点击“立即秒杀”浏览器发一个POST请求到订单服务订单服务要做的动作包括校验用户登录态、校验秒杀资格、查询商品信息、检查库存是否足够、扣减库存、生成订单号、落订单表、可能还要调用营销服务发放优惠券、最后返回“下单成功”。整个请求期间数据库连接一直被占用任何一步慢一点整个请求就变慢。秒杀流量的特点是指数级瞬发不是缓慢爬坡。某秒杀活动刚上线的前10秒请求量可能占到全天的70%以上。这种流量模型下同步链路有两个致命问题串行瓶颈堆积一个请求从进入系统到返回结果前后要经过4到8次数据库操作。DB的连接数是有限的假设连接池配置是50每个连接处理一个请求需要300毫秒那这个服务理论上每秒最多处理约160个请求。可秒杀瞬时涌入的是每秒几万个请求多出来的全部排队、超时、重试然后重试又加剧拥堵雪崩就是这么来的。故障放大效应同步链路里只要有一个环节抖动比如商品服务响应慢了200毫秒所有请求都会被拖住用户感知就是“页面卡死”“一直转圈”。异步下单的思路恰恰是规避这两点把耗时的核心业务处理从用户请求线程中剥离出去。用户请求进来只做两件最轻的事校验资格、写入一个待处理标记然后立刻返回“已收到请求”。真正的订单创建、库存扣减、支付引导统统放到后台异步执行。2.2 异步下单的完整链路骨架我梳理的异步下单链路分成四段用户端发起请求 → 接入层限流风控 → 缓冲层消息队列 → 消费端异步处理订单 → 结果通知接入层负责接口幂等校验、用户资格预检、限流。这一层要把明显无效的流量挡掉比如重复点击、没有秒杀资格的用户。缓冲层消息队列承担削峰功能。秒杀瞬时流量再大写入MQ的速度一般都能扛住MQ做持久化后流量就变成可控速率地从队列里被消费。消费端真正干活的模块。从MQ拉取消息执行库存预扣、订单落库、状态流转等核心逻辑。结果通知用户不一定要同步拿到结果可以通过轮询订单状态、WebSocket推送或者短信/站内信延迟通知。这个骨架里有一个关键的设计选择用户请求在接入层返回的是什么。很多人误以为异步下单就是接口响应变慢其实恰恰相反异步下单的接口响应会非常快——因为它返回的不是“下单结果”而是“受理结果”。比如接口返回“请求已受理请稍后查看订单状态”用户端再通过订单查询接口去获取最终状态。2.3 为什么是把流量先写入消息队列而不是直接写数据库这是我被问过最多的问题既然最终要扣库存、订单要落库为什么中间非要隔一层MQ直接在接入层用Redis预扣库存然后再异步写订单不行吗答案是可以而且实际架构里Redis预扣本来就是必须做的。但MQ承担的角色不是替代Redis做库存控制而是做数据库写操作的流量整形。Redis虽然吞吐量高但它不是关系型数据库订单数据最终还是要落到MySQL这类存储里。如果几千个并发同时往订单表INSERT即使库存已在Redis层扣好了数据库的写入能力依然是瓶颈。把订单创建消息投递到MQ后消费端可以根据自身能力设置消费速率比如每秒处理500个订单这样数据库每秒最多接收500次写入完全在安全水位内。MQ在这里起到的是**“可变速率器”**的作用把不可预测的高峰流量转换成下游存储能够承受的匀速流量。3. 核心环节详解从请求接入到库存扣减的设计逻辑3.1 接入层用户请求进来后第一步做什么用户点了“立即秒杀”到达后端接口第一步不是直接扣库存而是做三个轻量级前置校验。第一个是登录态和资格校验。用户是否登录、是否在黑名单里、是否已经参与过该场次秒杀。这里我会用一个独立的Redis Key来记录“用户已参与”比如seckill:user:{userId}:{activityId}值为1则表示已参与。这个Key的过期时间设置为活动结束时间既防止重复参与也不会长期占用内存。第二个是接口幂等校验。秒杀页面用户会疯狂点击按钮前端一般会做按钮置灰但防不住多终端同时请求或者网络异常后自动重试。幂等方案我采用的是UUID令牌页面加载时向后端申请一个全局唯一的requestToken提交下单时必须携带Redis里用SETNX写入这个Token写入成功说明是第一次请求反之说明是重复提交直接拒绝。第三个是限流。接入层限流有全局限流和用户级限流两种。全局限流用Redis Lua脚本实现滑动窗口限流防止整体流量超过系统水位用户级限流则限制单个用户每秒最大请求次数比如每秒3次防止脚本刷接口。3.2 前置校验通过后写Redis预扣还是直接发MQ前置校验通过后进入核心链路的第一步库存预扣。这一步是秒杀系统的胜负手必须保证“扣减准确”和“绝不超卖”。我采用Redis Lua脚本来执行预扣。脚本逻辑大致如下-- KEYS[1]: 秒杀商品库存Key -- KEYS[2]: 用户参与标记Key -- ARGV[1]: 用户ID -- ARGV[2]: 活动ID if redis.call(exists, KEYS[2]) 1 then return -1 -- 用户已参与过 end local stock tonumber(redis.call(get, KEYS[1])) if stock nil or stock 1 then return 0 -- 库存不足 end redis.call(decr, KEYS[1]) redis.call(set, KEYS[2], 1, EX, ARGV[3]) return 1 -- 预扣成功这段Lua脚本为什么要用Lua而不是Java/Go代码里先查后减核心原因只有一个原子性。单个Lua脚本在Redis中是原子执行的中间不会被其他客户端的命令插入所以不存在“A查到库存还剩1个B也查到还剩1个然后A扣成0B又把0扣成-1”的竞态问题。如果用代码里“先GET再DECR”的方式两个线程同时读到库存为1各自都执行DECR最终库存变成-1这就是典型的超卖场景。预扣成功之后商品库存已经在Redis层被占用但这不代表订单就一定有——因为后续创建订单可能失败。所以预扣只是“预占”真正的“扣减”要以订单创建成功为准。我遇到不少团队在设计时把Redis预扣等同于库存扣减后面订单失败时没有释放库存导致活动结束后还有大量库存没卖出去系统里却显示“已售罄”。这个坑后面会细说。3.3 消息投递订单消息里应该带什么预扣成功后把“订单创建消息”投递到MQ。这一步要注意消息体不能过大也不能只传一个商品ID就完事。我给出的最小消息体建议是{ requestToken: UUID令牌, userId: 100123, commodityId: SP20240601001, activityId: ACT20240601, seckillPrice: 99.00, quantity: 1, stockPayToken: 库存预扣成功后的唯一凭证, timestamp: 1717200000000 }特别注意stockPayToken这个字段。它是Redis预扣成功时生成的唯一标识比如取requestToken本身。消费者处理消息时只要拿到这个Token就能确认“库存确实已经被这个请求预占了”从而避免重复扣减。有些方案里还会把Token对应的状态写入Redis比如seckill:stockToken:{token}的值是occupied订单落库成功后改成committed失败改成released这相当于给“预扣-确认-释放”做了一个状态机。3.4 消费者端如何保证库存最终一致消费端从MQ拉取到订单创建消息后工作流程是这样的第一步检查消息幂等。消息队列提供的是at-least-once投递语义也就是说消息可能被重复投递。我收到消息后先查requestToken对应的订单是否已存在。可以对订单表的request_token字段建唯一索引这样即使消息重复消费第二次INSERT会因唯一键冲突而失败从数据库层面保证幂等。第二步落订单数据。订单状态初始为“待支付”。这里订单编号不能用数据库自增ID因为自增ID无法在写入前生成且容易被猜出订单量。我使用的是雪花算法生成全局唯一订单号在消费者本地生成后随INSERT一起写入。第三步确认Redis库存状态。检查stockPayToken对应的状态如果是occupied说明一切正常继续往下走如果已经是committed说明这条消息被重复消费了直接ACK丢弃如果是released说明之前有一次订单创建失败了库存已经释放这条消息不应该再创建订单记录日志后ACK。第四步执行数据库库存扣减。这里要说明一个很多文章没讲透的点Redis预扣是“占坑”数据库扣减才是“真正卖出”。为什么不能只依赖Redis因为Redis有丢失数据的风险虽然可以开启AOF持久化而且订单最终要落库MySQL里需要维护一个真实的库存数字用于对账。正确的做法是订单表和库存表在同一个数据库事务里操作UPDATE stock_table SET sold_count sold_count 1 WHERE commodity_id ? AND sold_count stock_total受影响行数为1才说明扣减成功。这里有一个性能争议为什么不直接在Redis预扣后就算下单成功数据库扣减放到异步慢慢做因为如果数据库扣减失败比如库存和Redis数据不一致订单已经建出来了但实际没货会给用户发货环节带来大麻烦。我的原则是订单状态必须由数据库真实库存扣减结果决定。3.5 结果通知用户在哪里看到“秒杀成功”用户请求在接入层已经快速返回但最终是否成功用户需要知道结果。这块有三条路径前端轮询下单后前端每隔2秒调用一次订单查询接口返回状态为“待支付”则提示用户去支付返回“已关单”则提示未中选。实现简单但2秒一次轮询在10万用户同时在线时也有压力所以轮询接口会用Redis缓存订单状态减少DB压力。WebSocket推送服务端订单状态变更后推送给用户端。体验好但实现复杂度高秒杀场景下海量连接对网关压力很大。主动查询延迟补偿订单状态写入Redis并设置过期时间用户端轮询接口优先查Redis超过一定时间Redis没有记录就查数据库兜底。实际项目里我常用第三种变体活动结束后统一做一次“未支付订单批量关单”同时给用户推送结果。比如活动结束后5分钟批量关闭所有未支付订单释放库存给候补用户。这个逻辑和异步下单的“结果通知”是配套的——用户在下单页看到“待支付”后必须在一个有效期内完成支付否则视为放弃。4. 实操过程中的关键参数与配置方案4.1 数据库表结构设计订单表这样建才能撑住高并发订单表是异步下单链路的最终落点设计上有几个关键点。先看简化版表结构CREATE TABLE seckill_order ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 业务订单号雪花算法生成, request_token varchar(64) NOT NULL COMMENT 请求幂等令牌, user_id bigint(20) NOT NULL, commodity_id bigint(20) NOT NULL, activity_id bigint(20) NOT NULL, pay_amount decimal(10,2) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已关闭, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_request_token (request_token), KEY idx_user_id (user_id), KEY idx_commodity_id (commodity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里面有几个容易被忽略的细节request_token建唯一索引这是业务幂等和消息去重的最强后盾。消息重复消费时INSERT会因为唯一键冲突直接失败不用写复杂的判断逻辑。order_no是雪花算法生成的业务订单号不是自增ID。自增ID在秒杀场景里有两个问题高并发下批量INSERT自增ID会成为热点而且自增ID暴露了订单量活动结束后竞品能大概算出你卖出了多少单。订单表本身要分表。单表千万数据后查询性能会明显下降。我建议按user_id做分表键这样同一个用户的订单都在同一张表里查询“我的订单”不需要跨表聚合。4.2 Redis Key设计一次秒杀活动要用哪些KeyKey类型用途过期时间seckill:stock:{activityId}String剩余库存活动结束seckill:user:{userId}:{activityId}String用户是否已参与活动结束seckill:token:{requestToken}String幂等令牌2天seckill:stockToken:{stockPayToken}String库存预扣状态活动结束后1天seckill:order:{userId}:{activityId}String订单状态缓存1天seckill:request:limit:{userId}String用户请求频率计数2秒这些Key有一个共性都要设置过期时间。秒杀活动结束后这些数据如果长期留在Redis里会浪费大量内存尤其是“用户已参与”这类Key如果不清理下次活动复用同一个Key前缀时会出现“新活动用户无法参与”的诡异Bug。我习惯在活动结束后跑一个清理任务把所有秒杀相关Key按前缀扫描删除。4.3 消息队列选型与消费速率配置异步下单的MQ选型社区通常在三者之间选RabbitMQ、Kafka、RocketMQ。对比项RabbitMQKafkaRocketMQ吞吐量中等万级极高百万级高十万级消息可靠性较高高高事务消息需插件不支持原生支持消息轨迹需插件需配置内置运维复杂度低高中秒杀场景我推荐RocketMQ或Kafka。如果是阿里云环境RocketMQ有现成的定时消息和事务消息对“活动结束批量关单”这类延迟任务支持得非常好。自建的话Kafka更常见但要注意Kafka的消费延迟和分区数量规划分区数是消费并行度的上限秒杀峰值如果每秒产生5000条订单消息单分区消费能力大约是每秒2000条那就至少需要3个分区才能消费完。消费端还有一个非常关键但经常被忽略的点消费速率和数据库写入速率的匹配。比如你的订单表MySQL能承受的峰值写入是每秒1000条那消费端应该通过限流工具比如Guava RateLimiter或Sentinel限制消费速率为每秒800条留20%的余量。很多系统出问题就出在消费端全速消费结果把数据库连接池打满反而比同步链路挂得更快。这个教训我是花了两个通宵才彻底明白的。4.4 库存扣减策略对比谁先扣谁后扣库存扣减涉及两个存储系统Redis预扣和MySQL真实扣减两者扣减的先后顺序会影响最终一致性。我对比过几种方案只说最典型的两种方案A先Redis预扣再异步扣MySQL推荐用户请求 → Redis预扣成功 → 发MQ → 返回已受理 消费者 → 收到消息 → 事务内INSERT订单 UPDATE库存表 → 提交优点Redis预扣操作极快Lua脚本微秒级用户请求响应快系统能扛住的瞬时并发最高。缺点如果MySQL扣减失败需要一个补偿任务释放Redis预扣的库存。这就是为什么Redis侧要有“预扣状态机”失败后要能把occupied改成released。方案B先扣MySQL再同步Redis用户请求直接对MySQL库存行做UPDATE成功后再写Redis。这种方式库存绝对准确但因为要等数据库IO吞吐量受限。秒杀场景下不一定撑得住。还有一个“只扣Redis不扣MySQL”的极端方案适合库存数量不大、对账容忍度高的场景但不适合正规电商我直接排除。5. 常见问题与排查实录5.1 消息重复消费导致订单重复现象压测时发现同一用户产生了两条相同商品的订单而且都显示待支付。排查过程我先查订单表里request_token字段是否有唯一索引结果发现开发环境表结构里漏加了。加上唯一索引后问题消失大半。但还有一个隐蔽场景消息重复投递发生在库里订单已经存在之后消费者重复消费时除非每次都对request_token做查询判断否则会直接报唯一键冲突异常。这个异常如果没被捕获会导致消息一直重试最终进入死信队列。解决方式是双保险消息消费入口先查一次request_token是否已存在订单存在直接ACK查不到时再执行INSERT让唯一索引兜底。避坑提示不要在生产环境依赖“先查再插”做幂等因为并发下两个消费者同时查都是空会同时插入成功。唯一索引才是真正可靠的幂等防线。5.2 Redis库存预扣成功但订单落库失败库存“凭空消失”现象活动结束后统计销量MySQL里确认的销售订单数小于Redis预扣成功数库存也不见了。排查过程这是我说的“预扣库存未释放”问题。消费者的订单创建逻辑里如果INSERT订单失败比如数据库事务超时代码没有捕获异常后调用“释放库存Token”的逻辑导致Redis里的库存被预扣了但没有任何订单绑定这个库存库存就蒸发了。解决方式订单落库失败时消费者要把stockPayToken的状态置为released并且记录一条释放日志。同时活动结束后的对账任务里扫描所有occupied状态但超过活动结束时间还未committed的Token统一释放库存。5.3 MQ消息积压订单状态迟迟不更新现象用户已经提交秒杀请求但等了半天订单状态还是“处理中”。排查过程先看MQ的消费速率发现消费端日志显示处理一条消息耗时从50毫秒涨到了2秒。进一步排查发现消费端里每处理一条消息都调用了两次用户服务RPC去查用户信息用户服务此时也有压力导致RPC超时重试消费速度直线下降。解决方式把用户信息预取到Redis缓存消费端直接读缓存而不是调用RPC。同时在生产端对消息做聚合同一条消费线程连续处理多条消息时减少链路中无意义的远程调用。处理完后消费速率恢复到每秒1800条左右积压消息在10分钟内消化完毕。到这里要说一个很实际的经验消息积压不是无解的核心是看你是“长尾积压”还是“流量瞬时限流”。如果是消费者自身逻辑慢优化消费端比扩容更有效如果是生产者瞬时发送量超过消费能力且持续很久才需要扩容分区或增加消费者实例。5.4 数据库主从延迟导致用户查不到订单现象用户提交秒杀后立即轮询订单接口提示“未找到订单”。明明Redis里显示库存已经扣了。排查过程订单接口走的是MySQL从库而消费端写入的是主库。秒杀场景下主库刚INSERT完一条订单从库同步存在延迟正常时1秒内但高峰时可能到3秒用户这3秒内查询从库自然查不到。解决方式订单写入后立即把“订单已生成”标记写入Redis轮询接口优先查Redis。Redis里有这个标记就返回“正在处理中”而不是“未找到订单”让用户继续等待或者去支付。同时轮询接口如果Redis里没有标记需要去主库兜底查一次避免从库延迟导致误判。5.5 消费者重复消费且消费速度被拖垮的排查工具给一个排查抗高并发链路问题的常用命令集合# 查看消费组堆积情况 kafka-consumer-groups.sh --bootstrap-server xxx:9092 --describe --group seckill-order-group # 查看Redis当前瞬时请求量 redis-cli --stat # 压测时查看MySQL当前连接数和慢查询 mysql -h xxx -e show processlist; mysql -h xxx -e show global status like Slow_queries;有一次我压测时发现MySQL的Threads_connected从正常30飙升到800用show processlist看出大量INSERT订单语句处于Waiting for table metadata lock状态定位到一个消费端实例里有个长时间未提交的事务占用了表锁。杀掉那个线程后连接数恢复正常。6. 我踩坑后的几点实操心得做秒杀异步下单这个模块前后改了四版设计最大的心得是异步化只是手段一致性才是目的。很多人把异步下单简单理解为“用MQ把流量缓冲掉”但真正难的是缓冲之后怎么让Redis、MQ、MySQL三个系统之间的状态保持一致。我整理几条直接能用上的经验第一Redis预扣的“状态机”一定要设计清楚。occupied已占用、committed已确认、released已释放三个状态缺一不可。每个状态变更都要有对应的日志否则出问题的时候你根本不知道库存去了哪里。第二消费者的幂等判断不能只靠代码逻辑。Redis或数据库的唯一约束至少要有一样双保险更稳。我在生产环境见过太多“代码里判断了但因为并发时机不对还是重复插入”的案例。第三消息体不要带大字段。比如不下放用户的收货地址、商品详情等信息到消息里消费者需要时再查。消息体越小MQ的吞吐越高序列化开销越小积压风险越低。第四库存扣减必须用UPDATE语句的条件判断不能用“先查后改”。UPDATE stock SET sold_count sold_count 1 WHERE id ? AND sold_count total这一步是数据库层的最后防线。第五压测不能只测单环节。我见过团队把Redis预扣压测到每秒5万觉得很满意结果MySQL订单写入每秒只能处理500瞬间积压。全链路压测时要把生产端、MQ、消费端、DB、限流策略全部打通观察瓶颈出现在哪一层。秒杀异步下单这个业务逻辑看起来只是一个“请求进MQ再消费”的简单流程真正落地时涉及分布式事务、幂等设计、消息可靠性、数据库性能优化等多个领域的知识。如果你正在做类似的系统建议把职责边界画清楚接入层只负责挡流量、MQ只负责削峰、消费端只负责保证“要么都成功要么都失败”的状态一致。边界清晰了问题就好定位得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑