笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 接入与通信

    • 游戏基础架构与工具
    • 接入网关(长连接接入层)
    • 消息总线(共享内存 IPC)
    • 帧同步(Lockstep)
    • 全栈极限低延迟(物理层→应用层)
    • KCP 与 QUIC
  • 网络与服务网格

    • CNI 与 K8s 网络插件
    • 容器运行时:Docker 与 containerd
    • Istio 与 Cilium 服务网格
    • 服务网格:中心化 vs 去中心化
    • eBPF 原理与在网络/可观测/安全的落地
    • 自研 Mesh 服务网格 × K8s 部署
    • 自研 Mesh × K8s · 数据面深水区
    • Tco 协程框架 · 运行时深水区
    • K8s 异构 & 网络插件
  • 有状态服务

    • 分布式游戏存储(分布式 KV)
    • 有状态服务的数据迁移
    • 有状态服务的数据恢复与容灾
    • 有状态服务的 K8s 编排层治理(防漂移 + 节点崩溃恢复)
    • 内存配置热刷新与更新机制
  • 算法与协程

    • 一致性哈希四算法实现(Ring Hash / Ketama / Maglev / Jump Hash)
    • 蓄水池抽样(Reservoir Sampling)与加权扩展
    • C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)
    • Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播
  • 流量与承载

    • 游戏秒杀场景承载
    • 令牌桶与漏桶
    • 限流与熔断
    • 限流:互联网 vs 游戏
    • 灰度发布 · Canary / BlueGreen / A-B / Shadow
    • 服务器日常系统开发与维护 SOP
    • 极限发压工具设计(多长连接 + 发压任务)
    • 性能分析与优化方法论(六条排查线)
  • 编译与排查

    • 编译优化与 LLVM/Clang/GCC
    • Sanitizer 工具链与内存泄漏定位

游戏秒杀场景承载

秒杀的本质是极短时间窗内海量请求争抢极少量库存。承载它靠的不是把 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 预扣只是概率性快路径。

最近更新: 2026/9/10 11:38
Next
令牌桶与漏桶