双11大促秒杀系统必知必会

一、背景

秒杀项目,很多人都有做过大大小小,有单体秒杀,微服务秒杀。

从古至今,秒杀项目在面试中还是会被问到,

  • 如果面试官做过秒杀项目,那么会问的深,如果面试官没做过,那可能面的浅一些。这里指的是微服务的秒杀;
  • 如果是单体秒杀,可以在基础上消化掉就可以。通过本地事务解决部分失败的问题,因为事务有acid;
  • 分布式事务是没有acid的,如何解决部分失败的问题,这个是解决不了的
  • “但分布式事务没有ACID护身符,‘创建订单’和‘扣库存’在不同服务里,一个成功另一个失败的场景是必然存在且无法根除的。我们的设计目标不是追求100%完美,而是保证不超卖,并尽量减少不一致。”

二、高并发秒杀会出现哪些问题?

作为后端工程师,我们不仅要让系统“跑得快”,更要让系统在极限压力下“跑得稳”、“算得准”。

秒杀业务,本质上是一个“读多写少”的极端场景。其核心挑战可以归结为三点:

  1. 部分失败问题:数十万甚至数百万用户在同一时刻涌入,系统部分组件可能因负载过高而失败,比如请求(查询商品详情)和写请求(下单)的冲击。
  2. 超卖问题:有限库存成为共享临界资源,大量并发请求同时扣减库存,极易引发“超卖”(库存减为负数)这一致命问题。
  3. 数据一致性问题:在极限压力下,如何保证核心链路(下单、支付)的高可用,同时确保数据(特别是库存)的最终一致性,是架构设计的重中之重。

传统的单体架构或简单的微服务架构,在如此压力下,数据库会迅速成为瓶颈,导致连接池耗尽、CPU飙高,最终整个系统雪崩。

三、秒杀系统架构

先看看秒杀一般都架构设计

图片

微服务秒杀架构设计一般是:

  1. 第一层:Redis预扣库存
    • 快速判断库存,拦截绝大部分无效请求。
  2. 第二层:Kafka异步削峰
    • 把通过的请求丢到消息队列,平滑流量,避免打垮下游服务。
  3. 第三层:下单与最终库存锁定
    • 消费Kafka消息,异步完成:创建订单 → 订单入库 → MySQL锁定库存。

四、 高并发秒杀有哪些挑战?

作为后端工程师,我们不仅要让系统“跑得快”,更要让系统在极限压力下“跑得稳”、“算得准”。

秒杀业务,本质上是一个“读多写少”的极端场景。其核心挑战可以归结为三点:

  1. 部分失败问题:数十万甚至数百万用户在同一时刻涌入,系统部分组件可能因负载过高而失败,比如请求(查询商品详情)和写请求(下单)的冲击。
  2. 超卖问题:有限库存成为共享临界资源,大量并发请求同时扣减库存,极易引发“超卖”(库存减为负数)这一致命问题。
  3. 数据一致性问题:在极限压力下,如何保证核心链路(下单、支付)的高可用,同时确保数据(特别是库存)的最终一致性,是架构设计的重中之重。

五、秒杀有哪些问题?

5.1 从正向流程出发遇到哪些问题?

阶段1:页面加载阶段

  1. 静态资源加载压力:CDN带宽被打满、静态资源服务器过载
  2. 动态数据查询压力:商品详情接口被刷爆、用户信息查询缓存击穿
  3. 时间同步问题:客户端与服务器时间不一致、倒计时提前或延迟

阶段2:秒杀请求阶段

  1. 网关层瓶颈:限流策略过于激进,误杀真实用户、鉴权服务成为性能瓶颈
  2. 库存热点竞争:Redis集群中单个库存key成为热点、网络带宽在单个Redis节点打满
  3. 重复请求问题:用户连续点击产生重复请求、网络超时重试导致重复扣减

阶段3:订单创建阶段

  1. 消息队列问题:Kafka分区不均导致消费倾斜、消息积压,订单创建延迟
  2. 数据库写入竞争:读取mysql超时、唯一索引冲突处理、数据库连接池耗尽

阶段4:支付阶段

  1. 支付渠道瓶颈:支付超时,支付失败

5.2 从逆向流程出发遇到哪些问题?

从逆向流程思考:用户秒杀失败阶段(没进秒杀页面,打开秒杀页面结束,支付失败,超时取消)

阶段一:没进秒杀页面

  • 活动开始前服务器已宕机
  • DNS解析失败或CDN故障
  • 网络抖动导致连接超时

阶段二:打开秒杀页面结束

  • 页面加载缓慢,等打开时已售罄
  • 倒计时显示异常,错过秒杀时机
  • 页面元素加载不全,无法点击

阶段三:秒杀操作失败

  • 库存瞬间售罄:真实的高并发场景
  • 防刷策略误判:正常用户被识别为机器人
  • 服务熔断:依赖服务不可用导致主服务不可用
  • 集群负载不均:部分服务器压力过大

阶段四:支付失败

  • 余额不足:用户账户资金不足
  • 支付超时:第三方支付接口响应慢
  • 支付风控:交易被风控系统拦截
  • 网络中断:支付过程中网络断开
  • 支付超时后库存未能及时释放
  • 库存回补时出现数据竞争
  • 回补数量不准确导致超卖

阶段五:订单取消/超时

  • 用户主动取消:改变主意或误操作
  • 系统超时取消:未在规定时间内支付
  • 风控取消:系统检测到异常行为

六、秒杀问题解决

从用请求秒杀到成功到流程,可以概括为几个大类问题:

  1. 系统设计问题
  2. redis问题
  3. 队列问题(Kafka)
  4. 数据库问题(MySQL)

6.1 系统设计问题

你的秒杀是如何设计的?

  • redis预扣库存
  • Kafka发送(削峰)
  • 消费者创建订单
  • 订单数据入库
  • 锁定库存

消费者,先创建订单还是锁定库存?

  • Mq消费者,先创建订单还是锁定库存。答案是都可以
  • 这里取决于消息体有没有带唯一凭证
  • 如果是有带,先创建订单和库存都可以
  • 如果没有带,就先创建订单,通过订单id或者唯一sn码去锁定库存

用户怎么知道秒杀结果?

“因为后端是异步的,所以前端不能直接同步等待结果。”

  • 主流方案
    • 用key查询,秒杀服务提供订单号/sn去轮训订单号
    • 秒杀服务预生成的订单号
    • 用户参加秒杀活动的uid_m—本质也是key
    • 轮询:前端每隔一秒问一下服务端“我这个请求成功没?”,用参与活动的Key或预生成的订单号来查。
    • WebSocket推送:服务端处理完后,主动推送给前端。
    • 不用担心并发:需要轮询或长连接的都是秒杀成功的用户,这个数量远小于参与秒杀的总人数,服务端撑得住。

轮询的话,那么多秒杀用户,撑得住并发吗?

参加的秒杀的人虽然多,但是秒杀成功的不多

因为需要轮询的并发压力,与秒杀开始的瞬时并发,完全不在一个数量级上。

websocket通知的话,那么多秒杀用户,撑得住吗?

参加的秒杀的人虽然多,但是秒杀成功的不多

支付失败怎么办?

核心答案: 支付失败(包括用户主动取消、余额不足、支付渠道问题等)后,核心操作是 “释放锁定的库存”,并将订单状态置为“已关闭”,最终结果对用户而言就是 “秒杀失败”

处理流程:

  1. 支付回调:支付平台(如微信、支付宝)会异步通知你的系统支付失败。
  2. 业务处理
    • Redis库存:执行 INCR 命令,将预扣的库存加回去。
    • MySQL库存:将之前锁定的库存释放(locked_stock - 1),恢复可用库存(available_stock + 1)。
    • 释放库存
    • 更新订单状态:将订单状态更新为 “已关闭” 或 “支付失败”
  3. 用户侧:前端页面提示用户“支付失败”,本次秒杀流程结束。

关键点:支付失败后,商品必须重新放回库存池,让其他等待的用户有机会购买。这是一个标准的库存回补流程。

超时未支付怎么办?

超时未支付更加麻烦一点,因为超时可能是 10分钟,而你的秒杀活动 3分钟就结束了你还不还 库存都没意义了

对于超时未支付:“这是一个更复杂的问题,它揭示了秒杀场景的一个固有矛盾。我们的解决方案是分层的:

  • 首先,我们会设置一个与活动周期匹配的、较短的支付超时时间(如5分钟)。
  • 其次,最优解是建立‘候补队列’机制。 当超时订单释放库存后,立即通知候补用户,最大化库存价值和用户体验。
  • 最后,如果业务允许,最简方案是接受‘少卖’,即不处理或延迟处理回补,这对于保证系统简单性和稳定性来说,也是一个合理的权衡。”

你的秒杀怎么保证每个人只能参与一次?

  • 如何防超卖与一人一次?
    • Key 的格式为:seckill_user:{activity_id}:{user_id}
    • 例如:用户 12345 参与活动 888,对应的 Key 就是 seckill_user:888:12345
    • 防重Key的设计
    • 使用 Lua脚本 原子性执行 用户防重(set NX) + 扣库存(DECR)
  • 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. 客户端轮询方案

  • 方案描述
    1. 将库存分为 N 份,对应 key0, key1, ..., key9
    2. 用户请求时,随机选择一个 keyN 作为起点
    3. 尝试对该 key 进行扣减。
    4. 如果 keyN 已售罄,则顺序查询 keyN+1, keyN+2, ...,最多轮询 3 个
  • 优点:实现相对简单,能有效缓解“一Key售罄即失败”的问题。
  • 缺点
    • 增加了请求延迟(最多可能需要3次尝试)。
    • 仍然无法彻底解决不均,可能出现用户轮询3个都失败但总库存还有的情况。

2. 服务端代理与动态路由方案

  • 方案描述
    1. 服务端维护一个全局的库存可用位图(BitMap) 或一个简化的库存状态列表(记录哪些Key还有库存)。
    2. 用户请求到达时,服务端首先查询这个位图,快速为用户分配一个**肯定有库存的 key**。
    3. 用户直接去扣减这个被分配到的 key
  • 优点:用户体验好,几乎不会遇到“有库存却失败”的情况。

3. 库存窃取方案

  • 方案描述:这是对轮询方案的增强,也是更优雅的解决方案。
    1. 用户首先尝试扣减随机选定的 keyN
    2. **如果 keyN 已售罄,不是简单地轮询下一个,而是从一个事先指定的、库存可能较多的“备用Key”(如 keyX)中,“窃取”一部分库存(例如一半)到 keyN**。
    3. 然后用户再次尝试扣减 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问题

数据库锁库存不够怎么办?

  • 核心答案:直接、明确地提示用户“秒杀失败”。
  • 原因与逻辑
    1. 最终防线:数据库层(通过 UPDATE stock SET stock = stock - 1 WHERE stock > 0)是防止超卖的最终且最可靠的一道防线。
    2. 正常业务逻辑:这意味着该商品在当前时刻确实已经售罄。虽然Redis预扣库存和Kafka排队制造了“机会”,但MySQL的最终确认才是“结果”。
    3. 用户体验:虽然用户会失望,但这是真实、准确的业务反馈。

面试点睛:强调这里是保证“不超卖”的终极手段,任何到达这里并发现库存不足的请求,都是正常业务结果,而非系统错误。

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

程序员千羽的头像程序员千羽

相关推荐

关注我们
关注我们
购买服务
购买服务
返回顶部