“我加了 synchronized”——带实习生踩过的分布式锁的坑

实习生交完代码自信满满地说:"锁肯定加了,我用的 synchronized。"结果凌晨三点,批处理任务在三个实例上各跑了一遍,用户收到了三份重复的通知。一通排查下来——分布式锁不是这么用的。


事故还原:三份重复通知是怎么发出的

事情是这样的。部门来了个实习生,挺聪明一小伙子,学东西也快。第二周我让他写个定时任务——每天凌晨把一批待处理数据批量同步到第三方系统。

代码很快写完,Code Review 时我扫了一眼:

@Scheduled(cron = "0 0 3 * * ?")  // 凌晨三点执行
public synchronized void syncData() {
    List<PendingData> list = pendingDataMapper.selectList(...);
    for (PendingData data : list) {
        thirdPartyApi.sync(data);
    }
}

我问:“这个加锁了吗?”

他拍着胸脯说:“加了!synchronized,保证同一时刻只有一个线程能进这个方法。”

我说:“那你回去想想,我们的服务部署了几个实例?”

第二天他红着脸过来:“哥,我查了,三个实例……synchronized 只对同一个 JVM 进程有效。”

翻了一遍日志才确认——问题不在同步逻辑本身,在更上游:

凌晨 03:00 → 定时任务触发 → 三个服务实例各自收到 Spring 的 tick
    ├── 实例 A:查到 200 条待处理数据 → 逐条同步 → 发送通知
    ├── 实例 B:查到 200 条待处理数据 → 逐条同步 → 发送通知
    └── 实例 C:查到 200 条待处理数据 → 逐条同步 → 发送通知

三个实例各自运行了同一段逻辑。数据同步本身是幂等的(设计时考虑了重复跑的场景),但同步完成后发通知那一步没有做幂等——结果用户收到了三份一模一样的通知。

这就引出了一个经典的问题:多实例部署下,怎么保证某个操作同一时刻只有一个实例在执行?

也就是分布式锁要解决的事。


先搞清楚单机锁为什么不行

如果是单机部署,这个问题很好解决。Java 里随便拎一个工具出来都行:

// synchronized:JVM 内置锁,最简单
public synchronized void syncData() {
    // 执行数据同步的逻辑
}

// ReentrantLock:比 synchronized 多了公平锁、条件变量等能力
private final ReentrantLock lock = new ReentrantLock();

public void syncData() {
    lock.lock();          // 获取锁
    try {
        // 执行数据同步的逻辑
    } finally {
        lock.unlock();    // 一定记得在 finally 里释放
    }
}

这些锁的核心机制是依赖 JVM 内部的一个共享标记——AQS(AbstractQueuedSynchronizer)。简单说,AQS 维护了一个 volatile int state,线程通过 CAS(Compare And Swap)去原子地把它从 0 改成 1,改成功的拿到锁,改失败的排队等着。这套机制在同一个 JVM 进程里运行得天衣无缝,但问题出在——这个 state 只存在于 JVM 的堆内存里,别的 JVM 看不到。

三个实例就是三个独立的 JVM 进程,各有各的 synchronized,谁也管不了谁。

那怎么办?需要一个所有实例都能访问到的"共享标记"——这就是分布式锁的核心思想:把锁的标志位放到一个所有进程都能访问的外部存储里

顺着这个思路,很自然就能想到三种方案——因为任何一个服务集群都会有这三种基础设施之一:

基础设施 能做锁吗 怎么实现
MySQL 用行锁或者唯一索引
Zookeeper 用临时顺序节点
Redis SET NX EX 命令

下面一个个说。


方案一:用 MySQL 做分布式锁

思路

MySQL 里有一句叫 SELECT ... FOR UPDATE 的 SQL。它的行为是这样的:在事务里执行这句 SQL 时,所选中的行会被加上排他锁(X 锁),其他事务如果也试图 SELECT ... FOR UPDATE 同一行,会被阻塞直到持有锁的事务提交或回滚。

这不就是锁的"互斥"语义吗?那我们可以建一张专门的锁表:

CREATE TABLE distributed_lock (
    lock_name    VARCHAR(128) PRIMARY KEY,  -- 锁的名字,比如 'data_sync_task'
    holder       VARCHAR(128),              -- 持有者标识,比如实例的 hostname
    expire_time  DATETIME,                  -- 锁的过期时间,防止死锁
    create_time  DATETIME DEFAULT NOW()
);

然后这样获取锁:

// 尝试获取锁(默认 REPEATABLE READ 隔离级别,InnoDB 对不存在的行也会加 gap lock)
public boolean tryLock(String lockName, String holder, int expireSeconds) {
    // 开启事务
    beginTransaction();

    // 加排他锁:SELECT ... FOR UPDATE
    // 如果锁被其他事务持有,这里会自动阻塞等待(相当于"循环直到能获取到")
    // 对方提交事务释放锁后,当前事务才能继续往下走
    Row row = query("SELECT * FROM distributed_lock WHERE lock_name = ? FOR UPDATE", lockName);

    if (row == null) {
        // 锁记录不存在 → 没人用过这把锁,INSERT 一条,当前线程持有
        execute("INSERT INTO distributed_lock (lock_name, holder, expire_time) " +
                "VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))",
                lockName, holder, expireSeconds);
    } else if (row.expireTime <= now()) {
        // 锁已过期 → 接管
        execute("UPDATE distributed_lock SET holder = ?, expire_time = DATE_ADD(NOW(), INTERVAL ? SECOND) " +
                "WHERE lock_name = ?", holder, expireSeconds, lockName);
    } else {
        // 锁还在有效期内且被别人占着 → 本次获取失败
        commit();  // 提交事务释放排他锁
        return false;
    }

    // 拿到锁 → 执行真正的业务逻辑
    doBusiness();

    // 提交事务,自动释放所有行锁
    commit();
    return true;
}

释放锁不需要单独调 DELETE——事务提交后所有行锁自动释放,过期时间作为兜底防止死锁。

当业务执行完后,删除这条记录或者将 holder 置空就释放了锁。

MySQL 方案优缺点

优点

  1. 零引入成本——项目已经在用 MySQL 的话,不需要额外维护任何中间件,直接用数据库就行。
  2. 强一致性——行锁由 InnoDB 引擎在事务层面严格保证,不会出现 Redis 主从切换那样的丢锁问题。
  3. 运维简单——DBA 日常维护的数据库,不需要学习新的运维技能。
  4. 阻塞等待友好——FOR UPDATE 自带排队等待,拿不到锁的事务会自动阻塞,不需要自己实现轮询。

缺点

  1. 性能瓶颈——每次加锁解锁都需要数据库连接和事务开销,QPS 通常只能到千级,扛不住高频加锁。
  2. 没有自动续期——锁过期时间设短了业务没跑完就过期,设长了进程崩溃后锁长时间不释放,做不到"动态续期"。
  3. 超时等待难实现——MySQL 没有原生的"等锁超时"语法(Oracle 有 WAIT 10,但 MySQL 不支持),要么用 NOWAIT 立即返回,要么自己实现轮询重试。
  4. 死锁风险——如果事务内加锁后抛出异常未提交,锁可能一直持有直到事务超时,需要额外的超时机制兜底。

一句话:并发量不高、不想引入新组件时,MySQL 低成本够用,但别拿来处理高频业务。


方案二:用 Zookeeper 做分布式锁

先花一分钟搞懂 ZK 的临时顺序节点

Zookeeper 是一个分布式协调服务。你不用把它想得太复杂——本质上它就是一个树形结构的文件系统,但这个文件系统有几个普通文件系统没有的特性。

其中最重要的两个是:

  • 临时节点(Ephemeral Node):客户端创建这个节点后,如果客户端断开连接(session 超时),这个节点会被自动删除。这个特性天然适合做"进程挂了自动释放锁"。
  • 顺序节点(Sequential Node):创建节点时可以指定"给我自动加一个递增的序号后缀",比如创建 /lock/request- 会自动变成 /lock/request-0000000001/lock/request-0000000002。这个特性天然适合做"排队"。

两者结合起来,就是 ZK 分布式锁的核心。

ZK 锁的工作流程

画出来大概是这样的:

                     /lock(持久节点)
                        ├── request-000000001(实例A创建,临时顺序节点)
                        ├── request-000000002(实例B创建,临时顺序节点)
                        └── request-000000003(实例C创建,临时顺序节点)

每个实例都往 /lock 目录下创建一个临时顺序节点。ZK 会自动给每个节点编号。

判断自己是否拿到锁的方式非常巧妙:所有实例都查看 /lock 下的子节点列表,序号最小的那个就是锁的持有者。其他人不需要询问任何人,各自看各自的。

// 用 Curator 框架(ZK 的 Java 客户端)获取锁的代码大概长这样
InterProcessMutex lock = new InterProcessMutex(curatorClient, "/lock/data_sync");

if (lock.acquire(5, TimeUnit.SECONDS)) {  // 最多等 5 秒
    try {
        // 执行数据同步
    } finally {
        lock.release();
    }
}

ZK 方案优缺点

优点

  1. 天然防死锁——临时节点机制:持有锁的客户端断开连接(进程崩溃、网络分区)后,ZK 自动删除节点,锁自动释放,不需要额外兜底。
  2. 公平排队——顺序节点天然实现 FIFO,先请求的先拿到锁,不会出现"某个实例一直抢不到"的饥饿问题。
  3. 强一致性——ZAB 协议保证集群所有节点数据一致,不存在 Redis 主从切换那样的丢锁场景。
  4. 监听通知——ZK 的 Watcher 可以监听锁释放事件,拿到锁的实例释放后立即通知等待方,不需要轮询。

缺点

  1. 性能较低——写操作需要过半节点确认(ZAB 多数派写入),单次加锁延迟比 Redis 高一个数量级。
  2. 吞吐量受限——单 Leader 写模型,所有写请求都由 Leader 处理,扛不住高频次加锁。
  3. 运维成本高——生产环境至少需要 3 个节点组成集群,引入 ZK 只为做分布式锁,代价太大。
  4. 客户端偏重——客户端要处理 session 超时重连、Watcher 回调等逻辑,虽然 Curator 封装了一部分,但比 Redis 的 SETNX 还是重得多。

一句话:如果你已经在用 ZK(做服务发现或配置中心),顺手用它做锁是合理的。否则,不要为了一把锁去维护一套 ZK 集群。


方案三:用 Redis 做分布式锁

从一个简单的 SETNX 开始

先说明一下名字:SETNX 是 “Set if Not eXists” 的缩写。Redis 早期版本有一个独立的 SETNX 命令,语义很直观——只有 key 不存在的时候才设置成功,已经存在就什么都不做。这个"有则跳过、无则写入"的原子语义天然适合做分布式锁的"抢锁"操作。后来 Redis 官方不推荐单独使用 SETNX,而是统一用带 NX 选项的 SET 命令来代替。

所以现在正经写法是 SET key value NX EX seconds,而不是调 SETNX。但大家叫习惯了,提到分布式锁时还是习惯说"用 SETNX 加锁"。

拆开来看各选项的含义:

  • NXOnly set if Not eXists——只有这个 key 不存在的时候才设置成功。天然实现"抢锁"。
  • EX seconds:设置过期时间,到期后 key 自动删除。防止进程崩溃后锁永远不释放。

一句话就能加锁:

# 如果 sync_lock 这个 key 不存在,就创建它,并设置 30 秒后自动过期
SET sync_lock instance_A NX EX 30
# 返回 OK → 加锁成功
# 返回 (nil) → 锁已被占用

释放锁就是删掉这个 key:

DEL sync_lock

看起来完美,对吧?但从这个"看起来完美"到"生产可用",中间隔着好几个坑。我们一个一个填。

第一个坑:DEL 删错了锁怎么办

假设这样一个时序:

时间轴:
T0:  实例A 拿到锁,SET lock instance_A NX EX 30 → OK
T30: 实例A 的业务还没执行完,锁自动过期了(过期时间设的是 30 秒)
T31: 实例B 拿到锁,SET lock instance_B NX EX 30 → OK
T32: 实例A 终于执行完了,执行 DEL lock → 把实例B的锁删了!!
T33: 实例C 拿到锁 → 实例B 和 实例C 同时在执行了

问题很明确:实例 A 在释放锁的时候,没法确认这个锁还是自己的。

解决方法是:把 value 设成一个唯一标识(比如 UUID),释放时先检查 value 是不是自己的,是自己的才删。

// 加锁时:value 用自己生成的唯一标识
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("sync_lock", lockValue, Duration.ofSeconds(30));

// 释放时:先查是不是自己的,是才删
String currentValue = redisTemplate.opsForValue().get("sync_lock");
if (lockValue.equals(currentValue)) {
    redisTemplate.delete("sync_lock");
}

但这就引出了第二个坑:检查 + 删除不是原子的

上面的代码里,"get 检查是不是自己的"和"delete 删掉"是两步操作。如果在 get 之后、delete 之前,锁刚好过期了,另一个实例拿到了锁——你检查的还是旧锁的 value,但实际上锁已经换人了。

要想彻底解决,必须把"检查 + 删除"变成一个原子操作。这就需要用到 Lua 脚本了。

顺带解释一下:为什么 Lua 脚本能保证原子性

Redis 是单线程执行命令的。这听起来像个性能弱点,但实际上是它最强大的特性——同一时刻只有一个命令在执行,天然避免了并发问题。而 Lua 脚本在 Redis 里被当作一个整体来执行,脚本执行期间其他命令必须等待。

所以如果用 Lua 脚本把"检查 + 删除"打包,就不会出现中间被插入的问题了:

-- 原子释放锁:先判断 value 是不是自己的,是才删
-- KEYS[1] 是锁的 key,ARGV[1] 是之前设置的唯一标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])   -- 是自己的锁,删除
else
    return 0                              -- 不是自己的锁,不删
end

Java 端这样调:

// 释放锁:用 Lua 脚本保证"判断 + 删除"的原子性
String script = """
    if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
    else
        return 0
    end
    """;

// RedisTemplate 执行 Lua 脚本,第二个参数是 key 列表,第三个是 argv 列表
Long result = redisTemplate.execute(
    new DefaultRedisScript<>(script, Long.class),
    List.of("sync_lock"),      // KEYS[1]
    lockValue                      // ARGV[1]
);

第三个坑:业务还没跑完,锁就过期了

前面设置锁的过期时间是 30 秒,但如果业务逻辑执行了 35 秒怎么办?

一个直觉反应是:"那我设长一点,比如 5 分钟。"但这只是把问题推迟了——万一哪天数据库慢查询,或者 GC 停顿了几秒,你的业务还是可能超过 5 分钟。而且如果进程在第 10 秒崩溃了,接下来 4 分 50 秒内没人能拿到锁。

根本解决方案是:锁需要有自动续期机制。当持有锁的线程还没执行完时,锁的过期时间被不断往后推;当线程执行完毕或崩溃时,过期时间不再被刷新,锁自然到期释放。

这就是 Redisson 的"看门狗"(Watchdog)机制要解决的事。它不是 Redis 自带的功能,而是 Redisson 这个 Redis 客户端框架在客户端层面实现的。

值得一提:Redisson(Redis + son,模仿 Jedis 的命名风格)是 Redis 的一个高级 Java 客户端,它在 Redis 的基础命令之上封装了很多分布式数据结构,比如分布式锁(RLock)、分布式集合(RMap、RSet)、分布式队列等,同时还处理了连接池管理、主从切换、哨兵和集群模式等运维层面的细节,让使用者可以像操作本地对象一样操作 Redis。

Redis 方案优缺点

优点

  1. 性能极高——Redis 纯内存操作,单节点 QPS 可达 5 万+,是三种方案里性能最好的。
  2. 自动续期——Redisson 看门狗机制,持有锁期间自动续期,业务跑多久锁就跟多久,不用担心锁过期。
  3. 可重入——Redisson 用 Hash 结构存储重入计数,同一个线程可以多次获取同一把锁,计数归零才真正释放。
  4. API 友好——Redisson 封装了完整的分布式锁 API(tryLockunlockisHeldByCurrentThread),使用体验和本地锁几乎一样。
  5. 运维成本低——绝大多数项目已经有 Redis,不需要额外维护中间件集群。

缺点

  1. 一致性较弱——Redis 主从异步复制,主节点宕机时锁可能丢失。RedLock 理论上可以解决,但有争议且实现复杂。
  2. 没有严格公平性——默认是非公平锁,高竞争下可能出现"线程饿死"。Redisson 提供 getFairLock() 但性能会下降。
  3. 依赖客户端实现——原子性(Lua 脚本)、续期(看门狗)、防御性解锁等关键安全机制都依赖客户端框架(Redisson)的正确使用,手写 SETNX 很容易踩坑。
  4. 进程崩溃后锁有窗口期——虽然看门狗会在进程退出时停止续期,但最多仍需等待 30 秒 TTL 过期,其他实例才能拿到锁。

一句话:三方案中综合表现最均衡,也是工程界的事实标准。除非一致性要求极其苛刻,否则 Redis + Redisson 是首选。


三种方案放一起比一比

三种方案都看完了,来一张汇总表放在一起对比:

维度 MySQL FOR UPDATE Zookeeper 临时顺序节点 Redis + Redisson 看门狗
性能(QPS) 低(~1000) 中(~5000) 高(~50000+)
公平性 不保证(竞争随机) ✅ 严格顺序排队 不保证(可通过 getFairLock() 支持)
自动续期 ❌ 事务会超时 ❌ session 有 TTL ✅ 看门狗(不指定 leaseTime 时)
可重入 ✅ 事务天然支持 ❌ 需自行实现 ✅ Hash field 存计数
进程崩溃后释放 ❌ 需手动清理 ✅ 临时节点自动删 最多等 30s TTL
运维成本 低(复用数据库) 高(需 ZK 集群) 低(复用 Redis)
适用场景 低频、强一致性要求 严格顺序、高可靠性 高频、性能敏感

没有银弹,只有适合的场景。先看看你手上已经有什么基础设施,不要为了一把锁去多维护一个中间件集群——这个代价比你想的大得多。

看完了三种方案的对比,你可能会问:既然 Redis 一致性不如 ZK,为什么工程界还是普遍用 Redis 做分布式锁? 答案在于下面要讲的 Redisson 看门狗机制——它在不牺牲太多性能的前提下,把 Redis 锁的可靠性拉到了一个很高的水位。


重点来了:Redisson 的看门狗机制是怎么工作的

先用 Redisson 写一个分布式锁

先说明一个概念:leaseTime(租约时间)是锁的有效期——拿到锁之后,如果没有主动释放,过了 leaseTime 锁就会自动过期,防止死锁。在 Redisson 里,leaseTime 对应 Redis key 的 TTL。

和前面手写的版本对比一下,用 Redisson 有多简洁:

// RedissonClient 是入口,通常在配置类里创建好并注入
RLock lock = redissonClient.getLock("sync_lock");

// tryLock 不传 leaseTime → 看门狗自动启用
if (lock.tryLock(10, TimeUnit.SECONDS)) {  // 最多等 10 秒
    try {
        // 你的业务逻辑,哪怕跑 5 分钟也没事
        syncData();
    } finally {
        // 防御性解锁:先判断锁还在不在自己手里
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
} else {
    // 等待 10 秒还没拿到锁,说明其他实例在执行
    log.warn("获取锁超时,跳过本次执行");
}

同一个锁,你用手写的 Redis 命令写大概要 40 行代码(UUID 生成、SET NX EX、Lua 脚本释放、超时处理、异常处理),用 Redisson 只要 10 行,而且安全性和性能都比手写的好。

但这个简洁的 API 背后是怎么实现的?关键就在那行 tryLock() 上——你不能简单传入 leaseTime 参数,否则看门狗就失效了。

两个参数:waitTime 和 leaseTime

Redisson 的 tryLock 有好几个重载,参数含义不一样,搞混了看门狗的行为就不可预期。先说明两个参数各自的作用:

参数 位置 含义 默认值
waitTime 第一个 long 获取锁的最大等待时间。锁被别人占着时,最多等这么久,超时就放弃返回 false -1(无限等待)
leaseTime 第二个 long 锁的租约时间(有效期)。拿到锁之后,leaseTime 到期自动释放,不需要手动 unlock -1(启用看门狗,默认 30 秒)

两个参数的组合决定了看门狗是否启用:

// ---------- 情况 1:不传 leaseTime(或传 -1)----------
// 看门狗 ✅ 启用,锁默认 30 秒过期,每 10 秒自动续期一次
lock.tryLock();                              // waitTime=-1(无限等), leaseTime=-1(看门狗)
lock.tryLock(10, TimeUnit.SECONDS);          // waitTime=10s,   leaseTime=-1(看门狗)
lock.tryLock(10, -1, TimeUnit.SECONDS);      // waitTime=10s,   leaseTime=-1(看门狗)

// ---------- 情况 2:明确传了 leaseTime ----------
// 看门狗 ❌ 不启用,锁在 leaseTime 后自动过期,不做续期
lock.tryLock(10, 30, TimeUnit.SECONDS);      // waitTime=10s,   leaseTime=30s(不续期)

设计逻辑很直观:传了 leaseTime 说明你清楚业务最长跑多久,不需要 Redisson 帮你盯着;不传说明你也不确定,看门狗自动顶上。

那看门狗的默认 30 秒能改吗? 可以。lockWatchdogTimeout 是 RedissonClient 级别的配置参数,创建客户端时指定就行:

Config config = new Config();
config.setLockWatchdogTimeout(15_000);  // 默认 30000ms,改为 15 秒
RedissonClient redisson = Redisson.create(config);

但一般不建议设太短——看门狗的续期间隔是 lockWatchdogTimeout / 3,设 15 秒的话每 5 秒就要续一次,网络开销反而上去了。30 秒、每 10 秒续一次是个合理的平衡点。

看门狗在你调用 tryLock() 时到底做了什么

把它拆成步骤来看,每一步都很清晰:

第一步:执行 Lua 脚本抢锁

Redisson 不是直接用 SET lock value NX EX 30 这么简单——它用的是 Hash 数据结构,而不是 String。这样做的原因是:Hash 可以同时存储"锁的持有者线程"和"重入计数"两个信息。

-- Redisson tryLock 内部执行的 Lua 脚本(简化版)
-- KEYS[1]:锁的 key,比如 "sync_lock"
-- ARGV[1]:锁的过期时间,默认 30000(毫秒)
-- ARGV[2]:锁的持有者标识,格式是 "UUID:线程ID"

-- 如果锁不存在(第一次加锁)
if (redis.call('EXISTS', KEYS[1]) == 0) then
    redis.call('HINCRBY', KEYS[1], ARGV[2], 1);  -- 重入计数设为 1
    redis.call('PEXPIRE', KEYS[1], ARGV[1]);      -- 设置过期时间 30 秒
    return nil;  -- 返回 nil 表示成功
end

-- 如果锁已经存在,且持有者是自己(重入场景)
if (redis.call('HEXISTS', KEYS[1], ARGV[2]) == 1) then
    redis.call('HINCRBY', KEYS[1], ARGV[2], 1);  -- 重入计数 +1
    redis.call('PEXPIRE', KEYS[1], ARGV[1]);      -- 刷新过期时间
    return nil;  -- 返回 nil 表示成功
end

-- 锁被别人占着,返回锁的剩余存活时间(毫秒)
return redis.call('PTTL', KEYS[1]);

注意这段 Lua 的逻辑:**锁用的是 Hash 结构,field 是"UUID:线程ID",value 是重入计数。**这样同一个线程可以多次获取同一把锁(可重入),每次重入计数 +1,释放时计数 -1,减到 0 才真正删除锁。

另外,它用了 HINCRBY 而不是 HSET——这个细节很重要,因为 HINCRBY 对不存在的 key 也能正常执行(相当于从 0 开始加),省去了先判断 key 存不存在的逻辑,让整个 Lua 脚本更简洁。

第二步:抢锁成功后,启动看门狗定时任务

如果 tryLock() 没有指定 leaseTime,Redisson 在抢锁成功后会启动一个后台定时任务。这个定时任务的核心逻辑是一个 Timeout 对象(基于 Netty 的时间轮 HashedWheelTimer):

定时任务每 intervalLockLeaseTime/3 执行一次
默认 internalLockLeaseTime = 30000ms
所以每 10 秒(30000/3)执行一次续期

每次执行时,看门狗用另一段 Lua 脚本检查锁是否还被当前线程持有,如果是,就把过期时间重新设为 30 秒:

-- 看门狗续期脚本(简化版):只续期自己的锁
-- KEYS[1]:锁的 key
-- ARGV[1]:锁的持有者标识(UUID:线程ID)
-- ARGV[2]:新的过期时间 30000(毫秒)

-- 如果锁的持有者还是当前线程(通过 Hash 的 field 判断)
if (redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1) then
    redis.call('PEXPIRE', KEYS[1], ARGV[2]);  -- 续期到 30 秒
    return 1;  -- 续期成功
end
return 0;  -- 锁已经不属于当前线程了

注意,这里用的是 PEXPIRE(以毫秒为单位设置过期时间),不是 EXPIRE(以秒为单位)。因为看门狗的续期间隔是 10 秒级别的,用毫秒精度可以更精确地控制过期行为。

第三步:业务执行完,释放锁并停止看门狗

-- Redisson 释放锁的 Lua 脚本(简化版)
-- KEYS[1]:锁的 key
-- ARGV[1]:释放锁的频道名(Redisson 内部用于通知等待中的线程)
-- ARGV[2]:锁的持有者标识
-- ARGV[3]:锁的过期时间(用于通知消息)

-- 如果锁不存在,直接广播通知并返回
if (redis.call('HEXISTS', KEYS[1], ARGV[2]) == 0) then
    return nil;
end

-- 重入计数 -1
local counter = redis.call('HINCRBY', KEYS[1], ARGV[2], -1);

if (counter > 0) then
    -- 还有重入计数没完全释放,续期但不删除
    redis.call('PEXPIRE', KEYS[1], ARGV[3]);
    return 0;
else
    -- 计数归零,完全释放:删除锁 + 广播通知等待的线程
    redis.call('DEL', KEYS[1]);
    redis.call('PUBLISH', KEYS[1] .. ':channel', ARGV[1]);
    return 1;
end

Java 端的 unlock() 方法在调用这段 Lua 脚本后,还会取消看门狗的定时任务:

// Redisson 内部 unlock 的大致流程
// 1. 执行上面的 Lua 脚本(原子释放)
// 2. cancelExpirationRenewal() → 取消看门狗定时任务
// 3. 如果有其他线程在等待这个锁,通过 Pub/Sub 通知它们

整个过程画成一个时间线

把上面的三步串起来,一次完整的"加锁 → 续期 → 释放"大概是这样的:

T0:   实例A 调用 tryLock()
      → 执行 Lua 脚本:HSET lock {UUID:thread1}: 1, PEXPIRE lock 30000
      → 抢锁成功
      → 启动看门狗定时任务(每 10 秒一次)

T10:  看门狗第 1 次触发
      → 检查 lock 的 Hash field 还是 {UUID:thread1}
      → 执行 PEXPIRE lock 30000 → 过期时间重设为 30 秒

T15:  业务还在跑,没执行完...

T20:  看门狗第 2 次触发
      → lock 的持有者还是 {UUID:thread1}
      → PEXPIRE lock 30000 → 又是 30 秒

T23:  业务执行完毕
      → 实例A 调用 unlock()
      → Lua 脚本:HINCRBY 减到 0 → DEL lock → PUBLISH 通知
      → cancelExpirationRenewal() → 停止看门狗

期间 实例B 和 实例C 调用 tryLock()
      → Lua 脚本发现 lock 已被持有(不是自己)
      → 返回 PTTL(锁的剩余存活时间)
      → 订阅 lock:channel,等通知或超时

如果实例 A 的进程在 T5 就崩溃了(JVM 直接退出,或者机器宕机),看门狗任务自然也没了。锁不再被续期,最多 30 秒后就自动过期释放——不会造成死锁。

看门狗的时间轮实现

这里补充一个底层细节。看门狗定时任务用的是 Netty 的 HashedWheelTimer,而不是 ScheduledThreadPoolExecutor

HashedWheelTimer 是 Netty 实现的一个时间轮算法调度器。你可以把它想象成一个圆形的表盘,分成 N 个槽(比如 512 个),每个槽对应一个时间段。表盘上有一根指针,每秒转一格。需要定时执行的任务就挂在对应的槽上,指针转到那个槽时执行。

相比 JDK 自带的 ScheduledThreadPoolExecutor(底层是 DelayedWorkQueue,本质是一个基于堆的优先队列),时间轮的优势在于:

  • 插入和取消都是 O(1)——直接挂到槽上或从槽上取下来就行,不需要维护堆结构。
  • 适合大量短周期定时任务——每个 Redisson 锁都有自己的看门狗任务,如果一个应用里有几十把锁的实例在运行,时间轮比堆更适合这个场景。

Redisson 选它并不是因为单个看门狗任务需要多高的性能,而是 Redisson 内部的定时器是全局共享的——所有锁的看门狗、所有锁的等待重试、所有超时处理都复用同一个时间轮,加起来的任务数量就不少了,时间轮在这个场景下有天然优势。


释放锁的正确姿势:防御性解锁

回到实际代码。在 finally 块里释放锁时,有一个很容易踩的坑:

// ❌ 错误姿势:直接 unlock
RLock lock = redissonClient.getLock("sync_lock");
lock.tryLock(10, 30, TimeUnit.SECONDS); // 指定了 30 秒 leaseTime,没有看门狗
try {
    // 业务跑了 35 秒...
    doSomethingSlow();  // 第 35 秒才执行完
} finally {
    lock.unlock();  // 💥 此时锁已经在第 30 秒过期了!
    // 第 31 秒时实例 B 拿到了锁,现在这个 unlock 是删掉别人的锁
    // Redisson 会抛 IllegalMonitorStateException
}

错误的根源在于:指定了 leaseTime = 30 秒,但业务跑了 35 秒,锁过期了你还去释放。Redisson 发现你尝试释放一个不属于你的锁,就会抛异常。

正确的做法是 “防御性解锁”——释放前先检查锁是不是还在自己手里:

// ✅ 正确姿势:先判断,再释放
RLock lock = redissonClient.getLock("sync_lock");
boolean acquired = false;

try {
    // 不指定 leaseTime → 看门狗自动续期
    acquired = lock.tryLock(10, TimeUnit.SECONDS);
    if (!acquired) {
        log.warn("获取锁超时,跳过本次执行");
        return;
    }
    // 执行业务逻辑
    doSomethingSlow();
} catch (Exception e) {
    log.error("业务执行异常", e);
} finally {
    // 防御性解锁:双重检查
    // 1. acquired 为 true → 确实拿到了锁
    // 2. isHeldByCurrentThread() → 锁还没过期,且属于当前线程
    if (acquired && lock.isHeldByCurrentThread()) {
        try {
            lock.unlock();
        } catch (Exception e) {
            log.error("释放锁异常", e);  // 释放锁失败不吞掉,记录日志
        }
    }
}

这里有一个值得一提的设计:如果用 tryLock 不指定 leaseTime,锁自带看门狗续期,isHeldByCurrentThread() 在正常情况下都会返回 true——因为只要你的线程在运行,看门狗就在续期。这个检查主要用来防异常情况,比如 Redis 连接断了导致锁状态无法确认、或者有人用 redis-cli 手动删了锁。


说到 Redis 分布式锁,绕不开的 RedLock

如果你去搜"Redis 分布式锁",一定会碰到 RedLock 算法——Redis 作者 antirez 提出的一个方案。这里也顺带说一下,免得你在设计文档时被问到。

问题的背景是这样的:如果你的 Redis 是主从架构,你在主节点上 SET NX EX 成功拿到锁,但主节点在把这条数据同步给从节点之前宕机了。从节点被哨兵提升为新主——它没有这条锁记录,于是另一个实例又可以拿到同一把锁。

为了解决这个问题,RedLock 的思路是:部署 N 个完全独立的 Redis 节点(没有主从关系),获取锁时向这 N 个节点依次发送 SET NX EX 命令,如果超过半数(≥ N/2+1)的节点返回成功,才算真正拿到锁。

这个方案在学术上是有争议的——分布式系统专家 Martin Kleppmann 写过一篇长文批驳它,antirez 也写了长文回应。核心争议点在于 RedLock 依赖时钟做安全性保证,而在分布式系统中,依赖时钟是一件危险的事。所以很多实际项目里(包括很多知名的中间件),直接用 Redisson 的单节点锁就够了——除非你的场景对一致性的要求极度苛刻。

对于绝大多数业务场景,Redis 单节点 + Redisson + 看门狗已经足够可靠。如果你真的遇到了"Redis 主从切换丢锁"的实际问题,再来考虑 RedLock 也不迟。


收尾

回到开头那个事故——最后解决方式很简单:在定时任务上包了一层 Redisson 的分布式锁,看门狗自动续期。实习生亲手改的代码,上线后再没出过问题。

那之后,这个实习生每次写定时任务都会自己先检查一遍有没有加分布式锁。有一回 Code Review 他还反过来问我:“哥,这个任务部署了三个实例,锁用的是 Redisson 吧?”

——带新人最欣慰的,大概就是看到他踩过的坑,变成了经验。


关于分布式锁,Martin Kleppmann 的 《How to do distributed locking》 和 antirez 的 《Is Redlock safe?》是两篇经典的对线文章,有时间值得一读。另外 Redisson 的官方文档和 GitHub 仓库源码也值得翻一翻——看门狗的具体实现在 RedissonLock.javatryLockInnerAsync()renewExpiration() 方法里,代码写得很清晰,比这篇文章讲的要更详细。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐