分布式锁实现方案对比:Redis Redlock vs ZK 临时节点 vs Etcd Lease
一个锁引发的血案
先看一个真实场景:你的订单服务有两台实例,收到两个并发请求要对同一个订单取消退款。如果不用分布式锁,两笔退款同时发起,用户收到双倍退款,公司亏了,你被叫去喝茶。
分布式锁要解决的就是——多台机器之间,互斥访问共享资源。听起来简单,但实现起来坑一个接一个。今天把三种主流方案(Redis Redlock、ZK 临时顺序节点、Etcd Lease)掰开揉碎讲清楚,重点是它们各自的假设是什么,假设失效时会发生什么。
场景建模
先抛开具体方案,看看分布式锁本质上需要什么:
- 互斥性:同一时刻只有一个客户端持有锁
- 安全性:客户端崩溃后锁自动释放,不造成死锁
- 可重入性:同一客户端可重复获取锁
- 公平性:按请求顺序获取锁(非必须)
- 容错性:锁服务部分节点宕机不影响锁的正确性
这三个方案在互斥性上都能保证吗?不一定。区别在于对互斥性的保证强度。
方案一:Redis Redlock——快但不够安全
核心流程
Redlock 由 Redis 作者 antirez 提出,目标是解决单点 Redis 锁的问题。流程:
- 客户端获取当前时间(毫秒)
- 依次向 N 个独立 Redis 节点(通常 N=5)申请锁,每个节点的锁 key 相同,value 是随机 UUID
- 计算获取锁的总耗时,如果大多数节点(N/2+1 = 3)在超时前返回成功,且总耗时小于锁的 TTL,则认为加锁成功
- 加锁成功则执行业务逻辑,结束后向所有节点发起解锁脚本(Lua 脚本检查 UUID 匹配才删除)
- 若加锁失败(不够多数或超时),则向所有节点发起解锁
代码实现(简化版)
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:
passRedlock 被质疑的核心问题
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 协议保证强一致性。锁的实现思路:
- 在锁的路径下创建临时顺序节点(EPHEMERAL_SEQUENTIAL)
- 获取该路径下的所有子节点,判断自己创建的节点序号是否最小
- 如果是,则获得锁
- 如果不是,监听序号比自己小的那个节点的删除事件(Watch)
- 收到删除事件后,重复步骤 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] │
│ │ "我是最小的,拿到锁" │代码实现
// 伪代码 — 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(租约)机制实现分布式锁:
- 创建一个 Lease(租约),设置 TTL
- 用该 Lease 关联一个 key(
/locks/my-lock),写入时带上 Lease ID - 成功写入(Txn 事务判断 key 不存在)则获得锁
- 持锁期间通过续约(KeepAlive)保持 Lease 不过期
- 释放锁时删除 key 或撤销 Lease
- 客户端崩溃 → Lease 到期 → key 自动删除 → 锁释放
代码实现
// 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"条件判断,加锁操作是原子的:
// 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 Redlock | ZK 临时顺序节点 | Etcd Lease |
|---|---|---|---|
| 一致性保证 | AP(大概率互斥) | CP(强一致) | CP(强一致) |
| 性能(单次加锁) | 0.5-5ms | 3-15ms | 3-10ms |
| 锁释放机制 | TTL 到期 | Session 超时 + 临时节点 | Lease 到期 + 续约 |
| GC 容忍 | 无(TTL 到期后锁丢失) | 无(Session 超时) | 有(续约机制可容忍短 GC) |
| 时钟依赖 | 依赖客户端本地时钟 | 不依赖 | 不依赖 |
| 运维复杂度 | 低(Redis 运维成熟) | 中(ZK 运维) | 中(Etcd 运维) |
| 可重入 | 需额外实现 | 原生支持(检查节点路径) | 原生支持(Mutex) |
| 公平锁 | 不支持 | 支持(顺序节点) | 支持(排序) |
| 脑裂处理 | 可能丢失锁 | 少数派主动释放锁 | 阻塞等待恢复 |
| 典型 TPS | 5万+(单节点) | 0.8-1.2万 | 1-3万 |
| 客户端 SDK | 需自实现 | Apache Curator | concurrency 包 |
实战选型建议
面试回答框架
面试官问"分布式锁怎么选"时,不要直接说"选 Redis 吧"或"选 Etcd 吧"。框架:
- 先问场景:业务对互斥性的容忍度有多高?
- 再画代价:强一致方案(Etcd/ZK)加锁耗时 3-15ms,Redis 方案 0.5-5ms,差一个数量级
- 最后给方案:分层,不要一刀切
分层方案
# 分层锁方案示意
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 NX | 2ms | 0.01% | 无脑裂保护 |
| Redlock (5节点) | 8ms | 0.03% | 网络开销大 |
| Etcd Lease | 12ms | 0% | 有 Raft 开销 |
| ZK 临时节点 | 18ms | 0% | 顺序节点 Watcher 开销 |
扩展:Redis 锁的正确姿势
如果最终选择 Redis 锁(非 Redlock,单节点),必须注意以下细节:
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。
面试官真正想听的是:你能不能说清楚每种方案的假设失效时的退路,而不是背一遍结构。