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 小 jar

jar 明明在。那是不是 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.jar
  • getParentFile() = 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。

至此整个链条闭合:

  1. Trino 483 用 HdfsClassLoader 隔离加载 HDFS 相关类
  2. HdfsClassLoader 只扫 plugin/<catalog>/hdfs/ 子目录
  3. HdfsClassLoader 的 parent 是 PlatformClassLoader,与应用 classpath 完全切断
  4. hive connector 的 Configuration 类由 HdfsClassLoader 加载
  5. Hadoop 的 FileSystem.getFileSystemClassConfiguration 的 ClassLoader 去 Class.forName(ofs 适配器)
  6. 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-pathClass.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”时:

  1. 别用独立 jshell,直接 Attach Agent 到目标 JVM。
  2. agent 里遍历 inst.getAllLoadedClasses(),找报错类的 ClassLoader,逐个 Class.forName 测试。
  3. dump 该 ClassLoader 的 URL 列表,确认 jar 在不在其中。
  4. 看源码确认 ClassLoader 的 parent 和加载范围。

Agent 模板就那十几行,但能省下好几个小时的盲猜。这次要不是 Attach 进去看了一眼 HdfsClassLoader 的 URL 列表,光靠 jshell 和源码阅读,可能还要在”jar 放哪个目录”上绕很久。

几点反思

  • “能跑通的参照系”很关键,但不能照搬配置。Kyuubi 能跑,是因为 Spark 用 extraClassPath 全引擎共享;Trino 有 ClassLoader 隔离。照搬前要理解为什么能跑。
  • 官方文档要读。腾讯云 CHDFS 文档那句”OFS 客户端自动拉取 cos api 客户端,无需安装”,一条信息就避开了手动堆 jar 的依赖地狱。我一开始没读,绕了一大圈。
  • shaded jar 的重定位是陷阱com.qcloud.chdfschdfs.1.5.2.8.com.qcloud.chdfs,手动放 classpath 后所有原包名引用全失效。看到 shaded 字样要警惕 relocation。
  • 框架的 ClassLoader 演进要从源码确认。411 → 483 的 HdfsClassLoader 隔离是个静悄悄的大变化,旧版 jar 放置方式不再适用,升级时这种”看似无害”的内部架构调整最坑。

一个 ClassNotFoundException,从 jar 缺失猜到 shaded 重定位,再到 ClassLoader 隔离,最后落到一个我从来没注意过的 hdfs/ 子目录。Debug 之路上,最费时间的往往不是最终答案,而是那些”看起来对、其实完全跑偏”的中间假设。