Spring 定时任务与分布式锁:避免重复执行的实践
2026/9/12 19:36:07 网站建设 项目流程

Spring 定时任务与分布式锁:避免重复执行的实践

1. 问题场景:为什么定时任务会重复执行?

假设你负责一个订单系统,每天凌晨 2 点需要给未支付订单发提醒邮件。最初部署单机时,@Scheduled注解简单可靠。后来为了高可用,你把应用部署成 3 个实例,前面挂了负载均衡。你以为万事大吉,结果第二天看到发邮件服务日志里,同一批订单收到了 3 封提醒邮件——因为 3 台机器都执行了同一个定时任务。

这就是典型的定时任务重复执行问题。集群环境下,每个节点都会运行自己的调度器,如果没有协调机制,任务会像被复制了一样同时执行。而很多任务(如发送通知、生成报表、过期数据清理)并不是天然幂等的,重复执行会带来严重后果:重复扣款、重复消息、数据不一致。

你可能会想:能否通过“让哪台机器跑”来避免?但调度器并不知道彼此的存在。所以我们需要一个分布式锁:在任务开始前,所有节点竞争同一把锁,只有抢到的节点才执行;执行完释放锁,其他节点跳过或等待下次调度。

2. 核心模型:锁与任务的关系

先记住一个最小模型:每个需要全局唯一的任务,配一把分布式锁。任务执行前先尝试加锁,加锁成功才继续;执行完(或超时)释放锁。锁的持有者唯一,从而确保同一时刻只有一个实例执行。

从全局看,系统中有三个角色:

  • 调度器:Spring 的 task scheduler,在每个节点上独立触发任务。
  • 任务:你要执行业务逻辑的代码块。
  • 分布式锁服务:负责锁的获取、释放、过期,它通常是独立组件(如 Redis、数据库)。

一次完整的流程是这样的:

  1. 每个节点的调度器在指定时间(如每天凌晨 2 点)触发executeTask()
  2. 任务方法首先向锁服务请求锁(带任务名和节点 IP)。
  3. 锁服务检查锁是否存在:若不存在,当前节点获得锁并开始执行业务逻辑;若存在且未过期,则说明其他节点已在执行,当前节点放弃执行。
  4. 执行完毕后,任务主动释放锁;若执行过程中崩溃,锁会在超时后自动过期。

下面用一个 ASCII 图展示 3 个节点竞争的过程:

节点A (10.0.0.1) 节点B (10.0.0.2) 节点C (10.0.0.3) | | | |--加锁请求---> | | | |--加锁请求---> | | | |--加锁请求---> | 锁服务 | |--成功:执行---> |--失败:跳过--> |--失败:跳过-->

3. 方案一:SchedulerLock 库

3.1 它是什么?

SchedulerLock(net.javacrumbs.shedlock)是一个专为 Spring 定时任务设计的分布式锁实现。它声明式地提供服务:你只需要在@Scheduled方法上加@SchedulerLock注解并指定锁名称和锁持续时间,任务执行前会自动尝试获取锁。

3.2 核心机制

它通过一种“锁租约”机制工作:

  • 任务开始时,SchedulerLock 自动在持久化存储(如数据库表)记录一条锁记录,包含任务名、持有节点、到期时间。
  • 任务执行中,其他节点看到锁已存在,且lock_until时间未到,就放弃执行。
  • 任务正常结束,会删除锁记录(或更新到期时间),使锁立即释放。
  • 若任务崩溃,锁记录会残留到lock_until到期,然后被其他节点接管。

为什么不像普通锁那样“用后即删”?因为如果没有租约,崩溃的节点永远不会释放锁,任务就永远无法再次执行。租约相当于自动过期时间。

3.3 完整示例:用数据库表实现 SchedulerLock

我们先用最小示例验证核心机制:一个简单的定时任务每天打印一行字。

目标:在本地运行两个@SpringBootApplication实例,观察只有一个能执行任务。

前置环境:JDK 8+,Maven,MySQL(或用 H2 内存库)。

步骤

  1. 创建 Spring Boot 项目,添加依赖:
<dependency><groupId>net.javacrumbs.shedlock</groupId><artifactId>shedlock-spring</artifactId><version>4.42.0</version></dependency><dependency><groupId>net.javacrumbs.shedlock</groupId><artifactId>shedlock-provider-jdbc-template</artifactId><version>4.42.0</version></dependency>
  1. 配置数据源,并创建锁表(如果数据库是 MySQL):
CREATETABLEshedlock(nameVARCHAR(64)NOTNULL,lock_untilTIMESTAMP(3)NOTNULL,locked_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3),locked_byVARCHAR(255)NOTNULL,PRIMARYKEY(name));
  1. 编写配置类,启用 SchedulerLock:
importnet.javacrumbs.shedlock.core.LockProvider;importnet.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;importnet.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.jdbc.core.JdbcTemplate;importjavax.sql.DataSource;@Configuration@EnableSchedulerLock(defaultLockAtMostFor="PT1M")// 默认锁最大持有时间publicclassSchedulerConfig{@BeanpublicLockProviderlockProvider(DataSourcedataSource){returnnewJdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder().withJdbcTemplate(newJdbcTemplate(dataSource)).usingDbTime()// 使用数据库时间,避免多机时钟不一致.build());}}
  1. 编写任务类:
importnet.javacrumbs.shedlock.spring.annotation.SchedulerLock;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Component;importjava.time.LocalDateTime;@ComponentpublicclassMyTask{@Scheduled(cron="*/30 * * * * *")// 每30秒执行一次@SchedulerLock(name="myTask",lockAtMostFor="PT1M",lockAtLeastFor="PT5S")publicvoidrun(){System.out.println("执行任务 @ "+LocalDateTime.now()+" - "+Thread.currentThread().getName());}}
  1. 启动两个实例(端口不同),观察控制台输出。

预期输出:任何时刻只有一个实例每 30 秒打印一行;另一个实例在调度到时刻时会跳过执行,不会打印任何内容。

易错点

  • 必须设置@EnableSchedulerLock,否则注解不生效。
  • lockAtMostFor要大于任务最大执行时间,但不宜过长,否则锁泄漏时阻塞太久。建议设为最大执行时间的两倍。
  • lockAtLeastFor用来防止任务执行太快导致锁刚释放立刻被其他节点抢到,形成“换人执行”,但设太长会阻塞延后调度。

3.4 适用场景与边界

SchedulerLock 适用于:确保同一任务名仅有一个实例执行,且不关心“哪个实例”。它不需要额外引入 Redis,只要有一个共享数据库即可。但要注意:

  • 它依赖数据库的读写,如果数据库不可用,所有节点都无法获得锁,任务暂停。
  • 如果任务本身执行时间超过lock_until,锁会被其他节点接管,造成重入。所以lockAtMostFor必须足够大。
  • 它不能解决“多个不同任务但业务上互斥”的情况,因为锁名是任务级。

4. 方案二:Redisson 分布式锁

4.1 为什么选 Redisson?

如果你的集群已经使用 Redis,Redisson 是一个功能丰富的 Java 客户端,提供了封装好的分布式锁,支持可重入自动续期等高级特性,比手写 SETNX 更可靠。

4.2 核心机制

Redisson 的锁基于 Redis 的 Lua 脚本实现原子操作。lock()方法会向 Redis 发送一段脚本,尝试设置一个带过期时间的 key;如果成功则获得锁,失败则阻塞等待。关键在于看门狗机制:当锁未设置过期时间时,Redisson 默认给锁 30 秒过期,并启动一个后台线程每 10 秒检查一次,若锁仍被持有,则续期到 30 秒。这避免了任务未执行完锁就过期的问题。

4.3 完整示例:用 Redisson 实现非注解式锁

使用 Redisson 通常不会直接集成到@Scheduled,而是手工在方法开头加锁。这个示例展示更精细的控制,比如业务需要获取锁后执行一系列操作。

目标:模拟库存扣减,确保多实例下只有一实例能执行。

前置环境:Redis 服务运行在 localhost:6379;Spring Boot 项目引入 Redisson 依赖:

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

代码

importorg.redisson.api.RLock;importorg.redisson.api.RedissonClient;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Component;importjava.util.concurrent.TimeUnit;@ComponentpublicclassInventoryTask{@AutowiredprivateRedissonClientredissonClient;@Scheduled(fixedDelay=10000)// 每10秒执行一次publicvoiddeductInventory(){RLocklock=redissonClient.getLock("inventoryTaskLock");try{// 尝试获取锁,最多等待3秒,自动释放时间由看门狗管理(不传leaseTime)if(lock.tryLock(3,TimeUnit.SECONDS)){System.out.println("获得锁,开始扣减库存 "+Thread.currentThread().getName());// 模拟业务操作,假设耗时5秒TimeUnit.SECONDS.sleep(5);System.out.println("库存扣减完成");}else{System.out.println("未获得锁,跳过执行");}}catch(InterruptedExceptione){Thread.currentThread().interrupt();System.out.println("任务被中断");}finally{// 判断当前线程是否持有锁,防止重复释放if(lock.isHeldByCurrentThread()){lock.unlock();}}}}

预期输出:两个实例同时运行时,只有一个实例打印“获得锁”,另一个实例尝试等待 3 秒后打印“未获得锁”。注意tryLock不会一直阻塞,适合不想让调度被阻塞的场景。

易错点

  • 必须配置 Redis 连接,否则启动失败。
  • 使用tryLock时,未抢到锁不要抛异常,跳过本次即可。
  • 释放锁前要检查当前线程是否持有,否则可能释放别的线程的锁。
  • 如果不提供leaseTime,看门狗会自动续期;如果提供了,看门狗不会启动,锁会在指定时间后自动释放,可能导致业务未完成锁已失效。因此生产上通常不主动设置leaseTime

4.4 适用场景与边界

Redisson 锁适合你需要在业务代码中灵活控制锁粒度的情况,比如一个任务既要加锁,又要处理其他资源。它比 SchedulerLock 更底层,但要求引入 Redis 依赖。

5. 方案三:数据库锁(悲观/乐观)

5.1 为什么不直接用数据库?

如果你连数据库锁表都不想引入,也可以直接在数据库中实现锁。常用两种:悲观锁SELECT ... FOR UPDATE和乐观锁(版本号)。在定时任务场景,悲观锁更直白,但会长时间占用数据库连接。

5.2 核心机制与示例

以 MySQL 为例,用GET_LOCK()函数实现锁,它在同一会话内有效。但集群中不同节点之间的会话不可能共享,所以需要一个共享的表或记录。

完整示例:使用唯一键实现互斥

思路:利用数据库的唯一约束,往task_lock表插入一行包含任务名的记录。如果插入成功,说明拿到锁;插入失败(DuplicateKey)则说明其他节点持有锁。

代码

importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Component;importorg.springframework.transaction.annotation.Transactional;@ComponentpublicclassDbLockTask{@AutowiredprivateJdbcTemplatejdbcTemplate;@Scheduled(cron="0 */1 * * * ?")// 每分钟执行publicvoidrun(){StringlockName="dbTask";Stringnode=java.net.InetAddress.getLocalHost().getHostName()+"-"+Thread.currentThread().getId();// 尝试插入锁记录try{jdbcTemplate.update("INSERT INTO task_lock(task_name, lock_until, locked_by) VALUES (?, ?, ?)",lockName,newjava.util.Date(System.currentTimeMillis()+60000),node);// 插入成功,获得锁,执行业务System.out.println("获得锁,执行业务:"+node);// ...业务逻辑...Thread.sleep(2000);// 业务完成后删除锁jdbcTemplate.update("DELETE FROM task_lock WHERE task_name = ?",lockName);}catch(org.springframework.dao.DuplicateKeyExceptione){// 主键冲突,说明已有锁,跳过System.out.println("已有锁,跳过执行");}catch(Exceptione){// 处理其他异常,并确保锁能释放if(einstanceofInterruptedException){Thread.currentThread().interrupt();}// 实际应记录日志,并考虑释放锁逻辑}}}

配合建表语句:

CREATETABLEtask_lock(task_nameVARCHAR(64)PRIMARYKEY,lock_untilDATETIMENOTNULL,locked_byVARCHAR(255)NOTNULL);

关键点:插入时带lock_until字段,用于别处检查是否过期。但上面代码并没有检查过期,也就是锁在任务崩溃后到lock_until之前都不会被释放,之后的调度依然插入失败。因此需要额外的清理任务。更完善的实现应支持更新过期时间,这里只展示基本互斥。

预期输出:多个实例运行时,只有插入成功的实例打印“获得锁”,其他实例捕获异常并打印“已有锁,跳过执行”。

5.3 边界与问题

数据库锁方式最简单,但存在几个问题:

  • 锁记录需要人工清理(或使用已存在的唯一键配合过期时间)。
  • 如果业务异常导致最后没有删除锁,锁会一直存在到锁的“天然过期”更新。
  • MySQL 默认的 REPEATABLE READ 模式下,插入唯一键冲突是原子可靠的。

这个方案适合已经使用相同数据库,且对锁的可靠性要求不高的场景,但用起来最容易出错。

6. 任务幂等性设计

6.1 为什么需要幂等?

分布式锁能防止同一时刻重复执行,但有些异常情况下,锁可能提前失效,或者两个节点几乎同时获得锁(如锁服务故障),这时靠幂等可以兜底。幂等是指同一个操作执行多次与执行一次效果相同。

例如,发送邮件提醒,如果你在业务表中记录“已提醒”,那么重复执行时检查状态,就不会重复发送。

6.2 设计模式

  • 唯一键/唯一索引:处理的数据行包含业务唯一键,如订单号。插入记录前先插入去重表,重复插入报错则跳过。
  • 状态机:更新前检查当前状态,只有符合预定义状态才会更新并转移。例如,订单状态为“待支付”时才能发提醒,并更新为“已提醒”。
  • 去重表:用一张表记录已执行的批次号或业务ID。比如每天的任务批次,可记录task_date,重复执行时发现存在则跳过。

6.3 与分布式锁配合使用

锁用于控制执行机会,幂等用于控制业务影响。两者互补:即使锁没能达到预期,幂等也能兜底。

7. 工程化实践建议

7.1 选型对比

方案依赖是否需额外组件易用性可靠性适用场景
SchedulerLockJDBC需要数据库(已有)高(注解)中,依赖数据库锁表统一管理定时任务,不用引入 Redis
RedissonRedis需要 Redis中(手动)高(看门狗,可重入)对锁灵活控制,任务耗时长,已有 Redis
数据库锁(自写)JDBC无额外组件低(易出错)低(需自己处理过期)尝试验证,或轻量级互斥

7.2 锁粒度与任务拆分

不要把所有任务放在一个方法里并加一个大锁。粒度应尽量小:一个锁控制一个业务单元。例如“对账摘要生成”和“发送对账单”应分开,避免锁时间过长。

7.3 监控和告警

分布式锁解决了重复问题,但也可能引入“任务漏掉”的问题。你需要监控两点:一是任务执行的频率,二是锁的争用情况。

常见做法:

  • 记录每次任务开始和结束的时间与节点,存入日志或数据库。
  • 利用 Spring Actuator 暴露调度器的状态。
  • 设置告警规则:如果任务超过计划时间尚未执行,或执行超时,发送告警(如通过邮件、钉钉)。

8. 常见误区

8.1 “给任务加锁就可以高枕无忧”

锁只能保证执行机会唯一,但如果业务代码有副作用(例如向外部系统发消息),还是需要幂等设计。因为锁可能过期、服务重启前未释放,对下游的影响是客观存在的。

8.2 “锁的持有时间设长一点”

太长可能导致另一节点迟迟无法接管,任务延迟。太短则可能重入。需要根据任务平均耗时和最大耗时综合分析。一般设为最大耗时的 2 倍。

8.3 “在所有节点启动同样的定时任务”

你可能会想用profile或参数禁止非主节点运行,但这样当主节点故障时,没有备能接。分布式锁更优雅,它不需要人工指定主节点。

9. 排障清单

当定时任务应该执行却没执行时,从下面顺序检查:

  1. 锁是否存在且未过期:查SchedulerLock的锁表或 Redis 中的锁 key。
  2. 锁是否被上一节点残留:检查lock_until或 Redis key 的 TTL。
  3. 调度器是否启用了@EnableScheduling
  4. 方法是否有@SchedulerLock,且锁名是否拼错。
  5. Redisson 的 watch dog 是否在续期:检查日志是否有 “Renewing lock” 等。
  6. 多节点时钟是否正确:如果相差大,SchedulerLock 的 DB 时间和锁的判断会出错。
  7. 业务代码是否抛异常导致提前返回且未释放锁(Redisson 中 finally 释放)。

10. 面试/复盘问题

  • 在集群下用@Scheduled,会发生什么?如何解决?
  • SchedulerLock 与 Redisson 的锁机制有何异同?
  • 分布式锁的过期时间怎么设置?什么是看门狗?
  • 你认为分布式锁能完全避免重复执行吗?还需要什么?
  • 如果 Redis 不可用,Redisson 线程会怎样?

11. 总结

定时任务的重复执行是集群环境下的经典问题。本文从问题场景出发,介绍了三种分布式锁方案:SchedulerLock(基于数据库)、Redisson(基于 Redis)和自己写数据库锁。它们各有权衡,选择时考虑你的技术栈和运维复杂度。同时,任务是“幂等”的设计不可忽视,它是最后的兜底。最后,监控和告警是我们在工程中不能忽略的一环。希望你能根据本文的框架,在自己的项目中做出明智的选择。

12. 参考资料

  • SchedulerLock GitHub: https://github.com/lukas-krecan/ShedLock
  • Redisson 官方文档: https://redisson.org/docs/
  • Spring Framework 官方文档: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#scheduling

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询