
两个服务实例同时扣同一件商品的库存:各查到剩余 10 件,各减 1,都写回 9 件——超卖。单进程里的互斥锁管不到别的进程,需要一把所有实例都认的锁:分布式锁。本文讲它要满足什么、Redis 怎么实现、实现里的坑,最后给选型建议。
一把合格的分布式锁
五个要求,前三条是底线,后两条决定工程可用性:
- 互斥:同一时刻只有一个持有者
- 防死锁:持有者崩溃,锁最终自动释放
- 防误删:只能释放自己持有的锁
- 可重入(可选):同一持有者重复加锁能成功
- 续期(可选):业务没跑完,锁不能先过期
Redis 加锁:一条 SET 命令

Redis 加锁就一条命令:
SET order:10086 <唯一token> NX PX 30000
三个参数各管一件事:NX 保证 key 不存在才写入,互斥靠它;PX 设过期时间,持有者崩溃后锁自动消失,防死锁靠它;value 放唯一 token(随机串),是防误删的凭据。
不能拆成两步:先 SETNX 再 EXPIRE。两条命令之间进程崩溃,锁就永远不释放。SET 的 NX PX 是原子的单条命令,这正是它取代 SETNX + EXPIRE 组合的原因。
释放锁:必须用 Lua

释放要校验 value 是不是自己的 token,防止误删。场景:锁过期了,别人拿到锁,你这时才执行 DEL,删掉的是别人的锁。先 GET 再 DEL 不原子,中间锁可能易主:
// 错误:Get 和 Del 是两条命令,中间锁可能已过期并易主,会误删别人的锁
if client.Get(ctx, key).Val() == token {
client.Del(ctx, key)
}
正确做法是 Lua 脚本,比较和删除在 Redis 端一次执行:
到这里,Redis 分布式锁的核心就齐了:一个 NX PX 的 SET 加锁,一个校验后删除的 Lua 释放。剩下两个问题属于”工程可用性”。
看门狗:业务没跑完,锁不能过期

TTL 定多长都尴尬。定短了:业务没跑完锁就过期,另一个实例进来,两个”持有者”同时执行;定长了:持有者崩溃后,锁要等很久才释放。折中方案:TTL 定一个合理值(如 30s),后台 goroutine 每隔 TTL/3 续期一次。持有期间锁永远不过期;持有者崩溃,看门狗随之消失,锁按 TTL 自动释放。
func (l *Lock) watchLoop(stop <-chan struct{}) {
ticker := time.NewTicker(l.ttl / 3)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
}
// 锁仍在自己名下才续期(Lua 原子校验,避免给他人续命)
if err := l.renew(ctx); err != nil {
continue // 瞬时网络错误,下一轮再试
}
}
}
续期脚本同样带 token 校验:锁已经易主就不续。另外注意:释放锁时先停掉看门狗,否则它会把已删除的锁”续活”。
可重入:hash 计数
同一实例重复加锁(A 方法里调 B 方法,都加同一把锁),第二次必须成功,否则自锁死。实现上把锁从字符串换成 hash:field 是 token,value 是持有深度。
释放时深度减一,归零才删除 key。一个容易犯的错:本地记录深度后,可重入的解锁跳过了 Redis 调用,导致本地深度和 Redis 计数对不上,最后一次释放会误判”仍被持有”。正确做法是每次解锁都走脚本递减,以 Redis 计数为准。
锁会丢:主从切换与 Redlock 争议
单节点 Redis 挂了,锁就没了。Redis 官方给过 Redlock:向 N/2+1 个独立节点加锁。但 Redlock 有著名的争议(Kleppmann vs antirez):它的安全性依赖时钟不跳变、GC 停顿有界、网络延迟有界,现实中这些假设可能被打破——客户端 GC 停顿几秒,锁过期,另一个实例拿到锁,两个”持有者”同时操作共享资源。antirez 反驳说时钟问题可以靠运维规避。这场争论没有官方定论。
工程上的共识是:Redlock 不比单节点”安全到哪去”,还要部署多套 Redis、慢约 7 倍。更实用的防线是 fencing token。
fencing token:锁失效后的最后防线
锁只能减少并发冲突,保证不了”旧持有者已停手”。做法:每次加锁返回递增 token,写数据时带上,数据端拒绝 token 小于已见最大值的请求。即使锁失效、两个持有者同时操作,旧持有者的写入也会被 token 校验挡掉。资金类场景建议锁 + token 双保险。
选型:Redis、ZooKeeper 还是 etcd
| 维度 | Redis | ZooKeeper | etcd |
|---|---|---|---|
| 一致性 | AP(最终一致) | CP(ZAB 强一致) | CP(Raft 强一致) |
| 加锁延迟 | 亚毫秒级 | 较高(多次往返) | 10~30ms |
| 锁丢失风险 | 主从切换可能丢 | 基本无 | 基本无 |
| 公平锁 | 默认非公平 | 天然公平 | 支持 |
| 运维成本 | 低 | 中 | 较高(3+ 节点) |
| 典型场景 | 高并发,可容忍短暂不一致 | 强一致、金融 | 金融级、云原生 |
一句话:高并发、能容忍极端情况短暂不一致,用 Redis;资金、对账这类强一致场景,用 ZooKeeper 或 etcd,别在 Redis 上硬扛。差距是数量级的:Redis 加锁亚毫秒,etcd 走 Raft 要 10~30ms。
Go 生态:现成的库
| 库 | Star | 定位 |
|---|---|---|
| go-redsync/redsync | ~4k | Redlock 多主节点;注意争议,慢约 7 倍 |
| bsm/redislock | ~1.8k | 单 Redis 事实标准;无自动续期,需手动 Refresh |
| go-zero RedisLock | 框架内置(go-zero 33k) | go-zero 用户零依赖;无重试机制 |
单 Redis 高并发选 bsm/redislock;多主节点容错只有 redsync(Redlock);go-zero 用户直接用内置。想完全掌控行为(看门狗、可重入、OnLost 回调),自己实现也不难——本文前面就是全部要点。
最后
分布式锁的选型本质是拿一致性换性能。以及,动手之前先想想能不能不用锁:幂等设计、唯一约束、乐观锁版本号,往往更简单——每引入一把分布式锁,就多一个故障点。



