面试官问:100 万人抢 1000 件商品怎么设计
前阵子做架构分享,被同事用一道经典题考住:"100 万并发抢 1000 件商品,怎么保证不超卖、系统还不挂?"这题看着老,但真要落地,每一层都有坑。我把我们实际做过的秒杀系统的设计要点拆开讲——不是教科书,是踩过坑的版本。
要点一:库存扣减,必须原子
最基础的错法是"先查库存再扣":
// 错误:查和扣不是原子,并发下必超卖
int stock = dao.getStock(itemId);
if (stock > 0) {
dao.updateStock(itemId, stock - 1); // 两个线程都看到 stock=1,都扣,超卖
}
正确做法是单条 SQL 里用条件更新,利用数据库原子性:
UPDATE seckill_stock
SET stock = stock - 1
WHERE item_id = ? AND stock > 0; -- 受影响行数=1 才算抢到
更进一步的方案是Redis + Lua 预扣减:把库存预热进 Redis,用 Lua 脚本保证"判断+扣减"原子,扛住入口洪峰,再异步落库。Lua 脚本在 Redis 里单线程执行,天然串行:
-- seckill.lua
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock <= 0 then return 0 end
redis.call('DECR', KEYS[1])
return 1
我们实测:纯 DB 扣减在 5 万 QPS 下就开始大量锁等待,P99 到 800 ms;前面加一层 Redis Lua 预扣,DB 只承接"已抢到"的 1000 笔落库,DB QPS 从 5 万降到千级。
要点二:防超卖,不光靠扣减
即使扣减原子,"超卖"还可能来自重复提交和库存回滚漏洞。我们加了两道防线:
- 用户维度去重:Redis 里
SETNX seckill:{itemId}:{userId},保证一人一单,重复请求直接拒绝。 - 支付超时回补:抢到后 15 分钟未支付,定时任务把库存加回。回补也要防并发,用带版本号的更新。
要点三:限流分层,把洪水拦在门外
100 万请求不可能都打到库存服务。我们做了四层漏斗:
| 层 | 手段 | 作用 |
|---|---|---|
| 接入层 | Nginx + OpenResty 限流 | 按 IP/令牌桶挡掉明显刷子,扛到 80 万 |
| 网关层 | Sentinel 集群限流 | 全局 QPS 上限,超出直接快速失败 |
| 应用层 | 本地令牌桶(Guava RateLimiter) | 单机限流,保护 JVM |
| 库存层 | Redis 原子扣减 | 真正决定谁抢到 |
实测:100 万并发进来,接入层+网关拦掉 99.7%,真正进到库存判定的只有约 3000 QPS,其中 1000 笔成功,其余快速返回"已售罄"。
要点四:热点隔离,别让秒杀拖垮正常业务
这是最容易翻车的地方——秒杀流量把普通商品详情页也打挂了。我们的做法是物理和逻辑双重隔离:
- 独立集群:秒杀服务单独部署,连独立的 Redis 实例和库存库,故障域不扩散。
- 独立缓存 key 空间:秒杀商品用
seckill:*前缀,和普通商品item:*分开,避免大 key 互相影响。 - 静态化:商品详情页提前生成静态页推到 CDN,秒杀开始时几乎不回源。
先到这
《高并发秒杀系统的设计要点》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。