首页/新闻资讯/正文详情

同一个接口 3 个实现类,线上只加载了 1 个:Java SPI 的 META-INF/services 坑,我栽过两次

发布时间:2026/9/26 7:23:25 来源:云帆数科 栏目:资讯中心
同一个接口 3 个实现类,线上只加载了 1 个:Java SPI 的 META-INF/services 坑,我栽过两次
title: 同一个接口 3 个实现类线上只加载了 1 个Java SPI 的 META-INF/services 坑我栽过两次date: 2026-09-25tags: [Java, SPI, ServiceLoader, 源码解析, 类加载器]去年 Q3 做支付网关重构我们把渠道接口抽象成ChannelGateway用 SPI 让各个渠道 jar 包自己注册实现。本地启动 3 个实现类都能加载一到灰度环境只剩 1 个。更诡异的是同样一份代码在容器 A 能加载 3 个在容器 B 只能加载 1 个。排查了 4 个小时最后发现是maven-shade-plugin把META-INF/services下的文件合并丢了而 ServiceLoader 的源码对这种失败几乎是静默的。这篇文章我把当时的完整排查过程和ServiceLoader源码拆开聊清楚 SPI 到底怎么加载、为什么失败不会抛异常、以及我觉得哪些场景不该用 SPI。一、事故现场3 个实现类只剩 1 个当时项目结构大致如下payment-core // 定义接口 ChannelGateway channel-alipay // 实现类 AlipayGateway channel-wechat // 实现类 WechatGateway channel-unionpay // 实现类 UnionpayGateway每个渠道模块都在src/main/resources/META-INF/services/com.xpay.ChannelGateway里写了自己的全限定类名。本地用ServiceLoader.load(ChannelGateway.class)能正常迭代出 3 个实现。灰度上线后监控发现只有支付宝渠道能下单微信和银联全灰了。我第一反应是类路径问题但classpath里三个 jar 都在。 then 我怀疑是 ServiceLoader 没读到文件于是写了段最小复现代码public class SpiDebug { public static void main(String[] args) { ServiceLoaderChannelGateway loader ServiceLoader.load(ChannelGateway.class); int count 0; for (ChannelGateway g : loader) { System.out.println(g.getClass().getName()); count; } System.out.println(loaded count count); } }灰度环境运行输出com.xpay.channel.alipay.AlipayGateway loaded count1本地输出com.xpay.channel.alipay.AlipayGateway com.xpay.channel.alipay.WechatGateway com.xpay.channel.alipay.UnionpayGateway loaded count3同样的代码同样的 JDK 17 镜像差异只在打包阶段。我们用maven-shade-plugin把所有渠道模块打成一个 fat jar提交给基础镜像。问题就出在这里。二、最小复现shade 合并把服务文件覆盖了maven-shade-plugin默认会把多个 jar 里同名的资源文件按覆盖策略处理。三个META-INF/services/com.xpay.ChannelGateway文件同名最后只保留了一个。下面这段pom.xml就是当时的配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.2.4/version executions execution phasepackage/phase goals goalshade/goal /goals /execution /executions /plugin打包后解压 fat jar会发现META-INF/services/com.xpay.ChannelGateway只有一行com.xpay.channel.alipay.AlipayGateway这就是线上只加载了 1 个实现的根因。但这里有个更隐蔽的问题ServiceLoader 不会报错。即使文件损坏、类名写错、类不存在它也只是不加载而不会抛异常。这对排查非常不友好。三、ServiceLoader 源码逐行解读ServiceLoader的入口是load(ClassS service)我们跟一下 JDK 17 的源码。public static S ServiceLoaderS load(ClassS service) { ClassLoader cl Thread.currentThread().getContextClassLoader(); return new ServiceLoader(Reflection.getCallerClass(), service, cl); }第一行取的是当前线程的上下文类加载器TCCL不是 AppClassLoader。这也是为什么在 Tomcat、Spring Boot 的 LaunchedURLClassLoader 环境里SPI 的行为会和普通 main 方法不一样。第二行创建ServiceLoader实例此时还不会真正加载实现类。真正加载发生在迭代器iterator()被调用时。ServiceLoader内部有一个LazyIteratorprivate class LazyIterator implements IteratorS { ClassS service; ClassLoader loader; EnumerationURL configs null; String nextName null; private LazyIterator(ClassS service, ClassLoader loader) { this.service service; this.loader loader; } private boolean hasNextService() { if (configs null) { // 1. 拼接资源文件名 String fullName PREFIX service.getName(); if (loader ! null) configs loader.getResources(fullName); else configs ClassLoader.getSystemResources(fullName); } // ... 解析每一行类名 } }这里PREFIX就是META-INF/services/。loader.getResources(fullName)会遍历类路径上所有同名资源理论上应该返回多个 URL。但如果 shade 打包把它们合并成一个文件那就只有一个 URL里面也只有一行。继续往下看解析逻辑while ((pending null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending parse(configs.nextElement()); }parse(URL u)方法会打开输入流按行读取类名同时会跳过#开头的注释和空行private IteratorString parse(URL u) throws ServiceConfigurationError { InputStream in null; BufferedReader r null; ArrayListString names new ArrayList(); try { in u.openStream(); r new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); int lc 1; while ((lc parseLine(u, r, lc, names)) 0); } // ... 关闭流 return names.iterator(); }注意返回值是IteratorString里面只有类名字符串。真正实例化是在nextService()private S nextService() { String cn nextName; nextName null; Class? c Class.forName(cn, false, loader); if (!service.isAssignableFrom(c)) { fail(service.getName() : Provider cn not a subtype); } S p service.cast(c.newInstance()); providers.put(cn, p); return p; }这里有三个关键动作1.Class.forName(cn, false, loader)—— 用 SPI 指定的类加载器加载类。2.isAssignableFrom—— 检查是否实现了接口。3.c.newInstance()—— 反射创建实例所以实现类必须有无参构造。如果类名写错、或者类加载器找不到这个类Class.forName会抛ClassNotFoundException但 ServiceLoader 会把它包装成ServiceConfigurationError抛出来不是受检异常。这点很重要如果你不用 try-catch 包住迭代过程线上可能直接挂。四、排查过程为什么容器 A 和容器 B 表现还不一样同一套 fat jar两个容器运行一个加载 3 个一个加载 1 个。这个差异当时困扰了我们很久。后来发现是基础镜像的启动方式不同容器 A 用java -cp libs/* com.xpay.Bootstrap启动所有 jar 平铺在 classpath 上没有 fat jar 合并问题。容器 B 用java -jar payment-all.jar启动走的是 Spring Boot 的LaunchedURLClassLoader读取的是 shade 后的 fat jar服务文件被覆盖了。也就是说不是 SPI 本身有问题而是打包方式决定了META-INF/services资源文件是否完整。为了验证我在容器 B 里临时加了段诊断代码ClassLoader cl Thread.currentThread().getContextClassLoader(); EnumerationURL resources cl.getResources(META-INF/services/com.xpay.ChannelGateway); while (resources.hasMoreElements()) { URL url resources.nextElement(); System.out.println(URL url); try (BufferedReader br new BufferedReader( new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { br.lines().forEach(System.out::println); } }容器 B 输出URLjar:file:/app/payment-all.jar!/META-INF/services/com.xpay.ChannelGateway com.xpay.channel.alipay.AlipayGateway只有一个 URL文件里只有支付宝。问题彻底定位。五、修复方案ServiceResourceTransformer 与服务合并maven-shade-plugin其实提供了ServicesResourceTransformer专门用来合并META-INF/services下的同名文件。加上这个 transformer 后打包时会自动把多个同名文件的内容拼接起来plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.2.4/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ServicesResourceTransformer/ /transformers /configuration /execution /executions /plugin重新打包后服务文件变成com.xpay.channel.alipay.AlipayGateway com.xpay.channel.wechat.WechatGateway com.xpay.channel.unionpay.UnionpayGateway灰度重新发布后3 个渠道全部恢复。这次事故的复盘会议上我们把SPI 服务文件是否合并加入了 fat jar 打包后的自动校验清单。六、另一个坑TCCL 被替换后 SPI 加载不到类除了 shade 合并SPI 还有一个常见坑当前线程上下文类加载器被设置成了错误的 ClassLoader。在一些框架比如 OSGi、某些容器里可能会这样写Thread.currentThread().setContextClassLoader(SomeFrameworkClassLoader.class.getClassLoader()); ServiceLoaderChannelGateway loader ServiceLoader.load(ChannelGateway.class);如果SomeFrameworkClassLoader看不到业务 jar 里的实现类SPI 就会加载不到。JDK 源码里ServiceLoader.load明确用的是 TCCL不是接口类的类加载器ClassLoader cl Thread.currentThread().getContextClassLoader();所以如果你明确想用接口本身的类加载器应该用ServiceLoader.load(service, service.getClassLoader())ServiceLoaderChannelGateway loader ServiceLoader.load( ChannelGateway.class, ChannelGateway.class.getClassLoader() );这在模块化环境JPMS或者容器环境里尤其重要。七、方案对比SPI vs Spring FactoryBean vs Spring Boot starter如果只是做接口多实现自动发现其实不止 SPI 一种方案。我当时总结了三种常见做法方案优点缺点适用场景Java SPIJDK 原生无第三方依赖无生命周期管理失败静默资源文件易被覆盖简单插件、框架扩展点Spring SPIspring.factories / META-INF/spring与 Spring 生命周期集成支持条件装配依赖 Spring 容器Spring 生态项目Spring Boot starter自动装配配置化程度高最重对非 Spring 项目不适用业务微服务模块我的取舍判断是如果项目已经用 Spring Boot优先用 starter 或spring.factories别为了原生而原生只有在写框架、或者必须零依赖时才用 JDK SPI。而且用了 JDK SPI 之后打包阶段必须校验服务文件是否完整。八、复盘真实数字排查耗时4 小时 15 分钟影响范围灰度环境微信、银联渠道无法下单约 12% 流量受影响根因定位shade 合并丢失 2 个服务文件修复成本加一行ServicesResourceTransformer重新打包发布后续预防CI 增加jar tf | grep META-INF/services校验确保每个接口的服务文件行数 ≥ 预期实现数九、我的建议用 fat jar 时务必加上ServicesResourceTransformer。对关键 SPI 接口启动时主动做一次加载校验数量不对就报错。不要依赖 ServiceLoader 的静默失败它不会让你少踩坑只会让你晚发现。模块化环境下搞清楚当前线程的 ClassLoader 是什么。十、思考题你项目里有没有用 SPI 做扩展点如果打包后服务文件被覆盖了你的系统会怎么表现欢迎在评论区说说你踩过的 SPI 坑。

相关推荐

双语言实现LintCode算法题:Java与Python刷题实战与避坑指南
双语言实现LintCode算法题:Java与Python刷题实战与避坑指南

简介:针对 lintcode 平台的算法与数据结构题目,这份压缩包提供 Java 与 Python 两种语言的完整实现方案,既能覆盖初学者学习算法所需,也能满足有基础开发者提升解题能力的需求,尤其适合作为毕业设计的参考资料。包内共… · 2026/9/26 7:23:25

Claude写代码实战:从工具选型到PR的完整指南
Claude写代码实战:从工具选型到PR的完整指南

1. 从“辅助写代码”到“主力写代码”的认知转变1.1 为什么这个话题突然火了最近半年,我身边越来越多的工程师开始把 Claude 放到编码流程的中心位置,而不是像以前那样只把它当成一个“高级自动补全”。这个转变不是小打小闹,它直接改变了我们… · 2026/9/26 7:23:25

从“能聊”到“能干活”:AI Agent 的工程化改造指南
从“能聊”到“能干活”:AI Agent 的工程化改造指南

上次清理旧项目时,我翻出一个当时觉得特别得意的对话机器人。它能记住用户三天前说过喜欢什么口味的咖啡,连上周聊到一半的电影都能接上话。但当我让它把桌面上一个 CSV 按规则清洗后发到我邮箱时,它卡住了,只回了一句“我可以帮你… · 2026/9/26 7:23:25

Baserow 自托管无代码数据库完整教程:从建表到自动化系统只需 3 步
Baserow 自托管无代码数据库完整教程:从建表到自动化系统只需 3 步

Baserow 自托管无代码数据库完整教程:从建表到自动化系统只需 3 步 【免费下载链接】baserow Build databases, automations, apps & agents with AI — no code. Open source platform available on cloud and self-hosted. GDPR, HIPAA, SOC 2 compliant. Bes… · 2026/9/26 7:58:56

心脏病数据分析系统:Java全栈实战拆解与重难点解析
心脏病数据分析系统:Java全栈实战拆解与重难点解析

心脏病数据分析系统这类项目,本质上是一个典型的 Java 全栈实战案例,但又不完全是“增删改查脚手架”。它真正的技术含量集中在统计聚合、关联分析、可视化报表和医疗数据的处理细节上。如果你是因为找毕设参考、做技术练手、或者想转行医疗信息化方向而… · 2026/9/26 7:58:55

一次推送跑完 3 个阶段:Baserow CI/CD 流水线与 Docker 镜像构建拆解
一次推送跑完 3 个阶段:Baserow CI/CD 流水线与 Docker 镜像构建拆解

一次推送跑完 3 个阶段:Baserow CI/CD 流水线与 Docker 镜像构建拆解 【免费下载链接】baserow Build databases, automations, apps & agents with AI — no code. Open source platform available on cloud and self-hosted. GDPR, HIPAA, SOC 2 compliant. B… · 2026/9/26 7:58:55

物联网无线收发芯片选型指南:Sub-1G与2.4G方案对比及实战避坑
物联网无线收发芯片选型指南:Sub-1G与2.4G方案对比及实战避坑

1. 物联网无线收发芯片的底层逻辑与方案选型思路搞物联网硬件的人都有一个共识:有线方案再稳,也架不住场景碎片化。你不可能给每台共享单车拉根网线,也不可能给农田里的土壤传感器铺光纤。无线收发芯片就是解决“最后一百米”甚至“最后十公里… · 2026/9/26 7:58:55

Win11共享打印句柄无效(0x00000012)故障深度解析
Win11共享打印句柄无效(0x00000012)故障深度解析

1. 这不是蓝屏,但比蓝屏更让人抓狂:一句“句柄无效”如何瘫痪整个办公室打印链2026年9月某个周一上午9:17,行政部小张刚把季度报表发到共享打印机队列,屏幕右下角突然弹出红色警告框:“操作失败:句柄无效&a… · 2026/9/26 7:58:55

C++适配器模式实战:接口转换与两种实现方式详解
C++适配器模式实战:接口转换与两种实现方式详解

做C开发这些年,适配器模式是我用得最频繁的几个设计模式之一。不管是接手老项目、接入第三方SDK,还是重构代码时统一接口,几乎都会碰到“接口长得不一样,但干的事差不多”的情况。适配器模式就是专门干这个事的:把不兼… · 2026/9/26 7:58:43

数据库课后习题答案别硬背:当测试用例集刷,效率翻倍
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍

简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21

OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40

向下兼容与向上兼容:接口设计中的兼容性策略与工程实践
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践

一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码