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

相关推荐
千里码aicood1 分钟前
flask-基于注意力机制的异常流量检测与自动防御系统
后端·python·flask
李迟3 分钟前
Golang实践录:工程框架实践:搭建工程模板
开发语言·后端·golang
Lynko9 分钟前
C语言指针高阶
后端
the局外人9 分钟前
学习 FastAPI 的 Day 2:用异步 ORM 完成增删改查
后端·python·fastapi
SamDeepThinking14 分钟前
if嵌套最好控制在3层以内
java·后端·程序员
苏渡苇17 分钟前
Spring Insight 里上报 Span 为什么用 JDK HttpClient 而不是 RestTemplate
java·后端·spring·spring cloud·springboot·性能监控
tonydf17 分钟前
AI驱动历史项目安全审查
后端·ai编程
她的男孩33 分钟前
同一个接口,为什么销售只看到自己的订单?我拆了 681 行数据权限拦截器的源码
java·后端·架构
元界metalite35 分钟前
SpringBoot接口通用响应对象设计 - 一个 Resp 管理五种责任
后端
海宇数据36 分钟前
零信任架构实战:基于海宇运营商近3个月平均账单构建自动化分布式租赁网关
人工智能·分布式·架构·自动化