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/EtcdRedis 场景下额外引入 ZK 运维成本过高

事后来看,双方都部分正确:Antirez 的多数派思路在工程上可用(redisson、redis-py 都内置了实现),Kleppmann 的时序批判在理论上成立(生产上确有因 GC 导致的 Redlock 双写事故)。结论不是「Redlock 能不能用」,而是「你的业务能否承受锁偶发失效的代价」。

六、分布式锁选型决策

七、常见误区

八、总结

本文给出了分布式锁的三层完整实现:正确的单机锁(原子加锁、Lua 释放、看门狗续期)、Redlock 多数派加锁(含 validity 窗口计算)、以及 Fencing Token 资源侧防线,并用代码复现了 GC 停顿导致的锁失效全过程。分布式锁的终极教训是:锁服务只能降低冲突概率,数据安全的最后防线必须建立在资源自身。当你无法确定锁是否可靠时,让资源变得足够聪明——这正是 Fencing Token 的思想,也是 Kleppmann 与 Antirez 论战留给所有工程师的礼物。

参考文献

  1. M. Kleppmann. How to do distributed locking. martin.kleppmann.com, 2016.
  2. S. Sanfilippo (antirez). Is Redlock safe?. antirez.com, 2016.
  3. Redis Documentation. Distributed Locks with Redis.
  4. K. Bhatia. Distributed Locking: When Redlock Is Not Enough. 2021.
← 返回文章列表