【黑马点评 | 第八篇】Redisson分布式锁

前言

秒杀场景中,同一个用户可能同时发起多次下单请求。多个应用实例共同处理请求时,单机锁只能约束当前进程内的线程,无法保证所有实例使用同一把锁。Redis 分布式锁可以解决进程间互斥,但手写实现还要处理可重入、锁重试、锁续期和 Redis 故障切换等问题。

Redisson 在 Redis 之上提供了 Java 分布式对象和并发工具。本文结合秒杀订单代码,说明 Redisson 的接入方式、RLock 的基本使用、可重入实现、锁重试、WatchDog 自动续期,以及 MultiLock 的工作方式。

一、基于 setnx 实现的分布式锁存在的问题:

使用 Redis 的 setnx 命令可以保证同一时刻只有一个线程写入锁 key。写入成功的线程获得锁,其他线程获取失败。为了防止持锁线程宕机后锁一直存在,还需要在写入锁的同时设置过期时间;释放锁时,还要比较锁标识,确认当前线程仍然拥有这把锁。

这种实现能够完成最基本的互斥,但距离一个完整的分布式锁还有差距,主要体现在下面几个方面。

1.1 重入问题

重入问题是指:已经获得锁的线程,再次进入同一把锁保护的代码块时,能不能继续执行。

可重入锁的意义在于避免同一线程自己把自己阻塞。例如一个方法使用 synchronized 修饰,方法内部又调用了另一个同样使用 synchronized 修饰的方法。如果锁不可重入,第二次加锁时会把自己挡在外面,程序就会一直等待自己释放锁。Java 的 synchronized 和 Lock 都支持可重入,Redisson 的 RLock 也需要满足这个特性。

基于 setnx 的简单实现通常只保存一个字符串值,例如线程标识。第二次加锁时,Redis 只能发现 key 已经存在,却无法区分"锁是当前线程持有的"还是"锁是其他线程持有的",也无法记录同一线程已经重入了几次。因此,普通的字符串锁无法直接实现可重入。

1.2 不可重试

简单实现通常只调用一次 setnx。获取成功就执行后续业务,获取失败就直接返回。这样的行为适合不允许等待的场景,但对于需要短时间排队的业务,线程在第一次竞争失败后还应该继续尝试获取锁。

如果自行实现重试,还要决定重试的总时长、每次等待多久,以及锁释放后如何尽快唤醒等待线程。一直循环访问 Redis 会增加 Redis 压力,固定时间休眠又可能在锁刚释放时仍然等待一段时间。Redisson 会结合等待时间和发布订阅机制处理这部分逻辑。

1.3 超时释放

加锁时设置过期时间,可以防止持锁线程宕机后形成死锁。例如锁的有效期设置为 10 秒,线程异常退出后,10 秒到期锁会自动删除,其他线程就可以继续获取。

但是,固定过期时间也会带来新的安全问题。假设线程 1 获取锁后执行时间较长,锁在 10 秒后自动释放;线程 2 发现锁已经不存在,于是获取成功并开始执行业务。此时线程 1 从阻塞中恢复,继续执行并释放锁。即使释放锁时采用 Lua 脚本判断锁标识,线程 1 也已经失去了锁的保护,线程 1 和线程 2 仍然可能同时执行业务。

因此,锁的有效期不能简单地设置成一个过短的固定值。业务执行时间不确定时,需要在持锁线程仍然存活的情况下自动延长锁的有效期。

1.4 主从一致性

Redis 主从集群通常采用异步复制。客户端向主节点写入锁以后,主节点还需要把这条数据同步给从节点。

如果主节点写入成功后、数据复制完成前发生宕机,哨兵可能会把还没有这条锁数据的从节点提升为新主节点。新主节点认为锁不存在,其他线程就能再次获取同名锁,原本的互斥关系也就失效了。

这说明单个 Redis 主从结构的可用性和锁数据的一致性并不是一回事。需要更高可靠性时,可以让同一把逻辑锁同时写入多个相互独立的 Redis 实例,这正是 MultiLock 要解决的问题。

二、Redisson 快速入门

项目在 pom.xml 中引入 Redisson 3.13.6:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

Spring 配置类负责创建 RedissonClient。连接地址、密码和数据库编号属于部署配置

java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        //配置
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://192.168.100.128:6379")
                .setPassword("******")
                .setDatabase(0);
        //创建RedissonClient
        return Redisson.create(config);
    }
}

useSingleServer() 表示使用单节点 Redis 配置。部署为 Redis 集群、哨兵或多个独立 Redis 实例时,需要改用对应的 Redisson 配置方式。应用代码只依赖 RedissonClient,通过它获取指定名称的锁对象:

java 复制代码
RLock lock = redissonClient.getLock("anyLock");
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (isLock) {
    try {
        // 执行业务
    } finally {
        lock.unlock();
    }
}

这里的第一个参数是最大等待时间,第二个参数是锁租期。获取锁后必须在 finally 中释放,并且释放动作要由持有锁的线程执行。

三、Redisson 在秒杀订单中的使用

当前秒杀请求先通过 Redis Lua 脚本校验库存和重复下单,再把订单消息写入 Redis Stream。后台线程消费消息后,按照用户 ID 获取订单锁,完成订单查询、库存扣减和订单保存。

java 复制代码
private void createVoucherOrder(VoucherOrder voucherOrder) {
    //1.获取用户
    Long userId = voucherOrder.getUserId();
    // 2.创建锁对象
    RLock redisLock = redissonClient.getLock("lock:order:" + userId);
    // 3.尝试获取锁
    boolean isLock = redisLock.tryLock();
    // 4.判断是否获得锁成功
    if (!isLock) {
        // 获取锁失败,直接返回失败或者重试
        log.error("不允许重复下单!");
        return;
    }
    try {
        // 5.1.查询订单
        Long count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            log.error("用户已经购买过了");
            return;
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0
                .update();

        if (!success) {
            log.error("库存不足");
            return;
        }

        save(voucherOrder);
    } finally {
        // 释放锁
        redisLock.unlock();
    }
}

锁名包含用户 ID,因此同一用户的订单消息会竞争同一把锁,不同用户之间仍然可以并行处理。tryLock() 不传等待时间和租期,表示获取失败时立即返回;同时,由于没有显式指定租期,Redisson 会使用 WatchDog 管理锁的有效期,具体机制见后文。

这段代码还体现了一个重要边界:锁只能约束进入 createVoucherOrder 的并发线程,不能替代数据库的库存条件更新和唯一约束。代码中的 stock > 0 条件仍然用于防止库存扣成负数,订单查询用于处理重复订单。

四、RLock 的可重入实现

可重入锁允许已经持有锁的线程再次获取同一把锁。例如外层方法持有锁后调用内层方法,内层方法再次获取同名锁时不会被自己阻塞。Redisson 使用 Redis Hash 保存锁状态:

  • Hash 的 key 是锁名称;
  • field 是 Redisson 生成的线程唯一标识,通常由客户端标识和线程 ID 组成;
  • value 是当前线程的重入次数。

同一线程每成功获取一次锁,重入次数加一;每调用一次 unlock(),重入次数减一;只有重入次数归零后,锁才会真正删除。

java 复制代码
void method1() {
    RLock lock = redissonClient.getLock("lock");
    boolean isLock = lock.tryLock();
    if (!isLock) {
        log.error("获取锁失败, 1");
        return;
    }
    try {
        log.info("获取锁成功, 1");
        method2(lock);
    } finally {
        log.info("释放锁, 1");
        lock.unlock();
    }
}

void method2(RLock lock) {
    boolean isLock = lock.tryLock();
    if (!isLock) {
        log.error("获取锁失败, 2");
        return;
    }
    try {
        log.info("获取锁成功, 2");
    } finally {
        log.info("释放锁, 2");
        lock.unlock();
    }
}

第一次获取锁时,Redisson 在 Hash 中写入线程标识和计数 1。第二次获取时发现 field 属于当前线程,就把计数改为 2。内层方法释放后计数回到 1,外层方法释放后计数回到 0,Redis 中的锁 key 才被删除。

获取锁和释放锁都通过 Lua 脚本完成,避免多条 Redis 命令之间被其他线程插入。获取锁的核心逻辑可以概括为:

lua 复制代码
local key = KEYS[1]
local threadId = ARGV[1]
local leaseTime = ARGV[2]

if redis.call('exists', key) == 0 then
    redis.call('hset', key, threadId, 1)
    redis.call('pexpire', key, leaseTime)
    return nil
end

if redis.call('hexists', key, threadId) == 1 then
    redis.call('hincrby', key, threadId, 1)
    redis.call('pexpire', key, leaseTime)
    return nil
end

return redis.call('pttl', key)

返回 nil 表示获取成功,返回剩余有效期表示锁由其他线程持有。释放锁时,脚本先判断当前线程是否拥有对应 field,再把重入次数减一;计数仍大于零时只更新有效期,计数归零时才删除锁并通知等待者。这样可以同时保证所有权检查、计数更新、删除和通知的原子性。

五、锁重试与 WatchDog 机制

5.1 锁重试

Redisson 的等待时间和租期是两个独立参数。等待时间决定"最多等多久才能拿到锁",租期决定"拿到锁后最多持有多久"。例如:

java 复制代码
boolean isLock = lock.tryLock(5, 30, TimeUnit.SECONDS);

这表示当前线程最多等待 5 秒,成功后锁租期为 30 秒。等待期间 Redisson 不会无间隔地高频访问 Redis,而是订阅锁释放通知。持有锁的线程释放锁后,Redis 发布通知,等待线程被唤醒并重新尝试获取锁;直到获取成功或超过等待时间。

不传等待时间的 tryLock() 会立即尝试一次,失败后直接返回 false。项目订单消费代码选择这种方式,获取失败时记录重复下单日志并结束本次消费处理;如果业务希望短时间排队等待,可以改用带 waitTime 的重载方法。

5.2 WatchDog 为什么存在

固定租期适合业务耗时上限明确的场景。如果业务执行时间超过租期,锁会自动过期,其他线程可能进入同一段业务区间。订单保存、远程调用或数据库繁忙时,实际耗时可能超过预估值,这时需要锁能够在业务仍然执行时自动延长有效期。

Redisson 在没有指定 leaseTime 时会启动 WatchDog。它先使用 lockWatchdogTimeout 作为锁的初始有效期,默认值为 30 秒;之后按超时时间的三分之一执行续期,也就是大约每 10 秒把锁的有效期重新设置为 30 秒。只要持有锁的 Redisson 客户端和线程仍然存活,续期任务就会持续运行。

WatchDog 的生命周期与锁的生命周期绑定:

  1. 成功获取锁后注册续期任务。
  2. 续期任务检查当前线程是否仍持有锁。
  3. 持有关系有效时重新设置过期时间。
  4. 调用 unlock() 后取消续期任务,并根据重入次数决定是否删除锁。
  5. 进程崩溃或客户端失去连接后,续期停止,锁在最后一次有效期结束后自动释放。

需要注意,显式指定 leaseTime 后,Redisson 按指定租期自动释放,不启动 WatchDog。例如 tryLock(5, 30, TimeUnit.SECONDS) 适用于能够确定 30 秒内完成的业务;无参 tryLock() 更适合耗时不固定、希望由 WatchDog 自动续期的业务。

调用方式 等待策略 锁有效期 WatchDog
tryLock() 立即失败 默认 WatchDog 超时 启动
tryLock(waitTime, unit) 最多等待指定时间 默认 WatchDog 超时 启动
tryLock(waitTime, leaseTime, unit) 最多等待指定时间 固定租期 不启动

WatchDog 只能解决"业务仍在运行但固定租期不够"的问题,不能替代业务超时控制。持锁代码仍然要尽量缩短临界区,并确保异常路径能够执行 unlock()。

六、MultiLock 与 Redis 高可用

6.1 主从切换带来的风险

单个 Redis 主从结构中,客户端先把锁写入主节点,主节点再异步复制给从节点。如果主节点在复制完成前宕机,哨兵可能将尚未保存锁数据的从节点提升为新主节点。新主节点认为锁不存在,其他客户端就可能再次获取同名锁,导致原本应该互斥的业务同时执行。

MultiLock 的思路是把多个独立 Redis 实例上的锁组合成一把逻辑锁。只有所有底层锁都成功,组合锁才算成功;任意一个底层锁获取失败,整体加锁失败,并处理已经获取的锁。

6.2 MultiLock 的基本使用

每个 RedissonClient 连接一个独立 Redis 实例,密码和地址仍然应该放在外部配置中:

java 复制代码
RLock lock1 = redissonClient1.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
RLock lock3 = redissonClient3.getLock("order");

RLock multiLock = redissonClient1.getMultiLock(lock1, lock2, lock3);

boolean isLock = multiLock.tryLock();
if (!isLock) {
    return;
}
try {
    // 执行业务
} finally {
    multiLock.unlock();
}

三个底层锁使用相同的名称,但分别存储在不同 Redis 实例中。getMultiLock 返回的对象仍然实现 RLock 接口,因此业务代码仍然使用 tryLock 和 unlock。释放时,MultiLock 会对参与组合的底层锁执行释放操作;调用线程必须与获取锁的线程保持一致。

6.3 加锁过程与取舍

MultiLock 获取锁时需要在多个 Redis 实例之间完成协调,整体耗时会受到最慢节点、网络延迟和重试次数影响。它提高了锁信息跨节点保存的可靠性,也增加了连接数量、配置成本和故障处理复杂度。

使用 MultiLock 前,需要确认参与组合的 Redis 实例相互独立。如果多个客户端实际上仍然落到同一套故障域,组合多个锁并不能带来预期的容错能力。节点不可用时还要结合等待时间、租期和监控策略,避免业务长时间等待。

单 Redis 锁适合结构简单、性能优先且 Redis 高可用方案已经明确的业务;MultiLock 适合对锁信息跨多个 Redis 节点的可靠性有更高要求的场景。两种方案都要求临界区代码保持简短,并正确处理获取失败和释放异常。

总结

Redisson 把分布式锁中容易出错的部分封装成了统一的 RLock API。Redis Hash 记录线程标识和重入次数,Lua 脚本保证加锁和释放锁的原子性,等待时间配合发布订阅实现锁重试,WatchDog 负责在业务仍然运行时续期,MultiLock 则把多个 Redis 实例上的锁组合成一把逻辑锁。

在秒杀一人一单场景中,先按用户维度确定锁名称,再根据业务耗时选择无租期的 WatchDog 模式或固定租期模式;只有在 Redis 部署结构和可靠性要求明确时,才需要引入 MultiLock。无论使用哪种方式,获取成功后的业务都应放在 try 中,并在 finally 中释放锁。

相关推荐
Leo.yuan1 小时前
2026年本地化Data Agent优质厂商盘点:哪些产品更适合企业生产环境
大数据·数据库·人工智能
考虑考虑1 小时前
synchronized字符串常量
java·后端·java ee
长谷深风1112 小时前
Tool与Skill:AI能力设计的分水岭
java·人工智能·ai·大模型·aiagent
m0_587383002 小时前
广州24小时自助健身房解决方案实战指南与系统部署要点
java·spring·小程序·架构·需求分析
做运维的阿瑞2 小时前
数据库增删改的安全写法
数据库·sql·mysql
Escalating_xu2 小时前
【C 语言】深入理解指针(1·下):指针运算、野指针、assert 与传址实战
java·c语言·开发语言
程序边界2 小时前
迁移评估不再拍脑袋,这个数据迁移工具的量化报告把我救了(上)
数据库
周杰偷奶茶2 小时前
【Java】数据类型与变量
java·开发语言
code斗2 小时前
Java数据结构:堆详解
java·开发语言·数据结构