ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redisson分布式锁实战:解决分布式定时任务重复执行

Redisson分布式锁实战:解决分布式定时任务重复执行

1. 定时任务在分布式环境下的重复执行困局

单机时代,定时任务是最省心的东西。一个@Scheduled注解,或者一个 Quartz 的 Job,到点就跑,跑完就停,从来不用操心"会不会跑两遍"这种问题。可一旦服务从一台机器扩到三台、五台,事情就变了味。同一个定时任务,三台机器上的调度器各自触发一次,业务逻辑就被执行了三遍。如果这个任务是"给用户发到期提醒短信",用户就会收到三条一模一样的短信;如果这个任务是"每天凌晨结算一次账户利息",账户余额就可能被重复累加,直接变成资损事故。

我在实际项目里见过最典型的一次翻车,是一个对账任务。原本单机跑得好好的,后来为了扛住流量,服务被横向扩到了四个实例。上线第二天财务就找过来了,说对账文件里同一笔交易出现了四次,账对不平。排查下来根因很简单:定时任务没有任何互斥机制,四个实例各跑各的。这个问题不是代码写错了,而是架构从单机演进到分布式之后,原本"默认只有一个执行者"的隐含前提被打破了。

所以分布式定时任务要解决的核心问题只有一个:在任意时刻,同一个任务只能有一个实例在执行。围绕这个目标,业界有几条主流路线。一条是引入独立的调度中心,比如 XXL-JOB、Elastic-Job 这类框架,由调度中心统一决定把任务派发给哪台机器,从源头上保证只有一个执行者。另一条是让每个实例都去抢同一把锁,谁抢到谁执行,抢不到的就跳过,这就是分布式锁方案。两条路线各有适用场景,前者适合任务量大、需要可视化管理和分片执行的复杂场景,后者适合任务数量不多、又不想额外引入一套调度系统的轻量场景。

这篇要聊的是第二条路线里最常用的一个实现:基于 Redisson 的分布式锁。Redisson 是 Redis 的 Java 客户端里封装程度最高的一个,它把分布式锁做成了RLock接口,用起来跟java.util.concurrent.locks.Lock几乎一模一样,底层却帮你处理了锁续期、可重入、原子释放这些容易踩坑的细节。如果你现在的项目里已经有 Redis,又不想为了几个定时任务专门搭一套调度中心,那 Redisson 分布式锁基本是最省事的选择。下面我会从原理、选型、代码落地、踩坑排查几个角度,把这件事讲透。

2. 为什么是 Redisson,而不是手写 SETNX 或换别的方案

2.1 手写 Redis 锁的三个致命细节

很多人第一反应是:分布式锁嘛,不就是SET key value NX EX 30加一个DEL吗,自己写十几行代码就搞定了,何必引入 Redisson 这么重的依赖。这个想法在 demo 阶段没问题,但放到生产环境,手写锁至少要面对三个绕不过去的细节。

第一个是锁的误删。假设实例 A 抢到了锁,设置的过期时间是 30 秒,结果业务执行了 35 秒。30 秒一到,锁自动过期释放,实例 B 立刻抢到了同一把锁开始执行。这时候实例 A 执行完了,执行DEL key,删掉的其实是实例 B 持有的锁。结果就是 A 和 B 同时在跑,互斥彻底失效。要解决这个问题,value 必须存一个唯一标识(比如 UUID),删除前先比对是不是自己的锁,而且比对和删除必须是原子操作,得用 Lua 脚本。

第二个是锁的续期。业务执行时间是不确定的,你没法预知它到底要跑 10 秒还是 10 分钟。过期时间设短了,业务没跑完锁就没了;设长了,万一实例宕机,锁要等很久才能释放,期间任务一直卡着。理想的做法是:只要持有锁的实例还活着,就自动给锁续期;实例一挂,续期停止,锁在过期时间后自动释放。这套"看门狗"机制手写起来相当麻烦。

第三个是可重入。同一个线程如果嵌套调用,或者任务内部又调了一次加锁逻辑,手写的锁会直接把自己锁死。要实现可重入,得在 value 里记录线程标识和重入次数,逻辑复杂度直线上升。

2.2 Redisson 把这些细节都封装掉了

Redisson 的RLock恰好把这三点都处理了。它的加锁逻辑底层是一段 Lua 脚本,用 Hash 结构存储"锁的持有者标识 + 重入次数",保证加锁、判断、计数都是原子的。释放锁同样用 Lua 脚本,先校验持有者是不是当前线程,是才递减重入次数,减到 0 才真正删除 key。至于续期,Redisson 默认开启看门狗:只要你没有显式指定leaseTime,它就会在后台起一个定时任务,每隔锁过期时间的三分之一(默认 30 秒锁,就是每 10 秒)续一次期,直到你调用unlock或者实例下线。

这里有个特别容易踩的坑,我必须提前说:看门狗只在你不指定 leaseTime 的时候生效。如果你写成lock.lock(30, TimeUnit.SECONDS),Redisson 就认为你明确知道业务多久能跑完,它会关掉看门狗,30 秒后不管业务有没有结束,锁一律释放。很多人为了"保险"随手加了个过期时间,结果反而把续期机制给关了,业务一超时就出问题。所以除非你非常确定任务的执行上限,否则加锁时不要传 leaseTime。

2.3 和其他方案的横向对比

为了让你选型时心里有数,我把几种常见方案放在一起对比一下。

方案互斥可靠性续期能力可重入引入成本适用场景
手写 SETNX低,需自行处理误删无,需自己实现无极低临时脚本、非核心场景
数据库唯一索引中,依赖 DB 可用性无无低任务粒度粗、能接受 DB 压力
数据库悲观锁中,长事务易锁表无无低低频任务
ZooKeeper 临时节点高靠会话维持支持高,需维护 ZK 集群对一致性要求极高的场景
Redisson 分布式锁高内置看门狗支持低,复用现有 Redis已有 Redis 的绝大多数场景

从表里能看出来,Redisson 的优势在于"性价比":可靠性接近 ZooKeeper,引入成本却低得多,只要项目里已经有 Redis 就能直接用。ZooKeeper 方案虽然一致性更强,但多维护一套集群的成本,对大多数中小规模项目来说并不划算。数据库方案则要小心,定时任务如果执行时间长,悲观锁会长时间占用连接甚至锁表,反而拖垮主库。

提示:选型时先问自己一个问题——项目里有没有现成的 Redis?有,优先 Redisson;没有,再评估是引入 Redis 还是上独立调度中心。不要为了一个定时任务专门去搭 ZooKeeper。

3. 从加锁到释放:Redisson 定时任务互斥的完整落地

3.1 依赖引入与客户端配置

第一步是把 Redisson 引进来。以 Maven 为例,加一个 starter 就够了:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.2</version> </dependency>

版本号建议跟着项目里 Spring Boot 的版本走,Redisson 的 starter 会自动适配 Spring Boot 的自动装配。如果你用的是 Spring Boot 3.x,注意选 3.20 以上的 Redisson 版本,低版本对 Jakarta 命名空间的支持有问题,启动时会报类找不到。

配置方面,最省事的是在application.yml里写 Redis 连接信息,starter 会自动创建RedissonClient:

spring: data: redis: host: 127.0.0.1 port: 6379 password: your-password database: 0

如果你的 Redis 是集群或者哨兵模式,YAML 配置就不够用了,得自己写一个RedissonConfig配置类,手动构造Config对象。集群模式下要特别注意scanInterval这个参数,它控制 Redisson 多久扫描一次集群拓扑变化,默认 1000 毫秒,网络抖动频繁的环境可以适当调大,减少无谓的拓扑刷新。

3.2 加锁逻辑的三种写法与取舍

Redisson 加锁有三种常见写法,效果和风险各不相同,我逐个说。

第一种是阻塞式加锁lock.lock()。抢不到锁的实例会一直阻塞等待,直到拿到锁。这种写法在定时任务里其实不太合适,因为定时任务通常是"到点触发",如果这个点没抢到锁,说明别的实例已经在跑了,当前实例应该直接跳过,而不是傻等。阻塞等待还可能造成线程堆积,任务密集时线程池会被占满。

第二种是带超时的尝试加锁tryLock(),这也是我最推荐的写法:

@Scheduled(cron = "0 0 2 * * ?") public void dailySettle() { RLock lock = redissonClient.getLock("lock:daily:settle"); boolean acquired = false; try { // 不传 leaseTime,交给看门狗续期;等待 0 秒,抢不到立即返回 acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); if (!acquired) { log.info("未获取到锁,本实例跳过本次执行"); return; } // 真正的业务逻辑 doSettle(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("加锁过程被中断", e); } finally { if (acquired && lock.isHeldByCurrentThread()) { lock.unlock(); } } }

这里有几个细节值得展开。tryLock(0, -1, TimeUnit.SECONDS)的第一个参数是等待时间,传 0 表示抢不到立刻返回,不等待;第二个参数是 leaseTime,传 -1 表示不指定,启用看门狗。isHeldByCurrentThread()这个判断非常重要,它保证只有真正持有锁的线程才去释放,避免释放别人的锁抛异常。

第三种是带 leaseTime 的加锁,前面说过,除非你确定任务执行上限,否则别用。我见过一个团队因为任务偶尔会跑超过 30 秒,又随手写了tryLock(0, 30, TimeUnit.SECONDS),结果任务跑到 35 秒时锁被释放,另一个实例进来重复执行,排查了半天才发现是 leaseTime 惹的祸。

3.3 锁的粒度与 key 的命名规范

锁的粒度决定了互斥的范围,这是设计阶段就要想清楚的事。最粗的粒度是一把全局锁,所有定时任务共用,这样虽然简单,但任务之间会互相阻塞,A 任务在跑,B 任务就得等,完全没必要。合理的做法是一个任务一把锁,key 用任务名区分,比如lock:job:dailySettle、lock:job:orderTimeout。

命名上我建议统一加前缀,比如lock:job:,这样在 Redis 里一眼就能看出哪些 key 是任务锁,方便排查和清理。千万不要用lock:1、lock:2这种无意义的命名,出问题时你根本不知道这把锁是哪个任务持有的。

还有一种更细的粒度是按业务维度分片加锁。比如"给每个城市的订单做超时处理",如果所有城市共用一个锁,那同一时刻只能有一个城市在处理,效率很低。这时候可以按城市 ID 拆锁,lock:job:orderTimeout:city_1001,不同城市并行处理,互不干扰。这种分片锁的写法在任务量大时能显著提升吞吐,但要注意分片数量不能太多,否则 Redis 里的锁 key 会爆炸式增长。

3.4 释放锁的正确姿势与异常兜底

释放锁必须放在finally里,这是铁律。但仅仅放 finally 还不够,还要处理几种异常情况。

如果业务逻辑抛了异常,finally里的unlock依然会执行,锁能正常释放,这没问题。但如果加锁成功之后、进入 try 块之前,JVM 发生了严重错误(比如 OOM),acquired变量可能还没赋值,这时候 finally 里的判断就依赖acquired的初始值 false,不会误释放。所以acquired一定要初始化为 false,并且加锁语句要放在 try 内部。

还有一种情况是 Redis 连接断了。unlock时如果连不上 Redis,会抛RedisConnectionException。这时候锁其实还在 Redis 里,只能等它自然过期。所以即使有看门狗,也建议给锁设一个足够大的兜底过期时间,防止极端情况下锁永远不释放。Redisson 的看门狗默认续期到 30 秒,如果实例彻底挂了,30 秒后锁自动释放,这个兜底是够用的。

注意:unlock()如果由非持有线程调用,会抛IllegalMonitorStateException。所以释放前务必用isHeldByCurrentThread()判断,尤其是在线程池复用的场景下,线程标识可能被复用,不加判断很容易出问题。

4. 那些文档里不会写的踩坑实录

4.1 看门狗失效的三种隐蔽原因

看门狗是 Redisson 最贴心的设计,但它失效的场景比想象中多。我整理了几种实际遇到过的。

第一种是显式指定了 leaseTime,前面反复强调过,这里不再展开。第二种是加锁和业务不在同一个线程。看门狗的续期任务绑定在持有锁的线程上,如果你用异步线程池去执行业务,加锁在主线程、业务在子线程,主线程加完锁就返回了,看门狗会认为锁已经不需要续期,直接停止。这种问题特别隐蔽,因为代码看起来完全正常,只是任务偶尔会重复执行。解决办法是让加锁和业务在同一个线程里,或者干脆用lock.lockAsync()的异步 API 配套处理。

第三种是Redis 主从切换。Redisson 的锁是写在主节点上的,如果主节点挂了,数据还没同步到从节点,从节点升为主节点后,锁就丢了。这时候另一个实例能立刻抢到锁,造成重复执行。这是所有基于 Redis 的分布式锁的固有缺陷,RedLock 算法试图解决但争议很大。对于定时任务这种场景,我的建议是业务逻辑本身要做幂等,把分布式锁当成"减少重复执行概率"的手段,而不是"绝对保证只执行一次"的银弹。比如结算任务,可以在数据库里加一个"结算批次号"的唯一索引,即使锁失效导致重复执行,第二次也会因为唯一约束而失败,不会造成资损。

4.2 任务执行时间超过锁过期时间的排查链路

有一次线上出现任务重复执行,排查过程挺有代表性,我完整复盘一下。

现象是每天凌晨的对账任务偶尔会跑两遍,不是每天都有,大概一周出现一两次。第一步先看日志,发现两个实例的日志时间戳几乎重叠,说明确实是并发执行。第二步查 Redis,发现锁 key 在任务执行到一半时就消失了。第三步看代码,加锁用的是tryLock(0, -1, TimeUnit.SECONDS),理论上应该启用看门狗。第四步查 Redisson 版本,发现是 3.11 的老版本,这个版本的看门狗在特定情况下有 bug,续期任务会因为线程池满而延迟。第五步确认任务执行时间,正常 20 秒左右,但数据量大时会到 40 秒,超过了默认的 30 秒锁过期时间。升级 Redisson 到 3.27 之后问题消失。

这个案例的教训是:看门狗不是万能的,它依赖后台线程池有空闲线程。如果你的应用线程池配置得很小,或者有大量任务同时加锁,续期任务可能排不上队。所以关键任务还是要评估执行时间上限,必要时显式设置一个足够大的 leaseTime 作为兜底。

4.3 时钟漂移与锁过期判断

Redisson 判断锁是否过期,用的是 Redis 服务端的时间,不是客户端时间,这一点设计得很好,避免了客户端时钟不一致的问题。但有一个例外:看门狗的续期周期是客户端计算的。如果客户端时钟比 Redis 慢很多,续期可能不及时;快很多,又可能频繁续期。生产环境一定要保证所有实例和 Redis 服务器的时间同步,用 NTP 服务对齐。我见过一个机房因为 NTP 没配好,几台机器时间差了十几秒,导致锁的续期行为异常,任务偶尔重复。

4.4 锁未释放导致的线程堆积

如果业务代码里有死循环,或者调用了会永久阻塞的外部接口,锁会一直被持有,看门狗会一直续期,锁永远不释放。这时候其他实例的tryLock会一直返回 false,任务永远不执行。更糟的是,如果用的是阻塞式lock(),等待的线程会越堆越多,最终把线程池撑爆。

所以定时任务的业务逻辑一定要有超时控制。调用外部接口时设置连接超时和读取超时,数据库查询设置查询超时,必要时用Future.get(timeout)包一层。宁可任务执行失败,也不能让锁被无限期持有。

5. 让互斥更稳的进阶配置与幂等兜底

5.1 合理设置锁的等待时间与重试策略

tryLock的等待时间怎么设,取决于业务对"错过执行"的容忍度。对于"每天必须执行一次"的任务,如果当前实例没抢到锁,其实不需要等待,因为抢到锁的实例会执行,任务不会漏。所以等待时间设 0 是合理的。但对于"每小时执行一次、错过就要等下一小时"的任务,可以设一个短暂的等待时间,比如 3 秒,给锁释放留一点缓冲,提高执行成功率。

重试策略上,Redisson 的tryLock内部其实有重试机制,它会按照一定的间隔反复尝试获取锁,直到等待时间耗尽。这个重试间隔是 Redisson 内部计算的,不需要我们配置。但要注意,等待时间设得太长,会占用线程资源,任务密集时反而降低整体吞吐。

5.2 用幂等设计给分布式锁上双保险

前面反复提到,分布式锁不能保证 100% 不重复执行,所以业务幂等是最后一道防线。幂等的实现方式有几种,我按可靠性从高到低排一下。

最可靠的是数据库唯一约束。给业务表加一个唯一索引,比如"任务批次号 + 业务主键",重复执行时插入会失败,直接抛异常回滚。这种方式依赖数据库,可靠性最高,但要求业务能抽象出唯一键。

其次是状态机。业务记录有明确的状态流转,比如订单从"待处理"到"处理中"再到"已完成",每次处理前先检查状态,只有"待处理"才继续。这种方式需要状态流转是单向的,不能回退。

再次是Redis 去重标记。执行前先在 Redis 里SETNX一个标记,执行完删除。这种方式性能好,但 Redis 本身可能丢数据,可靠性不如数据库。

实际项目里我通常组合使用:分布式锁做第一层拦截,数据库唯一约束做第二层兜底。两层都过了还重复,那概率已经低到可以忽略。

5.3 监控与告警:让锁的状态可见

分布式锁出问题时往往很隐蔽,所以监控很重要。我一般会关注几个指标:锁的获取成功率、锁的持有时间、看门狗续期次数。获取成功率突然下降,说明有实例一直抢不到锁,可能是某个实例的任务卡住了;持有时间突然变长,说明业务变慢,要警惕锁过期;续期次数异常增多,可能是任务执行时间在增长。

实现上,可以在加锁前后打点,把数据推到监控系统。Redisson 本身也提供了RLock的一些状态查询方法,比如isLocked()、getHoldCount(),可以在管理端做一个简单的锁状态查看页面,出问题时能快速定位是哪把锁、被谁持有、持有了多久。

提示:不要等到出事故才想起来加监控。定时任务的锁状态监控成本很低,但排查问题时能省下大量时间。建议在项目初期就把这块补上。

6. 我在实际项目里沉淀下来的几条经验

分布式锁这个东西,用起来简单,用对不容易。我自己踩过的坑和总结的经验,挑几条最实用的分享出来。

第一条,加锁代码要尽量短。从tryLock成功到unlock之间的代码越少越好,最好只包住真正的业务逻辑,日志、参数校验、结果处理这些能放外面就放外面。锁的持有时间越短,出问题的概率越低。

第二条,锁的 key 要能一眼看出归属。统一前缀加任务名,别用无意义的字符串。出问题时你在 Redis 里keys lock:job:*一下,所有任务锁一目了然。

第三条,永远不要相信分布式锁能保证绝对互斥。把它当成"降低重复概率"的工具,业务幂等才是根本。这个心态摆正了,很多设计上的纠结就迎刃而解了。

第四条,测试环境要模拟多实例。本地单实例跑,永远发现不了重复执行的问题。测试时至少起两个实例,观察日志里有没有并发执行的痕迹。有条件的可以用压测工具同时触发多个实例,验证锁的互斥效果。

第五条,升级 Redisson 版本要谨慎。Redisson 的锁实现经历过几次调整,不同版本的看门狗行为、重试策略都有差异。升级前先在测试环境验证,重点看任务执行时间和锁续期是否正常。我一般会锁定一个稳定版本,不轻易跟风升级。

最后说一个容易被忽略的点:定时任务的调度器本身也可能成为瓶颈。如果用的是 Spring 的@Scheduled,默认是单线程执行的,多个任务会排队。任务多了之后,后面的任务可能被前面的任务阻塞,导致执行时间漂移。解决办法是给调度器配置一个线程池,让不同任务并行调度。这个配置和分布式锁是两回事,但经常一起出现,值得注意。

@Configuration public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar registrar) { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); scheduler.setThreadNamePrefix("sched-"); scheduler.initialize(); registrar.setTaskScheduler(scheduler); } }

线程池大小根据任务数量来定,一般任务数加 2 就够用。设太大反而增加上下文切换开销,得不偿失。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进