Spark 3.5 升 4.1.3 实录:Iceberg 1.11 的 class 61、JDK 17 底座、CALL 鉴权前置到 parser Spark 3.5 升 4.1.3 实录:Iceberg 1.11 的 class 61、JDK 17 底座、CALL 鉴权前置到 parser湖仓平台的引擎升级有个反常识的地方:mvn compile全绿的那一刻,风险一个都还没暴露。把「我的数据空间」(datastudiohappy.cn)的引擎从 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-runtimeclass 文件版本可跑的 JRE1.9.2 / 1.10.0 / 1.10.255Java 11 ✅1.11.061Java 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-modulesjdk.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.1246/46(含 parser 闸 8 项)同一套源码回归 Spark 4.1.3 / Scala 2.1343/43真集群 CALL 鉴权 e2e6/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,且安全边界逐项回归过」。「我的数据空间」是一套可私有化部署的数据平台(湖仓 调度 数据治理 智能诊断),引擎侧逐表逐列鉴权、湖仓维护作业、多引擎查询开箱即用,支持 OEM 合作。