主题
Spark 3.5 升 4.1.3 实录:Iceberg 1.11 的 class 61、JDK 17 底座、CALL 鉴权前置到 parser
湖仓平台的引擎升级有个反常识的地方:mvn compile 全绿的那一刻,风险一个都还没暴露。 把「我的数据空间」(查询与引擎能力页)的引擎从 Spark 3.5.9 / Iceberg 1.9.2 / JDK 11 推到 Spark 4.1.3(Scala 2.13.17)/ Iceberg 1.11.0 / Temurin JDK 17,编译层真正需要改的只有一处;拦住上线的三道关全在运行期,而最难的一道根本不是版本问题,是鉴权时机。
一、先定顺序:这三件事不能一起做
| 组件 | 这轮怎么处理 | 依据 |
|---|---|---|
| JDK 11 → 17 | ✅ 先升 | 它是 Iceberg 1.11+ 与 Spark 4 的共同前置,且能独立验证、独立回滚 |
| Iceberg 1.9.2 → 1.11.0 | ✅ 再升 | 只换 jar,联动面最小;但上界不由「最新版」决定,由 JDK 决定(见第二节) |
| Spark 3.5.9 → 4.1.3 | ✅ 最后升 | 牵动 Scala 2.13 全链重编 + SQL 网关引擎 jar + 引擎侧鉴权扩展适配 |
| Flink 1.20.5 → 2.x | ⏸️ 按住不动 | 外部依赖没跟上:Iceberg 对 Flink 2.x 的 runtime 只覆盖到 2.1 且刚落地一个版本,而实时链路全靠它写 Iceberg |
顺序本身就是设计。三者的联动面互不相交,一起升会让故障无法定位——按「JDK → Iceberg → Spark」递进,每一步都能单独验证、单独回滚。
至于为什么现在升:判据是「越晚做越贵吗」,不是「现在能多干什么」。Scala 2.12 → 2.13 是一次性全链重编,平台自己的代码改完就完事;一旦有客户定制 jar 或用户提交的 Spark/Flink JAR 作业在跑,就变成要协调外部代码迁移。Spark 4 默认开启的 ANSI 模式同理——算术溢出与非法类型转换由静默返回 null 改成报错,这是正确性提升,但对已有报表就是 breaking change。这两条都是典型的越晚越贵,而 Spark 4 的新能力(VARIANT、String collation、SQL UDF)当前一个都不是刚需。升级理由是成本窗口,不是功能饥渴。

二、编译全绿,运行期还有三道关
关一:Iceberg 1.11.0 的字节码是 Java 17
升完 Iceberg 后端能起、Trino 与 OLAP 路径都通,唯独 Spark 路径全挂,SQL 网关只报一句「engine application has been terminated」。真话在 engine driver 的 pod 日志里:
UnsupportedClassVersionError: org/apache/iceberg/spark/ExtendedParser
has been compiled by a more recent version of the Java Runtime (class file version 61.0),
this version of the Java Runtime only recognizes class file versions up to 55.0逐版本验字节码,边界很干脆:
| iceberg-spark-runtime | class 文件版本 | 可跑的 JRE |
|---|---|---|
| 1.9.2 / 1.10.0 / 1.10.2 | 55 | Java 11 ✅ |
| 1.11.0 | 61 | Java 17 起 |
结论一句话:Iceberg 的可用上界不是「最新版」,而是「你的 JDK 允许的最新版」。iceberg-flink-runtime 同步跳版,所以 Flink 镜像的底座也一起换。
关二:Iceberg 1.10 起多了一个 KMS 硬依赖
后端服务启动即挂,栈很清楚:
NoClassDefFoundError: software/amazon/awssdk/services/kms/model/EncryptionAlgorithmSpec
at org.apache.iceberg.aws.AwsProperties.<clinit>
at ... S3FileIO.initialize → CatalogUtil.loadFileIO → HiveCatalog.initialize数一下 AwsProperties 里的 kms 引用:1.9.2 是 0 处,1.10.2 是 26 处——1.10 引入了对 AWS SDK v2 kms 模块的硬依赖。
只有一类用法会中招:自己声明 iceberg-aws + 逐个挑 awssdk 模块的服务(缺谁就炸);引擎镜像装的是 shaded 的 iceberg-aws-bundle,自带完整 SDK,完全无感。同一次升级,在两种打包方式下的表现完全不同——这就是为什么升级验证不能只看一条路径。
关三:Spark 4 上少给 --add-opens,会挂住而不是报错
Spark 3.3 起就把自己需要的 JVM 模块化参数放在了 org.apache.spark.launcher.JavaModuleOptions.defaultModuleOptions()。手工拼 classpath 跑 Spark 的脚本习惯硬编码两三个 --add-opens,在 3.5 上侥幸能过,到 Spark 4 直接卡死到超时被杀,全程零错误输出(实测需要 22 项,含 jdk.internal.ref、sun.security.action、--add-modules=jdk.incubator.vector)。
正确姿势是问 Spark 要,别硬编码。 这条的价值不在参数本身,在故障形态:同一个缺失,3.5 是「能跑」、4.x 是「无声挂住」,排查时极容易被引到别处。
三、自建 JDK 17 底座的两条硬约束
镜像底座要换 JDK,有两个地方一试就知道:
- 不能把宿主的 JDK 挂进容器。宿主 glibc 2.39、容器 2.31/2.35,新 glibc 编出来的二进制跑不了旧 glibc:
/lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found。用官方 tarball(Temurin 17.0.20,顺带规避早期 Java 17 的 C2 SIGSEGV 已知问题),整目录替换JAVA_HOME,下游镜像零改动。 ADD xx.tar.gz不能留在最终镜像的层里。ADD 解包自成一层,后面RUN rm -rf只写删除标记、字节仍在下层——实测镜像从 654MB 涨到 1.71GB,白背一份解包后的 JDK。改多阶段(前一阶段 ADD,最终镜像只COPY --from)后回落到 1.18GB。
还有一条容易被忽略的:自建底座必须与被替换的官方底座保持同样的默认用户语义。官方 Flink 镜像的 Config.User 是空(即 root,由 entrypoint 用 gosu 降权),底座里擅自补一行 USER flink,下游 Dockerfile 里的 rm -rf 立刻 Permission denied。连写 USER root 都不对——那会把「空」固化成 "root",不写才是继承。
四、真正的门槛:Spark 4 在 analysis 阶段就执行 CALL
湖仓的维护动作(合并小文件 rewrite_data_files、过期快照 expire_snapshots、清孤儿文件 remove_orphan_files)在 Spark 上都是 CALL 语句。多租户平台必须在它执行之前判断:这个人有没有权限动这张表。
平台的引擎侧鉴权扩展原先把这道闸注入成 Catalyst 的 check rule——在 Spark 3.5 上完全正确。到了 Spark 4,Call 实现了 ExecutableDuringAnalysis,由 analyzer 规则 InvokeProcedures 在 analysis 阶段直接执行。实测 Resolution batch(79 条规则)内的次序:
[22/79] ResolveProcedures [23/79] BindProcedures
[59/79] ProcedureArgumentCoercion
[74/79] InvokeProcedures ← 这里执行 procedure(真改数据)
[78/79] 扩展能注入到的最早位置 ← 换注入点也救不了扩展点全都太晚:injectResolutionRule 固定落在 batch 末尾,永远在 InvokeProcedures 之后;post-hoc 与 optimizer 规则更晚;而 InvokeProcedures 自己没有开关。
两条看似可行的出路,一条不通、一条通:
- ❌ 一律拒掉 CALL:合并小文件与清孤儿文件只能走 Spark CALL,拒掉等于湖仓失去核心运维能力。功能可降级,但不能把能力降到零。
- ✅ 把鉴权前置到 parser 层:parser 是唯一早于 analysis 的注入点。

前置之后有三个设计点值得单独说,它们决定了这道闸能不能一套源码同时服务两个大版本:
- 用动态代理包住 parser,而不是去实现 parser 接口。两版的方法集不同(Spark 4 多了抽象方法,还有方法的返回类型从可变 Seq 变成不可变 Seq),直接实现无法一套源码兼容;
java.lang.reflect.Proxy按运行时接口转发则天然兼容,将来 Spark 再加方法也不用改。 - 在 parser 阶段就拿到真正的 procedure 实例。Iceberg 的过程注册表提供了按名字取实例的入口,且两版签名一致——于是 parser 阶段也能拿到过程实例,它的类名正是权限语义表的 key、它声明的参数顺序正是位置实参的落位依据。语义表与参数定位逻辑与原来那层完全同源,不必另建一份「SQL 过程名 → 权限语义」映射;两份映射迟早漂移,而鉴权口径漂移就是安全漏洞。
- 两版的 CALL 语法产物按类名反射分派。3.5 的 CALL 由 Iceberg 的 parser 扩展产出,4.x 是 Spark 原生语法产出,连命名实参的表达式类型都不同;解包后下游逻辑零分支。
两层闸形成纵深防御:3.5 上两层都拦,4.x 上 parser 那层是唯一有效的那层。顺带把边界口径也校正了一处:网关侧的 SQL 解析抽不出表名时,交出空表集、由引擎侧鉴权兜底——网关的解析只是尽力而为,SQL 语义的权威是引擎。

五、SQL 网关侧的三件事
平台用 Kyuubi 做统一 SQL 接入,升级时有三件事各能省半天:
- Scala 2.13 的引擎 jar 不用自己编。Maven Central 只发
_2.12,一度以为要拿源码用 2.13 profile 自行构建;实际上官方 binary tarball 的externals/engines/spark/下两个 Scala 版本都带。 - engine pod 会复用旧镜像。改了引擎镜像并重启网关 server 后,存量 engine pod 不会跟着换——它是独立 pod、按空闲超时回收。判据是直接看 engine pod 的
.spec.containers[0].image;不显式回收掉,你验的一直是旧引擎。 - 改 ConfigMap 得
apply,不是restart。网关从 ConfigMap 读配置,只重启读到的还是旧内容;apply 之后挂载同步还有秒级延迟,要回查 pod 内文件确认再重启。
六、验收标准:跑到进程起来,且每条引擎路径都发过一条 SQL
这次三道关里,第一关靠读字节码版本、第二关靠真启动、第三关靠跑到超时——mvn compile 全绿的时候,后两关都还埋着。
| 层次 | 结果 |
|---|---|
| 引擎侧鉴权扩展回归 @ Spark 3.5.9 / Scala 2.12 | 46/46(含 parser 闸 8 项) |
| 同一套源码回归 @ Spark 4.1.3 / Scala 2.13 | 43/43 |
| 真集群 CALL 鉴权 e2e | 6/6,越权与参数越界均拒在 procedure 执行之前 |
| 平台冒烟(查询 / schema 演进 / 文件入湖) | 8/8 · 17/17 · 30/30,跨引擎读写核验全过 |
顺带一条纪律:编译期依赖版本必须与运行期一致。升级前编译环境比运行环境低两个 Iceberg 版本,一直靠「签名恰好一致」撑着——这不是兼容,是运气。
七、四句话总结
- 引擎升级的顺序本身就是设计:JDK 是 Iceberg 与 Spark 的共同前置,递进比一把梭风险可控得多,每步都能单独回滚;
- Iceberg 的可用上界由 JDK 决定,不由发布页决定;字节码版本、shaded 与非 shaded 的依赖差异,都是编译期看不见的;
- Spark 4 把 procedure 收成原生 API 并在 analysis 阶段执行,凡是靠 analysis 之后的规则做鉴权的实现,在 4.x 上都会被绕过——parser 是唯一有效的注入点;
- 升级的验收线不是「构建通过」,而是「每条引擎路径都真发过一条 SQL,且安全边界逐项回归过」。
「我的数据空间」是一套可私有化部署的数据平台(湖仓 + 调度 + 数据治理 + 智能诊断),引擎侧逐表逐列鉴权、湖仓维护作业、多引擎查询开箱即用。看安全与权限与核心能力总览,交流合作 QQ:1559851993。