分布式锁学习笔记
1. 场景问题
假设电商系统有一个限量商品,库存只剩 1 件。
用户 A 和用户 B 几乎同时下单,后端代码如下:
java
int stock = stockMapper.getStock(productId);
if (stock > 0) {
stockMapper.decreaseStock(productId);
orderService.createOrder(userId, productId);
}
两个请求可能同时读取到库存为 1:
text
用户 A 用户 B
| |
|---- 查询库存 = 1 -------->|
| |
|<--- 查询库存 = 1 ---------|
| |
|---- 扣减库存 ------------>|
| |
|<--- 扣减库存 -------------|
最终可能出现重复扣减,甚至库存变成负数。
这里的问题是:多个请求同时访问共享资源,业务判断与修改之间缺少有效的并发控制。
分布式锁就是解决这类并发协调问题的一种手段。
但要注意:库存扣减通常更适合优先使用数据库原子更新或乐观锁。分布式锁不是解决所有并发问题的首选方案。
2. 什么是分布式锁?
2.1 为什么 synchronized 不够?
在单个 JVM 内,可以使用:
java
synchronized (this) {
// 修改共享资源
}
它只能协调共享同一把监视器锁的线程。
当服务部署成多个实例时:
text
负载均衡器
/ \
/ \
Order-Service-1 Order-Service-2
JVM A JVM B
两个 JVM 中的 synchronized 是相互独立的。
JVM A 的锁无法阻止 JVM B 进入自己的临界区。
因此,需要一种所有服务实例都能共同访问的协调机制,例如 Redis 或数据库。
2.2 分布式锁的核心作用
分布式锁用于协调多个进程、多个 JVM 或多个服务实例对共享资源的并发访问。
一把可靠的分布式锁通常需要考虑:
- 互斥性:同一时刻,只允许一个持有者进入受保护的临界区。
- 安全释放:只能释放自己持有的锁。
- 故障恢复:持有者崩溃后,锁不能永久占用。
- 过期处理:避免业务执行过久时,锁提前失效。
- 异常与网络分区:明确系统在故障下能否继续保证互斥。
- 性能与可用性:锁服务不能成为明显的性能瓶颈。
不同实现对这些性质的保证程度不同,不能认为只要使用了分布式锁,就一定能保证所有业务场景的数据正确性。
3. Redis 分布式锁的基础实现
3.1 为什么不能直接使用 SETNX?
Redis 提供 SETNX,意思是只有键不存在时才设置成功。
例如:
text
SETNX lock:order 1
如果键不存在,设置成功;如果已经存在,则失败。
但如果只使用 SETNX,可能遇到以下问题:
- 获取锁后,服务崩溃,锁一直不释放。
- 锁过期后,旧请求还在执行。
- 一个请求释放了另一个请求的锁。
因此,生产环境不能只依赖简单的 SETNX 实现锁。
3.2 正确的基础模式:SET + NX + PX
获取锁时,使用一条原子命令:
redis
SET lock:order:123 unique-token NX PX 30000
参数含义:
lock:order:123:锁的键。unique-token:本次持有者的唯一标识。NX:仅当键不存在时设置成功。PX 30000:锁在 30,000 毫秒后过期。
执行结果:
- 返回
OK:获取锁成功。 - 返回空结果:锁已存在,本次获取失败。
实际代码中,unique-token 应使用每次加锁都足够唯一的值,例如 UUID,不能所有请求都使用固定字符串 1。
3.3 为什么必须设置过期时间?
假设:
text
请求 A 获取锁
↓
请求 A 所在服务突然崩溃
↓
没有机会主动释放锁
↓
锁一直存在
↓
其他请求无法继续
如果锁有过期时间,即使持有者崩溃,锁最终也可以自动过期。
不过,过期时间并不是越短越好,也不是越长越好:
- 太短:业务尚未完成,锁已经过期。
- 太长:持有者异常退出后,其他请求等待时间过长。
因此,过期时间需要根据业务耗时、长尾延迟及续期机制来设计。
4. 为什么释放锁必须校验持有者?
4.1 错误的释放方式
下面的做法有风险:
redis
DEL lock:order:123
假设发生以下过程:
text
1. A 获取锁,设置过期时间 30 秒
2. A 执行业务超过 30 秒
3. A 的锁过期
4. B 成功获取同一把锁
5. A 终于执行完成,直接 DEL 锁
此时 A 删除的其实是 B 的锁。
这会导致其他请求在 B 仍然执行时获取锁,破坏互斥性。
4.2 使用唯一标识校验后再删除
释放锁时,必须先确认锁仍然属于自己,再执行删除操作。
Redis Lua 脚本可以保证校验和删除在 Redis 内部原子执行:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
调用时:
KEYS[1]:锁的键。ARGV[1]:当前请求获取锁时保存的唯一标识。
核心原则:校验持有者与删除锁必须是一个原子操作。
不能在 Java 代码中先 GET 校验,再单独执行 DEL,因为两条命令之间仍然可能发生并发变化。
5. 锁过期了,但业务还没执行完怎么办?
这是分布式锁设计中最重要的问题之一。
5.1 锁过期导致两个持有者同时执行
假设锁的有效期是 10 秒,但业务需要 15 秒:
text
时间线:
0 秒:A 获取锁
0-10 秒:A 执行业务
10 秒:A 的锁过期
11 秒:B 获取锁
11-15 秒:A 和 B 同时执行
15 秒:A 执行完成
即使释放锁时做了唯一标识校验,也只能防止 A 删除 B 的锁,不能阻止 A 在锁过期后继续执行。
因此:
唯一标识解决的是安全释放问题,不解决锁过期后的并发执行问题。
5.2 方案一:合理设置过期时间
适合执行时间相对稳定、最长耗时可预估的业务。
但如果业务耗时波动很大,仅仅把过期时间调长并不能彻底解决问题。
5.3 方案二:锁续期机制
如果业务执行时间不确定,可以在持锁期间定期延长锁的有效期。
续期时也必须校验锁的唯一标识,避免延长其他持有者的锁。
Redisson 的看门狗机制就是常见方案之一。
5.4 方案三:Fencing Token(栅栏令牌)
如果业务要求即使发生暂停、网络故障或锁过期,也不能让旧持有者覆盖新持有者的操作,就需要考虑栅栏令牌。
例如:
text
A 获取锁,令牌 = 101
A 暂停,锁过期
B 获取锁,令牌 = 102
B 执行业务并写入数据
A 恢复执行,尝试写入令牌 = 101 的数据
受保护的资源必须拒绝过期的旧令牌,例如拒绝小于当前已接受令牌的写入。
重点:栅栏令牌必须由能够保证令牌单调递增的机制生成,并由真正的资源端校验。 仅仅给请求增加一个数字,而资源端不校验,并不能解决问题。
6. Redisson 分布式锁
在 Java Spring Boot 项目中,通常可以使用 Redisson,而不是自己从头实现完整的锁逻辑。
6.1 基本用法
java
RLock lock = redissonClient.getLock("lock:order:123");
boolean locked = false;
try {
// 等待最多 3 秒获取锁,获取后租期为 10 秒
locked = lock.tryLock(
3,
10,
TimeUnit.SECONDS
);
if (!locked) {
throw new IllegalStateException("获取锁失败");
}
// 执行需要互斥保护的业务
orderService.processOrder(123L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁时线程被中断", e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
这个示例使用了显式租期:获取锁成功后,租期为 10 秒。
需要注意:
- 业务耗时不能超过租期,否则仍可能出现锁提前过期。
isHeldByCurrentThread()是释放前的保护性检查,但它不能替代正确的锁实现与租期设计。- 如果业务在锁过期后继续执行,仍需要其他机制保护数据正确性。
6.2 Redisson 看门狗机制
另一种常见写法:
java
lock.lock();
try {
orderService.processOrder(123L);
} finally {
lock.unlock();
}
在常见 Redisson 配置中,如果使用不指定租期的 lock() 或相应的无显式租期获取方式,Redisson 可以通过看门狗机制续期。
基本思路:
text
获取锁
↓
设置初始租期
↓
业务尚未完成
↓
看门狗定期续期
↓
业务完成并释放锁
如果持有锁的进程崩溃,续期停止,锁最终过期。
注意:
- 显式指定租期的
tryLock(waitTime, leaseTime, unit)通常不会依靠看门狗无限续期。 - 看门狗不能保证进程暂停、网络分区等所有异常下都不会出现双持有者。
- 不要把续期机制等同于强一致性锁。
7. Redis 分布式锁与数据库锁如何选择?
7.1 Redis 锁
优点:
- 获取和释放通常很快。
- 适合跨服务实例协调。
- 可以使用 TTL 自动过期。
- Redisson 提供了较成熟的 Java API。
缺点:
- 需要考虑锁过期、续期和 Redis 故障。
- Redis 主从切换等故障场景可能影响锁的互斥保证。
- 如果业务资源不校验旧请求,锁过期后旧持有者仍可能继续修改数据。
适用场景:
- 定时任务防止多个实例重复执行。
- 对共享业务流程进行短时间协调。
- 缓存重建、某些任务去重等。
7.2 数据库锁
数据库可以通过事务、行锁、唯一约束或条件更新完成并发控制。
例如库存扣减:
sql
UPDATE product
SET stock = stock - 1
WHERE id = ?
AND stock > 0;
然后检查影响行数:
- 影响 1 行:扣减成功。
- 影响 0 行:库存不足或记录不存在,需要进一步判断具体原因。
这条 SQL 把库存判断和扣减放在一个原子更新中,通常比"先查库存、再加分布式锁、再更新库存"更直接。
适用场景:
- 数据库中的库存扣减。
- 唯一业务记录约束。
- 必须依赖数据库事务维护一致性的业务。
注意:数据库锁也需要考虑事务隔离级别、锁范围、死锁和事务耗时等问题。
7.3 如何选择?
| 场景 | 优先考虑 |
|---|---|
| 防止同一个定时任务被多个实例同时执行 | Redis 锁或数据库任务认领 |
| 库存不能扣成负数 | 数据库原子条件更新 |
| 同一业务编号只能创建一条记录 | 数据库唯一约束 |
| 对多个服务共享的业务流程进行协调 | 根据一致性要求选择分布式锁或其他协调方案 |
| 长时间执行、允许任务恢复和接管 | 任务状态机、租约、幂等及必要的栅栏机制 |
| 对外部系统执行不可重复操作 | 幂等键、业务状态约束及补偿机制 |
不要为了使用分布式锁而使用分布式锁。优先使用更直接、约束更强的原子操作。
8. 分布式锁常见坑点
8.1 锁粒度太大
错误示例:
text
lock:order
所有订单都争抢同一把锁。
更合理的做法可能是:
text
lock:order:1001
lock:order:1002
不同订单使用不同的锁,减少不必要的竞争。
但锁粒度也不能随意缩小:如果多个订单会竞争同一个库存资源,锁的粒度必须与真正需要协调的共享资源相匹配。
8.2 没有设置获取锁的超时时间
如果请求无限期等待锁,可能导致:
- 请求线程大量堆积。
- 接口响应时间变长。
- 线程池耗尽。
应根据业务设置合理的等待时间,并定义获取失败后的行为,例如返回可重试错误、进入任务队列或执行降级处理。
8.3 锁内执行耗时操作
例如:
text
获取锁
↓
调用第三方接口
↓
等待 20 秒
↓
更新数据库
↓
释放锁
如果锁内包含慢速 RPC、长事务或大批量处理,会延长锁持有时间,降低吞吐量。
应尽量缩小临界区,但不能为了缩短持锁时间而把必须保持原子性的业务步骤拆开。
8.4 锁与业务操作不是同一个原子系统
例如:
text
获取 Redis 锁
↓
更新 MySQL
↓
服务崩溃
↓
锁过期
Redis 锁与 MySQL 更新并不是一个共同事务。
因此,不能仅凭 Redis 锁就保证数据库更新与锁状态永远一致。关键数据还应依赖数据库约束、原子更新、幂等处理或业务状态控制。
8.5 误以为锁能代替幂等
锁主要解决并发协调问题,幂等解决重复请求问题。
例如用户点击支付按钮两次,即使两次请求没有同时执行,也可能先后执行两次。
因此,支付、订单创建等业务通常还需要业务唯一键、状态校验或幂等记录。
9. 线上排查清单
当系统出现重复执行、数据错乱或接口大量等待时,可以按以下步骤检查。
第一步:确认锁是否真的互斥
- 是否所有实例都使用同一套锁服务?
- 锁键是否一致?
- 锁的作用范围是否正确?
- 是否存在使用固定 value 导致误删锁的情况?
第二步:检查锁的生命周期
- 获取锁是否成功?
- 是否设置过期时间?
- 业务耗时是否超过租期?
- 续期是否正常?
- 释放锁是否校验持有者?
第三步:检查业务资源本身
- 数据库是否有唯一约束?
- 更新是否使用条件 SQL?
- 是否有幂等键?
- 旧请求在锁过期后是否还能写入?
- 是否需要栅栏令牌?
第四步:检查性能和故障
- 获取锁的平均耗时、P95、P99。
- 获取锁失败率。
- 锁持有时间分布。
- Redis 延迟、超时和连接池情况。
- 锁等待是否导致线程池堆积。
- Redis 主从切换或网络异常时的行为。
第五步:明确故障时的业务策略
获取锁失败时,不能一律直接重试。
需要结合业务判断:
- 立即返回失败。
- 短时间重试。
- 进入队列等待。
- 由任务调度器稍后接管。
重试也要设置上限、退避策略,并保证业务幂等。
10. 深入学习
10.1 分布式锁不等于强一致性
Redis 锁的安全性与 Redis 部署架构、故障模式和客户端行为有关。
例如,在某些主从异步复制及故障切换场景中,旧主节点上的锁可能尚未复制到新主节点,切换后另一个客户端就可能获取同名锁。
因此,对于高价值资源,不能仅依赖普通 Redis 锁来保证绝对互斥。
需要根据业务风险考虑:
- 数据库约束和原子更新。
- 可验证的租约与栅栏令牌。
- 支持所需一致性保证的协调系统。
- 业务幂等与状态机。
10.2 什么业务需要考虑栅栏令牌?
如果业务允许锁偶尔失效后由其他持有者接管,但旧持有者仍可能继续执行写操作,就需要重点考虑栅栏机制。
例如:
text
A 获得令牌 101
A 因长时间暂停失去锁
B 获得令牌 102
B 成功写入资源
A 恢复运行
A 尝试写入旧数据
资源端记录已接受的最新令牌,并拒绝过期令牌的写入,才能真正防止旧持有者覆盖新持有者的数据。
10.3 最终记忆
记住下面五点:
synchronized只能协调共享同一把 JVM 锁的线程,不能直接协调多个 JVM。- Redis 获取锁通常使用
SET key token NX PX ttl。 - 释放锁必须原子地校验 token 并删除。
- 锁过期、进程暂停和网络故障可能导致旧持有者继续执行;看门狗不等于绝对安全。
- 库存、支付和订单等核心业务,应优先利用数据库原子操作、唯一约束、幂等与必要的栅栏机制来保障数据正确性。
一句话总结:分布式锁解决的是并发协调,而业务正确性最终仍要由受保护资源自身的约束和校验来兜底。