2016年2月8日,分布式系统研究员Martin Kleppmann发表了一篇博客文章,标题直截了当:《如何正确实现分布式锁》。文章开篇就对Redis官方文档中的Redlock算法提出了尖锐批评,结论是"这个算法不适合用于正确性依赖于锁的场景"。

三天后,Redis的作者antirez发表了长文回应。两位技术领袖的这场论战,揭示了分布式锁这个看似简单的概念背后深藏的设计困境——一个至今仍在让无数开发者在生产环境中踩坑的根本性问题。

锁的两重天:效率还是正确性?

Kleppmann在文章开头提出了一个关键区分:分布式锁有两种截然不同的用途。

效率优先:锁用于避免重复工作。比如多个节点同时触发某个定时任务,只需要一个节点执行即可。即使锁偶尔失效导致两个节点同时执行,结果也只是多花几块钱的云服务费用,或者用户收到两封相同的邮件通知。这类场景下,锁的不完美是可以容忍的。

正确性优先:锁用于防止并发操作破坏系统状态。比如多个节点同时修改同一个文件、向数据库写入数据、或者给病人用药。如果锁失效,结果可能是数据丢失、文件损坏、甚至医疗事故。

区分这两者的方法很简单:问自己"如果锁失效了会怎样?"

Kleppmann的核心论点是:如果你只是追求效率,用单个Redis实例就够了,没必要折腾Redlock这种需要5个Redis节点的复杂方案;如果你追求正确性,Redlock根本不够安全。

一个看似正确实则错误的锁实现

考虑这个经典的锁使用模式:

# 这段代码有严重Bug
def write_data(filename, data):
    lock = lock_service.acquire_lock(filename)
    if not lock:
        raise Exception('Failed to acquire lock')
    
    try:
        file_content = storage.read_file(filename)
        updated = update_contents(file_content, data)
        storage.write_file(filename, updated)
    finally:
        lock.release()

这段代码在单机多线程环境下完全正确。但在分布式系统中,它存在一个根本性漏洞。

假设客户端获取锁后,执行到一半时发生了长时间的GC暂停(Stop-The-World GC)。暂停期间,锁的TTL到期自动释放。另一个客户端获取了同一个锁,开始执行操作。此时第一个客户端从GC暂停中恢复,继续执行写操作——两个客户端同时认为自己持有锁,同时操作同一个资源。

这并非理论上的可能。Kleppmann在文章中引用了一个真实案例:GitHub曾发生过网络数据包延迟约90秒的事件。在这种延迟下,任何依赖超时的锁机制都会失效。他指出,HotSpot JVM的CMS垃圾收集器虽然号称并发,但仍需要偶尔"Stop-The-World"。在大堆内存场景下,几秒甚至几分钟的暂停在文献中确实有记录。

关键洞察:分布式锁不是互斥锁,而是租约。它的互斥性只在TTL有效期内成立,超过这个时间,任何客户端都可能获取同一个锁。

Fencing Token:终极解决方案?

Kleppmann提出了解决方案:Fencing Token。

Fencing Token是一个单调递增的数字,每次锁被获取时递增。客户端在操作共享资源时,必须携带这个Token。资源服务器记录已处理的最大Token值,拒绝任何Token较小的请求。

举个例子:客户端A获取锁,Token=33。A进入长时间GC暂停,锁过期。客户端B获取锁,Token=34,执行写操作。A恢复后尝试写入,但Token=33小于34,请求被拒绝。

这个方案要求资源服务器主动参与Token校验,但实现起来并不复杂。关键在于:锁服务必须能够生成单调递增的Token

这正是Kleppmann批评Redlock的核心点:Redlock没有提供Fencing Token机制。它生成的随机值不具备单调性,无法用于排序。

Redlock的安全性问题

Redlock是Redis官方提出的分布式锁算法,使用5个独立的Redis主节点,通过多数派机制获取锁。算法流程如下:

  1. 获取当前时间戳
  2. 依次向5个Redis节点请求加锁
  3. 计算获取锁花费的时间
  4. 如果在多数节点(≥3)成功加锁,且花费时间小于锁TTL,则认为加锁成功
  5. 否则,向所有节点发送解锁请求

Kleppmann指出了这个算法的两个致命问题:

时钟同步问题:Redlock依赖系统时钟来计算TTL。如果某个Redis节点的时钟向前跳跃(比如NTP同步),该节点上的锁可能提前过期。假设节点C的时钟跳跃,客户端A持有A、B、C三个节点的锁,但C上的锁提前过期,客户端B可能获取C、D、E的锁——两个客户端同时持有"多数派"锁。

antirez在回应中承认这个问题,并表示Redis应该使用单调时钟API。但他同时指出,这要求系统管理员不要手动修改时间,NTP配置应该使用slew模式而非step模式。

进程暂停问题:更严重的问题是GC暂停或网络延迟。Kleppmann给出了一个场景:

  1. 客户端1向5个节点请求加锁
  2. 请求发送后,客户端1进入长时间GC暂停
  3. 响应被缓存在内核网络缓冲区
  4. 所有节点上的锁过期
  5. 客户端2获取锁
  6. 客户端1从GC恢复,收到"加锁成功"的响应
  7. 两个客户端同时认为自己持有锁

antirez对此的反驳是:Redlock在加锁后会再次检查时间,如果花费时间过长会认为加锁失败。但Kleppmann指出,这个检查发生在客户端获取响应之后,此时如果发生暂停,问题依然存在。

ZooKeeper:另一种思路

与Redis不同,ZooKeeper采用了完全不同的设计思路。

ZooKeeper使用临时顺序节点实现分布式锁。客户端在锁目录下创建一个临时顺序节点,节点名形如lock-0000000001。然后获取锁目录下所有子节点,如果自己创建的节点序号最小,则获取锁成功;否则,监听序号比自己小的相邻节点,等待其删除。

这种设计有几个关键优势:

不依赖时钟:ZooKeeper的临时节点生命周期与会话绑定,而非依赖TTL。只要客户端会话有效,节点就存在。会话通过心跳维持,不依赖系统时钟的准确性。

避免羊群效应:每个客户端只监听序号相邻的前一个节点,而非监听锁目录本身。当一个客户端释放锁时,只有一个客户端被唤醒,避免了大量客户端同时竞争的问题。

天然支持Fencing Token:ZooKeeper的zxid(事务ID)或znode版本号可以作为天然的单调递增Token。Kleppmann在文章中明确指出:如果你使用ZooKeeper作为锁服务,可以使用zxid或znode版本号作为Fencing Token。

sequenceDiagram
    participant C1 as 客户端1
    participant C2 as 客户端2
    participant ZK as ZooKeeper
    
    C1->>ZK: 创建临时顺序节点 lock-0001
    ZK-->>C1: 创建成功,序号=1
    C1->>ZK: 获取子节点列表
    ZK-->>C1: [lock-0001]
    Note over C1: 序号最小,获得锁
    
    C2->>ZK: 创建临时顺序节点 lock-0002
    ZK-->>C2: 创建成功,序号=2
    C2->>ZK: 获取子节点列表
    ZK-->>C2: [lock-0001, lock-0002]
    Note over C2: 序号不是最小,等待
    C2->>ZK: 监听 lock-0001
    
    Note over C1: 执行业务逻辑
    
    C1->>ZK: 删除 lock-0001(释放锁)
    ZK->>C2: 触发监听事件
    C2->>ZK: 获取子节点列表
    ZK-->>C2: [lock-0002]
    Note over C2: 序号最小,获得锁

但ZooKeeper并非完美。2013年有人在Stack Overflow上提出了对ZooKeeper锁方案的质疑,指出该方案无法保证"任意时刻没有两个客户端认为自己持有同一个锁"。

问题的根源在于客户端崩溃后的恢复。如果客户端在创建临时节点后、获取锁之前崩溃,ZooKeeper会删除临时节点。但如果客户端在持有锁期间网络分区,会话超时后临时节点会被删除,其他客户端可能获取锁——此时原客户端可能仍在操作资源。

etcd与Jepsen的发现

etcd是CoreOS开发的分布式键值存储,使用Raft协议实现强一致性。它提供了Lock API,基于Lease机制实现。

Jepsen在2019年对etcd 3.4.3进行了深入测试,发现了一个令人震惊的结果:etcd的锁API不能保证互斥性,多个客户端可能同时持有同一个锁

测试显示,在短TTL(1-3秒)情况下,即使集群完全健康,锁也会在几分钟内出现互斥违反。当引入进程暂停和网络分区后,问题更加严重。在2秒TTL、5个并发进程、每5秒暂停一次的测试中,约18%的已确认更新丢失。

Jepsen报告指出了问题的根源:

  1. Lease的生命周期定义:Lease的TTL是物理时间间隔,服务器和客户端各自用自己的时钟度量。服务器可能认为Lease已过期,而客户端仍然认为自己持有Lease。

  2. 锁获取后的验证缺失:当客户端等待锁释放后,服务器没有重新验证Lease是否仍然有效。

etcd文档后来明确承认了这一点:Lock API本身不能保证互斥性。它应该作为互斥的优化机制使用,而非唯一保障。

etcd推荐的使用方式是结合Revision(修订版本号)作为Fencing Token。每次操作时,在事务中检查Revision是否仍然有效。这与Google Chubby的Sequencer机制类似。

Google Chubby的启示

Google的Chubby锁服务是这个领域的先驱。2006年的OSDI论文详细描述了它的设计。

Chubby引入了Sequencer的概念,本质上就是Fencing Token。客户端获取锁时,Chubby返回一个Sequencer,包含锁的序号和代数。客户端在访问共享资源时必须携带这个Sequencer,资源服务器验证其有效性。

Chubby论文中还描述了一个重要的工程实践:锁延迟。当客户端主动释放锁时,Chubby会延迟一段时间后才允许其他客户端获取该锁,以防止网络延迟导致的竞态条件。

Redisson的Watch Dog:续命还是续毒?

在Java生态中,Redisson是最流行的Redis分布式锁客户端。它引入了Watch Dog机制,自动续期锁的TTL。

默认情况下,锁的TTL是30秒,Watch Dog每10秒(TTL的1/3)检查一次,如果持有锁的线程仍在运行,就延长TTL。

这个机制试图解决一个问题:业务执行时间超过了预期的锁TTL。但它引入了新的风险:

  1. 掩盖问题而非解决问题:如果业务执行时间过长,正确的做法是拆分任务或优化逻辑,而非无限续期锁。

  2. 死锁风险:如果客户端崩溃,Watch Dog也会停止,锁最终会过期释放。但如果客户端只是响应缓慢或网络问题,Watch Dog可能持续续期,导致其他客户端无法获取锁。

  3. 不解决正确性问题:即使有Watch Dog,GC暂停或网络延迟仍可能导致客户端在不知道锁已过期的情况下操作资源。续期机制无法替代Fencing Token。

工程实践指南

经过上述分析,可以得出以下实践建议:

明确锁的用途

在决定使用分布式锁之前,先回答这个问题:如果锁失效,后果是什么?

如果只是效率问题(如避免重复计算),使用简单的Redis单实例锁即可:

# 加锁
SET resource_name random_value NX PX 30000

# 解锁(使用Lua脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

如果是正确性问题(如数据一致性),分布式锁本身不足以保证安全,必须结合Fencing Token。

选择合适的实现方案

需求场景 推荐方案 原因
效率优化,容忍偶尔失效 Redis单实例 简单高效,足够用
正确性要求高,资源可控 PostgreSQL Advisory Lock 数据库本身提供ACID保证
正确性要求高,已有共识系统 etcd/ZooKeeper + Fencing Token 基于Raft/ZAB,天然提供单调Token
正确性要求高,资源不在共识系统内 重新设计架构,避免分布式锁 分布式锁无法保证正确性

实现Fencing Token

无论使用哪种锁服务,正确性场景都必须实现Fencing Token:

etcd方案:使用锁key的Revision作为Token,在事务中验证:

// 获取锁
resp, _ := client.Lock(ctx, "/my-lock/")
token := resp.Header.Revision

// 操作共享资源时携带Token
txn := client.Txn(ctx).
    If(clientv3.Compare(clientv3.ModRevision("/resource"), "=", currentRev)).
    Then(clientv3.OpPut("/resource", newData))
txn.Commit()

ZooKeeper方案:使用zxid或znode版本号:

// 获取锁时记录版本号
Stat stat = zk.exists(lockPath, false);
long fencingToken = stat.getMzxid();

// 操作资源时验证
if (resource.getLastToken() >= fencingToken) {
    throw new StaleLockException();
}
resource.update(data, fencingToken);

数据库方案:使用乐观锁:

-- 资源表包含版本号字段
UPDATE resources 
SET data = ?, version = version + 1 
WHERE id = ? AND version = ?

避免的陷阱

  1. 不要用分布式锁保护外部资源:如果资源不在锁服务的控制范围内(如文件系统、外部API),锁无法保证正确性。

  2. 不要假设锁的互斥性:分布式锁是租约,不是互斥锁。时刻假设"锁可能已过期但我不知道"。

  3. 不要无限续期锁:如果业务执行时间不确定,应该重新设计流程,而非依赖Watch Dog续命。

  4. 不要忽略时钟问题:如果使用Redis,确保配置单调时钟,NTP使用slew模式。

本质:分布式系统没有万全之策

回到Kleppmann与antirez的论战,两人的分歧本质上是系统模型假设的差异。

Kleppmann站在异步系统模型的角度:不假设任何时间界限,进程可以无限暂停,网络可以无限延迟,时钟可以任意错误。在这种假设下,Redlock确实不安全,因为它依赖"时间"这个不可靠的假设。

antirez站在半同步系统模型的角度:假设时钟漂移、网络延迟、进程暂停都在合理范围内。在这种假设下,Redlock在大多数时候是安全的。

谁对谁错?从严格的分布式系统理论角度,Kleppmann是对的——“大多数时候安全"对于正确性要求来说是不够的。但从工程实践角度,antirez也有道理——过度追求理论完美可能付出巨大的性能代价

正确的态度是:理解你的系统模型假设,在假设可能被打破时做好防护

如果你使用分布式锁,请记住:它不是互斥锁,而是租约。它在"正常情况下"提供互斥性,但"正常"的定义取决于你的环境。如果你真的需要正确性,请实现Fencing Token,或者重新思考是否真的需要分布式锁。


参考资料

  1. Kleppmann, M. (2016). How to do distributed locking. martin.kleppmann.com
  2. antirez. (2016). Is Redlock safe? antirez.com
  3. Redis Documentation. Distributed Locks with Redis. redis.io
  4. Apache ZooKeeper. ZooKeeper Recipes and Solutions. zookeeper.apache.org
  5. etcd Documentation. etcd versus other key-value stores. etcd.io
  6. Jepsen. (2020). etcd 3.4.3 analysis. jepsen.io
  7. Burrows, M. (2006). The Chubby lock service for loosely-coupled distributed systems. OSDI'06
  8. Howard, H. et al. (2015). Raft: In search of an understandable consensus algorithm. USENIX ATC
  9. Cachin, C., Guerraoui, R., Rodrigues, L. (2011). Introduction to Reliable and Secure Distributed Programming. Springer