接手过一个支付对账系统,定时任务散落在各台业务服务器上,cron表达式写得到处都是,线上一个节点磁盘满了,任务没跑,第二天早上账单对不上,凌晨被人从被窝里拉起来排查。那之后我认真梳理了一遍分布式任务调度的选型,最终落在XXL-JOB上。如果你也在从单机cron往分布式调度迁移,或者正在为任务执行不具备可观测性、不可动态配置而头疼,这篇分享应该能帮你少走不少弯路。这里不聊官方文档能查到的基础概念,重点讲选型逻辑、部署细节、路由策略的实战取舍,还有我踩过的一些坑。
1. 为什么需要分布式任务调度平台
1.1 单机cron的痛点
很多人一开始接触定时任务都是从Linux crontab或者Spring自带的@Scheduled起步的。单机场景下这些东西够用,但业务一旦多起来,问题就变得非常具体而现实。
我在几个项目里遇到的集中痛点基本可以归纳为四类。第一,任务散落不可管。每个服务各自写着@Scheduled,改一个cron表达式要重新发布代码,运维被拉去发版本,开发被拉去排查,日志散落各处,想确认某个任务今天到底跑没跑,得一台一台登录机器去看。第二,没有失败重试和告警。定时任务一旦抛出异常,默认只留下一行日志,如果没人盯着,任务就静默失败了。第三,分布式环境下的重复执行。部署了多个节点之后,同一个定时任务会在每个节点上都执行一遍,不做分布式锁的话数据会重复处理。第四,无法水平扩展和分片处理。数据量大了之后,单个节点跑批处理时间过长,cron表达式没法排列组合,需要把一份大任务拆成多台机器并行处理,自己做的话又得引入分布式锁、选举等等一堆东西。
XXL-JOB这类分布式任务调度平台解决的正是上面这一整套问题:集中化的任务管理和调度,动态修改触发规则,任务执行的完整日志,以及执行器集群的自动注册、路由、分片与高可用。
1.2 调度中心与执行器分离的设计思路
XXL-JOB的架构可以简单理解为两个角色:调度中心和一个或多个执行器。调度中心负责管任务、管触发、记账;执行器负责真正干活。二者通过HTTP接口通信,共享一个注册中心概念,执行器启动后自动上报自己的地址,调度中心维护一份在线执行器列表。
这个设计我认为是它最核心的亮点。把调度逻辑和业务执行逻辑拆开,业务方只需要在自己的服务里集成执行器SDK,写一个JobHandler,剩下的路由策略、失败重试、日志记录全交给调度平台处理。业务代码不会跟调度框架深度耦合,迁移成本也低,新服务接进来加个依赖、做个配置、写个Handler就行了。
调度中心避免重复触发的方式是底层基于Quartz集群模式,多个调度中心节点通过数据库行锁实现互斥,保证同一时刻只有一个节点在处理触发请求。也就是说,调度中心可以多节点部署做高可用,但调度动作不会因为多节点而重复触发。这也引出了一个非常重要的实践前提:调度中心必须依赖一个可靠的数据库,数据库一旦出问题,调度也就停了。
2. 部署一套调度中心和执行器
2.1 调度中心的初始化
调度中心的部署其实是整个接入过程中最简单的部分,但有几个细节不处理好后面会返工。
先在数据库中创建xxl_job库,把官方提供的tables_xxl_job.sql导入进去。这套表结构里包含了任务信息表、执行器注册表、调度日志表、任务日志表、触发器表等。建议专门用一个数据库实例来放调度中心的表,不要跟业务库混在一起,否则大日志量的清理操作会影响业务库性能。
调度中心本身是一个Spring Boot应用,核心配置集中在application.properties里。端口、数据源、通讯token、调度线程池参数、日志保留天数都在这里面配置。有一个参数我建议大家一上来就改掉:日志保留天数,默认是30天,如果任务执行非常频繁,日志表会涨得很快,根据实际需要调整到7天或者15天都行。另外调度线程池参数,fast线程池和slow线程池的最大线程数分别对应快速任务和慢任务的调度线程数,默认值200和100,一般不用动,但如果你的任务量很大、调度频率很高,注意观察监控。
2.2 执行器集成时的几个关键配置
执行器要集成到业务服务中,maven依赖加上之后,需要配置几个核心项。xxl.job.admin.addresses是调度中心的地址列表,多个地址用逗号分隔;xxl.job.executor.appname是执行器名称,这个名称是调度中心里注册和分组用的;xxl.job.executor.port是执行器自己暴露的HTTP服务端口,用于接收调度中心的触发请求。
这里有一个我踩过的坑:xxl.job.executor.ip这个配置项。在有多网卡的机器上部署时,如果不显式指定IP,执行器自动获取的地址可能是内网IP或者docker虚拟网卡IP,调度中心拿这个IP去回调执行器时会调用不通,导致任务一直处于触发失败状态。后来我在部署平台上通过环境变量强制指定了这个IP配置,问题才彻底解决。
执行器注册成功之后,在调度中心的任务管理中新建任务时,执行器下拉列表里就能看到这个服务了。这里注意,执行器列表是有心跳自动下线的,执行器进程挂了超过一定时间,调度中心会自动剔除,所以在排查问题时不要光盯着执行器进程本身。
2.3 部署过程中的注意事项
调度中心多节点部署时,所有节点必须连接同一个数据库,且每个节点的xxl.job.accessToken要一致,否则节点之间或者执行器与调度中心之间通信会鉴权失败。accessToken本质上就是一个共享密钥,部署多个环境时,不同环境要使用不同的token,避免测试环境的任务能触发生产环境的执行器。
执行器端口要注意避免冲突。如果同一台机器上部署多个业务服务,每个服务都有自己的执行器端口,默认是9999,多个服务部署在同一台机器上时务必改成不同端口。我在一个现场遇到过两个服务用了同一个执行器端口,结果一个服务的执行器注册后把另一个服务顶下线了,任务随机失败,排查了很久才找到原因。
3. 创建任务背后的关键参数
3.1 路由策略的取舍
调度中心创建任务时,路由策略是最需要花心思理解的一个配置项。很多人刚开始都是默认轮询,但不同场景下的最佳选择其实很不一样。我把几个主要路由策略的适用场景拆开来聊。
轮询和随机路由适合无状态的任务,比如定期清理临时文件、刷新缓存之类的操作,跑在哪台机器上结果都一样。一致性哈希路由适合有状态任务,比如任务执行依赖本机内存中的某个状态,或者任务要把数据写到某个固定位置的场景,通过哈希算法让同一个任务固定落在某个节点上。故障转移路由适合强依赖执行成功的任务,调度中心会主动探测执行器是否健康,失败时自动切换到下一个可用节点。忙碌转移路由比较实用,它会检查执行器当前任务线程的空闲情况,只在有空闲线程的节点上执行,适合任务占用时间较长且执行器上还有其他任务混合跑的场景。
任务分片是我认为XXL-JOB里最有价值的路由策略,后面单独一节展开。
路由策略的选择直接影响任务可靠性,我的建议是:如果拿不准,优先考虑故障转移;如果任务要求每台机器都参与处理,就选分片广播。轮询和随机看起来公平,但在执行器节点性能不均时容易拖慢整体速度。
3.2 阻塞处理策略的三种选择
阻塞处理策略解决的是“任务还在跑,下一次触发时间又到了”的问题。XXL-JOB提供了三种策略:单机串行、丢弃后续调度、覆盖之前调度。
单机串行是默认值,同一个任务的调度请求会排队依次执行,适合大多数跑批场景。丢弃后续调度适合任务执行时间波动很大但又不希望堆积的场景,上一次没跑完,下一次触发直接丢弃,保证任务不堆积。覆盖之后调度则相反,它会把上一次正在执行的任务终止掉,然后开始新的一次调度,适合对时效性要求高、允许中断重来的任务,比如一些实时的对账扫描。
这三者的选择本质是在权衡数据一致性、资源占用和时效性。对于数据库类的批处理任务,我强烈建议使用单机串行,并发执行同一个业务逻辑容易产生重复数据或锁冲突;对于外部接口轮询类的任务,可以适当使用覆盖策略,保证拿到的状态尽量新。
3.3 失败重试、超时时间与任务参数
失败重试次数这个参数也有很多讲究。默认配置为0,也就是说失败不重试。对于网络抖动导致的偶发失败,重试能够自动恢复;但对于业务逻辑本身有bug的情况,重试只会放大问题。我通常的做法是:读类操作可以配置1到2次重试,写类操作除非保证幂等,否则不要配重试,而是靠告警日志人工介入。
超时时间用于限制任务执行的最大时长,执行超过这个时间会被判定为超时并终止。这个参数在排查慢任务时特别好用,比如某些SQL查询在数据量增长后突然变慢,配置了超时时间后就能通过调度日志快速发现,不用再靠人工盯执行时间。
任务参数是一个字符串类型的配置,会在触发时传给JobHandler。我见过很多团队忽略这个参数,实际上它非常有用。比如同一个JobHandler对应多个周期任务,只是参数不同,可以通过参数区分处理不同业务;又比如分片广播模式下,可以配合分片序号处理不同范围的数据。规范化的参数设计能让一个Handler复用到多个任务上,减少大量重复代码。
4. 分片广播的原理与最佳实践
4.1 分片参数的获取与计算逻辑
分片广播是XXL-JOB在多机并行处理场景下的大杀器。它的含义是:调度中心触发任务时,会向当前执行器的所有在线机器各发送一次调度请求,每次请求携带分片总数和当前分片序号,每台机器拿到属于自己的分片序号后,各处理一部分数据,达到并行加速的效果。
执行器端通过ShardingUtil.getShardingVo()拿到分片信息,其中shardingTotal是总分片数,shardingIndex是当前机器的分片序号。经典用法是对数据库中的数据按分片序号取模过滤,比如订单表要按天跑批,总共有4台机器,每台机器处理订单ID对4取模等于自己序号的那部分数据。这样一个跑批任务从单机执行几小时,变成多机并行十几分钟完成,效果立竿见影。
4.2 分片在动态扩容下的威力
分片广播一个非常大的优势是动态扩容不需要改代码。执行器集群增加一台机器时,任务的下一次触发会自动把分片总数变为5,每台机器自动通过取模逻辑处理自己那份数据。
这里有一个需要提前做好的设计:数据分片按ID取模在扩容时会导致同一批数据的归属节点变化,如果任务逻辑是无状态的,比如扫描数据后发消息或者写汇总表,这没有问题;但如果任务是有状态的,或者依赖本地文件、本地缓存,就要慎重。实际项目里,我一般建议把分片的取模键设计成业务主键而不是机器编号,并且尽量保证任务逻辑幂等,这样扩容、缩容时不需要关注历史数据归属问题。
4.3 分片任务与数据倾斜的应对
分片广播处理大数据量时会出现数据倾斜问题。不同业务主键的数据分布往往并不均匀,有的分片处理的数据量可能是其他分片的好几倍,导致部分机器跑完了,部分机器还在跑。
解决思路有两个方向。一个是在取模之外,引入更均匀的分片维度,比如先按某个业务字段分桶,再按桶取模;另一个是不要直接按ID取模,而是先查出一批待处理的数据,把主键列表拉出来之后按分片序号均匀分配,这样虽然多了一次查询,但数据分布要均匀得多。我在一个数据迁移场景里就是用后一种方式,把跑批时间从3小时压缩到40分钟,同时各节点完成时间差异控制在5分钟以内。
5. 调度稳定性优化与高可用
5.1 调度中心与执行器的集群部署
调度中心的高可用部署方式并不复杂:多个节点部署同一个应用,连接同一个数据库,前端通过负载均衡访问。由于Quartz集群机制,同一时间只有一台调度中心节点在实际上负责触发任务,其他节点处于待命状态。也就是说,调度中心集群解决的是节点故障时的自动切换,而不是调度能力的水平扩展。
执行器的集群则相反,它是真正意义上的水平扩展。路由策略在多个执行器节点之间做选择,分片广播让多个执行器节点并行执行。执行器集群的规模可以根据任务量和数据量随时伸缩,调度中心会自动感知注册节点的上下线。
部署时有一个容易忽略的点:执行器的日志目录。xxl.job.executor.logpath指定了任务执行日志的存放路径,要确保该路径磁盘空间足够大并且配置了清理策略。任务日志既存在数据库中,也以文件形式存在执行器本地,调度中心查看日志详情时是从执行器上拉取日志文件的。因为执行器是有状态的,这个目录一旦撑满磁盘,会导致执行器推送日志失败,还会影响执行器自身所在的服务。
5.2 调度日志与数据库压力
调度日志表是整个调度平台里增长最快的数据。每次触发都会产生调度日志,每次任务执行还会产生执行日志。大任务量下,日志表膨胀速度非常快,如果不定期清理,会拖慢整个调度中心。
日志清理可以借助框架自带的日志自动清理功能,按保留天数滚动删除。不过需要注意,清理操作本身也会占用数据库资源,在业务高峰期大批量删除历史日志容易引发锁竞争。我的经验是调整清理时间为业务低峰期执行,并且一次清理的数据量控制在一个合理的范围内。如果日志表已经膨胀得很大,建议先手动按时间范围分批清理,再开启自动清理,而不是一次性删除全部历史数据。
还有一个数据库相关的问题容易被忽略:调度中心所使用的数据库账号,要保证连接数足够。调度中心多节点部署时,每个节点都会维持一批数据库连接,如果业务上大量任务同时触发,调度线程池和数据库连接池之间容易形成瓶颈。框架默认的连接池配置一般够用,但如果你的调度频率非常高,记得把调度中心的数据源连接池上限调大,否则会出现连接等待超时的报错。
5.3 调度线程池与慢任务隔离
调度中心内部区分了fast线程池和slow线程池。入口处会对任务执行时长做统计,耗时超过一定阈值的任务会被判定为慢任务,后续调度请求会被转移到slow线程池中处理,避免慢任务占满fast线程池,影响其他快速任务的调度。
这个设计非常实用,但它要求我们关注慢任务的注入。如果一个耗时很长的任务一直占着fast线程池的线程,fast池的活跃线程数会持续偏高。实际排查中我遇到过这类现象:某个数据量膨胀的跑批任务没有设置超时时间,执行时长从原来的10分钟慢慢涨到40分钟,结果fast线程池被拖到接近耗尽,其他任务触发延迟明显上升。后来把任务超时时间配置上,并为慢任务单独设置了较低的调度频率,整体调度延迟才恢复正常。
一条实用的调优路径是:先观察调度中心监控中fast线程池的活跃线程数和队列积压情况,如果发现大量任务触发延迟,优先排查是否有执行时间异常增长的任务;再结合任务超时时间、阻塞处理策略进行调整;最后才考虑调整fast和slow线程池的容量。
6. GLUE模式与在线任务扩展
6.1 什么是GLUE模式
GLUE模式是XXL-JOB里相对冷门但实用的一个能力。它的含义是任务代码不需要部署在执行器服务中,而是以脚本形式存储在调度中心,任务的运行模式选择GLUE之后,可以在调度中心的网页上直接编辑代码,保存后代码会被推送到执行器上,通过类加载器动态加载并执行。
支持的语言包括Java、Shell、Python等。这意味着一些临时的数据处理脚本、运维操作脚本,不需要经过完整的业务服务发布流程,在线编辑保存后下一次调度就能生效。对于运维团队来说,这个能力很实用。我见过有些团队把日常的日志清理、数据订正、临时统计任务全部用GLUE模式管理,既有了定时调度和日志记录,又不需要为几十个临时任务单独写代码部署服务。
6.2 GLUE模式使用的边界
GLUE模式虽然方便,但不能滥用。它更适合临时脚本和维护类操作,不适合承载核心业务流程。
原因有几个方面。GLUE代码是动态加载执行的,没有经过常规的代码审查、测试和发布流程,出问题不容易回溯版本。GLUE脚本在调度中心数据库中保存,代码量大之后管理和版本控制都不方便。GLUE代码的执行是在执行器上进行的,如果脚本写得不好,比如死循环或内存溢出,会直接影响执行器所在的服务稳定性。
我在实际项目中一般定了两条规矩:一是任何GLUE任务必须设置超时时间,避免脚本异常拖垮执行器;二是核心业务任务不允许使用GLUE,临时脚本任务执行后确认无问题,超过一定时间未使用就清理掉,控制脚本数量。
7. 常见故障与排查技巧实录
7.1 执行器注册不上的排查方法
执行器注册不上是接入期最常见的故障,表现形式是调度中心的任务管理页面里,执行器下拉列表看不到新添加的执行器信息。
排查顺序我建议固定成这样:先确认执行器服务启动速度,查看启动日志里注册是否成功;再确认执行器与调度中心的网络连通性,尤其是跨网段部署的场景,需要手动telnet测试调度中心端口与执行器端口的连通性;然后检查执行器注册IP,也就是前面提到的多网卡场景,确认调度中心拿到的执行器地址能否回调;最后检查accessToken配置是否两边一致。
这套顺序看着基础,但确实能解决绝大多数注册问题。我遇到过不止一次同事把网络连通性放在最后排查,结果耗了半天发现是防火墙拦了执行器端口。
7.2 任务触发了却没有执行
任务在调度日志里显示触发成功,但执行日志里没有对应记录。这个问题我遇到过两类原因。
一类是执行器的JobHandler名称匹配失败。调度中心配置的任务Handler名称与执行器代码里@XxlJob注解中的名称不一致,导致执行器接收调度请求后找不到对应的Bean,从而拒绝执行。这类问题在代码重构改名后特别容易出现,排查时直接看执行器日志即可。
另一类是路由策略选出的执行器节点在触发请求到达时已经下线。比如执行器节点正在重启,调度中心还保留着它的注册信息,触发的请求就失败了。这种情况调度日志里通常会有失败原因展示。
7.3 分片任务部分节点没有执行
分片广播模式下,偶发出现某些节点没有执行任务的现象。我一般从两个方向分析。一是确认这些节点的执行器注册是否正常,如果某节点注册信息丢失,调度中心就不会把它作为分片目标;二是看任务触发的日志,确认触发请求是否真的发送到了缺失的节点上。
还有一个容易忽略的点:分片广播的路由策略依赖执行器心跳。如果某个执行器节点压力很大,心跳上报延迟,调度中心可能会把它视为不健康,在分片触发时暂时隔离它。这种场景下,任务本身没有问题,只是节点被临时摘掉了,恢复健康后下一次分片会再次参与。
7.4 调度日志与执行日志不匹配
调度日志里记录了调度行为本身,执行日志是执行器上业务代码产生的日志。很多任务出问题,单看调度日志会走弯路。我建议排查问题时的基本路径是:先在调度中心看任务的历史触发记录,确认调度是否正常;再进入执行日志详情,阅读执行器上业务代码的日志片段;最后再结合执行日志定位业务逻辑的问题。
这里有一个好用的技巧:处理大任务时,在JobHandler代码的入口和出口主动记录耗时日志和数据量日志。我一般习惯把处理的总条数、成功条数、失败条数都打出来,配合调度中心的日志详情功能,某次任务没有处理完,通过日志就能一目了然,不需要去执行器服务器上翻原始日志文件了。
7.5 时间与时区问题
分布式环境下服务器时间不一致会导致两类问题。一类是调度触发时间出现偏差,表现为任务不在预期时间点执行;另一类是任务日志中记录的执行时间和实际时间对不上,给排查造成干扰。
XXL-JOB本身对时区处理得比较完善,但整个链路涉及调度中心、执行器、数据库等多台机器,任何一个环节的服务器时间不准确,都会造成触发时间偏差。部署规范上,所有调度相关机器的系统时间必须启用时间同步服务,这个在初始化环境时就应该处理好。我在遇到时间类问题时的排查路径,永远是先检查机器时间,再检查业务代码。
8. 接入XXL-JOB半年后的一些体会
整套平台接入到现在,我最深的感触是:分布式任务调度的价值不在于把cron表达式搬到网页上,而在于把零散、无序、不可观测的定时任务收敛到一个有治理能力的地方。任务的分片能力让我在数据量翻倍时不用重构代码,逻辑迁移的能力让运维脚本有了统一出口,完整的调度日志让凌晨三点被电话叫醒的场景少了很多。
有几个小建议收尾。第一个是给所有任务配置超时时间加失败告警邮箱,这是投入产出比最高的一件事。第二个是分片任务的数据处理逻辑务必保证幂等,触发一次和触发多次的结果必须一致,网络抖动导致的重复调度要靠幂等兜底。第三个是定期检查调度中心的日志表大小,不要等到磁盘报警了再处理。最后一个,任务代码里不要只记异常,正常执行完成的数据量、耗时也建议打日志,这个习惯在排查问题时能节省大量时间。
分布式调度平台本身只是一个工具,把任务治理的规范和意识建立起来,比选哪一个平台更重要。希望这篇分享对正在做任务调度选型或已经被定时任务坑过的人有点帮助。