如何限制Quartz Job在多节点Spring Boot应用中仅运行一次?
这是分布式定时任务场景下非常常见的问题,我给你几个经过实践验证的方案,都能满足你的核心需求——不用绑定特定节点,确保每天只生成并发送一份报表:
解决方案
方案一:基于Redis的分布式锁(推荐,可靠性高)
如果你们项目已经在用Redis,这个方案是最靠谱的。我个人在多个分布式项目里用过Redisson的分布式锁来解决这类问题,它自带的可重入特性和自动续期(看门狗)功能,完美适配定时任务的场景,能避免因为任务执行时间超过锁过期时间导致的重复执行。
- 具体实现步骤:
- 引入Redisson的Spring Boot Starter依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.24.0</version> <!-- 选择最新稳定版即可 --> </dependency> - 在你的Quartz Job类里注入RedissonClient,执行任务前先抢占锁:
@Component public class ReportJob implements Job { private static final Logger log = LoggerFactory.getLogger(ReportJob.class); @Autowired private RedissonClient redissonClient; @Autowired private ReportService reportService; @Override public void execute(JobExecutionContext context) throws JobExecutionException { // 生成唯一锁Key:任务标识+当天日期,确保每天的任务锁唯一 String lockKey = "quartz:daily-report:" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); RLock lock = redissonClient.getLock(lockKey); try { // 尝试获取锁:最多等待10秒,锁自动过期时间设为任务最长执行时间的1.5倍(比如30分钟) if (lock.tryLock(10, 1800, TimeUnit.SECONDS)) { // 成功拿到锁,执行报表生成和发送逻辑 reportService.generateAndSendReport(); } else { // 没抢到锁,直接跳过本次执行 log.info("已有节点在执行报表任务,当前节点调度跳过"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new JobExecutionException("获取分布式锁被中断", e); } finally { // 确保锁被正确释放,避免死锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } } - 注意点:锁的过期时间一定要设置得比任务最长执行时间长,Redisson的看门狗会自动给正在执行的任务续期,不用担心任务没做完锁就过期。
- 引入Redisson的Spring Boot Starter依赖:
方案二:配置Quartz原生集群模式(无需额外中间件)
Quartz本身就支持集群部署,只要所有节点共享同一个Quartz数据库,它会通过数据库的行锁机制自动协调,每次只让一个节点执行任务,完全不用手动指定节点。
- 具体配置步骤:
- 确保所有服务器的Spring Boot项目都连接同一个数据库(比如MySQL),并创建Quartz所需的系统表(Quartz官网提供了对应数据库的SQL脚本,或者开启Spring Boot的自动初始化)。
- 在
application.yml里配置Quartz集群参数:spring: quartz: job-store-type: jdbc # 使用数据库存储任务元数据 jdbc: initialize-schema: always # 首次启动时初始化表,后期可以改成never properties: org: quartz: scheduler: instanceId: AUTO # 自动生成唯一实例ID,不用手动指定 jobStore: class: org.quartz.impl.jdbcjobstore.JobStoreTX driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate isClustered: true # 开启集群模式 clusterCheckinInterval: 2000 # 节点心跳间隔,单位毫秒 useProperties: false - 配置完成后,Quartz会自动在集群中选一个节点执行调度任务,其他节点会监听任务状态,不会重复执行。优点是不用自己写锁逻辑,原生支持;缺点是依赖数据库,对数据库性能有轻微要求。
方案三:基于数据库的乐观锁(轻量,适合小型项目)
如果不想引入Redis或者修改Quartz集群配置,可以用数据库的乐观锁来实现,核心是通过更新操作的返回行数判断是否获得执行权,不用额外中间件。
- 具体实现步骤:
- 先创建一张任务执行记录表:
CREATE TABLE task_execution ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_id VARCHAR(50) NOT NULL COMMENT '任务唯一标识', execute_date VARCHAR(8) NOT NULL COMMENT '执行日期(yyyyMMdd)', status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/EXECUTED/FAILED', node_ip VARCHAR(20) COMMENT '执行节点IP', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_date (task_id, execute_date) ); - 在Job类里执行任务前,先尝试抢占执行权:
@Component public class ReportJob implements Job { private static final Logger log = LoggerFactory.getLogger(ReportJob.class); @Autowired private JdbcTemplate jdbcTemplate; @Autowired private ReportService reportService; @Override public void execute(JobExecutionContext context) throws JobExecutionException { String taskId = "daily-report-job"; String executeDate = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String nodeIp = ""; try { nodeIp = InetAddress.getLocalHost().getHostAddress(); } catch (UnknownHostException e) { log.warn("获取节点IP失败", e); } // 先插入当天的任务记录(如果不存在) jdbcTemplate.update( "INSERT INTO task_execution (task_id, execute_date) VALUES (?, ?) ON DUPLICATE KEY UPDATE task_id = task_id", taskId, executeDate ); // 尝试更新状态为EXECUTED,只有当前状态是PENDING的才会更新成功 int updateCount = jdbcTemplate.update( "UPDATE task_execution SET status = ?, node_ip = ? WHERE task_id = ? AND execute_date = ? AND status = ?", "EXECUTED", nodeIp, taskId, executeDate, "PENDING" ); if (updateCount == 1) { // 更新成功,获得执行权 try { reportService.generateAndSendReport(); } catch (Exception e) { // 执行失败,更新状态为FAILED jdbcTemplate.update( "UPDATE task_execution SET status = ? WHERE task_id = ? AND execute_date = ?", "FAILED", taskId, executeDate ); throw new JobExecutionException("报表任务执行失败", e); } } else { // 更新失败,说明已有节点执行或任务已完成 log.info("本次报表任务已被其他节点执行,当前节点调度跳过"); } } } - 注意点:可以加一个定时清理或重置任务状态的逻辑,比如定期把超过2小时的EXECUTED状态任务重置为PENDING,避免节点挂掉后任务无法重试。
- 先创建一张任务执行记录表:
额外注意事项
- 不管用哪种方案,都要确保所有节点的系统时间一致,避免因为时间差导致锁Key或执行日期不匹配。
- 针对任务失败的场景,要预留重试机制:比如分布式锁方案设置合理的过期时间,Quartz集群会自动重试,数据库乐观锁可以加状态重置逻辑。
内容的提问来源于stack exchange,提问作者user9421031




