适用版本: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 分布式锁,至少要满足四个条件:
- 互斥:同一时刻只有一个持有者;
- 可释放:持有者执行完能释放;
- 防死锁:持有者崩溃后,锁会自动过期;
- 防误删:只有锁的持有者才能释放自己的锁。
只满足前两个条件,只能叫“能工作的 demo”,不能叫生产可用方案。
三、正确获取锁:SET NX EX 是基础
Redis 中获取锁的标准做法是用一条原子命令完成“如果不存在就设置,并附带过期时间”:
SET lock:order:1001 requestId-uuid NX EX 30
含义如下:
NX:仅当 key 不存在时设置成功;EX 30:锁 30 秒后自动过期;requestId-uuid:锁的持有者标识,用于释放锁时校验。
为什么一定要把加锁和设置过期时间做成原子操作
如果你写成两步:
SETNX lockKey valueEXPIRE lockKey 30
那么一旦在两条命令之间进程崩溃,锁就没有 TTL,会变成死锁。
所以,加锁和过期时间必须一次完成。
四、为什么 value 不能随便写成 1
很多示例喜欢把锁 value 写成 1、true 或线程 ID,这在单机 demo 看不出问题,但在线上很危险。
典型误删场景
- 线程 A 拿到锁,value=1,TTL=10 秒;
- 线程 A 执行业务太慢,10 秒后锁自动过期;
- 线程 B 拿到同名锁,value=1;
- 线程 A 执行完,调用
DEL lockKey; - 结果把线程 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 主从切换下的可靠性边界
这是很多文章容易一笔带过,但工程上必须说清楚的点。
问题描述
假设:
- 客户端 A 在主节点加锁成功;
- 锁还未来得及同步到从节点;
- 主节点故障;
- 从节点晋升为新主;
- 客户端 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 分布式锁最核心的实践原则,可以概括成下面几句话:
- 加锁要原子:
SET key value NX EX ttl; - value 要唯一:不能随便写成 1;
- 解锁要校验持有者:Lua 原子校验再删除;
- TTL 要结合业务耗时设计:必要时引入自动续期;
- 锁不是万能解法:幂等、唯一约束、状态机同样重要;
- 认清边界:Redis 锁适合大多数工程互斥控制,但不是强一致协调协议。
真正成熟的工程实践,不是“会写 Redis 锁”,而是知道:什么时候应该用它,什么时候不该用它,以及用了之后如何监控、降级和兜底。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!