直接用 sync.Mutex 在 Gin 接口里锁不住库存,因为它仅作用于单机进程内,多实例部署时无法约束跨机器并发请求,导致超卖;必须用 Redis 分布式锁或 Lua 脚本实现原子扣减。

为什么直接用 sync.Mutex 在 Gin 接口里锁不住库存
因为 sync.Mutex 是进程内锁,只对当前实例有效。Gin 部署在多台机器(或多个 Go 进程)时,每个请求可能落到不同实例,sync.Mutex 完全失效,库存照样超扣。你看到“抢到 1001 单”,实际数据库只写了 1000 条,说明已有并发写入绕过了锁。
常见错误是把锁声明为全局变量然后在 handler 里 mu.Lock() —— 这只能防住单机 goroutine 冲突,对分布式流量毫无约束力。
- 单机部署且确定永不横向扩展 → 可用
sync.Mutex,但必须绑定到具体 SKU 粒度(比如muMap[skuID]),不能全局一把锁 - 任何生产环境(哪怕只有一台服务器)→ 必须用 Redis 分布式锁,否则上线即翻车
- 别信“先查再扣”的 if 判断:数据库读写之间存在时间窗口,两个请求同时查到 stock=1,都会执行扣减
go-redis + redsync 实现可重入、带自动续期的锁
直接用 SETNX 手写锁容易漏掉原子性、锁释放校验、死锁预防等细节。redsync 封装了 Redlock 算法,支持多 Redis 节点容错,且默认启用看门狗机制——只要业务没结束,锁就会自动续期,避免因处理超时导致锁提前释放。
关键配置项:
-
Expiry:建议设为业务最大耗时 × 2(例如库存扣减+订单生成最长 800ms,则设 2s) -
RetryDelay:获取锁失败后重试间隔,设 50–100ms,避免密集轮询打爆 Redis -
Quorum:红锁要求多数节点成功才算加锁成功,生产环境至少配 3 个 Redis 实例
示例片段(非完整初始化):
mutex := rs.NewMutex("sku-lock:1001", rs.WithExpiry(2*time.Second), rs.WithRetryDelay(100*time.Millisecond))
if err := mutex.Lock(); err != nil {
c.JSON(429, gin.H{"error": "busy, try again"})
return
}
defer mutex.Unlock() // 注意:必须在 handler 返回前调用,不能放在 goroutine 里Redis Lua 脚本扣库存比加锁更轻量、更原子
如果只是单纯扣减整数型库存(如 stock 字段),用 Lua 脚本一条命令完成“读-判-减”三步,比加锁+DB事务更快、更可靠。它天然具备原子性,且不依赖额外锁服务。
典型脚本:
local stock = redis.call("GET", KEYS[1])
if not stock or tonumber(stock) <= 0 then
return -1
end
redis.call("DECR", KEYS[1])
return tonumber(stock) - 1- 调用方式:
rdb.Eval(ctx, script, []string{"sku:1001"}).Int64() - 返回值为扣减后的剩余库存,-1 表示已售罄,无需额外查库
- 注意:Lua 脚本无法触发 MySQL 事务,所以仅适用于“纯缓存库存”场景;若需同步落库,仍要配合分布式锁 + DB 事务
锁粒度选商品 ID 还是 SKU ID?看业务是否允许多规格并发
电商中“iPhone 15 Pro”是一个商品,但 “256GB 白色” 和 “512GB 黑色” 是两个独立 SKU。若锁在商品 ID 上,所有规格抢购会串行排队;锁在 SKU ID 上,不同规格可并行,吞吐量提升数倍。
- 绝大多数场景应锁
sku_id,不是product_id - 只有当业务强制要求“某商品所有规格总库存不可超限”(比如限量款总发放 1000 份),才考虑商品级锁 + 额外计数器
- 锁 key 命名必须带业务前缀和唯一标识,如
"lock:sku:1001",避免不同模块 key 冲突
真正难的不是写锁代码,而是判断哪段逻辑必须锁、锁到什么粒度、以及锁住之后该不该立刻落库还是延后异步处理。很多线上超卖,根源不在锁没加,而在锁的范围和后续动作不匹配。


















