设想这样一个场面:全城最大的剧场开票,官方放出 1000 张票,结果开售的一瞬间涌进 80 万人同时在线排队。剧场门口只有四个售票窗口,每个窗口的售票员每秒最多只能处理 2 笔订单。如果让所有人都挤到窗口前,别说买票,道路会被堵死,系统也会瞬间崩溃。这就是“秒杀”最真实的场景——不是系统不够强,而是流量在极短时间里形成“洪峰”,远远超出任何常规架构的承受上限。唯一的出路不是硬扛,而是像剧场管理春运人潮那样“聪明地分流”:入口加安检闸机(限流),大厅用显示屏提前分流信息(多级缓存),售票员用机械计数器精准扣票(原子扣减),发号让大家分散等待(消息队列削峰)。本文就用这座“春运剧场”的故事,把高并发秒杀系统的四大法宝一一拆给你看。
掌握秒杀系统设计,是通往高并发架构师的必经之路。它让你在一线互联网公司的大促、抢购、活动营销场景中,面对“系统扛得住吗”时给出有数据、有方案的答案,而不是靠猜。
目录
可视化演示
展示CDN-限流-缓存-库存-异步多级削峰架构、Redis原子减库存与防超卖,以及消息队列异步化处理,滑杆调节并发流量。
本节配有交互动效(进入视口后自动加载,约 3KB)。
多级削峰架构 CDN拦截静态流量→入口限流 多级缓存+Redis原子库存 订单异步化削峰 层层过滤保护后端
高并发挑战:洪峰为什么可怕
春运第一天的清晨,城市最大的剧场开门放票,一万张演出票瞬间迎来 80 万人的抢购洪流。如果让所有人都冲到四个售票窗口前排队,会发生什么?窗口系统被超过负荷的等待队列拖垮,页面响应从 200 毫秒劣化成 20 秒;更可怕的是,因为多个请求同时操作同一条库存记录,最终票会被卖出 10500 张——超卖的 500 张让剧场陷入纠纷。秒杀的困难从来不是“人多”,而是“这么多人对同一条数据的读写无法同步”。
技术上的本质困难有三个。第一,瞬时流量不匹配:平时系统 QPS 可能只有 500,秒杀瞬间冲到 50 万,1000 倍的差异让任何常规接口都瞬间成为瓶颈。第二,热点集中:所有请求都盯着同一个商品 ID,所有读写都落在同一行库存记录上,数据库行锁变成全局瓶颈。第三,读改写竞争:多个请求同时读到“库存为 1”,各自判断可以购买,随后各自扣减,最终写回负数。衡量系统压力时,我们最常使用 QPS 指标。
其中 T_{rt} 是平均响应时间(毫秒),C 是同时处理的并发请求数。
所以,秒杀设计的第一步不是急着写代码,而是先承认:既然无法靠单机硬扛洪峰,就必须像剧场一样,在入口、大厅、窗口前分别设置闸机、显示屏和取号机,让每一层流量都被消化一部分。后面每一节,都是这套分流工程的一个环节。
- QPS(每秒查询数)系统每秒最多能处理的请求数量,是衡量秒杀系统吞吐量的核心指标。
- 1000 / T_rt把单个请求的平均耗时(毫秒)换算成一秒内能串行完成多少次请求。
- 乘以 C同一时刻允许 C 个请求并行处理,总吞吐量再乘上并发度。
多级缓存架构:层层分流查询压力
剧场很有经验:开票前先在广场大屏滚动显示“余票 850 张”,候车厅的电子屏每 3 秒同步一次,检票口的小屏显示“本口已售 213 张”。大多数观众看到大屏就知道“还有票”,根本不会都挤到窗口前再问一句“还有票吗”。这就是多级缓存——用越来越靠近用户的“信息展示”,替后端数据库挡住 99% 的重复查询。
多级缓存的工程形态自外向内依次是:CDN 与静态页面(把秒杀页提前渲染为静态 HTML,秒杀开始后直接刷新的次数极少)、浏览器与本地缓存(按钮置灰、倒计时防止重复点击)、应用本地缓存(如 Caffeine,存热点商品信息)、Redis 集群(承载绝大多数读请求),最后一层才是数据库。越靠外的层访问速度越快、能存的数据越少、数据越旧;越靠内的层越接近真实票源。核心难点是缓存击穿:某个热点 key 在过期的一瞬,海量请求同时发现“缓存没了”,于是统统打到数据库。解决思路有三种:互斥锁(只让一个请求去数据库回源,其余等待)、逻辑过期(热点数据不设物理过期时间,后台异步刷新)、过期时间加随机值(防止同时失效演变成雪崩)。
这就像剧场的三层显示屏:越靠前的屏越大、刷新越慢、数据越旧;真正精准的数字,只有售票窗口里的那个小屏才有。设计者是用“空间换时间”——用稍旧的数据,换掉了后端数据库上 95% 以上的查询压力。
入口限流方案:闸机控制流量
剧场检票口前有一道旋转闸机,保安设定好“每秒钟最多放行 30 人”。来多少人都不怕,闸机的转速始终不变;就算有人硬挤,也会被隔在门外。这种“匀速放行、超出就等”的机制叫限流。有一类更聪明的闸机,里面藏着一个“令牌盒子”:盒子每秒匀速掉出一枚令牌,进闸的人必须先抢到一枚令牌;盒子最多存满一盒,多余的令牌自动丢弃。令牌多的时候可以瞬间放进一批人,但长期速率被盒子牢牢控制。这就是令牌桶算法,它允许突发流量,非常适合秒杀初期“流量脉冲式上涨”的节奏。
令牌桶算法的核心参数是 r(令牌补充速率)和 N_capacity(桶容量,即最大突发量)。每个请求必须取走一枚令牌,桶空则拒绝或排队。相比固定窗口,令牌桶不会在窗口切换瞬间出现“双倍放行”的问题;相比漏桶,它又允许短时间冲进一小波流量。工程上可以用 Redis 加 Lua 脚本实现分布式令牌桶,让所有前端节点共享同一个限流状态。
闸机把汹涌的人潮梳成细流,剧场才不会被瞬间挤爆。限流从来不是为了拒绝用户,而是把“所有请求同时打到系统”这个不可能的任务,变成“系统可承受范围内的有序请求”。
- N_tokens(t)当前时刻桶里剩余的令牌数量。
- N_tokens(t-Δt)上一轮记录时的令牌数,这是递推的基础。
- r × Δt在时间间隔 Δt 内匀速生成的令牌总数,时间越长令牌越多。
- min(……)总数不能超过桶的上限 N_capacity,多余的令牌直接丢弃,避免桶无限膨胀。
Redis原子扣减:机械计数器防超卖
剧场售票窗口里,售票员用的是老式机械计数器:每卖一张票必须按一下按钮,数字才减一;就算两个售票员同时伸手去按,内部的棘轮也只允许一方生效,绝不会出现“两个人都以为卖到了最后一张票”的情况。这就是原子性。秒杀里的库存扣减,也必须保证这种“同时只允许一个成功”的原子效果,否则就会超卖。
最简单也最容易踩坑的做法是:先 GET 库存,再 DECR 库存。但这两步之间,另一个请求可能插进来,导致两个请求都读到库存等于 1,各自减一,最终变成 -1。Redis 的 DECR 命令本身是原子的,但“先判断库存 >0 再减一”这个组合并不是原子的。正确方案是使用 Lua 脚本,把“检查 >0”与“减一”合并到 Redis 服务器端的一段脚本里执行,Redis 会保证整个脚本执行期间不会有其他命令插队。无论并发请求有多少个,库存只会被减到 0,绝不为负。
计数器的棘轮结构(原子性)保证了不会超卖。但要记得,Redis 扣减只是“计数层面”的正确,后续若订单没生成或用户没付款,还需要把库存加回去,这就与订单流程和消息队列的配合有关了。
- stock > 0先读当前库存,判断还有没有票。
- 当且仅当(iff)只有条件成立才执行后面的减法,这是防超卖的关键约束。
- stock = stock - 1在同一个原子操作内完成库存减一。
消息队列削峰:取号机制平滑洪峰
剧场发现,如果 1000 个观众同时堵在四个窗口前排队,队伍会一直排到马路上。于是改成“取号制”:观众先在取号机上拿到一个号,然后去候场区休息,听到叫号再去窗口。取号机每秒能发 2000 个号(快),窗口每秒只能办理 30 个(慢),但人群不再拥挤,因为中间多了一个缓冲区。这就是消息队列的削峰填谷。
秒杀下单的常规流程是:请求 → 校验 → 扣库存 → 写订单 → 返回结果。但写订单涉及数据库落盘,速度慢且有锁冲突。削峰方案把流程拆成两段:请求入口只做校验、限流,然后把用户 ID 和商品 ID 写入消息队列,立即返回“下单排队中”;后端的消费者从队列里慢慢取消息,真正执行 Redis 扣减和数据库订单写入。通过调节消费者的并发数和消费速率,系统可以在几秒甚至几分钟内慢慢消化掉流量洪峰。用排队论的术语,系统负载强度为:
其中 λ 为平均到达率(每秒新到多少请求),μ 为平均服务率(每秒能处理多少请求)。
取号机让观众不再堵在窗口前,消息队列让数据库不再直面洪峰。它以“缓一缓”的方式把尖峰抹平,这正是秒杀系统承载百万流量的关键一跃。
- λ(到达率)单位时间内进入队列的新请求数,秒杀场景下它会瞬间暴涨。
- μ(服务率)每个消费者单位时间内能处理的请求数,受数据库和网络限制。
- ρ = λ / μ(负载强度)当 ρ < 1 时队列稳定,系统能持续消化积压;当 ρ > 1 时队列无限增长,最终积压爆炸。
订单流程设计:锁座出票状态机
买票不是窗口大妈喊一声“卖给你了”就结束。真实流程是:你先“锁座”——预占一个座位,15 分钟内付款,付完才正式出票;超时不付,座位自动释放给下一个人。每一步都有明确状态,绝不混乱。这就是订单状态机。秒杀订单如果“抢到即落库”,数据库会在瞬间被几十万条订单写入打垮,所以工程上采用“先 Redis 后 MySQL”的两段式设计。
用户抢到资格后,先在 Redis 中保存一条“待支付”的订单草稿,同时完成库存扣减;用户支付时,再把订单异步落库到 MySQL 成为正式订单。如果用户超时未支付,延迟队列会触发“库存回滚”,把扣掉的库存加回去,并把订单状态改为已取消。同时必须设计幂等:同一订单的支付回调可能到达两次,必须依靠订单号加状态字段保证只处理一次,否则会出现重复扣款或重复发货。
剧场用“锁座—付款—出票”管理座位,系统用状态机管理订单。前端一次点击买票,后端不是一次简单的调用,而是一条清晰的状态流转链,每一步出错都有对应的回退方案。
防刷防黄牛:安保组合拳
剧场门口还有一队保安:先查身份证(风控),再验一下验证码(人机识别),然后告诉你“每人限购 3 张”(用户限购),最后如果发现同一批身份证号反复出现,就会触发警报(黑产识别)。这一套组合拳把黄牛挡在门外,保证普通观众买得到票。
秒杀最常见的刷单方式有三类:脚本机器人毫秒级重复点击、多账号批量注册、代理 IP 池不断更换地址。对应的防护手段也分四层:第一层接口风控,对用户 ID、设备 ID、IP 做频率统计与黑白名单;第二层人机校验,滑块/图形验证码能够显著抬高脚本的破解成本;第三层用户维度限购,同一用户最多买 N 件,用 Redis 计数器实现;第四层数据风控,对异常聚集模式(同一设备大量账号、同一 IP 大量用户)做离线聚类识别。需要特别注意的是,风控必须放在限流和库存扣减之前,否则黄牛会先消耗掉宝贵的放行额度。
秒杀在某种程度上是“人与脚本的赛跑”。剧院的保安越严格,普通观众买到票的概率越高;系统的风控越细致,真用户被机器人挤掉的概率就越低。防刷不是把门锁死,而是把开锁成本提高到机器人不愿意付出的水平。
压测与监控:演练与调度大屏
春节前,剧场要做一次应急演练:模拟 5 万人同时涌向大门,测试闸机放行速度、候车厅容纳量、售票窗口的处理极限。演练会发现“东门太窄”“提示牌不清晰”等问题,然后逐一整改。上线运营后还有调度大屏,实时显示各区域的人流拥堵指数。秒杀系统的压测与监控,就是这个思路。
全链路压测是在灰度环境或线上用回放流量模拟真实秒杀流量,观测各环节瓶颈:数据库连接数、Redis 内存、网络带宽、消息队列积压量。核心指标包括 QPS、RT 的 P99 延迟、错误率、CPU/内存/磁盘水位、MQ 积压数。告警监控要设置合理阈值,比如 MQ 积压超过 1 万条、Redis 命中率低于 90%、错误率超过 1%,通过监控大盘与电话告警及时发现问题,并配合自动扩容、动态限流开关进行治理。线上出现超卖或卡顿时,依靠全链路 Trace(如 SkyWalking)定位耗时到底在哪个环节,再针对性优化。
剧场的演练是为了避免“庆典变事故”。秒杀系统的压测监控,就是在真正开票前把所有隐患暴露一遍,把“祈祷别出问题”变成“我们知道可能会出什么问题,并且预案已经准备就绪”。
AI 应用场景
场景1:电商平台 618 大促主会场流量治理
阶段:流量预测与智能限流降级
解决的问题:平时 QPS 只有 5000,大促零点瞬间冲到几十万,传统同步下单方式会让数据库连接池瞬间占满、接口超时率飙升。AI 流量预测模型根据历史大促数据提前预估秒杀峰值与持续时长,动态调整弹性伸缩组容量;入口处再配置多级限流(分布式令牌桶 + 队列排队页),把超出系统容量的流量平滑导入等待页,避免雪崩。
具体案例:某头部电商大促前 30 秒的峰值流量达平时 400 倍。通过“页面静态化 → 本地缓存 → Redis → DB”四层缓存配合入口限流与订单消息队列异步落库,核心下单接口 P99 延迟从 800ms 压到 120ms,全程零超卖、零崩溃。
场景2:演唱会抢票防黄牛与真实用户体验保障
阶段:智能风控识别与流量过滤
解决的问题:票务平台常年被脚本抢票机器人轰炸,真实粉丝一票难求。传统图形验证码已被打码平台破解。AI 风控系统结合设备指纹、用户行为序列(点击频率、滑动轨迹)、IP 聚类特征实时打分,对机器人流量进行拦截;同时配合 Redis 原子扣减与消息队列异步出票,保证真实用户的抢票成功率。
具体案例:某演唱会开票放出 1 万张票,1 分钟内涌入 120 万请求。风控系统成功拦截 78% 的脚本流量;剩余 22% 进入限流队列,通过消息队列削峰后分批生成订单。最终 1 万张票在 40 秒内由真实用户抢完,售后投诉率下降 65%。
Python 演示
# 模拟秒杀系统:1000个用户抢10件商品# 链路:令牌桶限流 -> 消息队列削峰 -> 原子扣减库存import threadingimport timefrom queue import Queue class TokenBucket: def __init__(self, rate, capacity): self.rate = rate # 每秒补充令牌数 self.capacity = capacity # 桶容量 self.tokens = capacity self._lock = threading.Lock() self._last = time.time() def take(self): with self._lock: now = time.time() added = (now - self._last) * self.rate self.tokens = min(self.capacity, self.tokens + added) self._last = now if self.tokens >= 1: self.tokens -= 1 return True return False class AtomicStock: def __init__(self, initial): self.stock = initial self._lock = threading.Lock() def try_decr(self): with self._lock: # 模拟 Redis Lua 脚本的原子性 if self.stock > 0: self.stock -= 1 return True return False class OrderWorker(threading.Thread): def __init__(self, q, stock_keeper): super().__init__(daemon=True) self.q = q self.stock_keeper = stock_keeper def run(self): while True: user = self.q.get() if self.stock_keeper.try_decr(): time.sleep(0.01) # 模拟写订单耗时 print(f'用户 {user} 抢购成功') else: print(f'用户 {user} 来晚了,已售罄') self.q.task_done() def main(): stock_keeper = AtomicStock(10) # 库存 10 件 bucket = TokenBucket(rate=5, capacity=10) # 每秒 5 个令牌 order_q = Queue() # 消息队列 OrderWorker(order_q, stock_keeper).start() passed = 0 for uid in range(1, 1001): # 1000 个用户同时抢 if bucket.take(): # 入口限流 order_q.put(uid) # 先入队,异步扣减 passed += 1 else: print(f'用户 {uid} 被限流拦截') order_q.join() # 等待队列处理完成 print(f'放行 {passed} 个请求,剩余库存 {stock_keeper.stock}') if __name__ == '__main__': main()
self.tokens = min(self.capacity, self.tokens + added)按时间补充令牌,但总量不超过桶上限。if self.tokens >= 1:有令牌才放行,这是限流的核心判断。if self.stock > 0:库存大于零才允许扣减,对应 Redis Lua 脚本里的关键判断。order_q.put(uid)请求先进入消息队列而不是直接写库,实现削峰。OrderWorker(order_q, stock_keeper).start()启动消费者线程,异步处理订单。order_q.join()主线程等待所有排队消息处理完毕。
常见误区
误区秒杀不就是流量大吗?我把服务节点从 10 台加到 100 台,单机撑不住就堆机器,QPS 顶上去不就完事了?
正解机器可以扛无状态请求,但秒杀的瓶颈在于“有状态的库存扣减”——所有请求最终都要落在同一条库存记录上。加机器只会让更多请求同时打到同一个 Redis key 或同一行数据库记录上,锁竞争反而更激烈。正确做法是分层治理:入口限流把流量削掉、缓存把读请求挡掉、原子扣减保证写正确。
为何常见:运维直觉是“水平扩展万能”,容易忽略热点资源是串行化的,无法靠堆机器分摊。
误区我已经把所有下单请求放入 MQ,消费者慢慢处理,MQ 可以削峰,库存肯定安全了吧?
正解MQ 只解决“流量瞬间冲击”问题,不解决“库存扣减的并发正确性”问题。如果消费者用“先查库存再 UPDATE”的方式扣减,并发时照样超卖。真正防超卖必须依赖 Redis Lua 原子的“检查-扣减”或数据库乐观锁。MQ 和原子扣减是两个正交的设计,缺一不可。
为何常见:很多人把“异步化”与“原子性”混为一谈,以为排队了就不会抢,忘了队伍里的消费者仍然是并发的。
误区为了秒杀性能,我把商品详情、剩余库存、用户订单全部放进 Redis,缓存多了就不怕了。
正解缓存有性价比和管理成本。库存放 Redis 是业务需要(高频读写),但订单明细这种最终要落库的数据也放 Redis 就有丢失风险。正确的分层是:静态内容尽量前置(CDN/本地),动态热点数据进 Redis,最终一致性数据落 MySQL 并用多级缓存兜住读峰值。
为何常见:对缓存的理解停留在“存得快”,忽略了数据的可靠性分层和一致性维护成本。
误区秒杀峰值 QPS 有 10 万,我把入口限到 100,系统总不会再挂了吧?
正解限流过狠会让 99% 的请求直接被拒,秒杀变成“运气测试”,正常用户根本抢不到,业务目标彻底失败。阈值应基于压测得出的系统真实容量(比如 3000 QPS)再乘以冗余系数,同时配合排队页与异步化提高有效吞吐,而不是一刀切限到极低。
为何常见:只考虑了“系统别挂”,没考虑“可用性与业务成功率之间的平衡”。
面试题
Q1. 秒杀系统出现超卖问题的根本原因是( )
- A.服务器性能不足导致请求超时
- B.并发请求同时读到同一剩余库存← 正确答案
- C.数据库连接池配置过小导致阻塞
- D.前端页面点击按钮时没加防抖
答案:B。超卖的本质是“读改写”竞态:多个请求同时读到库存为 1,各自认为还有货,随后各自扣减导致负数。服务器性能、连接池、前端防抖都只是诱发或加剧因素,不是根因。
Q2. 下列哪类限流算法允许突发流量通过( )
- A.固定窗口计数算法
- B.令牌桶限流算法← 正确答案
- C.漏桶限流算法
- D.滑动窗口计数算法
答案:B。令牌桶允许桶内积攒的令牌在一瞬间被多个请求消耗,因此支持突发流量。漏桶输出速率恒定,不允许突发;固定窗口和滑动窗口属于计数器类,重点在时间窗口计数,也不直接支持突发。
Q3. 秒杀场景下多级缓存各层容量特征描述正确的是( )
- A.越靠近用户容量越小数据越新
- B.越靠近用户容量越大数据越旧← 正确答案
- C.越靠近数据库容量越小数据越旧
- D.各层容量和数据实时性一致
答案:B。多级缓存越靠近用户,存储空间越小,能缓存的数据量有限;同时因为更新链路长,数据也越旧。数据库在最内层,容量相对充裕且数据最新。
Q4. 下列关于 Redis 扣减库存的说法,正确的是( )
- A.单独使用 DECR 命令就能完全防止超卖
- B.用 Lua 脚本组合检查库存与扣减可防超卖← 正确答案
- C.使用 GET 然后再 SET 不会有并发问题
- D.Redis 事务一定能解决所有数据一致性问题
答案:B。DECR 本身原子,但“判断大于 0 再减”需要 Lua 脚本在服务端原子执行才能真正防超卖。GET+SET 两步之间会插入其他请求;Redis 事务虽能保证命令连续执行,但秒杀的“条件扣减”逻辑仍需脚本或 WATCH 配合。
Q5. 秒杀系统中引入消息队列的作用包括( )
- A.将订单写入操作与请求入口解耦← 正确答案
- B.平滑流量峰值避免数据库被打满← 正确答案
- C.保证库存一定不会发生超卖
- D.支持失败后重试提升可用性← 正确答案
答案:ABD。MQ 的核心价值是解耦、削峰、重试。它不能保证库存不超卖,因为超卖取决于消费端扣减的原子性。若消费者用非原子方式扣库存,MQ 并不会阻止超卖。
Q6. 一个热门商品的缓存 key 失效瞬间,海量请求直击数据库属于( )
- A.缓存整体雪崩
- B.缓存数据穿透
- C.热点缓冲击穿← 正确答案
- D.缓存数据篡改
答案:C。单个热点 key 失效导致大量请求同时打穿缓存,称为击穿。雪崩是大批 key 同时失效;穿透是查询根本不存在的 key 绕过缓存直击数据库。
Q7. 秒杀订单采用“先入队后落库”方案,以下说法正确的有( )
- A.请求入口先校验基本参数与限流← 正确答案
- B.用户请求立即返回排队成功而非最终订单← 正确答案
- C.消费者异步创建订单与真实扣减库存← 正确答案
- D.该方案完全不需要数据库参与
答案:ABC。先入队方案中,入口只做校验、限流和写 MQ,立即返回排队结果;消费者异步完成 Redis 扣减和 MySQL 落库。数据库仍然是最终数据载体,不可能完全不需要。
Q8. 如果支付回调重复到达两次,而系统没有做幂等处理,最可能发生的后果是( )
- A.系统自动忽略多余的重复请求
- B.用户被重复扣款或重复发货← 正确答案
- C.数据库主从复制的延迟增大
- D.消息队列中出现重复的消费消息
答案:B。幂等性是指同一操作执行多次与执行一次效果相同。支付回调重复到达时,若没有用订单号加状态字段做去重,就可能重复扣款或重复发货。系统不会自动忽略,需要显式设计。
Q9. 秒杀活动开始前防黄牛的合理手段包括( )
- A.相同设备指纹只允许一个账号抢购← 正确答案
- B.同一用户设置每日单商品限购数量← 正确答案
- C.对高频异常行为执行实时人机验证← 正确答案
- D.完全拒绝所有代理 IP 的合法用户
答案:ABC。设备指纹绑定、用户限购、实时人机验证都能有效提高脚本成本。但一刀切拒绝所有代理 IP 会误伤大量使用公司网络或 VPN 的真实用户,属于过度防御。
Q10. 全链路压测数据显示:库存 Redis 扣减耗时 2ms,订单 MySQL 写入耗时 80ms,秒杀接口总 RT 为 120ms。以下哪项优化方向最合理( )
- A.将订单落库改为异步队列写库← 正确答案
- B.在接口层增加十层循环重试
- C.把商品库存数量扩大十倍
- D.将 Redis 替换为本地内存缓存
答案:A。瓶颈在 MySQL 写入耗时。最合理的是把订单落库拆到消息队列异步执行,接口只做校验和入队,RT 会从 120ms 降到 10ms 以内。循环重试会放大压力,扩大库存与业务事实不符,本地缓存则丢了跨节点的原子扣减能力。