接手一个跑了七八年的 JSP 老项目,最让人头疼的往往不是页面丑、架构旧,而是那些写死在配置文件和代码里的定时任务。想改个执行时间,得改 XML、重新打 war 包、挑个半夜停服重启,稍微手抖一下还会连带把别的功能搞挂。这套流程走下来,一个小需求能拖垮半个团队。这篇内容要聊的就是:在一个传统的 JSP 工程里引入 Apollo 配置中心,把定时任务的 cron 表达式、开关状态、批量条数这些动态参数从代码里抽出来,做到改完配置几秒钟内生效,不重启、不发版。核心关键词就是JSP 项目、Apollo、动态参数、定时任务。适合正在维护老系统、被"改个参数重启一次"折磨过的后端同学,也适合刚接触配置中心、想知道动态刷新在非 Spring Boot 环境下怎么落地的新手。无论你手上是 Quartz、Spring Task 还是原生 Timer,下面的思路和代码都能直接拿去改。
1. 老 JSP 项目为什么必须把定时任务参数交给配置中心
1.1 写死在代码和 XML 里的定时任务,到底卡在哪
先说清楚痛点,不然很多人会觉得"配置中心是微服务才用的东西,我一个 war 包凑什么热闹"。我见过最常见的三种写法:第一种是web.xml里配org.springframework.web.context.ContextLoaderListener加一段<task:scheduled-tasks>,cron 写在spring-task.xml里;第二种是 Quartz 的quartz.properties配合jobdetail的 XML 声明;第三种更狠,直接new Timer()或者ScheduledExecutorService.scheduleAtFixedRate,间隔时间硬编码成1000 * 60 * 30。这三种写法的共同问题是:参数是编译期或打包期确定的,线上想调整必须走发布流程。
发布流程在老项目里意味着什么?意味着要申请停服窗口、要通知业务方、要准备回滚方案、要有人半夜盯着日志。而定时任务这类需求,往往只是"高峰期把批处理从 5 分钟改成 15 分钟"这种临时性调整。为了一个数字动用整套发布流程,成本收益完全不成比例。更麻烦的是紧急场景:某天凌晨任务把数据库压垮了,你手上没有发布权限,只能眼睁睁看着它每五分钟跑一次。
还有一种隐性成本是多人协作的冲突。配置文件是共享的,A 想改任务一的时间,B 想改任务二的开关,两个人改同一个文件,合并冲突、互相覆盖,最后谁都不敢动。把参数挪到配置中心之后,每个 key 独立管理,改谁的都不影响别人,还能看到谁在什么时候改了什么值,出了问题有据可查。
1.2 选型对比:Apollo、数据库表、JMX、本地文件
把参数外置的方案不止一种,我在不同项目里都试过,这里做个横向对照,方便你判断自己的场景该选哪条路。
| 方案 | 生效方式 | 生效延迟 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| Apollo 配置中心 | 客户端长轮询推送 | 通常 1-3 秒 | 需要部署一套服务端 | 多节点、需要审计和灰度 |
| 数据库参数表 | 定时轮询或手动触发 | 取决于轮询间隔 | 低,但要做缓存 | 已有 DB 且变更不频繁 |
| JMX / MBean | 运维工具远程调用 | 即时 | 中,需要开放端口 | 单机、临时排查 |
| 本地 properties 文件 | 手改 + 重启或热加载 | 慢,易出错 | 低 | 单机小项目 |
| 环境变量 | 重启生效 | 慢 | 低 | 容器化部署 |
为什么最后选了 Apollo?核心是三点。第一,它是推拉结合的长轮询模型,客户端发起一个带超时的 HTTP 请求挂起在服务端,配置一变服务端立刻返回,客户端拉取后写入本地缓存并回调监听器,正常情况下一两秒就能生效,比数据库轮询那种"30 秒一次"的粒度精细得多。第二,它有本地缓存兜底,配置中心挂了、网络抖了,客户端读的是本地文件快照,任务不会因为拉不到配置就停摆。第三,有发布历史和灰度能力,改错了能一键回滚,还能只对某几台机器灰度验证,这对老系统改造来说太重要了——你永远不敢一次性把新 cron 推给所有节点。
数据库方案我也用过,问题在于"谁来触发重调度"。你得再写一个轮询线程去比对参数表,轮询间隔长了生效慢,短了对 DB 又是压力,而且多节点同时轮询还得处理并发。JMX 适合救火不适合常态运营,端口开放本身就是安全隐患。所以综合下来,Apollo 是这类场景性价比最高的选择。
1.3 方案全景:一次配置变更的完整链路
在动手写代码之前,先把整条链路在脑子里跑一遍,后面每一步才知道自己在干什么。完整的链路是这样的:运维在 Apollo 管理台修改task.cron的值并发布,服务端把新配置推到长轮询挂起的客户端连接上,客户端收到通知后拉取全量配置、写入本地缓存文件、对比出变更的 key,然后回调所有注册在这个 namespace 上的ConfigChangeListener。监听器里我们做两件事:一是校验新值是否合法,二是调用调度器的重调度接口。调度器把旧触发器换掉,下一次触发就按新 cron 走。
这里有个关键的设计决策要提前定:监听器里绝对不能直接跑任务逻辑。监听器的回调线程是 Apollo 客户端的网络线程,你在里面做耗时操作会阻塞后续配置的推送。所以监听器只做"解析 + 校验 + 通知调度器"这三件轻量的事,真正的业务逻辑还是在任务线程里跑。
另一条链路是任务执行时的参数读取。很多人会图省事,在execute方法里每次都调config.getProperty("batch.size")。这个调用本身是内存读,速度很快,但涉及到字符串转数字、默认值处理、空值判断,写多了代码会很脏。我的做法是在工程里维护一个DynamicTaskConfig的单例持有类,所有参数以volatile字段的形式缓存,监听器触发时统一刷新,任务执行时直接读字段。一次内存读,零开销。
2. Apollo 客户端接入 JSP 工程的实操步骤
2.1 依赖引入与 jar 冲突排查
JSP 项目一般是 Maven 或老式的WEB-INF/lib手工管理。Maven 的话加两个依赖就够:apollo-client本体,以及它依赖的 Guava。为了不让示例里出现容易混淆的坐标信息,下面用占位符表示 groupId,你直接从官方仓库复制即可,artifactId 和 version 保持一致就行。
<dependency> <groupId>${apollo.client.groupId}</groupId> <artifactId>apollo-client</artifactId> <version>2.1.0</version> </dependency>版本选择上有讲究。如果你的项目还在 JDK 7 上跑,只能用 1.x 系列,2.x 要求 JDK 8 起步。判断方法很简单,看pom.xml里的maven.compiler.source,或者直接在 Tomcat 上跑个System.getProperty("java.version")。升级 JDK 的成本远高于选个老版本客户端,别为了用新特性给自己挖坑。
依赖加进去之后,第一件事是跑一次mvn dependency:tree看冲突。老 JSP 项目最典型的冲突是这三个:Guava 版本打架,Apollo 需要较新的 Guava,而项目里的某个老组件锁着 Guava 14;slf4j 与 log4j 桥接包重复绑定,启动时刷一屏Class path contains multiple SLF4J bindings;HttpClient 版本冲突,Apollo 用的是自己 shade 过的,一般没事,但如果你项目里也引入了httpclient且版本很老,可能出现NoSuchMethodError。
处理冲突的手段就两条:<exclusions>排掉传递进来的老版本,或者用<dependencyManagement>统一提升版本。我的经验是优先排除、谨慎升级,因为老项目里哪个组件依赖了 Guava 的哪个方法,你根本查不清。如果实在排不干净,可以把apollo-client用maven-shade-plugin打成 fat jar 再引,用重定位包名的方式彻底隔离,代价是包体积大一点。
还有一个坑要提前说:war 包部署到 Tomcat 时,WEB-INF/lib里的 jar 和 Tomcat 的lib目录加载顺序是有区别的。如果配置中心客户端依赖的某个包在 Tomcat 的lib里也有一份,且版本更老,会出现"编译没问题、运行就报错"的情况。排查方法是在启动日志里搜索报错类名,然后去 Tomcat 目录下find . -name "*.jar" | xargs -I {} unzip -l {} | grep 类名,虽然笨但有效。
2.2 app.properties 与启动参数怎么放
Apollo 客户端启动时需要知道三件事:我是谁(app.id)、我去哪拿配置(meta server 地址)、我在什么环境(env)。这三项有两个投放位置,选哪个取决于你的部署方式。
第一种是放在src/main/resources/META-INF/app.properties里,打包后位于WEB-INF/classes/META-INF/app.properties。内容就一行:
app.id=legacy-jsp-task-app这种方式适合 app.id 固定、所有环境都一样的情况。缺点是环境信息(env、meta 地址)不能写在这里,因为它跟着包走,你不可能给每个环境打一个包。
第二种是通过 JVM 启动参数注入,在 Tomcat 的catalina.sh(或setenv.sh)里加:
JAVA_OPTS="$JAVA_OPTS -Dapp.id=legacy-jsp-task-app \ -Denv=PRO \ -Dapollo.meta=http://config-host-a:8080 \ -Dapollo.cacheDir=/data/appdata/apollo-cache"这种方式的优势是同一份 war 包可以部署到测试、预发、生产,靠启动参数区分环境,符合"一次构建、多处部署"的原则。而且运维改参数不需要开发重新打包。
注意:
-Denv的取值必须是配置中心里已经创建好的环境名,且通常是大写。写错大小写会导致客户端连上默认环境,表现是"配置明明发了但客户端读不到",这种问题排查起来非常浪费时间。
如果项目是用容器化方式部署的,把上面这些-D参数改成环境变量APP_ID、ENV、APOLLO_META也能生效,客户端会优先读系统属性,读不到再读环境变量。这个优先级顺序要记住:JVM 系统属性 > 环境变量 > 配置文件。
2.3 缓存目录、集群名与超时参数
缓存目录是最容易被忽略但最容易出事的一项。客户端首次拉取配置成功后,会把配置以 JSON 形式写到本地磁盘,路径默认在 Linux 下是/opt/data/{appId}/config-cache,Windows 下是C:\opt\data\...。问题在于:Tomcat 进程的启动用户往往没有/opt目录的写权限,客户端会静默降级成"不写缓存",日志里只有一行 WARN,很容易被忽略。结果就是配置中心一抖动,客户端拉不到配置,进程重启后直接拿空配置跑任务。
所以上线前必须显式指定一个有写权限的目录,并且确认启动用户能创建文件。检查方法:
sudo -u tomcat mkdir -p /data/appdata/apollo-cache sudo -u tomcat touch /data/appdata/apollo-cache/.probe && echo OK集群名(cluster)在单机房场景下不用管,默认default。但如果你的系统是两地部署,希望不同机房的机器读到不同的分片配置(比如各机房的任务执行时间错开),就可以用-Dapollo.cluster=SH这种方式区分,然后在配置中心为对应集群单独建配置。这个能力在动态任务场景里挺有用,能让两地机房错峰跑批处理。
超时参数方面,客户端拉取配置默认的 connect timeout 是 10 秒、read timeout 是 60 秒(长轮询的挂起时间)。内网环境一般不用调。但如果你发现任务启动特别慢,可能是首次ConfigService.getConfig()在等超时——默认情况下它是非阻塞的,直接返回内存里的 Config 对象,不会卡住启动;但如果你调了带 timeout 参数的重载方法,就会阻塞。这点在初始化顺序设计时要注意,我会在 2.4 里说明。
2.4 五行代码验证接入是否成功
写业务逻辑之前,先用最少的代码验证链路通了。建一个最普通的 Servlet 或者 JSP 页面,写:
Config config = ConfigService.getConfig("application"); String appId = config.getProperty("app.id.check", "not-found"); System.out.println("[apollo-probe] key=app.id.check value=" + appId);在配置中心里给这个应用的applicationnamespace 加一个app.id.check=hello并发布,然后访问这个页面看控制台输出。如果打印出hello,说明从服务端到客户端的链路是通的。如果打印not-found,按这个顺序查:先看启动日志里有没有Apollo config service相关的 INFO 行,确认客户端找到了 meta 地址;再看日志里打印的appId、env是不是你期望的值;最后查缓存目录下有没有生成文件。
有一个细节值得说:ConfigService.getConfig(namespace)这个调用在第一次执行时才会触发远程拉取,之前的任何代码都只是读取本地配置。所以如果你把这段验证代码放在一个从不执行的类里,它永远不会连服务端。这也是为什么初始化必须放在ServletContextListener里——保证应用启动时就完成第一次拉取。
public class ApolloBootstrapListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { Config config = ConfigService.getConfig("application"); config.addChangeListener(new AppConfigChangeListener()); sce.getServletContext().log("[apollo] bootstrap done"); } @Override public void contextDestroyed(ServletContextEvent sce) { } }在web.xml里注册:
<listener> <listener-class>com.example.legacy.ApolloBootstrapListener</listener-class> </listener>注意:
web.xml中<listener>是按声明顺序初始化的。Apollo 的监听器必须放在 Quartz 或者 Spring 的监听器之前,否则调度器启动时读到的还是默认参数,会出现"第一次触发用的是旧 cron"的现象。这个顺序问题我踩过一次,排查了半天。
3. 动态参数的分层设计与读取性能
3.1 参数分四类,处理方式完全不同
把所有参数一视同仁地丢进监听器里刷新,是最容易把事情搞乱的做法。我在实际项目里把动态参数分成四类,每类的处理机制不一样,这个分类直接决定了代码结构。
| 参数类型 | 典型例子 | 生效机制 | 风险等级 |
|---|---|---|---|
| 调度类 | task.cron | 必须重调度触发器 | 高,解析失败任务停摆 |
| 开关类 | task.enabled | 暂停/恢复 Job | 中,误关导致数据断档 |
| 执行类 | task.batch.size | 下次执行时读取 | 低,立即生效 |
| 连接类 | task.retry.times | 下次执行时读取 | 低,影响重试策略 |
调度类参数是最麻烦的,因为它改变了调度器的内部状态,不是简单赋值能搞定的。cron 变更必须走rescheduleJob,而rescheduleJob本身可能失败(触发器不存在、调度器已关闭、cron 格式非法)。所以这类参数的处理必须包在 try-catch 里,并且设计成"解析成功才替换"的语义。
开关类参数看着简单,但也有陷阱。如果把"关闭任务"实现成if (!enabled) return;,任务其实还在被调度,只是每次空跑一下,日志里一堆"skipped";如果实现成scheduler.pauseJob(),那才是真正的停止触发。两种方式各有适用场景:前者适合快速开关,后者适合长期停用。我的做法是两者都做——开关关闭时先pauseJob,同时在execute开头也加一次判断,防止暂停指令还没生效时正在执行的那一次跑起来。
执行类参数就轻松多了,volatile字段一改,下次任务读取就是这个值。唯一要注意的是类型转换的兜底,配置中心里填了个 "abc" 到整型参数上,不能让整个任务抛NumberFormatException挂掉,必须有默认值兜底。
3.2 单例监听器与本地快照
监听器的注册有个硬性约束:同一个 namespace 上不要重复注册监听器。JSP 项目里如果有人在 Servlet 的init方法里注册、有人在 Listener 里注册,应用重启几次就挂了十几个监听器,每次配置变更回调十几次,重调度也执行十几次。虽然结果幂等,但日志会刷屏,而且每次回调都拿锁,并发高的时候容易出问题。
正确的做法是做成单例持有类,用一个静态内部类保证只初始化一次:
public final class DynamicTaskConfig { private static final Config CONFIG = ConfigService.getConfig("task-config"); private static volatile String cronExpr = "0 0/10 * * * ?"; private static volatile boolean enabled = true; private static volatile int batchSize = 200; static { reloadAll(); CONFIG.addChangeListener(event -> handleChange(event)); } private static void reloadAll() { cronExpr = readString("task.cron", cronExpr); enabled = readBoolean("task.enabled", enabled); batchSize = readInt("task.batch.size", batchSize); } private static void handleChange(ConfigChangeEvent event) { for (String key : event.changedKeys()) { ConfigChange change = event.getChange(key); String newValue = change.getNewValue(); try { switch (key) { case "task.cron": if (CronValidator.isValid(newValue)) { QuartzRescheduler.reschedule(newValue); cronExpr = newValue; } break; case "task.enabled": boolean flag = Boolean.parseBoolean(newValue); QuartzRescheduler.setEnabled(flag); enabled = flag; break; default: reloadAll(); } } catch (Exception e) { log.error("[apollo] apply change failed key={} value={}", key, newValue, e); } } } }这段代码里有几个刻意的设计。第一,cronExpr只在reschedule成功之后才赋值,保证内存里的值永远和调度器的真实状态一致,不会出现"配置读到了新值但调度器还是旧值"的错位。第二,switch的default分支走全量刷新,这样以后加新参数不用改回调逻辑。第三,所有异常都被捕获并记日志,绝不让异常冒泡到 Apollo 的回调线程——一旦回调线程挂了,后续所有配置变更都收不到,这是最致命的。
CronValidator这个校验器别偷懒跳过。Quartz 的CronScheduleBuilder.cronSchedule()遇到非法表达式会抛RuntimeException,而且异常信息很含糊。提前用CronExpression.isValidExpression()校验,能给出明确的报错,也避免把调度器搞成半死不活的状态。
3.3 读取性能与变更延迟
关于性能,先说结论:读配置不慢,但不要在热路径里反复读。config.getProperty是纯内存操作,纳秒级,但如果你在一个每秒执行上百次的任务里每次都调,再加上字符串比较和类型转换,累积起来也是开销。更重要的是代码可读性,满屏的config.getProperty("xxx", "10")会让人搞不清这个任务到底依赖哪些参数。
用volatile字段缓存之后,读取就是一次内存访问,和读普通变量没区别。volatile保证了可见性,配置线程改了值,任务线程下一次读就能看到,虽然有一定概率读到旧值(因为内存屏障的时机),但对定时任务这种秒级粒度完全够用。如果你对一致性要求极高,可以加个AtomicReference包装,不过我实际经验是没必要。
变更延迟方面,正常网络下从点"发布"到客户端生效是 1 到 3 秒。如果发现延迟明显更长,通常是这几种情况:客户端和服务端之间网络不稳,长轮询频繁超时重连;配置的 namespace 特别大(几百个 key),序列化和对比耗时;客户端 JVM 负载高,回调线程被其它任务抢占。前两种可以在服务端日志和客户端日志里看到拉取耗时的记录,第三种只能靠监控线程池来发现。
还有一个容易被忽略的点:Apollo 客户端在首次加载完配置后,也会触发一次变更回调,此时changedKeys包含所有 key。这意味着如果你的监听器里做的是"增量更新"的逻辑,第一次回调会变成"全量更新",需要保证逻辑幂等。我的处理方式是在静态块里手动调一次reloadAll(),然后让监听器的首次回调也走同样的逻辑,重复执行一次无副作用。
4. 让 Cron 表达式实时生效:Quartz 重调度实操
4.1 版本差异与初始化方式
老 JSP 项目里的调度器基本是这三种:Quartz 1.8、Quartz 2.x、Spring 的TaskScheduler。它们的重调度 API 完全不同,写代码之前先确认版本。
Quartz 2.x 是主流,核心类是org.quartz.Scheduler,通过StdSchedulerFactory创建。如果是 Spring 集成模式,直接用注入的SchedulerFactoryBean拿到Scheduler。判断版本很简单,看pom.xml里的 artifactId:quartz版本号以 2 开头就是 2.x。
Quartz 1.x 的 API 是另一套,Trigger和JobDetail的构造方式都不一样,rescheduleJob的签名是rescheduleJob(String triggerName, String groupName, Trigger newTrigger)。如果你的项目还在 1.x,强烈建议先花半天升级到 2.x,改动量不大但后续维护省心很多。升级主要改三处:包名从org.quartz换成org.quartz.impl(部分类),SimpleTrigger的构造从new SimpleTrigger(name, group, repeatCount, interval)改成 builder 模式,JobExecutionContext的方法名有微调。我升过一个,一个下午搞定,之后写代码顺畅太多了。
初始化时机是另一个关键。Quartz 的Scheduler必须在 Apollo 的首次配置加载之后启动,否则会用默认的 cron 建触发器,等配置推过来再重调度,多了一次无意义的触发。用ServletContextListener的顺序保证:先注册 Apollo 的 listener,再注册 Quartz 的 listener。如果 Quartz 是 Spring 管理的,那就要保证 Spring 容器的启动在 Apollo 初始化之后——这种情况走ApplicationContext的ContextRefreshedEvent里做一次手动 reload 更保险。
4.2 rescheduleJob 完整代码与异常处理
这是整篇的核心代码,我把它整理成一个工具类,直接抄过去改包名就能用。
public final class QuartzRescheduler { private static Scheduler scheduler; public static synchronized void init(Scheduler s) { scheduler = s; } public static void reschedule(String newCron) throws SchedulerException { if (scheduler == null) { log.warn("[quartz] scheduler not ready, skip reschedule"); return; } TriggerKey tk = TriggerKey.triggerKey("dataSyncTrigger", "syncGroup"); Trigger oldTrigger = scheduler.getTrigger(tk); if (oldTrigger == null) { log.warn("[quartz] trigger not found: {}", tk); return; } Trigger newTrigger = oldTrigger.getTriggerBuilder() .withIdentity(tk) .withSchedule(CronScheduleBuilder .cronSchedule(newCron) .withMisfireHandlingInstructionDoNothing()) .build(); scheduler.rescheduleJob(tk, newTrigger); log.info("[quartz] rescheduled: {} -> {}", oldTrigger.getCronExpression(), newCron); } public static void setEnabled(boolean enabled) throws SchedulerException { JobKey jk = JobKey.jobKey("dataSyncJob", "syncGroup"); if (enabled) { scheduler.resumeJob(jk); } else { scheduler.pauseJob(jk); } } }几个要点解释一下。withMisfireHandlingInstructionDoNothing()是"错过的触发不再补",这个策略对定时任务很重要。老项目里经常出现任务执行时间长于间隔时间的情况,如果配了补触发策略,重启之后会一口气把积压的几十次全部跑一遍,直接把数据库打满。用 DoNothing 是最安全的,错过就错过,下一次正常跑。
rescheduleJob的返回值是第一次触发的时间,可以用来做日志或者监控。注意它会保留原来的 JobDetail 和 JobDataMap,只是替换触发规则,所以不需要重新构造 Job。这一点很多人不知道,以为要deleteJob再scheduleJob,那样会丢失 JobDataMap 里的数据,而且如果任务正在执行中,删除操作会阻塞。
异常处理上,rescheduleJob可能抛SchedulerException,cronSchedule可能抛RuntimeException(表达式非法)。前者通常是调度器已关闭(应用正在停止),后者是配置写错了。两种都要 catch,但处理方式不同:非法表达式要打 ERROR 日志并保留旧值,调度器已关闭只打 WARN 就行。
关于pauseJob和resumeJob,还有一个细节:pauseJob不会打断正在执行的那一次。如果任务正在跑,它会正常跑完,只是后续不再触发。这在设计上是对的,但如果你需要"立即停止",就得在任务内部加一个中断标志的检查。我的做法是在任务的execute里每处理完一批数据就检查一次DynamicTaskConfig.isEnabled(),发现被关了就直接返回,剩下的数据留到下次。
4.3 开关、并发、批量的动态化
除了 cron,还有几个参数值得做成动态的,实际用起来收益很高。
执行开关前面说了,用pauseJob/resumeJob实现。但要加一个保护:不能允许"关闭时间超过 N 小时"。我遇到过一次,运维误操作把任务关了,三天后才发现,补齐数据花了很久。后来的做法是在监控里加一条告警规则:任务连续 24 小时没有执行记录就报警。技术手段兜不住人性,监控才是最后一道防线。
并发控制用 Quartz 的@DisallowConcurrentExecution注解就能解决,保证同一个 JobKey 不会并发执行。但有些任务是"分片执行"的,比如按 ID 取模分成 4 片并行跑,这时候就不能用这个注解了。动态化的做法是把分片数做成参数task.shard.total,任务启动时读一次,按分片跑。这个参数变更需要重启,因为分片逻辑在调度层面,不适合热更新。
批量条数是最常用的动态参数。task.batch.size控制每次从数据库捞多少条处理。高峰期调小降低 DB 压力,闲时调大提升吞吐。这个值要设个上限保护,比如代码里强制Math.min(batchSize, 2000),防止有人在配置中心填个100000直接把内存打爆。
@Override public void execute(JobExecutionContext context) { if (!DynamicTaskConfig.isEnabled()) { return; } int limit = Math.min(DynamicTaskConfig.getBatchSize(), 2000); List<Record> records = recordDao.fetchPending(limit); for (Record r : records) { if (!DynamicTaskConfig.isEnabled()) { log.info("[task] interrupted by config change"); break; } process(r); } }这段代码的break判断是整段里最有价值的一行。任务执行到一半发现被关闭了,优雅中断而不是继续跑完,能省掉很多"关闭了还在写数据"的扯皮。
重试次数和间隔也值得做成动态的。task.retry.times和task.retry.interval.ms,读取的时候做好上下限校验,重试次数别超过 5,间隔别小于 100 毫秒,防止配置成 0 导致密集重试把下游打挂。
4.4 非 Quartz 场景的替代方案
有些项目根本没用 Quartz,就是原生Timer或者ScheduledExecutorService,这种情况怎么办?先说结论:ScheduledExecutorService做不了 cron 级别的动态重调度,只能做固定间隔的动态调整。
scheduleAtFixedRate返回一个ScheduledFuture,你可以cancel掉再重新提交,用新的间隔。但要注意两点:cancel之后原来的 Future 不能复用,必须重新提交;而且cancel对于正在执行的任务有效果,但已经排队的那次可能还会跑。更稳的做法是用ScheduledThreadPoolExecutor加上自己的包装,每次变更时shutdownNow再重建。不过这样一来,手动管理线程池的生命周期会很麻烦,容易泄漏线程。
如果只需要"固定间隔 + 动态修改间隔",可以这样:
public final class IntervalTaskHolder { private static ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); private static volatile ScheduledFuture<?> future; private static volatile long intervalSeconds = 60; public static synchronized void restart(long newIntervalSeconds) { if (future != null) { future.cancel(false); } intervalSeconds = newIntervalSeconds; future = executor.scheduleAtFixedRate( IntervalTaskHolder::runSafely, 0, intervalSeconds, TimeUnit.SECONDS); } private static void runSafely() { try { doTask(); } catch (Throwable t) { log.error("[task] uncaught error", t); } } }注意cancel(false)的参数:传false表示不打断正在执行的任务,传true会 interrupt 线程。定时任务里如果有阻塞 IO,interrupt 可能没效果,还可能留下半截数据,所以推荐false。而runSafely里的catch Throwable是必须的——ScheduledExecutorService有个坑,任务里抛出未捕获异常后,后续调度会静默停止,日志里只有第一次的堆栈,然后任务就再也不跑了。这个问题坑过无数人,一定要包住。
如果你的需求确实需要 cron 语义(比如"每天凌晨 2 点"),又不想引入 Quartz,可以用 Spring 的ThreadPoolTaskScheduler,它支持schedule(Runnable, CronTrigger)并且返回ScheduledFuture,可以取消重排。但前提是项目里已经有 Spring,否则为了一个调度引入整个 Spring 也不划算。折中方案是把 Quartz 的核心 jar(大约 600KB)单独引进来,只做内存调度不用持久化,这个代价是可以接受的。
5. 集群与发布场景下的坑
5.1 多节点重复执行
这是老 JSP 项目上集群后必踩的坑:war 包部署了 4 台机器,每台都启动了一个调度器,同一个任务一小时跑了 4 次。表现是数据重复处理、日志里有 4 份相同记录,严重的时候会引发唯一键冲突。
解决思路有两条路。第一条是让调度器变成集群模式,Quartz 原生支持,配置org.quartz.jobStore.isClustered=true并指定数据库,多台机器共享QRTZ_*表,靠行锁保证同一时刻只有一个节点执行。这个方案的优点是原生支持、可靠性高,缺点是要建 11 张表,且 cron 变更需要更新数据库里的触发器记录,rescheduleJob在集群模式下会走 DB 更新,比内存模式慢一点但可用。
第二条是应用层选主,用数据库行锁或者分布式锁,每个节点都跑任务但只有拿到锁的才真正执行。这个方案改动小,不用碰 Quartz 的表结构,但是有个致命缺陷:锁的粒度和任务执行时间的匹配问题。如果任务执行时间超过锁的过期时间,会出现两个节点同时执行的情况。所以锁的过期时间必须设得足够长,而且要有续期机制。
我在实际项目里选的是第一条路,因为老项目本来就有数据库,建几张 Quartz 表不费事,而且省去了自己想锁续期的麻烦。配置大致是:
org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.dataSource=myDS org.quartz.jobStore.tablePrefix=QRTZ_ org.quartz.jobStore.isClustered=true org.quartz.jobStore.clusterCheckinInterval=15000clusterCheckinInterval是节点心跳间隔,默认 15 秒,别调太小,否则 DB 压力大。有个坑是集群模式下节点的时间必须同步,差超过 7.5 秒(心跳间隔的一半)会被判定为失联节点,任务被重新分配,可能导致重复执行。上线前确认 NTP 服务是开着的,ntpq -p看一眼偏移量。
还有一点,如果 Quartz 用的是集群模式,那 Apollo 的配置变更会每台机器都收到,每台都会调rescheduleJob。因为 Quartz 集群下触发器是共享的,多台机器同时改同一个触发器,最终结果一致但会产生锁竞争。如果节点多,可以在重调度前加个随机延迟(比如 0 到 3 秒),把请求打散开。
5.2 灰度发布与回滚顺序
配置中心的灰度能力是它相比本地文件最大的优势。改 cron 之前,先在 Apollo 里配置灰度规则,只对一台机器的 IP 生效,观察 10 分钟,确认报告正常、数据无异常,再全量发布。这个流程走一遍大概 15 分钟,比发布 war 包快太多,而且风险可控。
灰度的时候有个细节要注意:cron 变更会影响触发器的时间基准。比如原来是0 0/10 * * * ?,改成了0 5/10 * * * ?,那下一次触发时间会从 0 分 10 分变成 5 分 15 分,中间的这段时间间隔会变化。如果任务里有"基于上次执行时间做增量计算"的逻辑,要确保它能容忍这种时间跳跃。我的做法是在任务里记录lastExecuteTime,每次执行时计算时间差,超过阈值(比如间隔的 3 倍)就打警告日志,方便发现异常。
回滚顺序也有讲究。假设你同时改了 cron 和批量条数,回滚的时候应该先回滚批量条数,再回滚 cron。因为批量条数影响的是单次执行的资源消耗,如果 cron 先回滚成高频、批量还是大值,很容易把数据库打爆。反过来先降批量,再调频率,中间有个缓冲,安全得多。
5.3 变更风暴与任务重叠
配置变更不是越频繁越好。有一次运维为了压测,在 5 分钟内连续改了 20 次 cron,结果每次变更都触发一次rescheduleJob,Quartz 的锁竞争导致部分节点的触发器状态混乱,出现了任务漏执行。后来加了个防抖:监听器收到变更后不立即重调度,而是把新值放进一个AtomicReference,由一个专门的线程每 2 秒检查一次,值变了才真正执行重调度。
private static final AtomicReference<String> pendingCron = new AtomicReference<>(); private static final ScheduledExecutorService applyExecutor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "apollo-apply"); t.setDaemon(true); return t; }); static { applyExecutor.scheduleWithFixedDelay(() -> { String target = pendingCron.getAndSet(null); if (target != null) { try { QuartzRescheduler.reschedule(target); } catch (Exception e) { log.error("[apollo] apply cron failed: {}", target, e); } } }, 2, 2, TimeUnit.SECONDS); }这个做法的额外好处是把重调度从 Apollo 回调线程里搬出来了,回调线程只做赋值,完全不碰调度器,彻底避免阻塞配置推送。
任务重叠是另一个常见问题。批处理任务如果执行时间超过了间隔时间,会不断堆积。除了@DisallowConcurrentExecution,还应该在调度器层面加保护:在任务开始时检查上一次是否还在跑,如果还在跑就直接跳过本次并记一条日志。这样即使注解失效(比如有人手动创建了 Trigger),也有兜底。
6. 常见问题速查与排查实录
6.1 问题速查表
下面这张表是我把这几年遇到的问题整理出来的,遇到现象直接对号入座,能省不少时间。
| 现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 启动报 appId not set | app.id没配或配错位置 | 检查web.xml加载顺序、启动参数 | 补META-INF/app.properties或-Dapp.id |
| 配置改了但客户端读不到 | env 大小写错误或 cluster 不匹配 | 看启动日志的 env、cluster 值 | 修正启动参数 |
| 客户端连不上服务端 | meta 地址错误、防火墙拦截 | telnet config-host 8080测连通性 | 开放端口或改地址 |
| 重启后配置全丢 | 缓存目录无写权限 | 看日志里的 WARN 行 | 指定可写目录-Dapollo.cacheDir |
| 配置变更回调不触发 | 监听器注册在错误 namespace | 打印注册的 namespace 名 | 统一用同一个 namespace |
| 重调度后任务不跑 | 新 cron 非法,被吞掉异常 | 搜索apply cron failed日志 | 用CronExpression.isValidExpression校验 |
| 任务重复执行 N 次 | 多节点各自调度 | 查所有节点日志的触发记录 | 上 Quartz 集群或选主 |
| 定时任务静默停止 | 任务抛未捕获异常 | 看是否有Uncaught堆栈 | 外层包catch Throwable |
| 配置生效延迟超过 30 秒 | 长轮询连接断开重连中 | 看客户端到服务端的连接状态 | 检查网络稳定性和代理 |
| 启动慢了几十秒 | 首次拉配置阻塞 | 看启动日志时间戳 | 改用非阻塞的getConfig |
6.2 几条踩出来的经验
第一条:永远保留"配置中心不可用"的降级路径。客户端的本地缓存能撑一阵,但如果是全新部署的机器(缓存目录是空的),首次启动时服务端不可用,getProperty返回的是 null。这时候必须有硬编码的默认值兜底,而且默认值要是"最保守的"——默认不执行、默认小批量。宁可任务不跑,也不能跑错。
第二条:所有动态参数都要有上下限校验。配置中心是把双刃剑,它让调整变简单了,也让误操作变简单了。有人在 cron 里写0/1 * * * * ?想试试秒级效果,然后忘了改回来,数据库半小时就撑不住了。我的做法是把校验逻辑写在解析层,超范围直接拒绝并打 ERROR 日志,同时在 Apollo 的管理台上给关键配置加注释和校验规则。
第三条:日志里一定要带上新旧值。排查问题时最痛苦的是只知道"配置变了",不知道"从多少变成多少"。[apollo] key=task.cron old=0 0/10 * * * ? new=0 0/5 * * * ?这样一行日志,能让你在三秒内判断出问题所在。同时把变更日志打到单独的 appender 里,方便事后审计。
第四条:别在监听器里做 IO。我见过有人在配置变更回调里写数据库、发 HTTP 请求、甚至重新初始化数据源。这些操作一旦慢下来或者抛异常,会阻塞整个配置推送链路,导致其他 key 的变更也收不到。监听器就该是纯内存操作,耗时逻辑统统丢到线程池。
第五条:先在测试环境把"配置中心挂掉"演练一遍。把服务端的网络断掉,重启客户端,看看任务能不能正常跑、日志里报什么。这个演练能暴露出缓存目录权限、默认值缺失、启动阻塞等一系列问题。我所在的项目组做完这个演练后,改了七八处代码,都是平时根本发现不了的隐患。
第六条:给动态配置加监控。任务执行次数、每次耗时、跳过次数、失败次数,这些指标都要上报。配置改了之后,通过这些指标能立刻判断是否生效、是否正常。没有监控的动态配置,等于闭着眼睛开车。
最后分享一个小的实现技巧。在DynamicTaskConfig里加一个dump()方法,把所有当前生效的参数拼成一行字符串,在每天凌晨的任务日志里打一次。这样任何时候回头看日志,都能知道当时的运行参数是什么,排查历史问题特别有用。这个习惯我坚持了好几年,帮自己和同事省下的时间难以估量。