一、这个场景先对号入座

先说说我遇到过的真实情况。数据团队每天凌晨两点要跑一个日批任务,把前一天的所有订单数据按城市、按渠道汇总成一张宽表,供早上八点的报表使用。刚开始只有几十个分区,任务跑得很顺。后来业务涨得快,分区涨到了一百多个,任务执行时间从二十分钟涨到了一个半小时。可真正让人头疼的不是执行慢,而是任务根本没有在两点整启动。每次看调度日志,任务的实际触发时间都飘到两点十几分,甚至两点半以后。于是这轮排查不再死盯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,先问一句:任务到底有没有准点出发。这个答案,往往能帮你少走很多弯路。