摘要
最近在学习分布式锁的时候,我一开始的想法其实很简单:单机环境下可以用 synchronized 或 ReentrantLock 控制并发,那到了分布式环境里,是不是只要把"锁"放到 Redis 里就行了?
但真正往下看之后,我发现 Redis 分布式锁并没有表面上那么简单。它确实能解决一人一单、库存扣减、定时任务防重复执行这类问题,但如果实现不严谨,也会带来锁误删、锁提前过期、主从切换后锁失效等风险。
这篇文章主要是结合我自己的学习过程,梳理几个问题:Redis 分布式锁到底是怎么实现的?为什么很多人手写的锁并不安全?Redisson 为什么更适合在 Java 项目里使用?它的看门狗机制又到底解决了什么问题?
一、为什么在分布式系统里需要锁?
在单体项目里,如果多个线程同时访问同一份共享资源,我们通常会想到 Java 本地锁。比如加 synchronized,或者使用 ReentrantLock,这样在一个 JVM 内部就能完成并发控制。
但系统一旦部署成集群,问题就变了。
因为这时候一个请求可能落到服务器 A,另一个请求可能落到服务器 B。两台机器上的线程虽然操作的是同一份业务资源,但它们根本不在同一个 JVM 里,所以本地锁只能锁住自己,锁不住别人。
这就意味着,像下面这些场景都可能出问题:
- 同一个用户重复下单
- 多个请求同时扣减同一份库存
- 同一个定时任务被多台机器重复执行
这些问题看起来不同,本质上都在解决同一件事:如何让分布式环境下的多个请求,对同一个共享资源实现互斥访问。
Redis 之所以常被用来实现分布式锁,就是因为它天然是一个共享存储,各个服务实例都能访问到它,所以非常适合作为"锁状态"的载体。
二、Redis 分布式锁的底层思路是什么?
Redis 分布式锁最核心的思想其实不复杂:
谁先在 Redis 中成功创建某个 key,谁就获得这把锁;其他线程只能等待或者获取失败。
通常我们会看到这样一条命令:
bash
SET resource_name my_random_value NX PX 30000
刚开始学的时候,我只觉得这是一条"加锁命令",后来才发现里面每一部分都很重要。
NX 表示只有 key 不存在时才设置成功,也就是"抢锁"。
PX 30000 表示给锁设置过期时间,避免服务异常退出后产生死锁。
my_random_value 不是随便写的值,而是当前持锁者的唯一标识,用来保证解锁时不会误删别人的锁。
所以 Redis 分布式锁真正要满足的,不只是"能抢到锁",还包括下面几个条件:
- 加锁必须原子完成
- 锁必须带过期时间
- 锁值必须能标识持锁者
- 解锁时必须确认删的是不是自己的锁
也正因为这些条件都要同时满足,我才越来越觉得:Redis 分布式锁看上去简单,真正想写稳其实并不容易。
三、为什么很多人手写的 Redis 锁并不安全?
setnx和``expire分开写,会留下隐患
很多人刚开始会这么写:
java
if (setnx(lockKey, "1")) {
expire(lockKey, 30);
}
表面上看好像没问题:先抢锁,抢到了再设置过期时间。
但问题在于,这两步不是原子操作。
如果 setnx 成功后,服务还没来得及执行 expire 就挂了,这把锁就没有过期时间,最后会变成永久锁。
所以更合理的做法,是直接使用一条命令同时完成"加锁 + 设置过期时间",而不是拆成两步。
2. 直接 del 解锁,可能误删别人的锁
这是 Redis 分布式锁里最经典的坑。
假设线程 A 拿到锁之后,业务执行时间太长,锁先过期了;随后线程 B 又拿到了同一个锁。
这时候线程 A 执行完再去 del,删掉的就不是自己的锁,而是线程 B 的锁。
所以 Redis 分布式锁在解锁时,不能直接删除,而是要先校验当前锁的 value 是否还是自己当初写进去的那个唯一标识。通常会用 Lua 脚本把"判断"和"删除"做成原子操作:
java
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
这段脚本的意思就是:只有锁值匹配时,才允许删除锁,避免旧线程把新线程的锁误删掉。
3. 锁过期了,但业务还没执行完
这一点也是我后来才真正意识到的。
很多人觉得,只要锁设置了 TTL 就安全了,但实际上 Redis 锁的互斥性只在锁有效期内成立。
如果你给锁设置了 10 秒过期,但业务跑了 15 秒,那么后面那 5 秒里,其他线程就可能重新拿到这把锁。
这时候系统里就可能出现这样的情况:
- 旧线程还在执行临界区代码
- 新线程已经重新拿到锁并进入临界区
所以 Redis 锁很多时候实现的是"限时互斥",而不是无限期绝对互斥。
4. 主从切换场景下,锁也可能失效
Redis 分布式锁真正让我开始重视"边界"的地方,是主从切换这个问题。
如果客户端 A 在主节点上拿到了锁,但这条写入还没同步到从节点,主节点就挂了;随后从节点升级为新的主节点,这时候客户端 B 又能在新主节点上拿到同一把锁。结果就是,A 和 B 都认为自己持有锁,互斥性被破坏。
这也说明一件事:普通 Redis 主从复制架构下的分布式锁,并不能简单理解成绝对安全。
四、为什么很多 Java 项目更喜欢用 Redisson?
学到这里我最大的感受是:Redis 原生命令确实给了我们实现分布式锁的能力,但如果真的在 Java 项目里从零手写一套可用的锁逻辑,要考虑的边界太多了。
而 Redisson 的价值,就在于它把 Redis 锁封装成了更接近 Java Lock 的使用方式。
它提供的 RLock 本质上是一个分布式可重入锁,使用习惯和 ReentrantLock 很像。也就是说,它不是单纯帮我们往 Redis 里存一个 key,而是把很多原本需要自己处理的细节做了统一封装,比如:
- 可重入
- 自动过期
- 线程持有者判断
- 解锁约束
- 等待获取锁的封装
所以我后来越来越能理解,为什么很多项目里更愿意直接用 Redisson。
它真正帮我们解决的,不只是"少写几行代码",而是把一个很容易写残的分布式锁问题,尽可能标准化成了一套更规范的 Java 用法。
五、Redisson 该怎么使用?
这一部分我想写得细一点,因为它是最容易直接落到项目里的。
1. 先创建 RedissonClient
在 Spring Boot 项目里,一般会先把 RedissonClient 配置成一个 Bean:
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("123456");
return Redisson.create(config);
}
}
如果你的 Redis 是哨兵模式、集群模式或者主从模式,也可以切换到对应配置。这里我主要想说明:后续所有分布式锁操作,都是基于 RedissonClient 来完成的。
2. 获取一把锁
java
RLock lock = redissonClient.getLock("lock:order:" + userId);
这行代码本身很简单,但我觉得真正值得注意的是:锁 key 的设计决定了锁的粒度。
比如:
- 一人一单场景,可以按用户 id 加锁
- 库存扣减场景,可以按商品 id 加锁
- 定时任务场景,可以按任务名加锁
也就是说,锁 key 本质上定义了"谁和谁会互相竞争"。
如果粒度太粗,会把本来不冲突的请求也串行化;如果粒度太细,又可能锁不住真正的共享资源。
3. 最常见的写法:tryLock
java
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean locked = false;
try {
locked = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("当前请求过多,请稍后再试");
}
// 业务逻辑
// 查询订单 -> 判断是否已下单 -> 创建订单
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
我对这段代码的理解是:
- 第一个参数
1:最多等待 1 秒去获取锁 - 第二个参数
10:拿到锁后,10 秒后自动释放 finally里解锁:保证业务异常时也能释放锁isHeldByCurrentThread():防止当前线程没有持有锁却去解锁
这种写法比较适合执行时间短、而且耗时容易预估的业务。
比如"一人一单"这种逻辑,整个临界区通常不会太长,用固定 leaseTime 往往就够了。
4. lock() 和 tryLock() 该怎么选?
这是我学习 Redisson 时觉得特别需要分清楚的一点。
tryLock()
适合用户请求类场景。
因为这类接口通常不适合一直等待,拿不到锁就应该尽快失败,返回"请求频繁,请稍后重试"之类的信息。对用户请求来说,快速失败往往比长时间阻塞更合理。
java
RLock lock = redissonClient.getLock("lock:task:" + taskId);
lock.lock();
try {
// 执行业务
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
这种方式会一直等待,直到获取到锁为止。
如果没有显式传 leaseTime,Redisson 还会启用看门狗机制。
所以我现在更倾向于这样理解:
- 用户请求型接口:优先考虑
tryLock - 必须串行执行的后台任务:更适合
lock
六、Redisson 为什么能比手写 Redis 锁更稳一些?
我觉得核心原因有三点。
1. 它支持可重入
RLock 是可重入锁。
也就是说,同一个线程在已经持有这把锁的情况下,可以再次进入这把锁保护的代码,而不会把自己锁死。
这件事的价值在于,复杂业务里经常会出现方法嵌套调用。
如果外层已经加了锁,内层又尝试获取同一把锁,没有可重入能力就很容易出问题。
2. 它对解锁做了更严格的约束
在 Redisson 里,解锁不是谁都能调。
一般来说,只有真正持有锁的线程,才能安全地执行 unlock()。
所以我们在写代码时,通常也会先判断:
java
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
这一步虽然看起来只是一个细节,但其实是在避免"没持有锁却误解锁"的问题。
3. 它提供了看门狗机制
这一点是我觉得 Redisson 最值得学懂的地方,也是它和手写 Redis 锁差别最大的地方之一。
七、Redisson 的看门狗机制到底解决了什么问题?
如果锁是在没有显式指定**leaseTime** 的情况下获取的,那么 Redisson 会启用看门狗机制。
我一开始看这个机制时,觉得它解决的是一个很现实的矛盾:
- 锁时间设置太短,业务还没执行完,锁就提前过期了
- 锁时间设置太长,服务挂了以后,别人又得等很久
看门狗的思路其实很直接:
- 客户端活着时,自动续期,尽量避免锁提前失效
- 客户端挂了时,停止续期,让锁自然过期,避免永久锁死
比如下面这种写法:
java
RLock lock = redissonClient.getLock("lock:report:" + reportId);
lock.lock();
try {
// 执行一个耗时不可预估的任务
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
因为这里没有显式传 leaseTime,所以就会交给看门狗来管理。
我后来对这个机制的理解是:
它并不是让锁永远有效,而是在"业务没跑完"和"服务已经挂掉"这两种情况之间做平衡。
所以现在我自己的使用习惯是:
- 短任务、耗时可控:
tryLock(waitTime, leaseTime, unit) - 长任务、耗时不可预估:
lock(),交给 watchdog 自动续期
八、一个更完整的业务示例
如果把它放到一人一单场景里,我会更倾向于这样写:
java
@Service
public class OrderService {
@Resource
private RedissonClient redissonClient;
public void createOrder(Long userId, Long voucherId) {
String lockKey = "lock:order:" + userId;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("请求过于频繁,请稍后再试");
}
// 1. 查询该用户是否已经下过单
// 2. 如果下过单,直接返回
// 3. 如果没有下过单,创建订单
// 4. 扣减库存
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
不过这里我也越来越觉得一个点很重要:
不要把分布式锁当成最终正确性的唯一保障。
如果是一人一单场景,我更倾向于这样设计:
- Redisson 分布式锁:拦住大部分并发请求
- 数据库唯一索引:作为最终兜底
因为锁更像第一道防线,数据库约束才是最后一道底线。
九、那 Redis 分布式锁真的安全吗?
如果只是问 Redis 分布式锁能不能在项目里用,我的答案是:能,而且很常用。
但如果问:只要用了 Redis 分布式锁,业务数据就绝对不会出错吗?
我的答案是:不能这么理解。
因为 Redis 锁本质上解决的是"分布式互斥访问"问题,不是万能的一致性方案。
Redisson 虽然通过可重入、自动续期、线程持有者判断等能力,帮我们把很多工程细节封装好了,但它也不等于可以单独兜住所有业务正确性。
所以我现在更认可的一种说法是:
Redis 分布式锁可以作为高性能并发控制手段,但不应该被当成系统正确性的唯一保障。
真正成熟的系统设计,往往还要再叠加这些手段:
- 业务幂等
- 数据库唯一约束
- 补偿机制
- 重试与告警
十、总结
这次学习 Redis 分布式锁,我最大的收获不是"会写一段加锁代码了",而是开始真正理解它的边界。
一开始我以为,分布式锁无非就是在 Redis 里加一个 key。
后来才慢慢意识到,真正可靠的实现至少要考虑这些问题:
- 加锁是不是原子的
- 解锁会不会误删别人的锁
- 业务没跑完时锁会不会提前失效
- 服务挂了以后锁会不会永久不释放
- 集群故障切换时互斥性会不会被破坏
也是在这个过程中,我才更能理解为什么很多 Java 项目会直接选择 Redisson。它不是单纯让我们少写几行代码,而是在可重入、自动续期、线程持有者约束这些方面,把 Redis 锁真正封装成了更接近生产可用的方案。