分布式系统中,多个节点同时操作共享资源时需要协调。分布式锁是解决这类互斥问题的核心手段。本文逐一分析主流实现方案的原理、正确用法与常见陷阱。
为什么需要分布式锁
单机环境下,Java 的 synchronized 或 ReentrantLock 可以保证同一进程内的线程互斥。但在分布式系统中,同一服务部署了多个实例,这些锁只在单个 JVM 内有效,无法跨进程协调。
典型场景:
- 库存扣减:多个实例同时读取库存为 1,都认为可以下单,导致超卖
- 定时任务:集群中多个节点同时触发同一个定时任务,产生重复执行
- 幂等控制:防止同一请求因网络重试被重复处理
分布式锁需要满足三个核心属性:
- 互斥性:同一时刻只有一个客户端持有锁
- 防死锁:持锁客户端崩溃后,锁必须能自动释放
- 容错性:锁服务部分节点故障时,客户端仍能正常加锁/解锁
Redis 分布式锁
基础实现:SET NX
最简单的 Redis 分布式锁使用 SET key value NX PX timeout 命令:
# NX:key 不存在时才设置(原子操作)
# PX 5000:5 秒后自动过期(防死锁)
SET lock:order:123 <unique_value> NX PX 5000
释放锁时必须验证 value 是持锁方自己设置的,防止误删他人的锁:
-- Lua 脚本保证原子性
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
value 必须是唯一标识(如 UUID + 线程 ID),原因:若锁因超时自动释放,持锁方 A 仍在执行,此时 B 获取了锁,若 A 不校验 value 直接删除,就会误删 B 的锁。
锁续期:看门狗机制
超时时间设置面临两难:
- 设太短:业务执行时间超过锁超时,锁自动释放,并发安全被破坏
- 设太长:持锁方崩溃后,其他客户端需要等待很长时间
Redisson 的解决方案是看门狗(Watchdog):
- 加锁时设置默认 30 秒超时
- 后台线程每 10 秒检查一次,若持锁方仍在运行,则将锁续期到 30 秒
- 持锁方崩溃后,后台线程停止,锁在 30 秒内自动过期
RLock lock = redisson.getLock("lock:order:123");
// 加锁,不设置超时时间,启用看门狗
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
// 或者:tryLock 带等待时间
boolean acquired = lock.tryLock(3, TimeUnit.SECONDS);
主从切换的问题
单 Redis 实例的分布式锁在主从架构下有一个经典问题:
- 客户端 A 在 master 上加锁成功
- 锁数据还未同步到 slave,master 宕机
- slave 晋升为新 master,此时锁数据丢失
- 客户端 B 在新 master 上加锁成功
- A 和 B 同时持锁,互斥性被破坏
这就是 Redlock 算法要解决的问题。
Redlock 算法
Redis 作者 antirez 提出了 Redlock,使用 N 个(通常 5 个)独立的 Redis 实例(非主从),通过多数派投票保证锁的安全性:
- 记录当前时间
t1 - 依次向 5 个 Redis 实例发送加锁请求(SET NX PX),每个请求设置较短的超时时间(如 50ms)
- 如果在 ≥ 3 个实例上加锁成功,且总耗时
t2 - t1 < 锁超时时间,则加锁成功 - 锁的有效时间 = 原始超时时间 - (t2 - t1)(扣除获取锁的耗时)
- 如果加锁失败(成功数 < 3,或有效时间 <= 0),向所有实例发送释放请求
// Redisson 的 Redlock 实现
RLock lock1 = redisson1.getLock("distributed-lock");
RLock lock2 = redisson2.getLock("distributed-lock");
RLock lock3 = redisson3.getLock("distributed-lock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 业务逻辑
} finally {
redLock.unlock();
}
Redlock 的争议:Martin Kleppmann 在 2016 年发文指出 Redlock 存在问题——即使多数派加锁成功,若持锁方发生 GC 停顿或时钟漂移,锁可能在持锁方仍在执行时过期,导致另一个客户端加锁成功。antirez 对此进行了反驳,认为这是极端情况,实际生产中可以接受。
工程结论:对强一致性要求极高的场景(如金融扣款),不应依赖 Redlock,应使用 ZooKeeper 或数据库行锁。
ZooKeeper 分布式锁
临时顺序节点
ZooKeeper 分布式锁基于两个特性:
- 临时节点(Ephemeral Node):客户端断开连接后,节点自动删除,天然防死锁
- 顺序节点(Sequential Node):创建时自动附加单调递增序号,如
lock-0000000001
加锁流程:
- 在
/locks/order-123/下创建临时顺序节点,如/locks/order-123/lock-0000000003 - 获取
/locks/order-123/下所有子节点,按序号排序 - 如果自己创建的节点序号最小,则加锁成功
- 否则,监听(Watch)序号比自己小的前一个节点
- 当前一个节点被删除时,收到通知,重新检查自己是否是最小节点
// Curator 框架的 ZooKeeper 分布式锁
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order-123");
if (lock.acquire(3, TimeUnit.SECONDS)) {
try {
// 业务逻辑
} finally {
lock.release();
}
}
为什么监听前一个节点
一个常见的错误实现是让所有等待者都监听根节点的变化。这会导致羊群效应(Herd Effect):每次有节点被删除,所有等待者同时被唤醒,争抢锁,产生大量无效的 ZooKeeper 请求。
监听前一个节点的方案(链式等待)解决了这个问题:每次只有一个等待者被唤醒,避免了惊群。
ZooKeeper 锁的特点
优点:
- 强一致性:ZooKeeper 使用 ZAB 协议,写操作需要多数派确认,不存在 Redis 主从切换导致的锁丢失问题
- 天然防死锁:临时节点在客户端断开后自动删除
- 公平锁:顺序节点保证先到先得
缺点:
- 性能低于 Redis:每次加锁需要多次网络交互(创建节点、获取子节点列表、设置 Watch)
- ZooKeeper 集群本身的维护成本较高
- Session 超时时间设置:Session 超时后临时节点才删除,这段时间内锁无法被其他人获取
数据库分布式锁
悲观锁:SELECT FOR UPDATE
利用数据库行锁实现互斥:
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE id = 123 FOR UPDATE; -- 加行锁
-- 执行业务逻辑
UPDATE orders SET status = 'processed' WHERE id = 123;
COMMIT; -- 释放锁
FOR UPDATE 会加排他锁(X 锁),其他事务的 SELECT FOR UPDATE 和写操作都会被阻塞,直到持锁事务提交或回滚。
适用场景:业务逻辑本身就在数据库事务内,锁的粒度与事务一致。不适合跨服务的分布式场景。
乐观锁:版本号
乐观锁不加真正的锁,而是通过版本号检测冲突:
-- 读取时记录版本号
SELECT id, stock, version FROM products WHERE id = 1;
-- 返回:stock=10, version=5
-- 更新时带上版本号条件
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 检查影响行数
-- affected_rows = 1:更新成功,没有并发冲突
-- affected_rows = 0:有其他事务先修改了数据,需要重试
乐观锁适合读多写少的场景,冲突概率低时性能优于悲观锁。高并发写场景下,大量重试会导致性能下降。
基于唯一索引的分布式锁
专门用一张锁表,利用唯一索引保证互斥:
CREATE TABLE distributed_lock (
lock_key VARCHAR(128) NOT NULL,
lock_value VARCHAR(64) NOT NULL, -- 持锁方唯一标识
expire_time DATETIME NOT NULL,
PRIMARY KEY (lock_key)
);
-- 加锁:INSERT,唯一键冲突则失败
INSERT INTO distributed_lock (lock_key, lock_value, expire_time)
VALUES ('order:123', 'node-1:thread-42', NOW() + INTERVAL 30 SECOND);
-- 释放锁:DELETE,校验 lock_value 防止误删
DELETE FROM distributed_lock
WHERE lock_key = 'order:123' AND lock_value = 'node-1:thread-42';
-- 清理过期锁(定时任务)
DELETE FROM distributed_lock WHERE expire_time < NOW();
这种方案依赖数据库,性能较低,但实现简单,适合对性能要求不高、已有数据库基础设施的场景。
方案对比
| 方案 | 性能 | 可靠性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| Redis SET NX(单节点) | 高 | 中(主从切换有风险) | 低 | 高并发、可接受极小概率锁失效 |
| Redis Redlock | 中 | 中高 | 中 | 高并发、需要一定容错能力 |
| ZooKeeper | 中低 | 高 | 中 | 强一致性要求、可接受较低吞吐 |
| 数据库行锁 | 低 | 高 | 低 | 并发量低、业务逻辑在数据库事务内 |
| 数据库乐观锁 | 中 | 高 | 低 | 读多写少、冲突概率低 |
常见陷阱
锁超时与业务执行时间
最容易被忽略的问题:业务执行时间超过锁的超时时间。
// 危险:超时时间 5 秒,但业务可能执行 10 秒
SET lock:key uuid NX PX 5000
// ... 业务逻辑(可能因 GC、慢查询、外部调用超时而变慢)
DEL lock:key // 此时锁已经过期,可能删了别人的锁!
解决方案:
- 使用 Redisson 看门狗自动续期
- 或者将超时时间设置得足够大(业务执行时间的 3~5 倍),并接受崩溃后锁较长时间无法释放的代价
非原子的加锁操作
早期常见的错误写法,将 SETNX 和 EXPIRE 分成两步:
# 错误写法:两步操作不是原子的
SETNX lock:key uuid
EXPIRE lock:key 5 # 如果这步执行前进程崩溃,锁永远不会过期!
正确写法是用 SET key value NX PX timeout 一条命令完成,Redis 2.6.12 之后支持。
可重入问题
Redis 的基础实现不支持可重入锁。同一个线程两次加锁会死锁:
lock.lock(); // 成功
// ...
lock.lock(); // 死锁!key 已存在,SET NX 失败,等待自己释放
Redisson 通过在 value 中记录重入次数解决了这个问题,底层使用 Hash 结构存储 field = 线程ID,value = 重入次数。
释放锁失败
释放锁的 Lua 脚本执行失败时,应该记录日志并告警,不能静默忽略。若释放锁失败,锁会在超时后自动释放,但这段时间内系统可用性会下降。
Fencing Token:锁之外的安全保障
Martin Kleppmann 在批评 Redlock 时提出了 Fencing Token 的概念,值得了解。
问题场景:
- 客户端 A 持锁,发生长时间 GC 停顿(如 30 秒)
- 锁超时自动释放
- 客户端 B 加锁成功,开始执行
- A 的 GC 结束,继续执行,此时 A 和 B 同时操作共享资源
Fencing Token 的解决思路:每次加锁时,锁服务返回一个单调递增的 token(如 ZooKeeper 的 zxid)。客户端在操作共享资源时带上这个 token,资源服务端拒绝处理 token 比当前已处理的更小的请求:
客户端 A 获取锁,token = 33
客户端 A GC 停顿,锁超时
客户端 B 获取锁,token = 34
客户端 B 操作存储,带 token=34,成功
客户端 A 恢复,操作存储,带 token=33,被拒绝(33 < 34)
这个方案要求存储服务端实现 token 校验逻辑,改造成本较高,但能提供真正的安全保障。
生产实践建议
选型原则:
- 高并发 + 可接受极低概率锁失效 → Redis SET NX + Redisson 看门狗
- 高并发 + 需要更高可靠性 → Redlock(5 节点)
- 强一致性要求 + 并发量中等 → ZooKeeper + Curator
- 并发量低 + 已有数据库 → 数据库行锁或乐观锁
实现规范:
- 锁的 value 必须全局唯一(UUID + 节点 ID + 线程 ID)
- 释放锁必须使用 Lua 脚本保证原子性
- 设置合理的锁超时时间,或使用看门狗自动续期
- 加锁失败时要有重试机制,重试间隔加入随机抖动(避免惊群)
- 监控锁的竞争情况,锁等待时间过长是系统瓶颈的信号
兜底策略:
- 分布式锁不是银弹,应结合业务幂等设计(如数据库唯一约束)作为兜底
- 即使锁失效,业务操作本身应该是幂等的,能够安全重试
总结
分布式锁的核心难点不在于实现,而在于理解各方案的边界条件。Redis 锁性能高但在极端情况下(主从切换、GC 停顿、时钟漂移)存在安全风险;ZooKeeper 锁一致性更强但性能较低;数据库锁适合低并发场景。
实际工程中,大多数业务选择 Redis + Redisson 看门狗的方案,并通过业务幂等设计作为兜底,这是性能与可靠性的合理平衡点。