在大促秒杀系统里,最让后端工程师夜不能寐的两个词,一个是“超卖”(Over-selling),另一个是“击穿”(Cache Breakdown)。
超卖意味着实际库存只有 100 件,系统却由于并发脏读扣减了 150 件,最终不得不向用户打电话道歉赔偿,带来直接的资金与声誉损失;而击穿则意味着秒杀开抢的第一毫秒,上万个并发请求直接绕过缓存打穿到 MySQL,引发全表行级锁争用,数据库瞬间锁死宕机。
很多新手常犯的错误,就是以为在应用层写个分布式锁(如 Redisson 或原生 SETNX),或者在 SQL 里加一句 WHERE stock >= quantity 就完事了。在几千上万 QPS 的秒杀洪峰面前,只要请求真正碰到了 MySQL 的写盘操作,数据库的吞吐天花板(通常只有一两千 TPS)就会立刻成为全站的断头路。
核心原则只有一条:秒杀库存扣减必须 100% 在 Redis 内存中完成,绝不允许一个未扣减成功的写请求打到数据库。
一、库存预热与对齐的标准执行流程
要让 Redis 成为一道坚不可摧的防火墙,关键在于大促开抢前 10 分钟的库存预热与状态对齐:
[ 秒杀开抢前 15 分钟 (T-15m) ]
│
▼ (定时预热任务)
[ 从 MySQL 锁定待秒杀商品库存 ]
├── 状态标记为 "预热中" (防止后台继续被修改)
└── 生成全局唯一的商品秒杀版本号 (VersionID)
│
▼ (写入 Redis 集群)
[ 灌入 Redis 内存 Key ]
├── string: seckill:stock:sku_1001 -> "100" (真实库存)
├── hash: seckill:users:sku_1001 -> {} (防重防刷排重表)
└── string: seckill:soldout:sku_1001 -> "0" (售罄熔断标记)
│
▼ (T-1m 预热完毕)
[ 预热校验核对无误,开放前端倒计时 ]
二、Lua 脚本实现原子扣减与防超卖双保险
在 Redis 中扣减库存,绝对不能分步执行 GET stock 然后判断再 DECR。网络往返存在天然的时间窗口,多并发下必发超卖。必须使用一段严谨的原子 Lua 脚本,在一个事务内完成防刷校验与库存扣减:
— KEYS[1]: 库存键名 (如 seckill:stock:sku_1001)
— KEYS[2]: 防刷排重键名 (如 seckill:buyers:sku_1001)
— ARGV[1]: 当前下单用户唯一 UID
— ARGV[2]: 扣减数量 (例如 1)
local stock_key = KEYS[1]
local buyers_key = KEYS[2]
local uid = ARGV[1]
local quantity = tonumber(ARGV[2])
— 1. 幂等校验:同一用户在同一次秒杀中仅限抢购一次
if redis.call('HEXISTS', buyers_key, uid) == 1 then
return -1 — 重复抢购,直接拦截
end
— 2. 读取当前剩余库存
local current_stock = tonumber(redis.call('GET', stock_key) or "0")
— 3. 判断库存是否充足
if current_stock < quantity then
return 0 — 库存不足,已售罄
end
— 4. 原子扣减库存并记录成功买家
redis.call('DECRBY', stock_key, quantity)
redis.call('HSET', buyers_key, uid, 1)
return 1 — 扣减成功,发放购买凭据
三、Go 语言实现的高并发秒杀拦截器
在 Go 业务端,我们在内存中维护了一套极轻量的单机本地售罄标记(In-Memory Soldout Flag)。一旦某个商品在 Redis 中返回了库存为 0,该标记在本地进程内立刻置为 true。后续涌进来的数万并发请求,直接在本地内存返回“已售罄”,连一次 Redis 网络 I/O 都省掉了。
package seckill
import (
"context"
"errors"
"fmt"
"sync"
)
type SeckillManager struct {
mu sync.RWMutex
soldoutFlags map[int64]bool // 本地售罄快速熔断缓存
redisClient RedisLuaExecutor
}
type RedisLuaExecutor interface {
ExecSeckillLua(ctx context.Context, skuID int64, userID string, quantity int) (int, error)
}
func NewSeckillManager(client RedisLuaExecutor) *SeckillManager {
return &SeckillManager{
soldoutFlags: make(map[int64]bool),
redisClient: client,
}
}
// TryAcquireSeckillQuota 秒杀配额获取入口
func (m *SeckillManager) TryAcquireSeckillQuota(ctx context.Context, skuID int64, userID string, quantity int) error {
// 第一道检查:单机内存直接阻断(0 网络开销)
m.mu.RLock()
if m.soldoutFlags[skuID] {
m.mu.RUnlock()
return errors.New("商品已抢光 (本地快速熔断)")
}
m.mu.RUnlock()
// 第二道检查:打入 Redis 执行原子 Lua 脚本
status, err := m.redisClient.ExecSeckillLua(ctx, skuID, userID, quantity)
if err != nil {
return fmt.Errorf("redis execution error: %w", err)
}
switch status {
case 1:
// 扣减成功,可以异步投递至 Kafka,由后台平滑入库落盘
return nil
case 0:
// 已经售罄:立刻更新本地标记,阻断后续并发打扰 Redis
m.mu.Lock()
m.soldoutFlags[skuID] = true
m.mu.Unlock()
return errors.New("手慢了,商品已抢光")
case -1:
return errors.New("您已参与过本次秒杀,请勿重复下单")
default:
return errors.New("系统繁忙,请稍候再试")
}
}
四、异步回写数据库与最终一致性避坑
在 Redis 扣减成功后,真正的“持久化订单创建”绝对不能同步去写 MySQL,而是采用可靠消息队列(如 Kafka / RabbitMQ)进行削峰异步回写:
五、架构师高并发防超卖 Checklist
在将秒杀扣库存链路推向生产大促前,必须逐项核对以下架构防线:
通过这套“提前预热 + 内存阻断 + 原子 Lua + 异步削峰 + 数据库硬约束”的组合拳,我们在双 11 前的三轮全链路压测中实现了秒杀接口 12,000 QPS 零故障、零超卖、零数据库阻塞,真正把高并发系统的防线铸成了铜墙铁壁。
网硕互联帮助中心




评论前必须登录!
注册