Redis 系列 15. Redisson 与 RedLock


1. 为什么要讲 Redisson

上一篇手写了 Redis 分布式锁,但生产项目里不建议每个团队都维护一套锁实现。分布式锁实现涉及多种工程边界问题,关键复杂度来自锁语义与异常边界处理,比如可重入、续期、释放校验、异常恢复、等待队列、主从切换。

Redisson 是 Java 生态里常见的 Redis 客户端,它对锁、集合、队列、信号量等分布式对象做了封装。最常用的是 RLock。

RLock lock = redissonClient.getLock("lock:order:" + orderId);
lock.lock();
try {
    createOrder(orderId);
} finally {
    lock.unlock();
}

这段代码比手写版本短,但底层仍然绕不开 Redis 锁的那些边界。

需要先明确两个概念:Redisson RLock 通常是基于单个 Redis 逻辑锁模型的工程封装;RedLock 是多个独立 Redis master 上的 quorum 锁算法。它们解决的问题相关,但不是同一个层次。

2. Redisson 基础配置

单机 Redis 示例:

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer()
        .setAddress("redis://127.0.0.1:6379")
        .setDatabase(0);
    return Redisson.create(config);
}

如果项目同时使用 Spring Data Redis,也常见到类似连接配置:

spring:
  redis:
    host: 127.0.0.1
    port: 6379

这类配置不等同于所有 Redisson starter 的专属配置。实际项目要以当前 Spring Boot、Redisson starter 和 Redis 客户端版本为准,Spring Boot 3 项目还要注意 spring.data.redis 这类配置前缀差异,不要只复制网上老版本配置。

3. RLock 的常用写法

阻塞加锁:

RLock lock = redissonClient.getLock("lock:stock:" + skuId);
lock.lock();
try {
    deductStock(skuId);
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

lock.lock() 没有显式传入 leaseTime 时,会启用 watchdog 自动续期。isHeldByCurrentThread() 的意义是防止 stale unlock:当前线程已经不再持有锁时,不应该再执行释放。

尝试加锁:

RLock lock = redissonClient.getLock("lock:stock:" + skuId);
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
    throw new IllegalStateException("system busy");
}
try {
    deductStock(skuId);
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

tryLock 这个重载会抛 InterruptedException,示例里省略了方法签名。真实代码要么在方法上继续抛出,要么捕获后恢复中断标记。

tryLock(waitTime, leaseTime, unit) 里:

参数 含义
waitTime 最多等待多久获取锁
leaseTime 获取锁后多久自动释放
unit 时间单位

如果传了明确 leaseTime,锁到期会释放。业务执行时间必须小于这个时间,否则有锁提前释放的风险。

waitTime 和 leaseTime 同时影响锁语义:waitTime 决定最多排队多久,leaseTime 决定拿到锁后多久自动释放。排队时间太长会拖慢调用方,租约太短会增加临界区执行过程中锁过期的风险。

4. 看门狗机制

Redisson 的 watchdog 用来解决业务执行时间不确定的问题。

如果调用:

lock.lock();

没有传 leaseTime,Redisson 会给锁设置默认过期时间,并通过内部定时任务在当前客户端仍持有锁时自动续期。默认锁看门狗超时时间可以通过 lockWatchdogTimeout 配置,常见默认值是 30 秒。

如果调用:

lock.lock(10, TimeUnit.SECONDS);

传了明确租约时间,Redisson 不会按 watchdog 无限续期,锁会在租约时间后自动释放。

这点很容易说错:不是所有 Redisson 加锁都会自动续期,要看调用方式。

watchdog 也不是绝对安全网。如果 JVM 发生长时间 GC pause、机器暂停、网络抖动或 Redis 短暂不可用,续期任务可能延迟,锁仍然可能过期。锁过期后旧持有者继续执行业务,就会进入并发执行窗口。

5. Redisson 锁底层思路

Redisson 的可重入锁会使用 Hash 结构记录持有线程和重入次数,逻辑和上一篇手写版本类似:

lock:order:1
  field = clientId:threadId
  value = count

加锁时:

  1. 如果锁不存在,创建 Hash,设置重入次数为 1,并设置过期时间。
  2. 如果锁存在且 field 是当前线程,重入次数加 1,刷新过期时间。
  3. 如果锁被其他线程持有,返回剩余 TTL,并等待通知或重试。

释放时:

  1. 校验当前线程是否持有锁。
  2. 重入次数减 1。
  3. 如果减到 0,删除锁并发布通知。

这里的 clientId:threadId 是唯一 owner 标识。每次重入都会刷新 TTL,避免嵌套调用期间锁提前过期。释放锁时 Redisson 底层通过 Lua 保证“校验持有者”和“递减 / 删除”这组动作原子执行,避免 stale client 误删新锁。

6. 主从切换带来的问题

单 Redis 实例加锁有一个典型风险:

客户端 A 在 master 获取锁
master 还没同步给 replica 就宕机
replica 被提升为新 master
客户端 B 在新 master 又获取同一把锁

这会导致同一时刻 A 和 B 都认为自己持有锁。

Redis 主从复制默认是异步复制,所以普通手写 Redis 锁不能承诺在主从切换时仍然绝对互斥。Redisson 在部分部署模式和配置下可以等待从节点同步来缩小窗口,但这仍然不是强一致锁;如果锁保护的是外部系统写入,仍然要考虑 fencing token 或业务幂等。

这不是某个客户端独有的问题,而是 Redis 锁模型和部署拓扑共同决定的边界。

更直白地说,replication lag 会带来双持锁窗口。Redis 锁更接近 best-effort mutual exclusion,不是线性一致锁。如果下游资源支持版本校验,fencing token 是更可靠的保护:旧锁持有者即使恢复执行,也会因为 token 过旧而被拒绝写入。

7. RedLock 是什么

RedLock 是 Redis 作者提出的一种分布式锁算法,核心思想是使用多个相互独立的 Redis master,而不是一个 master 加多个 replica。

假设有 5 个独立 Redis 节点:

redis-1
redis-2
redis-3
redis-4
redis-5

客户端获取锁时:

  1. 记录开始时间。
  2. 依次向多个 Redis 节点使用相同 key、相同 value、相同 TTL 加锁。
  3. 只有拿到多数派,比如 5 个节点里至少 3 个成功,才算成功。
  4. 总耗时必须小于锁有效期,并给时钟漂移和网络延迟留余量。
  5. 如果失败,要向所有节点尝试释放锁,包括已经获取成功的节点。

RedLock 多节点加锁示意

RedLock 通常使用奇数个节点,是为了更容易形成多数派。比如 N = 5 时,至少 3 个节点加锁成功才算成功,也就是 P > N / 2;如果允许失败 X 个节点,常见规模可以按 N = 2X + 1 估算。这里的重点不是“奇数节点一定强一致”,而是 RedLock 的判断条件依赖多数派和剩余有效时间。

剩余有效时间还要扣除加锁总耗时和 time drift。网络抖动时,timeout 不一定等价于加锁失败;客户端可能不知道某个节点上的写入到底是否已经成功,所以失败释放必须尽力清理所有节点。

8. RedLock 的使用边界

RedLock 有几个关键前提:

前提 说明
节点独立 多个 Redis master 不能是同一套主从里的副本
多数派成功 必须超过半数节点加锁成功
时间有效 获取锁耗时要小于 TTL,且要考虑时钟和网络延迟
失败释放 获取失败时清理已加锁节点

RedLock 在业界有争议。争议点主要在网络分区、时钟、暂停时间和锁语义到底能否满足严格正确性。从 CAP 视角看,网络分区发生时,如果系统仍然希望保持可用,就很难同时提供严格线性一致的锁语义。

我的工程建议是:

  1. 普通业务互斥,用 Redisson RLock,并接受 Redis 锁边界。
  2. 对正确性要求极高的场景,用数据库事务、唯一约束、乐观锁、ZooKeeper、etcd 等更合适的机制。
  3. 账务、支付、资金流水这类系统,不建议把 Redis 锁作为最终正确性基础。
  4. 不要因为听起来高级,就默认上 RedLock。它需要多套独立 Redis master,运维复杂度、监控成本和故障排查难度都会显著增加。

9. Redisson 和 RedLock 怎么选

场景 建议
缓存重建互斥 Redisson RLock
防重复提交 Redisson RLock 或 token 机制
定时任务单实例 Redisson RLock,也可用调度平台能力
秒杀库存扣减 Redis 预扣可以做,但最终正确性要落 DB 或库存服务
金额、账务、支付 不建议只靠 Redis 锁
多机房强一致锁 优先看 etcd、ZooKeeper 或专门一致性系统

锁只是并发控制的一层。真正的数据正确性通常还需要数据库唯一索引、版本号、事务和幂等表一起兜底。

如果锁保护的是外部存储写入,并且外部存储能校验单调递增 token,可以优先评估 Redisson 的 RFencedLock。它比“我以为我还持有锁”更可靠,因为下游资源可以拒绝旧 token 的写入。

也可以把锁和 CAS / versioning 结合起来:Redis 锁只负责降低并发概率,数据库版本号或 fencing token 负责最终拒绝旧请求。这样比单独依赖 Redis 锁更稳。

10. 常见错误写法

错误 问题
lock.lock() 后没有 finally unlock 异常时锁只能等过期
释放前不判断当前线程 可能抛异常或误释放
临界区里做长 RPC 锁时间不可控
leaseTime 设置太短 业务没执行完锁已释放
认为 Redisson 等于强一致 Redis 异步复制边界仍然存在
Redis 重启后仍假设锁存在 锁状态可能丢失,业务必须能容忍重试

推荐模板:

RLock lock = redissonClient.getLock(lockKey);
boolean locked = lock.tryLock(2, 15, TimeUnit.SECONDS);
if (!locked) {
    return;
}
try {
    doBusiness();
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

这段代码同样需要处理 InterruptedException。

这个模板不能替你解决所有一致性问题,但至少把锁释放、租约时间和当前线程校验几个基本点守住了。

最后可以按两组结论记:

工程建议:

  1. 普通短临界区优先用 Redisson RLock。
  2. 明确 leaseTime 会禁用 watchdog。
  3. 解锁前保留 isHeldByCurrentThread() 判断。
  4. 高正确性场景必须用 DB 约束、版本号、幂等表或一致性系统兜底。

风险总结:

  1. Redis 锁不是强一致锁,只是 best-effort mutual exclusion。
  2. 主从切换、GC pause、网络分区、Redis 重启都可能破坏锁语义。
  3. RedLock 是 multi-master quorum model,但仍存在争议和运维复杂度。
  4. fencing token 是防旧持锁者写外部系统的重要手段。

文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录