Quartz Job 异常处理:ERROR 状态与 OOM 的挽回
一、问题现象
Quartz 定时任务跑了一段时间后,Trigger 状态变成了 ERROR,Job 不再执行。但系统本身还在正常运行,其他 Job 也没问题。
二、Trigger 变 ERROR 的机制
Quartz 在执行 Job 时,如果 executeInternal 方法抛出了任何未被捕获的 Throwable(包括 Exception 和 Error),Quartz 框架就会把这个 Trigger 标记为 ERROR。
一旦 Trigger 进入 ERROR 状态,就不会再自动触发,相当于这个 Job 悄悄死了。
三、所有可能导致 ERROR 的异常类型
3.1 异常全景图
Throwable(任何未捕获的都会导致 Trigger 变 ERROR)
│
├── Error(catch Exception 抓不住)
│ ├── OutOfMemoryError → 堆内存不足
│ ├── StackOverflowError → 方法调用栈过深
│ ├── NoClassDefFoundError → 运行时类找不到
│ └── NoSuchMethodError → 运行时方法找不到
│
└── Exception(catch Exception 可以抓住)
├── RuntimeException
│ ├── NullPointerException → 空指针
│ ├── IndexOutOfBoundsException → 数组/列表越界
│ ├── ClassCastException → 类型转换错误
│ ├── IllegalArgumentException → 非法参数
│ ├── UnsupportedOperationException → 不支持的操作
│ └── FeignException → Feign 调用失败(超时、服务不可用等)
│
├── IOException → IO 异常
├── SQLException → 数据库异常
└── …
3.2 按场景分类
场景一:网络与远程调用
| FeignException | 对端服务不可用、超时、返回异常 | ✅ 能 |
| SocketTimeoutException | 网络连接超时 | ✅ 能 |
| ConnectException | 无法建立连接 | ✅ 能 |
| RetryableException | Feign 重试耗尽 | ✅ 能 |
典型代码:
integrationFeignClient.mixedTrigger(); // 对端服务挂了 → FeignException
场景二:内存与资源
| OutOfMemoryError | 堆内存不足(处理大数据、内存泄漏) | ❌ 抓不住 |
| StackOverflowError | 递归过深 | ❌ 抓不住 |
典型代码:
List<Data> all = feignClient.queryAll(); // 数据量太大 → OOM
场景三:数据库与持久化
| SQLException | 数据库连接失败、SQL 语法错误 | ✅ 能 |
| PessimisticLockingFailureException | 数据库锁竞争(集群模式下常见) | ✅ 能 |
| DataIntegrityViolationException | 数据约束冲突 | ✅ 能 |
场景四:代码逻辑
| NullPointerException | 对象为 null 时调用方法 | ✅ 能 |
| ClassCastException | 类型转换错误 | ✅ 能 |
| IndexOutOfBoundsException | 集合越界访问 | ✅ 能 |
场景五:类加载与依赖
| NoClassDefFoundError | 运行时找不到类(jar 包冲突或缺失) | ❌ 抓不住 |
| NoSuchMethodError | 运行时找不到方法(依赖版本不一致) | ❌ 抓不住 |
| ClassNotFoundException | 动态加载类失败 | ✅ 能 |
场景六:Quartz 框架层面
| JobPersistenceException | JDBC JobStore 操作失败 | ❌ 在框架层,不在 executeInternal 内 |
| 集群 check-in 超时 | GC 停顿导致节点未及时心跳 | ❌ 框架层面 |
四、项目中实际存在的风险模式
4.1 完全没有 try-catch(最高风险)
// SalesQuarterDateJob
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
Result result = basicFeignClient.generateData();
// 没有任何 try-catch!
// 任何异常都会直接穿透到 Quartz → Trigger 变 ERROR
}
风险:Feign 调用失败、NPE、OOM,任何一种发生,Job 就永久停止。
4.2 catch 了但 re-throw(等于没 catch)
// CheckMQStatusJob
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
calculationFeignClient.schedule(new ScheduleTriggerCommand());
} catch (Exception ex) {
throw ex; // 抓住了又抛出去,等于没抓
}
}
风险:和没有 try-catch 完全一样,任何 Exception 都会导致 Trigger 变 ERROR。
4.3 catch 了 Exception 但抓不住 Error(中等风险)
// CommonMixedProcessJob
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
integrationProcess();
}
private void integrationProcess() {
try {
integrationFeignClient.mixedTrigger();
} catch (Exception e) {
// ✅ 能抓住 Feign 调用异常、NPE 等
// ❌ 抓不住 OutOfMemoryError、NoClassDefFoundError 等
log.info("执行失败 " + e.getMessage()); // 用的 log.info,不是 log.error
}
}
风险:常规异常没问题,但 OOM 等 Error 会穿透,Trigger 变 ERROR。
4.4 风险等级总结
| 没有 try-catch | 🔴 最高 | 什么都防不住 | 一切 |
| catch + re-throw | 🔴 最高 | 什么都防不住 | 一切 |
| catch(Exception) | 🟡 中等 | 常规异常 | OOM、StackOverflow 等 Error |
| catch(Throwable) | 🟢 最低 | 一切 | 只有 JVM 彻底崩溃才防不住 |
五、如何最大化规避 ERROR
5.1 第一道防线:所有 Job 统一使用 catch(Throwable)
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
// 业务逻辑
doWork();
} catch (Throwable t) {
log.error("[JobName] 执行失败: ", t);
// 不抛出 → Trigger 不变 ERROR → 下次调度继续执行
}
}
为什么是 Throwable 而不是 Exception:
Throwable
├── Error ← OOM、StackOverflow 在这里,只有 Throwable 能抓住
└── Exception ← 常规异常在这里,Exception 就能抓住
5.2 第二道防线:Feign 调用必须配置超时
没有超时的 Feign 调用,一旦对端服务无响应,线程就会被永久占用:
feign:
client:
config:
default:
connectTimeout: 5000 # 连接超时 5 秒
readTimeout: 30000 # 读取超时 30 秒
5.3 第三道防线:大数据量场景使用分页或流式处理
// ❌ 危险:一次性加载全量数据
List<Data> all = feignClient.queryAll(); // 数据量大 → OOM
// ✅ 安全:分页处理
int page = 0;
while (true) {
List<Data> batch = feignClient.queryByPage(page, 1000);
if (batch.isEmpty()) break;
process(batch);
page++;
}
5.4 第四道防线:日志级别要正确
// ❌ 失败用 info,排查问题时根本看不到
catch (Throwable t) {
log.info("执行失败 " + t.getMessage()); // 没有堆栈,没有 ERROR 级别
}
// ✅ 失败用 error,带上完整堆栈
catch (Throwable t) {
log.error("[JobName] 执行失败: ", t); // ERROR 级别 + 完整堆栈
}
5.5 第五道防线:JVM 参数兜底
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump
OOM 时自动生成 heap dump,事后用 MAT 或 VisualVM 分析。
六、已经变 ERROR 了怎么恢复
6.1 方式一:重启服务
最简单粗暴,重启后 Quartz 重新加载所有 Trigger,状态恢复为 NORMAL。
6.2 方式二:通过代码恢复
// 恢复指定 Trigger
TriggerKey triggerKey = TriggerKey.triggerKey(jobClassName, jobGroupName);
scheduler.resumeTrigger(triggerKey);
// 恢复整个 Job 组
scheduler.resumeJobs(GroupMatcher.jobGroupEquals(groupName));
6.3 方式三:扩展 JobController 提供恢复接口
项目中的 JobController 已有 listJob、page 等管理接口,可扩展一个恢复接口:
@ApiOperation(value = "Resume Error Job")
@PostMapping("/resumeJob")
public Result resumeJob(@RequestParam String jobName, @RequestParam String groupName) throws Exception {
TriggerKey triggerKey = TriggerKey.triggerKey(jobName, groupName);
scheduler.resumeTrigger(triggerKey);
return ResultGenerator.genSuccessResult();
}
七、总结
7.1 异常捕获决策树
executeInternal 执行
│
├─ 发生 Exception(NPE、Feign 超时等)
│ ├─ 有 catch(Exception) 或 catch(Throwable) → 被抓住 → Job 继续
│ └─ 没有 catch 或 re-throw → 穿透 → Trigger 变 ERROR
│
├─ 发生 Error(OOM、StackOverflow 等)
│ ├─ 有 catch(Throwable) → 被抓住 → Job 继续
│ └─ 只有 catch(Exception) 或没有 catch → 穿透 → Trigger 变 ERROR
│
└─ Quartz 框架层面异常(JobStore 失败等)
└─ 业务代码抓不住 → 需要运维介入或重启
7.2 防御层级
| 代码层 | catch (Throwable t) + log.error | 防止任何未捕获异常导致 Job 静默死亡 |
| 配置层 | Feign 超时配置 | 防止线程被无限占用 |
| 设计层 | 分页/流式处理大数据 | 防止 OOM |
| JVM 层 | HeapDump 参数 | 事后分析 OOM 根因 |
| 运维层 | 监控 Trigger 状态 | 及时发现 ERROR 的 Job |




