
告别Scheduled手把手把Spring Boot定时任务迁移到XXL-JOB先说说背景。我接手过一个电商项目初期定时任务不多用Spring Boot自带的Scheduled注解写得很爽——库存超时关单、优惠券过期提醒、每日报表统计一个注解搞定。但到了后期服务拆分成十几个微服务、每个服务多实例部署之后头疼的事就来了Scheduled是进程内调度每个实例都会执行一遍定时逻辑明明只想发一次优惠券通知结果用户收到三条一模一样的短信。改分布式锁数据库锁Redis锁各种方案试了一圈代码越写越恶心最后彻底换成XXL-JOB才算是把这块烂摊子收拾干净。这篇文章就把我这次从Scheduled迁移到XXL-JOB的完整实战过程写出来包含调度中心和执行器的部署配置、Spring Boot项目集成步骤、任务路由策略选型、以及我在生产环境踩过的各种坑。如果你也正在经历定时任务在分布式环境下的“重复执行”“不好管理”“没有运维界面”这些痛点这篇文章应该能帮你省不少时间。1. 为什么我决定放弃Scheduled1.1 Scheduled在单机场景下的舒适区先说句公道话Scheduled在单体应用、单实例部署的场景下完全没有问题而且非常好用。往方法上贴一个注解Spring容器启动后就会自动扫描注册不需要额外部署什么中间件也不引入任何新依赖开发效率是真的高。我自己在个人项目和早期的单体项目里也用得很顺手。比如Component public class OrderTask { private static final Logger log LoggerFactory.getLogger(OrderTask.class); // 每5分钟扫描一次超时未支付的订单 Scheduled(cron 0 */5 * * * ?) public void closeTimeoutOrders() { ListOrder orders orderMapper.selectTimeoutOrders(5); for (Order order : orders) { order.setStatus(OrderStatus.CLOSED); orderMapper.updateById(order); log.info(订单超时关闭{}, order.getOrderNo()); } } }在单实例部署的时候这个逻辑完美工作代码简单到看一眼就懂。但“简单”背后藏着一个前提——系统里只有一个进程在跑这个定时器。一旦部署架构从单实例变成多实例问题就来了。1.2 多实例部署后遇到的三个实际问题第一个问题就是重复执行。应用部署到两台机器上做负载均衡JVM进程各自独立Spring容器各自管理各自的定时任务结果同一个closeTimeoutOrders方法在每台机器上都会执行一遍。业务逻辑如果不做幂等处理用户的优惠券、短信、积分这些就会重复发放。第二个问题是缺少统一监控和运维界面。Scheduled的任务不在同一个地方管理有的分布在订单服务里有的在用户服务里有的在营销服务里十几个服务几十个定时任务想看某个任务上次执行时间、执行结果是成功还是失败、耗时多少完全没有一个统一的平台可以看。每次排查线上问题都要翻好几台机器的日志效率很低。第三个问题是任务的启停和调度策略调整不方便。用Scheduled任务的执行周期写死在代码里想临时把一个任务停下来得改配置重新发布想让某个任务在某台指定的机器上跑也没有原生支持。我当时为了解决重复执行的问题先试过用Redis分布式锁代码长这样Scheduled(cron 0 */5 * * * ?) public void closeTimeoutOrders() { String lockKey lock:closeTimeoutOrders; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.MINUTES); if (!Boolean.TRUE.equals(locked)) { return; // 没拿到锁说明其他实例在执行 } try { // 业务逻辑 } finally { redisTemplate.delete(lockKey); } }能用但真的只是“能用”。锁超时时间设多少合适业务执行超过锁时间怎么办加了锁之后其他实例空转浪费资源怎么办每个定时方法都要写这一坨加锁逻辑还要保证锁的可靠性越写越觉得这不是正路。后来调研了市面上主流的分布式定时任务方案选了XXL-JOB算是从根本上解决了这套问题。2. XXL-JOB核心概念与架构拆解2.1 调度中心和执行器是怎么回事XXL-JOB讲到底是一个“中心化调度 分布式执行”的架构核心就两个角色调度中心Admin和执行器Executor。调度中心是一个独立部署的Web应用负责管理任务信息、下发调度指令、展示执行日志、统计执行结果。你可以理解成“大脑”所有任务的调度策略、下次触发时间计算、任务启停都由它负责。调度中心不执行业务代码只负责“告诉执行器该跑哪个任务了”。执行器是嵌入在业务应用里的一个组件依赖一个XXL-JOB的SDK启动的时候自动向调度中心注册自己告诉调度中心“我在这里我有这些任务可以执行”。调度中心到了执行时间点就通过HTTP回调执行器的接口触发对应任务方法的执行。这个设计最妙的地方在于业务代码和调度逻辑解耦了。业务应用只负责“执行”调度中心只负责“调度”两边通过HTTP协议通信互不干扰。新增一个定时任务不需要改业务代码重新发布在线配置一下调度中心的cron表达式就行了。2.2 任务、触发器、日志三者的关系用Scheduled的时候任务和调度是绑死在一个注解里的。XXL-JOB把这两层拆开了。先说任务JobInfo。在XXL-JOB的调度中心里每条JobInfo记录包含的信息有任务名称、关联的执行器、JobHandler名称即执行器里业务方法的名字、cron表达式、路由策略、阻塞处理策略、超时时间、失败重试次数等。这个配置是可以随时修改的改完点击“更新”立即生效不需要重启业务服务。然后是触发器。调度中心内部会为每个任务生成一个触发器根据cron表达式计算任务的触发时间点。到了触发时间调度中心发起一次调度生成一条调度日志并向执行器发送执行请求。我用的XXL-JOB版本默认是“调度中心负责计算触发时间实行推模式”把任务推给执行器执行。最后是执行日志。每次调度都会在调度中心生成一条完整记录包含调度时间、执行器返回结果、执行耗时、失败原因。执行器端也会把任务的方法执行日志返回到调度中心在调度中心Web界面里可以直接查看任务的完整执行日志不需要再去服务器上翻日志文件。这一点在实际排查问题时特别有用后面我会专门讲到。2.3 调度中心的高可用设计XXL-JOB的调度中心支持集群部署。如果担心单点故障可以部署多个调度中心实例所有实例连接同一个数据库。各个调度中心实例之间通过数据库锁来保证同一个任务在同一时间只会被一个调度中心实例调度这个机制叫“调度锁”原理是在数据库表中竞争获取分布式锁。我这里生产环境用两台机器部署调度中心前面挂Nginx做负载均衡数据库共用一套MySQL。实际运行了几个月没出现过重复调度的问题。调度中心本身是轻量级的一般情况下两实例集群已经足够。3. 部署XXL-JOB调度中心3.1 环境准备和源码获取XXL-JOB是开源项目直接用GitHub上的源码就行。我建议下载release版本源码不建议直接拉master分支因为master上可能有正在开发的新功能稳定性不如release版本。我用的版本是2.3.1目前来看比较稳定。git clone https://github.com/xuxueli/xxl-job.git cd xxl-job git checkout 2.3.1项目是标准的Maven多模块结构主要包含xxl-job-admin调度中心一个Spring Boot应用xxl-job-core执行器SDK核心包业务应用依赖这个xxl-job-executor-samples官方提供的示例执行器本地开发调试的话直接把xxl-job-admin启动起来就行。不过第一步要先把数据库初始化好。3.2 数据库初始化和配置修改XXL-JOB需要一张数据库来保存任务信息、调度日志、执行器注册信息等。在源码的doc/db/路径下有一个tables_xxl_job.sql脚本执行它就能把表结构初始化好。mysql -uroot -p tables_xxl_job.sql脚本执行后会创建xxl_job_*系列的表比如xxl_job_info任务信息表、xxl_job_log调度日志表、xxl_job_registry执行器注册表等。这里要记住调度中心集群部署时多个实例必须连接同一个数据库这样才能通过数据库锁协调调度。数据库搞定之后修改xxl-job-admin的配置文件application.properties主要改两个地方### 数据库连接配置 spring.datasource.urljdbc:mysql://127.0.0.1:3306/xxl_job?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password123456 spring.datasource.driver-class-namecom.mysql.jdbc.Driver ### 调度中心通信token执行器连接时需要配置相同的token xxl.job.accessTokendefault_tokenxxl.job.accessToken是个很容易被忽略但很重要的配置。调度中心和执行器通信时会校验这个token两边必须配置成一样否则执行器注册不上。生产环境一定要改掉默认值设置成一个足够复杂的字符串。3.3 启动调度中心并初始化登录账号修改完配置直接启动XxlJobAdminApplication访问http://localhost:8080/xxl-job-admin默认账号是admin/admin123登录进去就能看到整个调度中心的管理界面。界面主要分几个模块执行器管理、任务管理、调度日志、用户管理等。我第一次打开的时候扫了一圈界面布局清晰功能入口直观基本看一眼就知道哪里配什么东西。特别是“任务管理”页面直接在线操作任务的新增、暂停、触发执行、查看日志这是用Scheduled的时候完全享受不到的体验。4. Spring Boot项目集成XXL-JOB执行器4.1 引入Maven依赖和配置yml调度中心部署好之后接下来就是改造我们的业务应用。为了让业务应用能接收调度中心下发的指令需要在Spring Boot项目中引入XXL-JOB的SDK。dependency groupIdcom.xuxueli/groupId artifactIdxxl-job-core/artifactId version2.3.1/version /dependency然后在application.yml里加上执行器的相关配置xxl: job: admin: addresses: http://localhost:8080/xxl-job-admin accessToken: default_token executor: appname: order-service-executor address: ip: port: 9999 logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30逐项说下关键配置的含义admin.addresses调度中心的地址多个地址用逗号分隔executor.appname执行器名称在调度中心配置任务时要按这个名称匹配对应的执行器executor.port执行器HTTP服务监听的端口调度中心通过这个端口回调执行器executor.logpath任务执行日志的保存路径调度中心展示的执行日志实际上是读取这个路径下的日志文件accessToken必须和调度中心配置的token一致这里有个容易踩的坑执行器的端口要保证不被防火墙挡住而且多实例部署时每台机器的端口要一致因为调度中心是使用appname ip port来定位一个执行器实例的。4.2 编写配置类和JobHandler处理器光有配置还不够要在Spring Boot里创建一个配置类把执行器bean装配进来。我参照官方的示例写了一个XxlJobConfigConfiguration public class XxlJobConfig { Value(${xxl.job.admin.addresses}) private String adminAddresses; Value(${xxl.job.accessToken}) private String accessToken; Value(${xxl.job.executor.appname}) private String appname; Value(${xxl.job.executor.ip}) private String ip; Value(${xxl.job.executor.port}) private int port; Value(${xxl.job.executor.logpath}) private String logPath; Value(${xxl.job.executor.logretentiondays}) private int logRetentionDays; Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor xxlJobSpringExecutor new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); xxlJobSpringExecutor.setAccessToken(accessToken); xxlJobSpringExecutor.setAppname(appname); xxlJobSpringExecutor.setIp(ip); xxlJobSpringExecutor.setPort(port); xxlJobSpringExecutor.setLogPath(logPath); xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays); return xxlJobSpringExecutor; } }这个配置类做的事情就是创建一个XxlJobSpringExecutor的Spring Bean。这个Bean启动后会自动向调度中心发起注册请求。注意一下如果配置项里没写ip它会自动获取本机IP但在某些多网卡环境下可能获取到的不是业务网卡的IP这种情况下就要手动指定executor.ip。接下来把原来用Scheduled注解的方法改造成XxlJob注解的方法Component public class OrderJobHandler { private static final Logger log LoggerFactory.getLogger(OrderJobHandler.class); XxlJob(closeTimeoutOrdersHandler) public void closeTimeoutOrdersHandler() throws Exception { log.info(开始执行超时订单关闭任务); ListOrder orders orderMapper.selectTimeoutOrders(5); for (Order order : orders) { order.setStatus(OrderStatus.CLOSED); orderMapper.updateById(order); log.info(订单超时关闭{}, order.getOrderNo()); } log.info(超时订单关闭任务执行结束共处理 {} 个订单, orders.size()); } }这里参数closeTimeoutOrdersHandler就是这个JobHandler的别名调度中心配置任务时使用的JobHandler名称必须和这里保持一致大小写也一致否则执行器找不到对应的处理方法。4.3 把原来Scheduled的代码干净地剥离改造过程中有一个小细节就是要把原来Scheduled注解和EnableScheduling配置彻底去掉。我一开始改造的时候想着两套机制先并存线上跑几天稳定了再下线Scheduled结果调度中心触发执行了一次本地的Scheduled照样也执行了一次等于重复执行问题一个都没解决。所以建议是这样的如果决定迁移就直接把Scheduled相关的注解和配置一次性删干净不要把两套定时机制混在一起跑。XXL-JOB本身提供了完整的“手动触发一次”功能迁移期间完全可以用调度中心界面手动触发来联调测试没必要保留旧的调度方式。5. 在调度中心配置你的第一个任务5.1 新增执行器配置调度中心登录后第一步先配置执行器。进入“执行器管理”页面点击“新增执行器”填写AppName填执行器配置里的appname比如order-service-executor名称中文名称方便识别比如“订单服务执行器”注册方式选“自动注册”即可执行器启动后会自动注册到调度中心保存后等个十几秒刷新页面如果执行器列表里出现了一台机器的IP和端口说明注册成功了。这一步能成功后面配置任务就会顺很多。5.2 创建任务并配置任务参数执行器配置好之后进入“任务管理”页面点击“新增任务”。这里有几个关键配置项需要仔细说明第一个是JobHandler填的是代码里XxlJob注解的名称比如刚才的closeTimeoutOrdersHandler。第二个是Cron。表达式语法和Spring的Scheduled略有不同XXL-JOB的Cron表达式是Quartz风格的总共7位最后一位是“年”可以省略。我第一次直接用原来的6位cron填进去结果调度中心报错了。正确的写法是0 0/5 * * * ?最后加一个?号表示“不指定”。第三个是路由策略。这个比较重要决定了同一个任务在多个执行器实例之间怎么分配我单独开一节详细讲。第四个是阻塞处理策略意思是上一次任务还没执行完下一次触发时间又到了怎么处理。单机串行的话选“丢弃后续调度”或者“单机串行”都行看业务需求。配置完保存任务默认是启动状态。到这一步一个最简单的分布式定时任务就跑通了。5.3 手动触发任务验证链路任务创建好之后先别急着等定时触发。在任务列表的操作栏里有一个“执行一次”按钮这就是手动触发功能。点击一下然后到“调度日志”页面查看日志记录。如果一切正常日志里会显示调度成功、执行器返回结果成功还能看到完整的执行日志输出。我当时第一次跑通这个链路的时候看到调度日志里出现“成功”两个字心里那块石头才落地。手动触发验证通过之后再等cron时间点自动触发整体流程就非常稳了。6. 路由策略选型多实例部署时任务发给谁6.1 各种路由策略的适用场景XXL-JOB的路由策略是这个框架最实用的功能之一它解决了“任务发给哪个执行器执行”的问题。Scheduled完全没有这个层面的抽象一切都要自己在代码里实现。路由策略有几个常见的选项我结合实际场景说下每个怎么选第一个是轮询Round Robin。任务依次发送给每个执行器实例。适合所有实例处理能力对等、任务本身没有状态依赖的场景能把负载均匀分摊到各个实例上。第二个是故障转移Failover。调度中心按照顺序尝试调用每一个执行器第一个能成功调通的响应就返回后续的就不试了。适合对任务成功率要求高、希望“只要有一台机器活着任务就能跑”的场景。第三个是分片广播Sharding Broadcast。每个执行器都会收到调度指令同时执行任务。每个执行器在执行时会拿到当前实例的“分片序号”和“分片总数”业务代码可以根据分片信息只处理属于自己的那部分数据。比如要批量处理100万条用户数据两台机器每台处理50万条处理速度直接翻倍。第四个是第一个First和最后一个Last。固定调度到第一个或最后一个注册的实例上比较适合只让某一台机器处理的场景比如本地缓存预热。第五个是最不经常使用LFU和最久未使用LRU。这两个是按实例的历史使用频率或最近使用时间来选择用的场景相对少一般轮询就够用了。6.2 我日常使用最多的三种配置根据我这段时间的实战经验90%以上的场景其实只需要三种路由策略轮询任务本身是无状态的、对单次执行耗时不敏感的比如清理日志、同步缓存、统计报表。故障转移任务执行频率低但要求必须执行成功的比如每日对账、月末结算、生成账单。分片广播任务的数据量很大、单机处理耗时长希望通过多实例并行来缩短整体执行时间的比如大批量数据迁移、定时批量推送。调度中心支持随时切换路由策略。也就是说哪怕你一开始配错了、跑了一段时间发现负载不均衡也不用改代码直接管理界面里换一个策略保存就行非常方便。我生产环境里有一个用户画像计算的任务数据量越来越大单机跑需要40分钟后来改成分片广播三台执行器同时跑时间缩短到15分钟以内改造就只是改了一下路由策略没动任何业务代码。这个体验是Scheduled给不了的。6.3 分片广播的代码写法示例分片广播的代码写起来也很简单在方法参数里获取分片信息然后根据分片序号对数据取模XxlJob(shardingUserHandler) public void shardingUserHandler() throws Exception { // 获取分片参数XxlJobHelper是XXL-JOB提供的工具类 int shardIndex XxlJobHelper.getShardIndex(); int shardTotal XxlJobHelper.getShardTotal(); log.info(当前执行器分片信息index{}, total{}, shardIndex, shardTotal); // 查询所有需要处理的用户ID ListLong userIds userMapper.selectAllNeedProcessUserIds(); for (Long userId : userIds) { // 根据分片总数取模只处理属于自己的那部分 if (userId % shardTotal shardIndex) { processUser(userId); } } }这里要注意分片取模的算法一定要保证每个数据只被一个实例处理、并且所有数据都被处理到。最简单的方式就是把数据的主键ID对分片总数取模如果分片总数变化了比如增加了一台机器同一批数据可能分给别的实例但因为每个实例只会处理取模结果等于自己序号的数据依然能保证不重复、不遗漏。7. 实操记录以商城超时关单为例的完整改造7.1 改造前业务现状和痛点我这边一个比较典型的场景是商城订单超时关闭。业务逻辑不复杂用户下单后如果15分钟不支付系统要把订单状态改成“已关闭”把库存释放掉。用Scheduled实现的时候每5分钟扫描一次订单表找出创建时间超过15分钟且状态为“待支付”的订单批量更新。在单实例时代这套逻辑跑得没问题。但服务拆分成多实例之后两台机器都在扫描订单表同一张订单可能被两台机器同时读到。虽然用“更新时带上状态条件”的方式可以避免重复关单但总是会多出一些无谓的数据库查询而且日志里经常看到两台机器报告处理了同一批订单看着就很别扭。7.2 迁移到XXL-JOB的具体步骤我把这个关单任务迁到XXL-JOB上步骤其实不复杂核心就四步第一步在订单服务里引入xxl-job-core依赖配置好执行器参数。订单服务我设的appname是order-service-executor端口是9999。第二步删掉原来Scheduled注解的方法写一个新的带XxlJob注解的方法把原来的业务逻辑原封不动搬进去XxlJob(closeTimeoutOrdersHandler) public void closeTimeoutOrdersHandler() throws Exception { XxlJobHelper.log(开始执行超时未支付订单关闭任务); ListOrder orders orderMapper.selectTimeoutOrders(15); int successCount 0; for (Order order : orders) { int rows orderMapper.closeIfStillPending(order.getId(), OrderStatus.PENDING_PAY); if (rows 0) { stockService.releaseStock(order.getOrderNo()); successCount; XxlJobHelper.log(订单已关闭{}, order.getOrderNo()); } } XxlJobHelper.log(任务执行完成共处理 {} 个订单, successCount); }注意这里我用了XxlJobHelper.log()而不是log.info()这个日志会同步到调度中心的日志详情里在线就能看到。第三步在调度中心里配置执行器order-service-executor然后新增任务JobHandler填closeTimeoutOrdersHandlercron填0 0/5 * * * ?路由策略选“轮询”阻塞处理策略选“丢弃后续调度”。第四步发布完代码之后先点“执行一次”手动验证看调度日志里的执行结果和日志输出确认没问题再修改cron等待自动触发。7.3 迁移后的收益和对比迁移完成之后直观的感受就是清清爽爽。原来两台机器都在跑的任务现在调度中心会按轮询策略把任务分发给其中一台机器执行另一台机器不会空转。任务状态在调度中心一目了然上次执行时间、上次调度结果、执行日志全部在线可查。另外还有一个意外收获——排错效率提高了。以前查定时任务执行失败要去好几台服务器翻日志文件用grep搜关键字。现在直接在调度中心的调度日志里点开对应的调度记录能看到任务执行到哪个步骤、报了什么错整个排查时间从半小时缩短到两分钟。8. 常见问题与排查技巧实录8.1 调度日志显示成功但业务没执行这个问题我记得特别清楚也是很多刚上手XXL-JOB的朋友最容易遇到的一个坑。调度日志显示调度成功、执行器返回成功但业务数据没变化。排查思路是这样先看执行器注册是否正常。如果执行器没有成功注册到调度中心任务的JobHandler就找不到可用的执行器这时候调度应该会失败。但如果执行器注册正常调度也显示成功就很有可能是JobHandler名称没对上。我遇到的情况就是代码里XxlJob(closeTimeoutOrdersHandler)和调度中心配置的JobHandler名称没对齐。调度中心里配置的继承了一个旧名字执行器这边改成了新名字结果调度中心把指令发到了执行器执行器收到后发现没有对应名字的handler返回失败但调度日志里显示的是“执行器返回状态失败”因为错误信息不够显眼一不留神就忽略了。后来我在调度日志里点进详情才看到“job handler not found”的报错。所以排查这类问题的顺序是先确认执行器在线、再确认JobHandler名称一致、再确认日志详情里的具体报错信息。8.2 执行器注册不上界面看不到实例如果调度中心的执行器管理页面里看不到执行器实例或者显示“注册方式为自动注册但机器列表为空”最常见的两个原因第一个是accessToken不一致调度中心配置的是default_token执行器的application.yml里配的是别的值两边对不上注册请求会被拒绝。第二个是网络不通执行器的port端口被防火墙挡了或者调度中心和执行器不在同一个内网网段。排查技巧是直接从调度中心所在机器去访问执行器的HTTP端口执行器SDK暴露的端口实际是一个Jetty或Netty的服务。用curl http://执行器IP:端口/如果通说明主链路没问题再回去检查token。8.3 任务重复执行怎么排查最后说一个分布式定时任务最敏感的问题重复执行。虽然XXL-JOB在调度层面已经做了很多防重复机制但某些极端情况下依然可能出现问题。比如任务配置了故障转移路由策略调度中心把任务发给执行器A但执行器A执行到一半宕机了没有及时响应调度中心调度中心认为调用失败又把任务发给执行器BB重新执行了一遍。虽然执行器A那边的任务可能因为进程挂了没执行完但从业务角度看断点在哪里不好说就有可能产生重复处理。针对这种情况我的做法是重要任务在业务代码里做幂等保护不依赖调度框架保证绝对不重复而是保证“重复执行也不会出错”。比如关单任务里用条件更新UPDATE order SET status CLOSED WHERE id ? AND status PENDING_PAY只有状态还是待支付的时候才能更新成功这样就算被重复调度第二次执行时行数返回0不会产生副作用。这个习惯是从Scheduled时代养成的从架构上就做到“框架即使出了意外业务也不会出bug”。9. 与Spring Boot生态的联动与扩展经验9.1 Spring Boot版本升级时执行器要注意什么如果你用的Spring Boot版本比较新比如Spring Boot 3.x或者Java 17/21引入XXL-JOB时需要注意版本兼容问题。XXL-JOB 2.3.1是多年以前发布的版本底层依赖的Servlet API和Spring版本都比较老直接用在Spring Boot 3上大概率会报类冲突或者自动配置失效的问题。我用过的建议组合是Spring Boot 2.x配XXL-JOB 2.3.1/2.4.0这两个搭配相当稳定。如果项目已经升级到Spring Boot 3.x就需要关注XXL-JOB官方更新的版本或者自己手动适配执行器SDK里的一些类路径变化。这类兼容性问题在官方Issue和社区里都有讨论升级之前先去搜一下能省不少事。9.2 虚拟线程与JobHandler的配合思路JDK 21的虚拟线程是这个话题的延伸。虚拟线程能显著提升I/O密集型任务的并发能力定时任务也分两类CPU密集型和I/O密集型。如果是I/O密集型的定时任务比如大批量读取数据库、调用第三方接口、发消息把线程池换成虚拟线程方案单实例能扛起的并发量会有质的提升。实践上可以给执行器单独配置一个线程池核心任务在池子里执行。做大促期间的批量推送任务时我把执行器线程池参数调整到位之后单台机器的单任务吞吐量提升非常明显。不过要注意虚拟线程并非万能CPU密集型的计算任务该用固定线程数还是要用固定线程数。9.3 与Spring Cloud微服务架构的结合方式如果你的系统已经上了Spring Cloud微服务架构XXL-JOB依然能很好地融入进来。每个业务微服务只需要把自己的执行器SDK引入并配置好然后在调度中心给每个服务创建一个执行器分组任务的归属就非常清晰了。网关服务负责处理外部请求定时任务在各自的业务服务内部通过调度中心统一管理。一个服务里可以有多个JobHandler配置多个任务对应不同的业务场景。这种模式下XXL-JOB其实承担了“分布式任务网关”的角色和Spring Cloud Gateway的HTTP网关职责互补都是微服务架构中不可或缺的中枢组件。9.4 日志保留与监控告警的经验补充最后提一个有价值的经验日志保留天数不要设置得太短也不要太长。默认30天够用如果业务要求审计留档适当调长到60天或90天。调度日志会占用数据库空间日志文件会占用磁盘空间所以每天定时清理XXL-JOB的系统日志也是一个不错的思路其实这也算是一个可以挂在XXL-JOB自己上面的定时任务挺有意思的。生产环境强烈建议配置任务失败告警。XXL-JOB自带邮件告警功能在任务配置里设置好告警邮箱任务调度失败或执行失败时调度中心会自动发告警邮件。我接到过凌晨三点的告警邮件爬起来一看就是某个第三方接口超时导致任务失败派单排查一个多小时。虽然没有彻底消除故障但至少没有让故障在用户侧暴露出来告警的价值就是这个。10. 迁移过来之后的几点个人心得项目全量迁移到XXL-JOB也有几个月了回过头来想其实Scheduled本身没有错选错场景才是问题。单体单实例用Scheduled是最高效的选择但一旦服务拆分成了多个实例、定时任务数量变多、需要监控和运维管理中心化的任务调度平台几乎是刚需。XXL-JOB的优劣势都很明显。部署和集成确实比Scheduled要多一步需要多维护一个调度中心但它带来的统一管理、在线配置、可靠调度和可观测性是后者永远给不了的。我个人在实际使用中最满意的一个特性还是“在线改cron不重启服务”这个能力——以前用Scheduled改一个执行周期要重新走一遍发布流程现在直接在页面上改完成之后立即生效对运维人员来说体验是质的变化。再分享一个小技巧作为收尾迁移到XXL-JOB之后不要急着把老任务一次性全部迁完。建议先挑一个不核心、执行频次低的任务做试点完整跑通流程之后再批量迁移其他任务。我在改订单关单任务的时候也犹豫过线上直接切换会不会出事但实际上调度中心的手动触发功能给了我充分的测试空间联调测试没有任何压力。如果你正在为分布式环境下的Scheduled重复执行问题发愁现在就可以动手部署一个调度中心试试了。