连载中 8/16

分布式锁:SET NX EX 之外,还有五道坑

2026-05-19 · 8459 阅读 · 0 评论 · 0 赞

一条短信发了两遍,锁是这么丢人的

先讲个丢人的事。业务上了两个实例做高可用,结果凌晨的定时任务被执行了两遍,用户一人收到两条相同的短信。排查结论很朴素:多实例部署后,任务需要一把分布式锁,谁抢到谁执行。于是我们上了第一版锁,安静了一个月——直到第二个坑、第三个坑陆续爆出来。今天把这几个月踩过的坑一次讲完。

先说结论:一把安全的分布式锁,需要凑齐原子加锁、过期保命、唯一标识、原子解锁、续期兜底五个要素,少一个,它都会在某个凌晨还你惊喜。

第一版:SET NX EX,及格但危险

最朴素的加锁是一条命令:SET key value NX EX 10——不存在才设置(互斥),带 10 秒过期(防死锁)。注意这两件事必须合成一条命令,先 SET 再 EXPIRE 的两步写法,中间宕机就是永久死锁。

public boolean tryLock(String key, String requestId, Duration expire) {
    // 原子加锁:不存在才设置,带上过期时间防死锁
    return Boolean.TRUE.equals(
            redisTemplate.opsForValue().setIfAbsent(key, requestId, expire));
}

这版锁能防住「任务重复执行」的君子场景,但离「安全」还差得远。三个经典的坑排着队等你:

触发场景后果解法
锁过期任务没完GC 停顿、慢 SQL 拖长了执行时间两个请求同时持锁看门狗自动续期
删了别人的锁锁超时后直接 DEL第三个请求乘虚而入唯一 requestId + Lua 原子解锁
主从切换丢锁加锁未同步,主库宕机两个请求同时持锁Redlock / ZK / 业务幂等兜底

坑一:锁过期了,活还没干完

推演一下:请求 A 拿到 10 秒的锁,任务却因为一次 GC 停顿加一条慢 SQL 跑了 15 秒。第 10 秒,锁按时过期;请求 B 顺理成章拿到锁进来执行。从此 A、B 并发持锁,锁的互斥性成了摆设。把过期时间设长一点?设 1 小时,那 A 一旦宕机,所有请求陪它干等一小时,防死锁的保命符变成了定时炸弹。

正解是看门狗:加锁成功后,后台线程定期检查任务是否还在执行,活着就给锁续期。任务跑多久锁就续多久,进程挂了没人续期,锁按时过期,两个诉求同时满足。Redisson 的看门狗默认给 30 秒锁,每 10 秒续一次,业务不停续命不止。

坑二:解锁时,删掉了别人的锁

第二个坑更隐蔽:A 的锁超时了,B 拿到锁开始干活;A 终于跑完任务,走到 finally 里一句 DEL——删掉的是 B 的锁。紧接着请求 C 顺利加锁,B、C 并发执行。解法是两件套:加锁时 value 写入唯一的 requestId;解锁时不直接 DEL,而是用 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 void unlock(String key, String requestId) {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
    redisTemplate.execute(script, List.of(key), requestId);
}

为什么必须用 Lua?如果 GET 和 DEL 分两步执行,中间恰好锁过期换了主人,照样误删。Lua 脚本在 Redis 里是原子的,比对和删除之间插不进任何其他命令——这正是第 5 篇埋下的那句「解锁建议用 Lua 校验 requestId 再删」的完整答案。

坑三:主从切换,锁凭空消失

第三个坑出在架构层:Redis 主从之间是异步复制。A 刚在主库加锁成功,这把锁还没来得及同步到从库,主库宕机;从库晋升为新主库——它压根不知道有这把锁;B 到新主库加锁,一次成功。A、B 同时持锁,而且这次谁都没犯规,是架构先天缺陷。

学术界的答案是 Redlock:向 N 个互不依赖的 Redis 主库依次加锁,过半成功且总耗时小于锁有效期,才算持锁成功。不过 Redlock 自 2016 年起争论不断——分布式系统大牛 Martin Kleppmann 和 Redis 作者 antirez 打了几个回合的笔仗,核心分歧在于「锁的正确性能不能依赖各节点的时钟」。

工程上的建议是:纠结之前先问自己要什么。防重复执行的幂等场景,Redisson 单实例足够;资金级的强互斥,换 ZooKeeper 或 etcd,或者干脆在业务层做幂等兜底——锁是防线,不是保命符,关键链路永远假设锁会失效。

生产环境:别手搓,用 Redisson

三个坑对应的解法,Redisson 全都帮你实现了:看门狗自动续期、Lua 原子解锁、可重入计数,API 还长得很像本地锁。生产代码长这样:

public void executeWithLock(Long productId) {
    RLock lock = redissonClient.getLock("lock:product:" + productId);
    boolean locked = false;
    try {
        // 最多等 3 秒;不传 leaseTime 时看门狗自动续期
        locked = lock.tryLock(3, TimeUnit.SECONDS);
        if (!locked) {
            throw new IllegalStateException("手慢了,锁被别人抢走了");
        }
        doBusiness(productId);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        // 判断持有再解锁,避免对没抢到的锁误调 unlock
        if (locked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

两个容易忽略的细节:一是 tryLock 失败时不能调 unlock,所以 finally 里要先判断 locked;二是除非你明确知道任务耗时,否则别传 leaseTime——传了看门狗就不续期了,坑一又回来了。


复盘一下这五道坑:原子加锁防半路宕机,过期时间防永久死锁,唯一 requestId 防张冠李戴,Lua 脚本防原子性缺口,看门狗防任务超时——五件套齐了,锁才算真正靠谱。下一篇聊聊 Redis 自己的「生命周期管理」——过期删除与内存淘汰:key 到期了为什么不立刻消失?内存满了 Redis 会「杀」谁?八种淘汰策略怎么选?下篇见。

咖啡凉了,记得趁热喝。

503

10 年全栈工程师 · 503咖啡馆主理人

#Redis#分布式锁#Lua脚本#Redisson#Redlock

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 5 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞