分布式锁
分布式锁
Redis 分布式锁(Redisson)与 Zookeeper 分布式锁(Curator)的实现原理、对比分析及项目实战。
一、为什么需要分布式锁
1.1 从单机锁到分布式锁
单机环境下,Java 提供 synchronized、ReentrantLock 等机制保证多线程对共享资源的互斥访问。这些锁依赖 JVM 进程内存,仅在单个进程内有效。
分布式环境下,服务部署多实例(如帖子点赞服务部署 3 个节点),每个节点运行在独立 JVM 中,单机锁无法跨进程互斥:
1
2
3
用户 A ──→ 节点1 ──┐
用户 A ──→ 节点2 ──┼──→ 同一帖子点赞记录(并发写入冲突)
用户 A ──→ 节点3 ──┘
此时需要一把所有节点都能看到的锁,即分布式锁。
1.2 分布式锁的核心要求
| 要求 | 说明 |
|---|---|
| 互斥性 | 任意时刻,只有一个客户端能持有锁 |
| 可重入性 | 同一线程/客户端可多次获取同一把锁,避免死锁 |
| 锁超时 | 持有锁的客户端崩溃时,锁能自动释放,避免死锁 |
| 公平性 | 可选,先请求先获取(公平锁)或随机抢占(非公平锁) |
| 高可用 | 锁服务自身不能成为单点故障 |
1.3 常见实现方案
| 方案 | 一致性模型 | 性能 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| Redis | AP(最终一致) | 高 | 中 | 高并发、允许极小概率锁失效 |
| Zookeeper | CP(强一致) | 中 | 高 | 对正确性要求高、低频加锁 |
| 数据库 | CP | 低 | 中 | 简单场景、并发量低 |
二、Redis 分布式锁
2.1 演进过程
第一代:SETNX + EXPIRE
1
2
SETNX lock:post:like:123 thread-001 # 加锁(key 不存在才设置)
EXPIRE lock:post:like:123 10 # 设置过期时间,防止死锁
致命问题:SETNX 和 EXPIRE 是两条命令,非原子操作。如果 SETNX 成功后客户端崩溃,来不及执行 EXPIRE,锁将永久存在(死锁)。
第二代:SET NX PX(原子加锁)
Redis 2.6.12+ 支持将 SET 与 NX、PX 组合为单条原子命令:
1
SET lock:post:like:123 thread-001 NX PX 10000 # 不存在才设置 + 10秒过期
遗留问题:释放锁时如何保证只释放自己的锁?
1
2
# 危险写法:直接 DEL 可能误删别人的锁
DEL lock:post:like:123
场景:线程 A 获得锁之后,因 GC 暂停超过 10s,锁自动过期;线程 B 获取锁;线程 A 恢复后执行 DEL,误删了线程 B 的锁。
第三代:唯一标识 + Lua 脚本(CAS 释放)
加锁时设置唯一标识(如 UUID),释放时用 Lua 脚本保证「判断 + 删除」原子性:
1
2
3
4
5
6
-- KEYS[1] = 锁 key, ARGV[1] = 持有者标识
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
仍存在的问题:锁过期时间难以预估。设短了,业务未完成锁就过期;设长了,客户端崩溃后锁长时间不释放。
第四代:Redisson 看门狗(Watchdog)
Redisson 通过看门狗机制自动续期,解决锁过期时间难题:
1
2
3
4
5
6
7
8
9
获取锁(leaseTime=-1)
│
├── 设置锁,过期时间 = 30s(lockWatchdogTimeout)
│
├── 启动看门狗定时任务(每 10s = 30s/3 执行一次)
│ │
│ └── 如果当前线程仍持有锁 → 重置过期时间为 30s
│
└── 业务执行完毕 → 释放锁 + 取消看门狗
核心逻辑:只要线程存活且未主动释放锁,看门狗会持续续期,锁不会过期;线程崩溃后看门狗停止续期,锁在 30s 后自动释放。
2.2 Redisson 底层实现
Redisson 的 RLock 基于 Redis Hash + Lua 脚本,支持可重入:
1
2
3
4
Hash 结构:
key = lock:post:like:123
field = UUID:threadId(持有者标识)
value = 重入次数
加锁 Lua 脚本(简化版):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- KEYS[1]=锁key, ARGV[1]=持有者, ARGV[2]=过期时间
if redis.call("EXISTS", KEYS[1]) == 0 then
-- 锁不存在,首次加锁
redis.call("HSET", KEYS[1], ARGV[1], 1)
redis.call("PEXPIRE", KEYS[1], ARGV[2])
return nil
end
if redis.call("HEXISTS", KEYS[1], ARGV[1]) == 1 then
-- 锁存在且是当前线程持有,重入计数 +1
redis.call("HINCRBY", KEYS[1], ARGV[1], 1)
redis.call("PEXPIRE", KEYS[1], ARGV[2])
return nil
end
-- 锁被其他线程持有,返回剩余过期时间
return redis.call("PTTL", KEYS[1])
2.3 RedLock 红锁算法
问题背景:Redis 主从异步复制,主节点加锁成功后尚未同步到从节点就宕机,哨兵选举新主,新主上没有锁记录,导致多个客户端同时持有锁。
RedLock 思想(Redis 作者提出):向 N 个(通常 5 个)独立 Redis 节点(非主从集群)同时加锁,超过半数(N/2+1)成功则视为加锁成功。
1
2
3
客户端 → 同时请求 → Redis1, Redis2, Redis3, Redis4, Redis5
✓ ✓ ✗ ✓ ✓
4/5 成功 > 5/2 → 加锁成功
争议:RedLock 依赖各节点时间同步,且在时钟漂移、GC 暂停等场景下仍无法保证绝对正确。Martin Kleppmann 在《Designing Data-Intensive Applications》中批评了 RedLock 的安全性。生产环境中,如果对正确性要求极高,建议使用 ZK;如果允许极小概率失效,单 Redis 足矣。
2.4 项目实现
本项目使用 Redisson 封装 Redis 分布式锁,核心代码位于 RedissonDistributedLock.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit unit) {
RLock lock = redissonClient.getLock(key);
try {
// leaseTime=-1 时启用看门狗自动续期
return lock.tryLock(waitTime, leaseTime, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
@Override
public void unlock(String key) {
RLock lock = redissonClient.getLock(key);
// 仅当前线程持有锁时才释放,避免误删
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
三、Zookeeper 分布式锁
3.1 核心特性
Zookeeper 天然适合做分布式锁,源于其两个核心特性:
| 特性 | 说明 |
|---|---|
| 临时节点 | 客户端会话断开后,其创建的临时节点自动删除 → 锁自动释放 |
| 顺序节点 | 创建节点时自动追加递增序号 → 天然实现公平排队 |
| 监听机制 | 客户端可监听节点变化事件 → 高效通知而非轮询 |
| ZAB 协议 | 写操作过半写入才返回成功 → CP 强一致性 |
3.2 基础实现:临时节点
最简单的 ZK 分布式锁——所有客户端争抢创建同一个临时节点:
1
2
3
4
客户端A → 创建 /locks/post-like 成功 → 获取锁
客户端B → 创建 /locks/post-like 失败(已存在)→ 监听 /locks/post-like 删除事件
客户端A → 业务完成,删除节点(或会话断开自动删除)
客户端B → 收到节点删除通知 → 再次尝试创建
问题:羊群效应。当锁释放时,所有等待的客户端同时被唤醒争抢,产生大量 ZK 写请求,降低性能。
3.3 优化实现:临时顺序节点
核心思想:每个客户端创建自己的临时顺序节点,按序号排队,只监听前一个节点。
1
2
3
4
/locks/post-like/
├── post-like-00000001 ← 客户端A(序号最小,获取锁)
├── post-like-00000002 ← 客户端B(监听 00000001)
└── post-like-00000003 ← 客户端C(监听 00000002)
加锁流程:
- 在锁路径下创建临时顺序节点
/locks/post-like/node-xxxx - 获取该路径下所有子节点,判断自己是否为序号最小的节点
- 如果是最小节点 → 获取锁成功
- 如果不是 → 监听前一个节点的删除事件,进入等待
- 前一个节点删除后 → 被唤醒,重新判断自己是否为最小节点
释放锁:
- 主动释放:删除自己创建的节点
- 被动释放:客户端会话断开,临时节点自动删除
优势:
- 解决羊群效应:每个客户端只监听前一个节点,释放时只唤醒一个后继
- 天然公平锁:先请求先获取
- 容灾性好:客户端崩溃后会话超时,临时节点自动删除,锁自动释放
3.4 Curator InterProcessMutex
Apache Curator 的 InterProcessMutex 是 ZK 分布式锁的生产级实现,封装了上述逻辑并支持可重入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
InterProcessMutex.acquire()
│
├── 在锁路径下创建临时顺序节点
│
├── 循环判断:
│ ├── 自己是最小节点 → 获取锁成功,记录重入计数
│ └── 不是最小节点 → 监听前一个节点,wait()
│
└── 前一个节点删除 → 被唤醒 → 重新判断
InterProcessMutex.release()
│
├── 重入计数 -1
│
└── 计数归零 → 删除自己的临时节点 → 唤醒后继
3.5 项目实现
本项目使用 Curator 封装 ZK 分布式锁,核心代码位于 ZkDistributedLock.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit unit) {
InterProcessMutex mutex = getMutex(key);
try {
// 内部创建临时顺序节点,等待获取锁
return mutex.acquire(waitTime, unit);
} catch (Exception e) {
log.error("ZK 获取锁失败: key={}", key, e);
return false;
}
}
@Override
public void unlock(String key) {
InterProcessMutex mutex = lockMap.get(key);
if (mutex != null && mutex.isAcquiredInThisProcess()) {
mutex.release(); // 重入计数 -1,归零时删除临时节点
}
}
private InterProcessMutex getMutex(String key) {
// 同一 key 复用同一个 Mutex 实例,保证可重入性
return lockMap.computeIfAbsent(key, k -> {
String path = LOCK_ROOT + "/" + k.replace(":", "-");
return new InterProcessMutex(curatorFramework, path);
});
}
四、Redis vs Zookeeper 分布式锁对比
| 维度 | Redis(Redisson) | Zookeeper(Curator) |
|---|---|---|
| 一致性模型 | AP — 异步复制,主从切换可能丢锁 | CP — ZAB 协议,写操作过半确认 |
| 性能 | 高 — 内存操作,TPS 万级 | 中 — 每次加锁需创建节点 + ZAB 写共识 |
| 锁释放 | 主动 DEL + 过期自动释放 | 主动删除节点 + 会话超时自动释放 |
| 公平性 | 非公平(默认),可配置公平锁 | 天然公平(临时顺序节点) |
| 可重入 | Hash 结构记录重入计数 | InterProcessMutex 内部计数 |
| 续期机制 | 看门狗自动续期(leaseTime=-1) | 依赖会话心跳,无需续期 |
| 容灾 | 主从切换可能丢锁(RedLock 缓解但非根治) | 会话断开自动释放,不丢锁 |
| 锁失效风险 | 极小概率(主从切换窗口期) | 几乎不会(CP 强一致) |
| 运维成本 | 低(Redis 通常已有) | 中(需独立 ZK 集群) |
| 适用场景 | 高并发、允许极小概率失效 | 低频、强正确性要求 |
CAP 权衡
1
2
3
4
5
6
Redis(AP) Zookeeper(CP)
│ │
├── 优先可用性 ├── 优先一致性
├── 主从异步复制 ├── ZAB 半数写入
├── 网络分区时仍可读写 ├── 网络分区时少数派不可写
└── 极端情况可能多个客户端持锁 └── 不会出现多个客户端持锁
选型建议
- 高频加锁 + 允许极小概率失效 → Redis(如点赞、抢红包、库存扣减)
- 低频加锁 + 强正确性要求 → Zookeeper(如订单状态变更、金融转账)
- 已有基础设施 → 优先复用已有中间件,避免引入新组件
五、项目实战设计
5.1 模块架构
本项目将分布式锁抽象为独立模块 common-lock,支持通过配置切换 Redis 和 ZK 实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
common-lock
├── DistributedLock # 抽象接口
├── DistributedLockAspect # AOP 切面(注解式)
├── LockKeyGenerator # SpEL key 生成器
├── annotation/
│ └── DistributedLock # 声明式注解
├── redis/
│ ├── RedissonDistributedLock # Redis 实现
│ └── RedisLockAutoConfiguration
└── zk/
├── ZkDistributedLock # ZK 实现
├── ZkLockAutoConfiguration
└── ZkLockProperties
5.2 配置切换
通过 catfun.lock.type 配置项切换实现,基于 Spring Boot 条件装配:
1
2
3
4
5
6
7
catfun:
lock:
type: redis # redis | zk
zk: # 仅 type=zk 时生效
connect-string: 192.168.139.176:2181
session-timeout: 30000
connection-timeout: 10000
1
2
3
4
5
// Redis 实现:type=redis 或未配置时生效(默认)
@ConditionalOnProperty(name = "catfun.lock.type", havingValue = "redis", matchIfMissing = true)
// ZK 实现:type=zk 时生效
@ConditionalOnProperty(name = "catfun.lock.type", havingValue = "zk")
5.3 两种使用方式
注解式(声明式)
通过 @DistributedLock 注解自动加锁/释放,key 支持 #param 占位符引用方法参数:
1
2
3
4
5
6
7
8
@DistributedLock(key = "post:like:#postId:#userId", waitTime = 3, leaseTime = 10)
public PostLikeVO likePost(String userId, String postId) {
// 此处已被锁保护,无需手动控制
if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) {
throw new BusinessException("已点赞");
}
// ... 点赞逻辑
}
AOP 切面自动完成:解析 key → tryLock → 执行业务 → finally unlock。
编程式
手动调用 DistributedLock 接口,灵活控制加锁范围:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void unlikePost(String userId, String postId) {
String lockKey = "post:like:" + postId + ":" + userId;
executeWithLock(lockKey, () -> {
// 取消点赞逻辑
});
}
private void executeWithLock(String lockKey, Runnable action) {
boolean locked = distributedLock.tryLock(lockKey, 3, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("操作太频繁,请稍后重试");
}
try {
action.run();
} finally {
distributedLock.unlock(lockKey);
}
}
5.4 锁粒度设计
点赞场景的锁 key 为 post:like:{postId}:{userId}(帖子ID + 用户ID):
- 同一用户对同一帖子并发操作 → 互斥(防止重复点赞)
- 不同用户对同一帖子并发操作 → 不互斥(最大并发度)
如果锁 key 只用 postId,所有用户对同一帖子的点赞都会串行化,性能急剧下降。
六、面试高频问题
Q1:Redis 分布式锁的 SETNX 有什么问题?如何演进?
答:四个阶段演进:
SETNX + EXPIRE:两条命令非原子,可能死锁SET NX PX:原子加锁,但释放锁可能误删他人锁唯一标识 + Lua 脚本:CAS 释放,但锁过期时间难定Redisson 看门狗:自动续期,解决过期时间难题
Q2:Redisson 看门狗原理是什么?
答:当 leaseTime = -1(不指定过期时间)时,Redisson 启动看门狗:
- 默认锁过期时间 30s(
lockWatchdogTimeout) - 每 10s(30s/3)检查一次:如果当前线程仍持有锁,重置过期时间为 30s
- 线程主动释放锁时取消看门狗
- 线程崩溃后看门狗停止续期,锁在 30s 后自动释放
关键点:看门狗续期通过 Lua 脚本保证原子性,续期前检查持有者标识。
Q3:Redis 主从切换导致锁丢失怎么办?
答:
- 原因:主节点加锁成功后异步复制到从节点,此时主节点宕机,哨兵选举新主,新主上没有锁记录
- RedLock 方案:向多个独立 Redis 节点同时加锁,半数以上成功才算成功
- RedLock 争议:依赖时钟同步,GC 暂停可能导致锁失效,无法保证绝对正确
- 生产建议:大多数场景用单 Redis 足够(允许极小概率失效);强正确性需求用 ZK
Q4:ZK 分布式锁的羊群效应是什么?如何解决?
答:
- 羊群效应:所有等待锁的客户端都监听同一个节点,锁释放时全部被唤醒争抢,产生大量 ZK 请求
- 解决方案:使用临时顺序节点,每个客户端只监听前一个节点,锁释放时只唤醒一个后继
- Curator 的
InterProcessMutex已内置此优化
Q5:ZK 分布式锁为什么是公平锁?
答:ZK 临时顺序节点按创建顺序自动编号(node-00000001、node-00000002…),获取锁时判断自己是否为最小序号节点。序号小的先获取锁,天然实现先到先得的公平锁。
Redis 分布式锁默认是非公平的(谁先抢到 SETNX 谁获取),Redisson 可通过 RedissonFairLock 实现公平锁,但性能有损耗。
Q6:Redis 和 ZK 分布式锁如何选型?
答:
| 场景 | 选择 | 原因 |
|---|---|---|
| 点赞、抢红包、库存扣减 | Redis | 高并发、允许极小概率重复 |
| 订单状态变更、金融转账 | ZK | 强正确性、低频 |
| 已有 Redis 无 ZK | Redis | 避免引入新中间件 |
核心原则:AP 场景选 Redis,CP 场景选 ZK。
Q7:分布式锁和数据库唯一索引如何配合?
答:以点赞为例,两层防护:
- 分布式锁(第一层):防止同一用户并发请求同时通过「是否已点赞」检查
- 数据库唯一索引(第二层):
UNIQUE(post_id, user_id)兜底,即使锁失效也不会插入重复记录
1
ALTER TABLE cf_post_like ADD UNIQUE INDEX uk_post_user (post_id, user_id);
分布式锁解决并发性能问题(减少无效 DB 写入),唯一索引保证数据最终正确性。
Q8:可重入锁的实现原理?
答:
- Redis(Redisson):用 Hash 结构存储
持有者标识 → 重入次数,每次加锁计数 +1,每次释放计数 -1,归零时删除 key - ZK(Curator):
InterProcessMutex内部维护ConcurrentMap<Thread, Integer>记录重入次数,同一线程多次 acquire 计数 +1,release 计数 -1,归零时删除临时节点
可重入的意义:避免同一线程在持有锁时再次请求同一把锁导致死锁。
Q9:如何实现锁的公平性?
答:
- Redis:Redisson 的
RedissonFairLock在 Hash 结构外额外维护一个等待队列(List),加锁失败时入队等待,锁释放时按队列出队 - ZK:临时顺序节点天然公平,序号小的先获取,无需额外结构
Q10:分布式锁的超时时间如何设置?
答:
- Redis:
- 指定
leaseTime:业务执行时间 + 缓冲(如业务 2s,设 5s) - 不指定(
leaseTime=-1):看门狗自动续期,无需预估 - 推荐用看门狗,避免业务慢导致锁提前释放
- 指定
- ZK:
- 不需要设置过期时间,依赖会话超时(
sessionTimeout)自动释放 - 会话超时通常 30s,客户端崩溃后 30s 内锁自动释放
- 不需要设置过期时间,依赖会话超时(
Q11:如果业务执行过程中客户端宕机了,锁会怎样?
答:
- Redis:锁有过期时间(或看门狗停止续期后 30s 过期),自动释放
- ZK:客户端与 ZK 的会话断开,临时节点自动删除,锁立即释放(会话超时内)
两者都能保证客户端宕机后锁最终被释放,不会死锁。
Q12:注解式分布式锁的 AOP 切面如何实现?
答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Around("@annotation(distributedLockAnnotation)")
public Object around(ProceedingJoinPoint joinPoint, DistributedLock annotation) {
// 1. 解析 SpEL key(#postId → 实际参数值)
String lockKey = LockKeyGenerator.generate(joinPoint, annotation.key());
// 2. 尝试加锁
boolean locked = distributedLock.tryLock(lockKey, waitTime, leaseTime, SECONDS);
if (!locked) throw new BusinessException("操作太频繁");
// 3. 执行业务
try {
return joinPoint.proceed();
} finally {
// 4. 释放锁
distributedLock.unlock(lockKey);
}
}
关键点:
@Around环绕通知拦截注解方法- SpEL 解析动态 key(支持
#param引用方法参数) try-finally保证锁一定被释放joinPoint.proceed()执行原业务方法
Q13:Redis 分布式锁的失效场景有哪些?如何解决?
答:即使使用 Redisson,单 Redis 实例仍存在极小概率失效,核心场景与解决方案如下:
(1)主从切换时序问题(最常见)
场景:
- 客户端 A 在主节点
Master加锁成功 - Redis
Master此时突然崩溃 - Sentinel 选举
Slave为新主,但异步复制尚未同步锁 Key - 客户端 B 向新主加锁成功 → A 和 B 同时持锁
解决:
- RedLock 算法:向 N 个(通常 5 个)独立 Redis 节点同时加锁,超过半数(N/2+1)成功才视为成功
- 架构优化:降低主从切换频率(Sentinel 合理配置、避免频繁重启)
- 业务兜底:配合数据库唯一索引做最终一致性兜底
(2)网络分区(脑裂)
场景:
Redis 部署了「1 主 + 1 从 + 3 台 Sentinel 哨兵」。客户端 A 和 B 初始都通过 Sentinel 发现主节点并连接到 M(主节点)。
- 网络分区发生,被切成左右两半:
- 左边:客户端 A + 主节点 M + 哨兵 S1(少数派)
- 右边:客户端 B + 从节点 S + 哨兵 S2、S3(多数派,Sentinel 过半才能决策)
- 两边互相不通
- Sentinel 故障转移(右边发生的事):
- 哨兵 S2、S3 发现主节点 M 失联(超过
down-after-milliseconds) - 过半哨兵(2/3)同意 M 客观下线 → 启动故障转移
- 投票选举从节点 S 提升为新主 S’,允许写入
- 哨兵 S2、S3 发现主节点 M 失联(超过
- 客户端 B 被通知换主(右边发生的事):
- 客户端 B 本来连接的是主节点 M,分区后与 M 断开了连接
- 客户端 B 向哨兵 S2、S3 重新查询「当前主节点是谁?」
- 哨兵回复:「主节点换成 S’ 了,地址是 xxx」
- 客户端 B 断开与旧主 M 的连接,重连到新主 S’
- 客户端 A 被蒙在鼓里(左边发生的事):
- 客户端 A 连接的哨兵 S1 是少数派,无法发起故障转移投票
- 客户端 A 仍然正常连接着主节点 M(没断)
- A 收不到任何换主通知,也不知道 M 其实已经被右边的哨兵罢免了
- 两边都成功加锁:
- 客户端 A → 向旧主 M
SET NX→ M 进程没挂、没被杀,写入成功 - 客户端 B → 向新主 S’
SET NX→ S’ 是合法新主,写入成功
- 客户端 A → 向旧主 M
- 双持锁:M 和 S’ 上各有一把锁,A 和 B 同时操作共享资源 → 数据冲突
解决:
| 方案 | 原理 |
|---|---|
| Redis Cluster | 使用 Gossip 协议 + 16384 个槽,自动识别网络分区,少数派节点拒绝写入,避免双主 |
| RedLock | 向 5 个独立 Redis 节点同时加锁,必须过半成功才有效,降低脑裂概率 |
配置 min-replicas-to-write |
主节点要求至少有 1 个从节点在线才接受写入,被孤立时会主动拒绝 A 的写入 |
| ZK 替代 | CP 一致性模型,分区时少数派节点自动变为只读,不会出现双主 |
(3)长 GC 暂停导致逻辑失效
场景:
- 客户端 A 获取锁(有效期 10s),开始执行业务
- JVM 发生 Full GC,整个进程 Stop-The-World 暂停 15s
- 关键点:Redisson 看门狗线程也被暂停,无法续期
- Redis 中锁 Key 因过期自动删除
- 客户端 B 获取锁成功
- A 的 GC 结束后继续执行 → 逻辑上双持锁
解决:
- 调优 JVM GC:缩短 GC 暂停时间(G1/ZGC、合理堆大小)
- 延长锁有效期:设置足够长的
leaseTime覆盖最长 GC 暂停时间(不推荐,影响其他客户端) - 业务幂等:业务逻辑本身实现幂等性,即使重复执行也不产生错误(如
INSERT ... ON DUPLICATE KEY UPDATE) - 看门狗优化:Redisson 看门狗默认 30s 过期,即使 GC 暂停 15s,恢复后立即续期
(4)RDB 全量同步丢失
场景:
- Redis 配置了定期 RDB 快照(如每 5 分钟)
- 客户端 A 加锁成功,此时 Redis 尚未执行 RDB save
- Redis 实例崩溃,内存数据全部丢失
- Redis 重启后从最近的 RDB 快照恢复,锁 Key 不在快照中 → 丢失
解决:
- AOF 持久化:启用
appendonly yes,AOF 记录每条写命令,重启后可完整恢复 - 混合持久化:Redis 4.0+ 支持 RDB + AOF 混合,兼顾恢复速度和数据完整性
- 降低 RDB 间隔:缩短
save周期,但增加 I/O 压力
(5)解决方法总结
| 失效场景 | 影响程度 | 解决方法 |
|---|---|---|
| 主从切换丢锁 | 低概率、高影响 | RedLock / 唯一索引兜底 |
| 网络分区脑裂 | 极低概率、高影响 | Redis Cluster / ZK |
| GC 暂停逻辑失效 | 中概率、中影响 | 调优 GC + 业务幂等 + 看门狗 |
| RDB 同步丢失 | 低概率、中影响 | AOF 持久化 |
生产建议:
- 绝大多数业务:单 Redis + 数据库唯一索引 + 业务幂等 三件套足以应对
- 金融级场景:使用 ZK(CP 一致性)而非 Redis
- 高并发核心链路:使用 RedLock 5 节点部署,或直接 ZK
七、项目实战踩坑记录
7.1 @Transactional 与 @DistributedLock 的切面顺序问题
现象:ZK 模式下并发点赞,点赞表插入了两条重复记录。
根因:@DistributedLock 切面默认在 @Transactional 切面内部执行,导致「锁释放在事务提交之前」。
1
2
3
4
// likePost 方法同时标注了两个注解
@DistributedLock(key = "post:like:#postId:#userId")
@Transactional(rollbackFor = Exception.class)
public PostLikeVO likePost(String userId, String postId) { ... }
错误执行顺序(事务在外层,锁在内层):
1
2
3
4
5
6
7
8
9
10
11
12
1. @Transactional 开启事务
2. @DistributedLock 加锁
3. 检查是否已点赞 → false
4. 插入点赞记录
5. @DistributedLock 释放锁 ← 锁释放了!但事务还没提交
6. @Transactional 提交事务
并发时:
A: 事务开启 → 加锁 → 检查(无) → 插入 → 释放锁 → [未提交]
B: → 加锁 → 检查(无!A未提交) → 插入 → 释放锁 → 提交
A: → 提交
结果:两条重复记录 💥
B 在 A 释放锁后拿到锁,但 A 的事务还没提交,B 的 existsByPostIdAndUserId 查不到 A 未提交的记录(MySQL REPEATABLE READ 隔离级别 + MVCC),所以 B 也插入了。
解决:给 DistributedLockAspect 加 @Order(Ordered.HIGHEST_PRECEDENCE),让锁切面在事务切面外层执行。
1
2
3
4
5
6
@Slf4j
@Aspect
@AutoConfiguration
@Order(Ordered.HIGHEST_PRECEDENCE) // ← 关键:锁切面优先级最高,在最外层执行
@RequiredArgsConstructor
public class DistributedLockAspect { ... }
正确执行顺序(锁在外层,事务在内层):
1
2
3
4
5
6
7
8
9
10
11
1. @DistributedLock 加锁
2. @Transactional 开启事务
3. 检查是否已点赞 → false
4. 插入点赞记录
5. @Transactional 提交事务
6. @DistributedLock 释放锁
并发时:
A: 加锁 → 事务开启 → 检查(无) → 插入 → 事务提交 → 释放锁
B: → 加锁 → 事务开启 → 检查(有!A已提交) → 抛异常
结果:只有一条记录 ✅
注意:
@Transactional默认Order = LOWEST_PRECEDENCE(最低优先级、最内层)。只要锁切面的@Order值小于它,就能保证锁在事务外层。
7.2 @Around 参数绑定失败:JoinPointMatch was NOT bound
现象:加上 @Order(Ordered.HIGHEST_PRECEDENCE) 后,请求点赞接口报错:
1
2
java.lang.IllegalStateException: Required to bind 2 arguments, but only bound 1
(JoinPointMatch was NOT bound in invocation)
根因:@Around 使用参数名绑定注解,但编译时未开启 -parameters 选项导致参数名丢失。
1
2
3
4
5
6
7
// ❌ 错误写法:依赖参数名 distributedLockAnnotation 绑定
@Around("@annotation(distributedLockAnnotation)")
public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLockAnnotation) {
// Spring 需要通过参数名 "distributedLockAnnotation" 匹配 @annotation(...) 中的名称
// 但如果编译时没开 -parameters,参数名丢失 → 匹配失败 → 只绑定了 joinPoint(自动绑定)
// 结果:Required to bind 2 arguments, but only bound 1
}
参数名绑定的原理:
1
2
3
4
5
6
7
8
@Around("@annotation(distributedLockAnnotation)")
^^^^^^^^^^^^^^^^^^^^^^^^
这个名称必须和方法参数名完全一致
public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLockAnnotation)
^^^^^^^^^^^^^^^^^^^^^^^^
编译后如果没开 -parameters,
参数名变成 arg0, arg1 → 匹配失败
JDK 8+ 默认不保留参数名,需要编译时加 -parameters 选项。Spring Boot 的 spring-boot-starter-parent 默认开启了 -parameters,但如果手动配置 maven-compiler-plugin 且未加该选项,参数名就会丢失。
解决:改用全限定类型匹配 + 反射获取注解,完全不依赖参数名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ✅ 正确写法:全限定类型匹配,注解通过反射获取
@Around("@annotation(com.catfun.common.lock.annotation.DistributedLock)")
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 只做类型匹配,不绑定到方法参数
public Object around(ProceedingJoinPoint joinPoint) {
// 只有一个参数 ProceedingJoinPoint,Spring 自动绑定,不需要参数名
// 通过反射获取注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock annotation = method.getAnnotation(DistributedLock.class);
String lockKey = LockKeyGenerator.generate(joinPoint, annotation.key());
long waitTime = annotation.waitTime();
long leaseTime = annotation.leaseTime();
// ...
}
两种写法对比:
| 维度 | @annotation(name) 参数名绑定 |
@annotation(全限定类名) 反射获取 |
|---|---|---|
| 方法参数 | 2 个(joinPoint + annotation) | 1 个(joinPoint) |
| 依赖参数名 | ✅ 是(需 -parameters) |
❌ 否 |
| 获取注解 | Spring 自动注入 | 反射 method.getAnnotation() |
| 编译要求 | 必须 -parameters |
无特殊要求 |
| 适用场景 | 确保编译选项正确时 | 通用、稳健 |
最佳实践:在公共模块(如
common-lock)中,无法保证下游使用者的编译配置,优先使用全限定类型匹配 + 反射获取,避免参数名依赖。
7.3 编程式锁与事务的顺序问题
背景:7.1 解决了注解式锁的顺序问题(通过 @Order),但编程式锁是在方法内部调用的,@Order 无法控制它与 @Transactional 的顺序。
问题代码:
1
2
3
4
5
6
7
@Transactional(rollbackFor = Exception.class) // 1. 方法入口开启事务
public CommentLikeVO likeComment(String userId, String commentId) {
return executeWithLock(lockKey, () -> { // 2. 方法内部加锁
// 3. 检查 + 插入
}); // 4. 释放锁(事务还没提交)
// 5. 提交事务 ← 锁已释放,和注解式同样的问题
}
错误执行顺序:
1
2
3
4
5
6
7
8
1. @Transactional 开启事务
2. executeWithLock 加锁
3. 检查是否已点赞 → false
4. 插入点赞记录
5. executeWithLock 释放锁 ← 锁释放了,但事务还没提交
6. @Transactional 提交事务
并发时 B 在 A 释放锁后拿到锁,但 A 的事务还没提交 → B 查不到 → 重复插入
为什么 @Order 管不到:@Order 控制的是 AOP 切面的执行顺序。注解式锁的切面和事务的切面都在方法外部,@Order 可以调整它们的嵌套关系。但编程式锁是方法内部的普通方法调用,不经过 AOP,@Order 对它无效。
解决:去掉 @Transactional,用 TransactionTemplate 在锁内部手动管理事务,顺序由代码逻辑保证。
1
2
3
4
5
6
7
8
9
10
11
12
13
public CommentLikeVO likeComment(String userId, String commentId) {
String lockKey = COMMENT_LIKE_LOCK_PREFIX + commentId + ":" + userId;
// 编程式锁 + 编程式事务:保证「加锁 → 开启事务 → 业务 → 提交事务 → 释放锁」的顺序
return executeWithLock(lockKey, () -> {
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
return txTemplate.execute(status -> {
// 检查 + 插入 + 计数(在事务内)
return toCommentLikeVO(commentLike);
});
// txTemplate.execute 返回时事务已提交
});
// executeWithLock 的 finally 释放锁(此时事务已提交)
}
正确执行顺序:
1
2
3
4
5
6
7
8
1. executeWithLock 加锁
2. TransactionTemplate.execute 开启事务
3. 检查是否已点赞 → false
4. 插入点赞记录
5. TransactionTemplate.execute 提交事务
6. executeWithLock 释放锁
并发时 B 在 A 释放锁后拿到锁,A 的事务已提交 → B 查到已点赞 → 抛异常 ✅
注解式 vs 编程式对比:
| 维度 | 注解式 @DistributedLock + @Transactional |
编程式 executeWithLock + TransactionTemplate |
|---|---|---|
| 锁顺序控制 | @Order(Ordered.HIGHEST_PRECEDENCE) |
代码逻辑(锁在外层,事务在内层) |
| 事务管理 | 声明式 @Transactional |
编程式 TransactionTemplate |
| 适用场景 | 锁和事务都在方法上,通过 AOP 顺序控制 | 锁在方法内部,需要手动控制事务 |
| 代码侵入 | 低(两个注解搞定) | 中(需注入 PlatformTransactionManager) |
核心原则:无论是注解式还是编程式,都必须保证「锁在外层、事务在内层」,即事务提交后才释放锁。
八、总结
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
分布式锁
├── Redis(AP)
│ ├── 原理:SETNX + 过期 + Lua CAS
│ ├── 续期:看门狗自动续期
│ ├── 实现:Redisson RLock(Hash + Lua)
│ └── 风险:主从切换极小概率丢锁
├── Zookeeper(CP)
│ ├── 原理:临时顺序节点 + 监听前驱
│ ├── 释放:会话断开自动删除节点
│ ├── 实现:Curator InterProcessMutex
│ └── 优势:强一致、公平锁、无羊群效应
└── 项目设计
├── common-lock 模块统一抽象
├── 配置切换:catfun.lock.type=redis|zk
├── 注解式:@DistributedLock + AOP 切面
├── 编程式:DistributedLock 接口手动控制
└── 锁粒度:{业务}:{操作}:{资源ID}:{用户ID}
一句话总结:Redis 分布式锁胜在性能(AP),ZK 分布式锁胜在可靠性(CP);Redis 演进经历了 SETNX → Lua CAS → 看门狗,核心是解决原子性和续期问题;ZK 通过临时顺序节点解决羊群效应,天然公平锁;生产环境通过分布式锁 + 数据库唯一索引双层防护保证正确性。