背景 / 现象
在一次 AI 后台任务调度系统的生产环境排查中,我们发现定时触发的任务虽然在调度器中显示“调度成功”,但实际并未执行。用户侧表现为任务状态长期停留在“已调度”,日志中无任何执行记录。该问题在低峰期偶发,高峰期重现率上升,影响多个业务模块的异步处理链路。
系统架构上,任务调度采用中心化调度器 + 分布式执行器的模式。调度器负责触发任务并更新状态,执行器通过消息队列拉取任务并执行。问题出现时,调度器日志显示任务已下发,消息队列监控显示消息已投递,但执行器端无消费记录。
问题拆解
我们将问题拆解为四个关键链路节点:
- 调度器状态更新:任务是否真正被标记为“已调度”?
- 消息投递:任务消息是否成功进入消息队列?
- 消息消费:执行器是否成功拉取并处理消息?
- 执行反馈:执行结果是否回写至调度器?
通过日志比对,我们确认调度器状态更新正常,消息队列监控显示消息已入队,但执行器消费日志缺失。问题集中在“消息消费”环节。
根因分析
进一步排查发现,执行器在高峰期出现消费延迟,部分消息在队列中积压超过 30 秒。深入分析执行器日志,发现以下现象:
- 执行器线程池满,新任务被拒绝;
- 拒绝策略为
CallerRunsPolicy,导致调用线程(即消息消费线程)直接执行任务; - 任务执行耗时较长(平均 8-12 秒),阻塞消费线程;
- 消费线程被阻塞后,无法继续拉取新消息,形成恶性循环。
根本原因在于:执行器线程池配置不合理,且拒绝策略选择不当,导致消费链路被任务执行阻塞,消息积压,最终表现为“调度成功但未执行”。
此外,监控缺失加剧了问题:执行器未暴露线程池状态指标,调度器也未感知执行器负载,导致问题无法提前预警。
实现方案
1. 线程池配置优化
调整执行器线程池参数,避免消费线程被任务执行阻塞:
// 原配置:核心线程数 10,最大线程数 20,队列容量 100,拒绝策略 CallerRunsPolicy // 问题:CallerRunsPolicy 导致消费线程执行任务,阻塞消息拉取 // 新配置:核心线程数 20,最大线程数 50,队列容量 200,拒绝策略 AbortPolicy ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); executor.setMaxPoolSize(50); executor.setQueueCapacity(200); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.setThreadNamePrefix("task-executor-"); executor.initialize();2. 拒绝策略降级机制
引入降级策略,当线程池满时,将任务重新投递至“延迟重试队列”,避免直接丢弃:
public class RetryRejectHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (r instanceof TaskRunnable) { Task task = ((TaskRunnable) r).getTask(); // 投递至延迟队列,5秒后重试 delayQueue.send(task, 5000); } } }3. 可观测性增强
- 暴露线程池指标(活跃线程数、队列大小、拒绝次数)至 Prometheus;
- 调度器增加执行器负载感知,当执行器队列积压超过阈值时,暂停新任务下发;
- 增加任务执行耗时监控,识别长尾任务。
4. 状态机兜底巡检
设计定时巡检任务,扫描“已调度”状态超过 60 秒的任务,自动触发重试或告警:
-- 巡检 SQL SELECT * FROM tasks WHERE status = 'SCHEDULED' AND scheduled_at < NOW() - INTERVAL 60 SECOND;巡检结果通过告警系统通知运维,并支持手动重试。
风险与边界
风险点
- 延迟重试队列的可靠性:若消息中间件故障,重试任务可能丢失。需确保队列持久化与 ACK 机制。
- 线程池扩容成本:最大线程数提升至 50,需评估服务器资源是否支撑。
- 巡检任务性能影响:高频扫描可能影响数据库性能,需控制扫描频率与索引优化。
边界条件
- 任务执行超时:设置任务超时时间(如 30 秒),超时后强制中断并标记失败;
- 执行器宕机:调度器需感知执行器存活状态,避免任务下发至不可用节点;
- 消息重复消费:执行器需实现幂等处理,防止重试导致重复执行。
总结
本次故障暴露了任务调度系统中“调度”与“执行”链路的解耦不足,以及执行器资源管理的盲区。通过优化线程池配置、引入降级重试机制、增强可观测性与兜底巡检,我们构建了一个更健壮的任务执行体系。
核心经验:
- 调度器与执行器应职责清晰,避免消费线程被任务执行阻塞;
- 拒绝策略需结合业务场景选择,避免“看似可用实则阻塞”的策略;
- 监控必须覆盖执行链路的关键指标,尤其是线程池状态与消息积压;
- 兜底机制是稳定性的最后防线,巡检与重试不可或缺。
技术补丁包
线程池拒绝策略选型 原理:CallerRunsPolicy 将任务交由调用线程执行,适用于低负载场景;AbortPolicy 直接抛出异常,需配合重试机制。 设计动机:避免消费线程被任务执行阻塞,保障消息拉取连续性。 边界条件:AbortPolicy 需配合外部重试队列,否则任务丢失。 落地建议:生产环境优先使用 AbortPolicy + 延迟重试队列,避免 CallerRunsPolicy。
执行器负载感知设计 原理:调度器通过监控执行器线程池队列大小,动态控制任务下发速率。 设计动机:防止执行器过载导致消息积压,实现流量削峰。 边界条件:需定义合理的积压阈值(如队列大小 > 80%),避免误判。 落地建议:结合 Prometheus 指标与调度器逻辑,实现动态限流。
任务状态机兜底巡检 原理:定时扫描长时间未执行的任务,触发重试或告警。 设计动机:弥补消息丢失或执行器故障导致的“假成功”问题。 边界条件:巡检频率不宜过高,避免数据库压力;需加索引优化查询性能。 落地建议:使用独立线程池执行巡检,避免影响主业务逻辑。
消息幂等处理机制 原理:任务执行前检查唯一 ID,避免重复执行。 设计动机:重试机制可能导致消息重复消费,需保障业务一致性。 边界条件:幂等键需全局唯一,建议使用任务 ID + 业务上下文。 落地建议:在任务执行入口增加幂等校验,记录执行状态至数据库。
任务超时强制中断 原理:为任务设置超时时间,超时后通过 Future.cancel() 中断执行。 设计动机:防止长尾任务阻塞线程池,影响系统整体吞吐量。 边界条件:中断可能无法立即生效,需配合线程协作机制。 落地建议:使用 Future.get(timeout, unit) 控制任务执行时间。