Trino 483 升级踩坑:一个 ClassNotFoundException 背后的 ClassLoader 隔离陷阱
一个 jar 明明就在 classpath 里,
Class.forName却死活找不到。最后发现 Trino 483 偷偷换了一个 ClassLoader,还把它指向了一个我压根没想到的子目录。
问题登场
最近把一套 Trino 集群从 411 升级到 483。升级后出现一个很别扭的现象:连接正常、元数据查询正常,唯独读数据时炸了。
报错长这样:
Failed to list directory: ofs://<chdfs-mount>/user/hive/warehouse/tpcds_sf10.db/call_center
Caused by: org.apache.hadoop.fs.UnsupportedFileSystemException: No FileSystem for scheme "ofs"
at org.apache.hadoop.fs.FileSystem.getFileSystemClass(...)
at io.trino.hdfs.TrinoFileSystemCache.createFileSystem(...)
Caused by: java.lang.ClassNotFoundException:
Class com.qcloud.chdfs.fs.CHDFSHadoopFileSystemAdapter not found
翻译成人话:Trino 想读取 CHDFS(腾讯云元数据加速桶,用 ofs:// 协议)上的数据,结果 Hadoop 的 FileSystem 工厂找不到 ofs 这个 scheme 对应的实现类。
关键背景是:同一套环境里,另一个引擎(Kyuubi on Spark)用 ofs:// 查得好好的。这就有意思了——为什么 Trino 不行?
第一轮:照搬经验,直接打脸
最朴素的思路:报 ClassNotFoundException,那就是 jar 没装。先确认 jar 在不在。
ls /lib/trino/plugin/hive/ | grep chdfs
# chdfs_hadoop_plugin-1.5.2.8-shaded.jar ← 21MB 大 jar
# chdfs_hadoop_plugin_network-2.8.jar ← 18KB 小 jarjar 明明在。那是不是 jar 本身有问题?解开看看:
# 小 jar(network)只有几个壳类
jar tf chdfs_hadoop_plugin_network-2.8.jar | grep CHDFS
com/qcloud/chdfs/fs/CHDFSHadoopFileSystemAdapter.class
com/qcloud/chdfs/fs/CHDFSHadoopFileSystemJarLoader.class
# 大 jar(shaded)把包名重定位了!
jar tf chdfs_hadoop_plugin-1.5.2.8-shaded.jar | grep "com/qcloud/chdfs/client"
chdfs/1/5/2/8/com/qcloud/chdfs/client/ # ← 注意前缀 chdfs.1.5.2.8.发现一个猫腻:那个 21MB 的 shaded jar 用了 maven-shade-plugin 的 relocation,把 com.qcloud.chdfs 重命名成了 chdfs.1.5.2.8.com.qcloud.chdfs。直接放 classpath 后,代码里 Class.forName("com.qcloud.chdfs.client.xxx") 永远找不到(类实际在 chdfs.1.5.2.8.xxx)。
但这是表象。把它换掉也没解决问题。
这时候我注意到 Kyuubi 用的另一套 jar 组合,而且它能正常查 ofs:
# Kyuubi 的 ofs jar 组合(能工作)
chdfs_hadoop_plugin_network-2.8.jar # 同款 18KB 壳
cos_api-bundle-5.6.246.3.jar # 非 shaded 的 impl
hadoop-cos-3.3.0-8.3.31.jar # COSN filesystem
我信心满满地照搬到 Trino,放到 plugin 目录、重启、查询——还是同一个错。
照搬失败。这说明问题不在 jar 缺失,而在类加载机制本身。
第二轮:ClassLoader 隔离的第一次误判
报错栈里有这么一行:
org.apache.hadoop.conf.Configuration.getClassByName
org.apache.hadoop.fs.FileSystem.getFileSystemClass
io.trino.hdfs.TrinoFileSystemCache.createFileSystem
Hadoop 的 FileSystem.getFileSystemClass 内部用 Class.forName 加载 scheme 实现类,默认走加载 Configuration 类的那个 ClassLoader(严格说还有 TCCL,但这里关键的是 Configuration 的 ClassLoader)。
于是我开始测:到底哪个 ClassLoader 能找到 ofs 类?
用 jshell 模拟:
# 用系统 classpath 测
jshell --class-path "/usr/lib/trino/lib/*" -s
jshell> Class.forName("com.qcloud.chdfs.fs.CHDFSHadoopFileSystemAdapter")
$2 ==> class com.qcloud.chdfs.fs.CHDFSHadoopFileSystemAdapter # FOUND!
# 用 plugin classpath 测
jshell --class-path "/lib/trino/plugin/hive/*" -s
jshell> Class.forName(...)
$4 ==> class ... # FOUND!两个都能找到!但 Trino 实际查询就是报 not found。
这个”找到了”是假阳性。jshell --execution local 启动的是一个全新的独立 JVM,用的是 AppClassLoader。而 Trino 查询线程用的根本不是 AppClassLoader。独立进程根本模拟不了目标 JVM 内部的 ClassLoader 拓扑。
这是整个排查里最隐蔽的一个坑:诊断 ClassLoader 问题,必须在目标 JVM 内执行代码,不能用独立进程模拟。
第三轮:Attach Agent 注入 Trino JVM
意识到独立 jshell 不可信后,我祭出 Java 的 Attach API——把一个 agent 注入到正在运行的 Trino JVM(PID 1)里,在它内部直接查。
先写一个 agent:
import java.lang.instrument.Instrumentation;
public class Agent {
public static void agentmain(String args, Instrumentation inst) {
// 遍历 JVM 内所有已加载的类,找 Configuration 类
for (Class<?> c : inst.getAllLoadedClasses()) {
if (c.getName().equals("org.apache.hadoop.conf.Configuration")) {
ClassLoader cl = c.getClassLoader();
System.out.println("CFG_LOADER=" + cl.getClass().getName());
// 用这个 ClassLoader 试着加载 ofs 适配器
try {
Class.forName("com.qcloud.chdfs.fs.CHDFSHadoopFileSystemAdapter", true, cl);
System.out.println("OFSTEST=FOUND");
} catch (Throwable t) {
System.out.println("OFSTEST=" + t);
}
}
}
}
}再写个注入器把 agent 塞进 Trino 进程:
import com.sun.tools.attach.VirtualMachine;
public class Inject {
public static void main(String[] a) throws Exception {
VirtualMachine vm = VirtualMachine.attach("1");
vm.loadAgent("/tmp/agent.jar");
Thread.sleep(2000);
vm.detach();
}
}打包 agent 时记得在 MANIFEST.MF 里声明 Agent-Class,否则 loadAgent 会报 Agent JAR not found or no Agent-Class attribute:
printf "Agent-Class: Agent\nCan-Redefine-Classes: false\nCan-Retransform-Classes: false\n" > m.txt
jar cfm agent.jar m.txt Agent.class运行后,Trino 的 server.log 里打印出决定性结果:
CFG_LOADER=io.trino.filesystem.manager.HdfsClassLoader@...
OFSTEST=java.lang.ClassNotFoundException:
com.qcloud.chdfs.fs.CHDFSHadoopFileSystemAdapter ❌
CFG_LOADER=jdk.internal.loader.ClassLoaders$AppClassLoader@...
OFSTEST=FOUND ✅(但查询不用这个)
真相大白:Trino 483 引入了一个叫 HdfsClassLoader 的东西,hive connector 用的 Configuration 类是由它加载的,而它找不到 ofs 适配器。系统的 AppClassLoader 倒是能找到,可查询压根不走它。
第四轮:dump 出 HdfsClassLoader 的真相
既然锁定是 HdfsClassLoader 的锅,那它到底加载了哪些 jar?再写个 agent dump 它的 URL 列表:
if (cl instanceof URLClassLoader) {
URL[] urls = ((URLClassLoader)cl).getURLs();
for (URL u : urls) System.out.println("URL=" + u);
}输出:
URL=file:/.../plugin/hive/hdfs/io.trino_trino-hdfs-483.jar
URL=file:/.../plugin/hive/hdfs/io.trino_trino-filesystem-483.jar
URL=file:/.../plugin/hive/hdfs/io.trino.hadoop_hadoop-apache-3.3.5-3.jar
... 全是 trino / hadoop 的系统 jar
注意路径里的 hdfs/ 子目录——HdfsClassLoader 只加载 plugin/hive/hdfs/ 这个子目录下的 jar。而我之前一直把 ofs jar 放在 plugin/hive/ 主目录,它根本看不见。
第五轮:源码确认
光看运行时行为还不够,翻 Trino 源码坐实。
lib/trino-filesystem-manager/src/main/java/io/trino/filesystem/manager/HdfsFileSystemLoader.java:
public HdfsFileSystemLoader(Map<String, String> config, String catalogName, ConnectorContext context) {
Class<?> clazz = tryLoadExistingHdfsManager();
if (!getClass().getClassLoader().equals(Plugin.class.getClassLoader())) {
verify(clazz == null, "HDFS should not be on the plugin classpath");
File sourceFile = getCurrentClassLocation(); // = trino-filesystem-manager-483.jar
File directory;
if (sourceFile.isDirectory()) {
// IDE 开发模式
directory = new File(sourceFile.getParentFile().getParentFile().getParentFile(),
"trino-hdfs/target/hdfs");
} else {
// 正常部署模式:HDFS jar 在 plugin 目录的子目录里
directory = new File(sourceFile.getParentFile(), "hdfs"); // ← 关键
}
verify(directory.isDirectory(), "HDFS directory is missing: %s", directory);
classLoader = createClassLoader(directory); // 只扫这个 directory
clazz = loadHdfsManager(classLoader);
}
...
}getCurrentClassLocation() 返回的是 trino-filesystem-manager-483.jar 自己。于是目录推导就是:
sourceFile=plugin/hive/io.trino_trino-filesystem-manager-483.jargetParentFile()=plugin/hive/new File(parentFile, "hdfs")=plugin/hive/hdfs/
再看 buildClassPath,它用 Files.newDirectoryStream(path) 只列这一层目录,不递归:
private static List<URL> buildClassPath(File path) {
try (DirectoryStream<Path> directoryStream = newDirectoryStream(path.toPath())) {
return stream(directoryStream)
.map(Path::toFile)
.sorted().toList().stream()
.map(HdfsFileSystemLoader::fileToUrl)
.toList();
}
catch (IOException e) { throw new UncheckedIOException(e); }
}最狠的是 HdfsClassLoader 的构造函数——它的 parent 不是 AppClassLoader,而是 getPlatformClassLoader():
final class HdfsClassLoader extends URLClassLoader {
public HdfsClassLoader(List<URL> urls) {
// This class loader should not have access to the system (application) class loader
super(urls.toArray(URL[]::new), getPlatformClassLoader()); // ← parent 是 PlatformClassLoader
}
...
}PlatformClassLoader 只加载 JDK 平台模块,看不到应用 classpath。所以把 jar 放到 /usr/lib/trino/lib/(AppClassLoader)也白搭——HdfsClassLoader 的继承链里压根没有 AppClassLoader。
至此整个链条闭合:
- Trino 483 用
HdfsClassLoader隔离加载 HDFS 相关类 HdfsClassLoader只扫plugin/<catalog>/hdfs/子目录HdfsClassLoader的 parent 是PlatformClassLoader,与应用 classpath 完全切断- hive connector 的
Configuration类由HdfsClassLoader加载 - Hadoop 的
FileSystem.getFileSystemClass用Configuration的 ClassLoader 去Class.forName(ofs 适配器) - ofs 适配器在
plugin/hive/主目录,不在hdfs/子目录 → 找不到 → 报错
为什么旧版(411)没事?
旧版 Trino 411 没有 HdfsClassLoader,hive connector 用的是 plugin 原生 ClassLoader,它加载 plugin 目录下全部 jar。所以把 ofs jar 扔到 plugin/hive/ 主目录就能用。
另外阿里云那套(用 JindoFS 访问 OSS-HDFS)之所以一直好好的,是因为 JindoFS 的 jar 是自包含的——impl 类就在标准非重定位包里,扔进 plugin 目录就完事。
对比腾讯云 CHDFS:
| 维度 | 阿里云 OSS-HDFS(JindoFS) | 腾讯云 CHDFS(ofs://) |
|---|---|---|
| jar 形态 | 自包含整 jar,impl 类在标准包 | 拆分式:18KB 壳 + 运行时下载的 shaded impl |
| 设计哲学 | 静态可用 | 运行时联网下载 |
| 容器友好度 | 高 | 低(要联网、要可写缓存、shaded jar 不能直挂) |
CHDFS 的 CHDFSHadoopFileSystemAdapter 其实只是个 18KB 的壳,真正的 impl 是它运行时去 CHDFS metadata server 下载一个 21MB 的 shaded jar、再用独立 URLClassLoader 加载的。这种”壳 + 运行时下载”的设计,本身就和”把 jar 直接放 classpath”的思路冲突——那个 21MB 的 shaded jar 本就不该手动放 classpath。
修复
参考腾讯云官方文档(cloud.tencent.com/document/product/436/115666)原话:
OFS 客户端自动拉取 cos api 客户端,无需安装。挂载成功后可在
fs.ofs.tmp.cache.dir配置的目录下查看对应 JAR 包及版本。
也就是说,正确的做法是只放 18KB 的适配器壳,让它自己运行时去下载 impl。改 init 脚本:
# 只把适配器壳放到 HdfsClassLoader 扫描的 hdfs/ 子目录
cp /nfs/chdfs_hadoop_plugin_network-2.8.jar \
/lib/trino/plugin/hive/hdfs/
# 适配器壳自己需要两个依赖(它不在 shaded impl 里):
# - gson:解析 metadata server 返回的 JSON
# - commons-codec:算下载 jar 的 MD5
cp /lib/trino/plugin/hive/com.google.code.gson_gson-*.jar \
/lib/trino/plugin/hive/hdfs/
cp /lib/trino/plugin/hive/commons-codec_commons-codec-*.jar \
/lib/trino/plugin/hive/hdfs/为什么适配器壳还要额外补 gson 和 commons-codec?因为适配器壳和运行时下载的 shaded impl 是用两个独立的 ClassLoader 加载的,impl 的依赖(全在 shaded jar 里)对适配器壳不可见。适配器壳自己要解析 metadata server 返回的 JSON、要算下载 jar 的 MD5,这两步用到的 gson 和 commons-codec 必须在适配器壳的 ClassLoader(即 hdfs/ 子目录)里可见。
修复后验证,/tmp/chdfs_cache/ 里出现了自动下载的 impl jar:
$ ls /tmp/chdfs_cache/
chdfs_hadoop_plugin-1.5.2.8-shaded.jar # 运行时下载的,21MB 自包含
chdfs_hadoop_plugin-1.5.2.8-shaded.jar.LOCK
查询全部通过:
SELECT count(*) FROM hive.tpcds_sf10.call_center → FINISHED, [[24]]
SELECT cc_call_center_id, cc_name ... LIMIT 5 → FINISHED, 5 行数据
SELECT count(*) FROM hive.tpcds_sf10.store_sales → FINISHED, [[28800991]]
踩过的坑(按时间顺序)
排查过程中踩了一串坑,每个都值得记一笔:
坑 1:jshell 独立 JVM 假阳性。 用 jshell --class-path 测 Class.forName 返回 FOUND,以为问题不在 ClassLoader。但 jshell 启动的是独立 JVM,用的是 AppClassLoader,根本不反映目标 JVM 的真实拓扑。教训:ClassLoader 问题必须在目标 JVM 内诊断。
坑 2:手动 mkdir 破坏 launcher symlink。 第一次改脚本时,顺手把 jar 也 cp 到运行时目录 /tmp/trino/data/plugin/hive/hdfs/,还 mkdir -p 造了真实目录。结果 Trino launcher 启动时报 failed to symlink the install dir inside the data dir: path /tmp/trino/data/plugin exists and is not a symlink——launcher 要把 /tmp/trino/data/plugin symlink 到 /usr/lib/trino/plugin,我提前建真实目录把这个机制破坏了。修复:只动 /lib/trino/plugin/,launcher 自己 symlink。
坑 3:jar 文件名硬编码。 脚本写死 hadoop-cos-3.3.0-8.3.31.jar,但 NFS 里实际叫 hadoop-cos-3.3.31.jar(之前 cp 时改名了),cp 报 No such file。修复:用通配符 hadoop-cos-*.jar。
坑 4:自动下载的依赖逐个补。 放了 network jar 后报 NoClassDefFoundError: gson/JsonParser,补 gson 又报 commons-codec/Hex。因为适配器壳自己也要这俩,而它们不在运行时下载的 impl jar 里(两个独立 ClassLoader)。逐个补到 hdfs/ 子目录才消停。
可复用的诊断套路
这次最有价值的产出,是这套 ClassLoader 问题的诊断方法。遇到”jar 明明在却 ClassNotFoundException”时:
- 别用独立 jshell,直接 Attach Agent 到目标 JVM。
- agent 里遍历
inst.getAllLoadedClasses(),找报错类的 ClassLoader,逐个Class.forName测试。 - dump 该 ClassLoader 的 URL 列表,确认 jar 在不在其中。
- 看源码确认 ClassLoader 的 parent 和加载范围。
Agent 模板就那十几行,但能省下好几个小时的盲猜。这次要不是 Attach 进去看了一眼 HdfsClassLoader 的 URL 列表,光靠 jshell 和源码阅读,可能还要在”jar 放哪个目录”上绕很久。
几点反思
- “能跑通的参照系”很关键,但不能照搬配置。Kyuubi 能跑,是因为 Spark 用
extraClassPath全引擎共享;Trino 有 ClassLoader 隔离。照搬前要理解为什么能跑。 - 官方文档要读。腾讯云 CHDFS 文档那句”OFS 客户端自动拉取 cos api 客户端,无需安装”,一条信息就避开了手动堆 jar 的依赖地狱。我一开始没读,绕了一大圈。
- shaded jar 的重定位是陷阱。
com.qcloud.chdfs→chdfs.1.5.2.8.com.qcloud.chdfs,手动放 classpath 后所有原包名引用全失效。看到 shaded 字样要警惕 relocation。 - 框架的 ClassLoader 演进要从源码确认。411 → 483 的
HdfsClassLoader隔离是个静悄悄的大变化,旧版 jar 放置方式不再适用,升级时这种”看似无害”的内部架构调整最坑。
一个 ClassNotFoundException,从 jar 缺失猜到 shaded 重定位,再到 ClassLoader 隔离,最后落到一个我从来没注意过的 hdfs/ 子目录。Debug 之路上,最费时间的往往不是最终答案,而是那些”看起来对、其实完全跑偏”的中间假设。