生产环境里一台实例开始刷 Failed to construct kafka producer,同一集群的其余实例一切正常。重启之后错误消失,几周后又随某次部署再次出现。这个报错我们前几次都记成「Kafka 集群 / Schema Registry 抖动」,直到这次把它拆开:根因不在网络,是一次落在错误线程上的类初始化。那条线程的 TCCL(线程上下文类加载器,规定这条线程用哪个类加载器去找类)看不见 fat jar 里打包的依赖,而类初始化失败之后就没有第二次机会了。

现象#

故障在实例内部的边界很清楚:走 Confluent 序列化器的 topic 全部发不出去,业务 topic、日志 topic、DLQ 一起挂;同一个 JVM 里走 StringSerializer 的 topic 全程正常,其中也有一个 DLQ。这条边界是后来在复现里才划清的,当时只能知道故障集中在一批 template 上;只盯着正常的 topic,会误以为 Kafka 没有问题。

日志里还有一个当时看不懂的细节:后续每条报错长得一模一样,连异常尾部方括号里的线程名都相同,而那个线程早就不是当前报错的线程。

出事的是一个对外提供 HTTP API 的 Spring Boot 服务,打成 fat jar 用 java -jar 跑在容器里。环境里有四件事和后面的分析有关:

  • 滚动发布是 start-first,新实例先起来、旧实例再退,所以新实例头几十秒基本没有流量;
  • 消息用 spring-kafka 的 KafkaTemplate 发,一部分 topic 的 value 用 Confluent 的 JSON Schema 序列化器(对接 Schema Registry),另一部分用普通的 StringSerializer;版本组合是 Boot 3.0.x、kafka-clients 3.4.0、Confluent serializer 7.4.0;
  • 发送入口除了 HTTP 请求路径,还有一个 @Scheduled 重试任务,应用就绪几秒后开始、每隔几秒扫一次积压队列,用 CompletableFuture.runAsync(...) 异步补发;
  • 发送都包在 try { send } catch { log } 里,失败只记日志、不打断业务,不翻日志没人知道。

排查#

三条线索把范围从「Kafka 集群那边出问题了」收进这一个 JVM。

第一,服务端故障和现象对不上。至少在这次故障中,broker 或 Schema Registry 的异常无法解释为什么只影响一个实例。

第二,时间对不上。报错从实例启动后几秒就开始了,而那台实例当时还没接过任何 HTTP 流量。能在这个窗口里发 Kafka 的只有那个 @Scheduled 重试任务:它首跑时队列里恰好有积压,于是抢在任何请求之前,触发了这个 JVM 的第一次发送。

第三,线程名对不上。NoClassDefFoundError 的 message 里带着 [in thread "..."],那个线程和打出这条日志的线程不是同一个。查了 JVM 规范(JVMS §5.5) 确认类初始化失败后会进入 erroneous state,后续使用时抛出 NoClassDefFoundError。至于异常 message 里保留原始线程名,是我在 Temurin 25 这次复现中观察到的实现细节,不能当成 JVMS 保证的格式。

正常情况:TCCL 由创建线程的一方决定,其余靠继承#

三条线索合起来是同一件事:这个 JVM 的第一次发送落在了一条不该抢到它的线程上,而那一次类初始化失败之后就定死了。要说清楚那条线程特殊在哪,得先有个参照:正常情况下一条线程用哪个加载器去找类,在 fat jar 下它为什么一直是对的。

委派是单向的:子加载器接到请求先转给父加载器,父加载器找不到才轮到它自己去找(ClassLoader 的 javadoc 把这个顺序写在 loadClass 上,先 findLoadedClass,再问 parent,最后才是自己的 findClass)。反方向没有通道,父加载器看不见只有子加载器才有的类。

麻烦就出在这儿。ServiceLoader、JDBC 驱动这类库,自己由父加载器加载,运行时却要照着配置里的类名去实例化一个实现类,而实现类往往只有子加载器看得见;库拿自己的加载器去找,怎么也找不到。TCCL 就是为这件事留的口子:每条线程上挂一个「这条线程该用哪个加载器」,库代码不问「谁加载了我」,改问 Thread.currentThread().getContextClassLoader()。这个值由创建线程的那一方负责设置。

Thread 的 javadoc 把这条规则写在 getContextClassLoader() 上:

The context ClassLoader may be set by the creator of the thread for use by code running in this thread when loading classes and resources. If not set, the default is to inherit the context class loader from the parent thread. The context ClassLoader of the primordial thread is typically set to the class loader used to load the application.

拆开是三件事:设置的责任在创建线程的一方;不设置就继承创建者那条线程的值;最早那条 main 通常就是「加载应用的那个加载器」。同一页还有个容易读漏的细节:null 不等于没设置,返回值说明里写着 null 表示系统类加载器,拿不到才退到 bootstrap。

Boot 的 fat jar 走的就是这条常规。JarLauncher 建好 LaunchedClassLoader 之后、调你的 main() 之前,先把它设成 main 的 TCCL(Launcher.java:94)。之后应用里绝大多数线程没人显式设过 TCCL,靠继承拿到同一个加载器。线程池的 worker 是惰性创建的,真正 new Thread 那一刻发生在第一次提交任务的那条线程上,而那条线程自己也是从 main 那条链上一路传下来的。

下面这个实验不依赖 Spring,专门看「继承」这一步(Temurin 25.0.2):六个探针依次打在 main 设置 TCCL 之前、设置之后、应用侧的 main、main 建的线程池、commonPool,以及 commonPool 自己再建的线程池上。launcher.jar 里只有启动器,应用类 app.Nested 放在另一个 app.jar 里、只挂在我自建的 URLClassLoader 上;它的 parent 是系统类加载器,和真的 LaunchedClassLoader 同一条委派链,差别只在真的那个还得读 jar 里的 jar。

探针就是同一个方法:打印当前线程的 TCCL,再拿它去看一眼应用类在不在。

static void show(String label) {
    ClassLoader tccl = Thread.currentThread().getContextClassLoader();
    String verdict;
    try {
        Class.forName("app.Nested", false, tccl);
        verdict = "看得见 app.Nested";
    } catch (ClassNotFoundException e) {
        verdict = "看不见 app.Nested";
    }
    System.out.printf("%-34s TCCL=%-52s %s%n", label, tccl, verdict);
}

完整的 Launcher.java 在配套仓库里,./run.sh inherit 会把两个 jar 打出来跑一遍:A 段是 java -jar,B 段是平铺 classpath 的对照组。

git clone https://github.com/meirongdev/kafka-tccl-issue
cd kafka-tccl-issue
./run.sh inherit

A 段在我机器上的输出:

[1] main 启动器设置前                    TCCL=jdk.internal.loader.ClassLoaders$AppClassLoader@7a8c5397 看不见 app.Nested
[2] main 启动器设置后                    TCCL=java.net.URLClassLoader@73d16e93                     看得见 app.Nested
[3] 应用侧 main                       TCCL=java.net.URLClassLoader@73d16e93                     看得见 app.Nested
[4] main 触发新建的固定池 worker           TCCL=java.net.URLClassLoader@73d16e93                     看得见 app.Nested
[5] commonPool worker              TCCL=jdk.internal.loader.ClassLoaders$AppClassLoader@7a8c5397 看不见 app.Nested
[6] commonPool 触发新建的固定池 worker     TCCL=jdk.internal.loader.ClassLoaders$AppClassLoader@7a8c5397 看不见 app.Nested

六行里只有 [5] 不按这条规则来,其余都是同一套委派加继承:

  • [2]→[4] 是常规的本体。[4] 那个 worker 没人给它设过 TCCL,它是 main 第一次 submit() 时惰性创建的,于是继承了 [2] 那个加载器。我在这个服务里看到 Tomcat worker、@Scheduled 调度线程和 Kafka listener 都带着 LaunchedClassLoader,就是这个继承传下来的;不过 TCCL 最终取决于实际的 ThreadFactory,不能当成别的服务的默认前提。
  • [5] 是最容易踩到的例外,因为 common pool 的 worker 不是「你」创建的。Java 25 的 ForkJoinPool javadoc 写明:没有通过系统属性提供 ThreadFactory 时,common pool 用的工厂直接把系统类加载器设成 TCCL。注意它的 hash 和 [1] 是同一个 @7a8c5397,停在启动器改动之前那一层,正好在 fat jar 之外。
  • [6] 是坏 TCCL 的传染:commonPool worker 把任务提交给一个普通固定池,那个 worker 是它此刻惰性创建的,于是继承了它自己那份坏 TCCL,线程名还是 JDK 默认的 pool-N-thread-M

按「谁创建了谁」摆开就是这样:实线是新建线程那一刻的继承,虚线是把任务交出去。worker 由那个池自己的工厂创建,TCCL 不从提交任务的线程来。

flowchart TD M["main
[1] 启动器设置前:AppClassLoader
[2][3] 启动器设置后:自建 loader(等价于 LaunchedClassLoader)"] M ==>|"submit() 时新建 worker,继承 TCCL"| W["固定池 worker [4]
TCCL = 自建 loader
看得见应用类"] M -.->|"runAsync() 只是提交任务"| C C["commonPool worker [5]
JDK 默认工厂直接把系统类加载器设成 TCCL
TCCL = AppClassLoader
看不见应用类"] C ==>|"submit() 时新建 worker,继承 TCCL"| C2["固定池 worker [6]
TCCL = AppClassLoader
线程名却是 pool-N-thread-M"] style C stroke:#c00,stroke-width:2px style C2 stroke:#c00,stroke-width:2px

同一份代码换成平铺 classpath 再跑一次(就是 B 段),[1] 到 [6] 全部变成「看得见」:加载器还是那两个(commonPool 拿到的仍是系统类加载器),变的只是这一回系统类加载器自己也看得见应用类。IDE、mvn spring-boot:run 和单元测试就属于这种对照组,本地试不出来,多半是运行形态的差异而不是代码的差异。

常规只保证一件事:继承自创建者。它不保证创建者自己那份是对的。

根因#

这条触发链有四段:fat jar 让应用依赖不在系统 classpath 上;commonPool 的线程把系统类加载器 AppClassLoader 当作 TCCL;Confluent 的配置类恰好在静态初始化时通过 TCCL 按类名解析默认值;类初始化只发生一次,成功和失败都只算一次。缺一段都不会出现这个现象。前两段合在一节讲,后两段各占一节,最后一张图把四段串起来。

fat jar 与 commonPool 让 TCCL 指向了错误的加载器#

java -jar app.jar 的 classpath 只有 app.jar 一项,而 Boot 的可执行 jar 是这个结构:

app.jar
├── META-INF/MANIFEST.MF                  Main-Class 指向 JarLauncher
├── org/springframework/boot/loader/...   ← 只有这一层在 jar 根
└── BOOT-INF/
    ├── classes/...                       ← 业务类
    └── lib/*.jar                         ← 全部依赖,jar 套 jar

AppClassLoader 只把 jar 根当作找类的起点,所以它真正能加载的就是 org/springframework/boot/loader/ 那一层,也就是 manifest 里 Main-Class 指的 JarLauncher。另外两处它都够不着,原因还不一样:

  • BOOT-INF/classes/ 里的业务类:加载 com.example.Foo 时它按包名找 com/example/Foo.class,jar 里的条目却是 BOOT-INF/classes/com/example/Foo.class,多一层前缀就对不上;
  • BOOT-INF/lib/ 里的依赖:NullContextNameStrategy 属于这一类,它在 jar 里面的 jar 里,而规范原话是 Java 没有加载嵌套 jar 的标准方式。

两种情况下 .class 文件都打进了 app.jar,只是都不在 AppClassLoader 会去查的位置上。它也没法把这活儿转给别人:类加载器只向父委派,而 AppClassLoader 正是 LaunchedClassLoader 的父(完整链条是 LaunchedClassLoaderAppClassLoaderPlatformClassLoader → bootstrap)。能读它们的是 JarLauncher 建起来的 LaunchedClassLoader(3.2 之前叫 LaunchedURLClassLoader),也就是上一节 [2] 那个位置在真 fat jar 里的样子。

上一节 [5] 那条例外是从这里进到我们服务里的:不传 executor 的 CompletableFuture.runAsync(...) 会落到 common pool,我们那个重试任务用的正是这个重载。

两条线程走的是同一套委派规则,落点却不同:

flowchart TB T1["main / Tomcat / @Scheduled 线程"] -.->|TCCL| L T2["commonPool worker
及它惰性创建的固定池 worker"] -.->|TCCL| A L["LaunchedClassLoader(子)
读得到 BOOT-INF/classes/
和 BOOT-INF/lib/*.jar
NullContextNameStrategy 在这里"] A["AppClassLoader(父,系统类加载器)
只读得到 app.jar 根下的 loader 类
委派只向上,没有向下的通道"] L ==>|向父委派| A style A stroke:#c00,stroke-width:2px

所以第一次 <clinit> 落在哪条线程上,直接决定了 NullContextNameStrategy 还能不能被解析出来。

Confluent 的配置类在 clinit 里按名解析类#

AbstractKafkaSchemaSerDeConfigcontext.name.strategy 的默认值声明成 String 类名:

public static final String CONTEXT_NAME_STRATEGY_DEFAULT =
    NullContextNameStrategy.class.getName();
...
.define(CONTEXT_NAME_STRATEGY, Type.CLASS, CONTEXT_NAME_STRATEGY_DEFAULT, ...)

而 kafka-clients 3.4.0 的 ConfigDefdefine() 时会验证默认值:Type.CLASS 的 String 默认值要经 Utils.loadClass 解析成 Class,用的是 getContextOrKafkaClassLoader()。这一切发生在 KafkaJsonSchemaSerializerConfig 的静态初始化(<clinit>)里。

成败就落在 getContextOrKafkaClassLoader() 的这个取舍上:TCCL 非 null 时它就用 TCCL,只有为 null 才回退到 getKafkaClassLoader(),也就是加载 kafka-clients 自己的那个加载器。这和前面 javadoc 里「null 表示系统类加载器」的约定不是一回事,Kafka 回退到的是自己那个。这个回退本来是对的:kafka-clients 也打在 BOOT-INF/lib/ 里,UtilsLaunchedClassLoader 加载,拿它去找 NullContextNameStrategy 找得到。换句话说 TCCL 为 null 反而没事,坏就坏在它非 null 却指向父加载器 AppClassLoader:一个更差但非空的答案,盖掉了本来正确的那个。

这里有两个加载器同时在场,分不开就看不懂那句报错。KafkaJsonSchemaSerializerConfig 自己是 LaunchedClassLoader 加载的:触发它的业务代码就由这个加载器加载,顺着委派链找下去一路都在 BOOT-INF/lib/ 里,这一步从来没失败过;失败的是它 <clinit> 里那次按名解析,Utils.loadClass 不问「谁加载了我」,只问当前线程的 TCCL。所以「两个类都在 BOOT-INF/lib/ 里,怎么一个加载得动、另一个说找不到」,答案是这两次解析问的不是同一个加载器。

这个写法是这次只影响 Confluent 序列化器的原因。./run.sh compare 在同一个坏 TCCL 下逐个初始化:kafka-clients 的 ProducerConfig、spring-kafka 的 JsonSerializerStringSerializer 都没有失败,只有 Confluent 的配置类失败。Confluent 社区论坛的相似报告只佐证了版本组合、报错线程和「间歇性、重启恢复」的现象;这里的根因仍以代码阅读和后面的最小复现为准。

还有一跳容易漏掉:第一次执行 <clinit> 的是 commonPool submit() 过去的那个固定池 worker(上面 [6]),不是 commonPool 线程本身,线程名因此是 JDK 默认的 pool-N-thread-M。这就是排查时那个旧线程名没有指向 commonPool 的原因。

类初始化只发生一次#

上面那个 ConfigException 是从 <clinit> 里抛出来的。按 JVMS §5.5,异常逃出静态初始化之后,JVM 就把这个类记成 erroneous,后面再用它只抛 NoClassDefFoundError,不会重跑一次 <clinit> 给它第二次机会。成功的那一半同理,<clinit> 只跑一次,成功和失败都算数。

要紧的是这笔账记在哪:记在「这个类 + 加载它的那个 ClassLoader」这一对上,不是记在 ClassLoader 上。同一个 LaunchedClassLoader 加载别的类照样正常,这正是同一个 JVM 里走 StringSerializer 的 topic 全程没事的原因。所以谁先触发第一次初始化,就决定了这一对之后一直是什么状态;重启能治好它,也只是因为新进程换了个 ClassLoader,KafkaJsonSchemaSerializerConfig 会拿到一个新的 Class 对象,重新初始化一次。

四段连起来#

flowchart TD A["@Scheduled 重试任务
(启动后几秒首跑,早于任何请求)"] --> B["CompletableFuture.runAsync(...)
未传 executor"] B --> C["ForkJoinPool.commonPool 线程
TCCL = AppClassLoader"] C --> C2["再 submit() 给一个普通固定池
惰性新建的 worker 继承坏 TCCL
线程名却是 pool-N-thread-M"] C2 --> D["本 JVM 第一次构造 KafkaProducer
触发 KafkaJsonSchemaSerializerConfig 的 clinit"] D --> E["ConfigDef.define() 验证 String 默认值
经 TCCL 解析 NullContextNameStrategy"] E --> F["系统加载器看不见 BOOT-INF/lib
ConfigException → ExceptionInInitializerError"] F --> G["类被 JVM 标记为 erroneous"] G --> H["此后所有走这个序列化器的发送:
NoClassDefFoundError
(走 StringSerializer 的 topic 不受影响)"]

最小复现#

前面那个继承实验和下面这个类加载器层面的最小复现都在 meirongdev/kafka-tccl-issue 里,此外还有 fat jar 上的端到端复现(./run.sh repro)、平铺 classpath 的对照组(./run.sh flat)、坏 TCCL 下逐个类初始化的对照实验(./run.sh compare),以及后面三处修复各自的开关。

思路:用一个 URLClassLoader 扮演 Boot 的类加载器,然后让 commonPool 线程和「TCCL 正确的线程」分别去抢第一次初始化。这次 parent 指向 platform loader,比前面那个继承实验(parent 是系统类加载器,为的是和真实的 LaunchedClassLoaderAppClassLoader 对齐)更严一档:那几个 Confluent jar 由 run.sh 当程序参数传进来、不放在 -cp 上,parent 再跳过系统类加载器,自建 loader 就是它们唯一的入口,坏 TCCL 才真的看不见。对比就是这两段:

// 坏线程:裸 supplyAsync 落进 commonPool,TCCL = AppClassLoader
CompletableFuture.supplyAsync(() -> init(poisoned)).join();

// 好线程:TCCL 显式设成扮演 Boot 的那个类加载器
Thread good = new Thread(() -> init(poisoned));
good.setContextClassLoader(poisoned);
good.start();

// init() 里就一句:Class.forName(CONFIG_CLASS, true, loader)

跑起来(不用起 Kafka,也不用 Spring):

./run.sh minimal

在我机器上(Temurin 25.0.2,Confluent 7.4.0 + kafka-clients 3.4.0)的输出,省略了开头的环境信息和 SLF4J 的 NOP 提示:

[1] 第一次初始化发生在 commonPool 上    -> ExceptionInInitializerError
      caused by ConfigException: Invalid value io.confluent.kafka.serializers.context.NullContextNameStrategy for configuration context.name.strategy: Class io.confluent.kafka.serializers.context.NullContextNameStrategy could not be found.
[2] TCCL 正确的线程,同一个 loader     -> NoClassDefFoundError: Could not initialize class io.confluent.kafka.serializers.json.KafkaJsonSchemaSerializerConfig
      caused by ExceptionInInitializerError: Exception org.apache.kafka.common.config.ConfigException: Invalid value io.confluent.kafka.serializers.context.NullContextNameStrategy for configuration context.name.strategy: Class io.confluent.kafka.serializers.context.NullContextNameStrategy could not be found. [in thread "ForkJoinPool.commonPool-worker-1"]
[3] 全新 loader,好线程抢先初始化      -> OK
[4] 好线程先初始化后,commonPool 再用  -> OK

四行分别说明:

  • [1] 就是生产报错的原文:NullContextNameStrategy could not be found,类明明在 jar 里,只是 TCCL 看不见它;
  • [2] TCCL 正确的线程也救不回来,并且异常里重放了 [in thread "ForkJoinPool.commonPool-worker-1"],机制和生产日志里那个「旧线程名」是同一个,只是生产上第一次失败落在固定池的 worker 上,重放出来的名字是 pool-N-thread-M
  • [3][4] 换一个全新的 ClassLoader,让好线程先完成初始化,之后 commonPool 也能正常使用:先到先得。

把版本组合换成 Confluent 8.1.1 + kafka-clients 3.9.2(mvn -Dconfluent.version=8.1.1 -Dkafka.version=3.9.2 -DskipTests package 之后再跑一遍),[1] 和 [2] 的输出一字不差(8.x 的 clinit 多依赖一个 jackson-annotations,其余行为一致)。这一组我只跑了一次,没贴输出。

怎么确认自己的服务是否受影响#

不用等线上炸。把应用打成 fat jar 用 java -jar 跑一次(平铺 classpath 下查不出东西),在 commonPool 线程上看一眼 TCCL 能不能看见你的序列化器依赖:

String verdict = CompletableFuture.supplyAsync(() -> {
    ClassLoader tccl = Thread.currentThread().getContextClassLoader();
    try {
        // initialize=false:只看可见性,不触发 clinit —— 否则这次探测本身就替好线程把类初始化了
        Class.forName("io.confluent.kafka.serializers.context.NullContextNameStrategy", false, tccl);
        return "看得见,这条链在你这里不成立";
    } catch (ClassNotFoundException | NoClassDefFoundError e) {
        return "看不见 ← 触发条件成立";
    }
}).join();

initialize=false 这个参数不能省:用 true 去探测,等于替好线程完成了初始化,顺手把 bug 修掉,然后你会得到「查不出问题」的结论。

另外两件事也值得查:grep 一遍只传一个参数的 CompletableFuture.runAsync( / supplyAsync((以及 thenApplyAsync 这类不带 executor 的重载),它们全都落在 commonPool 上;再看看这个 JVM 的第一次 producer 构造由谁触发,启动即有活干的 @ScheduledApplicationRunner、消息驱动的补偿任务,都可能抢在请求线程前面。仓库里 Tccl.javaExecutorTcclProbe.java 把这几项做成了启动时的自检日志,./run.sh repro 一跑就能看到每类线程的 TCCL 各是什么。

解决方案#

根因是错误 TCCL 抢到了第一次初始化,所以修复分别对着链条上的三处:让首次初始化发生在正确的线程上、别让坏 TCCL 从异步入口进来、以及让已经中毒的实例尽快下线。只做一处的话我会做第一处,它利用「初始化只发生一次」的语义,不依赖调用方怎么写异步代码;第二处是把坏 TCCL 的来源也堵上;第三处不防复发,只缩短单次故障的时长。

在应用初始化阶段用正确的 TCCL 做 warm-up#

在 bean 初始化阶段主动触发一次 KafkaJsonSchemaSerializerConfig 的初始化,让它跑在 TCCL 是 LaunchedClassLoader 的线程上,之后哪个线程来用都是已经初始化好的类。

这里不要只指定 Class.forName 的 ClassLoader:ConfigDef<clinit> 里读取的是当前线程的 TCCL,所以两者都要指向加载了业务类的那个 ClassLoader,也就是 fat jar 下的 LaunchedClassLoader

@Configuration
public class KafkaProducerConfig {

    private static final ClassLoader APP_CLASS_LOADER =
            KafkaJsonSchemaSerializerConfig.class.getClassLoader();

    @PostConstruct
    void warmUpSerializerConfig() throws ClassNotFoundException {
        Thread current = Thread.currentThread();
        ClassLoader previous = current.getContextClassLoader();
        try {
            current.setContextClassLoader(APP_CLASS_LOADER);
            Class.forName(KafkaJsonSchemaSerializerConfig.class.getName(), true,
                    APP_CLASS_LOADER);
        } finally {
            current.setContextClassLoader(previous);
        }
    }
}

我把 warm-up 放在 @PostConstruct,而不是 ApplicationRunner:前者发生在 bean 初始化期间,后者在 context refresh 之后才执行。@Scheduled 任务可能已经开始调度;在示例仓库把 initialDelay 改成 0 后,./run.sh race 能看到第一次调度先于 runner。这是一个时序风险,不是每次都会复现。

如果一个服务有多个 ProducerFactory,我更倾向于在同一阶段对每个 factory 调一次 createProducer()。这会把整条序列化链的初始化提前,并在应用接流量前暴露 bootstrap 地址或 serializer 配置的问题。DefaultKafkaProducerFactory 在非 transactional、未启用 producer-per-consumer-partition 时会按 factory 共享 producer,warm-up 构造出来的 producer 后续还能复用。

给异步入口传入显式 executor,并固定它的 TCCL#

首次初始化落在哪个线程,不该取决于调用者碰巧用了哪个 executor。重试任务改成传入专用 executor,它的 ThreadFactory 显式设置 LaunchedClassLoader,顺便把线程名留给日志:

private static final ClassLoader APP_CLASS_LOADER = KafkaProducerConfig.class.getClassLoader();
private static final AtomicInteger SEQ = new AtomicInteger();

static final ExecutorService RETRY_EXECUTOR = Executors.newFixedThreadPool(SIZE, r -> {
    Thread t = new Thread(r, "outbound-log-" + SEQ.incrementAndGet());
    t.setContextClassLoader(APP_CLASS_LOADER);
    return t;
});

CompletableFuture.runAsync(this::retryPendingMessages, RETRY_EXECUTOR);

同样要检查裸 runAsyncsupplyAsync 和不带 executor 的 thenApplyAsync。如果用 Spring 的 ThreadPoolTaskExecutor,也要给它提供这个 ThreadFactory;否则 worker 仍会在创建时继承创建线程的 TCCL。

让编排器换掉坏实例#

这一处管的是链已经成立之后怎么办,不是这条链本身:中毒的那个 JVM 不会再自己恢复,只能换实例。这次真正把损失放大的是它的「静默」。KafkaExceptionRuntimeException,被 catch (Exception e) { log.error(...); } 接住之后业务无感、监控无感,坏实例可以带病跑很久。检测到序列化路径上的 NoClassDefFoundError / ExceptionInInitializerError 时把 liveness 打成 DOWN,就能把它变成「由编排器自动替换实例」:

// context 是注入的 ApplicationContext
AvailabilityChangeEvent.publish(context, LivenessState.BROKEN);

这行是我在 Boot 3.0.5 上实测跑通的,发布之后 /actuator/health/liveness 返回 HTTP 503。容器探针要指向 liveness 分组(management.endpoint.health.probes.enabled=true),health group 显式只包含 livenessState 和这个故障的专用 indicator。别去探聚合的 /actuator/health:它可能带着 DB、Redis 的 indicator,依赖抖一下就把多个实例一起重启了。再配上 server.shutdown=graceful 把在途请求 drain 干净,比在 catch 块里到处写 System.exit(1) 好控制。完整的 health group 与 indicator 配置在仓库里 liveness 那一处修复里。

不把升级当作这个问题的修复#

我写这篇时(2026-08-26)对照了 schema-registry 的 master 分支、kafka trunk 的 ConfigDefUtils,以及 Java 25 的 ForkJoinPool javadoc。三处都还保留着这条触发链:master 上的默认值仍是 String 类名(Type.CLASS 型配置还从 7.4.0 的 3 个涨到 7 个),trunk 的 ConfigKey 仍在 define 时解析默认值、Utils.loadClass 仍是 TCCL 优先。所以单纯升级 Boot、JDK 或 Confluent 版本不能证明问题已经消失。升级可以作为常规维护,但这个场景仍应通过 fat jar 复现和 TCCL 检查来验证。

教训#

类初始化失败会留在类和加载它的 ClassLoader 这一对上,不随下一次调用自愈:第一次初始化发生在哪个线程,决定了这个实例之后还能不能发消息。

只要一段全局初始化只跑一次、它要读的上下文藏在当前线程上、而谁第一个触发它又不确定,故障就会只挑某些实例、某些启动顺序出现,本地怎么试都是好的。ServiceLoader.load(Class) 读的同样是当前线程的 TCCL。这类地方不用另想修法:<clinit> 只跑一次既是故障的原因,也是前面那个 warm-up 能奏效的原因,换个库同样成立。所以本地复现不了的问题,我现在先怀疑打包和运行形态的差异;「重启就好了」也要追问重启掉的到底是哪一层。

总结#

  • commonPool 的 worker 把 AppClassLoader 当 TCCL,fat jar 下它看不见 BOOT-INF/lib;不传 executor 的 runAsync / supplyAsync / thenApplyAsync 都落在这里,而且新建线程会继承创建它的线程的 TCCL,坏 TCCL 还会传给下一个池;
  • ConfigDef.define() 在 clinit 里就用 TCCL 解析 Type.CLASS 的 String 默认值,这是同一个 JVM 里只有走 Confluent 序列化器的 topic 全挂、StringSerializer 那批全程正常的原因;
  • 中毒的 JVM 不会自己好,只能换掉;同一个 loader 加载的其他类不受影响;
  • 这次的直接修复是在 @PostConstruct 里用 LaunchedClassLoader 同时设置 Class.forName 的加载器和当前线程 TCCL;专用 executor 和 liveness 是另外两处,分别管防复发和缩短故障时间。

相关文章#

参考资料#