Java

Redisson 分布式锁:从为什么需要锁,到它是怎么工作的

在单体应用时代,我们经常使用 synchronizedReentrantLock 解决并发问题:同一时刻只允许一个线程进入临界区。

但当应用从单机扩展到多个实例之后,一个很自然的问题就出现了:如果两个请求分别落到了两台服务器上,synchronized 还能保证它们互斥吗?

答案是否定的。

这也是分布式锁存在的主要原因。

本文不打算只介绍几个 API,而是从问题出发,逐步理解 Redis 分布式锁,以及 Redisson 为什么能把一个看似简单的 Redis SET NX 封装成一个相对完整的分布式锁实现。

一、为什么需要分布式锁

先看一个很常见的场景:库存扣减。

假设数据库中有一条商品库存:

stock = 1

此时两个用户几乎同时购买最后一件商品:

请求 A:查询库存 -> 1
请求 B:查询库存 -> 1

请求 A:扣减库存 -> 0
请求 B:扣减库存 -> -1

如果代码运行在同一个 JVM 中,可以使用 synchronized 把查询和扣减包起来:

synchronized (lock) {
    // 查询库存
    // 扣减库存
}

但生产环境通常会部署多个实例:

                ┌── Server A ── JVM A
Client ── LB ───┤
                └── Server B ── JVM B

此时:

synchronized (lock)

只能锁住 JVM A 中的线程,无法锁住 JVM B 中的线程。

因此我们需要一个所有实例都能够访问的、位于进程之外的协调机制

Redis、ZooKeeper、数据库等都可以用来实现这种协调机制。

二、Redis 分布式锁的基本思路

Redis 提供了一个非常适合实现锁的原子操作:

SET key value NX EX 30

其中:

  • NX:只有 key 不存在时才设置成功
  • EX 30:设置 30 秒过期时间
  • value:通常用一个随机唯一值标识当前锁的持有者

例如:

SET lock:order:10001 550e8400-e29b-41d4-a716-446655440000 NX EX 30

如果返回 OK,说明当前客户端抢到了锁。

如果返回空结果,说明锁已经被其他客户端持有。

为什么一定要设置过期时间?

假设服务器拿到锁之后突然宕机:

Server A
   |
   | 获取锁
   v
Redis
   |
   | lock = A
   |
   X  Server A 宕机

如果锁没有过期时间,那么这个锁可能永久存在,后续请求将永远无法获得锁。

所以分布式锁通常必须考虑:

锁持有者异常退出之后,锁如何自动释放?

这就是 Redis 分布式锁中非常重要的 TTL 设计。

三、为什么不能直接 DEL 解锁

假设 A 获取了锁:

lock = A
TTL = 30s

30 秒后,A 的业务执行时间过长,锁自动过期。

此时 B 抢到了锁:

lock = B

但 A 的业务线程终于执行完了,如果它直接:

DEL lock

就会把 B 的锁删除。

于是出现:

A 持有锁

锁过期

B 获取锁

A 执行 DEL

B 的锁被误删

因此,释放锁时必须确认“现在这个锁还是不是我自己的”

通常会把唯一标识写入 value:

lock = UUID-A

释放时使用 Lua 脚本进行原子判断:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

这比先 GET,再 DEL 更安全,因为后者不是一个原子操作。

四、Redisson 是什么

urlRedissonhttps://github.com/redisson/redisson 是一个基于 Redis 的 Java 客户端,同时提供了丰富的分布式对象和并发工具。

其中最常使用的就是:

RLock lock = redissonClient.getLock("order:10001");

然后像使用本地 Lock 一样:

lock.lock();
try {
    // 临界区业务
} finally {
    lock.unlock();
}

这也是 Redisson 最容易让人产生误解的地方:

看起来只是把 ReentrantLock 换成了 RLock,实际上背后已经从 JVM 内存中的锁,变成了 Redis 协调的分布式锁。

五、Redisson 的锁到底存了什么

以普通的 RLock 为例,它并不是简单地在 Redis 中保存一个字符串:

lock -> value

Redisson 会利用 Redis 数据结构记录锁的持有信息,包括线程相关的信息。

因此它能够支持一个非常重要的能力:可重入锁

什么是可重入

例如同一个线程已经获得锁:

lock.lock();

之后业务代码再次调用同一把锁:

lock.lock();

不会发生自己把自己阻塞的问题,而是增加持有次数。

逻辑上类似:

Thread-1

获取 lock

持有次数 = 1

再次获取 lock

持有次数 = 2

对应地,需要释放两次:

lock.unlock(); // 2 -> 1
lock.unlock(); // 1 -> 0,真正释放

这与 Java ReentrantLock 的语义是一致的,也使 Redisson 更容易融入现有 Java 代码。

六、Redisson 最值得理解的机制:Watchdog

如果只设置固定 TTL,会遇到一个问题:

业务执行时间:可能 5 秒
锁 TTL:30 秒

看起来没问题。

但如果某一次因为数据库慢、下游接口慢或者其他原因,业务执行了 45 秒:

0s        30s        45s
|----------|-----------|
获取锁     锁过期       业务结束

30 秒之后锁已经自动释放,其他实例可能获取同一把锁。

这会破坏锁的互斥语义。

Redisson 的 RLock 提供了一个很有代表性的机制:Watchdog(看门狗)

当使用默认的 lock() 获取锁时,Redisson 会为锁设置一个默认租约时间,并在锁仍由当前客户端持有时持续续期。

可以粗略理解成:

获取锁

设置 TTL

Watchdog 定期检查

续期

业务还没结束?继续续期

unlock()

停止续期并释放锁

这解决了一个非常现实的问题:业务执行时间通常不是完全可预测的。

需要注意的是,看门狗并不是让锁“永不过期”。如果客户端彻底失联,无法继续续期,Redis 中的锁仍然会在 TTL 到期后自动释放。

七、lock() 和 tryLock() 应该怎么选

实际开发中,经常会看到:

lock.lock();

或者:

lock.tryLock(10, 30, TimeUnit.SECONDS);

两者的语义并不一样。

lock()

lock.lock();

表示:

我需要这把锁,如果现在拿不到,就等待。

适合业务必须串行执行,而且等待是可以接受的场景。

tryLock()

boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);

可以理解成:

最多等 10 秒,如果拿不到就放弃;拿到以后,租约时间为 30 秒。

具体业务中可以根据结果决定:

if (!locked) {
    return;
}

try {
    // 执行业务
} finally {
    lock.unlock();
}

对于不能无限等待的接口,tryLock 往往更容易控制系统行为。

八、一个完整的 Spring Boot 示例

首先引入 Redisson:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>${redisson.version}</version>
</dependency>

配置 Redis 后,就可以注入 RedissonClient

@Service
public class OrderService {

    private final RedissonClient redissonClient;

    public OrderService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public void createOrder(Long userId) {
        RLock lock = redissonClient.getLock("order:user:" + userId);

        try {
            if (!lock.tryLock(5, TimeUnit.SECONDS)) {
                throw new IllegalStateException("操作过于频繁,请稍后再试");
            }

            // 查询、校验、创建订单等需要互斥的业务

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

这里有一个值得注意的细节:

lock.isHeldByCurrentThread()

释放锁之前进行判断,可以避免当前线程实际上没有持有锁时调用 unlock()

当然,在具体项目中还需要结合业务异常、线程中断和锁粒度进行更细致的处理。

九、分布式锁到底应该锁什么

分布式锁最容易写错的地方之一,其实不是 API,而是锁粒度

例如:

getLock("order");

意味着所有订单创建操作都竞争同一把锁。

假设系统每秒有大量订单请求,那么所有请求都会串行化:

订单 A ─┐
订单 B ─┼─> order lock ─> 一个一个执行
订单 C ─┤
订单 D ─┘

这可能严重降低吞吐量。

如果业务真正需要保护的是“同一个用户不能重复下单”,更合理的锁可能是:

getLock("order:user:" + userId);

此时:

user-1 -> lock:user:1
user-2 -> lock:user:2
user-3 -> lock:user:3

不同用户之间互不影响。

所以一个很重要的原则是:

锁的粒度应该尽可能精确地对应真正需要互斥的资源。

十、分布式锁有哪些典型应用场景

1. 防止重复提交

例如用户连续点击“支付”:

请求 A ─┐
        ├─ 同一个订单锁
请求 B ─┘

保证同一个订单不会同时进入支付状态变更逻辑。

2. 库存扣减

多个实例同时处理同一个 SKU 时,可以使用 SKU 维度的锁进行串行化。

不过需要强调:分布式锁不能替代数据库事务、原子更新或正确的库存模型。

例如库存扣减本身可以使用:

UPDATE product
SET stock = stock - 1
WHERE id = ?
  AND stock > 0;

数据库原子更新可能比额外引入分布式锁更加直接。

3. 定时任务防重复执行

当应用部署多个实例时:

Server A ─┐
Server B ─┼─ 定时任务
Server C ─┘

每个实例都可能触发任务。

可以通过分布式锁保证同一时刻只有一个实例执行任务主体。

4. 防止同一资源并发修改

例如:

修改用户资料
生成同一份报表
刷新某个缓存
处理同一个订单

如果这些操作必须保证同一资源在某个时间窗口内串行执行,分布式锁就有使用价值。

十一、分布式锁并不是万能的

看到这里,很容易产生一个误区:

Redis + Redisson = 分布式系统里的万能并发控制方案。

实际上并不是。

1. 锁不能解决所有一致性问题

如果业务涉及数据库事务、消息队列、支付等多个系统,单纯增加一把 Redis 锁并不能自动保证整个链路的一致性。

2. 锁的性能成本是真实存在的

原本:

Java 线程 -> JVM

使用分布式锁后:

Java 线程

Redisson

Redis 网络通信

Redis

网络、Redis 负载、连接池等都会成为额外成本。

3. 锁粒度过大可能把系统“锁死”

如果把本来可以并行处理的业务全部放进同一把锁,系统虽然更“安全”,但吞吐量也会明显下降。

4. 不要把锁持有时间设置得过于随意

如果使用固定 lease time,需要确保业务执行时间与租约设计相匹配。

而使用 Watchdog 时,也要理解它依赖客户端持续运行和续期,并不是无条件保证锁永远有效。

十二、Redisson 和手写 Redis 分布式锁有什么区别

如果自己实现一个最简单的 Redis 锁,其实代码并不复杂:

SET NX + TTL

唯一 value

Lua 原子解锁

真正复杂的是边界情况:

  • 可重入
  • 锁续期
  • 线程与锁的关联
  • 等待与超时
  • 异常释放
  • Pub/Sub 通知
  • 不同类型的锁
  • 集群环境
  • 网络异常

Redisson 的价值,正是在于把这些能力封装成了 Java 开发者熟悉的并发抽象。

因此,在成熟项目中,如果已经使用 Redis,并且确实存在分布式互斥需求,直接使用经过大量实践验证的客户端实现,通常比自己从 SET NX 开始造一个完整的锁实现更省心。

十三、什么时候不应该使用分布式锁

这一点反而值得重点强调。

如果数据库本身可以通过唯一索引解决:

UNIQUE KEY uk_order_no (order_no)

就没有必要为了防止重复数据额外加 Redis 锁。

如果数据库可以通过原子条件更新解决:

UPDATE ... WHERE stock > 0

也应该认真考虑是否真的需要分布式锁。

如果只是单 JVM 内的并发控制:

ReentrantLock
synchronized

通常更加简单直接。

所以可以形成一个比较实用的判断顺序:

能用数据库约束解决?
        ↓ 是
      数据库
        ↓ 否
能用原子更新/事务解决?
        ↓ 是
      数据库
        ↓ 否
是否需要跨 JVM 互斥?
        ↓ 是
    分布式锁

十四、最后总结

理解 Redisson 分布式锁,可以抓住下面这条主线:

单机并发

synchronized / ReentrantLock

多实例部署

JVM 锁无法跨进程

需要共享的协调机制

Redis 分布式锁

SET NX + TTL + 唯一标识 + 原子解锁

Redisson

可重入 + Watchdog + 等待/超时 + 更完整的锁语义

而在真实项目中,真正值得思考的并不是:

“Redisson 的 API 怎么调用?”

而是:

这个业务到底需不需要锁?锁住的资源是什么?锁粒度多大?业务执行时间多长?即使锁失效,业务是否仍然安全?

把这些问题想清楚之后,Redisson 本身反而只是最后一步的工具选择。


一句话理解:

Redisson 分布式锁,本质上是把 Redis 提供的跨进程协调能力,封装成了 Java 开发者熟悉的 Lock 编程模型;真正困难的不是“加一把锁”,而是确定什么时候应该锁、锁什么,以及锁失效之后业务是否仍然正确。

更早的文章

在树莓派上绕过Linux直接启动本地大模型

欢迎在评论区留下您的见解~