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;续租和失败重试;防止旧工作者在租约过期后继续提交结果。
六、加分布式锁能解决什么,不能解决什么?
分布式锁适合减少多实例重复扫描或重复执行重型任务的资源浪费,但它本身不构成业务“恰好一次”保证。
例如使用数据库维护一条任务锁记录,锁持有者才执行扫描。设计时至少考虑:
- 锁超时时间是否大于最坏执行时长;
- 任务执行时间超出锁租约后,旧实例是否仍可能写数据;
- 实例崩溃后锁如何过期和恢复;
- 网络分区期间是否可能出现两个自认为持锁的实例;
- 执行失败后业务能否安全重试。
锁解决的是同一时间谁优先执行;幂等解决的是重复请求是否产生重复业务效果。 两者不能互相替代。
对单次执行时间很短、结果天然幂等的扫描任务,不一定值得增加复杂分布式锁;对需要动态任务管理、持久化触发和集群协调的场景,应考虑成熟任务调度平台或 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;
处理顺序应当是:
- 条件更新订单,仅在成功状态转换后写 Outbox。
- 发布器异步扫描
NEW并发送消息。 - 发送确认后记录已发布状态。
- 消费端依照稳定事件键去重,允许重复投递。
这通常实现的是至少一次投递 + 消费端幂等,而不是跨数据库与消息系统的绝对恰好一次。
涉及退款、短信等不可随意重发的操作时,需要供应商幂等接口、状态查询或人工介入。
十三、集成测试和故障演练
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 定时任务重复执行,很多时候不是注解用错,而是系统从单实例走向多实例后,原有“只有一个进程”的隐含假设失效了。
可靠方案的顺序是:识别重复来源 → 保证业务幂等 → 控制并发认领 → 处理失败恢复 → 建立监控审计。 不要让一把分布式锁承担所有可靠性责任。

浙公网安备 33010602011771号