分布式锁场景问题

分布式锁学习笔记

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 或多个服务实例对共享资源的并发访问。

一把可靠的分布式锁通常需要考虑:

  1. 互斥性:同一时刻,只允许一个持有者进入受保护的临界区。
  2. 安全释放:只能释放自己持有的锁。
  3. 故障恢复:持有者崩溃后,锁不能永久占用。
  4. 过期处理:避免业务执行过久时,锁提前失效。
  5. 异常与网络分区:明确系统在故障下能否继续保证互斥。
  6. 性能与可用性:锁服务不能成为明显的性能瓶颈。

不同实现对这些性质的保证程度不同,不能认为只要使用了分布式锁,就一定能保证所有业务场景的数据正确性。


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 复制代码
获取锁
  ↓
设置初始租期
  ↓
业务尚未完成
  ↓
看门狗定期续期
  ↓
业务完成并释放锁

如果持有锁的进程崩溃,续期停止,锁最终过期。

注意:

  1. 显式指定租期的 tryLock(waitTime, leaseTime, unit) 通常不会依靠看门狗无限续期。
  2. 看门狗不能保证进程暂停、网络分区等所有异常下都不会出现双持有者。
  3. 不要把续期机制等同于强一致性锁。

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 最终记忆

记住下面五点:

  1. synchronized 只能协调共享同一把 JVM 锁的线程,不能直接协调多个 JVM。
  2. Redis 获取锁通常使用 SET key token NX PX ttl。
  3. 释放锁必须原子地校验 token 并删除。
  4. 锁过期、进程暂停和网络故障可能导致旧持有者继续执行;看门狗不等于绝对安全。
  5. 库存、支付和订单等核心业务,应优先利用数据库原子操作、唯一约束、幂等与必要的栅栏机制来保障数据正确性。

一句话总结:分布式锁解决的是并发协调,而业务正确性最终仍要由受保护资源自身的约束和校验来兜底。

相关推荐
每天一道题4 小时前
CAP、Raft 与多数派:分布式系统如何达成一致?
分布式·后端·微服务
方方洛7 小时前
ray教程-18-性能调优与反模式
人工智能·分布式·机器学习
每天一道题12 小时前
分布式锁真的安全吗?从 Redis 锁、Lease 到 Fencing Token
redis·分布式·安全
小O的算法实验室15 小时前
IEEE TAES,多无人机通信故障下的分布式任务分配与压制干扰
分布式
Deng桑1 天前
分布式锁与幂等
分布式
wifi___1 天前
Redis分布式锁
数据库·redis·分布式
方方洛2 天前
ray教程-15-RayServe与LLM服务
人工智能·分布式·机器学习
m0_639906942 天前
MySQL 实现分布式锁:悲观锁、乐观锁、唯一索引锁
数据库·分布式·mysql
T量子永生2 天前
Android SISO 分布式编译集群配置方案
android·分布式·云计算·源代码管理