
1. 先把这条调度链路的地图画清楚聊 XXL-JOB 的任务调度执行流程很多人第一反应是去翻JobScheduleHelper这个类觉得看懂时间轮就懂了一切。我一开始也这么想后来在线上排过几次任务莫名其妙没触发和任务重复跑了三次的问题之后才发现光看调度中心是不够的真正的问题往往出在执行器那一侧的回调、注册或者线程回收上。所以这篇东西我打算把调度中心和执行器两侧的完整链路串起来讲从 cron 到期那一刻开始一直讲到你在控制台上看到日志文件为止。XXL-JOB 是一套分布式任务调度平台它把决定什么时候跑和实际在哪里跑这两件事拆成了两个角色调度中心负责时间计算、路由分发、日志归档和失败重试执行器负责接收请求、拉起业务逻辑、回收结果并把执行记录回传。这个拆分方式解决的核心问题是业务服务通常有很多实例但一个定时任务在一个时间点只应该被触发一次分片广播任务除外如果把定时逻辑写在每个业务实例里要么重复执行要么得自己搞一套分布式锁维护成本高得离谱。这篇内容适合三类人正在用 XXL-JOB 但只会配 cron、遇到问题只会重启的运维和开发准备给团队自研调度组件、想参考成熟设计的架构同学以及面试前想把分布式调度这个点讲明白的人。我会以 2.3.x / 2.4.x 这条主线版本的代码结构为参考早期的 1.x 和 2.1 之前的设计差异会顺带提一下方便你对照手里的版本。1.1 调度中心和执行器各自管什么调度中心本质上是一个 Spring Boot 应用核心职责有四件事。第一件是任务元数据的存储和计算任务的 cron 表达式、路由策略、阻塞策略、超时时间这些配置都存在xxl_job_info表里调度中心会定期把trigger_next_time往前推算出下一次该跑的时间戳。第二件是触发动作到点之后根据路由策略挑一个在线的执行器地址发一个 HTTP 请求出去。第三件是日志与失败处理每次触发都会在xxl_job_log里插一条记录执行器回传结果后更新这条记录失败的按配置重试并告警。第四件是注册中心角色维护xxl_job_registry表记录哪些执行器还活着。执行器是一个嵌入到业务应用里的组件通过XxlJobSpringExecutor这个 Bean 启动。它启动之后做三件事向调度中心注册自己注册信息包含 appname、机器地址、时间戳开一个内嵌的 HTTP 服务监听调度中心的请求扫描应用里所有带XxlJob注解的方法并注册成 JobHandler。任务真正执行的线程不归业务应用的线程池管而是 XXL-JOB 自己维护的一套JobThread这一点后面会展开说因为它直接决定了任务堆积了怎么办这个经典问题的答案。注意调度中心和执行器之间的通信是单向下发加单向回调的两条独立链路不是长连接。调度中心发请求用的是短连接 HTTP执行器回传结果走的是另一个异步队列。这个设计意味着网络抖动不会导致任务卡死只会导致这一秒触发失败理解这点对排查问题很关键。1.2 为什么不是简单的定时轮询有同学会问为什么不用数据库轮询这么简单的方案。原始的做法是在xxl_job_info里存trigger_next_time然后每隔几秒select * from xxl_job_info where trigger_next_time now()查一遍。这个方案在任务量小的时候没问题但任务数上万之后trigger_next_time这个字段的索引会变得非常热频繁的范围查询加上更新操作数据库压力会直接顶到天花板。XXL-JOB 的解法是把读取和触发解耦。它用一个长度为 60 的时间轮做缓冲一个后台线程每 5 秒从数据库预读未来 5 秒内要触发的任务把它们按秒数扔进时间轮对应的槽位里另一个维度的扫描逻辑每秒取走当前槽位的任务交给线程池去发。这样一来数据库的访问频率从每次任务触发一次查询降到了每 5 秒批量拉一批压力下降了好几个数量级。这种设计的代价是有一点点时间精度损失——最坏情况下任务的实际触发时间会比 cron 计算的时间晚一秒左右因为槽位是按整秒对齐的。对于绝大多数的定时任务场景晚个几百毫秒完全无所谓但如果你要做毫秒级的精度控制XXL-JOB 就不太合适了那是另一个量级的问题。2. 调度中心侧时间轮怎么把任务推出去先把调度中心这一侧拆开看。集群部署的时候同一个调度中心会有多个实例在跑如果不加控制每个实例都去推进时间轮任务就会被触发多次。XXL-JOB 的处理办法是在xxl_job_lock表里放一行schedule_lock所有实例在进入调度主循环之前先抢一次数据库行锁select ... for update抢到的那个实例干活其他实例在这一轮里直接跳过。这就是为什么你部署三个调度中心实例任务也不会重复触发。2.1 注册表与在线节点维护执行器启动时会调用调度中心的注册接口往xxl_job_registry表插一条记录之后每 30 秒续一次心跳把update_time刷新到当前时间。调度中心这边有一个独立线程跑注册清理逻辑默认每 30 秒扫一次把所有update_time超过 90 秒的记录删掉。这里 90 秒这个数字不是随便定的它等于心跳周期乘以三留出了两次心跳失败的重试余量。排查执行器明明启动了但调度中心显示离线这类问题时第一个要看的就是这几张表和心跳周期是否匹配。如果业务应用的机器负载很高线程调度被拖慢30 秒的心跳有可能延迟到 40 秒才发出去这时候把 90 秒的阈值设得太小就会误判。反过来执行器进程 crash 之后调度中心最长要等 90 秒才能把它摘掉这 90 秒内路由到这个节点的任务会全部失败。这两个方向的取舍需要根据你的业务容忍度来调。2.2 每秒滴答的时间轮推进逻辑时间轮这个结构其实很朴素就是一个MapInteger, ListIntegerkey 是 0 到 59 的秒数value 是待触发的任务 ID 列表。核心的调度线程做两件事预读和扫描。预读这一步线程每隔 5 秒醒一次算出一个时间窗口从数据库里拉出trigger_status 1且trigger_next_time落在窗口内的任务条数上限是(快池最大线程数 慢池最大线程数) * 20按默认配置就是 6000 条。为什么要乘以 20 这个系数我理解这是一种经验性的限流保证预读出来的任务量不会远超线程池在接下来 5 秒内能处理掉的量避免内存里堆积过多待触发对象。预读出来之后对每个任务算出trigger_next_time / 1000 % 60得到槽位扔进去然后用CronExpression.getNextValidTimeAfter()把trigger_next_time推到下一次。扫描这一步是每秒执行的它会取当前秒和上一秒两个槽位的数据。取上一秒是为了做补偿——如果某一秒扫描逻辑因为 GC 或者数据库慢查询卡了一下上一秒的任务不会被漏掉。取出任务列表后就交给触发线程池同时把槽位从 Map 里移除。这里有个坑我踩过如果你观察到任务总是比预期晚一到两秒触发先别急着怀疑时间轮去看一下服务器本身的 NTP 时间同步是不是有偏差。我之前遇到过一次机器时间比标准时间慢了 3 秒cron 算出来的trigger_next_time和实际墙上时间对不上表现就是固定延迟。校时之后就正常了。2.3 快慢隔离的触发线程池触发动作本身也是异步的用的是两个独立的线程池快池和慢池。判断走哪个池的依据是这个任务最近 10 分钟内的触发超时次数超过 10 次就标记为慢任务扔进慢池慢池的线程数更少、队列更长。为什么要这么分因为调度中心的触发线程实际上是同步等待执行器返回的如果某个任务的执行器响应特别慢比如那个任务本身跑得久或者那台机器网络有问题它就会一直占着线程。线程池被占满之后所有任务都发不出去一个坏任务拖垮整个调度中心。快慢隔离就是把这个爆炸半径限制住让慢任务在慢池里互相耗着不影响快池里那些正常任务的触发。这个思路在服务治理里很常见本质上是把故障域做隔离。实际调优的时候如果慢池经常排满说明你的慢任务太多了得回头看看是不是有任务超时配置不合理或者执行器那边确实遇到了瓶颈。2.4 路由策略与阻塞策略的落地路由策略决定的是从执行器地址列表里挑哪一个。常见的有第一个、最后一个、轮询、随机、一致性哈希、最不经常使用、最近最久未使用、故障转移、忙碌转移和分片广播。这里面的选择和你的业务特征强相关。如果任务是无状态的、随便哪台机器跑都行用轮询或者随机就够了负载也均匀。如果任务内部有本地缓存或者依赖本地文件那用一致性哈希能让同一个任务尽量固定落在某台机器上缓存命中率会高很多。故障转移和忙碌转移这两个比较特殊它们不是一次挑一个就不管了而是挑一个发过去失败或者忙就换下一个重试适合那种只要有一台跑成功就行的场景。阻塞策略处理的是另一个维度的问题同一个任务上一次还没跑完这一次又到点了怎么办。串行执行是排队等前面那次跑完堆积过多就会撑爆执行器那边的队列丢弃后续是直接丢掉新来的这次日志里会记一条被丢弃的记录覆盖之前是中断掉正在跑的那次强行开始新的。默认是串行执行这个最安全但也最容易堆积。生产环境里我建议对耗时任务显式配置丢弃后续或者覆盖之前再配合合理的 cron 频率不然队列堆积起来很难看。3. 执行器侧请求进来之后发生了什么调度中心发出来的请求是一个 HTTP POST打到执行器的/run路径上body 是一段 JSON包含 jobId、执行参数、分片序号、分片总数、日志 ID、日志时间戳这些字段。执行器收到之后整个处理链路其实比调度中心那一侧还要绕一些因为涉及线程模型、队列、注解扫描、结果回传好几层。3.1 EmbedServer 的 HTTP 入口执行器内部跑的是一个轻量级的 HTTP 服务基于 Netty 实现监听在配置的端口上默认 9999context path 默认是/xxl-job-executor。这个服务不是业务应用自己的 Web 容器是 XXL-JOB 自己起的一套所以即使你的业务应用是纯后台服务、不暴露 HTTP 端口XXL-JOB 的执行器接口照样能工作。之所以在 2.2.0 之后从早期的 RPC 通信改成 HTTP我理解主要是两个考虑。一个是通用性HTTP 谁都懂抓包、压测、写客户端都方便出问题的时候用 curl 直接打一下接口就能判断是网络问题还是业务问题。另一个是可观测性请求和响应都是明文的 JSON日志打出来一眼能看懂。代价是序列化开销比二进制协议大一点但在任务调度这个场景下QPS 通常远没有到需要抠这点性能的地步。3.2 JobThread 常驻线程与队列模型这是执行器侧最值得仔细看的部分。每个任务在执行器里对应一个常驻的JobThread注意是每个任务一个线程不是每次触发创建一个线程。这个线程内部维护了一个有界阻塞队列默认容量 2000。调度请求进来之后先找到这个任务对应的 JobThread如果还没创建就创建一个然后把请求参数塞进队列。JobThread 的主循环是一个带超时的队列取数操作取到任务就执行取不到就等。等待超时是 30 秒每超时一次就把空闲计数加一连续空闲达到 30 次也就是大约 15 分钟没有任务进来之后这个线程会自己退出并把注册表里的记录清掉。这个设计是为了避免长期没人用的任务一直占着线程资源。队列容量 2000 这个值意味着如果任务堆积超过 2000 个请求后续的请求就会被阻塞策略拦掉。串行执行策略下会直接返回失败给调度中心日志里记一条阻塞策略拦截。我在线上见过一次某个任务因为依赖的下游接口挂了单次执行时间从 200 毫秒涨到了几十秒cron 又是每秒一次结果队列几分钟就堆满了。那次之后我们给这类任务加了超时时间执行超过阈值直接中断避免线程被长期占用。3.3 JobHandler 的扫描注册与调用执行器启动的时候XxlJobSpringExecutor会去扫描 Spring 容器里所有带XxlJob注解的方法把注解里的名字作为 key、方法本身作为 value 注册到一个内存 Map 里。调度请求带过来的执行器任务名就是通过这个 Map 找到对应的方法。方法调用本身走的是反射。这意味着业务方法必须是 public 的参数和返回值都有固定的约定。XXL-JOB 提供了一个XxlJobHelper工具类你可以在方法里用它拿到执行参数、分片序号、分片总数也可以用它主动设置执行结果和日志。这里有个很实用的技巧如果你在方法里不主动调用成功或者失败的方法框架会认为执行成功但日志里不会有你自定义的输出。对于需要排查问题的任务建议至少打一行开始和结束的日志出问题的时候能快速定位到是卡在哪一步。注意反射调用会包一层异常处理业务方法抛出的异常会被框架捕获并记录成执行失败但异常堆栈不一定会完整显示在调度中心的控制台上。如果你的任务失败但看不到堆栈去执行器本地的日志文件里翻通常能找到完整信息。3.4 执行结果的回传与日志落盘执行结束之后结果不是直接同步返回给调度中心的而是走两条路。一条是 HTTP 响应告诉调度中心我收到了这个请求注意这只是接收确认不代表执行成功。另一条是异步回调执行结果被塞进一个专门的回调队列由回调线程攒够一批默认最多 150 条之后批量 POST 回调度中心的回调接口。这个异步设计的意义在于即使调度中心那一瞬间挂了或者网络不通执行器的任务照样能跑完结果先存在本地等调度中心恢复之后再补发。回调失败的结果会被写到执行器本地的一个重试文件里由另一个线程定期重试。这个机制我实测过把调度中心停掉十分钟期间执行器照样在跑任务控制台上看不到日志等调度中心起来之后一两分钟内日志就全补上了挺稳的。日志文件按天分目录存在执行器本地路径类似logs/xxl-job/jobhandler/2024-xx-xx/文件名是任务 ID 加序号。调度中心控制台上看到的执行日志实际上是从执行器拉过来的调度中心本身不存日志内容只存元信息。这就解释了一个常见疑问为什么调度中心数据库会越用越大因为xxl_job_log表记录的是每次触发的元信息任务量一大这个表涨得飞快需要单独做清理策略或者分区。4. 串起一次完整调度从 cron 到期到日志可查前面拆开讲了两侧现在把它们合起来用一条时间线把整个流程走一遍。假设你配了一个每 5 分钟跑一次的任务路由策略是轮询阻塞策略是串行执行有两个执行器实例在线。4.1 触发过程的参数计算与耗时估算第 0 秒调度中心的时间轮预读线程从数据库读到这个任务的trigger_next_time落在未来 5 秒窗口内把它扔进对应秒数的槽位同时把trigger_next_time更新为 5 分钟之后。第 1 秒到第 5 秒之间扫描线程在某一次滴答时取出这个任务交给触发线程池。触发线程根据轮询策略从上一次记录的索引位置往后取一个在线执行器地址比如这次轮到实例 A。接着发起 HTTP POST请求体里带上分片参数。这里顺带说下分片参数的计算如果是最普通的不分片任务分片序号固定是 0分片总数是执行器实例数。如果是分片广播任务调度中心会给每个在线执行器各发一次请求分片序号从 0 递增到实例数减 1每个执行器收到的序号都不一样。执行器 A 收到请求找到任务对应的 JobThread参数入队。JobThread 从队列取出参数反射调用你写的业务方法执行完毕后把结果放进回调队列。回调线程攒批POST 回调度中心的回调接口。调度中心收到回调更新xxl_job_log这条记录的完成时间、执行结果、执行耗时。至此一次调度闭环完成。整条链路的耗时正常情况下从 cron 时间点到日志可查大概是几百毫秒到一秒多。其中时间轮的对齐损失算 0 到 1 秒网络往返算几十毫秒业务执行时间取决于你的代码回调更新算几十毫秒。如果你观察到的延迟明显超过这个量级那就得按环节去排查了。4.2 超时中断、失败重试与告警任务配置里有一个超时时间字段默认是 0表示不限制。一旦你配了非 0 值执行器侧就会用带超时的 Future 来执行任务超时之后尝试中断线程并把结果标记为超时。这里必须提醒一句Java 里线程中断是协作式的如果你的业务代码里有 sleep、wait、IO 阻塞这类可中断操作中断能生效如果是一段纯计算的死循环或者不支持中断的第三方库调用中断是没用的线程依旧会跑下去。所以超时时间是个尽力而为的保险不是绝对保证。失败重试的逻辑在调度中心这一侧。有一个失败监控线程默认每 30 秒扫一次xxl_job_log找出执行结果为失败且还没重试过的记录根据任务配置的重试次数决定是否重新触发。重试的时候会新建一条日志记录所以你在控制台上会看到同一时间点附近有多条记录点进去能看到哪次是重试的。告警是在回调处理的时候触发的配置了邮件或者 Webhook 告警之后失败记录会触发一次告警动作。这里有个容易忽略的点告警是异步的且失败重试期间不会重复告警也就是只有当这条日志的告警状态还没被标记过时才会发。所以你不会收到十条一模一样的告警邮件这点设计还是挺克制的。4.3 分片广播任务的实现细节分片广播是 XXL-JOB 里我认为最有意思的一个特性。它的工作方式是调度中心会给所有在线的执行器各发一次请求每个请求带上不同的分片序号。执行器侧业务代码通过XxlJobHelper.getShardIndex()和getShardTotal()拿到这两个值然后自己按这个序号去切分数据。举个实际的例子你要处理一张有一千万行的表单机跑要两个小时。用分片广播三个执行器实例每个实例拿到序号 0、1、2业务代码里写where id % 3 shardIndex三个实例并发跑理论上时间降到三分之一。这个模式比自己写一个分布式分片框架简单太多而且天然适配执行器实例数变化的场景——你加一个实例分片总数自动变成 4数据切分方式自动跟着变。不过有个坑要注意分片广播的广播是在调度时刻基于当时在线的实例列表来做的如果某个实例在调度之后启动了它不会收到这次的请求那部分数据这次就不会被处理。所以做全量分片任务的时候通常要配合处理完之后做一次补偿校验或者在业务层面记录每个分片的完成状态不然会漏数据。这个细节文档里不太提但实际用起来很容易踩。4.4 GLUE 模式与 BEAN 模式的差别任务除了用注解写在自己的 Spring 容器里还支持 GLUE 模式也就是直接在调度中心的界面上写代码保存之后动态生效不用重新发布应用。支持的语言有 Java、Shell、Python、PHP、NodeJS 这几种。GLUE 的优势是改逻辑不用走发布流程适合那种经常要调整的小脚本类任务。代价是代码存在数据库里版本管理和代码审查比较麻烦而且 GLUE 代码的执行环境和业务应用的类加载器是隔离的你没法直接调用业务应用里的 Bean。所以我的建议是需要访问应用内部服务和数据源的任务用 BEAN 模式纯脚本、纯外部命令的任务用 GLUE 模式别混着用。补充一句GLUE 的每次修改都会在xxl_job_logglue表里留一条历史版本记录理论上可以回滚。但实际用的时候还是建议在外部再做一层代码备份毕竟操作是在生产库上直接改代码心里得有点数。5. 踩坑记录与排查手册前面讲的是它应该怎么工作下面这部分讲它不工作的时候怎么查。这些基本都是我在实际项目里一条条趟出来的。5.1 调度不触发或重复触发怎么查调度不触发按这个顺序查。第一看任务的调度状态是不是被停了界面上的开关有没有打开。第二看trigger_next_time这个字段有没有在正常推进如果它一直停在某个过去的时间不往前走说明预读逻辑没跑到这个任务上可能是任务数超过了预读上限被挤掉了或者 cron 表达式本身有问题导致算不出下次时间。第三看调度中心的日志里有没有抢锁失败的记录集群环境下如果锁一直被某个卡住的实例持有其他实例就干不了活。第四看执行器是不是被摘除了注册表里没有在线节点的话任务会直接记一条失败。重复触发的情况更隐蔽一些。最常见的原因是调度中心集群的锁机制失效比如数据库连接池配置不当导致行锁超时某个实例抢锁失败之后没有正确回退。另一个常见原因是任务被配置了多个执行器分组而你的理解是只会在一个组里跑实际上是每个组都触发一次。还有一个是业务的幂等性没做好任务被重试了一次你看起来就像跑了两次。排查重复触发的时候建议直接查xxl_job_log表看同一个任务在时间上相邻的记录是来自不同的调度中心节点还是同一条记录被重试两种情况处理方式完全不同。5.2 执行器注册不上、地址漂移的处理注册不上的问题核心是网络连通性和地址正确性这两件事。执行器注册的时候会上报自己的机器地址如果这台机器有多张网卡比如同时有内网和公网地址或者有容器网络和宿主机网络框架可能会选错那个。这种情况下就得手动配置上报地址把它固定成调度中心能访问到的那个 IP。地址漂移是容器环境下的高频问题。执行器以容器方式部署每次重启 IP 都会变注册表里的地址跟着变。如果调度中心那一瞬间拿到的是旧地址请求就会失败一次然后触发故障转移路由到其他实例。解决方式通常是给执行器配置固定地址或者在 K8s 里用稳定的 headless service 做服务发现。我个人的经验是容器环境下一定要显式配置上报地址别依赖自动探测。5.3 大任务量下的性能调优清单任务量上来之后性能瓶颈往往出现在三个地方调度中心的数据库、调度中心的线程池、执行器的 JobThread 数量。数据库这一侧xxl_job_log表的膨胀是最要命的。我的做法是按天做批量清理保留最近 7 到 30 天的记录清理的时候用分批删除一次删几千条别一条 SQL 删几百万行把库锁死。xxl_job_registry表的清理是框架自带的不用管。xxl_job_info表通常不会太大但如果任务数上了几万trigger_next_time的索引就值得单独优化一下了。线程池这一侧快池和慢池的最大线程数是可以配的。调整的原则是看你的触发耗时如果触发请求的 P99 耗时是 200 毫秒那么 200 个线程一秒最多能发 1000 个请求你按这个反推需要多少线程就清楚了。别盲目调大线程太多反而会增加上下文切换的开销。执行器这一侧JobThread 是一个任务一个线程如果你有几千个任务理论上是几千个线程这个数量级有点吓人。好在闲置 15 分钟会自动回收所以实际并发数取决于最近有活动的任务数。如果你的应用任务特别多可以考虑把部分低频任务合并到一个 JobHandler 里用一个分发器模式内部再分派减少线程数量。5.4 几个容易忽略的配置项有一个配置项叫 accessToken调度中心和执行器两边都要配且必须一致。这个值不配的话默认是空字符串也就是不做鉴权。生产环境一定要配上不然任何知道执行器地址的人都能往上面发任务请求。还有一个是日志保留天数执行器侧的配置默认是 -1意思是不清理日志会一直堆在磁盘上。我见过一个跑了两年没清过的环境日志目录占了几十 G 磁盘直接把机器写满导致服务挂了。改成保留 7 天或者 30 天配一个定期任务清理就行。最后一个是执行器的线程队列大小默认 2000。如果你的任务都是秒级的轻量任务2000 够用如果是耗时的批量任务堆积起来很容易突破这个值这时候要么降低 cron 频率要么显式配置阻塞策略为丢弃后续避免队列里堆一堆注定要失败的请求。我个人在实际项目里最看重的一件事是给每个任务都配一个合理的超时时间哪怕你一时想不清楚该设多少先设个比正常耗时大十倍的保守值也行。因为超时是唯一能防止一个坏任务拖垮整个执行器的手段其他的调优都是锦上添花这个才是保命的。