Quartz扩展与XXL-Job迁移实践

1. 对应简历段落

这篇文章对应简历中“遗留 Quartz 定时任务治理与 XXL-Job 平台化迁移”的项目经历。简历可以写成:

负责保险核心系统定时任务治理,将应用内 Quartz 任务逐步迁移至 XXL-Job,完成任务资产盘点、幂等改造、分片执行、失败重试、执行日志、阻塞策略和灰度切换,解决集群环境下任务重复执行、不可观测和补偿困难问题。

面试官会追问:Quartz 和 XXL-Job 的定位差异是什么?为什么要迁移?Quartz 集群如何防止重复执行?XXL-Job 的调度中心和执行器如何交互?迁移时如何保证任务不漏跑、不重复?失败重试和幂等怎么设计?日终任务、补偿任务、报表任务、状态同步任务有什么不同策略?

回答这类问题要注意,不要把 XXL-Job 说成“更高级的定时器”。Quartz 是优秀的调度框架,适合嵌入应用;XXL-Job 更偏任务调度平台,提供控制台、执行日志、手动触发、路由策略、分片、失败重试和告警。迁移的本质是把散落在应用内的任务调度能力平台化、可观测化、可运维化。

2. 业务背景

保险系统里的定时任务很多,而且重要程度不低。常见任务包括:保单状态同步、支付状态补偿、核保超时查询、短信失败重发、客户标签刷新、佣金结算、日终报表、渠道数据同步、文件对账、风险名单更新、临期保单提醒、回访任务生成等。这些任务有的分钟级执行,有的日终批处理,有的依赖外部文件,有的需要分机构并发,有的不能并发执行。

老系统通常把 Quartz 嵌入应用。单机时代问题不大,应用启动时 Scheduler 启动,按 cron 执行 Job。进入集群后问题开始出现:如果没有 Quartz 集群配置,多实例会重复执行同一任务;如果用了数据库锁,配置和表结构又需要维护;任务执行日志散在应用日志里,运营无法手动触发;任务失败后是否重试不清楚;任务参数修改需要发版;某个任务卡住会影响应用线程;业务方问“昨天状态同步跑没跑完”,开发只能翻日志。

迁移到 XXL-Job 的目标是让任务统一调度、统一查看、统一告警、可手动补偿、可分片扩展。对保险业务来说,这能显著提升问题处理效率。比如支付状态补偿失败,运营可以看到失败原因并触发重跑;日终报表耗时过长,可以按机构分片;核保超时查询可以设置失败重试;任务切换期间可以通过调度开关避免 Quartz 和 XXL-Job 同时执行。

3. 核心原理

Quartz 的核心概念是 Scheduler、Job、JobDetail、Trigger 和 JobStore。Scheduler 负责调度,Job 是执行逻辑,Trigger 描述执行时间,JobStore 保存任务和触发器。Quartz 可以使用 RAMJobStore,也可以使用 JDBC JobStore 支持集群。集群模式下多个节点共享数据库表,通过锁和 trigger 状态协调执行,避免同一 trigger 被多个节点同时获取。

Quartz 优点是成熟、灵活、可嵌入、表达能力强,适合应用内部复杂调度。但它的运维界面、执行日志、手动触发、告警、分片等能力通常需要团队自己扩展。遗留系统中常见做法是封装一个任务基类,记录开始结束日志,防止并发执行,再加数据库任务锁。

XXL-Job 的核心是调度中心加执行器。调度中心负责管理任务、触发时间、路由策略、执行日志和告警;执行器嵌入业务应用,注册到调度中心,接收调度请求并执行具体 handler。它把调度控制面从业务应用中抽离出来,使任务开关、参数、日志和失败重试可以在平台上管理。

XXL-Job 常用能力包括路由策略、阻塞处理、失败重试、分片广播、任务参数、执行日志和手动触发。路由策略决定选择哪个执行器实例;阻塞策略决定上一次未结束时新调度如何处理;分片广播可以让多个执行器实例分别处理不同分片;失败重试由调度中心触发,但业务侧必须保证幂等。

迁移时最重要的原理是调度和执行解耦。Quartz 时代任务触发和执行都在应用内,XXL-Job 时代触发由调度中心发起,业务应用只保留执行逻辑。因此要把原 Job 中的业务逻辑抽成可复用 Service,让 Quartz Job 和 XXL Handler 在过渡期都能调用同一套执行方法,便于灰度切换。

4. 项目落地

落地第一步是任务资产盘点。每个任务都要记录名称、业务负责人、cron、执行窗口、平均耗时、最长耗时、是否允许并发、是否可重试、是否可补跑、依赖外部系统、读写哪些表、失败影响、历史执行日志位置。不要低估这一步,很多遗留任务没人敢动,就是因为不知道它到底影响什么。

第二步是任务分类。补偿类任务要求幂等和可重复执行,例如支付状态补偿、短信重发;日终批处理要求执行窗口、依赖顺序和对账;同步类任务要求游标、批次号和断点续跑;报表类任务要求资源隔离和超时控制;清理类任务要求防止误删。不同任务不能用同一种迁移策略。

第三步是抽离执行逻辑。把 Quartz Job 中的业务代码移到 Service,例如 policyStatusSyncService.sync(param),Quartz Job 只负责解析参数和调用 Service,XXL Handler 也调用同一个 Service。这样在迁移期间可以双入口但单逻辑,减少行为差异。

第四步是补幂等和执行记录。每个任务执行都要有业务批次,例如业务日期、机构、任务类型、分片号。执行记录表保存 running、success、failed 状态,唯一键防止重复执行。对于可重试任务,要保证重复调用不会重复扣费、重复发券或重复生成数据。

第五步是灰度切换。不能让 Quartz 和 XXL-Job 同时按 cron 调度同一任务。一般做法是先上线 XXL Handler 但不启用调度,用手动触发验证;再暂停 Quartz 触发器,开启 XXL 调度;观察一段时间后下线 Quartz 配置。高风险任务可以先按低频或指定业务日期手动触发。

第六步是监控和告警。任务执行日志要包含 jobId、任务名、业务日期、分片号、traceId、处理数量、成功数量、失败数量、耗时和异常摘要。失败要告警到对应负责人,而不是只打印日志。

5. 关键代码或流程

原 Quartz Job 应该瘦身:

public class PolicyStatusQuartzJob implements Job {

    @Autowired
    private PolicyStatusSyncService syncService;

    @Override
    public void execute(JobExecutionContext context) {
        String bizDate = context.getMergedJobDataMap().getString("bizDate");
        syncService.sync(new SyncCommand(bizDate, "QUARTZ"));
    }
}

XXL-Job Handler 调用同一业务服务:

@Component
public class PolicyStatusJobHandler {

    private final PolicyStatusSyncService syncService;

    @XxlJob("policyStatusSyncJob")
    public void execute() {
        String param = XxlJobHelper.getJobParam();
        SyncCommand command = SyncCommandParser.parse(param);
        command.setSource("XXL_JOB");
        syncService.sync(command);
    }
}

幂等执行记录:

@Transactional(rollbackFor = Exception.class)
public void sync(SyncCommand command) {
    String batchKey = command.batchKey();
    if (!jobRunMapper.tryStart(batchKey)) {
        log.info("job batch already running or finished, batchKey={}", batchKey);
        return;
    }
    try {
        int count = doSync(command);
        jobRunMapper.markSuccess(batchKey, count);
    } catch (Exception ex) {
        jobRunMapper.markFailed(batchKey, ex.getMessage());
        throw ex;
    }
}

分片处理思路:

int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
List<String> orgCodes = orgRepository.findAllEnabledOrgCodes();
for (int i = 0; i < orgCodes.size(); i++) {
    if (i % shardTotal == shardIndex) {
        syncOrg(orgCodes.get(i), command.getBizDate());
    }
}

迁移流程建议是:盘点任务、分类分级、抽业务 Service、补执行记录、上线 Handler、手动触发验证、暂停 Quartz、开启 XXL 调度、观察告警、清理旧任务。

6. 常见坑

第一个坑是双调度。Quartz 没停,XXL-Job 又开了,补偿任务重复执行,轻则重复日志,重则重复扣款或重复通知。

第二个坑是没有幂等就开失败重试。调度平台能重试,不代表业务可以安全重试。

第三个坑是把任务参数写死在代码里。迁移后应该让业务日期、机构、批次、开关等通过平台参数或配置传入。

第四个坑是分片逻辑不稳定。按列表下标分片时,如果列表顺序不固定,重复执行可能覆盖范围不一致。要排序或使用稳定 hash。

第五个坑是任务和应用资源互相影响。大报表任务占满线程或数据库连接,会拖垮在线接口。需要线程池、连接池或实例隔离。

第六个坑是只看调度成功,不看业务成功。任务被触发不代表处理完成,必须记录处理数量、失败数量和业务结果。

7. 面试追问

  1. Quartz 和 XXL-Job 的定位区别是什么?
  2. Quartz 集群如何避免重复调度?
  3. XXL-Job 调度中心和执行器如何交互?
  4. 迁移时如何保证不重复、不漏跑?
  5. 为什么要先抽离任务业务 Service?
  6. 失败重试为什么要求幂等?
  7. 分片广播适合什么任务?
  8. 阻塞策略怎么选?
  9. 日终任务和分钟级补偿任务迁移策略有什么不同?
  10. 任务执行日志应该记录哪些字段?

8. 推荐回答

如果问“为什么从 Quartz 迁移到 XXL-Job”,可以回答:

Quartz 本身很成熟,但在遗留系统里它是嵌入应用的,任务开关、执行日志、手动触发、失败重试和告警都不够平台化。集群后还要处理重复执行问题。XXL-Job 把调度中心独立出来,执行器只负责业务执行,运维可以在控制台查看日志、触发任务、配置路由和重试。我们迁移的重点不是换框架,而是补齐任务治理能力。

如果问“如何保证迁移不重复执行”,可以回答:

首先盘点每个任务的 cron 和执行窗口,迁移时先上线 XXL Handler 但不开调度,通过手动触发验证。切换时先暂停 Quartz,再开启 XXL-Job,避免双调度。业务层面为每个任务增加批次号和幂等记录,例如业务日期、任务类型、机构、分片号组成唯一键。即使平台重试或人工补跑,也不会重复处理已成功批次。

如果问“分片任务怎么设计”,可以回答:

分片适合按机构、渠道、客户段或文件分块处理的任务。XXL-Job 提供分片号和总分片数,业务侧用稳定规则把数据分配给不同分片,比如机构编码排序后按下标取模,或者按 hash 取模。每个分片都要有独立执行记录,失败时能只补对应分片,而不是全量重跑。

9. 延伸学习路线

第一阶段学习 Quartz 核心概念,包括 Scheduler、Job、Trigger、JobStore、Misfire、集群和数据库锁。

第二阶段学习 XXL-Job 架构,掌握调度中心、执行器、注册、路由策略、阻塞策略、失败重试、分片广播和执行日志。

第三阶段学习任务幂等设计,包括批次号、唯一键、执行记录、状态机、断点续跑和补偿。

第四阶段学习批处理性能治理,包括分页扫描、游标、分片、线程池隔离、数据库连接控制和限流。

第五阶段结合保险业务复盘任务治理,把支付补偿、状态同步、日终对账、报表生成和短信重发分别讲清楚。面试中能说明“调度成功不等于业务成功”,就是很关键的工程意识。