一、背景
秒杀项目,很多人都有做过大大小小,有单体秒杀,微服务秒杀。
从古至今,秒杀项目在面试中还是会被问到,
- 如果面试官做过秒杀项目,那么会问的深,如果面试官没做过,那可能面的浅一些。这里指的是微服务的秒杀;
- 如果是单体秒杀,可以在基础上消化掉就可以。通过本地事务解决部分失败的问题,因为事务有acid;
- 分布式事务是没有acid的,如何解决部分失败的问题,这个是解决不了的
- “但分布式事务没有ACID护身符,‘创建订单’和‘扣库存’在不同服务里,一个成功另一个失败的场景是必然存在且无法根除的。我们的设计目标不是追求100%完美,而是保证不超卖,并尽量减少不一致。”
二、高并发秒杀会出现哪些问题?
作为后端工程师,我们不仅要让系统“跑得快”,更要让系统在极限压力下“跑得稳”、“算得准”。
秒杀业务,本质上是一个“读多写少”的极端场景。其核心挑战可以归结为三点:
- 部分失败问题:数十万甚至数百万用户在同一时刻涌入,系统部分组件可能因负载过高而失败,比如请求(查询商品详情)和写请求(下单)的冲击。
- 超卖问题:有限库存成为共享临界资源,大量并发请求同时扣减库存,极易引发“超卖”(库存减为负数)这一致命问题。
- 数据一致性问题:在极限压力下,如何保证核心链路(下单、支付)的高可用,同时确保数据(特别是库存)的最终一致性,是架构设计的重中之重。
传统的单体架构或简单的微服务架构,在如此压力下,数据库会迅速成为瓶颈,导致连接池耗尽、CPU飙高,最终整个系统雪崩。
三、秒杀系统架构
先看看秒杀一般都架构设计

微服务秒杀架构设计一般是:
- 第一层:Redis预扣库存
- 快速判断库存,拦截绝大部分无效请求。
- 第二层:Kafka异步削峰
- 把通过的请求丢到消息队列,平滑流量,避免打垮下游服务。
- 第三层:下单与最终库存锁定
- 消费Kafka消息,异步完成:创建订单 → 订单入库 → MySQL锁定库存。
四、 高并发秒杀有哪些挑战?
作为后端工程师,我们不仅要让系统“跑得快”,更要让系统在极限压力下“跑得稳”、“算得准”。
秒杀业务,本质上是一个“读多写少”的极端场景。其核心挑战可以归结为三点:
- 部分失败问题:数十万甚至数百万用户在同一时刻涌入,系统部分组件可能因负载过高而失败,比如请求(查询商品详情)和写请求(下单)的冲击。
- 超卖问题:有限库存成为共享临界资源,大量并发请求同时扣减库存,极易引发“超卖”(库存减为负数)这一致命问题。
- 数据一致性问题:在极限压力下,如何保证核心链路(下单、支付)的高可用,同时确保数据(特别是库存)的最终一致性,是架构设计的重中之重。
五、秒杀有哪些问题?
5.1 从正向流程出发遇到哪些问题?
阶段1:页面加载阶段
- 静态资源加载压力:CDN带宽被打满、静态资源服务器过载
- 动态数据查询压力:商品详情接口被刷爆、用户信息查询缓存击穿
- 时间同步问题:客户端与服务器时间不一致、倒计时提前或延迟
阶段2:秒杀请求阶段
- 网关层瓶颈:限流策略过于激进,误杀真实用户、鉴权服务成为性能瓶颈
- 库存热点竞争:Redis集群中单个库存key成为热点、网络带宽在单个Redis节点打满
- 重复请求问题:用户连续点击产生重复请求、网络超时重试导致重复扣减
阶段3:订单创建阶段
- 消息队列问题:Kafka分区不均导致消费倾斜、消息积压,订单创建延迟
- 数据库写入竞争:读取mysql超时、唯一索引冲突处理、数据库连接池耗尽
阶段4:支付阶段
- 支付渠道瓶颈:支付超时,支付失败
5.2 从逆向流程出发遇到哪些问题?
“
从逆向流程思考:用户秒杀失败阶段(没进秒杀页面,打开秒杀页面结束,支付失败,超时取消)
阶段一:没进秒杀页面
- 活动开始前服务器已宕机
- DNS解析失败或CDN故障
- 网络抖动导致连接超时
阶段二:打开秒杀页面结束
- 页面加载缓慢,等打开时已售罄
- 倒计时显示异常,错过秒杀时机
- 页面元素加载不全,无法点击
阶段三:秒杀操作失败
- 库存瞬间售罄:真实的高并发场景
- 防刷策略误判:正常用户被识别为机器人
- 服务熔断:依赖服务不可用导致主服务不可用
- 集群负载不均:部分服务器压力过大
阶段四:支付失败
- 余额不足:用户账户资金不足
- 支付超时:第三方支付接口响应慢
- 支付风控:交易被风控系统拦截
- 网络中断:支付过程中网络断开
- 支付超时后库存未能及时释放
- 库存回补时出现数据竞争
- 回补数量不准确导致超卖
阶段五:订单取消/超时
- 用户主动取消:改变主意或误操作
- 系统超时取消:未在规定时间内支付
- 风控取消:系统检测到异常行为
六、秒杀问题解决
从用请求秒杀到成功到流程,可以概括为几个大类问题:
- 系统设计问题
- redis问题
- 队列问题(Kafka)
- 数据库问题(MySQL)
6.1 系统设计问题
你的秒杀是如何设计的?
- redis预扣库存
- Kafka发送(削峰)
- 消费者创建订单
- 订单数据入库
- 锁定库存
消费者,先创建订单还是锁定库存?
- Mq消费者,先创建订单还是锁定库存。答案是都可以
- 这里取决于消息体有没有带唯一凭证
- 如果是有带,先创建订单和库存都可以
- 如果没有带,就先创建订单,通过订单id或者唯一sn码去锁定库存
用户怎么知道秒杀结果?
“因为后端是异步的,所以前端不能直接同步等待结果。”
- 主流方案:
- 用key查询,秒杀服务提供订单号/sn去轮训订单号
- 秒杀服务预生成的订单号
- 用户参加秒杀活动的uid_m—本质也是key
- 轮询:前端每隔一秒问一下服务端“我这个请求成功没?”,用参与活动的Key或预生成的订单号来查。
- WebSocket推送:服务端处理完后,主动推送给前端。
- 不用担心并发:需要轮询或长连接的都是秒杀成功的用户,这个数量远小于参与秒杀的总人数,服务端撑得住。
轮询的话,那么多秒杀用户,撑得住并发吗?
参加的秒杀的人虽然多,但是秒杀成功的不多
因为需要轮询的并发压力,与秒杀开始的瞬时并发,完全不在一个数量级上。
websocket通知的话,那么多秒杀用户,撑得住吗?
参加的秒杀的人虽然多,但是秒杀成功的不多
支付失败怎么办?
“
核心答案: 支付失败(包括用户主动取消、余额不足、支付渠道问题等)后,核心操作是 “释放锁定的库存”,并将订单状态置为“已关闭”,最终结果对用户而言就是 “秒杀失败”。
处理流程:
- 支付回调:支付平台(如微信、支付宝)会异步通知你的系统支付失败。
- 业务处理:
- Redis库存:执行
INCR命令,将预扣的库存加回去。 - MySQL库存:将之前锁定的库存释放(
locked_stock - 1),恢复可用库存(available_stock + 1)。
- 释放库存:
- 更新订单状态:将订单状态更新为 “已关闭” 或 “支付失败”。
- Redis库存:执行
- 用户侧:前端页面提示用户“支付失败”,本次秒杀流程结束。
“
关键点:支付失败后,商品必须重新放回库存池,让其他等待的用户有机会购买。这是一个标准的库存回补流程。
超时未支付怎么办?
“
超时未支付更加麻烦一点,因为超时可能是 10分钟,而你的秒杀活动 3分钟就结束了你还不还 库存都没意义了
对于超时未支付:“这是一个更复杂的问题,它揭示了秒杀场景的一个固有矛盾。我们的解决方案是分层的:
- 首先,我们会设置一个与活动周期匹配的、较短的支付超时时间(如5分钟)。
- 其次,最优解是建立‘候补队列’机制。 当超时订单释放库存后,立即通知候补用户,最大化库存价值和用户体验。
- 最后,如果业务允许,最简方案是接受‘少卖’,即不处理或延迟处理回补,这对于保证系统简单性和稳定性来说,也是一个合理的权衡。”
你的秒杀怎么保证每个人只能参与一次?
- 如何防超卖与一人一次?
- Key 的格式为:
seckill_user:{activity_id}:{user_id} - 例如:用户 12345 参与活动 888,对应的 Key 就是
seckill_user:888:12345。
- 防重Key的设计:
- 使用 Lua脚本 原子性执行
用户防重(set NX) + 扣库存(DECR)。
- Key 的格式为:
- Redis与MySQL数据不一致?
- 不强求一致。底线是MySQL最终扣减保证不超卖,接受因部分失败导致的“少卖”。
- Redis崩了?
- 降级:绕过Redis,直接写Kafka,或前端加随机数限流。
我在秒杀里面,你能不能保证,商品全部、不多不少刚好卖出去?
如果你源源不断一直有人参加秒杀,那就可以做到,不然就做不到
在技术层面,我们无法100%保证商品刚好全部卖完。我们的核心架构目标是 第一,绝不超卖;第二,尽可能减少少卖。
如果我的并发超过10w,你这个架构还能用吗?
潜台词:redis撑不住
- 可以,在前端引入随机数
- 限流,超过5w的,都认为秒杀失败
- 前端来一个随机数,直接拒绝
- 直接到Kafka,不用redis
直接转Kafka,MySQL撑得住吗?
- 完全撑得住,mysql的压力和消费速率有关
- 和抢购并发无关
直接转Kafka,MySQL能撑住吗?能! MySQL压力只与消息消费速率有关,与秒杀瞬时并发无关
Redis分key方案有什么缺点?一定要解决怎么解决?
命中率降低,库存不均的问题,有些先抢完了,有些没抢完不需要解决的,因为后面只要有人参加,最终都会抢完
key0…key9
随机一个起点,keyN,如果 keyN 没有,就到 keyN+1.最多轮询 3个
stealing,keyN 没了,从 keyX 里面拿走一半
Redis怎么分key?怎么做到?
1. 客户端轮询方案
- 方案描述:
- 将库存分为 N 份,对应
key0, key1, ..., key9。 - 用户请求时,随机选择一个
keyN作为起点。 - 尝试对该
key进行扣减。 - 如果
keyN已售罄,则顺序查询keyN+1, keyN+2, ...,最多轮询 3 个。
- 将库存分为 N 份,对应
- 优点:实现相对简单,能有效缓解“一Key售罄即失败”的问题。
- 缺点:
- 增加了请求延迟(最多可能需要3次尝试)。
- 仍然无法彻底解决不均,可能出现用户轮询3个都失败但总库存还有的情况。
2. 服务端代理与动态路由方案
- 方案描述:
- 服务端维护一个全局的库存可用位图(BitMap) 或一个简化的库存状态列表(记录哪些Key还有库存)。
- 用户请求到达时,服务端首先查询这个位图,快速为用户分配一个**肯定有库存的
key**。 - 用户直接去扣减这个被分配到的
key。
- 优点:用户体验好,几乎不会遇到“有库存却失败”的情况。
3. 库存窃取方案
- 方案描述:这是对轮询方案的增强,也是更优雅的解决方案。
- 用户首先尝试扣减随机选定的
keyN。 - **如果
keyN已售罄,不是简单地轮询下一个,而是从一个事先指定的、库存可能较多的“备用Key”(如keyX)中,“窃取”一部分库存(例如一半)到keyN**。 - 然后用户再次尝试扣减
keyN。
- 用户首先尝试扣减随机选定的
6.2 Redis相关问题
Redis里面的库存和MySQL里面的库存,什么情况会不一致?
- MQ发送失败
- 锁定库存失败
- redis预扣库存超时,但是事实上成功了
Redis和MySQL如何保证一致性呢?
- 没有必要保证一致性,也做不到一致性,只能是尽可能减少
- MQ发送失败,把库存还回去(不可行,这个过程会失败也会超时)
- MySQL的库存同步过去Redis(能够缓解问题,尽可能保证我东西都卖出去)
- 当我redis扣减到0的时候,再次尝试同步MySQL中的数据,不会产生超卖
Redis怎么扣库存?使用DECR扣库存有什么问题?
1.最简单的做法,redis的decr命令,decr之后返回的库存数量>=0就是秒杀成功
使用decr扣库存有什么问题
不做幂等谁没有必要,非幂等操作,在超时情况下也无法重试,只能返回失败
2.用lua脚本(GET,检查,扣库存)+内嵌一个幂等操作
3.代码get出来,用lua脚本cas操作
将mysql的库存同步过去redis
(1)从mysql读出数据写redis也会失败
(2)Redis-kafka-mysql(能够缓解问题,尽可能保证我东西都卖出去)
(3)当我redis扣减库存0的时候,再次尝试同步mysql数据
Redis预扣库存之后一定要接Kafka吗?
- 必须接Kafka:当秒杀商品数量大(如数千到数万),且预期并发远超下游MySQL处理能力时,Kafka是必须的,它扮演了“泄洪区”和“异步控制器” 的角色。
- 可以不用Kafka:当秒杀商品数量少(如几百个),且并发量在MySQL可承受范围内时,可以去掉Kafka,简化架构。
Redis崩了怎么办?
- 方式一:双redis集群
- 而且只有一起扣才能成功
- 数据不一致,很费钱
- 方式二: 绕过Redis,直写Kafka,去掉预扣库存的操作
- 我直接一个随机数N(0,100),拒绝大部分请求,而后转Kafka
redis如何分key
“
确保不同的key,落到不同的redis cluster集群上
手动抓几个key,然后计算crc16得到槽,再根据映射关系得到目标节点
如果目标节点重复,就还一个key
6.3 Kafka问题
Kafka发送失败怎么办?
- Kafka崩了
- mq发送失败,把库存还回去(也不可行,因为也会失败,比如超时)
消费失败,重试也失败,返回秒杀失败
发送消息成功,但是消费失败了怎么办?
当发送到Kafka成功,但消费者处理失败时,标准的处理模式是:重试机制 -> 死信队列 -> 最终处理。
Kafka崩了怎么办?
- mq发送失败,把库存还回去(也不可行,因为也会失败,比如超时)
- 消费失败,重试也失败,返回秒杀失败
消息存在Kafka里面,然后MySQL恢复了再说
6.4 MySQL问题
数据库锁库存不够怎么办?
- 核心答案:直接、明确地提示用户“秒杀失败”。
- 原因与逻辑:
- 最终防线:数据库层(通过
UPDATE stock SET stock = stock - 1 WHERE stock > 0)是防止超卖的最终且最可靠的一道防线。 - 正常业务逻辑:这意味着该商品在当前时刻确实已经售罄。虽然Redis预扣库存和Kafka排队制造了“机会”,但MySQL的最终确认才是“结果”。
- 用户体验:虽然用户会失望,但这是真实、准确的业务反馈。
- 最终防线:数据库层(通过
“
面试点睛:强调这里是保证“不超卖”的终极手段,任何到达这里并发现库存不足的请求,都是正常业务结果,而非系统错误。
6.5 其他问题
redis规格
- 架构:Redis Cluster(集群模式),用于实现高可用与横向扩展。
- 节点:5个节点。
- 单节点配置:32核CPU,128GB内存。
- 性能估算:参考阿里云等厂商的开源版基准测试,此类配置大致能提供约 10万 QPS/TPS(具体数值取决于命令类型和使用模式)。
mysql的规格
- 配置:32核CPU,64GB内存,2TB SSD硬盘。
- 性能估算:参考云数据库基准,此类配置的写并发能力大约在 1.4万 TPS 左右。SSD硬盘保证了极高的IOPS,是应对高并发写的关键。
Kafka规格
- 集群:7个Broker(节点)。
- 单Broker配置:16核CPU,64GB内存。
- Topic分区策略:设置7个Partition,与Broker数量一致,以实现均匀的负载分布。
- 性能估算:
- 对于约 500B 大小的消息,单个Partition大约能支撑 5万 TPS。
- 整个Topic的理论吞吐约为 40万 TPS(7 partitions * ~5w TPS,需考虑网络和复制开销)。
- 消息体越小,TPS越高。
你部署了几个节点
- 节点数量:部署 9个节点(遵循分布式系统常用的奇数原则,如3, 5, 7, 9,便于共识算法决策和管理)。
- 单节点配置:8核CPU,16GB内存。
- 设计思路:通过多节点实现业务层的无状态水平扩展,通过负载均衡将海量请求分散开来。
秒杀其实就是抽奖
- 深刻理解:这句话道破了秒杀业务的本质。
- 从用户体验看,在百万人争抢少量商品的瞬间,成功与否的随机性极大,技术体验上接近于抽奖。
- 从技术架构看,我们设计的整个系统——从前端随机数、到Redis预检、再到Kafka排队——其核心目的就是在做一件事:通过技术手段,将无法承受的瞬时洪峰,变成一个可控的、公平的(或感觉公平的)“抽签”过程。
- 架构目标:因此,架构师的首要目标不是保证每个用户都能买到,而是保证这个“抽奖”系统在极限压力下 “不崩溃、不作弊、算得准”
参考文献:https://www.bilibili.com/video/BV1N4b8zEEm3/?spm_id_from=333.1387.upload.video_card.click
声明:来自程序员千羽,仅代表创作者观点。链接:https://eyangzhen.com/4085.html