67.6 秒杀与抢购 (库存 / 限购 / 防刷 / 削峰)

一句话

秒杀的本质是在同一时刻、把大量并发读写压到同一个商品上;它不是“抗住流量”,而是在“不超卖、不少卖、不被刷、不雪崩”四个约束下,把百万级 QPS 逐层过滤到数据库能承受的量级——每一层只做一件事,每层都只放行下一层能接住的那么多。

💡 本节口径:只讲架构决策。代码级极致优化(Java/JVM、Vert.x、物理机调优)不在本节展开,那属于实现层,换语言就换一套。

秒杀系统的四个挑战

挑战 具体表现 为什么难
瞬时流量 用户全挤在同一秒开抢,热门商品 1–2 秒售罄 峰值是均值的几十上百倍,按峰值扩容成本不可接受
热点数据 几十万人读写同一个库存 key Redis 单 key 写入只有几万 TPS 量级,数据库同一行会上行锁
刷子流量 H5 页面抓包即可直接调接口,脚本按频率刷 请求都是“合法”的,靠业务规则很难区分
公平性 内部人/脚本抢走大部分库存 需要限购、预约、风控共同保证

🔑 四者里瞬时流量是总根源:它击垮入口(连接数/线程)、压垮依赖(数据库/缓存)、拉长响应(用户报错、客诉)。其余三个是它的衍生问题。

第一条主线:把流量“漏斗化”

一次 HTTP 请求要经过的链路,每一层都该承担一部分过滤:

层 能做什么 不能做什么
DNS / 安全 攻击拦截、流量清洗 写业务逻辑
Nginx 反向代理、负载均衡、静态资源、Lua 做校验前置 承担重业务
Web 服务 业务聚合、页面渲染数据、流量筛选与限流 直接扛全部写流量
RPC / 库存 真正的基础服务,量已被削到很小 直接面对用户流量

🔑 原则只有两条:校验前置(能在外层拦掉的,绝不放到内层)与分层过滤(每层只放行下一层能接住的量)。佘志东把这条画成“流量漏斗”,许令波把它总结成“4 要 1 不要”。

第二条主线:4 要 1 不要

原则 为什么 具体做法
数据要尽量少 传输、压缩、编码都耗 CPU;调下游还有序列化 页面去掉装修、只传必要字段;少读库、少调依赖
请求数要尽量少 每个请求都有握手、DNS、连接数限制 合并 CSS/JS(一个 URL 用逗号拼多个文件,服务端动态合并)
路径要尽量短 每多一个节点多一次连接与不确定性 强依赖的应用合并部署,把 RPC 变成进程内调用
依赖要尽量少 弱依赖在极端情况下会拖垮主链路 系统分级:0 级系统不强依赖 1 级系统(支付 0 级、优惠券 1 级,极端时降级优惠券)
不要有单点 单点 = 没备份,风险不可控 服务无状态化(状态与机器解耦、配置动态化);存储靠冗余

路径短的价值可以直接算:假设每个节点可用性 99.9%,5 个节点串起来是 0.999^5 ≈ 99.5%——每多一跳,可用性就掉一截,延迟同理。

⚠️ 这些原则是“尽量”,不是“绝对”。许令波举过一个反例:把首屏 CSS 内联进 HTML 能少一个请求(符合“请求少”),但页面变大(违背“数据少”)。他的做法是只内联首屏 CSS,其余仍走外链——架构是平衡的艺术,脱离场景的原则都是空谈。

第三条主线:隔离

秒杀流量最怕的不是自己挂,而是把正常交易拖下水。淘宝内部的说法很直白:0.01% 的爆品可能影响 99.99% 的普通商品售卖。

隔离维度 做法 目的
业务隔离 活动提报:预估流量、并发、库存,据此决定扩容/降级/限流 先有数,再有方案
系统隔离 核心系统(详情、交易)物理隔离部署;末端系统经削峰后不隔离 把成本花在会被冲击的地方
数据隔离 热点商品独立缓存/独立库;库存单独放 Redis 不让热点行锁拖垮整库

🔑 隔离的判据是“这个系统会不会被秒杀流量直接打到”:会被打到 → 物理隔离;不会被(已削峰)→ 共享即可。全链路复制一套不现实,也没必要。

第四条主线:限购是库存的“过滤网”

佘志东有句话点得很透:限购之于库存,就像秒杀之于下单——前者都是后者的过滤网和保护伞。

维度 限制什么 例子
商品维度 活动库存、地区投放 只在北京/上海投放,各地区库存隔离
个人维度 同一用户/手机号/收货地址/设备 IP 每天 1 单、每单 1 件、每月 2 件

设计上,用户提单先过限购(原子扣减活动库存),通过后才去扣真实库存——这样到库存系统的流量已经与库存量匹配,“多出来的请求”在限购层就失败了。

减库存:三种时机,三种代价

时机 做法 优点 致命问题
下单减库存 下单即扣,事务保证不为负 最精确、逻辑最简单、性能最好 恶意下单:对手把库存全部拍下不付款,商品“卖不出去”
付款减库存 付款时才扣 不会被恶意占库存 超卖:100 件库存可能 300 人下单成功,付款时大面积失败
预扣库存 下单预扣、保留 10 分钟、超时释放 兼顾体验与占用 恶意买家 10 分钟后再来;需限购+反作弊兜底

秒杀为什么选“下单减库存”:抢到就是赚到,下单不付款的比例很低;商品数量少、时间短、逻辑单一,用最简单的方式换性能最划算。日常商品则常用预扣(机票、电影票的“有效付款时间”就是预扣)。

防库存为负的三板斧:

-- ① 应用层事务判断(减后为负则回滚)
-- ② 库存字段设为无符号整数(减成负数直接报错)
-- ③ CASE WHEN 条件更新
UPDATE item SET inventory = CASE WHEN inventory >= ? THEN inventory - ? ELSE inventory END;

⚠️ ③ 只是“不会为负”,它不告诉你这次扣减是否成功——真正下单还要看 affected rows。很多“库存扣了但订单没生成”的事故,就是把 UPDATE 执行成功当成了扣减成功。

代码:三种扣减实现,超卖与少卖

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

const (
	stock  = 100 // 活动库存
	buyers = 200 // 并发买家,每人买 1 件
)

// 方案 A:先查后扣(读与写不是一个原子操作)
func naive() (success int64, left int64) {
	var s int64 = stock
	seen := make([]int64, buyers)
	var phase1 sync.WaitGroup
	phase1.Add(buyers)
	for i := 0; i < buyers; i++ {
		go func(i int) {
			seen[i] = atomic.LoadInt64(&s) // 大家先都读一遍
			phase1.Done()
		}(i)
	}
	phase1.Wait()

	ready := make(chan struct{})
	var phase2 sync.WaitGroup
	var ok int64
	phase2.Add(buyers)
	for i := 0; i < buyers; i++ {
		go func(i int) {
			defer phase2.Done()
			<-ready
			if seen[i] > 0 { // 基于“读到的旧值”判断
				atomic.AddInt64(&s, -1)
				atomic.AddInt64(&ok, 1)
			}
		}(i)
	}
	close(ready)
	phase2.Wait()
	return ok, s
}

// 方案 B:原子条件扣减(查询与扣减在同一把锁内)
func atomicDecr() (success int64, left int64) {
	var mu sync.Mutex
	left = stock
	var ok int64
	var wg sync.WaitGroup
	wg.Add(buyers)
	for i := 0; i < buyers; i++ {
		go func() {
			defer wg.Done()
			mu.Lock()
			if left > 0 { // 判断与扣减不可分割
				left--
				ok++
			}
			mu.Unlock()
		}()
	}
	wg.Wait()
	return ok, left
}

// 方案 C:预扣(Redis 原子扣)+ 异步落库(模拟 10% 落库失败/进程崩溃)
func reserveThenPersist() (reserved int64, dbLeft int64, lost int) {
	reserved = stock // 预扣阶段:100 件全部被锁住
	success := 0
	for i := 0; i < int(reserved); i++ {
		if i%10 == 0 { // 每 10 单有 1 单没落库(崩溃/消息丢失)
			continue
		}
		success++
	}
	dbLeft = stock - int64(success) // 数据库里还剩 10 件
	return reserved, dbLeft, int(reserved) - success
}

func main() {
	fmt.Printf("库存 %d,%d 个并发买家(每人 1 件)\n\n", stock, buyers)

	okA, leftA := naive()
	oversold := int64(0)
	if leftA < 0 {
		oversold = -leftA
	}
	fmt.Printf("%-22s 成功 %3d,剩余 %4d,超卖 %3d,少卖 0\n", "A 先查后扣", okA, leftA, oversold)

	okB, leftB := atomicDecr()
	fmt.Printf("%-22s 成功 %3d,剩余 %4d,超卖 0,少卖 0\n", "B 原子条件扣减", okB, leftB)

	reserved, dbLeft, lost := reserveThenPersist()
	fmt.Printf("%-22s 预扣 %3d,落库 %3d,数据库剩 %3d,少卖 %3d\n",
		"C 预扣+异步落库", reserved, int(reserved)-lost, dbLeft, lost)

	fmt.Println("\n三种方案分别对应三个坑:超卖、没问题、少卖(库存被锁死)")
}

本机实测(macOS,Apple M1,Go 1.27):

库存 100,200 个并发买家(每人 1 件)

A 先查后扣                 成功 200,剩余 -100,超卖 100,少卖 0
B 原子条件扣减               成功 100,剩余    0,超卖 0,少卖 0
C 预扣+异步落库              预扣 100,落库  90,数据库剩  10,少卖  10

三种方案分别对应三个坑:超卖、没问题、少卖(库存被锁死)

读数:

方案 结果 病因
A 先查后扣 200 人全部成功,库存 -100 查询与扣减非原子 + 并发请求无序(超卖两大原因)
B 原子条件扣减 100 成功、100 失败、库存 0 判断与扣减在同一临界区,两个问题一起解决
C 预扣 + 异步落库 预扣 100、落库 90、DB 剩 10 少卖:库存被锁死不释放,需超时回补

🔑 超卖的根因只有两个:查询与扣减不是一个原子操作、并发请求无序。解法也就两条:要么让“判断+扣减”原子(DB 条件更新、行锁、Lua 脚本),要么让它有序(按商品维度的队列)。预扣方案不是解决超卖,它是把风险从“超卖”换成“少卖”,所以必须有超时回补。

库存扣减的三种实现与取舍

实现 原子性 性能 什么时候用
DB 行锁 / 条件更新 强(事务) 差(同一行串行,并发越高 TPS 越低、RT 越高) 有复杂 SKU 联动、必须事务
分布式锁(Redis/ZK) 强 中 不推荐:锁有效期两难——太短业务没跑完就释放,太长一旦释放失败,所有线程阻塞到过期
Redis + Lua(EVALSHA) 强(单线程串行执行脚本) 好 逻辑单一的库存扣减(秒杀首选)

Redis 方案的工程细节:

细节 做法 原因
脚本预加载 SCRIPT LOAD 得到 sha1,业务里用 EVALSHA EVAL 每次传脚本文本:浪费网络 + Redis 每次重新编译
脚本里读写顺序 先 GET 判断,最后 DECRBY Lua 不会自动回滚,写命令放最后就不需要回滚
脚本内容 if not c_s or tonumber(c_s) < n then return 0 end; redis.call('decrby', key, n) 判断与扣减在一次执行里完成
库存与库一致性 本地缓存只放静态数据;库存放 Redis,主动失效 + 定时兜底(如 3 秒) 秒杀不要求库存的“精确一致读”,缓存旧一点可接受

⚠️ 数据库热点行的连锁反应值得单独记住:并发越高,等待行锁的线程越多,TPS 下降、RT 上升,最终一个热点商品拖慢整个库。缓解手段是“热点隔离到独立库/独立缓存 + 应用层按商品排队 + 合并高频更新(如 lastmodifytime 只保留最后一次)”。

削峰、限流与防刷

手段 做什么 注意
预约 活动前收集意向,预估流量上限、发资格 预约量本身就是容量输入
答题 / 验证码 拉长请求间隔、拦掉脚本 题库要随机,别被穷举
消息队列削峰 请求先入队,按库存能力匀速消费 队列入队后要告诉用户“排队中”,否则体验差
Nginx 限流 按 IP/URI 做网关级限流 单机限流,集群要乘实例数
应用层限流 按商品/用户维度限流 与业务语义结合,比 IP 更准
防刷 Token 页面下发一次性 token,提单校验 token 要与用户、活动绑定,防转卖
黑名单 / 风控 设备指纹、历史行为、名单 误杀与漏杀的权衡(68.6 误杀与漏杀的权衡(待补))

兜底 PlanB

兜底 触发 动作
降级 依赖超时/错误率高 去掉弱依赖(优惠券、推荐、成交列表),只保下单主链路
限流 QPS 超阈值 拒绝多余请求,返回“当前人数过多,请稍后再试”
熔断 下游持续失败 快速失败,保护下游恢复(35.6 熔断与降级)
兜底数据 缓存击穿/库存服务不可用 返回“已售罄”或静态兜底页,绝不让页面白屏
预案开关 大促前 一键关闭非核心功能、切静态页

反模式

反模式 后果
用“先查库存再扣减” 超卖(本节实测 -100)
用分布式锁保库存 锁有效期两难;释放失败时全链路阻塞
预扣库存但没有超时回补 少卖:库存被锁死,用户买不到,商家卖不出
把 UPDATE 成功当成扣减成功 库存没变但订单生成了(或反之)
按峰值扩容扛秒杀 成本不可接受,且下游仍会被打穿
只在应用层限流 多实例时实际阈值 = 单机阈值 × 实例数
秒杀与日常交易共用一套库/集群 0.01% 的爆品拖垮 99.99% 的业务
没有降级与兜底 一次依赖抖动就是白屏 + 客诉

自检

Q1 为什么“付款减库存”会导致超卖,而“下单减库存”不会?

▸ 答案:付款减库存在下单阶段不占库存,100 件商品可以被 300 人同时下单成功(下单只校验“当前还有货”,没有原子扣减),到付款时才扣,后 200 人付款失败。下单减库存在下单时就用事务/条件更新把库存扣掉,扣到 0 之后的请求直接失败,所以不会超卖。代价是下单减库存要面对“恶意下单占库存”。

Q2 秒杀为什么选“下单减库存”,而机票/电影票用“预扣”?

▸ 答案:秒杀商品“抢到就是赚到”,下单不付款的比例很低,且库存少、时间短、逻辑单一,用最简单的下单减库存换性能最划算。机票/电影票的决策周期长(要填信息、比价、支付),必须给用户保留一段时间(预扣),否则座位会被反复占用;它们的超时释放与回补是业务流程的一部分。

Q3 预扣方案为什么必须配“超时回补”,不回补会怎样?

▸ 答案:预扣把库存先锁住(Redis 减到 0),但后续落库/支付可能失败。如果不回补,这 10 件库存永远“锁死”:用户看到售罄,商家实际还有货——就是少卖(本节实测:预扣 100、落库 90、DB 剩 10)。回补的触发是“预扣后 N 分钟未完成支付”,回补动作要幂等(25.4 幂等设计与去重),否则重复回补会造出“虚拟库存”。

Q4 为什么“路径要尽量短”能同时提升可用性和性能?

▸ 答案:可用性上,每多一个节点就多一次独立故障概率:5 个 99.9% 的节点串起来只剩 0.999^5 ≈ 99.5%。性能上,每个节点都要建连接、做序列化/反序列化、排队,路径越长延迟越高、抖动越大。所以把强依赖的应用合并部署(RPC 变进程内调用)是“一举两得”,代价是部署耦合与独立扩缩容能力下降。

关联