在单体应用时代,我们经常使用 synchronized 或 ReentrantLock 解决并发问题:同一时刻只允许一个线程进入临界区。
但当应用从单机扩展到多个实例之后,一个很自然的问题就出现了:如果两个请求分别落到了两台服务器上,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 是什么
urlRedissonhttps://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编程模型;真正困难的不是“加一把锁”,而是确定什么时候应该锁、锁什么,以及锁失效之后业务是否仍然正确。
欢迎在评论区留下您的见解~