Redis 分布式锁的边界条件:Redlock 争议、锁续期与客户端崩溃后的互斥失效

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. 面试与复盘问题

  1. 为什么 SETNX + EXPIRE 两条命令不安全?如果改成 SET NX PX 还剩下哪些问题?
  2. 释放锁为什么必须用 Lua?如果不用 Lua,什么时序会导致误删别人的锁?
  3. 看门狗在什么条件下生效,什么条件下不会续期?进程崩溃后锁多久释放?
  4. Redlock 想解决什么问题?它在客户端长时间暂停和时钟漂移下的边界在哪里?
  5. fencing token 为什么能兜住锁失效?它要求令牌具备什么性质?
  6. 一个库存扣减场景,你会怎么组合 Redis 锁、数据库唯一约束和幂等表?

14. 总结:一张决策图收束所有讨论

text 复制代码
需要跨进程互斥?
   |
   +-- 否 --> 用 JVM 内锁即可
   |
   +-- 是 --> 业务失败会不会造成资金/库存/状态错误?
              |
              +-- 否(效率型) --> 单实例 Redis 锁
              |                    - SET NX PX
              |                    - Lua 释放
              |                    - TTL 按 P99 设置
              |
              +-- 是(正确性型) --> Redis 锁只是第一层
                                   - 加 fencing token / 版本号
                                   - 数据库唯一约束兜底
                                   - 评估是否直接上数据库锁或共识组件
                                   - 监控 GC、主从切换、网络分区

回到开头那个超卖问题:加一把 Redis 锁确实能挡住大部分并发,但真正让系统不再出错的,是理解锁的边界------它依赖过期时间、依赖客户端不长时间暂停、依赖 Redis 故障切换时不丢状态。这些依赖在当前实现里并不总能满足,所以正确的工程姿势是:用锁降低冲突概率,用幂等和版本号保证最终正确性。 当你能清楚说出"这把锁失效时,我的业务会怎样",你才算真正掌握了 Redis 分布式锁。

15. 参考资料

相关推荐
kaixin_learn_qt_ing1 小时前
qgeoview-samples-basic
数据库
Omics Pro1 小时前
微软:多模态生物世界模型
数据库·人工智能·算法·microsoft·机器学习·自然语言处理
字节渡客2 小时前
接口明明返回成功,数据库偶尔就是没写入
数据库
Ivanqhz2 小时前
KV Cache
服务器·数据库·人工智能·深度学习·算法
Omics Pro2 小时前
~30,000+引用!理论2005,R包2008,多组学集成AI增强
开发语言·数据库·人工智能·算法·机器学习·自然语言处理·r语言
灯澜忆梦2 小时前
【minio】#5 | MinIO 分布式部署 + HTTPS 部署
分布式·网络协议·https·对象存储·minio
呆萌很3 小时前
数据库逻辑结构设计
数据库
howdoyoudo2026063 小时前
当新案例冲击旧框架:分类系统的宿命与修正路径
大数据·网络·数据库·人工智能·安全·ai·分类
自己的九又四分之三站台3 小时前
GeoPackage 到底适合什么场景:从 SQLite 容器到空间数据格式选型
数据库·oracle·sqlite