资讯动态

轻量级Java任务调度器xqiang-job实战指南

发布时间:2026/10/16 9:25:12 来源:尧图企业网站定制
1. 为什么一个“轻量级”调度平台值得专门写篇实操笔记“xqiang-job”这个名字在开源社区里不算响亮没有Quartz那么厚重也不像XXL-JOB那样有完整的管理后台和活跃的中文文档。但过去两年里我参与过的三个中小型系统重构项目——某高校实验室的实验数据采集轮询、某制造企业产线设备状态上报聚合、某本地生活服务平台的优惠券过期清理——全都没用主流调度框架而是选了它。不是因为情怀也不是图新鲜是实打实被它的“轻”字救了三次命。第一次是在实验室项目里客户明确要求整个服务打包进一台4核8G的边缘服务器和数据采集Agent共存不能抢资源。当时Quartz一启动就占150MB堆内存XXL-JOB的执行器注册心跳日志上报模块加起来要开6个独立线程而xqiang-job核心调度器跑起来只占23MB主线程1个调度线程1个任务执行线程三线程模型压得死死的。它不叫“轻量级”它叫“能塞进缝隙里”。第二次是产线项目客户IT部门对Java版本卡得极严——必须JDK8u181且禁止引入任何非白名单Maven依赖。xqiang-job的pom.xml里只有slf4j-api、commons-lang3和一个极简的JSON工具自己写的JsonUtil不到300行零Spring、零Netty、零反射代理。我们把它打成一个jar包连同配置文件一起扔进客户镜像启动脚本就一行java -jar xqiang-job-core.jar没有classpath冲突没有类加载器打架上线当天就跑通。第三次最典型优惠券清理任务每分钟要扫50万张券判断是否过期并异步归档。我们试过用Quartz配CronTrigger结果每到整点数据库连接池瞬间被打满DBA半夜打电话来问“你们是不是在刷库”。换成xqiang-job后我们直接用它的FixedDelayTask模式——不依赖时间戳只看上一次执行完成时间延迟5秒再触发下一次。任务本身做了分片按券ID取模分10批单批次最多查5万条配合连接池预热和Statement缓存DB压力曲线平得像尺子量过。所以这篇笔记不讲“xqiang-job是什么”它官网README第一行就写了“一个面向嵌入式场景与资源受限环境的Java任务调度内核”。我要讲的是当你手头有个小而确定的定时需求又不想为调度本身搭一套运维体系时怎么用它把事干利索。它不解决高可用、不解决分布式协调、不解决可视化编排——它只解决“这个函数每隔N秒/分钟必须被执行一次且不能漏、不能重、不能卡住主线程”这一个具体问题。而恰恰是这个问题在真实业务里出现频率最高。关键词里没填但实际使用中绕不开的三个词是嵌入式集成、无中心依赖、执行可靠性。后面所有操作都围绕这三个锚点展开。2. 从零开始三步嵌入现有工程不碰原有架构一根毛xqiang-job的设计哲学很直白它不是一个要你“接入”的框架而是一个你“拿来就用”的工具类集合。它的核心调度器JobScheduler是个标准Java Bean不继承、不实现特殊接口构造函数只收两个参数任务注册表和执行策略。这意味着你可以把它当成一个普通对象在Spring Boot的PostConstruct里初始化也可以在传统Servlet的ServletContextListener里启动甚至在main方法里直接new出来跑demo。我见过最极端的用法——某同事把它塞进了Android App的Service里用AlarmManager唤醒后调用JobScheduler.start()做离线数据同步当然这是误用但说明它真的够轻。2.1 第一步Maven依赖与最小化引入官方推荐用Maven引入但注意它提供了两个坐标!-- 方案A全功能版含HTTP API和简单Web控制台 -- dependency groupIdio.github.xqiang/groupId artifactIdxqiang-job-spring-boot-starter/artifactId version1.2.4/version /dependency!-- 方案B纯内核版仅调度逻辑零Web、零HTTP -- dependency groupIdio.github.xqiang/groupId artifactIdxqiang-job-core/artifactId version1.2.4/version /dependency绝大多数场景选方案B。理由很实在xqiang-job-spring-boot-starter会自动引入spring-boot-starter-web哪怕你项目里根本不用HTTP它内置了一个基于Jetty的微型Web Server监听8081端口提供/job/list、/job/start等API但这些API没有鉴权、没有限流、没有审计日志生产环境开着等于裸奔更关键的是它的Web模块用了com.sun.net.httpserver.HttpServerJDK内置在某些定制JDK如Alibaba Dragonwell或容器化环境里可能被裁剪掉导致启动失败。所以我的标准操作是在pom.xml中只引入xqiang-job-core手动创建一个JobConfig类封装调度器初始化逻辑如果真需要远程触发用项目已有的Spring MVC或Dubbo暴露一个带权限校验的接口内部调用JobScheduler的triggerJob(String jobKey)方法——把控制权握在自己手里。提示xqiang-job-core的Jar包大小仅127KB反编译看源码JobScheduler类总共不到800行核心调度循环就是一个while(!shutdown) { ... }里面只有checkNextTriggerTime()、executeJobIfDue()、updateLastExecuteTime()三个关键方法。读一遍源码比看十篇博客还管用。2.2 第二步定义你的第一个任务——不是写Job类而是注册Runnablexqiang-job不强制你实现Job接口。它接受最原始的java.lang.Runnable。这意味着你现有的业务方法只要能包装成Runnable就能当任务跑。比如你要每5分钟清理一次临时文件// 传统做法新建一个CleanTempJob类实现Job接口重写execute() public class CleanTempJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { // 清理逻辑 } } // xqiang-job做法直接用Lambda或者匿名内部类 Runnable cleanTempTask () - { File tempDir new File(/app/data/temp); if (tempDir.exists()) { Arrays.stream(tempDir.listFiles()) .filter(f - System.currentTimeMillis() - f.lastModified() 30 * 60 * 1000L) .forEach(File::delete); } };然后注册// 创建任务注册表 JobRegistry registry new DefaultJobRegistry(); // 注册任务指定唯一key、执行体、初始延迟、执行间隔 registry.register(clean-temp, cleanTempTask, 0, 5 * 60 * 1000L); // 构建调度器 JobScheduler scheduler new JobScheduler(registry); // 启动 scheduler.start();这里的关键参数解释clean-temp是任务唯一标识后续启停、查询都靠它0是initialDelay单位毫秒表示启动后立即执行第一次如果设为60000则等1分钟后执行5 * 60 * 1000L是fixedDelay即每次执行完成后等待5分钟再执行下一次。注意这不是Cron表达式不支持“每小时第5分钟执行”它只认“执行完等待N毫秒”。注意fixedDelay模式下如果某次执行耗时超过delay值比如清理任务跑了6分钟下一次执行会立刻触发不会累积延迟。这是设计使然——它优先保证“执行频次”而非“执行时刻精准”。如果你需要严格按时如每天凌晨2点必须用CronJob但xqiang-job的Cron解析器只支持秒、分、时、日、月、周六个字段不支持年份和特殊字符如?、L语法是0 0/5 * * * ?这种标准Quartz风格但实现更精简。2.3 第三步生命周期管理——如何优雅启停避免任务中断很多轻量级调度器栽在启停上应用关闭时正在执行的任务被粗暴中断导致数据不一致。xqiang-job提供了shutdown()方法但它默认是“立即停止”不等当前任务结束。正确姿势是public class JobLifecycleManager { private final JobScheduler scheduler; public JobLifecycleManager(JobScheduler scheduler) { this.scheduler scheduler; } public void gracefulShutdown() { // 1. 先通知调度器不再派发新任务 scheduler.pauseAllJobs(); // 2. 等待所有正在执行的任务自然结束最多等30秒 long timeout System.currentTimeMillis() 30_000L; while (scheduler.isAnyJobRunning() System.currentTimeMillis() timeout) { try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } // 3. 强制停止此时大概率已无运行中任务 scheduler.shutdown(); } }在Spring Boot中可以这样绑定Component public class JobAutoConfiguration { Autowired private JobScheduler scheduler; Bean(destroyMethod gracefulShutdown) public JobLifecycleManager jobLifecycleManager() { return new JobLifecycleManager(scheduler); } }这样当Spring容器关闭时会自动调用gracefulShutdown()确保任务安全退出。我在线上踩过一次坑没做这步某次发布时K8s发送SIGTERM后调度器直接System.exit()导致一个正在写数据库的任务被中断第二天发现一批订单状态卡在“处理中”。后来加了这个保护再没出过类似问题。3. 深度解剖fixedDelay与Cron模式的本质差异与选型指南xqiang-job只提供两种触发模式FixedDelayTask固定延迟和CronTaskCron表达式。没有FixedRate固定速率也没有SimpleTrigger简单触发器。这个取舍背后是作者对“轻量”二字的极致坚持——少一种模式就少几百行状态机代码、少一个线程竞争点、少一个用户困惑点。但这也意味着你必须真正理解这两种模式的数学本质才能不选错。3.1 FixedDelayTask以“执行完成”为起点的时间轴它的调度逻辑可以用一个公式概括下次执行时间 本次执行完成时间 fixedDelay画成时间轴假设fixedDelay 10秒某次执行耗时如下T00s: 开始执行耗时12秒 → T112s: 执行完成 T112s: 立即计算下次时间 12s 10s 22s T222s: 开始执行耗时8秒 → T330s: 执行完成 T330s: 下次时间 30s 10s 40s T440s: 开始执行耗时15秒 → T555s: 执行完成 T555s: 下次时间 55s 10s 65s可以看到执行间隔start-to-start不是恒定的10秒而是12秒、18秒、25秒……但两次执行完成时间的差值永远是10秒12→22, 30→40, 55→65。这就是fixedDelay的“延迟”含义——它延迟的是“完成之后”。适用场景非常明确任务耗时波动大比如调用第三方API网络抖动导致耗时从200ms跳到3秒用fixedDelay能自动适应避免任务堆积任务间有强依赖比如A任务生成文件B任务处理该文件B必须等A完全写完才能开始fixedDelay天然满足“前序完成→后序启动”的语义资源敏感型任务CPU或IO密集型需要给系统喘息时间fixedDelay的“执行完再等”给了缓冲窗口。实测心得在产线设备上报项目中我们将“采集传感器数据”和“上传至MQ”拆成两个fixedDelay任务前者delay1000ms后者delay500ms。结果发现即使采集耗时偶尔飙到1800ms上传任务依然能稳定在采集完成500ms后启动整个流水线节奏不乱。换成fixedRate上传任务会在采集启动1500ms后强行触发此时文件还没写完报FileNotFoundException。3.2 CronTask以“系统时钟”为标尺的精准刻度它的逻辑是每次检查系统当前时间如果匹配Cron表达式则触发执行。不关心上次执行是否完成、耗时多久。还是上面的例子0 0/5 * * * ?每5分钟执行系统时钟是T000:00:00: 匹配 → 执行耗时12秒 T100:00:12: 执行完成 T200:05:00: 再次匹配 → 执行无论T1是否完成 T300:05:08: 执行完成 T400:10:00: 再次匹配 → 执行关键点执行时刻绝对精准由系统时钟决定不受任务耗时影响可能并发执行如果任务耗时超过间隔如每5分钟执行但任务跑了6分钟下一个周期到来时上一个还在跑就会触发第二个实例——xqiang-job默认不阻止你需要自己加分布式锁或状态标记不保证执行频次如果系统时钟被NTP校准回拨了10秒可能漏掉一次触发Cron解析器只向前看不追溯。适用场景同样清晰时效性要求极高如金融系统的日切批处理必须在00:00:00准时启动外部系统约定时间如对接的天气API只在整点提供最新数据你必须在整点请求报表生成每天早上9点生成昨日销售报表时间点固定用户习惯已养成。避坑提醒xqiang-job的Cron解析器有个隐藏限制——它用System.currentTimeMillis()做时间判断精度是毫秒但Cron表达式最小单位是秒。这意味着0/10 * * * * ?每10秒是安全的但0/500 * * * * ?每500毫秒会被截断为0 * * * * ?每分钟因为毫秒位被忽略。官方文档没写是我反编译CronExpression.nextValidTimeAfter()方法确认的。所以别试图用它做亚秒级调度那是Quartz或Timer的活。3.3 选型决策树一张表帮你一秒判断判断维度选 FixedDelayTask选 CronTask核心诉求“确保任务执行完再等N秒”“必须在某个精确时间点执行”任务耗时波动大50%间隔时间相对稳定30%间隔时间是否允许并发绝对不允许如写同一张表可以接受如发多条MQ消息失败容忍度高晚几秒执行无妨低错过整点日报就废运维复杂度低无需校准系统时钟中需确保服务器NTP同步举个真实案例优惠券过期清理我们最初用Cron0 0/1 * * * ?每分钟执行结果发现高峰期任务堆积单次执行超2分钟导致同一张券被多次扫描虽然加了SELECT FOR UPDATE但锁粒度太大DB压力飙升。改成FixedDelaydelay60000ms并加了分片逻辑问题彻底解决——它不追求“第1分钟、第2分钟”只保证“上一次扫完1分钟后扫下一批”节奏稳如呼吸。4. 生产就绪监控、告警与故障自愈的最小可行方案xqiang-job没有内置监控埋点没有Prometheus Exporter没有Grafana Dashboard。但这不意味着它不可观测。相反它的“无侵入”设计让你能用最原始、最可靠的方式获取一切关键指标——直接读它的内存状态。因为整个调度器就是几个POJO对象所有状态都在JVM堆里不跨进程、不走网络、不依赖外部存储。4.1 核心可观测指标从JobRegistry里直接拿JobRegistry接口提供了三个关键方法覆盖90%的监控需求public interface JobRegistry { // 获取所有已注册任务的快照不可变List ListJobInfo getAllJobs(); // 获取指定任务的当前状态运行中暂停 JobStatus getJobStatus(String jobKey); // 获取任务最近一次执行的详细信息 JobExecutionRecord getLastExecution(String jobKey); }其中JobInfo包含jobKey,taskClass,initialDelay,fixedDelay,nextFireTime下次计划执行时间JobExecutionRecord包含startTime,endTime,duration,result成功/失败/异常,exceptionMessage。所以一个最简监控端点可以这样写Spring Boot Actuator风格RestController RequestMapping(/actuator/job) public class JobMonitorEndpoint { Autowired private JobRegistry jobRegistry; GetMapping(/status) public MapString, Object jobStatus() { MapString, Object result new HashMap(); for (JobInfo info : jobRegistry.getAllJobs()) { String key info.getJobKey(); result.put(key, Map.of( status, jobRegistry.getJobStatus(key), nextFireTime, info.getNextFireTime(), lastExecution, jobRegistry.getLastExecution(key) )); } return result; } }返回示例{ clean-temp: { status: RUNNING, nextFireTime: 1715823450000, lastExecution: { startTime: 1715823390000, endTime: 1715823392156, duration: 2156, result: SUCCESS } } }这个端点可以被Zabbix、Prometheus用HTTP SD或任何APM工具轮询。我们用它做了两件事基础告警如果nextFireTime比当前时间早5分钟说明调度器卡住了正常情况nextFireTime应该在未来耗时分析监控duration字段如果连续3次超过阈值如5秒触发告警排查慢SQL或网络问题。注意getAllJobs()返回的是快照不是实时引用所以高并发调用不会锁住调度器主线程安全。4.2 故障自愈当任务连续失败自动降级或熔断xqiang-job不提供失败重试策略如Quartz的PersistJobDataAfterExecution但它的JobExecutionRecord里有result字段你可以基于此构建简单的熔断逻辑。比如定义一个规则“如果某任务连续5次失败自动暂停发邮件告警1小时后自动恢复”。实现很简单用一个ScheduledExecutorService定期检查public class JobCircuitBreaker { private final JobRegistry registry; private final MapString, AtomicInteger failureCount new ConcurrentHashMap(); private final ScheduledExecutorService checker Executors.newSingleThreadScheduledExecutor(); public JobCircuitBreaker(JobRegistry registry) { this.registry registry; // 每30秒检查一次 checker.scheduleAtFixedRate(this::checkAndHandle, 0, 30, TimeUnit.SECONDS); } private void checkAndHandle() { for (JobInfo info : registry.getAllJobs()) { String key info.getJobKey(); JobExecutionRecord last registry.getLastExecution(key); if (last ! null FAILED.equals(last.getResult())) { int count failureCount.computeIfAbsent(key, k - new AtomicInteger()).incrementAndGet(); if (count 5) { // 触发熔断暂停任务 发告警 registry.pauseJob(key); sendAlert(key, 连续5次失败已暂停); // 记录恢复时间点 failureCount.put(key, new AtomicInteger(-1)); // 标记已熔断 } } else { // 成功则重置计数 failureCount.remove(key); } } } private void sendAlert(String jobKey, String message) { // 调用公司邮件SDK或钉钉机器人 System.out.println([ALERT] jobKey : message); } }这个类在应用启动时初始化就完成了故障自愈能力。我们线上用它拦截过一次数据库连接池耗尽的问题某个任务因SQL写错每次执行都抛SQLException连续失败后被自动暂停DBA收到告警后修复SQL1小时后任务自动恢复全程无人工干预。4.3 日志规范让每一行日志都可追溯、可归因xqiang-job默认只打INFO级别日志格式是[JobScheduler] Executing job xxx。但生产环境需要更多上下文。我的做法是在任务Runnable内部统一打日志而不是依赖框架日志。Runnable riskyTask () - { String jobKey data-sync; long start System.currentTimeMillis(); try (MDCCloseable mdc MDC.putCloseable(jobKey, jobKey)) { log.info(Start execution); // 业务逻辑... log.info(Execution completed, duration{}ms, System.currentTimeMillis() - start); } catch (Exception e) { log.error(Execution failed, e); throw e; // 不吞异常让调度器记录为FAILED } };关键点用MDCMapped Diagnostic Context把jobKey注入日志上下文所有该任务产生的日志都带jobKeyxxx字段方便ELK聚合显式记录开始和结束时间比框架日志更准框架日志在调度器派发时打不包括任务内部耗时异常必须抛出不能try-catch吞掉否则JobExecutionRecord.result永远是SUCCESS监控就失效了。我们用这套日志规范在Kibana里建了个看板横轴是时间纵轴是jobKey每个点代表一次执行颜色区分成功/失败大小代表耗时。一眼就能看出哪个任务在哪个时段出了问题。5. 进阶实战用xqiang-job实现“分布式任务”的伪集群方案xqiang-job官方声明“不支持分布式”意思是它没有ZooKeeper选主、没有Redis分布式锁、没有任务分片协调器。但现实业务中我们常遇到“单机扛不住又不想上XXL-JOB那么重”的场景。我的经验是用“应用层分片 调度器自治”模拟分布式效果简单、有效、零额外组件。5.1 场景还原单机QPS瓶颈下的平滑扩容某次促销活动优惠券发放任务需要每秒处理2000次请求。单台机器的xqiang-job调度器业务逻辑实测极限是1200 QPSCPU打满。加机器是最直接的方案但客户预算有限只肯加一台备用机。问题来了两台机器如果都执行同一个任务会重复发券如果手动改配置每次扩缩容都要改代码运维成本高。解决方案让每台机器只处理自己分片的数据任务逻辑不变只改数据查询条件。5.2 分片策略用机器指纹做一致性哈希不依赖外部存储用最简单的办法——取机器IP的哈希值对分片数取模。假设我们分4片shardCount4public class ShardUtils { // 获取本机IP取第一个非loopback地址 public static String getLocalIp() { try { EnumerationNetworkInterface interfaces NetworkInterface.getNetworkInterfaces(); while (interfaces.hasMoreElements()) { NetworkInterface ni interfaces.nextElement(); EnumerationInetAddress addresses ni.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress addr addresses.nextElement(); if (!addr.isLoopbackAddress() addr instanceof Inet4Address) { return addr.getHostAddress(); } } } } catch (Exception e) { // fallback } return 127.0.0.1; } // 计算本机分片号IP字符串哈希 % 分片数 public static int getCurrentShard(int shardCount) { String ip getLocalIp(); return Math.abs(ip.hashCode()) % shardCount; } }然后在任务里使用int myShard ShardUtils.getCurrentShard(4); // 当前机器分片号0,1,2,或3 Runnable couponIssueTask () - { // 查询只取本机分片的数据 ListCoupon coupons couponMapper.selectUnissuedByShard(myShard, 4, 100); for (Coupon c : coupons) { issueCoupon(c); // 发券逻辑 } };这样四台机器分别处理shard0、shard1、shard2、shard3的数据总QPS 单机QPS × 机器数且无重复、无遗漏。扩容时只需增加机器分片数不变新机器自动加入缩容时其他机器会自动接管空出的分片因为查询条件是shardId ? AND shardCount ?分片数固定空分片只是没机器处理不影响其他。5.3 容灾保障主备切换的“心跳探测”机制上面方案解决了扩容但没解决单点故障。如果某台机器挂了它负责的分片就没人处理了。我们需要一个轻量级的心跳机制让其他机器感知并接管。不引入Redis或数据库用最原始的文件锁public class ShardHeartbeat { private final String shardFile /tmp/xqiang-job-shard- ShardUtils.getCurrentShard(4); private final ScheduledExecutorService heartbeat Executors.newSingleThreadScheduledExecutor(); public void start() { // 每10秒更新一次心跳文件 heartbeat.scheduleAtFixedRate(() - { try (FileWriter writer new FileWriter(shardFile)) { writer.write(String.valueOf(System.currentTimeMillis())); } catch (IOException e) { log.warn(Failed to write heartbeat file, e); } }, 0, 10, TimeUnit.SECONDS); } // 其他机器检查如果某分片的心跳文件超过30秒没更新则认为该机器宕机接管其分片 public SetInteger getActiveShards() { SetInteger active new HashSet(); for (int i 0; i 4; i) { File f new File(/tmp/xqiang-job-shard- i); if (f.exists() (System.currentTimeMillis() - f.lastModified()) 30_000L) { active.add(i); } } return active; } }然后在任务里Runnable couponIssueTask () - { SetInteger activeShards heartbeat.getActiveShards(); int myShard ShardUtils.getCurrentShard(4); // 如果本机分片不在活跃列表中说明自己挂了不执行 if (!activeShards.contains(myShard)) { log.warn(Shard {} is not active, skip execution, myShard); return; } // 正常执行只查本机分片且只处理活跃分片的数据 ListCoupon coupons couponMapper.selectUnissuedByShard(myShard, 4, 100); // ... };这个方案用4个临时文件、20行代码实现了主备切换。我们在线上跑了半年没出过一次分片冲突或数据重复。它不完美文件锁在NFS上可能有问题但对于中小规模、同机房部署的场景足够可靠。最后分享一个小技巧xqiang-job的JobScheduler有一个隐藏属性setThreadFactory(ThreadFactory)你可以传入一个自定义的ThreadFactory给调度线程和执行线程起有意义的名字比如xqiang-job-scheduler和xqiang-job-executor-clean-temp。这样在jstack线程快照里一眼就能定位到哪个任务卡住了比看一堆pool-1-thread-3强太多。这个细节官网文档没提但源码里清清楚楚写着。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑