一、这个场景先对号入座
先说说我遇到过的真实情况。数据团队每天凌晨两点要跑一个日批任务,把前一天的所有订单数据按城市、按渠道汇总成一张宽表,供早上八点的报表使用。刚开始只有几十个分区,任务跑得很顺。后来业务涨得快,分区涨到了一百多个,任务执行时间从二十分钟涨到了一个半小时。可真正让人头疼的不是执行慢,而是任务根本没有在两点整启动。每次看调度日志,任务的实际触发时间都飘到两点十几分,甚至两点半以后。于是这轮排查不再死盯SQL,改成专门查调度本身。
这种问题在大数据场景里特别常见。日批任务的数据量一上来,任务实例多、执行时间长、执行器资源紧张,各种各样的调度延迟就会冒出来。如果调度这一步就晚了,后面再优化Spark、优化Hive,效果都要打折扣。
二、先把XXL-JOB的调度流程捋清楚
2.1 调度中心和执行器谁在干什么
XXL-JOB由两部分组成,一个是调度中心,一个是执行器。
调度中心负责记录任务配置,比如任务几点跑、用什么路由策略、超时时间是多长。到了触发时间,调度中心会给执行器发一个HTTP请求。执行器收到请求后,再从自己的线程池里拿一个线程去执行真正的业务方法。
也就是说,任务出现延迟,可能卡在调度中心这一侧,也可能卡在执行器这一侧,不是单看业务代码就能知道的。
2.2 延迟最容易出现在四个地方
调度中心读数据库任务表时变慢了。
调度中心内部的调度线程因为锁、慢SQL或者垃圾回收,没能及时把任务发出去。
任务通过HTTP发给执行器的过程中网络变慢,或者执行器连接池满了。
执行器收到请求后,线程池里没有空余线程,任务只能在队列里等着。
这四个环节只要有一个出了问题,最终表现就是任务启动时间比预定时间晚。我们平时最容易忽略的是第一和第二个,因为它们藏在框架内部。
三、一次完整的排查思路
3.1 现象:任务到达时间明显晚于预期
当时我在调度日志里看到了大量“超时告警”,但业务代码日志并没有出现明显的异常堆栈。为了确认延迟发生在调度阶段,我先在一个任务里把预期时间和实际到达时间都打出来。
// 技术栈:Java 8
// 在一个任务里打印出“实际到达时间”和“期望时间”的差
import com.xxl.job.core.context.XxlJobHelper;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Component
public class OrderRollupJob {
// 假设调度中心传进来的参数就是希望执行的时间
// 例如:2025-04-01 02:00:00
@XxlJob("orderRollupJob")
public void execute(String param) throws Exception {
String expectTime = param;
LocalDateTime expectDateTime = LocalDateTime.parse(
expectTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
LocalDateTime arriveDateTime = LocalDateTime.now();
// 计算延迟毫秒数,方便判断积压情况
long diffMs = java.time.Duration.between(expectDateTime, arriveDateTime).toMillis();
XxlJobHelper.log("预期触发时间:" + expectDateTime
+ ",实际到达时间:" + arriveDateTime
+ ",延迟:" + diffMs + "ms");
// 如果延迟超过2分钟,说明问题在调度环节,任务代码还没跑起来
// 这时候就不要先去优化下面的业务逻辑了
doRealWork();
}
private void doRealWork() {
// 真正的宽表计算逻辑,这里省略
}
}
日志打出来后,延迟值一直在涨,从三分钟到十几分钟都有。这就说明任务根本没有按照预期时间被调度到,而不是业务代码执行缓慢。
3.2 顺手看一眼调度线程池状态
既然怀疑调度中心内部有积压,我写了一个简单的Java方法来查看调度线程的快照。这个动作相当于给调度中心做一次“体检”。
// 技术栈:Java 8
// 诊断调度中心线程池状态的Java方法
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class AdminThreadCheck {
public static void dumpScheduleThreads() {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(true, true);
for (ThreadInfo info : threadInfos) {
String name = info.getThreadName();
// 只关心调度中心相关的线程,避免刷屏
if (name.contains("xxl-job") || name.contains("schedule")) {
System.out.println(name + " 状态:" + info.getThreadState()
+ " 等待锁:" + info.getLockName());
}
}
}
}
看到的结果是,几个调度线程一直卡在 BLOCKED 状态,说明它们都在等同一个锁。这个锁不是业务表的数据锁,而是XXL-JOB调度中心用来保证集群节点不重复调度任务的锁。
3.3 再往深处挖,找到数据库锁竞争
XXL-JOB的调度中心支持集群部署。多个调度中心为了避免同一时刻重复触发同一个任务,会使用数据库锁做互斥。当任务总量变大、数据库连接池不够用、或者锁表所在的数据库实例性能变差时,这把锁的获取时间就会变得很不可控。
一个大任务还没跑完,下一轮的调度又开始了。调度线程只能等着锁,后面的任务全部排队。于是本来应该两点整触发的任务,硬生生拖到了两点十八分。
3.4 真正的问题其实有两个
第一个问题是任务体量太大,太多子任务挤在一个大任务里执行。第二个问题是调度中心数据库锁在高并发调度下变成了瓶颈。只加执行器的机器数量,解决不了第一个问题;只优化调度中心数据库,也解决不了第二个问题。
四、动手修复:把大任务拆成小分队
4.1 先缓解调度中心压力
我把调度中心使用的数据库连接池调大了一些,同时把调度中心的数据库和业务数据库分开,避免业务慢查询把调度锁拖住。这个操作做起来很快,算是临时救火。
但光靠这个不够,因为根子出在“一个任务干了几百个分区”这件事上。
4.2 用分片广播让每个执行器只处理一部分
XXL-JOB自带分片广播功能。多个执行器同时收到同一个任务,每个执行器通过分片索引判断自己该处理哪些分区。这样任务不再是一个笨重的大胖子,而是一群协同工作的小分队。
// 技术栈:Java 8
// 分片广播:让每台执行器只处理自己应该处理的那部分分区
import com.xxl.job.core.context.XxlJobHelper;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class BigDataShardingJob {
private List<String> getYesterdayPartitions() {
// 这里模拟拿到昨天产生的所有分区,比如60个分区
List<String> partitions = new ArrayList<>();
for (int i = 0; i < 60; i++) {
partitions.add("partition_" + i);
}
return partitions;
}
private void calcOnePartition(String partition) throws Exception {
// 每个分区的具体计算逻辑
Thread.sleep(500);
}
@XxlJob("bigDataShardingJob")
public void execute(String param) throws Exception {
// 当前执行器的分片索引,从0开始
int shardIndex = XxlJobHelper.getShardIndex();
// 总共参与任务的执行器数量
int shardTotal = XxlJobHelper.getShardTotal();
// 拿到所有分区
List<String> partitions = getYesterdayPartitions();
// 用取模的方式把任务均匀地分给每一台机器
for (int i = 0; i < partitions.size(); i++) {
if (i % shardTotal == shardIndex) {
XxlJobHelper.log("执行器" + shardIndex + "/" + shardTotal
+ " 开始处理分区:" + partitions.get(i));
calcOnePartition(partitions.get(i));
}
}
}
}
这一步很重要。四个执行器一起跑同一个任务,每个执行器只处理四分之一的分区,调度中心不用再给每个分区单独建任务,调度量立刻降下来了。
4.3 在任务内部用线程池做异步缓冲
有些场景下,业务没法完全依赖分片。比如执行器只有两台,但一个个流程特别多。这时候可以在任务内部再用一个线程池,把同步串行改成有限度的并行。
// 技术栈:Java 8
// 使用线程池做异步处理,同时保留失败现场
import com.xxl.job.core.context.XxlJobHelper;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Component
public class BigDataAsyncJob {
// 线程池核心线程数是4,最大是8,队列容量200
private final ExecutorService partitionPool = new ThreadPoolExecutor(
4,
8,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
// 队列满了就由当前线程自己执行,防止任务丢失
new ThreadPoolExecutor.CallerRunsPolicy()
);
private List<String> getYesterdayPartitions() {
List<String> partitions = new ArrayList<>();
for (int i = 0; i < 60; i++) {
partitions.add("partition_" + i);
}
return partitions;
}
private void calcOnePartition(String partition) {
// 这里写真正的数据处理逻辑
System.out.println("处理分区:" + partition);
}
@XxlJob("bigDataAsyncJob")
public void execute(String param) throws Exception {
List<String> partitions = getYesterdayPartitions();
for (String partition : partitions) {
partitionPool.execute(() -> {
try {
calcOnePartition(partition);
} catch (Exception e) {
// 把失败信息留到日志里,方便做补偿任务
XxlJobHelper.log("分区处理失败:" + partition + ",原因:" + e.getMessage());
}
});
}
XxlJobHelper.log("分区任务已经全部提交到线程池,数量:" + partitions.size());
}
}
使用线程池时要特别注意队列长度和拒绝策略。队列太长可能会压爆内存,队列太短又可能频繁触发拒绝策略,所以要根据机器的核心数和任务耗时来不断调整。
4.4 把调度阻塞策略和过期策略设对
修复调度延迟还有一个重要操作,就是把任务的阻塞策略配置好。当时我把任务设置为“单机串行”,避免上一次任务没跑完,下一次任务又挤进来。同时把调度过期策略改成“忽略过期调度”,防止某些延迟很久的任务重复触发。
这些配置在XXL-JOB的任务编辑页面里就能看到。不要小看它们,很多线上事故都是因为任务积压后疯狂补调度造成的。
五、应用场景、优缺点和注意事项
5.1 适合哪些场景
XXL-JOB适合日批任务、小时批任务、数据同步、定时清理日志、定时生成报表这一类需要准实时触发的场景。它上手简单,部署也不复杂,比从零手写一套定时框架要快得多。
5.2 技术优点
控制台对任务的管理非常直观,能直接看到执行日志、执行耗时和失败原因。
支持路由策略、分片广播、失败重试等多种高级功能。
执行器采用HTTP方式对接,支持Java、Python等多种语言。
生态成熟,社区里很多开源项目都在用它。
5.3 技术缺点
调度中心依赖数据库锁来保证集群不重复调度,任务量大时容易出现数据库瓶颈。
没有内置消息队列,不适合把超大量子任务直接塞给调度中心。
任务调用是HTTP请求,面对超大并发调度时响应时间不够稳定。
对于秒级甚至毫秒级的高频调度,XXL-JOB并不是最优选择。
5.4 值得记在心里的注意事项
不要把几万个分区任务全部放在一个任务里循环跑,一定要通过分片或者异步队列拆解。
任务必须做幂等,因为消息重试、失败重跑、人工触发都可能导致同一份数据被处理两次。
要密切关注调度中心本身的状态,单独监控调度中心所在机器的CPU、内存和数据库连接池。
执行器日志不要只记录业务信息,还要记录任务到达时间、执行结束时间,方便排查调度延迟。
当任务量增长很快时,优先考虑拆分任务,而不是无限增加执行器数量。
六、文章总结
大数据场景下的任务调度延迟,很多时候不是任务本身执行慢,而是任务根本就没能按时被触发。排查这类问题,要从日志里找出“预期时间和实际到达时间”的差距,再顺着调度线程状态和数据库锁竞争找到真正的瓶颈。修复的方向也很清晰:减少单个调度任务的体量,通过分片广播和异步线程池把大任务拆散,同时优化阻塞策略和调度中心数据库压力。
以后再遇到凌晨任务迟到,不要急着优化SQL,先问一句:任务到底有没有准点出发。这个答案,往往能帮你少走很多弯路。
评论
围绕“大数据场景下XXL-JOB任务调度延迟问题的排查与修复”参与讨论