Go + Redis 分布式锁

目录

Go + Redis 分布式锁面试要点整理。覆盖:为什么需要分布式锁、SET NX EX 原子加锁、Lua 脚本解锁、过期时间与看门狗续期、主从切换与 Redlock。

为什么需要分布式锁

sync.Mutex 只保护一个进程内的 goroutine:锁状态存在内存里,别的进程看不见。服务多实例部署后,同一段逻辑跑在多个进程甚至多台机器上,进程间没有共享内存,需要一把所有实例都认的锁。

Redis 是公共存储,所有实例都能访问。把锁做成 Redis 里一个带过期时间的 key,是最常见的做法。

典型场景:

  • 定时任务调度:多个实例都可能触发同一个任务,只允许一个实例执行。
  • 扣库存/秒杀:多个实例同时扣减同一个商品的库存。
  • 防止重复处理:消息消费、对账任务,同一批数据只处理一次。

分布式锁要满足三个性质:

  • 互斥:同一时刻只有一个客户端持有。
  • 不死锁:持有者崩溃后锁能自动释放(靠过期时间)。
  • 可识别:解锁时能确认「这把锁是我的」(靠唯一 value)。

后两条的坑在下面几节。

加锁:SET NX EX

最初的写法:SETNX

SETNX(SET if Not eXists,不存在才设置)天生适合抢锁:key 不存在时写入并返回 1,已存在时什么都不做返回 0。返回 1 就是抢到了。

Redis 2.6.12 起 SETNX 被标记为弃用,官方建议用 SET 的 NX 参数替代。写法演变上还是从它讲起。

两步写法的坑

新手写法是 SETNX 加锁、EXPIRE 单独设过期时间:

// 错误写法:两条命令分开,中间崩溃就死锁
ok, _ := rdb.SetNX(ctx, "lock:order", "token").Result() // 只设置,不带过期
if ok {
	rdb.Expire(ctx, "lock:order", 30*time.Second) // 单独设过期时间
	// 如果两条命令之间进程崩溃:
	// key 没有过期时间,永远不会释放,其他实例永远抢不到锁
}

SETNX 和 EXPIRE 是两条独立命令。执行完 SETNX、还没执行 EXPIRE 时进程崩溃,key 留在 Redis 里没有过期时间。锁的「不死锁」性质直接失效。

原子写法:SET NX EX

Redis 2.6.12 起,SET 命令自带 NX、EX 选项,一步完成「不存在才写 + 设过期时间」。go-redis 对应 SetNX 方法,第三个参数就是过期时间:

// 抢锁:原子完成,key 不存在才写入,同时带 30 秒过期
ok, err := rdb.SetNX(ctx, "lock:order", "token-1", 30*time.Second).Result()

对应 Redis 命令是 SET lock:order token-1 NX EX 30。ok 为 true 抢到锁,false 说明锁被别人持有。

实测行为(Redis 7.4):

客户端1 加锁: ok=true
客户端2 加锁: ok=false(锁已被持有)
锁 TTL: 30s

value 必须是唯一标识

value 不能写死。每个抢锁方用自己唯一的标识(随机串、UUID、进程 ID + 协程 ID),解锁时靠它确认「这把锁是我的」。原因见解锁一节。

解锁:Lua 脚本校验

直接 DEL 的坑

拿到锁后直接 DEL 释放,是最常见的错误:

// 错误写法:可能删掉别人的锁
rdb.Del(ctx, "lock:order")

场景:客户端 A 拿到锁,业务执行超过 30 秒,锁过期被 Redis 自动删除。客户端 B 抢到锁。此时 A 才执行完,一个 DEL 把 B 的锁删了。B 的临界区还在跑,C 又抢到锁——互斥被破坏,两个客户端同时进入临界区。

实测演示:

客户端2 直接 DEL: 1(把客户端1 的锁删了)

校验 value 再删

解锁前先确认 value 是自己的,匹配才删。但「GET 比较 + DEL」是两条命令,中间锁可能易主,比较和删除之间又出窗口。正确做法是把两步写进一个 Lua 脚本,Redis 保证脚本整体原子执行:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

go-redis 调用:

// 解锁脚本:value 匹配才删除,返回 1;不匹配返回 0
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end`

// 用别人的 value 解锁:返回 0,锁还在
n, _ := rdb.Eval(ctx, unlockScript, []string{"lock:order"}, "token-2").Int()
// 用自己的 value 解锁:返回 1,锁删除
n, _ = rdb.Eval(ctx, unlockScript, []string{"lock:order"}, "token-1").Int()

实测行为:

用错误 value 解锁: 0(锁还在)
用正确 value 解锁: 1(锁已删)

这就是 value 必须唯一的原因:脚本靠它区分「锁是不是我的」。value 写死的话,任何客户端都能用同一个 value 解锁别人的锁。

过期时间与看门狗

过期时间怎么定

锁必须带过期时间,否则持有者崩溃后锁永远不释放。但过期时间本身是双刃剑:

  • 设短了:业务还没执行完锁就过期,另一个客户端抢到锁,两个客户端同时进临界区。
  • 设长了:持有者崩溃后,其他客户端要等很久才能抢到锁。

没有完美的固定值。业务执行时间波动大时,固定过期时间必然在「提前过期」和「等待过久」之间二选一。

看门狗:自动续期

解决思路是让锁「活多久由业务说了算」:持有者活着就不断续期,死了没人续期,锁自然过期。这就是看门狗(watchdog)机制。

Redisson(Java 最常用的 Redis 客户端)内置看门狗,源码行为(RedissonLock / RenewalTask):

  • 不传 leaseTime 加锁时,默认过期时间 30 秒(lockWatchdogTimeout = 30 * 1000)。
  • 加锁成功后启动后台定时任务,每 lockWatchdogTimeout / 3 = 10 秒续期一次,把过期时间重置回 30 秒。
  • 续期脚本先校验锁的持有者还是自己(hexists),锁已易主就停止续期。
  • 解锁或持有者崩溃,定时任务停止,锁最多 30 秒后自动过期。

效果:业务跑多久锁就续多久,崩溃后锁在 30 秒内自动释放,两边都照顾到。

Go 生态的现状

Go 这边没有 Redisson 这种全家桶。go-redis 只提供原语,续期要自己写:抢锁成功后起一个 goroutine 定时执行 Lua 续期脚本(校验 value 匹配后 PEXPIRE),解锁时停止。

redsync(Go 的 Redlock 实现)默认不带自动续期:锁过期时间默认 8 秒,业务超过 8 秒锁就没了。用之前要确认自己是否需要续期,需要就自己实现。

主从切换丢锁与 Redlock

单实例的隐患

上面的方案都假设「Redis 只有一个实例」。主从架构下有个经典丢锁场景:

  1. 客户端 A 在主节点抢到锁,写入主节点。
  2. 主节点还没把这条写同步到从节点,就宕机了。
  3. 从节点晋升为主节点,此时它没有锁的 key。
  4. 客户端 B 在新主节点抢到同一把锁。

A 和 B 同时认为自己持有锁,互斥被破坏。Redis 主从复制默认是异步的,这个窗口真实存在。

这个场景官方文档(Distributed locks 页面)直接定性为 SAFETY VIOLATION,根因是主从复制异步——新主上的状态是旧主宕机前某一时刻的滞后快照。锁的互斥核心是「所有参与者对谁持锁达成一致」,异步复制保证不了这个一致,一致性要靠共识协议(Raft/Paxos),Redis 不提供。「主从 + SET NX EX」的丢锁窗口是结构性的,调配置消不掉。

Redlock 算法

Redlock 的思路:不依赖单点,向 N 个互相独立的 Redis 节点(官方建议 5 个)同时抢锁,超过半数(N/2 + 1)成功才算抢到。单个节点宕机不影响大局,少数节点丢锁不影响互斥。

流程:

  1. 记录当前时间,向所有节点发 SET key value NX PX ttl。
  2. 计算整个抢锁过程耗时,如果超过半数节点成功、且总耗时小于锁的过期时间,才算抢到。
  3. 抢到后,锁的实际有效时间 = 过期时间 - 抢锁耗时。
  4. 抢锁失败(节点数不够或耗时超限),向所有节点发解锁脚本清理。

争议:Redlock 是否安全

Redlock 从发布起就有争议,两篇必读文章:

  • Martin Kleppmann《How to do distributed locking》:核心论点是分布式锁的过期时间无法防住「持有者被 GC 暂停、锁已过期、恢复后继续写」的场景,锁服务必须提供单调递增的 fencing token,由存储端校验拒绝旧 token 的写。他认为 Redlock 没有这个机制,且依赖时钟假设,不安全。
  • antirez(Redis 作者)《Is Redlock safe?》:逐条反驳。fencing token 的前提是存储端本身可做线性化校验,那不如直接用 token 方案;Redlock 只要求各节点时钟相对速度有界(比如数 5 秒误差不超过 10%),不要求绝对时间一致,用单调时钟即可满足。

争议的实质是系统模型假设不同,没有公认结论。面试能说出双方论点即可。

工程上的务实选择

  • 锁只用于效率(防重复计算、防重复通知),丢了锁只是多花点钱:单实例 Redis + SET NX EX 就是首选——简单、快、现成,Redlock 的复杂度不值得。锁的「互斥有保质期」在这个场景无所谓。
  • 锁用于正确性(扣库存、写文件):别靠锁兜底,让资源端自己防并发(数据库唯一约束、版本号 CAS、幂等键),或者换 etcd/zk 这类强一致锁,锁只做第一道闸。
  • 主从架构下,即使不用 Redlock,也要知道丢锁窗口存在,别把锁的互斥当成绝对保证。

按丢锁的代价选:重复干活,Redis 锁直接上;数据错误,配资源端兜底或换强一致锁。

常见问题

锁的过期时间设多少合适?

没有标准答案。业务执行时间稳定就设略大于最坏执行时间;波动大就上续期机制,别赌固定值。

为什么解锁要用 Lua 脚本?

「比较 value + 删除」拆成两条命令中间有窗口:比较完、删除前锁可能已过期易主,删掉的是别人的锁。Lua 脚本在 Redis 里原子执行,比较和删除之间插不进其他命令。

看门狗续期失败会怎样?

续期失败说明 Redis 不可达或锁已易主。锁在过期时间后自动释放,业务方要自己处理「锁可能已经没了」的情况——这就是分布式锁和本地锁的区别:本地锁持有期间互斥是绝对的,分布式锁的互斥有保质期。

编辑此页

目录