Skip to content

分布式锁实现方案对比:Redis Redlock vs ZK 临时节点 vs Etcd Lease

一个锁引发的血案

先看一个真实场景:你的订单服务有两台实例,收到两个并发请求要对同一个订单取消退款。如果不用分布式锁,两笔退款同时发起,用户收到双倍退款,公司亏了,你被叫去喝茶。

分布式锁要解决的就是——多台机器之间,互斥访问共享资源。听起来简单,但实现起来坑一个接一个。今天把三种主流方案(Redis Redlock、ZK 临时顺序节点、Etcd Lease)掰开揉碎讲清楚,重点是它们各自的假设是什么,假设失效时会发生什么

场景建模

先抛开具体方案,看看分布式锁本质上需要什么:

  • 互斥性:同一时刻只有一个客户端持有锁
  • 安全性:客户端崩溃后锁自动释放,不造成死锁
  • 可重入性:同一客户端可重复获取锁
  • 公平性:按请求顺序获取锁(非必须)
  • 容错性:锁服务部分节点宕机不影响锁的正确性

这三个方案在互斥性上都能保证吗?不一定。区别在于对互斥性的保证强度

方案一:Redis Redlock——快但不够安全

核心流程

Redlock 由 Redis 作者 antirez 提出,目标是解决单点 Redis 锁的问题。流程:

  1. 客户端获取当前时间(毫秒)
  2. 依次向 N 个独立 Redis 节点(通常 N=5)申请锁,每个节点的锁 key 相同,value 是随机 UUID
  3. 计算获取锁的总耗时,如果大多数节点(N/2+1 = 3)在超时前返回成功,且总耗时小于锁的 TTL,则认为加锁成功
  4. 加锁成功则执行业务逻辑,结束后向所有节点发起解锁脚本(Lua 脚本检查 UUID 匹配才删除)
  5. 若加锁失败(不够多数或超时),则向所有节点发起解锁

代码实现(简化版)

python
import time
import uuid
import redis

class Redlock:
    def __init__(self, redis_nodes, ttl=10000):
        self.redis_nodes = [redis.Redis.from_url(url) for url in redis_nodes]
        self.ttl = ttl  # 锁的存活时间,毫秒

    def lock(self, resource, retry_delay=200, retry_count=3):
        val = str(uuid.uuid4())
        for _ in range(retry_count):
            start = int(time.time() * 1000)
            acquired = 0
            for node in self.redis_nodes:
                try:
                    if node.set(resource, val, nx=True, px=self.ttl):
                        acquired += 1
                except redis.RedisError:
                    pass
            elapsed = int(time.time() * 1000) - start
            # 多数节点成功 且 总耗时小于 TTL
            if acquired >= len(self.redis_nodes) // 2 + 1 and elapsed < self.ttl:
                return val  # 返回锁的标识,用于解锁
            # 解锁
            self._unlock_all(resource, val)
            time.sleep(retry_delay / 1000)
        return None

    def unlock(self, resource, val):
        self._unlock_all(resource, val)

    def _unlock_all(self, resource, val):
        # Lua 脚本保证原子性:检查 value 匹配才删除
        script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        for node in self.redis_nodes:
            try:
                node.eval(script, 1, resource, val)
            except redis.RedisError:
                pass

Redlock 被质疑的核心问题

Martin Kleppmann(DDIA 作者)在 2016 年发表了著名分析文章 "How to do distributed locking",指出 Redlock 存在两个根本性问题:

问题 1:GC pause 破坏互斥性

时序图 — GC pause 导致锁失效

客户端 A               Redis 5 节点              客户端 B
  │                       │                       │
  ├── SET NX 成功 (3/5) ──┤                       │
  │                       │                       │
  ├── [开始 Full GC]      │                       │
  │  STW = 3 秒           │                       │
  │                       │  TTL 2 秒到期 ────────┤
  │                       │  key 自动删除         │
  │                       ├── SET NX 成功 ────────┤
  │                       │                       │
  ├── GC 恢复 ────────────┤                       │
  │  "我还在持有锁"       │    两个客户端同时持有锁 │
  │                       │                       │

这不是 Redlock 独有的问题,但 Redlock 没有机制应对。GC pause 的时间是不可预测的,TTL 设得再长也无法 100% 覆盖。生产环境中遇到过 Java 微服务 Full GC 长达 8 秒的情况(堆 8GB、CMS 老年代碎片化),如果 TTL 设 5 秒,必出问题。

问题 2:时钟漂移

Redlock 依赖客户端本地时间来计算加锁耗时并判断是否超时。如果客户端 A 的时钟比客户端 B 快 500ms,在 Redlock 的"多数派"判断中会出现偏差。更严重的是,如果 Redis 节点的时钟发生漂移,一个已经过期被删除的 key 可能被其他客户端读到,破坏互斥性。

真实案例:某云厂商的 Redis 实例发生过 NTP 时钟同步导致 Redis 节点时钟回拨 1 秒,所有 Redlock 锁在回拨期间全部失效,多个客户端同时获得锁,触发了一次线上 full gc 事故。

面试追问:Redis 7.0 WAIT 能否修复 Redlock?

Redis 7.0 引入的 WAIT 命令可以等待副本复制完成,但它解决的是数据持久化问题,不是互斥性问题。WAIT 确保写操作被至少 N 个副本确认,但 Redlock 的互斥性缺陷来自 GC pause 和时钟漂移,这两者 WAIT 管不了。面试官问到这一点,如果能说出"WAIT 解决的是持久化不是互斥性",说明你真正理解了这个问题的本质。

一句话总结

Redlock 的性能是最好的(微秒级),但它的互斥性保证不是严格数学意义上的 CP,而是"大概率互斥"。适合对一致性要求不高、允许偶发并发冲突的业务(如防重复提交、缓存重建去重)。

方案二:ZooKeeper 临时顺序节点——强一致但有代价

核心原理

ZK 基于 ZAB 协议保证强一致性。锁的实现思路:

  1. 在锁的路径下创建临时顺序节点(EPHEMERAL_SEQUENTIAL)
  2. 获取该路径下的所有子节点,判断自己创建的节点序号是否最小
  3. 如果是,则获得锁
  4. 如果不是,监听序号比自己小的那个节点的删除事件(Watch)
  5. 收到删除事件后,重复步骤 2

时序图 — ZK 公平锁获取流程

客户端 A                  ZooKeeper                 客户端 B
  │                         │                         │
  ├─ create /lock/lock-000 ─┤                         │
  │    (EPHEMERAL_SEQUENTIAL)│                         │
  │                         │                         │
  ├─ getChildren /lock ─────┤                         │
  │    [lock-000]           │                         │
  │    "我是最小的,拿到锁"   │                         │
  │                         │                         │
  │  执行业务逻辑...         │                         │
  │                         │                         │
  │                         │    create /lock/lock-001│
  │                         │◄────────────────────────┤
  │                         │                         │
  │                         │    getChildren /lock ───┤
  │                         │    [lock-000, lock-001] │
  │                         │    "我不是最小的,等"    │
  │                         │                         │
  │                         │    exists /lock/lock-000│
  │                         │    (设置 Watch)         │
  │                         │                         │
  ├─ 释放锁 ────────────────┤                         │
  │  (delete /lock/lock-000)│                         │
  │                         │    Watch 触发 ──────────┤
  │                         │                         │
  │                         │    getChildren /lock ───┤
  │                         │    [lock-001]           │
  │                         │    "我是最小的,拿到锁"  │

代码实现

java
// 伪代码 — ZooKeeper 分布式锁
public class ZkDistributedLock implements AutoCloseable {
    private final ZooKeeper zk;
    private final String lockPath;
    private String currentNode;

    public ZkDistributedLock(ZooKeeper zk, String lockPath) {
        this.zk = zk;
        this.lockPath = lockPath;
    }

    public void lock() throws Exception {
        // 1. 创建临时顺序节点
        currentNode = zk.create(lockPath + "/lock-",
                new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,
                CreateMode.EPHEMERAL_SEQUENTIAL);

        // 2. 尝试获取锁
        if (!tryAcquire()) {
            // 3. 等待前一个节点释放
            waitForLock();
        }
    }

    private boolean tryAcquire() throws Exception {
        List<String> children = zk.getChildren(lockPath, false);
        Collections.sort(children);
        // 当前节点序号最小 → 获得锁
        return currentNode.equals(lockPath + "/" + children.get(0));
    }

    private void waitForLock() throws Exception {
        // 关键:只 Watch 前一个节点,避免惊群效应
        String prevNode = getPreviousNode();
        CountDownLatch latch = new CountDownLatch(1);
        Stat stat = zk.exists(lockPath + "/" + prevNode,
                event -> latch.countDown());
        if (stat != null) {
            latch.await();  // 等待前一个节点释放
        }
        // 醒来后重新尝试获取锁
        if (!tryAcquire()) {
            waitForLock();
        }
    }

    @Override
    public void close() {
        // 释放锁:删除临时节点即可
        // 客户端断开连接时,ZK 自动删除临时节点,防止死锁
        if (currentNode != null) {
            try {
                zk.delete(currentNode, -1);
            } catch (Exception e) {
                // 忽略
            }
        }
    }
}

ZK 锁的陷阱

陷阱 1:Session 超时导致锁误释放

客户端 A 持有锁 → 网络抖动 → ZK 检测到 Session 超时(默认 30s)→
临时节点自动删除 → 客户端 B 拿到锁 → 客户端 A 网络恢复,但锁已经没了

这个问题在 Redlock 中不存在(Redis 不会主动删除),但在 ZK 中是固有的。解决方案:将 Session 超时时间设得足够长(通常是业务执行时间上限的 2-3 倍),但这样又拖慢了故障恢复速度。生产实践中,电商场景的订单锁 Session 超时通常设为 15-30 秒,而配置变更场景的锁可以设到 60 秒。

陷阱 2:惊群效应(虽已优化)

如果不加优化,每个节点监听父节点的子节点列表变化,那么任何一个节点释放锁都会通知所有等待节点,大量无意义的 Watcher 回调。代码中通过只监听前一个节点来避免这个问题,这是标准做法。

陷阱 3:性能上限

ZK 的写入是 Leader 单点,集群越大(超过 7 节点),ZAB 协议内部的网络开销越大,写入性能下降明显。ZK 锁的 TPS 通常在几千到一万级别,不适合高频加锁场景。在一次压测中,3 节点 ZK 集群的锁 TPS 约为 8000-12000,而同样配置的单节点 Redis 可以达到 5 万+。

方案三:Etcd Lease——Raft 共识 + 租约机制

核心原理

Etcd 基于 Raft 实现强一致性,借助 Lease(租约)机制实现分布式锁:

  1. 创建一个 Lease(租约),设置 TTL
  2. 用该 Lease 关联一个 key(/locks/my-lock),写入时带上 Lease ID
  3. 成功写入(Txn 事务判断 key 不存在)则获得锁
  4. 持锁期间通过续约(KeepAlive)保持 Lease 不过期
  5. 释放锁时删除 key 或撤销 Lease
  6. 客户端崩溃 → Lease 到期 → key 自动删除 → 锁释放

代码实现

go
// Go 代码 — Etcd 分布式锁
import (
    "context"
    "fmt"
    "go.etcd.io/etcd/client/v3"
    "go.etcd.io/etcd/client/v3/concurrency"
    "time"
)

func acquireLock(endpoints []string, lockKey string) error {
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   endpoints,
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        return err
    }
    defer cli.Close()

    // 创建 Session(底层自动管理 Lease 续约)
    session, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
    if err != nil {
        return err
    }
    defer session.Close()

    // 创建 Mutex 锁
    mu := concurrency.NewMutex(session, lockKey)

    // 加锁(阻塞等待,底层通过 Watch 实现)
    ctx := context.Background()
    if err := mu.Lock(ctx); err != nil {
        return err
    }

    // 业务逻辑...
    fmt.Println("got lock, doing work...")
    time.Sleep(5 * time.Second)

    // 释放锁
    if err := mu.Unlock(ctx); err != nil {
        return err
    }
    return nil
}

Etcd 锁的优势

续约机制比 ZK 优雅:Etcd 的 Session 内部自动续约(KeepAlive),客户端不需要手动刷新。Lease 到期自动释放 key,不需要像 ZK 那样依赖 Session 超时检测。续约的频率可以控制(默认 1/3 TTL 时间续约一次),比 ZK 的 Session 超时更灵活。

Txn 事务保证原子性:Etcd v3 的 Txn 支持"if-then-else"条件判断,加锁操作是原子的:

go
// Etcd Txn 实现加锁
txn := cli.Txn(ctx)
txn.If(clientv3.Compare(clientv3.CreateRevision("/locks/mylock"), "=", 0))
txn.Then(clientv3.OpPut("/locks/mylock", "client-abc", clientv3.WithLease(leaseID)))
txn.Else(clientv3.OpGet("/locks/mylock"))
resp, err := txn.Commit()

Watch 机制实现锁释放通知:等待锁的客户端通过 Watch 监听锁 key 的删除事件,一旦锁释放立即收到通知,而不是像 ZK 那样轮询或被动等待。这比 ZK 的 Watcher 更可靠(Etcd 的 Watch 是流式的,不会丢事件)。

Etcd 锁的边界情况

边界:Raft 多数派失效时

如果 Etcd 集群 3 个节点挂了 2 个,Raft 无法形成多数派,所有写入都会被阻塞。此时加锁操作会超时,但持有锁的客户端如果还能续约,锁理论上不会释放。问题是:如果持有锁的客户端恰好是那台能连到剩下节点的机器,而其他客户端连不到集群,谁也加不了锁 → 系统只读不可写。

这和 ZK 的脑裂处理不同:ZK 在脑裂时会自动断开少数派的 Session,少数派节点上的临时节点自动删除 → 锁释放。Etcd 在 Raft 分裂时不主动释放锁,而是等网络恢复。哪种更合理?取决于你的业务场景。如果宁可锁丢失也不希望服务不可用,ZK 的方式更激进;如果宁可服务不可用也不希望锁丢失,Etcd 的方式更保守。

横向对比

特性Redis RedlockZK 临时顺序节点Etcd Lease
一致性保证AP(大概率互斥)CP(强一致)CP(强一致)
性能(单次加锁)0.5-5ms3-15ms3-10ms
锁释放机制TTL 到期Session 超时 + 临时节点Lease 到期 + 续约
GC 容忍无(TTL 到期后锁丢失)无(Session 超时)有(续约机制可容忍短 GC)
时钟依赖依赖客户端本地时钟不依赖不依赖
运维复杂度低(Redis 运维成熟)中(ZK 运维)中(Etcd 运维)
可重入需额外实现原生支持(检查节点路径)原生支持(Mutex)
公平锁不支持支持(顺序节点)支持(排序)
脑裂处理可能丢失锁少数派主动释放锁阻塞等待恢复
典型 TPS5万+(单节点)0.8-1.2万1-3万
客户端 SDK需自实现Apache Curatorconcurrency 包

实战选型建议

面试回答框架

面试官问"分布式锁怎么选"时,不要直接说"选 Redis 吧"或"选 Etcd 吧"。框架

  1. 先问场景:业务对互斥性的容忍度有多高?
  2. 再画代价:强一致方案(Etcd/ZK)加锁耗时 3-15ms,Redis 方案 0.5-5ms,差一个数量级
  3. 最后给方案:分层,不要一刀切

分层方案

python
# 分层锁方案示意
class LockService:
    def __init__(self):
        self.redis_lock = RedisLock()   # 快速但不可靠
        self.etcd_lock = EtcdLock()     # 可靠但慢

    def lock(self, resource, level='fast'):
        if level == 'fast':
            # 防重复提交、缓存重建去重 — 允许偶发并发
            return self.redis_lock.lock(resource, ttl=5000)
        elif level == 'strict':
            # 订单退款、扣减库存 — 必须严格互斥
            return self.etcd_lock.lock(resource, ttl=30000)

具体场景决策

  • Redis 锁够用:防重复提交、缓存重建去重、非关键资源的并发控制。关键要求:必须加随机值做 owner 标识,解锁用 Lua 脚本保证原子性。
  • Etcd 锁优先:订单操作、资金操作、库存扣减、配置变更。这些场景即使牺牲一点性能,也不能容忍并发冲突。
  • ZK 锁慎用:如果公司已经有 ZK 集群(如 Kafka 用 ZK),可以复用,但不要为了分布式锁新建 ZK 集群。Etcd 的续约机制和 Watch 实现在体验上明显优于 ZK。

真实场景数据

某电商平台在秒杀场景下的实测数据(3 节点集群,同机房):

方案P99 加锁耗时并发冲突率备注
Redis SET NX2ms0.01%无脑裂保护
Redlock (5节点)8ms0.03%网络开销大
Etcd Lease12ms0%有 Raft 开销
ZK 临时节点18ms0%顺序节点 Watcher 开销

扩展:Redis 锁的正确姿势

如果最终选择 Redis 锁(非 Redlock,单节点),必须注意以下细节:

python
import redis
import uuid
import time

class SimpleRedisLock:
    def __init__(self, client, resource, ttl=10000):
        self.client = client
        self.resource = resource
        self.ttl = ttl  # 毫秒
        self.owner = str(uuid.uuid4())

    def acquire(self):
        """加锁:SET NX PX(原子操作)"""
        result = self.client.set(
            self.resource,
            self.owner,
            nx=True,           # 不存在时才设置
            px=self.ttl        # 过期时间(毫秒)
        )
        return result is not None

    def release(self):
        """解锁:Lua 脚本保证原子性"""
        script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        self.client.eval(script, 1, self.resource, self.owner)

    def renew(self):
        """续约:重置 TTL(需要用一个后台线程定时调用)"""
        # 必须检查 owner 是否匹配
        script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("pexpire", KEYS[1], ARGV[2])
        else
            return 0
        end
        """
        self.client.eval(script, 1, self.resource, self.owner, self.ttl)

关键细节

  • NX + PX 必须用 SET 命令原子完成,不能用 SETNX + EXPIRE 两条命令(会死锁)
  • 锁的 value 必须是唯一标识(UUID),解锁时通过 Lua 检查 value 是否匹配,防止误删其他客户端持有的锁
  • 续约需要用后台线程(守护线程)定期执行,续约周期建议为 TTL 的 1/3
  • 续约线程的异常处理:如果续约失败,应该立即释放锁并告警,而不是继续持有锁并假设自己还有锁

续约线程的时序问题

主线程                    续约线程(守护)
  │                          │
  ├── acquire() 成功 ────────┤
  │                          ├── 定时 TTL/3 后续约(如 TTL=10s,每 3s 续约一次)
  │  执行业务逻辑...          │
  │                          ├── 续约成功,TTL 重置
  │                          ├── 续约成功,TTL 重置
  ├── [挂起等待 RPC]         │
  │                          ├── 续约失败(网络问题)
  │                          │   → 日志告警,主动释放锁
  │                          │
  │  业务逻辑还在执行         │
  │  TTL 到期 → 锁自动释放   │
  │                          │  → 其他客户端拿到锁
  │                          │  → 两个客户端同时操作

这就是为什么续约失败时不能静默——它可能导致互斥性失效。生产环境中的最佳实践是:续约失败时除了告警,还要在业务线程中插入一个检查点,让业务代码感知到"锁可能已经丢了"。

总结

回到开头的问题:分布式锁到底选哪个?

场景                     → 推荐方案
性能优先、容忍偶发冲突   → Redis 锁(单节点,非 Redlock)
强一致、关键资源         → Etcd Lease
已有 ZK 集群、人力有限   → ZK 临时顺序节点

分布式锁的本质不是"什么方案更好",而是你的业务场景对互斥性的容忍度有多高。Redlock 被质疑不是因为算法不对,而是它在 "CP" 这个维度上给了用户错误的预期。如果业务需要严格的互斥性,选 Etcd 或 ZK;如果业务只是防重复点击,一个简单的 Redis SET NX 就够了,不需要为了"分布式锁"这个名词去上 Redlock。

面试官真正想听的是:你能不能说清楚每种方案的假设失效时的退路,而不是背一遍结构。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。