返回首页

Redis 工程实践6.2:分布式锁实践

适用版本:Redis 7.2.x

分布式锁几乎是 Redis 工程实践里最容易“看起来很简单,实际上很容易出事故”的主题之一。很多业务第一次接触时,都会写出类似这样的代码:

if (redis.setnx("lock:order:1001", "1")) {
    try {
        // 执行业务
    } finally {
        redis.del("lock:order:1001");
    }
}

这段代码能跑,但并不可靠。它至少没有解决下面这些问题:

  • 锁没有过期时间,进程崩溃后会变成死锁;
  • 释放锁时没有校验持有者,可能误删别人的锁;
  • 业务执行时间超过锁 TTL 会怎样?
  • 高并发下抢锁失败如何重试?
  • Redis 主从切换时锁是否还可靠?

这篇文章就围绕这些典型问题,系统梳理 Redis 分布式锁的实践方法、边界和风险。


一、什么时候需要分布式锁

分布式锁的目的,不是“让所有代码都串行执行”,而是在分布式多实例环境中,对某一类共享资源访问进行互斥控制

典型场景包括:

  • 同一订单只能被一个请求完成支付处理;
  • 同一用户同一时刻只能触发一次发券逻辑;
  • 定时任务集群中,只允许一个节点执行某个 job;
  • 库存补偿、对账、幂等修复任务需要避免重复执行;
  • 热点缓存重建,只允许一个线程回源数据库。

如果问题本质上是“重复请求”、“消息重试”、“接口幂等”,有时候唯一索引、状态机、幂等表比分布式锁更稳妥。分布式锁不是万能方案。


二、一个最小可用的 Redis 锁应该满足什么条件

一个工程上可用的 Redis 分布式锁,至少要满足四个条件:

  1. 互斥:同一时刻只有一个持有者;
  2. 可释放:持有者执行完能释放;
  3. 防死锁:持有者崩溃后,锁会自动过期;
  4. 防误删:只有锁的持有者才能释放自己的锁。

只满足前两个条件,只能叫“能工作的 demo”,不能叫生产可用方案。


三、正确获取锁:SET NX EX 是基础

Redis 中获取锁的标准做法是用一条原子命令完成“如果不存在就设置,并附带过期时间”:

SET lock:order:1001 requestId-uuid NX EX 30

含义如下:

  • NX:仅当 key 不存在时设置成功;
  • EX 30:锁 30 秒后自动过期;
  • requestId-uuid:锁的持有者标识,用于释放锁时校验。

为什么一定要把加锁和设置过期时间做成原子操作

如果你写成两步:

  1. SETNX lockKey value
  2. EXPIRE lockKey 30

那么一旦在两条命令之间进程崩溃,锁就没有 TTL,会变成死锁。

所以,加锁和过期时间必须一次完成


四、为什么 value 不能随便写成 1

很多示例喜欢把锁 value 写成 1true 或线程 ID,这在单机 demo 看不出问题,但在线上很危险。

典型误删场景

  1. 线程 A 拿到锁,value=1,TTL=10 秒;
  2. 线程 A 执行业务太慢,10 秒后锁自动过期;
  3. 线程 B 拿到同名锁,value=1;
  4. 线程 A 执行完,调用 DEL lockKey
  5. 结果把线程 B 的锁删掉了。

这就是经典的“锁超时后误删他人锁”。

正确做法

锁 value 应该使用全局唯一标识,通常是:

  • UUID;
  • UUID + 线程 ID;
  • UUID + 服务实例 ID。

例如:

2c1e94d8-3d1f-4fd4-b4cc-a45e2b68c113:thread-27

五、正确释放锁:必须校验 value,再删除

释放锁不能直接 DEL key,而要先判断当前 value 是否仍然是自己,再删除。

但“先 GET 再 DEL”也不安全,因为这两步之间不是原子操作。

正确做法:Lua 脚本原子释放

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

Java 伪代码如下:

String lockKey = "lock:order:" + orderId;
String requestId = UUID.randomUUID() + ":" + Thread.currentThread().getId();
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));

if (Boolean.TRUE.equals(success)) {
    try {
        doBusiness();
    } finally {
        redisTemplate.execute(unlockScript, List.of(lockKey), requestId);
    }
}

这一步是 Redis 分布式锁最关键的基本功。


六、典型问题一:业务执行时间超过锁 TTL

这是线上最常见的问题之一。

场景复现

  • 锁 TTL 设置为 10 秒;
  • 业务正常情况下 3 秒执行完成;
  • 某次数据库抖动,业务执行了 18 秒;
  • 第 10 秒锁过期;
  • 第 11 秒另一个线程抢到锁;
  • 两个线程同时执行临界区逻辑。

解决思路一:TTL 预估保守一些

如果临界区最长可能 20 秒,就不要把 TTL 设成 5 秒。

但这只能缓解,不能根治。因为线上耗时存在抖动,尤其涉及:

  • 远程 RPC;
  • 数据库锁等待;
  • Full GC;
  • 网络抖动。

解决思路二:引入自动续期(看门狗)

很多成熟客户端会在持锁线程还活着时自动续租,例如每隔一段时间把 TTL 延长到安全窗口。这种机制常被称为 watchdog。

例如:

  • 初始加锁 TTL = 30 秒;
  • 每 10 秒检测一次;
  • 若当前线程仍持有锁且业务未结束,则续期到 30 秒。

风险提示

自动续期虽然常用,但不是无风险:

  • 如果业务线程已经卡死,却没有正确停止续期,锁会占用很久;
  • 如果客户端和 Redis 网络隔离,续期可能失败;
  • 如果业务本身出现死循环,锁会长期存在。

所以自动续期要配合:

  • 任务超时控制;
  • 线程中断或熔断机制;
  • 监控长持锁时间。

七、典型问题二:抢锁失败后怎么处理

不是所有抢锁失败都要立刻返回“系统繁忙”。处理方式取决于业务语义。

方案一:直接失败

适用场景:

  • 用户重复点击提交;
  • 同一业务同一时刻只允许一次;
  • 失败后前端可提示“处理中,请稍后重试”。

方案二:自旋重试

例如每隔 50ms 重试一次,最多重试 10 次。

适用场景:

  • 临界区很短;
  • 请求方可接受短暂等待;
  • 并发竞争不是特别激烈。

方案三:排队异步化

对高竞争场景,最好不要大量线程抢同一把锁,而应该:

  • 把请求写入消息队列;
  • 后台串行消费;
  • 用状态机保证顺序执行。

工程建议

  • 抢锁失败路径一定要有埋点;
  • 区分“正常竞争失败”和“异常超时失败”;
  • 不要无限自旋,否则会浪费 CPU 并放大 Redis 压力。

八、典型问题三:锁粒度怎么设计

锁粒度过粗和过细都会出问题。

1. 锁粒度过粗

例如:

lock:order

问题在于:所有订单共用一把锁,会让并发能力急剧下降。

2. 锁粒度过细

例如把多个本应互斥的资源拆成不同锁,导致控制失效。

3. 推荐做法

锁 key 应围绕“共享资源标识”设计:

lock:order:1001
lock:user:coupon:20086
lock:job:daily-settlement
lock:cache-rebuild:product:9999

这样可以把锁冲突范围控制在真正有互斥需求的对象上。


九、典型问题四:Redis 主从切换下的可靠性边界

这是很多文章容易一笔带过,但工程上必须说清楚的点。

问题描述

假设:

  1. 客户端 A 在主节点加锁成功;
  2. 锁还未来得及同步到从节点;
  3. 主节点故障;
  4. 从节点晋升为新主;
  5. 客户端 B 在新主上再次加锁成功。

这时就可能出现两个客户端都认为自己持有锁

这说明什么

Redis 单实例分布式锁,更准确地说是:

  • 在大多数场景下足够好用;
  • 但不是强一致分布式协调系统;
  • 它适合控制并发,不适合承载金融级绝对互斥语义。

工程上的判断标准

如果锁失效会造成:

  • 重复发券、可补偿;
  • 缓存重建重复执行、影响可接受;
  • 定时任务偶发重复跑一次、可容忍;

那么 Redis 锁通常够用。

如果锁失效会造成:

  • 余额重复扣减;
  • 资金类订单重复执行;
  • 核心状态机进入不可逆错误状态;

那么应优先考虑:

  • 数据库约束;
  • 乐观锁 / 悲观锁;
  • ZooKeeper / etcd 这类一致性协调系统;
  • 业务幂等与状态机保护。

十、Redisson 一类客户端为什么常被采用

很多 Java 业务里会直接使用 Redisson 之类成熟客户端,而不是自己手写 SET NX EX + Lua,主要原因在于它帮你封装了这些能力:

  • 原子加锁 / 释放;
  • 可重入锁;
  • 自动续期;
  • tryLock 超时等待;
  • 公平锁、读写锁、联锁等高级模型。

但要注意

封装再成熟,也不意味着可以不理解底层语义。至少要清楚:

  • 自动续期是怎么工作的;
  • 锁是否可重入;
  • 解锁异常怎么处理;
  • 超时等待期间线程是否可中断;
  • Redis 故障时客户端行为是什么。

否则线上出了问题,只会停留在“框架不是都帮我做好了吗”的误区里。


十一、一个更完整的实践模板

下面给出一个偏工程化的分布式锁使用模板。

1. 获取锁

  • key 体现资源粒度;
  • value 使用全局唯一 requestId;
  • TTL 依据业务最大耗时保守设置;
  • tryLock 支持等待超时。

2. 执行临界区

  • 临界区尽量短;
  • 不要在锁内做无关耗时操作;
  • 远程调用要设置超时;
  • 核心业务仍要具备幂等能力。

3. 释放锁

  • finally 中释放;
  • 用 Lua 校验 value 后删除;
  • 解锁失败要打日志和监控。

4. 监控与告警

至少监控以下指标:

  • 加锁成功率;
  • 平均等待时长;
  • 持锁时长分布;
  • 自动续期次数;
  • 解锁失败次数;
  • 同一资源抢锁热点分布。

十二、示例:缓存重建锁

缓存重建是非常典型、也非常适合 Redis 锁的场景。

场景

  • product:detail:1001 是热点 key;
  • 缓存过期后,大量请求同时到来;
  • 希望只有一个线程回源 DB,其他线程快速返回旧值或短暂等待。

实现思路

String cacheKey = "product:detail:" + productId;
String lockKey = "lock:cache-rebuild:product:" + productId;
String requestId = uuid();

String val = redis.get(cacheKey);
if (val != null) {
    return val;
}

Boolean locked = redis.setIfAbsent(lockKey, requestId, Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
    try {
        String again = redis.get(cacheKey);
        if (again != null) {
            return again;
        }
        Product p = dao.selectById(productId);
        redis.setex(cacheKey, 1800, toJson(p));
        return toJson(p);
    } finally {
        redis.execute(unlockScript, List.of(lockKey), requestId);
    }
}

Thread.sleep(50);
return redis.get(cacheKey);

风险与优化

  • 锁等待不能太长,否则请求线程会堆积;
  • 失败重试次数要有限制;
  • 最好结合逻辑过期,避免大量请求阻塞等待。

十三、哪些场景不建议优先使用 Redis 分布式锁

1. 可以靠唯一约束解决的问题

比如“同一用户同一张券只能领一次”,通常数据库唯一索引更直接可靠。

2. 可以靠状态机幂等解决的问题

例如订单状态从 INIT -> PAYING -> PAID,状态流转本身就能防止重复处理。

3. 需要强一致主从协调的问题

比如多个节点争抢主控权、需要严格选主、需要会话语义与 fencing token 的场景,ZooKeeper / etcd 更合适。


十四、实践中的常见误区

误区一:加了 Redis 锁就万无一失

错。锁只能降低并发冲突概率,不能替代业务幂等和数据约束。

误区二:TTL 随便给个 5 秒就行

错。TTL 应依据临界区实际耗时、抖动情况和故障边界设计。

误区三:解锁直接 DEL 没问题

错。必须校验 value,否则会误删他人锁。

误区四:所有重复执行问题都该用锁

错。很多时候幂等表、唯一索引、消息去重更简单稳妥。

误区五:锁内逻辑越多越安全

错。锁持有时间越长,竞争越激烈,风险越高。临界区应该尽可能小。


十五、小结

Redis 分布式锁最核心的实践原则,可以概括成下面几句话:

  1. 加锁要原子SET key value NX EX ttl
  2. value 要唯一:不能随便写成 1;
  3. 解锁要校验持有者:Lua 原子校验再删除;
  4. TTL 要结合业务耗时设计:必要时引入自动续期;
  5. 锁不是万能解法:幂等、唯一约束、状态机同样重要;
  6. 认清边界:Redis 锁适合大多数工程互斥控制,但不是强一致协调协议。

真正成熟的工程实践,不是“会写 Redis 锁”,而是知道:什么时候应该用它,什么时候不该用它,以及用了之后如何监控、降级和兜底。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Redis工程实践6.1: 缓存设计模式详解

下一篇

Redis 工程实践6.3:缓存一致性与雪崩、击穿、穿透