2016 年,Martin Kleppmann(《DDIA》作者)公开质疑 Redis 作者 Antirez 提出的 Redlock 算法,引发了分布式领域最著名的论战之一。Redlock 到底安不安全?本文不复述论战双方的观点,而是给出完整可运行的代码:一个工业强度的 Redis 分布式锁实现(原子加锁、安全释放、看门狗续期),一个能真实复现「GC 停顿导致两个客户端同时持锁」的失效实验,以及作为终极补救的 Fencing Token 完整方案。
一、为什么单机锁不够用
用单节点 Redis 做分布式锁是最常见的实践:SET lock_key unique_value NX PX 30000。但当这个 Redis 主节点宕机、哨兵完成故障切换后,未同步到新主的锁记录会丢失——此时两个客户端可能同时认为自己持有同一把锁。Redlock 的目标是在不依赖单个 Redis 节点可用性的前提下解决这个问题:向 5 个独立 Redis 主节点申请锁,多数派(≥3)成功才算加锁成功。
二、工业强度的分布式锁实现
2.1 原子加锁与安全释放
先给出正确的基础实现。两个最易犯错的点:加锁必须原子(SET 的 NX PX 参数而非 SETNX+EXPIRE 两条命令),释放必须校验身份(Lua 脚本保证「比较并删除」原子性,防止误删别人的锁):
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var ErrLockNotHeld = errors.New("lock not held")
// unlockScript 原子执行"比较值并删除",防止误删他人锁
const unlockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`
// renewScript 原子执行"比较值并续期"
const renewScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end`
type Lock struct {
client *redis.Client
key string
token string // 唯一身份令牌
ttl time.Duration
stopWatch chan struct{} // 停止看门狗的信号
}
func randomToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// Acquire 原子加锁:SET key token NX PX ttl
func Acquire(ctx context.Context, client *redis.Client,
key string, ttl time.Duration) (*Lock, error) {
token := randomToken()
ok, err := client.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, err
}
if !ok {
return nil, ErrLockNotHeld
}
l := &Lock{client: client, key: key, token: token,
ttl: ttl, stopWatch: make(chan struct{})}
go l.watchdog() // 启动看门狗自动续期
return l, nil
}
// Release Lua 脚本原子释放
func (l *Lock) Release(ctx context.Context) error {
close(l.stopWatch)
res, err := l.client.Eval(ctx, unlockScript,
[]string{l.key}, l.token).Int()
if err != nil {
return err
}
if res == 0 {
return fmt.Errorf("锁已被他人持有或已过期,拒绝释放")
}
return nil
}
2.2 看门狗续期:应对业务执行超过 TTL
业务执行时间超过锁的 TTL 是生产事故的常客。看门狗模式在后台以 TTL/3 的间隔自动续期,业务不结束锁不过期:
// watchdog 每 ttl/3 检查一次,只要还持有锁就续期
func (l *Lock) watchdog() {
ticker := time.NewTicker(l.ttl / 3)
defer ticker.Stop()
for {
select {
case <-l.stopWatch:
return
case <-ticker.C:
ctx, cancel := context.WithTimeout(
context.Background(), 2*time.Second)
res, err := l.client.Eval(ctx, renewScript,
[]string{l.key}, l.token,
l.ttl.Milliseconds()).Int()
cancel()
if err != nil || res == 0 {
// 锁已丢失:应触发业务侧的中止逻辑
fmt.Printf("[看门狗] 锁 %s 已丢失,业务必须立即中止!\n", l.key)
return
}
}
}
}
注意看门狗发现锁丢失时的注释——这是绝大多数实现忽略的关键:续期失败不能只打日志,必须通过 context 取消或 channel 通知业务层中止,否则业务会在无锁保护下继续写共享资源。
2.3 Redlock:多节点多数派加锁
// Redlock 向 5 个独立 Redis 实例申请锁,多数派成功才算成功
type Redlock struct {
clients []*redis.Client
key string
token string
ttl time.Duration
}
func (r *Redlock) Acquire(ctx context.Context) (bool, time.Duration) {
r.token = randomToken()
quorum := len(r.clients)/2 + 1
start := time.Now()
success := 0
for _, c := range r.clients {
cctx, cancel := context.WithTimeout(ctx,
r.ttl/10+50*time.Millisecond) // 单节点超时远小于 TTL
ok, err := c.SetNX(cctx, r.key, r.token, r.ttl).Result()
cancel()
if err == nil && ok {
success++
}
}
drift := time.Since(start)
validity := r.ttl - drift - time.Millisecond*2 // 扣除时钟漂移余量
if success >= quorum && validity > 0 {
fmt.Printf("Redlock 加锁成功: %d/%d 节点确认, 有效持锁时间 %v\n",
success, len(r.clients), validity)
return true, validity
}
// 未达多数派:向所有节点发起释放(包括刚才失败的)
r.releaseAll(ctx)
return false, 0
}
func (r *Redlock) releaseAll(ctx context.Context) {
for _, c := range r.clients {
c.Eval(ctx, unlockScript, []string{r.key}, r.token)
}
}
Redlock 的关键细节是 validity 计算:加锁耗时(含网络往返)必须从上锁 TTL 中扣除,再减去时钟漂移余量(Redis 文档建议 ±2ms),剩余才是业务可安全使用的时间窗口。
三、锁失效复现:GC 停顿与时钟跳变
Kleppmann 质疑的核心不是算法逻辑,而是 Redlock 依赖的时序假设在真实世界不成立。下面的模拟程序复现最经典的失效场景:
// 场景复现:客户端 A 持锁后遭遇长时间 GC 停顿,
// 锁过期,客户端 B 获得锁,A 苏醒后误以为自己仍持锁
func simulateGCPause() {
store := newMockStore() // 模拟带自动过期的锁存储
resource := &SharedResource{}
// T1: 客户端 A 获得锁,TTL 30s
store.set("lock", "A", 30*time.Second)
fmt.Println("T+0s: 客户端 A 获得锁 (TTL 30s)")
// T2: A 开始写资源,写到一半被 GC 冻结 45 秒
go func() {
resource.write("A: step1")
time.Sleep(45 * time.Second) // 模拟 Stop-The-World GC
resource.write("A: step2") // 危险!此刻锁已不属于 A
fmt.Println("T+45s: 客户端 A 苏醒,继续写入 (但它早已失去锁)")
}()
// T3: 30 秒后锁过期,客户端 B 获得锁
time.Sleep(31 * time.Second)
if store.expired("lock") {
store.set("lock", "B", 30*time.Second)
fmt.Println("T+31s: 锁过期,客户端 B 获得锁")
resource.write("B: step1")
fmt.Println("T+31s: 客户端 B 开始写入 → 与 A 的苏醒写入冲突!")
}
time.Sleep(20 * time.Second)
}
同样的失效路径还有三种:网络延迟(A 的写请求在网络上漂了比 TTL 更久)、时钟跳变(NTP 校时让 Redis 服务器的时钟突然前进,锁提前过期)、进程暂停(虚拟机迁移、容器被 cgroup 冻结、甚至笔记本合盖休眠)。这些都不是小概率事件——在 Java 应用中一次 Full GC 停顿 30 秒以上的案例屡见不鲜。
四、Fencing Token:资源侧的终极防线
Kleppmann 给出的正解:不要指望锁绝对可靠,让资源自身拒绝过期的写。每次加锁颁发一个单调递增的 Fencing Token,资源记住见过的最大 Token,拒绝一切更小 Token 的写入:
// FencingStore 带 Fencing Token 的锁服务
type FencingStore struct {
mu sync.Mutex
holder string
counter uint64 // 单调递增的 Token 生成器
expiry time.Time
}
func (s *FencingStore) Acquire(clientID string, ttl time.Duration) (uint64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.holder != "" && time.Now().Before(s.expiry) {
return 0, false // 锁被持有
}
s.counter++ // 关键:每次授予都颁发更大的 Token
s.holder = clientID
s.expiry = time.Now().Add(ttl)
return s.counter, true
}
// FencedResource 校验 Token 的共享资源
type FencedResource struct {
mu sync.Mutex
lastToken uint64 // 已处理的最大 Token
data []string
}
func (r *FencedResource) Write(token uint64, value string) error {
r.mu.Lock()
defer r.mu.Unlock()
if token <= r.lastToken {
return fmt.Errorf("拒绝写入: token %d 已过期 (当前最大 %d)",
token, r.lastToken)
}
r.lastToken = token
r.data = append(r.data, value)
return nil
}
用 Fencing Token 重放前面的失效场景:A 持 Token 33 被 GC 冻结 → B 获 Token 34 并完成写入 → A 苏醒后以 Token 33 写入,被资源直接拒绝。即使锁服务完全失效(两个客户端同时持锁),数据也不会损坏——安全性不再依赖时序假设。这就是 ZooKeeper 的 zxid、Etcd 的 revision 天然适合做分布式锁的原因:它们自带单调递增序号,而 Redis 没有,Redlock 需要额外的数据存储来生成 Token。
五、论战双方的论点梳理
| 议题 | Kleppmann 的质疑 | Antirez 的回应 |
|---|---|---|
| 时钟依赖 | 系统时钟跳变会让锁提前过期,Redlock 对此无防护 | 用单调时钟(CLOCK_MONOTONIC)且运维避免大步进校时可缓解 |
| 进程暂停 | GC/网络延迟可导致持锁者假死,锁过期后产生双主 | 承认存在,但认为可通过缩短 TTL、validity 窗口控制概率 |
| 正确性论证 | 算法缺乏形式化证明,异步模型下时序假设不成立 | 算法是工程方案而非理论成果,多数派 + 超时在实践中足够 |
| 替代方案 | 用 Fencing Token,或直接用 ZooKeeper/Etcd | Redis 场景下额外引入 ZK 运维成本过高 |
事后来看,双方都部分正确:Antirez 的多数派思路在工程上可用(redisson、redis-py 都内置了实现),Kleppmann 的时序批判在理论上成立(生产上确有因 GC 导致的 Redlock 双写事故)。结论不是「Redlock 能不能用」,而是「你的业务能否承受锁偶发失效的代价」。
六、分布式锁选型决策
- 任务去重、防并发执行(可容忍偶发重复):单 Redis 节点锁 + 看门狗即可,简单高效。例如定时任务防重复触发,重复一次的代价只是多跑一遍幂等任务。
- 资金扣减、库存变更(不容忍并发写):用 Etcd/ZooKeeper 锁 + 自带 revision 作为 Fencing Token,资源侧强制校验。这是 Kleppmann 路线的完整落地。
- 已有 Redis 基础设施且需多数派保证:Redlock + 业务侧 Fencing(例如数据库乐观锁 version 列充当 Token)。
- 能用数据库就不用锁服务:
SELECT ... FOR UPDATE或唯一约束往往是最简单且最正确的方案,单机数据库的锁没有分布式时序问题。
七、常见误区
- 误区一:SETNX 之后立刻 EXPIRE。两条命令之间客户端宕机,锁永不过期。必须用 SET 的 NX PX 单命令形式。
- 误区二:DEL 直接释放。不校验 Token 的释放会删掉别人的锁。Lua 脚本原子比较是底线。
- 误区三:看门狗续上就万事大吉。续期只防「业务正常但跑得久」,防不了「业务被 GC 冻结」——冻结时看门狗线程同样被冻结。
- 误区四:Redlock 多数派 = 强一致。多数派只解决节点宕机,不解决时钟跳变与进程暂停,这两类失效与节点数量无关。
八、总结
本文给出了分布式锁的三层完整实现:正确的单机锁(原子加锁、Lua 释放、看门狗续期)、Redlock 多数派加锁(含 validity 窗口计算)、以及 Fencing Token 资源侧防线,并用代码复现了 GC 停顿导致的锁失效全过程。分布式锁的终极教训是:锁服务只能降低冲突概率,数据安全的最后防线必须建立在资源自身。当你无法确定锁是否可靠时,让资源变得足够聪明——这正是 Fencing Token 的思想,也是 Kleppmann 与 Antirez 论战留给所有工程师的礼物。
参考文献
- M. Kleppmann. How to do distributed locking. martin.kleppmann.com, 2016.
- S. Sanfilippo (antirez). Is Redlock safe?. antirez.com, 2016.
- Redis Documentation. Distributed Locks with Redis.
- K. Bhatia. Distributed Locking: When Redlock Is Not Enough. 2021.