Develop

分布式锁的实现与选择:Redis、ZooKeeper 与 etcd 方案对比

✎ -- 字 🕐 -- 分钟
字号

分布式锁的实现与选择:Redis、ZooKeeper 与 etcd 方案对比

如果你在写单体应用,synchronizedmutex 就够了。但一旦服务拆分、多实例部署,本地锁立刻失效——两个 Pod 各锁各的,谁也拦不住谁。这时候你需要一把所有实例都看得见、争得了的锁,也就是分布式锁。

看起来很简单:往 Redis 里写个 key,谁先写入谁拿到锁。但分布式环境下的锁远比想象中复杂——节点挂了怎么办?锁过期了但业务没跑完?主从切换导致锁丢失?这些问题一不小心就会在凌晨三点变成生产事故。本文带你逐一拆解。

分布式锁的核心要求

一把合格的分布式锁至少要满足三个基本属性:

  • 互斥性:同一时刻只能有一个客户端持有锁。这是最基本的要求,做不到互斥就不叫锁。
  • 无死锁:即使持有锁的客户端崩溃或网络分区,锁最终也要能被释放,不能永远卡死。
  • 容错性:锁服务本身不能是单点故障。只要锁服务集群中大多数节点存活,锁就可以正常获取和释放。

在实际生产中,还会衍生出更多要求:可重入、阻塞/非阻塞获取、锁粒度控制、性能开销、运维复杂度等。不同的业务场景对这些属性的优先级不同,这正是我们需要理解不同方案的原因。

方案一:Redis 分布式锁

基础实现:SET NX + 过期时间

最早的做法是两步:SETNX key value 然后 EXPIRE key ttl。这有两个问题:一是两步操作不是原子的,如果在 SETNX 成功后、EXPIRE 之前进程崩溃,锁就永远不会过期;二是 value 没有任何标识,解锁时可能误删别人的锁。

Redis 2.6.12 起,SET 命令支持 NXPX 参数,一行搞定原子加锁:

SET lock_key unique_value NX PX 30000

解锁时不能直接 DEL,必须用 Lua 脚本先校验 value 是否匹配:

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

这套基础方案满足了互斥和无死锁,但在 Redis 单机或主从模式下,容错性是有缺陷的。

Redlock 算法

Redis 作者 antirez 提出了 Redlock 算法,核心思路是用 N 个独立的 Redis 节点(通常 5 个),客户端依次向每个节点尝试获取锁,当成功获取超过半数(N/2 + 1)节点的锁,且在有效时间内完成,才算获取成功。

Redlock 的关键参数:

  • 锁的有效时间:比如 30 秒,需要在所有节点上设置一致
  • 获取超时时间:向单个节点请求的超时,远小于锁的有效时间,比如 10 毫秒
  • 时钟漂移补偿:实际锁的有效时间 = TTL - 获取总耗时 - 时钟漂移补偿

但 Redlock 并非银弹。Martin Kleppmann 在 2016 年发文指出,Redlock 在 GC 停顿、时钟跳跃等场景下,安全性并非绝对保证。如果你的场景涉及金钱、库存扣减等强一致性需求,Redlock 不够安全;但对于缓存更新、幂等控制这类场景,它已经足够。

Go 语言 Redlock 实现

package main

import (
    "context"
    "fmt"
    "time"
    "github.com/go-redsync/redsync/v4"
    "github.com/go-redsync/redsync/v4/redis/goredis/v9"
    goredis "github.com/redis/go-redis/v9"
)

func main() {
    client := goredis.NewClient(&goredis.Options{
        Addr: "localhost:6379",
    })
    pool := goredis.NewPool(client)
    rs := redsync.New(pool)

    mutex := rs.NewMutex("order:create:lock",
        redsync.WithExpiry(10*time.Second),
        redsync.WithTries(3),
        redsync.WithRetryDelay(200*time.Millisecond),
    )

    ctx := context.Background()
    if err := mutex.LockContext(ctx); err != nil {
        panic(err)
    }
    defer func() {
        if ok, err := mutex.UnlockContext(ctx); !ok || err != nil {
            fmt.Println("unlock failed:", err)
        }
    }()

    fmt.Println("do some critical work here...")
    time.Sleep(5 * time.Second)
}

Watchdog 自动续期

Redis 锁的一个致命问题:锁的 TTL 是固定的,但业务执行时间不可预测。如果业务跑了 15 秒而锁的 TTL 只有 10 秒,锁自动释放后其他请求进来,你的"互斥"就形同虚设了。

解决方案是 Watchdog(看门狗)机制:加锁后启动一个后台协程,每隔 TTL/3 的时间检查业务是否还在持有锁,如果是,就续期。Redisson(Java 客户端)内置了这个功能,Go 生态中需要自己实现:

type WatchdogMutex struct {
    client  *redis.Client
    key     string
    value   string
    ttl     time.Duration
    stopCh  chan struct{}
}

func (w *WatchdogMutex) Lock(ctx context.Context) error {
    ok, err := w.client.SetNX(ctx, w.key, w.value, w.ttl).Result()
    if err != nil || !ok {
        return fmt.Errorf("lock failed")
    }
    w.stopCh = make(chan struct{})
    go w.renew(ctx)
    return nil
}

func (w *WatchdogMutex) renew(ctx context.Context) {
    ticker := time.NewTicker(w.ttl / 3)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            script := `
                if redis.call("GET", KEYS[1]) == ARGV[1] then
                    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
                end
                return 0
            `
            w.client.Eval(ctx, script, []string{w.key}, w.value, w.ttl.Milliseconds())
        case <-w.stopCh:
            return
        }
    }
}

方案二:ZooKeeper 分布式锁

临时顺序节点 + Watch 机制

ZooKeeper 的锁方案完全不同——它不依赖"写入一个 key 并设 TTL",而是利用 ZK 的临时顺序节点(EPHEMERAL_SEQUENTIAL)和 Watch 机制。

具体流程:

  1. 所有竞争者在一个指定路径(如 /locks/order)下创建临时顺序节点,ZK 自动分配递增序号,如 /locks/order/0000000001/locks/order/0000000002
  2. 客户端获取该路径下所有子节点,排序后检查自己是不是序号最小的那个
  3. 如果是最小的 → 获得锁;如果不是 → 对前一个节点设置 Watch,进入等待
  4. 前一个节点释放(客户端断开或主动删除)→ ZK 通知等待者 → 重新检查是否轮到自己

这套机制有天然的优势:

  • 无死锁:客户端崩溃 → 会话断开 → 临时节点自动删除 → 锁自动释放
  • 公平性:按序号排队,不会出现抢占饥饿
  • 通知机制:Watch 让等待者在锁释放时立即被唤醒,而非忙等(busy-wait)

Go 语言 ZK 锁实现

package main

import (
    "fmt"
    "sort"
    "strings"
    "time"
    "github.com/go-zookeeper/zk"
)

type ZkMutex struct {
    conn     *zk.Conn
    path     string
    lockPath string
}

func NewZkMutex(conn *zk.Conn, path string) *ZkMutex {
    return &ZkMutex{conn: conn, path: path}
}

func (z *ZkMutex) Lock() error {
    // Create ephemeral sequential node
    prefix := z.path + "/lock-"
    created, err := z.conn.CreateProtectedEphemeralSequential(prefix, []byte{}, zk.WorldACL(zk.PermAll))
    if err != nil {
        return err
    }
    z.lockPath = created

    for {
        children, _, err := z.conn.Children(z.path)
        if err != nil {
            return err
        }
        sort.Strings(children)

        // Extract our sequence number
        parts := strings.Split(created, "/")
        mySeq := parts[len(parts)-1]

        if children[0] == mySeq {
            // We are the first in line - got the lock
            return nil
        }

        // Watch the node ahead of us
        var prevNode string
        for i, child := range children {
            if child == mySeq && i > 0 {
                prevNode = children[i-1]
                break
            }
        }

        _, _, ch, err := z.conn.ExistsW(z.path + "/" + prevNode)
        if err != nil {
            return err
        }
        <-ch // Wait for the previous node to be deleted
    }
}

func (z *ZkMutex) Unlock() error {
    return z.conn.Delete(z.lockPath, -1)
}

ZooKeeper 方案的缺点是依赖较重。ZK 集群本身需要 3-5 台机器维护,且基于 Java,引入一个 ZK 集群只是为了做分布式锁,运维成本可能比业务本身还高。但如果你的架构中已经有了 ZK(比如用 Kafka 就自带 ZK 依赖),那它就是最自然的选择。

方案三:etcd 分布式锁

Lease + 事务 + Watch

etcd 是 CoreOS 开发的分布式 KV 存储,基于 Raft 共识算法。它的分布式锁思路与 ZK 类似但更现代:

  • 创建 Lease(租约),设置 TTL
  • 通过事务写入 key,条件是该 key 的 Revision 为 0(即不存在)
  • 后台协程自动续租(KeepAlive)
  • 其他竞争者通过 Watch 监听 key 的删除事件

Go 语言 etcd 锁实现

package main

import (
    "context"
    "fmt"
    "time"
    clientv3 "go.etcd.io/etcd/client/v3"
    "go.etcd.io/etcd/client/v3/concurrency"
)

func main() {
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        panic(err)
    }
    defer cli.Close()

    // Create a session (lease-based)
    session, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
    if err != nil {
        panic(err)
    }
    defer session.Close()

    // Create a mutex on the given key prefix
    mutex := concurrency.NewMutex(session, "/locks/order-create")

    ctx := context.Background()
    if err := mutex.Lock(ctx); err != nil {
        panic(err)
    }
    fmt.Println("got the lock, doing critical work...")
    time.Sleep(3 * time.Second)

    if err := mutex.Unlock(ctx); err != nil {
        panic(err)
    }
    fmt.Println("lock released")
}

如果想要更精细的控制,可以用 etcd 原生操作手写锁逻辑:

func TryLock(cli *clientv3.Client, key string, ttl int64) (clientv3.LeaseID, error) {
    ctx := context.Background()
    lease, err := cli.Grant(ctx, ttl)
    if err != nil {
        return 0, err
    }

    txn := cli.Txn(ctx).
        If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)).
        Then(clientv3.OpPut(key, "locked", clientv3.WithLease(lease.ID))).
        Else(clientv3.OpGet(key))

    resp, err := txn.Commit()
    if err != nil {
        return 0, err
    }
    if !resp.Succeeded {
        return 0, fmt.Errorf("lock held by another client")
    }
    return lease.ID, nil
}

etcd 的锁在 Go 生态中体验极佳——concurrency 包直接提供了 Mutex,一行代码加锁,还有自动续期。加上 etcd 基于 Raft 协议天然支持强一致性,安全性比 Redlock 高一个档次。

三种方案全方位对比

维度Redis(Redlock)ZooKeeperetcd
一致性最终一致性,不安全(GC/时钟影响)顺序一致性强一致性(Raft)
性能极高(内存操作)中等(磁盘 + 选举开销)中等(Raft 日志复制)
公平性非公平(随机抢占)公平(顺序节点排队)公平(FIFO 队列)
自动续期需自己实现 Watchdog不需要(Session 心跳)内置 Lease KeepAlive
锁释放主动释放 或 TTL 超时Session 断开即释放Lease 过期 或 主动释放
可重入需额外逻辑(ThreadLocal 计数)天然支持(同客户端可多次创建节点)需额外逻辑
运维复杂度低(Redis 常见组件)高(Java、选举、JVM 调优)中(Go 编写、部署简单)
最小集群单机可用,Redlock 需 5 节点3 节点(生产)3 节点(生产)
Go 生态成熟(go-redis, redsync)一般(go-zookeeper 不够活跃)官方 SDK,体验极佳
适用场景高性能、允许偶发冲突已有 ZK 生态、需严格排队云原生、Go 技术栈、强一致性需求

选型决策指南

没有银弹,但有一个实用的决策路径:

  1. 你的架构里已经有什么? 如果你已经在维护 ZK 集群(比如配合 Kafka),直接用 ZK 做锁是最省事的。如果你的服务注册发现用的是 etcd(Kubernetes 标配),那把 etcd 也用来做锁是最自然的。什么都没用?Redis 最常见、最简单。
  2. 你的场景能不能容忍偶尔的锁冲突? 缓存击穿保护、定时任务去重这类场景,Redis 完全够用。但如果是订单库存扣减、账户余额变更——必须用 etcd 或 ZK 这类共识算法保证的方案。
  3. 你的技术栈是什么? 全 Go 技术栈用 etcd 体验最好;Java 栈用 ZK 或 Redisson 都很成熟;Node.js/Python 用 Redis 生态最丰富。
  4. 要不要公平性? 如果你的锁竞争激烈,需要"先来后到"的公平排队,ZK 和 etcd 天然支持,Redis 做不到。

锁续期:99% 的事故都发生在这里

锁续期(或锁过期)是分布式锁最常出问题的地方。几个典型案例:

  • GC 停顿:Java 应用 Full GC 卡了 15 秒,Redis 锁的 TTL 只有 10 秒,锁早就自动释放了。GC 结束后你的代码继续执行,但锁已经被别人拿走了。这就是 Martin Kleppmann 批评 Redlock 不安全的关键论据。
  • 网络抖动:客户端和锁服务之间的网络短暂断开,心跳丢失,锁被判定为过期。
  • 业务超时:你以为 5 秒能跑完的批量操作,遇上数据量暴涨跑了 30 秒,锁在中间就释放了。

防御措施:

  1. Watchdog 自动续期:只要业务还活着,后台自动续期。Redisson 和 etcd concurrency 包都有内置支持,Redis 基础客户端需要自己实现。
  2. 锁粒度尽量细:不要用一把大锁锁住整个模块。比如扣库存,用 stock:sku_12345 而非 order:lock,减少竞争。
  3. 业务幂等设计:这是兜底手段。即使锁出了问题,重复执行同一操作也不会产生副作用。在锁里写入一个唯一请求 ID,下游用这个 ID 做幂等判断。
  4. 设置合理的超时:TTL 设置成业务 P99 耗时的 3-5 倍,给 Watchdog 足够的缓冲时间。

常见陷阱速查

陷阱现象修复
直接 DEL 而不校验 value误删别人的锁Lua 脚本 GET + 比较 + DEL
锁 TTL 太短业务没跑完锁就释放加 Watchdog 或增大 TTL
Redlock 用于强一致性场景偶发锁冲突导致数据错误切换到 etcd 或 ZK
忘记 defer Unlock业务 panic 后锁不释放总是 defer unlock,配合 TTL 兜底
可重入锁实现不正确同一个线程加锁两次导致死锁用 ThreadLocal / goroutine ID 计数器
Watchdog 协程泄漏goroutine 数量持续增长确保 Stop channel 在 Unlock 时关闭
主从切换丢锁Redis 主节点挂了,从节点还没同步到锁用 Redlock 多节点,或等 Redis 7.4 WAITAOF

总结

分布式锁是一个"简单概念、复杂实现"的典型案例。几行代码打天下很容易,但搞清楚你的锁在节点宕机、网络分区、GC 停顿这些极端情况下会怎么行为,才是区分熟练工程师和架构师的分水岭。

如果你的需求是高性能 + 可容忍偶发冲突,用 Redis + Watchdog 就够了,这是投入产出比最高的方案。如果你的需求是强一致性 + 零容忍,用 etcd 或 ZK,基于共识算法的方案从理论上保证了安全性。重点是搞清楚自己的需求落在哪个象限里,别用错了工具。

最后记住:分布式锁只解决"互斥"问题,不解决"幂等"问题。如果你的业务逻辑本身就是幂等的,锁的很多风险就降级为性能问题而非正确性问题。