每年电商大促零点刚过的那几秒,后台监控面板上的并发曲线会像心电图一样猛地拉高,然后迅速回落到一个低得多的水平。抢到的人欢天喜地,没抢到的骂服务器,而在这张热闹页面背后,真正被推到极限的,是那套看起来简单、实则到处是暗坑的秒杀系统。
秒杀这个词,做过服务端的人都不陌生。它是电商里最典型的"高并发读+高并发写"混合场景:一个商品几千件库存,偏偏有几十万人盯着同一时刻开抢。你既要把并发扛下来,又不能卖超,还不能让用户长时间卡在白屏上。这篇内容不聊高深的理论,就结合我这些年做过的几个秒杀项目,把设计思路、技术选型、落地步骤和踩过的坑一次性说透。适合准备接秒杀需求的团队,也适合想把这套架构看明白的后端同学。
1. 秒杀系统的本质:一次设计极其不对称的活动
1.1 秒杀和普通下单差在哪
普通下单流程是:查购物车、核对地址、锁库存、生成订单、跳转支付。每一步都有充足的响应时间,系统偶发抖动用户也能接受。但秒杀不行。秒杀场景里,绝大多数请求从进网关那一刻起,就已经注定是"陪跑"的——库存就那么多,成交的量是固定的,剩下几十万请求唯一的价值是消耗系统资源。
所以秒杀系统的本质,是一场"不对称"的设计。你不用把所有请求都当爷一样伺候,你要做的是快速识别出那极少数的有效请求,然后把大部分无效请求挡在最外层。换句话说,普通系统追求的是让更多人"进得来",秒杀系统追求的是让大多数人"别进来"。
这个认知非常关键。很多团队第一次做秒杀,下意识按普通订单系统去设计,结果被流量直接冲垮。你越早接受"大部分请求是来捣乱的"这个事实,架构思路就会越清晰。
1.2 核心难点拆解
这几件事是每个秒杀设计都绕不开的:
- 高并发冲击:瞬时流量是平时的几十上百倍,普通数据库根本扛不住
- 库存一致性:不能超卖,也不能少卖,这是业务底线
- 防刷防羊毛党:机器脚本、批量账号在黑灰产手里是常规武器
- 体验与公平:页面要快,结果要准,不能有人下单成功了却被提示失败
这四个问题并不是互相独立的。比如防刷没做好,真实用户一个都抢不到;系统扛不住,用户等半天最后超时,体验彻底完蛋。所以秒杀设计要从入口到落单,层层布防,而不是在某一个点死扛。
我见过一个团队,把所有希望寄托在数据库的乐观锁上,Redis没用、MQ没用、限流也没做,结果压测刚上2000并发,数据库连接池先被打爆。秒杀从来不是某一个环节的技术难题,而是整条链路的协作设计。
1.3 流量漏斗:把百万请求压到几百
我习惯把秒杀链路画成一个漏斗。最外层可能进来了100万次点击,经过CDN、网关限流、资格校验、库存预扣之后,真正进入订单创建的只有几百上千。这个漏斗的每一层,都有明确的职责:
| 漏斗层级 | 主要职责 | 手段举例 |
|---|---|---|
| CDN/静态页面 | 抗住绝大部分页面请求 | 活动页静态化,动态数据单独异步拉取 |
| 网关层 | 全局限流、IP/用户维度限流 | 令牌桶、滑动窗口、验证码 |
| 服务层 | 活动有效性、用户资格、重复购买校验 | 查询活动配置、判断用户状态 |
| 缓存层 | 库存预扣、用户已购标记 | Redis Lua脚本原子操作 |
| 消息队列 | 削峰填谷,异步落单 | 订单创建消息、消费重试 |
| 数据库 | 最终库存落盘、订单持久化 | 乐观锁更新、唯一索引约束 |
漏斗设计的核心原则就一句话:越靠近入口,越要轻。入口层你如果做个复杂计算,100万请求进来直接把CPU打满,后面全白搭。静态页面就是死文件,网关限流就是一个简单计数,这些操作必须极其廉价。
2. 关键技术方案选型
2.1 页面静态化与动静分离
活动页是秒杀流量的第一个出口。如果每个用户来了都请求源站的动态接口,光渲染页面就能把后端打死。所以第一件事就是静态化。
具体做法是:活动开始前,把商品图、价格、文案、按钮这些基础信息渲染成静态HTML文件,推到CDN上。用户打开页面时,边缘节点直接返回,源站零开销。页面里需要用到的"倒计时""是否已抢到""剩余库存"这类动态信息,单独拆成轻量接口,页面加载后再异步拉取。
这里有个细节要提醒:动态接口别做成一个页面几十个请求。我见过一个活动页拆了三十多个动态接口,结果秒杀还没开始,后端先被自己的页面打崩了。尽量收敛成两三个聚合接口,一个拿基础状态,一个轮询订单结果,页面加载和轮询分开。
静态化的另一个好处是抗住"预热"流量。秒杀开始前,用户就会不断刷页面看倒计时,这些流量如果不靠CDN吃掉,源站同样会被拖垮。把静态页面推到CDN,等于把最重的读流量全部挡在了门外。
2.2 库存预扣与最终扣减:先拿号的思路
库存怎么扣,是秒杀系统最核心的决策之一。常见的思路有两种。
第一种是"先查再改"。业务代码里先查数据库库存,够就UPDATE扣减。这种写法在低并发下没问题,秒杀一来必出超卖——两个请求同时查到库存1,同时扣,库存就变成-1了。这是典型的竞态条件,靠业务代码的"先判断"解决不了。
第二种是"预扣+异步落库",这也是秒杀的主流方案。用户请求进来,先在Redis里做库存扣减,扣成功才允许走下一步;同时把下单操作丢到消息队列,消费者拿到消息后创建订单,再在数据库层面做一次最终扣减。Redis单线程特性天然保证原子性,扣减操作不会互相踩踏。
预扣最大的好处,是你用Redis这把"闸门"筛掉了绝大多数无效请求。数据库只接收已经扣到库存的那一小部分请求,压力骤减。但预扣也带来新问题:库存被扣了,订单却可能创建失败,或者用户迟迟不付款,怎么办?所以预扣的Redis库存必须设置合理的释放策略。常见做法是给预扣记录设一个有效期,比如15分钟,超时未支付的订单由定时任务扫描并关闭,同时把Redis库存回补。
用户侧看到的结果也要设计清楚。扣库存失败返回"已抢光"或"手慢了",重复购买返回"您已参与过该活动",扣成功则返回"正在排队"。这些提示文案和状态码要在前后端约定好,否则体验会非常混乱。
2.3 削峰填谷:为什么要异步下单
秒杀场景下,如果每个用户抢到后都同步走完"创建订单→锁库存→返回结果"全流程,数据库同一时刻要承受几千上万次写操作,几乎必然扛不住。所以要把峰值抹平,这就是削峰填谷。
思路很简单:扣减Redis库存成功后,不直接写库,而是发布一条订单消息到MQ。消费者按照自己的消费能力,慢慢把订单创建出来。这样同一时刻数据库的写入量被限制在一个可控范围,系统不会因为瞬时尖峰挂掉。
异步化带来的体验变化是什么?用户点击"立即抢购"后,不会立刻看到订单结果,而是看到"正在排队"的提示。前端再轮询订单状态接口,几秒内拿到是否成功的最终结果。这个交互方式在秒杀场景下用户是能接受的,关键是提示要友好,轮询接口要轻,不能一次查一堆表。
消息队列这块有几个要点:消息不能丢,生产端要保证正常的路由和持久化;消费端要支持重试,重试要幂等。所谓幂等,就是同一条消息被消费两次,结果跟消费一次一样。订单创建接口必须用订单号或"用户+活动"的唯一约束去查重,否则重试会造出多个订单,对账的时候哭都来不及。
3. 实操落地:一条完整秒杀链路怎么搭
3.1 网关层限流与防刷
动手写业务代码之前,先把限流做上。限流对秒杀系统来说不是可选项,而是第一条命。
常见的限流维度有三个:
- 全局维度:控制整体并发,令牌桶算法比较常用。每秒放进去一定数量的令牌,请求要先拿到令牌才能通过。令牌桶允许短时间内的突发流量,比固定窗口算法更温和
- 用户维度:每个用户每秒最多N次请求。用Redis计数,INCR之后判断是否超过阈值,同时给计数器设置很短的有效期
- IP维度:每个IP每秒最多M次请求,防脚本多账号扫
代码大致是这样:
import time import redis r = redis.Redis(host='...', port=6379, decode_responses=True) def rate_limit(user_id, prefix, limit, window=1): key = "{}{}:{}".format(prefix, user_id, int(time.time()) // window) count = r.incr(key) if count == 1: r.expire(key, window + 1) return count <= limit这只是一个最简版本,生产里还要考虑Redis超时后的降级处理,以及本地限流兜底。网关层如果完全依赖Redis,Redis一抖动,限流本身就成了新瓶颈。我习惯在网关层做一个两级限流:本地内存限流先挡掉一波,再到分布式限流做精确控制。
防刷光靠限流也不够。UA校验、设备指纹、验证码弹出、行为轨迹分析,都可以叠加在网关层。我的经验是,秒杀刚开始的1-2分钟是脚本最疯狂的时候,验证码可以在这个阶段有策略地弹出,过了高峰期再放宽,用户体验和防刷能同时兼顾。当然,验证码本身要轻量,滑块验证码如果验证服务处理不过来,反而会制造新的故障。
3.2 Redis库存原子操作与Lua脚本
库存预扣是秒杀链路的灵魂。这一步要用Redis的原子操作,最稳妥的方式是写一段Lua脚本,让Redis保证检查库存、扣减、标记用户这三个动作原子完成。
-- KEYS[1]: 商品库存key -- KEYS[2]: 用户已购标记key -- ARGV[1]: 用户ID -- 1. 检查用户是否已经抢过 if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return 0 end -- 2. 检查库存是否充足 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock <= 0 then return -1 end -- 3. 扣减库存并标记用户 redis.call('DECR', KEYS[1]) redis.call('SADD', KEYS[2], ARGV[1]) return 1调用端怎么判断返回值?0代表该用户重复抢购,要友好提示"您已参与过该活动";-1代表库存不足,提示"已抢光";1代表抢购成功,走下一步。返回1之后,才把下单消息发送到MQ。这里一定要理解:Redis扣减只是预扣,不是最终成交,真正的落单在消费者那边。
实际操作中要特别注意几个细节。库存key不要设置过期时间,万一过期了Redis里库存清零,但实际还能卖,那就出大问题了。如果非要处理缓存淘汰,用主动淘汰逻辑,不能依赖TTL。另外,用户已购标记的集合会越来越大,活动结束后要及时清理,或者换到独立的存储,别让Redis里堆了一堆垃圾key。还有一个容易被忽略的点:Redis实例本身要预留足够的CPU余量,库存key太热的话,单实例的CPU会先扛不住。
提示:秒杀库存的Redis key不要设置过期时间,用主动淘汰来管理。依赖TTL清理热点key,等于给自己埋了一颗定时炸弹。
3.3 下单异步化与订单状态机
抢购成功后,前端轮询订单状态,后台实际发生的流程是:
- 消费端从MQ拿到下单消息
- 先查数据库,判断这个用户ID加活动ID是否已经有有效订单,有就跳过(幂等)
- 扣减数据库库存,用乐观锁条件:
UPDATE inventory SET stock = stock - 1 WHERE product_id = ? AND stock > 0 - 创建订单,初始状态为"待支付"
- 返回或记录结果
数据库扣减一定要用乐观锁的写法,执行后还要检查影响行数。影响行数为0说明库存已经被别人扣光了,此时要回滚整个流程,并且回补Redis库存。这里业务流程分了几段,各段之间没有强事务,靠消息和定时任务来兜底,这就是最终一致性。
订单表的设计也要扛住高并发写入。订单号最好用分布式ID生成器,避免数据库自增ID碰撞;订单创建要加唯一约束,把"用户ID+活动ID"作为业务唯一键,防止消息重试产生重复订单。
给订单设置支付超时时间同样关键。我的做法是订单表加一个expire_time字段,定时任务每两分钟扫一次,把超时未支付的订单置为"已关闭",并回补库存。扫描的SQL要命中索引,否则数据量大起来任务就跑不动了。回补库存要单独封装逻辑,最好放进消息队列异步执行,避免定时任务和回补操作互相拖累。
注意:回补库存和关闭订单是两件事,顺序不能倒。先关订单再改Redis,万一Redis操作失败,会出现"订单关了但库存还没回来"的数据不一致。
4. 常见问题与排查经验
4.1 超卖到底是怎么发生的
超卖是秒杀系统最不能接受的问题。我见过不少团队,上线前拍着胸脯说绝对不会超卖,结果压测一跑,库存500,订单出了600。超卖的根源无非三种。
第一种,直接改库存没条件判断。UPDATE stock SET stock = stock - 1,这种写法并发下全是负库存,属于新手错误。改成WHERE stock > 0,再检查影响行数,就能解决。
第二种,先查后改的竞态。代码里先SELECT库存,等于0就不扣,不等于0才UPDATE。两个请求同时SELECT到1,然后同时UPDATE,就超了。这种必须用原子操作或者数据库行锁,不能靠业务代码的"先判断"。
第三种,Redis和数据库库存不同步。Redis扣了但消息发丢了,数据库没扣,对账的时候发现两边不一致;或者消息消费失败重试多次,数据库扣了两遍,订单却只有一个。这种情况要在下单逻辑里加唯一约束,并在消费端做幂等判断,确保重试不会产生新的有效订单。
排查超卖问题的第一步永远是对账。把Redis剩余库存、数据库剩余库存、有效订单数拉出来对比,哪一步对不上就往哪查。日志里一定要带上活动ID、商品ID、用户ID、请求ID这些上下文,不然对账对到一半就卡住了。我吃过这个亏,日志里只有一行"扣库存失败",连是哪个活动的哪个商品都不知道,最后只能靠翻网关日志硬凑。
4.2 热点Key与缓存击穿
秒杀场景有个典型问题:所有流量都打向同一个商品的库存key,这个key就成了热点key。单台Redis实例CPU飙升,其他业务缓存也被它拖累。我的经验是,秒杀相关的缓存最好用独立的Redis实例隔离,不让它影响常规业务。隔离不是奢侈,是必要的故障边界。
热点key还有一种处理思路是把一个key拆成多个带后缀的子key。比如库存key拆成seckill:stock:sku1-01、seckill:stock:sku1-02这样16个分片,请求路由到不同分片,压力就散开了。但拆key的代价是逻辑复杂度上升,扣减前要确定路由规则,回补时也要同一分片操作。小规模秒杀没必要这么干,单实例扛不住的时候再考虑。
缓存击穿也要防。如果给库存key设置了TTL,刚开始"失效"的瞬间,大量请求同时落到数据库,数据库直接被打挂。秒杀场景最省心的做法就是前面说的:库存key不设TTL,用主动方式管理。如果非要走缓存重建的路线,得用互斥锁——第一个请求去重建缓存,其他请求短暂自旋等待,别一股脑全往数据库冲。对于活动详情这类查询性数据,空值也要缓存,避免查询不存在的数据时反复穿库。
4.3 压测与容量评估
上线秒杀之前不压测,等于裸奔。压测不是拿压测工具点点点就行,要做阶梯加压。先从低并发开始,比如100并发跑5分钟,然后200、500、1000、2000逐渐加,观察每个阶段的QPS、响应时间P99、错误率。当QPS不再上涨或者错误率明显抬升时,这个点就是系统的拐点。容量规划要按拐点的60%-70%来留冗余,别卡着上限部署,生产环境稍微有点抖动就全盘崩溃。
压测一定要用隔离的影子环境,或者至少把压测流量与生产流量通过特殊参数区分开。我见过有人直接把生产环境拿来压测压死的案例,这种事一旦发生,业务侧基本是事故级的。
压测完了写个报告,把各层的瓶颈写清楚——是Redis CPU先满,还是数据库连接池先枯竭,还是MQ消费跟不上。每个瓶颈对应一个优化方向:Redis换更高配置或加副本,数据库增加连接池上限并优化慢SQL,MQ消费端加节点或者调整消费并发数。
监控一定要提前上线。关键指标包括:网关请求量、各层拒绝量、Redis读写延迟、MQ积压量、数据库慢查询、订单创建成功率。秒杀过程中,监控面板上最先跳红的通常是MQ积压量,其次是Redis延迟。这两个指标一失控,后面订单的最终一致性就会出问题。
我个人做了几个秒杀项目之后最大的体会是,秒杀系统设计要敢做减法。流量能挡就挡,能异步就异步,能做最终一致就不要轻易碰分布式事务。很多团队死在追求"完美一致"上,其实用户根本不在乎你内部是强一致还是最终一致,他们只在乎结果别出错、页面别卡死。先把漏斗每一层的职责划分清楚,再逐层把限流、预扣、异步、幂等这些基本功做扎实,秒杀系统就成功了一大半。还有一个小技巧,压测之后一定要做一次故障演练:把Redis节点故意停掉一个,看看流量是不是会直接穿透到数据库;把MQ消费者停五分钟,看看积压恢复速度。演练发现的问题,远比在代码评审里发现的问题更真实。