一、背景与问题
我们生产环境上有一个Flink实时作业近期出现写入 HBase 失败,日志频繁打印Exception日志,但作业的运行状态却一直健康正常;进行应急手动重启作业后恢复正常,HBase正常写入,未再复现。
环境信息:Flink(1.16)、HBase(2.4)、Kafka(2.8)
作业的计算链路DAG大致如下:
arduino
算子链路:Source → Filter/FlatMap → Process → HBaseSink
二、问题排查
此Flink作业是一个Flink-Java作业,用户自定义实现消费加工逻辑。
排查Flink作业的运行指标监控,发现各指标均正常(内存、反压、吞吐、checkpoint等)。
排查Flink作业异常日志,总结如下:
| 层级 | 异常信息 | 算子位置 |
|---|---|---|
| 最外层 | try-catch自定义打印Exception日志 | Filter/FlatMap |
| 中间层 | Could not forward element to next operator | Process |
| 内层 | RetriesExhaustedWithDetailsException: Failed 1 action | HBaseSink |
| 根因 | NoClassDefFoundError: Could not initialize class org.apache.hadoop.hbase.ipc.RemoteWithExtrasException$ClassLoaderHolder | HBase Client |
根据HBaseSinkException堆栈信息指引,Flink的Hbase-Client一直在尝试连接Rowkey所在HBase表region的一个RegionServer,接着发现该RegionServer最近重启过,而Flink并没有失败重试刷新缓存连接Meta表获取表Region信息。
另外,排查日志过程中发现此作业并不是所有HBase写入都失败,只有遇到HBase服务端响应异常时才抛出此异常堆栈。
最后我们review了此作业的Java代码,发现作业在最外层Filter/FlatMap使用try-catch整个包起来,而在catch代码块仅打印异常日志,没有增加异常处理。
csharp
###简单示例
public void flatMap(String value, Collector<OUT> out) {
try {
OUT result = transform(value);
out.collect(result); // ← 关键:collect 也在 try 块内
} catch (Exception e) {
LOG.error("flatmap处理异常: {}", e.getMessage(), e);
// 没有 throw,没有 out.collect,什么都没做
}
}
三、问题分析
查看作业DAG执行计划,这四个算子被 chain 在一起,意味着它们运行在同一个线程中,通过同步方法调用串联。Chain 内部不是消息队列,而是直接方法调用栈嵌套。
scss
┌─────────────────── 单线程(Task Thread)───────────────────┐
│ │
│ source.emitRecord() │
│ │ │
│ ▼ output.collect() │
│ flatmap.processElement() │
│ │ │
│ ▼ output.collect() │
│ process.processElement() │
│ │ │
│ ▼ output.collect() │
│ hbaseSink.invoke() ← 异常原点 │
│ │
└────────────────────────────────────────────────────────────┘
异常从 hbaseSink 抛出后,沿着 Java 调用栈逐层向上 :
scss
线程调用栈(从底向上):
TaskThread.run()
└─ StreamTask.processInput()
└─ StreamOneInputProcessor.processElement()
└─ ChainingOutput.collect() ← flatmap 的 output
└─ FlatMapOperator.processElement()
└─ ChainingOutput.collect() ← process 的 output
└─ ProcessOperator.processElement()
└─ ChainingOutput.collect() ← sink 的 output
└─ StreamSink.invoke()
└─ HBaseSinkFunction.invoke()
└─ mutator.mutate(put)
└─ 💥 NoClassDefFoundError
异常抛到Filter/FlatMap最外层时,被try-catch捕获,仅打印日志,这种处理会导致数据静默丢失,且同时阻断 HBase 重试和 Flink 自动重启机制,符合生产现象。
Flink 的 exactly-once / at-least-once 保障依赖一个核心前提:异常必须向上抛出,让框架感知到失败,才能触发 checkpoint 回滚和数据重放。
arduino
正常容错链路:
异常抛出 → Task FAILED → JobManager 感知 → 触发 restart-strategy
→ 从最近 checkpoint 恢复 → source 回退 offset → 数据重新消费
被 catch 吞掉后:
异常打印日志 → Task 继续 RUNNING → checkpoint 正常 → offset 提交
→ 数据永久丢失,无法恢复
到这里整个关系链就清晰了,接下来只要找到HBaseSinkException的NoClassDefFoundError问题点就闭环了。
四、根因分析
Flink作业日志中的根因异常是NoClassDefFoundError: Could not initialize class,不是ClassNotFoundException,这意味着类已经被找到,但静态初始化块执行失败。
根据源码定位,RemoteWithExtrasException$ClassLoaderHolder是 HBase client 中用于反序列化远程异常的内部类,其核心逻辑如下:
java
// hbase-client: org.apache.hadoop.hbase.ipc.RemoteWithExtrasException
private static class ClassLoaderHolder {
private static final ClassLoader CLASS_LOADER = initClassLoader();
private static ClassLoader initClassLoader() {
// 尝试获取当前线程上下文类加载器或系统类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = ClassLoader.getSystemClassLoader();
}
return cl;
}
}
这个类在 HBase RPC 响应中遇到异常时被触发,用于加载远程异常类进行反序列化,异常触发路径如下:
arduino
Flink TaskManager
→ HBase Client Put/Get
→ RPC 调用 RegionServer
→ RegionServer 返回异常响应
→ Client 尝试反序列化异常
→ 加载 ClassLoaderHolder(静态初始化)
→ 失败 → NoClassDefFoundError
根据 JVM 规范,如果一个类的静态初始化抛出异常,JVM 会将该类标记为erroneous状态,后续任何对该类的引用都直接抛NoClassDefFoundError,不会重试初始化。这个标记是per-ClassLoader的,只有销毁 ClassLoader(重启 Task/JVM)才能重置。
直接原因:HBase Client 内部类RemoteWithExtrasException$ClassLoaderHolder的静态初始化失败,被 JVM 永久标记为 erroneous 状态。后续该 TaskManager 上所有 HBase 写入操作均触发NoClassDefFoundError,无法自愈,直至作业重启创建新的 ClassLoader。
根本原因:时序竞态问题--HBase RegionServer 瞬态故障(GC 停顿/网络抖动/Region 迁移)恰好与 Flink Task ClassLoader 生命周期切换窗口重叠,导致 HBase Client 后台重试线程在 ClassLoader 处于异常状态时首次触发了ClassLoaderHolder的静态初始化。
以下场景中,Flink 会切换或关闭 Task 的 ClassLoader:
- Checkpoint 触发的 async snapshot
- Task 取消/Failover(另一个 Task 失败触发整体重启)
- TaskManager Slot 释放
五、优化与总结
- 修改 FlatMap 代码,在catch代码块中正常抛出Exception,让Flink框架感知到,能够触发容错重启机制
- Flink 配置增加 HBase 包 parent-first 加载策略,避免 ClassLoader 生命周期竞态
- 增加Flink作业异常日志关键字告警,及时感知并干预
本次故障为 HBase RegionServer 瞬态异常与 Flink 类加载器生命周期竞态叠加导致的低概率问题,重启可恢复。核心风险在于作业代码中 try-catch 吞异常的写法,会在常规 IO 异常场景下造成静默数据丢失。整改重点为修复代码异常处理逻辑、调整类加载策略、补齐监控告警。