Spring Boot 分布式定时任务:从单机到高可用集群

目录

1、问题背景

1.1、@Scheduled

1.2、技术原理

1.3、故障场景

2、解决方案

[2.1、Redis 分布式锁](#2.1、Redis 分布式锁)

[2.2、Quartz 集群模式](#2.2、Quartz 集群模式)

[2.3、Kubernetes CronJob](#2.3、Kubernetes CronJob)

2.4、数据库唯一约束


前言:

为什么分布式定时任务成为云原生时代的刚需?

在单体架构时代,一个 @Scheduled 注解就能搞定所有定时任务。但随着微服务、容器化、Kubernetes 的普及,应用部署从"单机"走向"多副本集群",定时任务的重复执行问题日益凸显。

根据 CNCF 2023 年报告,83% 的企业已在生产环境使用多副本部署 ,而其中 67% 曾因定时任务重复执行导致数据异常(如重复发券、重复扣款)。

💡 核心矛盾Spring 的 @Scheduled是 JVM 级别的,而现代应用是集群级别的。


1、问题背景

为什么 @Scheduled在集群中会重复执行?

1.1、@Scheduled

1.开启定时任务

java 复制代码
@SpringBootApplication
@EnableScheduling // ← 关键注解!
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2.编写定时任务

java 复制代码
@Component
public class MyTask {

    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
    public void dailyReport() {
        log.info("生成日报...");
        // 业务逻辑
    }

    @Scheduled(fixedRate = 30000) // 每30秒执行一次
    public void healthCheck() {
        log.info("健康检查...");
    }
}

优点:简单、轻量、无需额外依赖;

致命缺陷每个节点都会执行!

1.2、技术原理

Spring 的定时任务基于 TaskScheduler接口 ,默认实现是 ConcurrentTaskScheduler,其底层依赖 JDK 的ScheduledExecutorService。

代码如下所示:

java 复制代码
// Spring 内部创建调度器
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(1); // 默认单线程
    return scheduler;
}
  • 每个 Spring Boot 应用启动时,都会创建独立的线程池和调度器
  • 调度信息仅存在于本 JVM 内存中,不与其他节点共享
  • 当集群有 N 个节点时,任务会被执行 N 次

🖼️ 架构示意:

节点 A: [JVM 内存] → 调度器 → 执行任务

节点 B: [JVM 内存] → 调度器 → 执行任务 ← 无感知对方存在!

节点 C: [JVM 内存] → 调度器 → 执行任务

为什么不能简单用****synchronized

  • synchronizedJVM 级别锁,无法跨进程
  • 集群中每个节点是独立 JVM,锁互不影响
  • 分布式系统必须用分布式协调机制

1.3、故障场景

  • 财务对账:每天凌晨 2 点跑批,结果对了 3 次账
  • 优惠券发放:用户收到 3 张相同券
  • 日志清理:多个节点同时删文件,导致 I/O 飙升

2、解决方案

为了解决上述分布式系统的定时任务重复问题,可以从以下方向进行治理。

2.1、Redis 分布式锁

1.核心挑战与设计原则

挑战 解决方案
死锁 设置合理过期时间(TTL)
误删锁 锁值唯一(UUID),释放时校验
锁续期 任务执行时间 > TTL 时,需自动续期
原子性 获取/释放锁必须原子操作

如下所示:

2.适用场景

  • 已有 Redis 集群
  • 任务执行时间 < 锁过期时间
  • 中小型项目,追求简单高效

3.实现步骤

1. 添加依赖

XML 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 编写分布式任务类

步骤 1:定义锁工具类

java 复制代码
@Component
@Slf4j
public class RedisDistributedLock {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取锁
     * @param lockKey 锁名称
     * @param requestId 请求ID(唯一标识)
     * @param expireTime 过期时间(毫秒)
     * @return 是否获取成功
     */
    public boolean tryLock(String lockKey, String requestId, long expireTime) {
        try {
            // SET key value NX PX milliseconds
            Boolean result = redisTemplate.execute(
                (RedisCallback<Boolean>) connection -> {
                    Object nativeConnection = connection.getNativeConnection();
                    if (nativeConnection instanceof Jedis) {
                        return ((Jedis) nativeConnection)
                            .set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
                    } else if (nativeConnection instanceof LettuceConnection) {
                        // Lettuce 实现略(生产建议统一用 Jedis 或 Lettuce)
                        throw new UnsupportedOperationException("Lettuce not supported");
                    }
                    return false;
                }
            );
            return LOCK_SUCCESS.equals(result);
        } catch (Exception e) {
            log.error("获取Redis锁异常", e);
            return false;
        }
    }

    /**
     * 释放锁(Lua脚本保证原子性)
     */
    public boolean releaseLock(String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                       "return redis.call('del', KEYS[1]) " +
                       "else return 0 end";
        try {
            Long result = redisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(lockKey),
                requestId
            );
            return result != null && result == 1L;
        } catch (Exception e) {
            log.error("释放Redis锁异常", e);
            return false;
        }
    }
}

步骤 2:实现分布式任务

java 复制代码
@Component
@Slf4j
public class DailyReportTask {

    @Autowired
    private RedisDistributedLock redisLock;

    private static final String LOCK_KEY = "lock:daily_report";
    private static final long LOCK_EXPIRE = 5 * 60 * 1000; // 5分钟

    @Scheduled(cron = "0 0 2 * * ?")
    public void execute() {
        String requestId = UUID.randomUUID().toString();
        
        if (redisLock.tryLock(LOCK_KEY, requestId, LOCK_EXPIRE)) {
            try {
                log.info("✅ 获得分布式锁,开始执行日报任务 | requestId={}", requestId);
                generateReport();
            } catch (Exception e) {
                log.error("❌ 任务执行异常", e);
                // 可选:发送告警
            } finally {
                // 释放锁
                if (redisLock.releaseLock(LOCK_KEY, requestId)) {
                    log.info("🔓 成功释放锁 | requestId={}", requestId);
                } else {
                    log.warn("⚠️ 释放锁失败(可能已过期)| requestId={}", requestId);
                }
            }
        } else {
            log.info("🔒 未获得锁,跳过本次执行");
        }
    }

    private void generateReport() {
        // 模拟耗时业务
        try {
            Thread.sleep(30_000); // 30秒
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        log.info("📊 日报生成完成");
    }
}

3.高级优化:Redlock 算法(防 Redis 单点故障)

当 Redis 是单机或主从架构时,主节点宕机可能导致锁丢失, Redis 官方推荐 Redlock 算法(需 5 个独立 Redis 实例)。

如下所示:

代码如下所示:

java 复制代码
// 使用 Redisson 实现 Redlock
RLock lock1 = redissonClient1.getLock("myLock");
RLock lock2 = redissonClient2.getLock("myLock");
RLock lock3 = redissonClient3.getLock("myLock");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
    boolean isLocked = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
    if (isLocked) {
        // 执行业务
    }
} finally {
    redLock.unlock();
}

⚠️ 注意 :Redlock 性能较低,仅在极高可靠性要求场景使用。

4.关键注意事项

  • 锁过期时间 > 任务最大执行时间(防死锁)
  • 锁值唯一 :建议用 UUID 避免误删(本例简化)
  • 异常处理:任务失败也要释放锁

2.2、Quartz 集群模式

如下所示:

1.适用场景

  • 企业级应用
  • 需要复杂调度(如动态修改 cron)
  • 要求高可靠、持久化

2.实现步骤

1. 添加依赖

XML 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

2. 配置 application.yml

bash 复制代码
spring:
  quartz:
    job-store-type: jdbc # 使用数据库存储
    properties:
      org:
        quartz:
          scheduler:
            instanceName: MyClusteredScheduler
            instanceId: AUTO
          jobStore:
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true # ← 关键!开启集群模式

3. 定义 Job

java 复制代码
public class DailyReportJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        log.info("🚀 Quartz 执行日报任务");
        // 业务逻辑
    }
}

4.注册 Job 和 Trigger

java 复制代码
@Configuration
public class QuartzConfig {

    @Bean
    public JobDetail reportJobDetail() {
        return JobBuilder.newJob(DailyReportJob.class)
            .withIdentity("dailyReportJob")
            .storeDurably()
            .build();
    }

    @Bean
    public Trigger reportTrigger() {
        return TriggerBuilder.newTrigger()
            .forJob(reportJobDetail())
            .withIdentity("dailyReportTrigger")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
            .build();
    }
}

2.3、Kubernetes CronJob

整体结构如下所示:

1.适用场景

  • 应用已容器化,运行在 K8s 集群
  • 任务独立,无需与主应用共享状态

2.YAML 示例

bash 复制代码
apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-report
spec:
  schedule: "0 2 * * *"  # UTC 时间
  concurrencyPolicy: Forbid  # 禁止并发执行
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: report
            image: your-registry/your-app:latest
            args: ["--task=daily-report"]
          restartPolicy: OnFailure

3.优势

  • 天然单实例执行
  • 与 K8s 生态无缝集成
  • 资源隔离,不影响主应用

2.4、数据库唯一约束

1.适用场景

  • 无 Redis,只有数据库
  • 任务按天/小时执行,可构造唯一键

2.表结构

sql 复制代码
CREATE TABLE task_execution (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    task_name VARCHAR(100) NOT NULL,
    exec_day DATE NOT NULL,  -- 按天去重
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE uk_task_day (task_name, exec_day)
);

3.代码

java 复制代码
@Scheduled(cron = "0 0 2 * * ?")
public void execute() {
    try {
        taskExecutionMapper.insert("daily_report", LocalDate.now());
        // 成功插入 → 本节点执行
        doBusiness();
    } catch (DuplicateKeyException e) {
        log.info("任务已被执行,跳过");
    }
}

小结:

终极建议:

  1. 优先用 Redis 分布式锁 ------ 简单可靠,覆盖大多数场景
  2. 任务必须幂等 ------ 即使重复执行也不产生副作用
  3. 监控 + 告警 ------ 任务失败/未执行要及时通知
  4. 避免在定时任务中做耗时操作 ------ 考虑异步化

记住"分布式系统的复杂性,不在于技术多高级,而在于如何用最简单的方式解决问题。"


参考文章:

1、SpringBoot定时任务@Scheduled与分布式定时任务锁@SchedulerLockhttps://blog.csdn.net/qq_34279574/article/details/120776854?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522652fb3f1df7dc5a0e3198396b41c826d%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=652fb3f1df7dc5a0e3198396b41c826d&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-120776854-null-null.142^v102^pc_search_result_base2&utm_term=%40Scheduled%E5%88%86%E5%B8%83%E5%BC%8F%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1&spm=1018.2226.3001.4187

相关推荐
28岁青春痘老男孩8 小时前
JDK8+SpringBoot2.x 升级 JDK 17 + Spring Boot 3.x
java·spring boot
天若有情6739 小时前
校园二手交易系统实战开发全记录(vue+SpringBoot+MySQL)
vue.js·spring boot·mysql
while(1){yan}9 小时前
MyBatis Generator
数据库·spring boot·java-ee·mybatis
奋进的芋圆9 小时前
DataSyncManager 详解与 Spring Boot 迁移指南
java·spring boot·后端
计算机程序设计小李同学10 小时前
个人数据管理系统
java·vue.js·spring boot·后端·web安全
Echo娴10 小时前
Spring的开发步骤
java·后端·spring
小刘爱搬砖10 小时前
SpringBoot3 + GraalVM安装和初次打包
spring boot·graalvm
追逐时光者10 小时前
TIOBE 公布 C# 是 2025 年度编程语言
后端·.net
Victor35610 小时前
Hibernate(32)什么是Hibernate的Criteria查询?
后端