做定时任务系统时,最容易收到的一类反馈就是:“这个任务怎么执行了两遍?”
第一反应通常是调度器出了问题:是不是同一个任务被扫描了两次?是不是多实例部署后抢到了同一条记录?是不是分布式锁失效了?
这些情况确实可能发生,但做过完整的任务链路后我发现,重复执行并不一定发生在调度阶段。消息重复投递、消费者重启、执行超时和状态回写失败,都可能让同一个任务再次进入执行流程。
如果只盯着调度器排查,很可能修了很久,最后发现调度器根本没有重复调度。
先分清:重复调度、重复投递和重复执行
一条定时任务真正跑起来,通常会经过下面几步:
调度器扫描到期任务;
生成本次执行实例;
将执行消息投递到消息队列;
消费者收到消息并调用业务逻辑;
执行完成后确认消息并更新状态。
用户看到的是“业务执行了两次”,但重复可能发生在任何一层。
例如,调度器只创建了一次执行实例,生产者发送消息后却没有及时收到 Broker 的确认。它无法判断消息到底有没有写入,于是重新发送。最终消息队列中出现两条内容相同的消息。
再比如,消费者已经完成了扣款、发券或者数据同步,但还没来得及确认消息,服务就重启了。Broker 认为这条消息没有消费成功,便把它重新投递给其他消费者。调度记录只有一条,消息也不是由调度器重复创建的,但业务仍然会再执行一次。
所以排查时首先要问的不是“调度器为什么执行了两次”,而是:
同一个任务执行实例,是在哪个环节被再次尝试的?
消息系统为什么允许重复投递?
很多任务系统会使用消息队列解耦调度与执行。调度器只负责发现到期任务并发送消息,执行器根据自身能力异步消费。这样扩容和故障隔离都更方便。
但消息队列通常很难在复杂分布式环境中同时保证“绝不丢失”和“绝不重复”。工程上常见的选择是 at-least-once,也就是一条消息至少成功处理一次。
它的优先级是避免任务丢失。只要 Broker 没有拿到明确的成功确认,就可能重新投递。因此,下面这些情况都会产生重复尝试:
-
生产者发送成功,但确认响应在网络中丢失;
-
消费者处理成功,但 ACK 发送前进程退出;
-
消费时间超过可见性超时或消费超时;
-
消费者与 Broker 短暂断连,确认状态不确定;
-
人工补偿或故障恢复重新投递了历史消息。
这并不是消息队列“不可靠”,反而是它为了不丢任务做出的取舍。如果系统要求任务一定执行,重复投递就应该被当作一种正常情况,而不是极小概率的异常。
服务重启最容易暴露问题
假设执行流程是“收到消息 → 调用第三方接口 → 更新状态 → ACK”。如果服务在调用接口后、更新状态前重启,数据库仍显示未完成,Broker 也没有收到 ACK,恢复后自然会再次执行。
而数据库事务无法回滚已经发出的短信、优惠券或第三方请求。因此,真正需要保护的是业务副作用,而不只是本地执行状态。
执行超时,不等于执行失败
超时也是重复执行的高发原因。
执行器调用下游接口,等待三秒仍未收到响应,于是认为本次失败并发起重试。但下游可能已经处理成功,只是响应返回得慢。此时第二次请求到达,下游如果没有幂等保护,就会再次处理。
这类问题不是简单的“成功或失败”,而是结果未知:请求可能没到下游,也可能已经成功但响应丢失。直接重试提高了可用性,也扩大了重复概率,所以重试必须和幂等一起设计。
分布式锁能解决吗?
分布式锁有用,但它解决的范围有限。
例如多个调度器实例同时扫描到一条任务,可以通过抢占锁、数据库条件更新或分片机制,减少同一任务被同时调度的概率。多个消费者并发处理同一执行实例时,也可以用锁限制并行进入。
但第一次执行释放锁后,重复消息仍能再次获取锁;锁租期过短还可能在任务未结束时提前失效。数据库、消息队列和第三方接口之间的副作用,也无法由一把 Redis 锁原子管理。
所以锁更适合防止同一时刻并发执行,幂等则负责保证同一业务重复进入时结果不变。两者不是一回事。
我更倾向于用“执行实例”做幂等
周期任务不能只使用 timerId 作为幂等键,因为同一个定时任务本来就要执行很多次。更合理的做法是为每次计划执行生成稳定的执行实例标识,例如:
runKey = timerId + scheduledTime
也可以直接生成 runTimerId,但重试和恢复时必须复用同一个 ID,不能每投递一次消息就生成一个新 ID。
执行器收到消息后,先尝试写入执行记录,并在数据库中为 run_key 建立唯一索引:
CREATE UNIQUE INDEX uk_run_key ON task_execution(run_key);
Java 代码可以简化为:
public void consume(TaskMessage message) {
boolean created = executionRepository.tryCreate(
message.getRunKey(), ExecutionStatus.RUNNING);
if (!created) {
return; // 相同执行实例已经进入过系统
}
try {
taskHandler.execute(message);
executionRepository.markSuccess(message.getRunKey());
} catch (Exception e) {
executionRepository.markFailed(message.getRunKey(), e.getMessage());
throw e;
}
}
这里不能只写“先查询是否存在,再插入”。两个消费者可能同时查询到不存在,然后都继续执行。最终兜底必须是数据库唯一约束,插入冲突则说明另一个实例已经抢先处理。
如果业务会调用第三方接口,最好继续把 runKey 作为幂等号传给下游。下游不支持幂等时,则要设计结果查询或补偿流程。
发现记录存在,也不能一律 return
SUCCESS 可以直接确认消息;RUNNING 既可能代表另一实例正在执行,也可能是宕机留下的僵尸状态;FAILED 则要根据错误类型和重试次数处理。因此系统通常还需要执行租约、最大重试次数和状态机。恢复任务重新接管时,也必须复用原来的 runKey。
排查重复执行,我会看这几个 ID
如果日志里只有“任务开始执行”,很难判断重复来自哪里。至少应记录 timerId、runKey/runTimerId、messageId、traceId 和 retryCount。
如果 runKey 不同,可能是调度器重复创建了执行实例;如果 runKey 相同而 messageId 不同,可能是生产端重复发送或补偿投递;如果两者都相同但消费日志出现多次,则更可能是消息重新投递或 ACK 失败。
有了这些信息,才能把“重复执行”从一个模糊现象定位到具体环节。
网硕互联帮助中心





评论前必须登录!
注册