分布式锁的设计与实现
分布式锁是在分布式系统中控制多个进程/服务互斥访问共享资源的机制,其核心目标是在并发场景下保证数据的正确性。下面从应用场景、设计目标、常见实现方案、关键细节四个方面,循序渐进讲解分布式锁的设计与实现。
1. 应用场景与设计目标
应用场景:
- 多台机器同时操作同一条数据库记录(如库存扣减)
- 分布式任务调度,防止同一任务被多个节点重复执行
- 对共享配置的并发更新保护
设计目标:
- 互斥性:任意时刻只有一个客户端能持有锁
- 避免死锁:即使锁持有者崩溃,锁最终也能被释放
- 高可用:锁服务本身需高可用(如集群部署)
- 高性能:加锁、释放锁的操作延迟要低
2. 常见实现方案及其原理
常见的实现方案有:基于数据库、基于 Redis、基于 ZooKeeper/etcd。下面重点讲解最典型的 Redis 实现 和 ZooKeeper 实现。
方案一:基于 Redis 的分布式锁
这是最常用的方案,利用 Redis 的原子命令实现锁。
(1)基本加锁命令
最简单的加锁方式是使用 SET 命令的 NX(不存在才设置)和 EX(设置过期时间)选项:
SET lock_key unique_value NX EX 10
lock_key:锁的名称unique_value:客户端生成的唯一值(如 UUID),用于安全释放锁,避免误删其他客户端的锁NX:仅在键不存在时设置成功,实现互斥EX 10:设置 10 秒过期时间,防止持有者崩溃后锁无法释放
(2)释放锁的逻辑
释放锁时,需要先检查锁的值是否为自己设置的 unique_value,是才删除。由于检查 + 删除需要原子性,通常用 Lua 脚本实现:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
脚本将 KEYS[1](锁键)与 ARGV[1](预期值)比较,匹配才删除,避免误删。
(3)锁的续期问题
如果业务执行时间超过锁的过期时间,锁会自动释放,导致互斥失效。为此,可以引入看门狗(watchdog)机制:
- 客户端启动一个后台线程,定期(如过期时间的 1/3 时间)检查锁是否仍持有,并刷新过期时间(通过
EXPIRE命令) - 业务执行完毕,主动停止续期并释放锁
(4)RedLock 算法
在单点 Redis 故障时,锁可能失效。RedLock 算法尝试在多个独立的 Redis 节点上同时获取锁,多数节点成功才算获取成功。其步骤如下:
- 获取当前时间戳 T1
- 依次向 N 个 Redis 节点发送加锁请求(使用相同键和值,设置较短的超时时间)
- 当从多数(≥ N/2 + 1)节点获得锁成功,且总耗时小于锁的过期时间,则认为加锁成功
- 锁的实际有效时间 = 初始过期时间 - 加锁总耗时
- 如果加锁失败,需向所有节点发送释放锁请求(即使未成功加锁的节点也尝试)
RedLock 仍有争议(如时钟漂移问题),但适用于对锁的可靠性要求较高的场景。
方案二:基于 ZooKeeper 的分布式锁
ZooKeeper 通过临时有序节点(Ephemeral Sequential Node)实现锁,更侧重一致性。
(1)加锁过程
- 客户端在锁对应的父节点(如
/locks/my_lock)下创建临时有序节点,如/locks/my_lock/node_00000001 - 客户端获取父节点下所有子节点,并按序号排序
- 如果自己创建的节点是序号最小的节点,则获得锁
- 否则,监听前一个节点(比自己序号小 1 的节点)的删除事件
- 当前一个节点被删除(即锁被释放)时,自己被唤醒,重新检查自己是否成为最小节点
(2)释放锁
- 客户端完成操作后,主动删除自己创建的临时节点(ZooKeeper 会自动清理,但主动删除可减少延迟)
- 删除后,后一个节点会收到通知,尝试获取锁
(3)特性分析
- 临时节点:客户端会话结束(如崩溃)时,节点自动删除,锁被释放,避免死锁
- 有序节点:实现了公平锁(按申请顺序获得锁)
- 监听机制:避免客户端不断轮询,减少网络开销
缺点:ZooKeeper 性能不如 Redis,且需要维护集群。
3. 关键细节与优化
(1)避免误删锁
必须用唯一标识(如 UUID + 线程 ID)作为锁的值,释放时验证,防止:
- 客户端 A 因阻塞导致锁过期释放
- 客户端 B 获得锁
- 客户端 A 恢复后误删 B 的锁
(2)锁的可重入性
同一个线程可多次获取同一把锁,需在客户端维护重入计数(如 ThreadLocal),并在释放时递减计数,计数为 0 时才删除 Redis 中的锁键。
(3)锁的续期与超时
业务代码应尽量缩短锁占用时间,避免长时间持有锁。看门狗续期适用于执行时间不确定但较长的任务。
(4)网络分区与时钟问题
在 Redis 方案中,如果发生网络分区,可能出现多个客户端同时持有锁的情况(脑裂)。RedLock 试图缓解,但无法完全避免。ZooKeeper 基于 CP 设计,在网络分区时可能不可用,但不会出现多个锁。
4. 方案对比与选型
| 特性 | Redis 锁 | ZooKeeper 锁 |
|---|---|---|
| 一致性模型 | AP(异步复制) | CP(ZAB 协议) |
| 性能 | 高(内存操作) | 中(需持久化与同步) |
| 实现复杂性 | 低(但 RedLock 较复杂) | 中(需处理临时节点与监听) |
| 锁是否公平 | 通常非公平(可结合队列实现公平) | 公平(有序节点) |
| 死锁处理 | 依赖过期时间 | 临时节点自动删除 |
| 典型应用场景 | 高并发、可容忍极短暂锁冲突 | 强一致性要求、锁持有时间长 |
选型建议:
- 对性能要求高、可容忍极端情况下少量锁冲突,选 Redis(多数业务场景)
- 对锁的强一致性要求高、业务执行时间长,选 ZooKeeper/etcd(如金融核心交易)
5. 实战示例(Redis 锁的简单实现)
以下是一个使用 Redis 命令的 Python 伪代码示例(实际需用 redis 库):
import redis
import uuid
import time
class RedisDistributedLock:
def __init__(self, redis_client, lock_key, expire_time=10):
self.redis = redis_client
self.lock_key = lock_key
self.expire_time = expire_time
self.identifier = str(uuid.uuid4()) # 唯一标识
def acquire(self, timeout=10):
end_time = time.time() + timeout
while time.time() < end_time:
# 尝试加锁
if self.redis.set(self.lock_key, self.identifier, nx=True, ex=self.expire_time):
return True
time.sleep(0.001) # 短暂等待后重试
return False
def release(self):
# 使用 Lua 脚本保证原子性
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
self.redis.eval(lua_script, 1, self.lock_key, self.identifier)
总结
分布式锁的核心是互斥、防死锁、高可用。Redis 锁性能高但需处理脑裂与续期;ZooKeeper 锁一致性强但性能较低。实际选型需权衡业务场景,并注意唯一标识、原子操作、锁续期等关键细节,避免常见坑点(如误删锁、锁过期等)。