Spring Boot 定时任务为什么重复执行?从 @Scheduled、多实例部署到幂等治理

技术范围:Java 17、Spring Boot 3.x、Spring Framework 6.x、MySQL、定时调度与幂等设计

系统刚上线时,定时任务每天执行一次;部署两台服务器后,同一批订单却被处理了两遍。排查发现,代码只有一个 @Scheduled 方法,数据库也没有重复数据。这种情况究竟是调度器出了问题,还是架构设计没有考虑多实例?

先说结论:@Scheduled 默认是每个应用实例各自触发的本地调度机制,并不是跨节点唯一执行的分布式任务平台。 即使引入分布式锁,也不能把“抢到执行权”等同于“业务一定只执行一次”。真正可靠的方案至少要同时考虑调度、并发互斥、业务幂等、失败恢复与可观测性。

一、先确认:重复执行到底发生在哪一层?

常见的重复有四种,处理方式完全不同。

现象 可能原因 优先排查
同一时刻两台机器都打印任务开始 多实例各自调度 实例数、容器副本数
单实例在短时间重复触发 重复注册 Bean、重复注解、多个调度配置 Bean 定义、@Scheduled 声明
任务只启动一次,数据却写了两次 重试、消息重复、循环逻辑、并发请求 业务日志、唯一键
任务执行中途失败,恢复后再次处理同一条记录 缺少幂等和断点恢复 任务状态与数据约束

不要一看到“重复”就立刻加 Redis 锁。先给日志补齐任务标识、实例标识、批次标识和业务主键,确认问题位于哪一层。

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderTimeoutJob {

    private final OrderTimeoutService orderTimeoutService;

    @Scheduled(cron = "0 */5 * * * *", zone = "Asia/Shanghai")
    public void execute() {
        String runId = UUID.randomUUID().toString();
        log.info("任务开始 runId={}, instance={}",
                runId, System.getenv().getOrDefault("HOSTNAME", "local"));
        orderTimeoutService.processExpiredOrders();
        log.info("任务结束 runId={}", runId);
    }
}

这里的 runId 用于区分一次触发,不是业务幂等键。容器主机名也只是诊断信息,不能作为可靠的唯一实例身份。

二、@Scheduled 的 fixedRate、fixedDelay 和 cron 有什么区别?

Spring Framework 官方文档明确支持三种常用调度方式:

@Scheduled(fixedDelay = 5000)
public void byDelay() {}

@Scheduled(fixedRate = 5000)
public void byRate() {}

@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai")
public void byCron() {}
  • fixedDelay:上一次完成后,再等待指定间隔。
  • fixedRate:按固定节奏计划触发,不代表同一方法在默认调度器下一定并行执行;具体执行行为与调度器配置有关。
  • cron:按日历时间触发,建议显式声明 zone,避免容器时区变化影响生产任务。

Spring 文档还指出:@Scheduled 可以重复声明;同一方法上的多个声明彼此独立,可能重叠执行。重复初始化同一个调度 Bean,也可能造成多次注册。

如果使用虚拟线程相关的 SimpleAsyncTaskScheduler,官方特别提示 fixedDelay 的任务运行在线程调度器的单一调度线程上,需要谨慎评估吞吐。不能把“换成虚拟线程”当成解决重复任务的手段。

三、为什么部署两个实例就会跑两次?

假设订单服务部署了 A、B 两个副本:

                  02:00
                    |
            +-------+-------+
            |               |
         实例 A           实例 B
        @Scheduled       @Scheduled
            |               |
            +-------+-------+
                    |
                 订单表

每个 Spring ApplicationContext 都会注册自己的定时任务。两台实例彼此不知道对方也在执行。数据库连接的是同一个库,并不会自动让调度器变成单节点。

这不是 Spring 的缺陷,而是本地调度和分布式调度的职责边界。

四、先做最重要的一层:让业务处理具备幂等性

以“关闭超时未支付订单”为例,最稳妥的基础操作是带状态条件的更新。

UPDATE orders
SET status = 'CLOSED',
    closed_at = CURRENT_TIMESTAMP
WHERE id = ?
  AND status = 'UNPAID'
  AND expire_at <= CURRENT_TIMESTAMP;

Java 服务只在影响行数为 1 时认为本次成功完成状态转换:

@Transactional
public boolean closeIfExpired(long orderId) {
    int changed = jdbcTemplate.update("""
            UPDATE orders
            SET status = 'CLOSED', closed_at = CURRENT_TIMESTAMP
            WHERE id = ? AND status = 'UNPAID'
              AND expire_at <= CURRENT_TIMESTAMP
            """, orderId);
    return changed == 1;
}

两台机器同时执行时,数据库会根据行锁和条件重检序列化冲突更新;在常见 MySQL InnoDB 隔离配置下,只有一个更新能把该订单从 UNPAID 改为 CLOSED。另一方更新影响行数通常为 0。

注意:这只保证该状态转换不会成功两次,不保证所有后续副作用恰好执行一次。 如果后面还要发送短信、退款或发消息,应进一步使用事务 Outbox、业务唯一键和消费端幂等。

五、批量扫描也要避免重复认领

不建议先查一批订单,再逐条无条件更新:

实例 A:SELECT 找到订单 1001
实例 B:SELECT 找到订单 1001
实例 A:执行关闭
实例 B:也执行关闭

更安全的模式是:扫描候选数据,最终更新必须带原状态条件;对于需要耗时处理的任务,可以增加 PROCESSING 状态、认领人、租约到期时间和重试次数。

例如认领 SQL 可以设计成:

UPDATE task_item
SET status = 'PROCESSING',
    worker_id = ?,
    lease_until = DATE_ADD(NOW(), INTERVAL 2 MINUTE)
WHERE id = ?
  AND (status = 'PENDING'
       OR (status = 'PROCESSING' AND lease_until < NOW()));

这里仅展示认领思想,并非完整可直接上线的任务系统。生产环境还需要:认领时保存并校验单调递增的 fencing token;处理结束时校验 token;续租和失败重试;防止旧工作者在租约过期后继续提交结果。

六、加分布式锁能解决什么,不能解决什么?

分布式锁适合减少多实例重复扫描或重复执行重型任务的资源浪费,但它本身不构成业务“恰好一次”保证。

例如使用数据库维护一条任务锁记录,锁持有者才执行扫描。设计时至少考虑:

  1. 锁超时时间是否大于最坏执行时长;
  2. 任务执行时间超出锁租约后,旧实例是否仍可能写数据;
  3. 实例崩溃后锁如何过期和恢复;
  4. 网络分区期间是否可能出现两个自认为持锁的实例;
  5. 执行失败后业务能否安全重试。

锁解决的是同一时间谁优先执行;幂等解决的是重复请求是否产生重复业务效果。 两者不能互相替代。

对单次执行时间很短、结果天然幂等的扫描任务,不一定值得增加复杂分布式锁;对需要动态任务管理、持久化触发和集群协调的场景,应考虑成熟任务调度平台或 Quartz JDBC 集群方案。

七、如何防止任务“执行一半”后永久丢失?

只用 @Scheduled,进程宕机时通常没有任务级的持久化执行状态。对重要任务,建议把“待处理工作”存进数据库,而不是仅把工作保存在内存定时器中。

最小可行的数据结构:

CREATE TABLE task_item (
    id BIGINT PRIMARY KEY,
    business_key VARCHAR(128) NOT NULL,
    status VARCHAR(20) NOT NULL,
    worker_id VARCHAR(128),
    lease_until DATETIME,
    retry_count INT NOT NULL DEFAULT 0,
    UNIQUE KEY uk_task_business (business_key)
);

business_key 要根据业务确定,例如“订单号 + 业务动作类型”,不要使用每次运行都会变化的随机 UUID,否则无法去重。

数据库表只是基础设施,仍需定义超时认领、异常重试、死信/人工处理、最大重试次数与执行结果审计。

八、测试:至少模拟三种真实故障

测试 1:两个实例同时处理同一订单

准备一个已过期的 UNPAID 订单,两个线程同时调用 closeIfExpired。预期只有一个返回 true,订单最终为 CLOSED。

测试 2:执行到一半时实例被杀掉

在任务认领后模拟进程退出,等待租约过期。预期其他实例能重新认领,并且不会重复产生外部副作用。

测试 3:锁过期后旧实例继续执行

让 A 获得锁后暂停,等待租约到期;B 获得新锁并执行;恢复 A。预期旧实例的写入被 fencing token 或业务状态条件拒绝。

以上是建议的测试设计与预期行为,不是本文实际运行过的测试报告。尤其是锁过期和外部副作用场景,需要按实际组件和网络故障模型做集成测试。

九、常见问题

@Scheduled 会自动在集群中只运行一次吗?

不会。它默认注册在当前 Spring 应用上下文中,每个实例都可能触发。

加 synchronized 可以防止两个容器重复执行吗?

不能。synchronized 只约束当前 JVM 内的同步范围,无法跨进程协调。

Redis SET NX 就能保证任务恰好执行一次吗?

不能。还要处理租约、过期、续租、旧持有者写入以及业务幂等。

fixedDelay 是否天然不会并发?

它按前一次完成后的延迟安排下一次执行,但仍要结合调度器、重复声明和多实例部署判断,不应据此推导“整个集群只有一次”。

最实用的改造顺序是什么?

先补齐执行日志与业务唯一键,再将关键写操作改为条件更新;确认存在多实例资源浪费后增加协调机制;需要持久化任务管理时再考虑调度平台。

十、从 SQL 到调度代码:构建一个可验证的订单关闭任务

前面的规则可以落成一个最小案例:Spring Boot 3.x、Java 17、Spring JDBC、MySQL 8.4。

示例代码展示关键组合方式,未在本次写作中独立编译或集成测试;生产项目需要按现有事务、连接池和订单模型调整。

1. 设计一张有条件更新能力的订单表

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    status VARCHAR(20) NOT NULL,
    expire_at DATETIME(3) NOT NULL,
    closed_at DATETIME(3),
    updated_at DATETIME(3) NOT NULL
      DEFAULT CURRENT_TIMESTAMP(3)
      ON UPDATE CURRENT_TIMESTAMP(3),
    KEY idx_order_expired (status, expire_at, id)
) ENGINE=InnoDB;

status 只允许有效业务转换:UNPAID -> PAID 或 UNPAID -> CLOSED,不可让已支付订单被超时关闭任务覆盖。

2. Service 用条件更新争夺业务执行权

@Service
public class ExpiredOrderService {

    private final JdbcTemplate jdbcTemplate;

    public ExpiredOrderService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<Long> candidates(int limit) {
        return jdbcTemplate.queryForList("""
            SELECT id FROM orders
            WHERE status = 'UNPAID'
              AND expire_at <= CURRENT_TIMESTAMP(3)
            ORDER BY expire_at, id
            LIMIT ?
            """, Long.class, limit);
    }

    @Transactional
    public boolean closeOnce(long id) {
        int changed = jdbcTemplate.update("""
            UPDATE orders
            SET status = 'CLOSED',
                closed_at = CURRENT_TIMESTAMP(3)
            WHERE id = ?
              AND status = 'UNPAID'
              AND expire_at <= CURRENT_TIMESTAMP(3)
            """, id);
        return changed == 1;
    }
}

A、B 两台机器都查到 ID=1001 没关系,关键是后续的 UPDATE 只允许其中一个完成状态转换。

这并不表示“所有业务副作用恰好执行一次”。后续发消息、解锁库存、调用退款接口要单独设计事务或幂等。

3. 调度只负责触发,不持有批量长事务

@Component
public class ExpiredOrderScheduler {

    private static final Logger log =
        LoggerFactory.getLogger(ExpiredOrderScheduler.class);

    private final ExpiredOrderService service;

    public ExpiredOrderScheduler(ExpiredOrderService service) {
        this.service = service;
    }

    @Scheduled(
        cron = "0 */5 * * * *",
        zone = "Asia/Shanghai")
    public void scan() {
        String runId = UUID.randomUUID().toString();
        int closed = 0;

        for (Long id : service.candidates(200)) {
            try {
                if (service.closeOnce(id)) closed++;
            } catch (DataAccessException ex) {
                log.error("失败 runId={}, orderId={}",
                          runId, id, ex);
            }
        }
        log.info("完成 runId={}, closed={}", runId, closed);
    }
}

把 closeOnce 放进独立 Bean,是为了让事务调用经过 Spring 代理;调度循环不建议自动包成单个长事务。

即使使用多线程调度配置,每个实例也只控制自身线程:

spring:
  task:
    scheduling:
      pool:
        size: 4
      thread-name-prefix: jobs-

它不提供集群级唯一调度,不会解决跨容器重复触发。

十一、任务超过一分钟怎么办:租约认领与版本校验

处理报表、视频或外部 API 的任务,可能持续很久。此时需要持久工作项,而不是只靠 @Scheduled 一次函数调用。

1. 保存唯一业务键与认领代次

CREATE TABLE job_item (
    id BIGINT PRIMARY KEY,
    business_key VARCHAR(128) NOT NULL,
    status VARCHAR(20) NOT NULL,
    owner_id VARCHAR(100),
    lease_until DATETIME(3),
    claim_epoch BIGINT NOT NULL DEFAULT 0,
    attempts INT NOT NULL DEFAULT 0,
    UNIQUE KEY uk_job_business (business_key),
    KEY idx_job_claim (status, lease_until, id)
) ENGINE=InnoDB;

这里 business_key 应类似 order:1001:close,不能每次生成新的 UUID。

claim_epoch 是每次领取递增的版本,用来拒绝过期持有者提交结果。

2. 认领更新需要原子执行

UPDATE job_item
SET status = 'PROCESSING',
    owner_id = ?,
    lease_until = DATE_ADD(
      CURRENT_TIMESTAMP(3), INTERVAL 90 SECOND),
    claim_epoch = claim_epoch + 1,
    attempts = attempts + 1
WHERE id = ?
  AND (
    status = 'PENDING'
    OR (status = 'PROCESSING'
        AND lease_until < CURRENT_TIMESTAMP(3))
  );

影响行数为 1 才能开始处理。记录这次获得的 claim_epoch,完成时仍需要验证:

UPDATE job_item
SET status = 'DONE',
    owner_id = NULL,
    lease_until = NULL
WHERE id = ?
  AND owner_id = ?
  AND claim_epoch = ?
  AND status = 'PROCESSING';

如果 A 已过期、B 重新领取并更新了版本,A 将无法再凭旧版本将任务标记完成。

但要特别注意:版本校验只约束使用该版本的受控资源。外部短信、退款接口如果不识别幂等键,旧工作者仍可能产生副作用。

3. 续租需要有失败退出策略

UPDATE job_item
SET lease_until = DATE_ADD(
    CURRENT_TIMESTAMP(3), INTERVAL 90 SECOND)
WHERE id = ?
  AND owner_id = ?
  AND claim_epoch = ?
  AND status = 'PROCESSING'
  AND lease_until > CURRENT_TIMESTAMP(3);

续租失败意味着该工作者已经不能假设自己拥有独占资格。停止下一步处理之前,还要评估正在执行的外部请求能否取消。

MySQL 的 SELECT ... FOR UPDATE SKIP LOCKED 适用于从队列表中短事务领取一批未锁工作项:

START TRANSACTION;
SELECT id FROM job_item
WHERE status = 'PENDING'
ORDER BY id
LIMIT 20
FOR UPDATE SKIP LOCKED;

-- 在同一事务内更新领取信息
COMMIT;

事务提交后行锁释放,因此后续耗时工作仍需要租约、业务幂等或其他恢复策略。

十二、关联消息队列:为什么单靠锁还不够

订单关闭后如果要通知其他服务,数据库事务与 MQ 发送之间存在两个窗口:

数据库提交成功 → MQ 未收到:后续系统遗漏事件
MQ 收到成功 → 标记前进程宕机:可能再次发送

一种常用改进是 Transactional Outbox,在同一数据库事务中写订单状态和待发送事件。

CREATE TABLE event_outbox (
    id BIGINT PRIMARY KEY,
    event_key VARCHAR(150) NOT NULL,
    event_type VARCHAR(80) NOT NULL,
    payload JSON NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'NEW',
    UNIQUE KEY uk_event_key (event_key),
    KEY idx_event_status (status, id)
) ENGINE=InnoDB;

处理顺序应当是:

  1. 条件更新订单,仅在成功状态转换后写 Outbox。
  2. 发布器异步扫描 NEW 并发送消息。
  3. 发送确认后记录已发布状态。
  4. 消费端依照稳定事件键去重,允许重复投递。

这通常实现的是至少一次投递 + 消费端幂等,而不是跨数据库与消息系统的绝对恰好一次。

涉及退款、短信等不可随意重发的操作时,需要供应商幂等接口、状态查询或人工介入。

十三、集成测试和故障演练

1. 两个线程尝试关闭同一订单

先准备一个到期且状态为 UNPAID 的订单,再并发调用 closeOnce:

ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch go = new CountDownLatch(1);

Callable<Boolean> job = () -> {
    go.await();
    return expiredOrderService.closeOnce(1001L);
};

Future<Boolean> a = pool.submit(job);
Future<Boolean> b = pool.submit(job);

try {
    go.countDown();
    boolean first = a.get(5, TimeUnit.SECONDS);
    boolean second = b.get(5, TimeUnit.SECONDS);
    assertEquals(1, (first ? 1 : 0) + (second ? 1 : 0));
} finally {
    pool.shutdownNow();
}

这是测试结构片段,不是已在 MySQL 环境执行通过的报告;真实集成测试还应断言最终数据库状态。

2. 必须模拟的异常场景

  • A 认领成功,但尚未处理就宕机。
  • A 调用外部接口超时,服务端可能已经处理成功。
  • A 租约到期后 B 接手,随后 A 从卡顿中恢复。
  • MQ 消费者收到两次相同业务事件。
  • 任务重试次数超过上限后需要人工干预。
  • 调度触发正常但数据库积压持续增长。

每项测试都应同时核对业务最终状态、执行日志、重试次数,以及是否产生重复外部副作用。

3. 生产监控应该跟踪哪些数

指标 用途
job_trigger_total 监测各实例触发次数
job_success_total 统计业务成功次数
job_idempotent_skip_total 记录重复请求被安全跳过
job_backlog 检查待处理任务积压
job_duration_seconds 观察耗时与超时分布
job_retry_total 发现故障与重试异常

实践顺序:业务唯一键和条件更新 → 结构化日志 → 必要时协调调度 → 租约和恢复 → 跨系统幂等。

不必执着于“整个集群只允许一个 JVM 进入方法”。真正重要的是:多次触发不会造成重复业务效果,失败的工作也不会永久丢失。

总结

Spring Boot 定时任务重复执行,很多时候不是注解用错,而是系统从单实例走向多实例后,原有“只有一个进程”的隐含假设失效了。

可靠方案的顺序是:识别重复来源 → 保证业务幂等 → 控制并发认领 → 处理失败恢复 → 建立监控审计。 不要让一把分布式锁承担所有可靠性责任。

参考资料

posted @ 2026-11-05 08:47  杨凯凡  阅读(17)  评论(0)    收藏  举报