真正可用的分布式锁必须满足互斥性、防死锁(自动过期)、解铃还须系铃人(仅加锁者可释放);redis需用原子set命令配合lua脚本实现加解锁与续期,etcd则依赖raft强一致性与lease+txn cas机制,选型取决于业务对一致性与延迟的容忍度。

直接用 sync.Mutex 在微服务里加锁毫无意义——它只锁得住单个进程,跨 Pod、跨机器时完全失效。真正能用的分布式锁必须依赖外部存储,且满足三个硬条件:互斥性、防死锁(自动过期)、解铃还须系铃人(只能由加锁者释放)。Redis 和 etcd 是目前最主流的两个选择,选哪个取决于你对一致性、延迟和运维成本的实际容忍度。
Redis 加锁必须用原子 SET,别信 SetNX 封装
很多 Go 客户端(比如 go-redis/v9)提供 SetNX(ctx, key, val, ttl) 方法,看似方便,但底层可能 fallback 成 SETNX + EXPIRE 两步操作。中间若进程崩溃或网络中断,SETNX 成功而 EXPIRE 失败,锁就永远卡住。
- 必须强制走原子命令:
SET lock:order:123 "a1b2c3" EX 30 NX,返回"OK"才算抢锁成功,nil表示失败 -
EX是秒级,PX是毫秒级,必须显式指定,否则锁不会自动释放 -
value必须是每个 goroutine 独立生成的唯一标识,推荐uuid.NewString();写死成"1"或复用字符串,后续所有校验都失效 - Go 里调用
client.Set(ctx, key, value, ttl).Result()更可靠,它默认走原子SET,不拆指令
解锁和续期必须用 Lua 脚本,GET + DEL 不是原子操作
很多人写完 GET 判断值相等再调 DEL,这在高并发下必然出错:A 拿着锁还没删,B 查到过期去抢,A 一删就把 B 刚 set 的锁干掉了——这不是 bug,是竞态必然结果。
- 解锁脚本必须原子执行:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:key abc123 - 续期脚本同样要校验:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("EXPIRE", KEYS[1], ARGV[2]) else return 0 end - 调用时传参顺序必须严格:
KEYS放 key,ARGV放 value 和 TTL;TTL 传整数,Lua 里不能当字符串比 - 返回
int64(0)表示校验失败,此时业务应立即中止,而不是重试——锁已不属于你
etcd 锁更适合强一致性场景,但 lease 必须手动保活
Redis 主从异步复制可能导致脑裂(两个节点同时认为自己持锁),etcd 基于 Raft 日志同步,天然线性一致,适合金融扣款等不允许任何冲突的链路。但它 API 更重,延迟略高,且 lease 不会自动续期。
- 加锁必须走
Txn+CAS:先OpGet读当前值,再Compare(Version(key), "=", 0)确保 key 未被创建,然后OpPut写入带 lease ID 的 value - lease ID 必须由 etcd 分配(
cli.Grant(ctx, 15)返回),不能自己拼时间戳或随机数——否则无法续期、无法自动回收 - 续租必须另起 goroutine 调
client.KeepAliveOnce(ctx, leaseID),并监听LeaseResponseChan;收到ErrLeaseExpired必须立刻放弃锁,不能等 context 超时 - 解锁不是无条件
Delete,而是构造Txn.If(Value == myValue).Then(OpDelete()),否则 panic 后 defer 可能跳过,恢复后又执行一次,删错 key
真正难的不是加锁,而是锁生命周期和业务逻辑的绑定:续期 goroutine 必须随锁释放而停止,否则变成“幽灵续期”;TTL 要设为业务 P99 × 2,太短易掉锁,太长故障恢复慢;Redis 单点故障会直接让锁失效,生产环境至少用 Sentinel 或 Cluster。这些细节没处理好,锁就只是个心理安慰。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











