文章

分布式锁

分布式锁

Redis 分布式锁(Redisson)与 Zookeeper 分布式锁(Curator)的实现原理、对比分析及项目实战。


一、为什么需要分布式锁

1.1 从单机锁到分布式锁

单机环境下,Java 提供 synchronizedReentrantLock 等机制保证多线程对共享资源的互斥访问。这些锁依赖 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          # 设置过期时间,防止死锁

致命问题SETNXEXPIRE 是两条命令,非原子操作。如果 SETNX 成功后客户端崩溃,来不及执行 EXPIRE,锁将永久存在(死锁)。

第二代:SET NX PX(原子加锁)

Redis 2.6.12+ 支持将 SETNXPX 组合为单条原子命令:

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)

加锁流程

  1. 在锁路径下创建临时顺序节点 /locks/post-like/node-xxxx
  2. 获取该路径下所有子节点,判断自己是否为序号最小的节点
  3. 如果是最小节点 → 获取锁成功
  4. 如果不是 → 监听前一个节点的删除事件,进入等待
  5. 前一个节点删除后 → 被唤醒,重新判断自己是否为最小节点

释放锁

  • 主动释放:删除自己创建的节点
  • 被动释放:客户端会话断开,临时节点自动删除

优势

  • 解决羊群效应:每个客户端只监听前一个节点,释放时只唤醒一个后继
  • 天然公平锁:先请求先获取
  • 容灾性好:客户端崩溃后会话超时,临时节点自动删除,锁自动释放

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 有什么问题?如何演进?

:四个阶段演进:

  1. SETNX + EXPIRE:两条命令非原子,可能死锁
  2. SET NX PX:原子加锁,但释放锁可能误删他人锁
  3. 唯一标识 + Lua 脚本:CAS 释放,但锁过期时间难定
  4. 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-00000001node-00000002…),获取锁时判断自己是否为最小序号节点。序号小的先获取锁,天然实现先到先得的公平锁。

Redis 分布式锁默认是非公平的(谁先抢到 SETNX 谁获取),Redisson 可通过 RedissonFairLock 实现公平锁,但性能有损耗。

Q6:Redis 和 ZK 分布式锁如何选型?

场景 选择 原因
点赞、抢红包、库存扣减 Redis 高并发、允许极小概率重复
订单状态变更、金融转账 ZK 强正确性、低频
已有 Redis 无 ZK Redis 避免引入新中间件

核心原则:AP 场景选 Redis,CP 场景选 ZK

Q7:分布式锁和数据库唯一索引如何配合?

:以点赞为例,两层防护:

  1. 分布式锁(第一层):防止同一用户并发请求同时通过「是否已点赞」检查
  2. 数据库唯一索引(第二层):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)主从切换时序问题(最常见)

场景

  1. 客户端 A 在主节点 Master 加锁成功
  2. Redis Master 此时突然崩溃
  3. Sentinel 选举 Slave 为新主,但异步复制尚未同步锁 Key
  4. 客户端 B 向新主加锁成功 → A 和 B 同时持锁

解决

  • RedLock 算法:向 N 个(通常 5 个)独立 Redis 节点同时加锁,超过半数(N/2+1)成功才视为成功
  • 架构优化:降低主从切换频率(Sentinel 合理配置、避免频繁重启)
  • 业务兜底:配合数据库唯一索引做最终一致性兜底

(2)网络分区(脑裂)

场景

Redis 部署了「1 主 + 1 从 + 3 台 Sentinel 哨兵」。客户端 A 和 B 初始都通过 Sentinel 发现主节点并连接到 M(主节点)。

  1. 网络分区发生,被切成左右两半
    • 左边:客户端 A + 主节点 M + 哨兵 S1(少数派)
    • 右边:客户端 B + 从节点 S + 哨兵 S2、S3(多数派,Sentinel 过半才能决策)
    • 两边互相不通
  2. Sentinel 故障转移(右边发生的事)
    • 哨兵 S2、S3 发现主节点 M 失联(超过 down-after-milliseconds
    • 过半哨兵(2/3)同意 M 客观下线 → 启动故障转移
    • 投票选举从节点 S 提升为新主 S’,允许写入
  3. 客户端 B 被通知换主(右边发生的事)
    • 客户端 B 本来连接的是主节点 M,分区后与 M 断开了连接
    • 客户端 B 向哨兵 S2、S3 重新查询「当前主节点是谁?」
    • 哨兵回复:「主节点换成 S’ 了,地址是 xxx」
    • 客户端 B 断开与旧主 M 的连接,重连到新主 S’
  4. 客户端 A 被蒙在鼓里(左边发生的事)
    • 客户端 A 连接的哨兵 S1 是少数派,无法发起故障转移投票
    • 客户端 A 仍然正常连接着主节点 M(没断)
    • A 收不到任何换主通知,也不知道 M 其实已经被右边的哨兵罢免了
  5. 两边都成功加锁
    • 客户端 A → 向旧主 M SET NX → M 进程没挂、没被杀,写入成功
    • 客户端 B → 向新主 S’ SET NX → S’ 是合法新主,写入成功
  6. 双持锁:M 和 S’ 上各有一把锁,A 和 B 同时操作共享资源 → 数据冲突

解决

方案 原理
Redis Cluster 使用 Gossip 协议 + 16384 个槽,自动识别网络分区,少数派节点拒绝写入,避免双主
RedLock 向 5 个独立 Redis 节点同时加锁,必须过半成功才有效,降低脑裂概率
配置 min-replicas-to-write 主节点要求至少有 1 个从节点在线才接受写入,被孤立时会主动拒绝 A 的写入
ZK 替代 CP 一致性模型,分区时少数派节点自动变为只读,不会出现双主

(3)长 GC 暂停导致逻辑失效

场景

  1. 客户端 A 获取锁(有效期 10s),开始执行业务
  2. JVM 发生 Full GC,整个进程 Stop-The-World 暂停 15s
  3. 关键点:Redisson 看门狗线程也被暂停,无法续期
  4. Redis 中锁 Key 因过期自动删除
  5. 客户端 B 获取锁成功
  6. A 的 GC 结束后继续执行 → 逻辑上双持锁

解决

  • 调优 JVM GC:缩短 GC 暂停时间(G1/ZGC、合理堆大小)
  • 延长锁有效期:设置足够长的 leaseTime 覆盖最长 GC 暂停时间(不推荐,影响其他客户端)
  • 业务幂等:业务逻辑本身实现幂等性,即使重复执行也不产生错误(如 INSERT ... ON DUPLICATE KEY UPDATE
  • 看门狗优化:Redisson 看门狗默认 30s 过期,即使 GC 暂停 15s,恢复后立即续期

(4)RDB 全量同步丢失

场景

  1. Redis 配置了定期 RDB 快照(如每 5 分钟)
  2. 客户端 A 加锁成功,此时 Redis 尚未执行 RDB save
  3. Redis 实例崩溃,内存数据全部丢失
  4. 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 通过临时顺序节点解决羊群效应,天然公平锁;生产环境通过分布式锁 + 数据库唯一索引双层防护保证正确性。

本文由作者按照 CC BY 4.0 进行授权