游戏秒杀场景承载
秒杀的本质是极短时间窗内海量请求争抢极少量库存。承载它靠的不是把 DB 换成更强的机器,而是分层削峰——在每一层把无效流量拦掉,只让"能中"的请求走到最后的原子扣减。
一句话结论
秒杀靠分层削峰漏斗把注定失败的请求尽早在廉价层拦掉,DB 只做原子扣减兜底。
场景问题
秒杀/限量抢购(游戏里如限量皮肤、开服礼包、拍卖行竞拍、活动兑换)的流量特征:瞬时峰值可达日常的百倍,且高度集中在同一个热点资源(同一件商品的库存)。直接打 DB 会踩到几个经典瓶颈:
- 热点单行的行锁竞争:所有请求都
UPDATE stock SET count=count-1 WHERE id=? AND count>0,MySQL 对同一行加行锁串行化,请求排队,TPS 被单行锁拖死。 - DB 连接与 IOPS 打满:连接池被抢占,慢查询堆积,拖垮同库其他业务。
- 缓存击穿:热点 key 恰好过期的瞬间,海量请求穿透到 DB。
- 超卖:读-判断-写非原子,并发下多个请求都读到"还有库存",扣成负数。
打个比方:扛秒杀像设计一个层层收窄的漏斗(或演唱会门口的多道安检)。几百万人涌来,真货却只有几千张——与其让所有人挤到最后一道结账口(DB)互相踩踏,不如在漏斗的每一层就筛掉注定没戏的人:最外层前端直接把按钮置灰、上验证码挡住机器人;网关层限流只放行一批;缓存层用 Redis 预扣库存,扣光了当场回一句"已抢完"打发走;最后只剩寥寥几个"真有希望"的请求,才走到最里层 DB 做原子扣减一锤定音。每一层都把一批无效流量拦在更廉价的地方,DB 只需伺候最后的零头。类比失效边界:漏斗削峰的前提是"越靠外的拦截越廉价、且容许不精确"——Redis 预扣可能因超时、集群抖动而误判,所以外层只是概率性削峰、不能当作最终账本;真正的库存对错必须以 DB 那道原子扣减为准。换句话说,外层再猛也别指望它 100% 准,最里层那道原子闸永远不能省。
- 重复下单/刷单:脚本/外挂对同一账号重复提交。
核心矛盾
库存只有 100 件,却有 100 万请求。其中 99.99% 注定失败,工程目标是让这 99.99% 尽早、尽廉价地失败,而不是都挤到最贵的 DB 层去失败。
实现方案
思路是一个逐层收窄的漏斗:前端 → 接入层 → 消息队列 → Redis 预扣 → 异步落库。
1. 前端 + 接入层削峰:按钮置灰防连点、答题/滑块增加人力成本、随机延迟打散峰值;网关侧 limit_req 令牌桶限流、按 IP/账号限速、幂等令牌(下单前先领 token,无 token 直接拒)。
2. Redis + Lua 原子预扣库存(核心):库存预热到 Redis,扣减用 Lua 脚本保证"读+判断+扣+记录"在单线程内原子完成,杜绝超卖。同时用 Lua 完成幂等(同一 token 只能扣一次):
-- seckill.lua
-- KEYS[1] = 库存 key,如 stock:{item123}
-- KEYS[2] = 已购去重集合,如 bought:{item123}
-- ARGV[1] = userId ARGV[2] = 幂等令牌 token
-- 返回: 1=成功 0=售罄 -1=重复下单(已购/token 用过)
-- 幂等:token 已消费过则拒绝
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
return -1
end
-- 限购:同一用户已购则拒绝
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1
end
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return 0 -- 售罄,快速失败
end
redis.call('DECR', KEYS[1]) -- 原子扣减
redis.call('SADD', KEYS[2], ARGV[1]) -- 记录该用户已购
redis.call('SADD', KEYS[2], ARGV[2]) -- 记录 token 已消费
return 1
Go 侧调用(用 EVALSHA 减少脚本传输):
package seckill
import (
"context"
"crypto/sha1"
"encoding/hex"
"github.com/redis/go-redis/v9"
)
//go:embed seckill.lua
var luaScript string
type SeckillService struct {
rdb *redis.Client
sha string // 预加载脚本的 SHA1
}
func NewSeckillService(rdb *redis.Client) (*SeckillService, error) {
sha, err := rdb.ScriptLoad(context.Background(), luaScript).Result()
if err != nil {
return nil, err
}
return &SeckillService{rdb: rdb, sha: sha}, nil
}
// TryAcquire 返回: 1 成功 / 0 售罄 / -1 重复
func (s *SeckillService) TryAcquire(ctx context.Context, itemID, userID, token string) (int64, error) {
stockKey := "stock:{" + itemID + "}" // hash tag 保证同槽,便于集群
boughtKey := "bought:{" + itemID + "}"
res, err := s.rdb.EvalSha(ctx, s.sha, []string{stockKey, boughtKey}, userID, token).Int64()
if err == redis.Nil || isNoScript(err) {
// 脚本被逐出,回退到 Eval 重新加载
res, err = s.rdb.Eval(ctx, luaScript, []string{stockKey, boughtKey}, userID, token).Int64()
}
return res, err
}
func isNoScript(err error) bool {
return err != nil && len(err.Error()) >= 8 && err.Error()[:8] == "NOSCRIPT"
}
var _ = sha1.New
var _ = hex.EncodeToString
3. 分段库存降热点:把 100 件拆成 stock:item:0..9 各 10 件,请求按 hash 落到不同分片,把单一热点行/热 key 打散成 N 个,扣减吞吐提升 N 倍。某分片售罄可"借"其他分片(需二次原子判断)。
4. MQ 异步下单 + 落库:Redis 预扣成功后仅入队一条下单消息,快速返回"排队中",消费者慢慢写 DB。DB 从"承受百万写"降级为"承受 100 条写"。落库带幂等键(token)防止消息重复消费重复下单。
5. 最终一致对账:Redis 扣减数与 DB 订单数定期对账,处理"扣了 Redis 但 MQ 丢消息/落库失败"的差异,补偿或回补库存。
统一库存:多渠道/多分片下的一致扣减
前面的分段库存解决的是单一库存池内部的热点问题。但真实业务里同一份货往往被多个入口同时抢:App / 官网 / 渠道商多渠道卖同一批限量皮肤,或多地域/多活机房各自就近承接一部分流量。此时冒出一个新矛盾——同一份物理库存被多处独立扣减,跨入口一起看就超卖了。这就是"统一库存"要回答的问题:谁是这份库存的唯一事实源,各入口如何在它之上做到既快又不超卖。
别把两件事混为一谈
- 分段库存(前面那节)是性能手段:把一个池子拆成 N 个分片提并行度,解决"单 key 单线程扣不动"。
- 统一库存是正确性边界:跨渠道/跨分片/跨机房,保证总扣减量不超过总库存。打散得再狠,也必须有一处能对"总账"一锤定音,否则各分片各卖各的必然超卖。
打个比方:一批限量球鞋放在一个总仓,但同时开了三家门店(多渠道)和分店在外地(多活)卖。两种活法:要么每笔成交都打电话回总仓问"还有没有、给我扣一双"(统一库存中心,绝不超卖但总仓电话被打爆);要么开卖前总仓先给每家店发一叠提货券(分片预分配,店里自己收自己的券、飞快,卖光了再打电话回总仓要下一批)。类比失效边界:发券模式快,但会出现"A 店券发完挂'售罄'牌、B 店抽屉里还压着一沓没卖掉"——总量没光、局部却先售罄,得靠"闲券还仓 + 热店借券"再平衡;而且券和总仓账本必须定期对齐,谁也不能拿"我店里的券"当最终账本,总账永远以总仓为准。
两种模型:统一库存中心 vs 分片预分配
| 维度 | 统一库存中心 | 分片预分配(配额下发) |
|---|---|---|
| 谁扣减 | 所有入口打到同一处原子扣减 | 各入口先扣本地配额,扣光才回源 |
| 正确性 | 强一致,天然不超卖 | 本地不超卖 + 回源二次判断兜底总账 |
| 性能 | 中心成热点,受单点吞吐/跨机房 RTT 限制 | 本地无锁扣减,吞吐/延迟最好 |
| 典型问题 | 中心是瓶颈与单点 | 分配不均、尾部碎片、回源风暴 |
| 适用 | 库存极少、超卖代价极高(唯一装备/竞拍) | 高并发多渠道多活、可容忍二次分配 |
实践里常两者组合:中心持总账并做最终原子扣减,日常靠给各渠道/各分片预分配配额扛住峰值,配额见底再回源——本质就是把前面的"分段库存 + 借还"从单机房推广到跨渠道/跨机房。
跨分片/跨渠道的一致扣减与借还
- 本地配额扣减:每个渠道/分片持
quota:{channel}一段配额,命中就地用 Lua 原子扣,不触达中心,这是快的来源。 - 售罄回源 + 二次分配:某分片配额见底,向中心发起一次原子申领
borrow(n);中心从总账里原子划走 n(total >= n才成功),成功则续上本地配额,失败则确系全局售罄。借用必须走中心的原子判断,杜绝"两个分片同时借走最后一批"。 - 闲量还仓与再平衡:给配额设低水位/高水位,低于低水位触发借用、长期高于高水位触发还仓,避免"A 分片售罄、B 分片压着一堆没卖"的尾部碎片。再平衡是后台异步动作,不在下单主链路上。
- 热点再平衡:按各渠道实时消耗速率加权分配下一批配额(消耗快的多给),减少回源频次。
-- borrow.lua:分片配额见底时向库存中心原子申领一批
-- KEYS[1] = 总账 key total:{item123}
-- ARGV[1] = 申领批量 batch(如 100)
-- 返回:实际划走的数量(可能小于 batch,0 表示全局售罄)
local total = tonumber(redis.call('GET', KEYS[1]) or '0')
if total <= 0 then return 0 end
local grant = math.min(total, tonumber(ARGV[1]))
redis.call('DECRBY', KEYS[1], grant) -- 从总账原子划走,别的分片借不到这批
return grant
一致性与对账
- 超卖检测:
sum(各渠道已扣)应恒<= 总库存;定期对账比对总账划出量、各分片本地已扣量、DB 已落订单数三者,任何不等即告警。 - 回补:预扣成功但下单最终失败(MQ 丢、落库失败、用户超时未支付)时,把这份配额原子归还到本地配额或中心总账,避免"扣了没卖出去"的库存泄漏。
- 谁是账本:Redis 预扣(本地配额/借用)都是概率性快路径,跨机房抖动、超时都可能误判;最终对错以中心总账/DB 的原子记录为准——与前面"漏斗类比失效边界"是同一条原则:外层再快也不能当最终账本。
相关专题
本节的账号视角默认"所有渠道共享同一 uid 空间",只讲了库存正确性;当同一件限购物品同时在游戏内 + 官网 H5 + iOS 米大师 + 安卓渠道 SDK 售卖、且各入口账号体系不同(QQ openid / WX openid / 米大师订单号 / 渠道 openid)时,还需要一层账号解析层归一 account_key 才能造 idem_key,配套的支付-发货事务链(TCC + 本地消息表)详见 跨渠道限购与游戏侧分布式事务落地。
为什么这么做
- 削峰的经济学:越靠前的层拦截越便宜。前端拦一个请求成本近 0,网关拦一个是内存操作,Redis 拦一个是一次内存 CAS,DB 拦一个则要一次磁盘 I/O + 行锁。把 99.99% 的失败下沉到廉价层,DB 才活得下来。
- Lua 保证原子性:Redis 单线程执行 Lua,脚本内的"读-判断-写"不会被其他命令穿插,从根上消灭超卖,比"WATCH+MULTI 乐观锁重试"更省往返、更稳。
- 分段库存打散热点:单点原子操作再快也有上限(单 key 单线程),分桶是把热点转化为并行度的标准手法,同思路见一致性哈希的分片。
- 异步落库解耦峰值与容量:MQ 把"瞬时峰值"整流成"平稳消费速率",正是漏桶思想的应用(见 令牌桶与漏桶)。
为什么别的选择不行
- 直接打 DB / 加 DB 只读从库:热点是同一行的写竞争,加从库只解决读,写仍串行在主库单行锁上,无解。
- 纯乐观锁 CAS 重试:低并发可行,秒杀级并发下重试风暴反而加剧竞争,成功率随并发上升而暴跌。
- 只在应用层加锁(分布式锁):把并发串行化到一把分布式锁上,锁本身成为新瓶颈,且锁粒度粗、超时/续期复杂。
- 只加限流不做预扣:限流能保护 DB 不被打死,但不能保证"不超卖"与"公平",两者是正交的,都要有。
沉淀结论
| 层 | 手段 | 拦掉什么 |
|---|---|---|
| 前端 | 置灰/答题/随机延迟 | 连点、机器人 |
| 接入层 | 令牌桶限流、幂等令牌 | 超额流量、无票请求 |
| Redis+Lua | 原子预扣 + 分段库存 | 超卖、热点、重复购买 |
| MQ | 异步下单 | 瞬时写峰值 |
| DB | 幂等落库 + 对账 | 最终一致兜底 |
游戏秒杀 vs 电商秒杀
- 公平性/世界状态:游戏抢的常是影响世界状态的资源(唯一装备、排行榜名次),公平性诉求更强,常引入服务器权威时间戳排序、答题门槛。
- 防外挂:客户端不可信,扣减必须服务端权威,需强幂等 + 行为风控识别脚本连点。
- 强状态耦合:中奖后要改玩家背包/世界数据,比电商单纯生成订单耦合更深,异步落库要保证与玩家状态的一致性。
分段 vs 统一(一句话记牢)
分段库存是性能(打散热点提并行度),统一库存是正确性(跨渠道/分片保证不超卖)。 两者正交、都要有:分段让扣得动,统一让不超卖;无论怎么打散,总账必须有唯一一处能一锤定音,最终以中心/DB 的原子记录为准,Redis 预扣只是概率性快路径。
一句话:秒杀 = 分层削峰漏斗 + Redis/Lua 原子扣减 + MQ 异步落库 + 最终对账;多渠道/多活再叠一层统一库存(中心持总账 + 分片预分配配额 + 售罄回源借还)。核心是让绝大多数注定失败的请求尽早在廉价层失败,同时让总账不超卖。
相关专题:幂等设计 · 限流与熔断 · 一致性哈希实现 · 容器运行时 · K8s 网络
记忆口诀
削峰漏斗:前端置灰 / 网关限流 / Redis 预扣 / MQ 落库
防超卖:Lua 原子 / 单线程读判写 / 分段库存打散热点
防重复:幂等令牌 / 已购去重集合 / 落库幂等键
兜底:MQ 整流 / 最终一致对账 / 差异补偿回补
统一库存:中心持总账 / 分片预分配配额 / 售罄回源借还 / 分段=性能·统一=正确性
内容来源
- 业界大厂电商与游戏秒杀实践公开分享(分层削峰、预扣库存、异步下单)
- Redis 官方文档:
EVAL/EVALSHA、Lua 脚本原子性、Redis Cluster hash tag - 《数据密集型应用系统设计》分区与热点章节
- 作者在游戏活动/限量发放系统的落地经验
自测:合上资料能说清楚吗?
秒杀的核心矛盾是什么?为什么"加更强的 DB/加从库"解决不了?
参考答案
矛盾是极短窗口内海量请求争抢极少库存,99.99% 请求注定失败。热点是同一行的写竞争,从库只解决读,写仍串行在主库单行行锁上无解;工程目标是让失败请求尽早在廉价层(前端/网关/Redis)失败,而非都挤到最贵的 DB。
为什么用 Redis + Lua 做预扣,而不是"WATCH+MULTI 乐观锁重试"或分布式锁?
参考答案
Redis 单线程执行 Lua,脚本内"读-判断-扣-记录"不会被穿插,从根上消灭超卖,比乐观锁少往返、无重试风暴;秒杀级并发下 CAS 重试反而加剧竞争、成功率暴跌。分布式锁把并发串行化到一把锁,锁本身成新瓶颈。
分段库存为什么能提升吞吐?代价是什么?
参考答案
把 100 件拆成 N 个分片各若干件,请求按 hash 落到不同 key,把单一热点打散成 N 个原子操作,吞吐约提升 N 倍(单 key 单线程有上限)。代价:某分片售罄需借其他分片(二次原子判断),逻辑更复杂、分配可能不均。
只做限流不做预扣够不够?两者是什么关系?
参考答案
不够。限流保护 DB 不被打死,但不能保证不超卖与公平——两者正交,都要有。限流控总量,预扣(Lua 原子)控精确扣减与幂等。
游戏秒杀和电商秒杀有什么不同?
参考答案
游戏抢的常是影响世界状态的资源(唯一装备、排行榜名次),公平性诉求更强,常用服务器权威时间戳排序;客户端不可信需强幂等+风控防外挂连点;中奖改背包/世界数据,状态耦合比电商生成订单更深,异步落库要保证与玩家状态一致。
多渠道/多活下"统一库存"要解决什么?中心扣减和分片预分配怎么取舍?
参考答案
同一份物理库存被多入口(多渠道/多机房)独立扣,跨入口一起看会超卖。统一库存中心让所有入口打到同一处原子扣减,强一致不超卖,但中心成热点/单点、受跨机房 RTT 限制。分片预分配由中心给各入口下发本地配额、就地无锁扣、售罄再回源二次分配,吞吐/延迟最好,但有分配不均、尾部碎片、回源风暴,需低/高水位借还再平衡。实践常组合:中心持总账、日常靠配额扛峰值。关键边界:分段库存是性能、统一库存是正确性,最终账以中心/DB 原子记录为准,Redis 预扣只是概率性快路径。