# 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 单独设过期时间：

```go
// 错误写法：两条命令分开，中间崩溃就死锁
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 方法，第三个参数就是过期时间：

```go
// 抢锁：原子完成，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` 释放，是最常见的错误：

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

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

实测演示：

```
客户端2 直接 DEL: 1（把客户端1 的锁删了）
```

### 校验 value 再删

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

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

go-redis 调用：

```go
// 解锁脚本：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 不可达或锁已易主。锁在过期时间后自动释放，业务方要自己处理「锁可能已经没了」的情况——这就是分布式锁和本地锁的区别：本地锁持有期间互斥是绝对的，分布式锁的互斥有保质期。


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/redis-distrubuted-lock/  

