本文目录导读:
这是一个非常经典的分布式系统问题,网络编程中的分布式锁,其核心目标是:在分布式系统中,多个进程/节点之间,保证对同一共享资源的访问互斥(即同一时刻只有一个节点能获取到锁)。
因为传统的 synchronized 或 ReentrantLock 只在单个JVM进程内有效,跨进程、跨网络时无法使用。
以下是目前最主流的三种分布式锁实现方式,从简单到可靠依次介绍。
核心原则
- 互斥性:任何时刻,只有一个客户端能持有锁。
- 安全性:锁在获取后,如果持有者崩溃(网络断开、进程死掉),锁必须能自动释放,避免死锁。
- 高可用/容错:锁服务本身要稳定,不能单点故障(例如使用 Redis 集群或 Etcd 集群)。
- 可重入性(可选):同一客户端是否可以多次获取同一把锁。
基于 Redis 的分布式锁
这是最常见、性能最高的方案,原理是利用 Redis 单线程处理命令的特性,通过 SETNX 命令实现互斥。
基础实现(不推荐用于生产)
思想:用 SETNX key value,key 不存在则设置成功(获得锁),存在则失败。
致命问题:无法处理死锁,如果获得锁的客户端崩溃,锁永远不释放。
正确实现:SET 命令 + 过期时间 + Lua 脚本(推荐)
这是目前 Redis 官方推荐的标准做法。
获取锁:
SET lock_key unique_value NX PX 30000
NX:只有 key 不存在时才设置(保证互斥)。PX 30000:设置 30 秒自动过期(解决死锁问题)。unique_value:客户端的唯一标识(如 UUID + 线程ID)。非常重要,用于释放锁时校验。
释放锁(保证原子性):
不能直接用 DEL lock_key,因为你可能释放掉别人刚获取的锁(比如自己业务执行太久,锁过期了),必须使用 Lua 脚本确保“先判断再删除”是原子操作。
-- Lua 脚本
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
代码示例(Java + Jedis):
public class RedisLock {
private Jedis jedis;
private String lockKey;
private String lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
private int expireTime = 30000; // 30秒
public boolean lock() {
// SET key value NX PX expireTime
String result = jedis.set(lockKey, lockValue, "NX", "PX", expireTime);
return "OK".equals(result);
}
public boolean unlock() {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(lockValue));
return "1".equals(result.toString());
}
}
优点:高性能、简单。 缺点:锁过期时间不好设(业务执行太久会自动释放);Redis 主从切换时可能丢锁(异步复制导致)。
进阶:Redlock 算法(用于 Redis 集群)
为了解决 Redis 主从切换丢锁的问题,Redis 作者提出了 Redlock,思想是:在 N 个独立的 Redis 节点上同时加锁,只有超过半数(N/2+1)节点成功才算加锁成功。
原理:
- 客户端获取当前时间
T1。 - 依次向所有 Redis 节点申请锁(超时时间很短,10ms)。
- 当获取成功的节点数
>= N/2 + 1,且总耗时小于锁的有效时间时,才认为锁成功。 - 释放时,向所有节点发送 unlock 脚本。
缺点:实现复杂,性能下降,且存在理论争议(时钟漂移问题),一般业务场景用简单 SET NX PX + 正确释放就能满足,99% 的场景不需要 Redlock。
基于 ZooKeeper / Etcd 的分布式锁(强一致性)
这类方案利用“临时顺序节点”和“Watch 机制”实现,原理依赖于 Paxos / Raft 共识算法,保证了强一致性和锁的严格互斥。
具体实现流程(以 ZK 为例):
-
创建临时顺序节点: 每个客户端去锁目录
/locks/my_lock下创建EPHEMERAL_SEQUENTIAL节点。- 客户端A:
/locks/my_lock/lock_0000000001 - 客户端B:
/locks/my_lock/lock_0000000002 - 客户端C:
/locks/my_lock/lock_0000000003
- 客户端A:
-
获取最小节点: 所有客户端获取
/locks/my_lock下的所有子节点列表,并对节点编号排序。 -
判断是否持有锁:
- 如果自己创建的节点是列表中最小的节点(序号最小),则成功获取锁。
- 如果不是最小的,则对比自己节点序号小1的节点(即前一个节点)设置一个 Watch(监听器),然后阻塞等待。
-
释放锁:
- 正常释放:删除自己创建的临时节点。
- 异常释放:客户端崩溃,会话断开,ZK 会自动删除该临时节点(天然避免死锁)。
-
通知下一个节点: 当上一个节点(如
lock_0000001)被删除时,ZK 会通知 Watch 它的节点(lock_0000002),该节点重新获取列表,发现自己变成最小,拿到锁。
完美解决:
- 死锁:临时节点机制。
- 锁竞争:顺序排队,类似公平锁(公平性比 Redis 好)。
- 锁超时:通过会话超时机制,比 Redis 固定过期时间更合理。
缺点:
- 性能:远低于 Redis(需多次网络交互,节点创建删除)。
- 复杂度:ZK 集群运维比 Redis 重。
- 羊群效应:高并发下大量节点 Watch 前一个节点,释放时惊群,可以优化(只 Watch 前一个)。
基于数据库的分布式锁(不推荐用于高并发)
利用数据库的唯一索引或行锁。
乐观锁(基于版本号)
适用于读多写少场景,不阻塞。
UPDATE resource SET version = version + 1, status = 'locked' WHERE resource_id = ? AND version = ?;
如果更新行数为0,说明版本号变了,获取锁失败。
悲观锁(基于 SELECT ... FOR UPDATE)
BEGIN; SELECT * FROM resource WHERE resource_id = ? FOR UPDATE; -- 如果查到了,就认为拿到锁 -- 执行业务... COMMIT; -- 释放锁
利用数据库的行级锁实现互斥。
优点:简单,不需要额外组件。 缺点:
- 性能差(数据库连接池、磁盘I/O)。
- 容易死锁(事务超时后回滚释放)。
- 数据库压力大,不适合高并发。
| 方案 | 实现难度 | 性能 | 可靠性 | 自动释放锁 | 可重入 | 典型场景 |
|---|---|---|---|---|---|---|
| Redis (SET NX PX) | 低 | 最高 | 中等(存在丢锁风险) | 需设置过期时间 | 需额外实现 | 高并发、允许小概率锁丢失(如秒杀、缓存更新) |
| Redlock | 高 | 高 | 较好(理论上容错强) | 同上 | 同上 | 对可靠性要求更高的 Redis 场景 |
| ZooKeeper/Etcd | 中 | 中等 | 最高(强一致性) | 天然支持(临时节点) | 天然实现 | 对一致性要求极高(如金融、配置中心、调度) |
| 数据库 | 低 | 最低 | 中等 | 需事务/超时控制 | 可简单实现 | 老系统、低并发、内网环境 |
选型建议
- 追求极致性能,能接受小概率锁丢失:选 Redis(SET NX PX + Lua),这是目前互联网公司的首选。
- 金融、计费、调度等要求严格互斥,不能有一点差池:选 ZooKeeper / Etcd。
- 公司完全没用 Redis/ZK,只用了 MySQL,并发极低:用 数据库乐观锁 或
FOR UPDATE。 - 需要保护锁节点,避免引入第三方中间件(如云原生环境):可以考虑基于 Etcd 的锁(比 ZK 更云原生、更轻量)。
最后提醒:无论选哪种方案,一定要保证锁的释放逻辑(unlock)在 finally 块中执行,并且要处理业务执行时间超过锁过期时间的问题(可以给锁加一个“看门狗”线程自动续期,或者使用 Redisson 等成熟框架)。
标签: ZooKeeper分布式锁