用 Redis 分片 + Lua 原子脚本预扣库存再异步落库,是 Go 秒杀系统防超卖最稳路径;MySQL 行锁因磁盘 IO、锁争抢和网络延迟在万级并发下 TPS 难超 500,单 key Redis 则因单线程串行处理成瓶颈,分片加 Lua 可实现原子扣减与负载均衡。

直接说结论:用 Redis 分片 + Lua 原子脚本预扣库存,再异步落库,是 Go 秒杀系统防超卖最稳的路径。纯数据库行锁或乐观锁在万级并发下会迅速成为瓶颈,而单 key Redis 又会打满单线程。
为什么 MySQL 行锁扛不住秒杀流量
很多人第一反应是 SELECT ... FOR UPDATE + 事务,但实际压测中,TPS 很难超过 500。根本不是 SQL 写得不对,而是:
- 每笔请求都触发磁盘 IO 和 redo log 刷盘,吞吐上不去
- 所有请求争抢同一行(比如
goods_id=1001),形成“锁队列”,一个慢查询拖垮整条链路 - 网络延迟放大锁等待时间,200ms 的锁等待会让下游 goroutine 大量堆积
Go 的高并发优势在这里反而会加剧问题——goroutine 越多,排队等锁的越多。
Redis 预扣库存必须分片 + Lua 原子执行
把库存全塞进 stock:1001 这个 key 是典型错误。Redis 单线程处理命令,所有 DECR 都串行,瞬间就成瓶颈。
立即学习“go语言免费学习笔记(深入)”;
正确做法:
- 按商品 ID 分片,例如
stock:1001:0、stock:1001:1…共 8 或 16 片 - 初始化用
INCRBY批量写入总库存,再平均分配到各分片 - 扣减时随机选一个分片执行 Lua 脚本,保证「读值 → 判断 → 扣减 → 设置过期」三步不可拆分
示例 Lua 脚本(必须整体发送,不能拆成多个 redis-cli 命令):
if redis.call("GET", KEYS[1]) == false then
return -1
end
local stock = redis.call("DECR", KEYS[1])
if stock < 0 then
redis.call("INCR", KEYS[1])
return -2
end
return stock
Go 层必须做限流和请求合并,不能只靠下游扛压
Redis 再快,也扛不住 10 万 QPS 直接砸过来。Go 服务本身就得设闸:
- 用
golang.org/x/time/rate按商品维度限流,比如rate.NewLimiter(200, 5)控制每秒最多 200 个请求、允许突发 5 个 - 对同一商品的密集请求做合并(burst merge):把 50 个扣减请求聚合成 1 个批量操作发给 Redis
- 网关层(如 Nginx)已限流 ≠ Go 层可以放松——网关失败返回快,但 Go 层若不做控制,仍会大量创建 goroutine 和连接
别忘了兜底:定时任务每分钟扫描 seckill:stock:*:0 等 key,比对 Redis 剩余库存与 MySQL 实际值,发现不一致立刻补偿。注意不是看 key 是否存在,而是读 GET 其值——key 可能被误删或自动过期。
最容易被忽略的一点:Lua 脚本里用 redis.call("INCR", KEYS[1]) 回滚时,必须确保该 key 当前确实是负值且未被其他逻辑覆盖;否则一次回滚可能把别人刚加进来的库存又加回去。真实环境建议加日志埋点,记录每次 DECR 返回值和最终决策。


















