You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何限制Quartz Job在多节点Spring Boot应用中仅运行一次?

这是分布式定时任务场景下非常常见的问题,我给你几个经过实践验证的方案,都能满足你的核心需求——不用绑定特定节点,确保每天只生成并发送一份报表:

解决方案

方案一:基于Redis的分布式锁(推荐,可靠性高)

如果你们项目已经在用Redis,这个方案是最靠谱的。我个人在多个分布式项目里用过Redisson的分布式锁来解决这类问题,它自带的可重入特性和自动续期(看门狗)功能,完美适配定时任务的场景,能避免因为任务执行时间超过锁过期时间导致的重复执行。

  • 具体实现步骤:
    1. 引入Redisson的Spring Boot Starter依赖:
      <dependency>
          <groupId>org.redisson</groupId>
          <artifactId>redisson-spring-boot-starter</artifactId>
          <version>3.24.0</version> <!-- 选择最新稳定版即可 -->
      </dependency>
      
    2. 在你的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();
                  }
              }
          }
      }
      
    3. 注意点:锁的过期时间一定要设置得比任务最长执行时间长,Redisson的看门狗会自动给正在执行的任务续期,不用担心任务没做完锁就过期。

方案二:配置Quartz原生集群模式(无需额外中间件)

Quartz本身就支持集群部署,只要所有节点共享同一个Quartz数据库,它会通过数据库的行锁机制自动协调,每次只让一个节点执行任务,完全不用手动指定节点。

  • 具体配置步骤:
    1. 确保所有服务器的Spring Boot项目都连接同一个数据库(比如MySQL),并创建Quartz所需的系统表(Quartz官网提供了对应数据库的SQL脚本,或者开启Spring Boot的自动初始化)。
    2. 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
      
    3. 配置完成后,Quartz会自动在集群中选一个节点执行调度任务,其他节点会监听任务状态,不会重复执行。优点是不用自己写锁逻辑,原生支持;缺点是依赖数据库,对数据库性能有轻微要求。

方案三:基于数据库的乐观锁(轻量,适合小型项目)

如果不想引入Redis或者修改Quartz集群配置,可以用数据库的乐观锁来实现,核心是通过更新操作的返回行数判断是否获得执行权,不用额外中间件。

  • 具体实现步骤:
    1. 先创建一张任务执行记录表:
      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)
      );
      
    2. 在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("本次报表任务已被其他节点执行,当前节点调度跳过");
              }
          }
      }
      
    3. 注意点:可以加一个定时清理或重置任务状态的逻辑,比如定期把超过2小时的EXECUTED状态任务重置为PENDING,避免节点挂掉后任务无法重试。
额外注意事项
  • 不管用哪种方案,都要确保所有节点的系统时间一致,避免因为时间差导致锁Key或执行日期不匹配。
  • 针对任务失败的场景,要预留重试机制:比如分布式锁方案设置合理的过期时间,Quartz集群会自动重试,数据库乐观锁可以加状态重置逻辑。

内容的提问来源于stack exchange,提问作者user9421031

火山引擎 最新活动