Redis 分布式锁的边界条件:Redlock 争议、锁续期与客户端崩溃后的互斥失效
1. 从一个真实的"锁没生效"问题说起
假设现在有一个库存扣减接口:两台应用服务器 A 和 B 同时收到同一个商品的下单请求,库存只剩 1 件。你在代码里写了 synchronized,本地测试完全正常,可上线后偶尔出现超卖。原因很直接:synchronized 只能管住同一个 JVM 里的线程,A 机器和 B 机器之间的线程它管不到。
于是你想到用 Redis 加一把"全局的锁":谁先在 Redis 里写入一个 key,谁就获得执行权,执行完再删掉 key。听起来只需要 SETNX 一个命令,但真正上线后又会出现新的问题:进程拿到锁之后宕机了,key 永远不会被删除,其他请求全部被卡死;或者进程执行时间比预期长,锁提前过期,第二个请求进来,两个进程同时操作同一份数据。
这篇文章要解决的就是这些边界条件。先记住一个最小模型:Redis 分布式锁本质上是一次"带过期时间的占位写入",谁写入成功谁就持有锁,释放锁就是把占位删掉。 它解决的是跨进程互斥,但它不保证持有者在执行期间一定不会被打断,也不保证 Redis 故障切换时锁状态不丢失。
本文面对的是已经会用 SET NX PX、但被"锁续期、误删、Redlock、主从切换"困扰的 Java 后端开发者与架构师。读完之后,你应该能判断:当前业务到底该用哪种锁,以及哪些情况下 Redis 锁根本不适合单独承担正确性。
2. 核心模型:三个角色与一次加锁的完整流转
先不要急着背命令。把整件事拆成三个角色:锁的使用者 (Java 进程里的业务线程)、锁的存储者 (Redis 实例或集群)、锁的竞争对象(同一份共享资源,例如订单、库存、定时任务)。三者之间通过"占位写入 + 过期时间 + 唯一标识"连接起来。
一次标准的加锁流程是这样的:业务线程生成一个全局唯一的 value(通常是 UUID + 线程 ID),然后向 Redis 发送一条带 NX 和 PX 的命令;Redis 只有在 key 不存在时才写入成功,并同时设置毫秒级过期时间;业务线程拿到成功结果后执行临界区代码;执行完成后,用一段 Lua 脚本判断 value 是否属于自己的,是则删除 key,不是则不删。
text
+-------------------+ +---------------------+ +------------------+
| 业务线程 A | | 业务线程 B | | Redis 实例 |
| (Java 进程 1) | | (Java 进程 2) | | (锁存储者) |
+---------+---------+ +----------+----------+ +---------+--------+
| | |
| 1. SET lock uuidA NX PX 30000 |
|---------------------------------------------------------->|
| | 2. 写入成功,返回 OK |
|<----------------------------------------------------------|
| | |
| 3. 执行临界区代码 | 4. 同样尝试 SET lock uuidB |
| |--------------------------->|
| | 5. key 已存在,失败 |
| |<---------------------------|
| | |
| 6. 执行完,Lua 判断 value 并删除 key |
|---------------------------------------------------------->|
| | 7. 删除成功 |
|<----------------------------------------------------------|
| | |
| | 8. 再次尝试,成功获得锁 |
| |--------------------------->|
这张图里有三个关键点,后面每一节都会对应展开:第一,加锁必须是一次原子操作 ,SET 加上 NX、PX 参数一条命令完成,而不是先 SETNX 再 EXPIRE,否则中间宕机就会留下永不过期的死锁;第二,value 必须是唯一标识 ,否则无法区分"我的锁"和"别人的锁",删除时就会误删;第三,删除必须是"判断 + 删除"的原子操作,中间一旦被插入其他请求,就会把别人的锁删掉。
这里最容易误解的是:很多人以为"key 存在就代表锁还在,业务是安全的"。其实 key 只能证明"某个时刻有人占过位",它不能证明"当前持有者一定还在执行、且没有被暂停"。理解这一点,后面的 Redlock 争议和客户端崩溃问题才有落脚点。
3. 最基础的加锁与解锁:为什么必须是 SET NX PX + Lua
3.1 错误写法与正确写法
先看一个常见的错误写法,它把加锁拆成了两个命令:
java
// 源码节选:错误示例,不能直接运行
public boolean wrongLock(Jedis jedis, String key, String value) {
Long result = jedis.setnx(key, value); // 第一步:占位
if (result == 1) {
jedis.expire(key, 30); // 第二步:设置过期
return true;
}
return false;
}
问题在于:如果在 setnx 成功之后、expire 执行之前,这台应用进程崩溃,那么这把锁就永远不会过期,其他所有请求都会被永久阻塞。这类问题在线上表现为"某个定时任务的锁一直不被释放,重启应用也没用,只能手动删 key"。
正确写法是把占位和过期设置合并为一条原子命令:
java
// 源码节选:正确的单命令加锁
try (Jedis jedis = pool.getResource()) {
String result = jedis.set(lockKey, uniqueValue, "NX", "PX", 30000);
return "OK".equals(result);
}
NX 表示 key 不存在时才写入,PX 30000 表示 30 秒后自动过期。这条命令在 Redis 内部是一个整体,不会出现"写入了但没设置过期"的中间状态。
3.2 释放锁为什么必须用 Lua
释放锁更危险。假设业务线程 A 拿到锁,value 是 uuidA。执行期间因为 GC 停顿了 35 秒,锁在 30 秒时已过期,线程 B 拿到了同一把锁,value 是 uuidB。此时 A 恢复执行,如果直接 DEL lockKey,删掉的是 B 的锁,B 还以为自己独享临界区,互斥直接被打破。
所以释放锁必须"先比较 value、再删除",并且这两个动作要原子完成。Redis 执行 Lua 脚本时是单线程串行执行的,脚本内部不会插入其他客户端命令,这正是我们需要的原子性:
lua
-- 解锁 Lua 脚本:只有 value 匹配才删除
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
java
// 源码节选:Java 侧调用 Lua 解锁
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
public boolean unlock(Jedis jedis, String key, String value) {
Object result = jedis.eval(UNLOCK_SCRIPT,
Collections.singletonList(key),
Collections.singletonList(value));
return Long.valueOf(1L).equals(result);
}
这里最容易出错的地方是把 Lua 参数顺序写反:KEYS[1] 必须是锁 key,ARGV[1] 必须是当前线程的唯一 value。如果传反,脚本永远返回 0,锁不会被释放;如果漏传 value 而用 DEL 直接删,就退回到误删别人锁的老问题。
下表把几种常见写法的风险摆在一起:
| 写法 | 加锁原子性 | 释放安全性 | 主要风险 |
|---|---|---|---|
| SETNX + EXPIRE 两步 | 否 | 无判断直接删 | 中间宕机导致死锁;误删他人锁 |
| SET NX PX 单命令 | 是 | 无判断直接删 | 锁过期后误删他人锁 |
| SET NX PX + Lua 比较删除 | 是 | 安全 | 仍无法解决业务超时与主从切换 |
| Redlock 多节点加锁 | 是(多数派) | 安全 | 争议大,仍受时钟与暂停影响 |
4. 完整示例一:可运行的 Redis 单机分布式锁
目标:用最小依赖实现一把可复现的 Redis 单机锁,验证"互斥""过期""释放"三件事。
前置环境 :本地或容器启动 Redis 6.x 以上,端口 6379;JDK 8+;Maven 项目引入 redis.clients:jedis:4.4.3。
输入:一个模拟的库存变量,初始值 100,启动 20 个线程各扣减 5 次。
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SimpleRedisLockDemo {
private static final String LOCK_KEY = "lock:stock:sku1001";
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
private static JedisPool pool;
private static int stock = 100; // 仅用于演示,真实库存应在数据库或 Redis 中
public static void main(String[] args) throws InterruptedException {
pool = new JedisPool("127.0.0.1", 6379);
CountDownLatch start = new CountDownLatch(1);
ExecutorService es = Executors.newFixedThreadPool(20);
for (int i = 0; i < 20; i++) {
es.submit(() -> {
try {
start.await();
deductWithLock();
} catch (Exception e) {
Thread.currentThread().interrupt();
}
});
}
start.countDown();
Thread.sleep(8000);
es.shutdownNow();
pool.close();
System.out.println("final stock = " + stock);
}
private static void deductWithLock() throws InterruptedException {
String value = UUID.randomUUID().toString();
Jedis jedis = pool.getResource();
try {
SetParams params = SetParams.setParams().nx().px(5000);
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
if ("OK".equals(jedis.set(LOCK_KEY, value, params))) {
try {
deductOnce();
} finally {
jedis.eval(UNLOCK_SCRIPT,
Collections.singletonList(LOCK_KEY),
Collections.singletonList(value));
}
return;
}
Thread.sleep(20);
}
System.out.println("获取锁超时,放弃本次扣减");
} finally {
jedis.close();
}
}
private static void deductOnce() throws InterruptedException {
// 模拟数据库或远程调用耗时,暴露锁过期窗口
int current = stock;
Thread.sleep(5);
stock = current - 1;
System.out.println(Thread.currentThread().getName() + " -> stock=" + stock);
}
}
关键步骤 :每个线程生成独立 value;用 SET NX PX 抢锁,最长等待 3 秒;抢到后扣减一次库存,再在 finally 中用 Lua 释放锁;jedis.close() 把连接还给连接池而不是关闭连接池本身。
预期结果 :最终 final stock 应为 0,说明 100 次扣减都成功执行且没有并发覆盖;如果没加锁,stock 通常会大于 0,因为多个线程读到同一个旧值。
适用场景 :单实例 Redis 且业务执行时间远小于锁过期时间的内部任务。容易改错的地方 :把 SetParams 写成 nx() 但忘了 px(),会退化成永不过期的死锁;把 value 提到方法外共享,会导致不同线程用同一个值,Lua 判断失去意义。
5. 锁续期与 watchdog:业务比锁活得久怎么办
5.1 固定过期时间的两难
锁过期时间设多长都别扭。设 30 秒,业务执行 40 秒时锁提前释放,第二个进程进来了,互斥失效;设 5 分钟,进程崩溃后要等 5 分钟才能恢复,这期间所有请求都在等待,故障恢复时间被拉长。
现实中业务执行时间并不稳定:一次数据库慢查询、一次 Full GC、一次网络抖动,都可能让本来 200 毫秒的任务变成 10 秒。固定过期时间相当于赌"业务一定能在过期前跑完",而分布式系统里这种赌注并不安全。
5.2 watchdog 的思路
解决方向是"锁续期"(renewal),也就是给锁加一个自动续命的守护线程,常被称为 watchdog。它的工作方式很朴素:在锁即将过期时,由后台线程检查持有者是否还活着,如果活着就把过期时间重新延长一段时间,如此循环,直到业务主动释放锁。
Redisson 的看门狗机制就是典型实现:加锁时不指定过期时间,默认使用 lockWatchdogTimeout(默认 30 秒);后台任务每隔约 10 秒(过期时间的 1/3)续期一次;只有"没有显式指定 leaseTime"时才会启用看门狗。
text
时间轴(毫秒)
0 10000 20000 30000 40000
|---------|---------|---------|---------|
业务开始 | | | 业务结束
抢到锁 | | | 主动释放
| | |
续期一次 续期一次 续期一次
(重置为30s) (重置为30s) (重置为30s)
如果进程在第 15 秒崩溃:
0 10000 20000 30000
|---------|---------|---------|
抢到锁 续期一次 X 进程崩溃
|
剩余 TTL 约 20 秒后自动过期
其他请求最多等待 20 秒
这个类比能帮助你理解"续期是在和过期赛跑",但它不能替代真实机制:续期不是无条件无限延长,如果进程崩溃或网络断开,续期循环本身也会停止,锁最终仍会过期。看门狗解决的是"业务比预期慢",不是"进程已经死了"。
5.3 用 Java 手写一个简化版 watchdog
下面这段是简化代码,用于说明续期逻辑,不推荐直接用于生产:
java
// 简化代码:演示续期思路,不能直接替代成熟锁实现
public class SimpleWatchdog {
private static final long LEASE_MS = 30_000;
private static final long RENEW_INTERVAL_MS = 10_000;
private volatile boolean running = true;
public void startRenewal(Jedis jedis, String lockKey, String value) {
Thread t = new Thread(() -> {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
while (running) {
try {
Thread.sleep(RENEW_INTERVAL_MS);
Object r = jedis.eval(script,
Collections.singletonList(lockKey),
Arrays.asList(value, String.valueOf(LEASE_MS)));
if (Long.valueOf(0L).equals(r)) {
// 锁已不属于自己,停止续期
running = false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
running = false;
}
}
}, "lock-watchdog");
t.setDaemon(true);
t.start();
}
public void stop() {
running = false;
}
}
关键点:续期前必须再次校验 value,确保锁还属于自己;续期脚本同样要用 Lua 原子执行;业务释放锁时要先停掉续期线程,否则释放之后看门狗又把 key 写回来,会制造"幽灵锁"。这也是生产中使用 Redisson 比自己手写更稳的原因:释放、续期、重入、异常路径都已经封装好。
6. 完整示例二:Spring Boot + Redisson 的可重入锁与看门狗
目标:在 Spring Boot 中集成 Redisson,验证可重入、自动续期与超时放弃。
前置环境 :Spring Boot 2.7+,JDK 8+,Redis 6.x(单机即可),依赖 org.redisson:redisson-spring-boot-starter:3.23.5。
输入:一个订单处理接口,模拟 3 秒业务逻辑;把看门狗超时调到 5 秒,观察续期是否生效。
yaml
# application.yml
spring:
redis:
host: 127.0.0.1
port: 6379
# 自定义 Redisson 配置
redisson:
lock-watchdog-timeout: 5000
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@RestController
public class OrderController {
@Autowired
private RedissonClient redissonClient;
@PostMapping("/order/process")
public String process(@RequestParam String orderId) throws InterruptedException {
String lockKey = "lock:order:" + orderId;
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
// 最多等 2 秒;leaseTime 不传,启用看门狗自动续期
acquired = lock.tryLock(2, TimeUnit.SECONDS);
if (!acquired) {
return "处理中,请稍后重试";
}
Thread.sleep(3000); // 模拟业务处理,超过默认看门狗时间
return "订单 " + orderId + " 处理完成";
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
关键步骤 :tryLock(2, TimeUnit.SECONDS) 只等待 2 秒,避免请求堆积;显式不传 leaseTime,让看门狗以配置的 5 秒为周期自动续期;unlock() 前判断 isHeldByCurrentThread(),防止未持锁时释放抛出 IllegalMonitorStateException。
预期结果 :同一个 orderId 并发请求只有一个成功进入业务,其他返回"处理中";业务执行 3 秒超过了初始 5 秒周期的一部分,但不会因过期被第二个请求抢占,这正是续期生效的表现。
适用场景 :需要可重入、自动续期、失败重试的企业内部业务。容易改错的地方 :一旦显式传入 leaseTime,看门狗就不再续期,长业务会直接过期;unlock() 必须在 finally 中,且要确认是当前线程持有,跨线程释放会失败。
| 参数 | 含义 | 典型取值 | 常见错误 |
|---|---|---|---|
| waitTime | 获取锁最长等待 | 1~3 秒 | 设得过长导致请求线程堆积 |
| leaseTime | 锁持有时间 | 不传或明确按业务上限 | 显式传入后看门狗失效 |
| watchdog timeout | 续期周期基准 | 默认 30 秒 | 设得过短导致频繁续期压力 |
| unlock 判断 | 是否当前线程持有 | isHeldByCurrentThread() |
跨线程释放抛异常 |
7. 客户端崩溃、GC 停顿与主从切换:互斥为什么会失效
前面所有方案都建立在"Redis 一直记得这把锁、业务线程一直正常执行"之上。但现实中至少有三类事件会打破这个前提。
第一类是客户端崩溃。进程拿到锁后宕机,看门狗线程也随之消失,锁会一直保留到过期。此时互斥是"暂时安全"的,代价是其他请求要等到 TTL 结束。真正危险的是"进程没死,但线程被长时间暂停",比如 Full GC 停顿 40 秒,锁在 30 秒时过期,另一个进程进来了,两个进程同时执行临界区------锁在 Redis 里消失了,可业务线程还以为自己持有锁。
第二类是主从切换。Redis 主从复制默认是异步的:主节点写入锁 key 并返回 OK,但在复制到从节点之前主节点宕机,哨兵把从节点提升为新主,新主上没有这把锁,另一个客户端就能再次加锁成功。两个客户端同时认为自己持有锁,互斥失效。
第三类是时钟与超时依赖。锁的有效性依赖 Redis 服务器时间和本地时间估算,如果机器时钟被调整,或者网络延迟导致请求在锁过期后才到达,同样会出现两个持有者。
text
主从切换导致的互斥失效:
客户端 A 主节点 M 从节点 S 客户端 B
| 加锁成功 | | |
|------------------>| | |
|<-- OK ------------| | |
| | 异步复制锁 key | |
| |----------------->| |
| | (未完成前宕机) | |
| | X 宕机 | |
| | | 提升为主 |
| | |<-----------------| 加锁成功
| 仍在执行临界区 | |----------------->|
| | | 两个持有者 |
这张图想说明的是:互斥失效不是 Redis 命令写错了,而是"锁状态"和"业务执行"之间的原子性无法靠单个 Redis 实例保证。 所以如果你要的是严格的正确性,就不能只看锁,还要看被保护的操作本身是否具备幂等或防重能力。
8. Redlock 争议:多节点加锁能解决什么,不能解决什么
针对主从切换问题,Redis 作者 antirez 提出了 Redlock 算法:准备 N 个互相独立的 Redis 主节点(通常 5 个),客户端依次向每个节点用相同的 key 和 value 加锁,并设置一个较短的超时;只有在多数派节点上成功、且总耗时小于锁有效时间时,才认为加锁成功;释放时向所有节点发送删除请求。
从设计意图看,Redlock 想用"多数派"换取对单点故障和主从切换的容忍度:即使少数节点故障或切换,只要多数派还保留着锁记录,其他客户端就无法凑齐多数派。
但分布式系统研究者 Martin Kleppmann 提出了著名质疑,核心论点有三条:第一,Redlock 依赖各节点时钟大致同步和本地时间估算,进程暂停(GC、虚拟机迁移)会让锁在有效期内失效;第二,它没有提供 fencing token(单调递增的令牌),即使锁失效,被保护资源也无法识别"持有者是否已经过期",因此无法阻止旧持有者继续写入;第三,对效率型场景它太重,对正确性型场景它又不够安全。antirez 随后做了回应,认为这些质疑部分成立但被夸大,双方争论至今没有统一结论。
| 维度 | 单实例 Redis 锁 | Redlock | 数据库行锁 / 唯一约束 |
|---|---|---|---|
| 解决单点故障 | 否 | 部分 | 是(依赖数据库高可用) |
| 对时钟依赖 | 低 | 高 | 低 |
| 需要 fencing token | 建议 | 建议且更必要 | 天然可用版本号 |
| 性能 | 高 | 中 | 低 |
| 适合场景 | 效率型互斥 | 想降低单点概率 | 强一致正确性 |
结论要分场景:如果只是为了避免重复执行、减少资源浪费(例如多个实例同时刷新同一份缓存),单实例锁加合理过期时间通常足够;如果被保护的操作涉及金额、库存、状态机流转,锁只是第一层保护,还需要业务侧幂等、版本号或数据库唯一约束兜底。Redlock 可以降低单点风险,但它不能把 Redis 锁变成强一致的分布式互斥原语。
9. 完整示例三:用 fencing token 兜住锁失效
目标:演示即使锁失效,也能通过数据库版本号阻止旧持有者写入,即使用单调递增令牌做保护。
前置环境:MySQL 8.x 或 H2 内存库,JDK 8+,Spring JDBC。下面用 H2 的 SQL 结构说明,足以复现关键逻辑。
输入 :一条订单记录,状态从 CREATED 流转到 PAID;要求同一订单只能被一个持有旧令牌的请求推进一次。
sql
-- 表结构:version 同时充当 fencing token
CREATE TABLE order_state (
order_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(32) NOT NULL,
version BIGINT NOT NULL
);
INSERT INTO order_state(order_id, status, version)
VALUES ('ORD-1001', 'CREATED', 1);
java
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class FencingTokenDemo {
/** 模拟一次带令牌的状态推进 */
public boolean pay(Connection conn, String orderId, long token) throws Exception {
String sql = "UPDATE order_state SET status='PAID', version=? " +
"WHERE order_id=? AND status='CREATED' AND version < ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, token + 1);
ps.setString(2, orderId);
ps.setLong(3, token);
int rows = ps.executeUpdate();
return rows == 1;
}
}
public long nextToken(Connection conn, String key) throws Exception {
String sql = "UPDATE fence_token SET current = current + 1 WHERE key_name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, key);
ps.executeUpdate();
}
try (PreparedStatement ps = conn.prepareStatement(
"SELECT current FROM fence_token WHERE key_name = ?")) {
ps.setString(1, key);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
}
}
关键步骤 :加锁成功后,先从数据库或独立发号器取一个单调递增的令牌 token;把它带进业务操作;写数据时用 version < token 作为条件,旧令牌的请求即使锁已经失效,也会因版本号不满足而更新 0 行,从而被拒绝。
预期结果 :旧持有者 A 用 token=5 提交时,如果新持有者 B 已经用 token=6 改成 PAID,A 的更新影响行数为 0,返回失败;业务不会出现"两个进程都成功状态流转"的情况。
适用场景 :金额、库存、订单状态机等对正确性要求高的场景。容易改错的地方 :令牌必须单调递增且全局唯一,不能用随机数;version < 与业务状态条件要一起使用,否则可能把已支付订单重新改回已支付;数据库连接和事务边界要显式管理,示例中省略了回滚逻辑以便聚焦令牌判断。
10. 常见误区:把锁当成万能保护伞
误区一:认为锁的过期时间越长越安全。 过期时间越长,进程崩溃后恢复越慢;真正安全的是让业务尽快完成,并配合幂等,而不是把 TTL 设成 1 小时。
误区二:认为 Redlock 一定比单机锁正确。 Redlock 降低了单点故障概率,但它引入更多网络往返和时钟依赖,在客户端长时间暂停时同样会失效;它不是强一致的线性化锁。
误区三:认为看门狗能防止"死人持锁"。 看门狗只能续期活着的进程;进程崩溃后续期停止,锁最终过期。看门狗解决的是"慢",不是"死"。
误区四:用锁来保证消费幂等。 消息重复投递可能发生在锁之外,先把锁删了再处理,或者处理后进程崩溃但消息已经 ack,都会破坏语义。幂等应该由业务主键、去重表或唯一约束保证。
误区五:锁 key 没有命名空间。 多个业务共用 lock 这种名字,一旦某处误删或误用,影响面难以排查。建议用 lock:{业务}:{对象} 的形式,并在监控里区分。
11. 生产实践建议:怎么选、怎么配、怎么观测
先判断用途。 如果是"效率型"目的,例如防止多个实例同时做一次重复计算、重复刷新缓存,可以用单实例锁,等待时间短,过期时间按业务 P99 定,失败直接跳过或重试。如果是"正确性型"目的,例如扣款、扣库存、状态机流转,锁只作为第一道防线,必须叠加数据库乐观锁、唯一约束或幂等表。
过期时间与续期策略。 不要让业务执行时间接近 TTL。推荐做法是:TTL 取业务 P99 的 2 到 3 倍;使用看门狗续期时,显式设置 lockWatchdogTimeout 并监控续期失败次数;如果业务有硬上限(例如 30 秒必须结束),直接设 leaseTime 并让超时失败的请求进入补偿流程,而不是无限续期。
释放锁的正确姿势。 永远在 finally 中释放,先判断当前线程是否持有;使用 Lua 比较 value;释放失败要打日志并记录 key 和 value,方便人工介入。不要用 DEL 直接删,也不要在解锁脚本里混入其他逻辑。
观测指标。 至少采集:获取锁成功率、平均等待时间、等待超时次数、TTL 剩余时间分布、续期次数、被其他线程抢先释放的次数。这些指标能帮你在事故之前发现"锁等待堆积"。
| 场景 | 推荐方案 | 过期/续期 | 失败处理 |
|---|---|---|---|
| 缓存刷新去重 | 单实例锁 | TTL 短,可续期 | 跳过本次 |
| 定时任务单实例 | 单实例锁或 Redisson | TTL 覆盖任务上限 | 记录并告警 |
| 订单状态机 | 锁 + 数据库版本号 | TTL 短,不无限续期 | 事务回滚重试 |
| 跨机房强一致 | 数据库/共识组件 | 不适用 | 依赖底层一致性 |
12. 排障清单:锁相关问题怎么一步步收敛
第一步,确认锁 key 是否存在、value 是什么、TTL 还剩多少。 使用 GET、PTTL、OBJECT IDLETIME 查看;如果 key 不存在却仍出问题,说明锁没有生效,先检查加锁命令是否真的用了 NX 和 PX。
第二步,确认是否发生误删。 搜索日志中的解锁脚本返回值,如果频繁返回 0,说明当前线程拿的 value 已不是锁上的值,通常意味着锁已经过期并被别人获取。
第三步,确认业务执行时间分布。 把业务耗时和锁 TTL 画在同一张图上,看 P99 是否逼近 TTL。如果接近,先排查慢 SQL、GC、外部 RPC 超时,再考虑续期。
第四步,确认 Redis 拓扑是否发生过切换。 查看哨兵日志、主从切换事件、INFO replication 的 master_link_status;如果切换期间出现双写,结合业务侧版本号判断是否真的造成数据错误。
第五步,确认客户端暂停。 查看 GC 日志中的 Full GC 停顿;如果停顿时间超过锁 TTL,即便 Redis 一切正常,互斥也会失效。
bash
# 排障常用命令
redis-cli GET lock:order:ORD-1001
redis-cli PTTL lock:order:ORD-1001
redis-cli INFO replication
redis-cli SLOWLOG GET 10
13. 面试与复盘问题
- 为什么
SETNX+EXPIRE两条命令不安全?如果改成SET NX PX还剩下哪些问题? - 释放锁为什么必须用 Lua?如果不用 Lua,什么时序会导致误删别人的锁?
- 看门狗在什么条件下生效,什么条件下不会续期?进程崩溃后锁多久释放?
- Redlock 想解决什么问题?它在客户端长时间暂停和时钟漂移下的边界在哪里?
- fencing token 为什么能兜住锁失效?它要求令牌具备什么性质?
- 一个库存扣减场景,你会怎么组合 Redis 锁、数据库唯一约束和幂等表?
14. 总结:一张决策图收束所有讨论
text
需要跨进程互斥?
|
+-- 否 --> 用 JVM 内锁即可
|
+-- 是 --> 业务失败会不会造成资金/库存/状态错误?
|
+-- 否(效率型) --> 单实例 Redis 锁
| - SET NX PX
| - Lua 释放
| - TTL 按 P99 设置
|
+-- 是(正确性型) --> Redis 锁只是第一层
- 加 fencing token / 版本号
- 数据库唯一约束兜底
- 评估是否直接上数据库锁或共识组件
- 监控 GC、主从切换、网络分区
回到开头那个超卖问题:加一把 Redis 锁确实能挡住大部分并发,但真正让系统不再出错的,是理解锁的边界------它依赖过期时间、依赖客户端不长时间暂停、依赖 Redis 故障切换时不丢状态。这些依赖在当前实现里并不总能满足,所以正确的工程姿势是:用锁降低冲突概率,用幂等和版本号保证最终正确性。 当你能清楚说出"这把锁失效时,我的业务会怎样",你才算真正掌握了 Redis 分布式锁。
15. 参考资料
- Redis 官方文档:SET 命令与 NX、PX 选项说明,https://redis.io/commands/set/
- Redis 官方文档:EVAL 与脚本原子性,https://redis.io/docs/manual/programmability/eval-intro/
- Redis 官方文档:Replication 与异步复制语义,https://redis.io/docs/management/replication/
- Redisson 官方文档:Lock 与 watchdog,https://redisson.org/docs/data-and-services/locks-and-synchronizers/
- Martin Kleppmann:How to do distributed locking,https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- antirez:Is Redlock safe?,http://antirez.com/news/101
- Redis 官方文档:Redis Cluster 与 Sentinel,https://redis.io/docs/management/sentinel/
- 《Redis 设计与实现》黄健宏著,关于单线程模型与过期机制章节