“我加了 synchronized“——带实习生踩过的分布式锁的坑
“我加了 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 方案优缺点
✅ 优点
- 零引入成本——项目已经在用 MySQL 的话,不需要额外维护任何中间件,直接用数据库就行。
- 强一致性——行锁由 InnoDB 引擎在事务层面严格保证,不会出现 Redis 主从切换那样的丢锁问题。
- 运维简单——DBA 日常维护的数据库,不需要学习新的运维技能。
- 阻塞等待友好——
FOR UPDATE自带排队等待,拿不到锁的事务会自动阻塞,不需要自己实现轮询。
❌ 缺点
- 性能瓶颈——每次加锁解锁都需要数据库连接和事务开销,QPS 通常只能到千级,扛不住高频加锁。
- 没有自动续期——锁过期时间设短了业务没跑完就过期,设长了进程崩溃后锁长时间不释放,做不到"动态续期"。
- 超时等待难实现——MySQL 没有原生的"等锁超时"语法(Oracle 有
WAIT 10,但 MySQL 不支持),要么用NOWAIT立即返回,要么自己实现轮询重试。 - 死锁风险——如果事务内加锁后抛出异常未提交,锁可能一直持有直到事务超时,需要额外的超时机制兜底。
一句话:并发量不高、不想引入新组件时,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 方案优缺点
✅ 优点
- 天然防死锁——临时节点机制:持有锁的客户端断开连接(进程崩溃、网络分区)后,ZK 自动删除节点,锁自动释放,不需要额外兜底。
- 公平排队——顺序节点天然实现 FIFO,先请求的先拿到锁,不会出现"某个实例一直抢不到"的饥饿问题。
- 强一致性——ZAB 协议保证集群所有节点数据一致,不存在 Redis 主从切换那样的丢锁场景。
- 监听通知——ZK 的 Watcher 可以监听锁释放事件,拿到锁的实例释放后立即通知等待方,不需要轮询。
❌ 缺点
- 性能较低——写操作需要过半节点确认(ZAB 多数派写入),单次加锁延迟比 Redis 高一个数量级。
- 吞吐量受限——单 Leader 写模型,所有写请求都由 Leader 处理,扛不住高频次加锁。
- 运维成本高——生产环境至少需要 3 个节点组成集群,引入 ZK 只为做分布式锁,代价太大。
- 客户端偏重——客户端要处理 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 加锁"。
拆开来看各选项的含义:
NX:Only 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 方案优缺点
✅ 优点
- 性能极高——Redis 纯内存操作,单节点 QPS 可达 5 万+,是三种方案里性能最好的。
- 自动续期——Redisson 看门狗机制,持有锁期间自动续期,业务跑多久锁就跟多久,不用担心锁过期。
- 可重入——Redisson 用 Hash 结构存储重入计数,同一个线程可以多次获取同一把锁,计数归零才真正释放。
- API 友好——Redisson 封装了完整的分布式锁 API(
tryLock、unlock、isHeldByCurrentThread),使用体验和本地锁几乎一样。 - 运维成本低——绝大多数项目已经有 Redis,不需要额外维护中间件集群。
❌ 缺点
- 一致性较弱——Redis 主从异步复制,主节点宕机时锁可能丢失。RedLock 理论上可以解决,但有争议且实现复杂。
- 没有严格公平性——默认是非公平锁,高竞争下可能出现"线程饿死"。Redisson 提供
getFairLock()但性能会下降。 - 依赖客户端实现——原子性(Lua 脚本)、续期(看门狗)、防御性解锁等关键安全机制都依赖客户端框架(Redisson)的正确使用,手写
SETNX很容易踩坑。 - 进程崩溃后锁有窗口期——虽然看门狗会在进程退出时停止续期,但最多仍需等待 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.java 的 tryLockInnerAsync() 和 renewExpiration() 方法里,代码写得很清晰,比这篇文章讲的要更详细。
更多推荐



所有评论(0)