Gin中sync.Mutex无法防止秒杀超卖,因其仅为单进程本地锁,多实例部署时互不感知;必须用Redis等外部存储实现分布式锁,且需NX参数、唯一value、Lua原子续期与解锁。

为什么 Gin 里直接用 sync.Mutex 挡不住秒杀请求
因为 sync.Mutex 只在单个进程内起作用,Gin 启多个实例(比如部署 4 个 Pod)时,每个实例都有自己的锁,彼此完全不感知。1000 个并发请求打进来,可能全被不同实例放行,库存照样超卖。
你看到“加了锁但还是超卖”,大概率就是掉进了这个坑——它根本不是分布式锁,只是本地锁幻觉。
- 真实场景中,必须依赖外部存储(如 Redis)实现跨进程互斥
- 哪怕只用单节点 Redis,也比
sync.Mutex强;集群环境下更得靠 Redis 的原子命令兜底 - Gin 中间件里塞
sync.Mutex,最多防住同一实例内的重复提交,对秒杀没实际意义
redis.Client.Set 加锁时漏掉 NX 参数的后果
写成 client.Set(ctx, key, "token", 30*time.Second) 看似没问题,但这是覆盖式写入:A 拿到锁还没释放,B 请求一来就直接把 key 覆盖掉,A 和 B 同时进入临界区。
正确做法是强制用 NX(Not eXists)保证“仅当 key 不存在才设置”,再叠加 EX 控制过期:
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
err := client.Set(ctx, lockKey, token, &redis.Options{
Expiration: 30 * time.Second,
NX: true, // 关键!
}).Err()- 返回
redis.Nil表示抢锁失败(别人已持有),不是错误,要正常处理 - 别用
SETNX单独命令——它不支持设过期时间,得额外调EXPIRE,两步非原子,中间可能崩 - value 必须是全局唯一字符串(推荐
uuid.NewString()),不能写死"1"或时间戳,否则解锁时无法校验归属
Gin handler 里怎么安全启动续期 goroutine
锁过期时间设 30 秒,但秒杀逻辑可能因 GC、网络抖动卡住 35 秒——没续期就等于自动丢锁,后续请求趁虚而入。
续期不能无脑跑,得绑定当前请求生命周期,避免 handler 返回后还在后台续:
ctx, cancel := context.WithCancel(r.Context())
defer cancel() // handler 结束时停掉续期
<p>// 启动看门狗
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 用 Lua 原子续期:GET 校验 + EXPIRE
script := redis.NewScript("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end")
res, _ := script.Run(ctx, client, []string{lockKey}, token, 30).Result()
if res != int64(1) {
// 续期失败,说明锁已不属于当前 holder,主动退出
cancel()
return
}
case <-ctx.Done():
return
}
}
}()- 续期频率建议为 TTL 的 1/3(30 秒锁,每 10 秒续一次),太频繁增加 Redis 压力,太慢容易断
- 必须用 Lua 脚本做“读-判-改”原子操作,单纯
GET后EXPIRE有竞态风险 - 续期 goroutine 要监听
ctx.Done(),handler 出错或超时返回时立即停止,防止僵尸续期
解锁为什么必须用 Lua 脚本而不是 DEL
常见错误是 handler 里先 GET 判断 value 是否匹配,再调 DEL——这两步之间可能被其他客户端抢占,导致删掉别人的锁。
Redis 官方明确要求:解锁必须原子执行“校验 value + 删除 key”,唯一可靠方式是 Lua:
script := redis.NewScript("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end")
script.Run(ctx, client, []string{lockKey}, token)- 别手写
if val == token { client.Del(...) },Go 层判断和 Redis 操作之间存在时间窗口 - 脚本返回 1 表示删除成功,0 表示锁已被他人持有或已过期,业务层据此决定是否告警
- 即使 handler panic,也要在 defer 里执行这个 Lua 解锁,否则锁残留,后续所有请求永久阻塞
真正难的不是写对这几行代码,而是让续期、解锁、异常中断三者严丝合缝——任何一环松动,秒杀库存就可能从 100 变成 -200。

















