XXL-JOB 调度执行全流程:时间轮、线程池与分片广播 XXL-JOB 的任务调度执行流程及实现原理是我在搭建分布式任务调度平台时翻源码最多的部分。很多团队用 XXL-JOB 只停留在页面配 Cron、写 XxlJob 注解一旦遇到调度延迟、任务重复、执行器掉线就不知道从哪下手。这篇按调度中心触发、执行器接收、任务执行、结果回调、失败重试、分片广播的顺序把里面的线程模型、时间轮、快慢线程池、路由策略、阻塞策略拆开讲。我会尽量不背源码只讲我踩过的坑和验证过的路径。适合已经用过 XXL-JOB、想搞懂底层调度机制的后端开发和运维也适合正在选型分布式任务调度的同学做参考。1. 调度中心和执行器到底怎么分工1.1 两个角色不是简单的服务端和客户端XXL-JOB 里有两个核心角色调度中心xxl-job-admin和执行器xxl-job-executor。调度中心负责管理任务、触发调度、收集日志、失败重试和告警执行器负责接收触发请求、真正执行任务、把结果回调给调度中心。两者之间不是传统的服务端和客户端关系调度中心不直接保存执行器的业务状态执行器也不主动拉取任务而是通过注册表解耦。执行器启动后向调度中心注册自己的地址之后每 30 秒发一次心跳调度中心把在线执行器维护在数据库表xxl_job_registry和内存注册表里超过 90 秒没有心跳就判定为死亡。这样调度中心可以动态感知执行器的上下线任务触发时再按路由策略挑一台或者一批机器。这个设计的好处是轻量。注册中心没有引入 ZooKeeper、Nacos 这些额外组件直接复用数据库部署成本低中小规模集群完全够用。我见过一些团队一上来就纠结“为什么不用注册中心”其实 XXL-JOB 的定位就是开箱即用数据库注册在几百台执行器的规模下压力也不大。真正要注意的是数据库本身的高可用以及调度中心和执行器之间的网络连通性。执行器注册的是 IP 和端口默认端口 9999如果机器有多网卡自动获取的 IP 可能不是你想要的这时候需要手动配置xxl.job.executor.ip否则调度中心会往一个不可达的地址发请求日志里就会出现大量触发失败。1.2 调度中心启动时初始化了哪些线程调度中心启动时XxlJobScheduler.init()会拉起一批后台线程它们各司其职不是靠一个大定时器包打天下。JobRegistryHelper负责注册监控每 30 秒清理死亡执行器同时刷新执行器地址列表JobFailMonitorHelper负责失败监控每 30 秒扫描失败日志并进行重试JobCompleteHelper负责处理执行器回调的结果JobLogReportHelper负责日志报表统计JobScheduleHelper是调度核心内部又分成扫描线程和时间轮线程JobTriggerPoolHelper管理快慢两个触发线程池真正把请求发出去。刚接触源码时容易迷路其实只要抓住“注册、调度、触发、回调、失败、报表”这六条线整个调度中心就清晰了。下面这张表是我自己读源码时整理的方便快速定位每个线程的职责和默认周期。注意不同版本参数可能略有差异但核心思想一致。线程/组件核心类主要职责默认周期注册监控JobRegistryHelper刷新执行器在线地址清理死亡节点30 秒失败监控JobFailMonitorHelper扫描失败日志触发重试和告警30 秒回调处理JobCompleteHelper处理执行器回调更新日志结果异步触发日志报表JobLogReportHelper统计成功失败数量生成报表1 分钟任务调度JobScheduleHelper扫描任务维护时间轮提交触发5 秒预读 1 秒轮询触发线程池JobTriggerPoolHelper快慢线程池发送触发请求实时这些线程默认都是守护线程调度中心停止时会一起退出。排查问题时我一般先看JobScheduleHelper的日志确认任务有没有被扫描到再看JobTriggerPoolHelper的线程池队列确认是不是触发拥堵最后看执行器注册表确认目标机器是否在线。这个顺序能快速缩小范围。有一次线上任务延迟了十几分钟最后发现是调度中心所在机器的系统时间慢了导致trigger_next_time计算异常时间同步后立刻恢复。所以别忽视时钟同步这个基础问题。2. 调度中心触发任务的完整链路2.1 任务扫描与时间轮的配合方式JobScheduleHelper里有两个关键线程scheduleThread和ringThread。scheduleThread每隔 5 秒扫描一次xxl_job_info表把未来 5 秒内需要触发的任务捞出来计算它们的trigger_next_time然后按照触发时间放进ringData。ringData是一个ConcurrentHashMapInteger, ListIntegerkey 是秒槽位value 是任务 ID 列表。ringThread每隔 1 秒运行一次取出当前秒对应的任务 ID 列表提交给JobTriggerPoolHelper去触发。你可以把它想成一个只有 60 个格子的钟表每个格子放接下来某一秒要执行的任务秒针每走一格就取走当前格子的任务。这样调度中心不需要每秒查数据库既保证了秒级精度又大幅降低了数据库压力。这里有个细节scheduleThread扫描时会预读 5 秒意味着一个任务最多可能提前 5 秒被放进时间轮但真正触发还是按秒槽位来。如果调度中心集群部署多个节点会通过数据库锁xxl_job_lock串行化扫描避免同一个任务被重复触发。锁的竞争时间很短一般不会成为瓶颈。但如果数据库本身压力大或者调度中心节点太多锁等待会拖慢扫描速度进而导致任务延迟。我的经验是调度中心节点不要超过 3 个且尽量独立部署不要和执行器抢资源。另外任务扫描有分页限制如果任务数量特别多可以适当调大preReadCount但不要一次性读太多否则单次扫描时间过长也会影响精度。2.2 快慢线程池和路由策略的选型逻辑JobTriggerPoolHelper里维护了两个线程池快池fastTriggerPool和慢池slowTriggerPool。快池默认核心线程 10、最大线程 200、队列 1000慢池默认核心线程 10、最大线程 100、队列 2000。触发时调度中心会统计每个任务最近 1 分钟的失败次数如果失败超过 10 次就把这个任务路由到慢池否则走快池。这么做的目的是隔离慢任务避免少数一直失败或者响应很慢的任务把快池的线程占满拖垮其他正常任务。这个设计在线上很实用我遇到过某个任务因为执行器网络抖动一直超时触发线程频繁重试结果快池队列被打满其他任务全部延迟。后来确认了慢池机制只要失败次数上去任务会自动进慢池快池就能保持通畅。路由策略决定了把触发请求发到哪台执行器。XXL-JOB 内置了十种策略常用的有第一个、最后一个、轮询、随机、一致性 HASH、最不经常使用、最近最久未使用、故障转移、忙碌转移、分片广播。第一个和最后一个适合固定机器调试轮询适合机器性能均匀的场景一致性 HASH 适合需要按参数固定路由到同一台机器的场景比如同一个订单 ID 总是落到同一台执行器故障转移会在触发失败后自动切换到下一台忙碌转移会根据执行器的负载情况选择较闲的机器分片广播则会把请求发给所有在线执行器用于并行处理大数据量任务。路由策略适用场景注意点第一个单机调试、固定入口机器宕机则失败最后一个临时切换、灰度验证同上轮询机器性能均匀的常规任务默认策略简单可靠随机机器数量多、想打散压力可能负载不均一致性 HASH按业务参数固定路由执行器上下线会导致重平衡最不经常使用机器性能差异大统计有延迟最近最久未使用希望优先用空闲机器实现依赖时间戳故障转移对可用性要求高会依次尝试增加延迟忙碌转移执行器负载差异明显需要执行器上报负载分片广播大数据量批处理、缓存预热每个执行器都会收到请求选路由策略时不要迷信默认值。比如你的任务处理的是用户维度的数据且要求同一用户的数据串行处理一致性 HASH 就比较合适如果你的任务只是发通知随机或者轮询就够。分片广播要特别小心它会让所有执行器同时执行如果任务本身不是分片逻辑就会造成重复执行。我见过有人配了分片广播但代码里没写分片处理结果所有机器都把全量数据跑了一遍数据库直接被打爆。所以分片广播必须配合XxlJobHelper.getShardIndex()和getShardTotal()使用。2.3 触发请求发送与超时处理触发线程最终会通过XxlJobRemotingUtil.postBody向执行器发送 HTTP POST 请求路径是/run。请求体里包含任务 ID、执行器 Handler 名称、执行参数、阻塞策略、超时时间、日志 ID、日志时间、GLUE 类型、GLUE 源码、分片序号和分片总数等。执行器返回一个ReturnT对象里面有 code、msg 和 content。调度中心根据返回结果更新日志如果 HTTP 状态不是 200或者返回的 code 不是 200就认为触发失败记录trigger_code500。触发超时时间默认是 3 秒如果执行器在 3 秒内没有响应也会判定为触发失败。这里要区分“触发成功”和“执行成功”触发成功只代表执行器收到了请求并放进了队列真正执行完还要等执行器回调。所以你在日志里看到触发成功不代表任务已经跑完要看 handle 相关的状态。触发失败的原因很多执行器掉线、网络不通、端口被防火墙拦截、执行器线程队列满、执行器正在 Full GC 等。调度中心会记录失败日志然后由JobFailMonitorHelper在 30 秒后扫描到并进行重试。重试次数用完后才会触发告警。如果你的任务对实时性要求高触发超时时间可以适当调大比如 5 秒但不要太大否则触发线程会被长时间占用。另外触发请求是同步的但触发线程池是异步的所以即使某个请求慢也不会阻塞调度线程。真正要监控的是触发线程池的队列长度如果队列持续增长说明触发能力不足需要调大线程数或者增加调度中心节点。3. 执行器接收与任务执行的内部机制3.1 内嵌服务与注册线程的工作方式执行器启动时XxlJobExecutor会初始化一个内嵌服务EmbedServer在 2.x 版本中它基于 Netty 实现默认监听 9999 端口处理/run、/beat、/idleBeat等请求。/run用来接收触发/beat用来心跳检测/idleBeat用来判断某个任务线程是否空闲。同时执行器会启动ExecutorRegistryThread每 30 秒向调度中心注册一次注册信息包括 appname、地址、IP、端口。如果注册失败它会自动重试。注册成功后调度中心的注册表里就有这台执行器任务触发时才能被路由到。执行器还会启动TriggerCallbackThread负责回调调度中心以及JobLogFileCleanThread定期清理本地日志文件。这一套线程模型比较轻执行器本身不依赖 Web 容器可以嵌入到 Spring Boot 应用里也可以独立部署。这里有个容易踩的坑执行器的 appname 必须和调度中心里配置的执行器组 AppName 完全一致否则注册表里虽然能看到机器但分组对不上任务页面上选不到执行器。还有执行器的端口不要和业务端口冲突如果一台机器部署多个执行器每个执行器要配不同的端口。多网卡机器一定要手动指定 IP否则注册的地址可能是 Docker 网桥或者虚拟网卡的地址调度中心根本连不上。我一般在启动参数里加上-Dxxl.job.executor.ip真实IP避免自动获取出错。3.2 JobThread 队列与阻塞策略的实现执行器收到/run请求后会根据 jobId 查找对应的JobThread。每个任务在执行器里对应一个JobThread它内部维护一个LinkedBlockingQueueTriggerParam队列。如果JobThread不存在就创建一个并启动如果已经存在就把触发参数放进队列。JobThread是一个循环线程不断从队列里take任务然后调用对应的IJobHandler.execute()执行。一次触发对应一次执行执行完继续取下一个。阻塞策略决定了队列满时怎么办XXL-JOB 提供了三种串行、丢弃后续、覆盖之前。串行是默认策略队列满时触发请求会阻塞等待直到队列有空间丢弃后续是队列满时直接丢弃新来的触发请求覆盖之前是清空队列把最新的触发请求放进去。实现上JobThread.pushTriggerQueue会根据策略操作队列。这三种策略没有绝对好坏关键看业务。串行适合不能丢任务、允许排队的场景比如订单结算。丢弃后续适合实时性要求高、过期任务没有意义的场景比如实时行情推送。覆盖之前适合只关心最新状态的场景比如定时刷新缓存旧任务被新任务覆盖掉反而更合理。要注意的是串行策略下如果任务执行时间很长队列会一直堆积执行器内存会增长而且任务的实际执行时间会远远晚于触发时间。这时候你应该考虑把任务改成异步、分片或者缩短执行时间而不是简单调大队列。我见过一个任务因为单次执行要 10 分钟Cron 又是每分钟一次串行策略下队列越堆越长最后 OOM。后来改成每次只处理增量数据问题才解决。3.3 任务执行、超时控制与结果回调JobThread从队列取出任务后会调用IJobHandler.execute()。如果是 BEAN 模式就反射调用 Spring 容器里的方法如果是 GLUE 模式就编译并执行在线代码。执行时如果配置了超时时间JobThread会把任务包装成FutureTask然后调用future.get(timeout)超时后调用future.cancel(true)并记录失败。但这里要特别注意cancel(true)只是给线程发一个中断信号如果任务代码不响应中断比如在死循环里、在不可中断的 IO 操作里线程实际上还会继续跑。调度中心看到的是超时失败可能会触发重试结果同一个任务被重复执行。这个问题很隐蔽我排查过一次任务日志显示超时失败但数据库里数据却写入了两次。后来发现是任务里的批量插入没有检查Thread.currentThread().isInterrupted()线程被中断后仍然继续执行。解决办法是在耗时循环里主动检查中断状态或者把超时时间设置得比实际执行时间长避免误判。执行完成后JobThread把结果封装成HandleCallbackParam放入TriggerCallbackThread的回调队列。回调线程会批量取出结果发送到调度中心的/callback接口。如果回调失败会放入重试队列由重试线程每 30 秒重试一次。调度中心的JobCompleteHelper收到回调后更新xxl_job_log表里的执行结果。整个回调是异步的所以执行器执行完到调度中心显示成功之间会有短暂延迟一般几秒内。如果回调一直失败比如调度中心不可用执行器本地会保留重试队列重启后可能丢失这点要有心理准备。重要任务最好在业务层做幂等和补偿不要完全依赖调度中心的回调。4. 失败重试、告警与分片广播4.1 失败监控与重试机制的实现细节JobFailMonitorHelper每 30 秒执行一次失败扫描。它会查询xxl_job_log表里trigger_code500或者handle_code500的日志并且retry_count小于任务配置的重试次数。对于触发失败调度中心会重新触发一次对于执行失败也会重新触发整个任务。重试时会把retry_count加 1并把trigger_next_time更新为当前时间加 1 分钟也就是下一次重试在 1 分钟后。如果重试次数用完了仍然失败就发送告警并把alert_status标记为已告警。告警方式默认支持邮件需要在调度中心配置邮件服务器。有些团队会扩展成 Webhook、钉钉、企业微信但官方只提供邮件扩展需要自己改源码或者用报警平台对接日志。重试机制有一个关键点它重试的是整个任务不是从失败的那一步继续。所以你的任务必须是幂等的否则重试会导致重复写入。比如扣款任务重试时可能扣两次。正确的做法是给每次执行加唯一流水号或者用状态机控制确保重复触发不会产生副作用。另外重试间隔固定 1 分钟不能按任务单独配置如果任务本身执行时间很长重试可能会和上一次执行重叠。这时候要结合阻塞策略串行策略会让重试排队覆盖策略会丢掉旧任务。我的建议是对于执行时间超过 1 分钟的任务把重试次数设成 0靠业务自己的补偿机制兜底不要依赖调度中心的自动重试。4.2 分片广播的调度实现与使用要点分片广播是 XXL-JOB 里很实用的一个特性路由策略选择SHARDING_BROADCAST后调度中心不会只选一台执行器而是向所有在线执行器都发送触发请求。每个请求里会带上broadcastIndex和broadcastTotal分别表示当前执行器的序号和本次广播的总执行器数。执行器在执行任务时可以通过XxlJobHelper.getShardIndex()和XxlJobHelper.getShardTotal()拿到这两个值。典型的用法是先查出待处理数据的总数然后每个分片只处理id % shardTotal shardIndex的数据。这样数据会被均匀分散到多台机器上并行处理大幅提升吞吐量。分片广播适合大数据量的批处理、缓存预热、全量对账等场景。但分片广播有几个坑要注意。第一分片总数是触发那一刻在线执行器的数量任务执行过程中如果有执行器上线或下线本次分片不会动态调整所以不要在任务执行期间频繁扩缩容。第二分片广播会为每个执行器生成一条日志记录日志 ID 不同排查时要根据日志 ID 分别查看。第三如果某个分片执行失败重试时调度中心会重新向所有执行器广播还是只重试失败的分片实际是重新触发整个任务所有分片都会再跑一遍。所以分片任务也必须幂等。第四分片序号是从 0 开始的编写分片逻辑时要注意边界。我一般在代码里加一个日志打印当前分片序号和总数方便确认分片是否均匀。如果发现某个分片数据特别多可能是取模的键分布不均可以考虑用一致性 HASH 或者自己实现更均匀的分片算法。5. 常见问题与排查技巧实录5.1 执行器注册不上或者频繁掉线执行器注册不上是最常见的问题之一。排查时先看执行器启动日志ExecutorRegistryThread有没有报错再看调度中心的xxl_job_registry表有没有对应 appname 的记录然后检查执行器配置的xxl.job.admin.addresses是否正确多个调度中心地址是否用逗号分隔接着确认网络连通性在浏览器或者命令行访问调度中心地址和执行器端口。常见原因包括appname 拼写不一致、执行器端口被防火墙拦截、调度中心未启动、执行器 IP 自动获取错误、机器时间不同步导致心跳过期。如果执行器频繁掉线又上线通常是网络抖动或者心跳间隔和超时时间设置不合理。默认心跳 30 秒、死亡判定 90 秒如果执行器所在机器负载很高心跳线程可能被延迟可以适当调大死亡判定时间但不建议太大否则真正掉线的机器无法及时剔除。多网卡场景要特别小心。执行器默认会自动获取第一个非回环 IP但在 Docker、Kubernetes 或者有虚拟网卡的机器上这个 IP 可能不是调度中心能访问的。解决办法是显式配置xxl.job.executor.ip或者在启动参数里指定。还有一个容易忽略的点调度中心和执行器如果跨网段要确保安全组和防火墙放行。我习惯在执行器部署后用telnet或curl测一下调度中心的注册接口提前暴露网络问题。5.2 调度延迟越来越大怎么办调度延迟的表现是任务实际触发时间比 Cron 时间晚很多甚至晚几分钟到几十分钟。原因通常有几个触发线程池队列积压、调度中心数据库锁竞争、时间轮扫描周期影响、系统时钟不同步。排查时先看调度中心日志搜索任务 ID看scheduleThread有没有按时扫描到ringThread有没有按时提交JobTriggerPoolHelper的线程池活跃线程和队列大小。如果队列很大说明触发能力不足可以调大快池最大线程数和队列容量或者增加调度中心节点。如果数据库慢查询多检查xxl_job_info、xxl_job_log表的索引和清理策略日志表太大也会拖慢扫描。时钟不同步时任务可能被提前或者延后触发所有节点都要配 NTP。我遇到过一次严重的调度延迟最后定位到是调度中心和执行器混部执行器任务把 CPU 打满调度线程抢不到时间片。把调度中心独立部署后恢复。所以调度中心最好单独一台机器至少不要和重负载执行器混部。另外任务数量特别多时scheduleThread每 5 秒扫描一次可能不够可以考虑分库分表或者拆成多个调度中心但 XXL-JOB 官方对超大规模支持有限任务量上万后需要自己评估。5.3 任务重复执行的几种原因任务重复执行是另一个高频问题。原因可能包括调度中心集群节点时钟不同步导致trigger_next_time更新冲突任务执行时间超过调度周期且阻塞策略是串行导致排队而不是重复但如果配置了故障转移执行器掉线后任务被路由到另一台执行器也会重复失败重试重新触发整个任务分片广播配置错误所有机器都执行全量数据。排查时先看日志表对比trigger_time和handle_time确认是同一调度中心触发多次还是多个调度中心各触发一次。如果是多调度中心检查数据库锁是否正常所有节点时间是否同步。如果是执行器掉线后故障转移检查执行器日志是否有中断记录。解决重复执行的根本手段是业务幂等。调度层面只能尽量减少重复但无法完全避免因为网络分区、进程崩溃、重试都会导致至少一次语义。我的做法是给每个任务执行生成一个唯一业务流水号写入数据库唯一索引重复执行时插入冲突就跳过。或者用状态字段控制只有特定状态才允许执行。不要假设调度中心不会重复触发这是分布式任务调度的基本认知。5.4 超时设置与线程中断的坑超时时间设置得太短任务还没跑完就被判定失败然后重试导致重复执行设置得太长任务卡死时无法及时释放线程。XXL-JOB 的执行器超时基于FutureTask超时后调用cancel(true)发送中断信号。但很多任务代码不响应中断比如 JDBC 查询、文件 IO、死循环线程会继续运行。调度中心已经记录失败并可能重试执行器里旧线程还在跑新线程又启动资源被耗尽。要解决这个问题任务代码必须在关键循环里检查Thread.currentThread().isInterrupted()或者使用支持超时的客户端。对于无法中断的任务建议把超时时间设得足够大或者干脆不设超时靠任务自身的超时机制控制。我一般会在任务入口打印开始和结束时间在日志里观察实际执行时长再根据 P99 设置超时时间通常比平均时长多 50% 到 100%。对于批量任务尽量拆成小批次每批处理完检查一次中断这样即使超时也能快速退出。另外超时后的重试要谨慎如果任务确实需要很长时间重试只会增加系统压力不如让任务继续跑完靠业务层面的状态标记来避免重复。6. 关键配置与版本差异提醒6.1 调度中心和执行器的核心配置清单调度中心的配置主要在application.properties里数据库连接、端口、访问令牌、国际化语言等。执行器的配置一般放在 Spring Boot 的配置文件里核心是调度中心地址、访问令牌、appname、端口、日志路径和日志保留天数。下面是我常用的小配置模板你可以根据自己的环境调整。注意访问令牌accessToken调度中心和执行器必须一致否则注册和触发都会被拒绝。# 调度中心 server.port8080 spring.datasource.urljdbc:mysql://127.0.0.1:3306/xxl_job?useUnicodetruecharacterEncodingUTF-8autoReconnecttrueserverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.passwordroot_pwd xxl.job.accessTokendefault_token xxl.job.i18nzh_CN# 执行器 xxl.job.admin.addresseshttp://127.0.0.1:8080/xxl-job-admin xxl.job.accessTokendefault_token xxl.job.executor.appnamexxl-job-executor-sample xxl.job.executor.ip xxl.job.executor.port9999 xxl.job.executor.logpath/data/applogs/xxl-job/jobhandler xxl.job.executor.logretentiondays30任务配置页面的几个参数直接影响调度行为。Cron 表达式决定触发时间XXL-JOB 支持秒级运行模式决定是 BEAN 还是 GLUEJobHandler 是执行器里注册的处理器名称路由策略决定选哪台执行器阻塞策略决定队列满时的行为超时时间决定执行器等待多久失败重试次数决定自动重试几次告警邮件决定失败后通知谁。这些参数没有统一标准要根据任务特性来定。比如数据同步任务通常设置重试 3 次、超时 10 分钟、阻塞策略串行实时通知任务可以设置重试 0 次、超时 10 秒、阻塞策略丢弃后续。6.2 源码阅读顺序与版本差异如果你要深入源码我建议先从JobScheduleHelper开始理解时间轮和扫描逻辑然后看JobTriggerPoolHelper理解快慢线程池和路由接着看执行器的ExecutorRegistryThread、JobThread、TriggerCallbackThread最后看JobFailMonitorHelper和JobCompleteHelper。这几个类吃透整个调度执行流程就通了。版本方面2.3.0 以后时间轮和快慢线程池比较稳定3.x 在通信和注册上做了一些优化但核心流程变化不大。不同版本的时间轮实现可能有细微差别比如ringData的清理策略、线程池参数默认值阅读时以你实际使用的版本为准。不要拿旧版本的源码去套新版本的行为遇到差异先看 release note。我个人的习惯是在本地搭一套单机调度中心加两个执行器然后手动触发、查看日志、打断点把整个流程跑一遍。比只看源码快得多。尤其推荐调试一次失败重试和一次分片广播亲眼看到日志表和回调队列的变化印象会非常深。最后分享一个我排查调度问题的固定套路先看调度中心日志里任务有没有被扫描到再看触发线程池有没有积压接着看执行器注册表在不在线最后看任务日志的trigger_code和handle_code。这四步能覆盖八成以上的调度异常。遇到时间轮相关的诡异延迟先把所有相关机器的 NTP 同步一遍往往会有意外收获。