
etcd 的 CompareAndSwap 是分布式锁最稳的起点
etcd 天然支持带租约(lease)和版本检查(revision)的原子操作,CompareAndSwap(CAS)是构建可靠分布式锁的核心。它不像 redis 的 SET key value NX PX 那样依赖命令原子性+客户端超时逻辑,而是由服务端保证“检查条件 + 写入”一步完成。
常见错误是直接用 Put 写入 key 而不校验前置状态,导致多个节点同时抢锁成功。必须配合 OpCmp 和 OpPut 构成事务调用:
txn := cli.Txn(ctx) txn.If(clientv3.Compare(clientv3.Version(key), "=", 0)). Then(clientv3.OpPut(key, lockValue, clientv3.WithLease(leaseID))). Else(clientv3.OpGet(key))
- 锁 key 必须带唯一标识(如
"lock:order:123"),避免跨业务冲突 - lease ID 一定要由 etcd 分配(
cli.Grant),不能自己生成时间戳或随机数——否则无法自动续期或释放 - 每次续租必须用同一个 lease ID 调用
cli.KeepAlive,且需处理context.DeadlineExceeded等连接中断场景 - etcd v3.5+ 支持
WithPrevKV,抢锁失败时能直接拿到当前持有者的 value,方便诊断谁卡住了锁
redis 实现锁要绕开 SETNX + EXPIRE 这个经典坑
单独用 SETNX 加锁再调 EXPIRE 设过期时间,不是原子操作:如果进程在两者之间崩溃,锁就永久残留。Redis 官方推荐的方案是 SET key value NX PX 30000,但 go 生态里很多旧库(比如 go-redis/redis v7 之前)默认不封装这个语义,容易误用。
真正安全的做法是用 lua 脚本封装加锁逻辑,确保 set + expire 原子执行:
立即学习“go语言免费学习笔记(深入)”;
script := redis.NewScript(` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2], "NX") end`) // 调用时传入 key、唯一 value(如 uuid)、毫秒过期时间
- value 必须全局唯一(建议用
uuid.NewString()),不能是固定字符串——否则释放锁时无法判断所有权 - 释放锁必须用 Lua 脚本比对 value 后再
DEL,否则可能误删别人持有的锁 - Redis Cluster 模式下,key 必须落在同一 slot;若用 hash tag(如
{lock:order}:123)可保证路由一致性 - 网络分区时,Redis 锁可能脑裂:一个客户端认为已获锁并写入,另一个客户端因连接中断重试后也拿到锁。这属于 cap 中的权衡,无法靠客户端逻辑完全规避
Go 客户端选型直接影响锁可靠性
etcd 官方 clientv3 和 go–redis/v9 是目前最值得信赖的选择。前者 API 明确区分 lease、txn、watch,后者把 SET 原子命令封装进 SetNX 方法,并内置了自动续期(WithContext + WithExpiration)。
要避开这些库:
-
coreos/etcd(已归档)——缺少 context 支持,超时控制全靠 goroutine sleep,易泄漏 -
garyburd/redigo——无原生 pipeline/Lua 封装,手动拼接命令易出错 - 某些轻量封装库(如
redislock)——内部用GET+DEL判断锁状态,非原子,且没处理时钟漂移
用 clientv3 时注意:cli.Get 默认不返回 revision,但实现锁续约或 watch 释放事件时,必须显式加 clientv3.WithRev(rev) 参数,否则可能监听到旧版本变更。
锁释放时机比加锁更难处理
加锁失败可以重试,但锁释放一旦出错,就会变成死锁。最常被忽略的是:锁释放必须和加锁在同一个上下文生命周期内完成,且不能依赖 defer。
- 不要写
defer unlock()—— 如果业务逻辑 panic 或提前 return,unlock 可能根本没执行;更糟的是,如果 unlock 本身 panic,defer 会吞掉原始 panic - 正确做法是用
context.WithTimeout包裹整个临界区,超时后主动调用 unlock,并记录 warn 日志 - etcd 场景下,watch 锁 key 的删除事件(
clientv3.WithPrefix+clientv3.WithRev)比轮询更高效,但要注意 watch channel 可能断连,需重连 + 重设 revision - Redis 场景下,如果业务耗时接近锁过期时间,必须在临界区中段主动调用
refresh(即 Lua 脚本更新 TTL),且 refresh 失败应立即退出,不能硬扛到超时
分布式锁真正的复杂点不在“怎么拿”,而在“怎么确认它真没了”。无论是 etcd 的 revision 监听,还是 Redis 的 Lua 校验,本质都是在对抗网络不可靠和时钟不同步——这些细节不压到代码里,光靠设计文档撑不住真实流量。