Administrator
发布于 2022-08-29 / 2339 阅读
45

高并发秒杀系统的设计要点

面试官问: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,秒杀开始时几乎不回源。

先到这

《高并发秒杀系统的设计要点》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考