上线了一套三节点的业务集群后,某天凌晨我盯着告警群里的消息,彻底失眠了。同一份对账数据,三个节点各生成了一份;同一批营销短信,用户收到了三条。排查了一圈,代码里明明只写了一个@Scheduled(cron = "..."),为什么集群里跑出了三份?
这个场景,我相信每一个把单体应用拆成多节点部署的团队都撞见过。定时任务从单机变成集群,最典型的副作用就是“重复执行”。而解决这个问题最干净利落的手段之一,就是用 Redis 分布式锁hold住任务执行权。这篇文章不聊空泛的概念,我把整个思路、代码实现、以及上线后踩过的坑都捋一遍,给准备动手的同学一份可以直接参考的完整方案。
1. 集群定时任务重复执行的根因,以及为什么幂等救不了场
1.1 一个定时任务在集群里为什么会跑三遍
先看清楚问题本质。在单体应用时期,整个应用只有一个进程,@Scheduled的调度器是由进程内的一份数据驱动的,同一时刻、同一个 cron 表达式对应的任务,在全局看只存在一个调度实例,任务自然只执行一次。
但到了集群环境就变了。应用部署在三台机器上,等于同时跑起了三个独立的 Java 进程。每个进程里都加载了一份 Spring 容器,Spring 的ScheduledAnnotationBeanPostProcessor会在每个 JVM 里各自注册一遍调度器。也就是说,同一个@Scheduled方法,在 node-1、node-2、node-3 上会分别被触发。
举个我实际遇到过的例子:每天凌晨两点有一个批处理任务,逻辑是扫描订单表里状态为“待结算”的订单,生成结算单并推给财务系统。在单机时代,这个任务跑一次,状态更新为“已结算”,第二天扫描不到数据,自然不重复处理。
集群上线后,三个节点在同一秒同时扫描,都扫到了同一批“待结算”订单,于是生成三张重复的结算单,财务系统收到了三份相同数据。问题就这么炸了。
1.2 常规规避手段的局限性:本地锁、幂等改造、唯一约束
遇到这种情况,不少团队的第一反应不是上分布式锁,而是先用一些“看起来能行”的办法去规避。我简单拆一下它们的局限性,就明白为什么最终还得靠一把跨进程的锁。
第一个思路:加一个本进程布尔标志位。比如在任务开始前检查atomicInteger.compareAndSet(0, 1),任务结束后再恢复。这种办法在单进程内部有用,但在集群下,node-1 上的标志位管不到 node-2,三个进程各有一份标志位,依旧会同时放行。
第二个思路:把任务改造得幂等。幂等这个概念本身没错,问题在于并不是所有业务都天然幂等。像“发送短信”“扣减库存”“追加日志”这类操作,天然带副作用,做两次和做一次的结果完全不同。要改造到强幂等,往往需要引入额外的状态记录或去重表,业务的复杂度一下就上去了。
第三个思路:利用数据库唯一约束去重。比如在结算单表里加一个“任务日期+业务ID”的唯一索引,第二次插入直接失败。这个方法在某些场景下是很好的兜底,但问题也很明显:目标表不一定有天然的唯一键,强行设计一个会侵入业务表结构;而且数据库的唯一约束只在“写入”时才生效,如果任务的前半段是高开销的预处理,重复执行造成的资源浪费依然存在。
所以,要想在集群环境下保证“同一时刻只有一台机器能执行这个定时任务”,必须在所有节点都能访问的公共存储上做一把可靠的互斥锁。Redis 分布式锁,就是目前性价比最高、实现也最成熟的选择。
2. Redis 分布式锁的核心原理:三个细节决定锁是否可靠
2.1 锁的本质:从 JVM 内存到公共存储的互斥标记
单机场景下的synchronized或ReentrantLock,本质是在 JVM 堆内存的对象头(或 AQS 队列)里做一个状态标记,同一个 JVM 里的所有线程通过读取这个内存状态来互相排斥。
分布式锁的思路一模一样,只是把“做标记”的地点从每个进程私有的内存,换成了所有进程都能访问的一个公共存储——比如 Redis。谁能在 Redis 里写下一个独占标记,谁就获得了任务的执行权;任务执行完,把标记删掉,其他人才能重新写标记。
这里面有三件事必须想清楚:
- 这个标记要有过期时间,防止持有者意外宕机导致死锁;
- 这个标记要能识别持有者身份,防止删掉别人的锁;
- 检查和删除这两个动作必须是原子的,不能中间被插队。
2.2 从 SETNX 到 SET NX EX:过期时间必须一步到位
早期实现 Redis 分布式锁,最常看到的写法是先用SETNX设置键,成功后再用EXPIRE给它加过期时间:
SETNX lock_key 1 EXPIRE lock_key 30这个两步写法存在一个非常隐蔽的原子性问题:如果SETNX执行成功后,进程在EXPIRE执行前崩溃了,这个锁的键会永远存在 Redis 里,所有节点再也抢不到锁,任务彻底挂死。
正确做法是使用 Redis 2.6.12 之后支持的SET命令扩展参数,把加锁和设置过期时间合并成一条原子命令:
SET lock_key unique_value NX EX 30NX表示只有键不存在时才设置成功,这是互斥的关键;EX 30表示锁的过期时间是 30 秒,防止持有者崩溃后锁不释放。
在 Java 的 Spring Data Redis 里,对应的方法是RedisTemplate.opsForValue().setIfAbsent(key, value, Duration)。这个方法底层就是走SET key value NX EX,是原子操作。
2.3 线程标识与 Lua 脚本:防止“删掉别人的锁”
加了过期时间后,又会出现一个新的经典坑:锁的持有者 A,因为业务执行时间超过了锁的过期时间,锁自动失效了。这时节点 B 抢到了锁,开始执行业务。A 终于跑完了,执行删除锁的逻辑——如果这里只是简单地DEL lock_key,它删除的是 B 的锁。B 还在处理数据,C 看到锁没了又抢进去,三个节点同时干活,集群任务重复执行的灾难再次降临。
解决办法是给锁的 value 设一个唯一的客户端标识(比如 UUID + 线程 ID),删除前先比较 value 是否等于自己当初写入的值,相等才删。而且,这个“比较+删除”两个步骤必须是原子的,否则还是会有竞态窗口。
最终落地用的是一段 Lua 脚本,因为 Redis 会原子地执行整个脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这段脚本把“判断持有者身份”和“删除键”放进同一个原子操作里。如果值匹配,删除成功返回 1;不匹配说明锁已经不是你的,返回 0,什么都不做。只有这样才能彻底杜绝误删锁的问题。
3. 可直接抄作业的实现:RedisTemplate + Lua 脚本 + 注解切面
3.1 引入依赖与基础配置
如果项目是基于 Spring Boot,最常用的客户端依赖有两个选择:spring-boot-starter-data-redis默认集成的 Lettuce,或者手动引入 Jedis。这里以spring-boot-starter-data-redis为例,在pom.xml里加上:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>然后在application.yml里配置 Redis 连接:
spring: redis: host: 10.0.0.10 port: 6379 password: your_password timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0这里有个小建议:连接池不要配得太大,定时任务的锁操作频率很低,8 个活跃连接完全够用。如果压得太深,反而会占用 Redis 的连接资源。
3.2 手写一个分布式锁工具类
我习惯把锁操作封装成一个独立的 Service,业务代码里不用直接跟 RedisTemplate 打交道,便于日后替换底层实现。
@Component public class RedisLockService { private final StringRedisTemplate redisTemplate; public RedisLockService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } // 解锁用的 Lua 脚本,保证判断和删除的原子性 private static final String UNLOCK_LUA = "if redis.call(\"get\", KEYS[1]) == ARGV[1] then " + "return redis.call(\"del\", KEYS[1]) " + "else return 0 end"; /** * 尝试获取锁,不阻塞 * @param lockKey 锁的键,比如 "task:settlement:20250112" * @param lockValue 唯一标识,通常用 UUID + 线程 ID 拼接 * @param expireMillis 锁过期时间,毫秒 * @return true 表示获取成功 */ public boolean tryLock(String lockKey, String lockValue, long expireMillis) { return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent( lockKey, lockValue, Duration.ofMillis(expireMillis))); } /** * 阻塞式获取锁,带超时控制 * @param lockKey 锁的键 * @param lockValue 唯一标识 * @param expireMillis 锁过期时间 * @param waitMillis 最大等待时间,超过返回 false * @return true 表示获取成功 */ public boolean tryLockWithWait(String lockKey, String lockValue, long expireMillis, long waitMillis) throws InterruptedException { long deadline = System.currentTimeMillis() + waitMillis; while (System.currentTimeMillis() < deadline) { if (tryLock(lockKey, lockValue, expireMillis)) { return true; } Thread.sleep(50); // 简单自旋,实际项目可以换成更优雅的调度策略 } return false; } /** * 释放锁 */ public boolean unlock(String lockKey, String lockValue) { Long result = redisTemplate.execute( new DefaultRedisScript<>(UNLOCK_LUA, Long.class), Collections.singletonList(lockKey), lockValue ); return result != null && result == 1L; } }几点说明:
lockValue必须是每次加锁时唯一生成的,最简单的方式是UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();setIfAbsent走的是 Redis 的SET NX EX原子命令,不存在两步操作的问题;- 解锁统一走 Lua 脚本,防止误删。
3.3 用注解 + AOP 把锁声明化
拿到锁工具类之后,如果每个定时任务都手写tryLock/finally/unlock,代码会啰嗦而且容易漏掉 finally。我建议做一个声明式注解,让业务代码只关注“要锁哪个 key”。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DistributedLock { /** 锁的 key,支持 SpEL 表达式,比如 "#taskId" */ String key(); /** 锁过期时间,毫秒 */ long expireMillis() default 30000; /** 等待时间,毫秒,超过则放弃执行 */ long waitMillis() default 0; }再写一个切面类:
@Aspect @Component public class DistributedLockAspect { private final RedisLockService lockService; public DistributedLockAspect(RedisLockService lockService) { this.lockService = lockService; } @Around("@annotation(distributedLock)") public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { String lockKey = parseKey(joinPoint, distributedLock.key()); String lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId(); boolean locked = false; try { if (distributedLock.waitMillis() > 0) { locked = lockService.tryLockWithWait(lockKey, lockValue, distributedLock.expireMillis(), distributedLock.waitMillis()); } else { locked = lockService.tryLock(lockKey, lockValue, distributedLock.expireMillis()); } if (!locked) { return null; // 没抢到锁,直接放弃执行 } return joinPoint.proceed(); } finally { if (locked) { lockService.unlock(lockKey, lockValue); } } } private String parseKey(ProceedingJoinPoint joinPoint, String key) { // 这里简化处理,实际项目可以引入 Spring Expression (SpEL) 解析参数 // 比如 key = "#taskId",从第一个参数里取值 if (key.startsWith("#") && joinPoint.getArgs().length > 0) { Object firstArg = joinPoint.getArgs()[0]; if (firstArg != null) { return key.replace("#", String.valueOf(firstArg)); } } return key; } }3.4 定时任务里加一行注解,搞定
有了注解,定时任务的代码就非常清爽了。以“订单结算”为例:
@Service public class SettlementTask { private final SettlementService settlementService; public SettlementTask(SettlementService settlementService) { this.settlementService = settlementService; } @Scheduled(cron = "0 0 2 * * ?") @DistributedLock(key = "task:settlement", expireMillis = 60000, waitMillis = 0) public void settleOrders() { // 只有抢到锁的节点才会进入这里 settlementService.settlePendingOrders(LocalDate.now()); } }三个节点同时触发这个定时任务时,node-1 先把task:settlement这个 Redis 键写成功了,node-2 和 node-3 的setIfAbsent返回 false,直接跳过。等 node-1 执行完,finally 里删掉锁,第二天同一时间再把执行权交给下一个抢到的节点。
这套封装在实际项目里用了很久,稳定可靠。简单业务场景到这里已经完全够用了。但如果你把系统推到更高的并发或更严格的可用性要求下,接下来的几个坑就不得不提前看一遍。
4. 真实环境最容易踩的三个坑:锁过期、主从切换、GC 停顿
4.1 锁自动过期,但业务还没跑完
我前面提到过这个场景的隐患,这里展开讲一下它真实发生的概率。
设定锁过期时间是 30 秒。假设某个批处理任务涉及的数据量突然暴涨,或者依赖的第三方接口在凌晨恰好变慢,最坏情况下,一个完整流程跑了 45 秒。第 30 秒时锁自动失效,node-2 抢到了锁,也开始跑同样的任务。node-1 在第 45 秒跑完后执行解锁,发现锁的 value 不是自己的,放弃删除——但此时 node-2 已经跟 node-1 重复处理了一批数据。
这个问题有几个方向可以缓解,没有银弹:
- 锁过期时间设置得足够长:比如根据历史最慢执行时长的 2~3 倍来设置。宁可出现“任务崩了锁迟迟不释放”,也不要出现“业务没跑完锁先没了”。前者最多是任务暂停一段时间,后者是直接重复执行。
- 定时续期(看门狗模式):持锁线程在锁快过期时主动续期,类似租约续签。自研实现也不复杂:拿到锁后启动一个后台线程,每隔
expireMillis / 3时间检查一次业务是否还在运行,是就把锁的过期时间重置。业务结束后记得把这个线程停掉,否则会一直续期到天荒地老。 - 业务天然幂等 + 分布式锁作为前置挡板:如果业务无论如何都会重复执行,至少让重复执行的结果一致。这是最后一道防线。
4.2 Redis 主从切换:锁丢了,两个节点同时拿到锁
如果说锁过期是频率较高的坑,那主从切换就是最隐蔽的坑。
标准 Redis 主从部署模式下,主节点负责写,从节点异步复制数据。假设流程是这样的:
- node-1 向 Redis 主节点写入了锁
task:settlement; - 主节点还没来得及把这条数据同步给从节点,主节点宕机了;
- Sentinel 检测到主节点挂了,把从节点提升为新主节点;
- node-2 向新主节点写锁,发现键不存在,写入成功;
- 此时 node-1 和 node-2 同时持有锁,定时任务重复执行。
这就是分布式锁在 Redis 主从架构下的经典可用性问题。业界有 RedLock(红锁)方案,思路是向多个独立的 Redis 节点同时加锁,超过半数成功才算加锁成功,借此降低单点故障带来的锁丢失风险。但 RedLock 本身在分布式系统界争议很大,运维成本也不低,很多团队不会轻易上。
我个人的实际做法是组合拳:
- 如果容忍度允许,给锁的过期时间设置一个相对较大的值,降低主从切换时锁未被复制的概率;
- 更关键的是给业务操作增加“幂等兜底”——比如结算单表加唯一约束、消息推送前查重,即使锁丢了,重复执行的数据也能被拦截;
- 在监控上盯住 Redis 主从切换事件,切换发生后对定时任务做一次人工巡检。
4.3 长时间 GC 停顿:持有者被“冻结”,锁却已到期
这个坑比主从切换还隐性,但真实发生过。我把排查过程记录下来,非常有参考价值。
有一段时间,凌晨某个定时任务偶尔会重复。查日志发现,node-1 明明持有锁并且业务也在跑,但 node-2 照样拿到了锁。两台机器都没有异常报错,锁也没有被手动删除。
深入排查后发现,node-1 上有个大对象在下半夜触发了 Full GC,STW(Stop The World)时间长达 40 秒。这 40 秒里,node-1 的业务线程被暂停,但锁的过期时间只有 30 秒,于是锁在 GC 期间悄悄失效了。等 GC 结束,node-1 的线程继续执行,node-2 已经趁虚而入。
这个问题比单次锁过期更难受,因为业务线程“并没有死”,只是被暂停了,你无法用简单的“任务执行完毕才解锁”逻辑去规避。
应对思路有这么几条:
- 尽量避免长时间 Full GC:检查 JVM 参数、堆内存设置、是否存在内存泄漏,把 STW 时间压在锁过期时间之内;
- 缩短锁租约 + 看门狗续期:如果业务线程被 STW 卡住了,看门狗线程同样会被卡住,续期也会中断,所以看门狗不是万能的;
- 锁过期时间放宽到远超预期 GC 时间:在大多数业务系统里,把锁过期时间设为 2~5 分钟,GC 的影响就能被基本吸收掉;
- 给定时任务配置独立的 JVM 或专用节点:将高开销任务隔离,减少它与其他业务互相触发 GC 的可能性。
5. 自研锁还是 Redisson:不吹不黑的选型建议
5.1 自研锁在什么情况下够用
上面这套 RedisTemplate + Lua 脚本的自研实现,在大多数中小型项目的定时任务场景下是完全够用的。给出的判断标准有三个:
- 节点数量不多(比如 3~5 个),运维上可以接受“偶尔一次主从切换带来的重复”;
- 任务有业务幂等兜底,即使锁失效,也不会产生严重的脏数据;
- 对锁过期、续期等极端场景容忍度高,出现问题时可以接受人工干预。
在这类场景里,引入一个重量级分布式锁框架反而增加维护成本。我见过不少团队连自研都不用,直接靠数据库的唯一约束就扛过去了,也能稳定运行很久。
5.2 Redisson 带来的关键能力:看门狗续期与更全面的锁类型
如果系统对分布式锁的可靠性有更高要求,同时又不想自己维护看门狗线程和一堆边界逻辑,Redisson 是当前最成熟的选择。
Redisson 的核心优势有两个:
第一个是看门狗(watch dog)自动续期。调用lock()方法后,Redisson 会启动一个监听任务,锁的默认租约时间是 30 秒,它会每隔 10 秒检查一次锁是否仍被持有,如果是就自动续期到 30 秒。业务跑多久,锁就续多久,业务结束自动释放,彻底绕开了“锁先于业务过期”的问题。
代码写起来也非常简单:
@Scheduled(cron = "0 0 2 * * ?") public void settleOrders() { RLock lock = redissonClient.getLock("task:settlement"); try { // 尝试加锁,最多等待 5 秒,锁有效期 30 秒,到期自动续期 if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { settlementService.settlePendingOrders(LocalDate.now()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }第二个是锁类型的全面性。除了最基础的可重入锁,Redisson 还提供了公平锁、读写锁、信号量、闭锁等,如果将来业务不仅需要“抢一个执行权”,还要控制并发读取数量、多节点协同等,Redisson 都能直接满足,不必再造轮子。
5.3 两个方案的核心对比
| 对比项 | 自研 Redis 锁 | Redisson |
|---|---|---|
| 加锁/解锁原子性 | 依赖 SET NX EX + Lua 脚本,需自行实现 | 框架内置,源码成熟 |
| 锁过期自动续期 | 需自行实现看门狗线程 | 内置 watch dog,默认自动续期 |
| 防误删 | 需在 value 中携带唯一标识 | 内置,底层同样用 Lua 保证 |
| 可重入 | 需自行维护计数 | 支持可重入锁 |
| 公平锁/读写锁 | 需自行实现 | 开箱即用 |
| 额外依赖 | 仅依赖 Redis | 需引入 Redisson 客户端 |
| 学习成本 | 较低,代码透明 | 中低,API 封装完善 |
| 极端场景(主从切换) | 需业务幂等兜底 | 同样不能完全规避,需自行权衡 |
5.4 我的个人建议:两阶段演进
我的习惯是分两步走。
第一步,项目初期直接采用自研实现。原因很简单:代码可控、逻辑透明、没有多余的依赖,而且大部分业务在这阶段根本遇不到锁过期的极端情况。
第二步,当系统出现以下任一信号,果断迁移到 Redisson:
- 定时任务耗时超过锁过期时间的次数开始增多;
- 频繁因为 GC、网络延迟导致锁提前失效;
- 业务需要锁的多种语义(公平、读写、可重入);
- 团队对分布式锁的稳定性开始有硬性指标要求。
迁移成本并不高,因为 Redisson 的 API 几乎可以跟自研锁的服务层方法一一对应。如果前期就把锁操作做了 Service 封装,替换的时候只需要动 Service 内部实现,业务代码一行都不用改。
6. 不抢锁也能防重复:数据库约束、调度框架与分片的另类解法
6.1 数据库唯一约束:最朴素的兜底手段
分布式锁解决的是“执行权互斥”,而数据库唯一约束解决的是“结果唯一”。两者并不冲突,反而是很好的互补关系。
对于批处理类任务,如果业务天然需要生成一条结果记录(比如对账单、汇总表、结算单),可以设计一张支撑表,把“业务日期 + 节点标识(或批次号)”作为唯一索引。任务执行时先试着插入这条记录,插入成功说明当前节点拿到了“写入权”,继续执行后续逻辑;插入失败说明其他节点已经在处理了,直接退出。
这个方案的好处是极其简单,不需要额外引入 Redis,也不怕主从切换丢锁,因为数据库事务的一致性比 Redis 的异步复制稳固得多。缺点是只能控制“写入结果”的环节,无法避免重复执行期内的高开销预处理。
6.2 定时任务框架自身提供的集群模式
有些成熟的调度框架本身就内置了集群防重机制,不需要你单独写锁逻辑。
比如 Quartz 的集群模式,它的原理是在数据库里维护一张 TRIGGER 表,多个调度实例通过数据库行锁来竞争同一个触发器。当一个节点拿到锁并调度任务时,其他节点的调度器会阻塞等待。这个方案简单可靠,但存在两个明显短板:一是数据库成为调度链路的瓶颈,高频率短周期任务会放大这个问题;二是它管的是“调度器层面”的互斥,跟业务数据的分布式锁不在同一个维度,如果你已经在用@Scheduled,引入 Quartz 改造代码的代价不小。
另外一类思路是 ElasticJob 这类生态。它把任务拆成多个分片,每个节点只处理自己负责的那一片数据。因为分片是明确的,不同节点各干各的活,天然就不会重复。这个思路下面单独讲。
6.3 把“全局互斥”改为“分片并行”,从根上规避重复
很多时候,定时任务重复执行的痛点不在“执行权”,而在于任务的输入范围没有明确划分。三个节点跑同一个全量扫描任务,互相不知道对方扫了哪些数据,自然就重复了。
如果业务数据量较大,合理的做法是引入分片机制。比如按用户 ID 的哈希范围分成三片:node-1 处理前 1/3,node-2 处理中间 1/3,node-3 处理最后 1/3。每个节点只能处理自己分片内的数据,相互之间没有交集,重复执行的问题从根上消失了,同时并发度还提升了三倍。
分片方案和分布式锁并不是对立关系。在 ElasticJob 这类框架里,分片的分配过程本身就需要分布式协调,底层的选主、分片变更通知等机制仍然依赖 ZooKeeper 或 Redis 等组件来保证一致性。
所以在实际项目里,我的习惯是先判断任务类型:
- 低频、全量、冷启动型任务(如每日账单生成、数据归档):用 Redis 分布式锁就足够了;
- 高频、碎片化、数据量大型任务(如按渠道同步订单、按地区刷新缓存):优先考虑分片,每个节点各跑各的,再用分布式锁做分片变更的保护。
这两种思路组合起来用,比单纯把锁写到业务里优雅得多。
这套方案前后在我手里经历过两次大改,第一版就是纯 RedisTemplate 自研锁,后来因为一次凌晨的 GC 抖动踩了锁过期的坑,才逐步把业务改成分片执行 + Redisson 兜底。回过头来总结,分布式锁在定时任务场景里解决的从来不只是“技术问题”,它逼迫你想清楚三个问题:任务能不能设计成天然幂等,锁的过期时间是否对得上真实耗时,以及在极端故障下你到底能容忍多少重复。把这几个问题想透了,代码怎么写都是次要的。