Spring 定时任务与分布式锁:避免重复执行的实践
1. 问题场景:为什么定时任务会重复执行?
假设你负责一个订单系统,每天凌晨 2 点需要给未支付订单发提醒邮件。最初部署单机时,@Scheduled注解简单可靠。后来为了高可用,你把应用部署成 3 个实例,前面挂了负载均衡。你以为万事大吉,结果第二天看到发邮件服务日志里,同一批订单收到了 3 封提醒邮件——因为 3 台机器都执行了同一个定时任务。
这就是典型的定时任务重复执行问题。集群环境下,每个节点都会运行自己的调度器,如果没有协调机制,任务会像被复制了一样同时执行。而很多任务(如发送通知、生成报表、过期数据清理)并不是天然幂等的,重复执行会带来严重后果:重复扣款、重复消息、数据不一致。
你可能会想:能否通过“让哪台机器跑”来避免?但调度器并不知道彼此的存在。所以我们需要一个分布式锁:在任务开始前,所有节点竞争同一把锁,只有抢到的节点才执行;执行完释放锁,其他节点跳过或等待下次调度。
2. 核心模型:锁与任务的关系
先记住一个最小模型:每个需要全局唯一的任务,配一把分布式锁。任务执行前先尝试加锁,加锁成功才继续;执行完(或超时)释放锁。锁的持有者唯一,从而确保同一时刻只有一个实例执行。
从全局看,系统中有三个角色:
- 调度器:Spring 的 task scheduler,在每个节点上独立触发任务。
- 任务:你要执行业务逻辑的代码块。
- 分布式锁服务:负责锁的获取、释放、过期,它通常是独立组件(如 Redis、数据库)。
一次完整的流程是这样的:
- 每个节点的调度器在指定时间(如每天凌晨 2 点)触发
executeTask()。 - 任务方法首先向锁服务请求锁(带任务名和节点 IP)。
- 锁服务检查锁是否存在:若不存在,当前节点获得锁并开始执行业务逻辑;若存在且未过期,则说明其他节点已在执行,当前节点放弃执行。
- 执行完毕后,任务主动释放锁;若执行过程中崩溃,锁会在超时后自动过期。
下面用一个 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 内存库)。
步骤:
- 创建 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>- 配置数据源,并创建锁表(如果数据库是 MySQL):
CREATETABLEshedlock(nameVARCHAR(64)NOTNULL,lock_untilTIMESTAMP(3)NOTNULL,locked_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3),locked_byVARCHAR(255)NOTNULL,PRIMARYKEY(name));- 编写配置类,启用 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());}}- 编写任务类:
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());}}- 启动两个实例(端口不同),观察控制台输出。
预期输出:任何时刻只有一个实例每 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 选型对比
| 方案 | 依赖 | 是否需额外组件 | 易用性 | 可靠性 | 适用场景 |
|---|---|---|---|---|---|
| SchedulerLock | JDBC | 需要数据库(已有) | 高(注解) | 中,依赖数据库锁表 | 统一管理定时任务,不用引入 Redis |
| Redisson | Redis | 需要 Redis | 中(手动) | 高(看门狗,可重入) | 对锁灵活控制,任务耗时长,已有 Redis |
| 数据库锁(自写) | JDBC | 无额外组件 | 低(易出错) | 低(需自己处理过期) | 尝试验证,或轻量级互斥 |
7.2 锁粒度与任务拆分
不要把所有任务放在一个方法里并加一个大锁。粒度应尽量小:一个锁控制一个业务单元。例如“对账摘要生成”和“发送对账单”应分开,避免锁时间过长。
7.3 监控和告警
分布式锁解决了重复问题,但也可能引入“任务漏掉”的问题。你需要监控两点:一是任务执行的频率,二是锁的争用情况。
常见做法:
- 记录每次任务开始和结束的时间与节点,存入日志或数据库。
- 利用 Spring Actuator 暴露调度器的状态。
- 设置告警规则:如果任务超过计划时间尚未执行,或执行超时,发送告警(如通过邮件、钉钉)。
8. 常见误区
8.1 “给任务加锁就可以高枕无忧”
锁只能保证执行机会唯一,但如果业务代码有副作用(例如向外部系统发消息),还是需要幂等设计。因为锁可能过期、服务重启前未释放,对下游的影响是客观存在的。
8.2 “锁的持有时间设长一点”
太长可能导致另一节点迟迟无法接管,任务延迟。太短则可能重入。需要根据任务平均耗时和最大耗时综合分析。一般设为最大耗时的 2 倍。
8.3 “在所有节点启动同样的定时任务”
你可能会想用profile或参数禁止非主节点运行,但这样当主节点故障时,没有备能接。分布式锁更优雅,它不需要人工指定主节点。
9. 排障清单
当定时任务应该执行却没执行时,从下面顺序检查:
- 锁是否存在且未过期:查
SchedulerLock的锁表或 Redis 中的锁 key。 - 锁是否被上一节点残留:检查
lock_until或 Redis key 的 TTL。 - 调度器是否启用了
@EnableScheduling。 - 方法是否有
@SchedulerLock,且锁名是否拼错。 - Redisson 的 watch dog 是否在续期:检查日志是否有 “Renewing lock” 等。
- 多节点时钟是否正确:如果相差大,SchedulerLock 的 DB 时间和锁的判断会出错。
- 业务代码是否抛异常导致提前返回且未释放锁(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