在单机时代,synchronized 和 ReentrantLock 就像部门内部的印章 ,只要在这个办公室(JVM 进程)里,大家都认。
但在微服务集群时代,你的应用可能部署了 10 个实例(10 个办公室)。此时,线程 A 在实例 1 加了锁,线程 B 在实例 2 根本不知道这个锁的存在,直接冲进去操作共享资源(如数据库库存),导致超卖 或数据不一致! 这可怎么办?这时候,我们需要一个全公司(集群)都认的"工商局备案章" ------ 分布式锁!
它的核心要求只有三条:
- 互斥性:任何时刻,只有一个客户端能持有锁。
- 防死锁:如果持有锁的客户端挂了,锁必须能自动释放(不能让别人永远等下去)。
- 高可用:锁服务本身不能挂(Redis 集群、ZK 集群)。
第一部分:基于 Redis 的分布式锁
Redis 因其高性能(单线程模型 + 内存操作)成为分布式锁的首选。但自己手写 Redis 锁坑非常多,强烈建议使用 Redisson。
1、naive 实现 vs. 生产级实现
// 错误示范:不要在生产环境这样写!
if (redis.setnx(lockKey, "myId") == 1) {
redis.expire(lockKey, 30); // 设置过期时间
try {
// 业务逻辑
} finally {
redis.del(lockKey); // 释放锁
}
}
这个为什么错呢?思考三秒钟
- 原子性问题 :
setnx和expire不是原子的。如果setnx成功但服务器宕机没执行expire,锁就永远死锁了。(Redis 2.6.12+ 支持SET key value NX EX seconds解决了原子性,但还有下一个问题)。 - 误删锁 :线程 A 持有锁,但业务执行时间超过 30 秒,锁自动过期释放。线程 B 抢到了锁。此时线程 A 执行完毕,去
del锁,结果把线程 B 的锁给删了! - 主从切换丢失:线程 A 在 Master 节点加锁,Master 还没同步给 Slave 就挂了。Slave 晋升为新 Master,锁丢了,线程 B 也能加锁。
2、生产级方案:Redisson 与"看门狗" (Watchdog)
原理深度解析:看门狗是如何工作的?
加锁逻辑:
-
调用
lock()时,如果不指定 leaseTime(租约时间),Redisson 默认开启看门狗。 -
初始锁过期时间设置为 30 秒 (
lockWatchdogTimeout)。 -
Redis 中存储的结构不是简单的 String,而是一个 Hash:
Key: "lock_key"
Value: { "uuid:threadId": 1 }
// 重入次数为 1,如果是重入锁,value 会递增 -
看门狗启动:
- Redisson在后台启动定时任务Timer,每隔 10 秒 (
lockWatchdogTimeout / 3) 检查一次 - 检查条件:如果当前线程还持有这把锁。
- 动作 :将锁的过期时间重置回 30 秒。
- 效果:只要业务线程还在运行(没挂),锁就永远不会过期。
- Redisson在后台启动定时任务Timer,每隔 10 秒 (
-
释放锁:
- 业务执行完,调用
unlock()。 - Redisson执行Lua脚本:判断锁UUID 是否匹配,匹配则删除否则则不删(防止误删)。
- 关键点:一旦手动解锁,看门狗定时任务会自动停止。
- 业务执行完,调用
-
故障场景:
-
如果业务线程宕机(断电、Kill -9),看门狗线程也一起死了。
-
没人再去"续期"了。
-
30 秒后,锁自动过期释放。其他线程可以重新竞争。完美解决死锁。
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;public class RedissonLockDemo {
private final RedissonClient redissonClient; public void processOrder(String orderId) { String lockKey = "order_lock:" + orderId; RLock lock = redissonClient.getLock(lockKey); // 1. 尝试加锁,最多等待 5 秒,上锁以后 30 秒自动解锁(如果不指定第二个参数,则开启看门狗) // 注意:如果指定了 leaseTime (如 30 秒),看门狗不会启动!锁会在 30 秒后强制释放,即使业务没跑完。 boolean isLocked = false; try { // 推荐用法:不指定 leaseTime,让看门狗自动续期 isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS); if (isLocked) { System.out.println("获取锁成功,开始处理订单..."); // 模拟业务耗时 40 秒 (超过默认的 30 秒) Thread.sleep(40000); System.out.println("订单处理完成"); } else { System.out.println("获取锁失败,业务繁忙"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (isLocked && lock.isHeldByCurrentThread()) { lock.unlock(); // 安全释放 } } }}
-
🌟 关于 Redlock 算法 :
为了解决 Redis 主从切换导致锁丢失的问题,Redis 作者提出了 Redlock 算法(向 N 个独立的 Redis 实例依次加锁,超过半数成功才算成功)。
- 争议:Martin Kleppmann 等大牛指出 Redlock 在极端时钟跳变下仍不安全(少数情况)
- 建议 :对于绝大多数业务(如秒杀扣库存),Redis 主从 + 哨兵/集群 + 看门狗 已经足够。如果对一致性要求极高(涉及资金),请考虑 Zookeeper 或 数据库乐观锁。
如何优化
第一层:代码与逻辑兜底
不要试图让锁本身 100% 完美,而要让业务逻辑能容忍锁的失效
1. 数据库乐观锁 (Optimistic Locking)
原理 :在数据库表中增加 version 字段或利用唯一索引。
作用:即使 Redis 锁失效,导致两个线程同时进入临界区,数据库层面的检查会拦截第二个提交者。
-
场景:库存扣减、余额修改。
-- 假设 Redis 锁失效,线程 A 和 B 都进来了
-- 线程 A 执行:
UPDATE stock SET count = count - 1, version = version + 1
WHERE id = 100 AND version = 5; -- 成功,影响行数 1-- 线程 B 执行(此时数据库中 version 已经是 6 了):
UPDATE stock SET count = count - 1, version = version + 1
WHERE id = 100 AND version = 5; -- 失败,影响行数 0 -
优化点 :在代码中判断
update返回值,如果为 0,说明并发冲突,抛出异常或重试。 结论 :Redis 锁作为**"高性能过滤器"** 挡住 99% 的请求,数据库乐观锁作为**"最后一道防线"**保证数据绝对一致。
2.唯一索引防重 (Unique Constraint)
原理 :利用数据库的唯一约束(Unique Key)。
作用:防止重复下单、重复领取优惠券。
- 操作 :建立
user_id + order_no的唯一索引。 - 效果 :即使 Redis 锁失效导致重复插入,数据库会报
DuplicateKeyException,捕获该异常即可。
3. 幂等性设计 (Idempotency)
原理 :业务逻辑本身支持重复执行而不产生副作用。
作用:即使锁失效导致接口被调用两次,结果也是一样的。
- 方法 :
- 状态机控制 :
UPDATE order SET status = 'PAID' WHERE id = 1 AND status = 'UNPAID'。第二次执行时状态已变,SQL 不生效。 - 去重表:先插入一张去重表(唯一索引),成功再执行业务。
- 状态机控制 :
第二层:Redis 架构与配置优化
1. 禁用主从复制的"异步"特性 (强一致性模式)
问题 :Master 写锁后,还没同步给 Slave 就挂了,Slave 晋升后锁丢失。
优化 :配置 Redis 的 min-slaves-to-write 和 min-slaves-max-lag。
# 至少有 1 个从节点且延迟不超过 10 秒,Master 才接受写请求
min-slaves-to-write 1
min-slaves-max-lag 10
- 效果 :如果 Master 发现没有健康的 Slave,它会拒绝写入(返回错误),而不是冒险写入导致数据丢失。
- 代价:可用性降低(网络抖动时可能无法加锁),但保证了数据一致性(CP 倾向)。
2. 使用 Redlock 算法 (谨慎使用)
原理:向 N 个(通常 5 个)独立的 Redis 实例依次申请锁,只有超过半数(N/2 + 1)成功,才算加锁成功。
- 优点:即使挂掉几个节点,只要过半数活着,锁就是安全的。
- 缺点 :
- 性能差(需要往返 N 次网络)。
- 实现复杂。
- 争议 :分布式系统专家 Martin Kleppmann 指出,在发生时钟跳变 或GC 停顿时,Redlock 依然不安全。
- 建议 :除非你对一致性要求极高且无法使用 ZK/Etcd,否则不推荐在常规业务中使用 Redlock。优先选择"Redis 单实例 + 数据库兜底"。
3. 调整看门狗 (Watchdog) 参数
问题 :默认 30 秒过期,10 秒续期。如果 Full GC 停顿超过 10 秒,看门狗没来得及续期,锁可能过期。
优化:
- 增大
lockWatchdogTimeout(如改为 60 秒)。 - 确保 JVM 堆内存充足,减少 Full GC 频率和停顿时间。
- 关键:业务逻辑中避免在持锁期间进行长时间的外部 RPC 调用或大对象序列化。
第三层:应对时钟跳变 (Clock Skew)
问题 :服务器时间突然回拨(如 NTP 同步),导致 Redis 认为锁已经过期并自动删除,而此时客户端认为自己还持有锁。
优化方案:
-
禁用 NTP 大幅调整:
- 配置 Linux 的
ntpd或chrony使用 Slew Mode (平滑模式) 而不是 Step Mode。让时间慢慢追回,而不是瞬间跳变。 - 命令:
chronyc -a makestep 0 -1(禁止步进调整)。
- 配置 Linux 的
-
逻辑校验 (Token 机制):
- Redisson 内部已经做了部分工作:释放锁时,会检查 Lua 脚本中的 UUID 是否匹配。
- 但如果时间回拨导致锁在 Redis 端自动过期,UUID 校验也无效(因为锁已经没了)。
- 根本解法 :还是回到第一层,依靠数据库版本号或状态机来防止并发修改。不要依赖时间来判断锁的有效性。
第四层:架构升级
如果业务场景是金融核心账务 、强一致性元数据管理,且完全不能容忍上述任何概率的异常
1.迁移到 Zookeeper / Etcd / Consul
- 理由 :这些是基于 Raft 或 ZAB 协议的 CP 系统。它们通过多数派投票和日志复制,从根本上解决了主从切换丢锁的问题。
- 代价:性能比 Redis 低一个数量级(毫秒级 vs 微秒级)。
- 策略 :
- 读写分离:读走 Redis,写(加锁)走 ZK。
- 分级锁:高频低一致性要求用 Redis,低频高一致性要求用 ZK。
2. 数据库行锁 (Database Row Lock)
- 理由 :直接利用 MySQL/PostgreSQL 的
SELECT ... FOR UPDATE。 - 优点:绝对可靠,天然事务支持。
- 缺点:性能最差,数据库连接池容易爆满。
- 适用:并发量不高(QPS < 500),但对数据一致性要求极高的场景。
第二部分:基于 Zookeeper 的分布式锁
Zookeeper (ZK) 是强一致性协议(ZAB),天生适合做分布式协调。它的锁机制基于 临时顺序节点
1. 核心原理:临时顺序节点
ZK 的数据结构像文件系统,每个节点叫 ZNode。
-
创建节点:
- 客户端尝试在
/locks/order_123目录下创建一个 临时顺序节点 (Ephemeral Sequential Node)。 - 例如:
/locks/order_123-000000001,/locks/order_123-000000002。 - 临时:客户端会话断开(宕机),节点自动删除(防死锁)。
- 顺序:ZK 保证全局唯一递增序号。
- 客户端尝试在
-
判断锁:
- 客户端获取
/locks/order_123下的所有子节点。 - 判断自己创建的节点是否是序号最小的那个。
- 是:获得锁,执行业务。
- 否:没获得锁。
- 客户端获取
-
监听等待 (Watch):
- 如果没有拿到锁,客户端不需要轮询(浪费资源)。
- 它只需要监听 比自己序号小 1 的那个节点 的删除事件。
- 例如:你创建了
003,你只监听002。 - 当
002执行完业务删除节点后,ZK 通知你。你再次检查,发现自己是现在最小的了,获得锁。
2. 优缺点对比
| 特性 | Redis (Redisson) | Zookeeper |
|---|---|---|
| 性能 | 极高 (内存操作,毫秒级) | 较低 (磁盘同步,ZAB 协议开销) |
| 一致性 | 最终一致性 (主从切换可能丢锁) | 强一致性 (CP 模型,Leader 选举期间不可用) |
| 实现复杂度 | 简单 (客户端库成熟) | 较复杂 (需处理 Watch 注册/重连) |
| 适用场景 | 高并发、允许极小概率不一致 (如秒杀、缓存重建) | 对一致性要求极高、并发量适中 (如元数据管理、调度任务) |
| 防死锁机制 | 看门狗 (心跳续期) | 临时节点 (会话断开即删) |
3. 代码实战 (Curator Framework)
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import java.util.concurrent.TimeUnit;
public class ZkLockDemo {
private final CuratorFramework client;
public void processOrder(String orderId) {
String lockPath = "/locks/order_" + orderId;
// 创建互斥锁对象
InterProcessMutex lock = new InterProcessMutex(client, lockPath);
try {
// 尝试获取锁,最多等待 10 秒
if (lock.acquire(10, TimeUnit.SECONDS)) {
try {
System.out.println("ZK 获取锁成功,处理业务...");
// 业务逻辑
Thread.sleep(5000);
} finally {
lock.release(); // 释放锁,节点删除
}
} else {
System.out.println("ZK 获取锁超时");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
-
底层细节 :
acquire方法内部会自动处理创建顺序节点、获取子列表、注册 Watcher、回调唤醒等复杂逻辑。// 伪代码:Zookeeper 分布式锁 acquire() 核心逻辑
public boolean acquire(String lockPath, long waitTime) {
String myNode = null;
String predecessor = null; // 前一个节点的名称(比自己序号小1的节点)// 1. 创建临时顺序节点 // 例如:/locks/order_123-000000005 myNode = zkClient.createEphemeralSequential(lockPath + "/lock-", myData); print("我创建了节点: " + myNode); long startTime = System.currentTimeMillis(); while (true) { // 2. 获取当前目录下所有子节点列表,并排序 // 结果示例:["lock-000000001", "lock-000000003", "lock-000000005"(我), "lock-000000008"] List<String> children = zkClient.getChildren(lockPath); sort(children); // 3. 判断自己是否是最小的节点 if (myNode.equals(children.get(0))) { print(" 我是最小的,获得锁!"); return true; // 成功获取锁 } // 4. 如果不是最小,找到"前驱节点" (Predecessor) // 比如我是 005,前驱就是 003。我只需要监听 003 的删除事件。 predecessor = findPredecessor(children, myNode); if (predecessor == null) { // 极端情况:列表变了,重试 continue; } // 5. 检查是否超时 if (System.currentTimeMillis() - startTime > waitTime) { print("等待超时,放弃获取锁"); // 删除自己创建的节点,释放资源 zkClient.delete(myNode); return false; } // 6. 注册 Watcher (监听前驱节点的删除事件) // 关键点:这里会阻塞当前线程,直到前驱节点被删除 或 超时 print("我没拿到锁,开始监听前驱节点: " + predecessor); boolean eventReceived = zkClient.watchAndWaitForDelete(predecessor, waitTime); if (!eventReceived) { // 如果是超时返回,再次循环检查是否超时或重新竞争 continue; } // 7. 被唤醒 (前驱节点已删除) print("收到通知:前驱节点 " + predecessor + " 已删除,准备重新竞争..."); // 循环回到第 2 步,重新获取列表,看自己是不是变成了最小 }}
// --- 辅助逻辑说明 ---
// 释放锁 (release) 的逻辑非常简单:
public void release(String myNode) {
// 直接删除自己创建的临时节点
zkClient.delete(myNode);
// ZK 会自动通知监听该节点的下一个等待者
}
如何优化Zookeeper的锁获取性能
ZK 的锁机制本质是:写操作(创建/删除节点)强一致,读操作(获取列表)相对轻量。性能瓶颈通常出现在高并发下的频繁写操作和大量的 Watcher 通知
1. 客户端代码层优化 (最直接有效)
A. 严格遵循"只监听前驱节点" (避免惊群)
这是 Curator InterProcessMutex 默认做的,但如果你手写 ZK 锁,绝对不能监听父节点!
- ❌ 错误 :所有线程监听
/locks目录的NodeChildrenChanged事件。锁释放时,1000 个线程同时被唤醒,瞬间发起 1000 次getChildren请求,ZK 直接被打挂。 - ✅ 正确 :每个线程只监听序号比自己小 1 的那个节点 的
NodeDeleted事件。- 效果 :锁释放时,只有一个线程被唤醒。网络流量从 O(N)O(N) 降为 O(1)O(1) 。
B. 本地缓存子节点列表 (减少 getChildren 调用)
在竞争激烈的场景下,不要每次被唤醒都去 ZK 拉取全量子节点列表。
- 优化策略 :
- 第一次调用
getChildren获取列表并排序。 - 在内存中维护这个列表。
- 当收到前驱节点删除的 Watcher 通知时,仅在本地内存中移除该节点,重新判断自己是否最小。
- 只有在本地列表为空 或异常时,才再次请求 ZK 获取最新列表。
- 第一次调用
- 收益:将大量的读请求从网络 IO 变为内存操作。
C. 批量操作与管道 (Pipeline)
如果业务允许(例如需要连续获取多个不同资源的锁),尽量合并请求。
- 虽然 ZK 的原生 API 对单条命令支持有限,但 Curator 框架内部使用了批处理优化。确保使用最新版本的 Curator,它会自动合并某些元数据请求。
D. 减少锁的粒度与持有时间
- 细粒度锁 :锁
order_123而不是锁all_orders。让不相关的业务并行执行。 - 快速失败 :设置合理的
waitTime。如果短时间内拿不到锁,直接返回失败或降级,不要让线程在 ZK 上无限等待,占用会话资源。 - 逻辑前置:能在本地校验的参数(如库存预检查),先在本地做完,再申请 ZK 锁。不要在持锁期间做耗时操作(如 RPC 调用、复杂计算)。
2. 架构设计层优化 (根本性解决)
A. "ZK 协调 + Redis 执行" 混合模式 (推荐 🔥)
ZK 擅长做协调 (谁该执行),不擅长做高频互斥。
- 方案 :
- 利用 ZK 的顺序节点特性,选出一个"Leader"或"执行者"。
- 只有拿到 ZK 锁的节点,才去 Redis 里执行真正的业务锁逻辑(或直接执行业务)。
- 或者:用 ZK 做低频的全局调度 (如每秒只允许一个任务运行),用 Redis 做高频的并发控制。
- 场景:定时任务调度、配置变更通知。
B. 分段锁 (Sharding / Striping)
如果所有请求都争抢同一个 ZK 节点(如 /lock/global),ZK 必死无疑。
方案:将锁分散到多个 ZK 路径上。
// 根据订单 ID 哈希,路由到 100 个不同的锁路径之一
int index = orderId.hashCode() % 100;
String lockPath = "/locks/shard_" + index;
C. 本地锁 + 分布式锁 (两级锁)
在单机多核环境下,先抢本地锁 (ReentrantLock),抢到了再去抢 ZK 锁。
- 流程 :
- 线程 A 尝试获取本地
synchronized。 - 如果成功,再尝试获取 ZK 锁。
- 如果 ZK 锁失败,释放本地锁,休眠随机时间后重试。
- 线程 A 尝试获取本地
- 收益:过滤掉大量同一进程内的无效 ZK 请求。只有真正代表该进程出战的线程才会去访问 ZK。
3. ZK 服务端调优 (运维层面)
A. 调整会话超时时间 (tickTime, maxSessionTimeout)
- 问题:超时时间太短,网络抖动会导致会话过期,触发大量临时节点删除和重建,造成"锁风暴"。
- 建议 :适当调大
maxSessionTimeout(默认 40s,可调至 60s+),给网络波动留出缓冲期。但也不能太大,否则故障恢复慢。
B. 增加 ZK 集群节点数
- ZK 的写性能受限于 Quorum (过半数) 确认机制。
- 公式:写延迟 ≈≈ 网络往返 ×× (N/2 + 1)。
- 建议 :
- 3 节点集群:允许挂 1 台,写性能一般。
- 5 节点集群:允许挂 2 台,写性能略降,但可用性高。
- 不要超过 7 节点:节点越多,达成一致的开销越大,写性能反而下降。通常 5 或 7 是上限。
C. 独立部署 (专机专用)
- 严禁将 ZK 与 Kafka、HBase 或其他高 IO 应用混部。
- ZK 对磁盘延迟极其敏感(每次写都要刷盘
fsync)。必须使用 SSD,并单独部署在低负载机器上。
D. 快照与日志优化
- 调整
snapCount:减少快照频率,避免频繁磁盘 IO 阻塞写请求。 - 将
dataDir(数据) 和dataLogDir(日志) 分盘存放,避免 IO 争抢。
终极建议
如果你的业务场景是 每秒数万次的锁请求(如秒杀扣库存):
- 放弃纯 ZK 锁方案。ZK 的设计初衷是协调,不是高并发互斥。
- 采用 Redis (Redisson) 方案 抗住大部分流量。
- ZK 仅用于 :
- 极低频的全局配置切换。
- Master 选举。
- 作为 Redis 挂掉后的降级兜底(平时不用,紧急时启用)。
第三部分:终极对决与选型指南
| 特性 | Redis (Redisson) | Zookeeper (上述伪代码) |
|---|---|---|
| 等待方式 | 自旋 + 休眠 (不断尝试 setnx) |
阻塞挂起 (OS 级别 wait,由 ZK 事件唤醒) |
| 流量压力 | 高 (频繁请求 Redis) | 低 (平时无请求,只有事件触发) |
| 实现难度 | 依赖 Lua 脚本 + 定时任务 (看门狗) | 依赖 ZK 的 Watcher 机制 + 临时节点 |
| 代码复杂度 | 客户端逻辑较重 | 客户端逻辑较轻,依赖服务端特性 |
1. 为什么 Redis 可能会"丢锁"?
在 Redis 主从架构下:
- 线程 A 在 Master 加锁成功。
- Master 还没来得及把数据同步给 Slave,Master 宕机。
- Slave 晋升为新 Master。
- 线程 B 在新 Master 上加锁,成功(因为刚才 A 的锁没同步过来)。
- 结果:A 和 B 同时持有锁。
解决方案权衡:
- 容忍:大多数业务(如防止重复提交、普通库存扣减)可以容忍极低概率的这种冲突,通过数据库唯一索引或乐观锁兜底。
- Redlock:实现复杂,性能下降,且仍有理论缺陷。
- 切换 ZK:如果业务绝对不能容忍并发冲突(如金融账务核心),直接用 ZK。
2. 为什么 ZK 不适合高并发秒杀?
- 羊群效应 (Herd Effect):虽然 Curator 优化了监听(只监听前一个节点),但在超高并发下(如每秒 10 万请求),创建/删除大量临时节点会给 ZK 集群带来巨大的写压力和网络风暴。
- 延迟:ZK 的写操作需要过半数节点确认,延迟通常在 10ms-50ms 级别,而 Redis 是 <1ms。
总结:分布式锁的"心法"
- 没有银弹:CAP 定理告诉我们,无法同时满足强一致性、高可用和分区容错性。Redis 偏向 AP(高可用),ZK 偏向 CP(强一致)。
- Redisson 是标配 :只要用 Redis 做锁,必须 用 Redisson 的看门狗机制。千万别自己写
setnx+expire。 - 兜底思维 :分布式锁只是第一道防线。在涉及资金、库存等核心数据时,数据库层面的乐观锁 (
version字段) 或唯一约束才是最后的救命稻草。 - 粒度控制 :锁的粒度越细越好。锁
order_123而不是锁all_orders