title: 同一个接口 3 个实现类,线上只加载了 1 个:Java SPI 的 META-INF/services 坑,我栽过两次
date: 2026-09-25
tags: [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) { ServiceLoader<ChannelGateway> 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 count=1本地输出:
com.xpay.channel.alipay.AlipayGateway com.xpay.channel.alipay.WechatGateway com.xpay.channel.alipay.UnionpayGateway loaded count=3同样的代码,同样的 JDK 17 镜像,差异只在打包阶段。我们用maven-shade-plugin把所有渠道模块打成一个 fat jar,提交给基础镜像。问题就出在这里。
二、最小复现:shade 合并把服务文件覆盖了
maven-shade-plugin默认会把多个 jar 里同名的资源文件按覆盖策略处理。三个META-INF/services/com.xpay.ChannelGateway文件同名,最后只保留了一个。下面这段pom.xml就是当时的配置:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> </execution> </executions> </plugin>打包后解压 fat jar,会发现META-INF/services/com.xpay.ChannelGateway只有一行:
com.xpay.channel.alipay.AlipayGateway这就是线上只加载了 1 个实现的根因。但这里有个更隐蔽的问题:ServiceLoader 不会报错。即使文件损坏、类名写错、类不存在,它也只是"不加载",而不会抛异常。这对排查非常不友好。
三、ServiceLoader 源码逐行解读
ServiceLoader的入口是load(Class<S> service),我们跟一下 JDK 17 的源码。
public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return new ServiceLoader<>(Reflection.getCallerClass(), service, cl); }第一行取的是当前线程的上下文类加载器(TCCL),不是 AppClassLoader。这也是为什么在 Tomcat、Spring Boot 的 LaunchedURLClassLoader 环境里,SPI 的行为会和普通 main 方法不一样。第二行创建ServiceLoader实例,此时还不会真正加载实现类。
真正加载发生在迭代器iterator()被调用时。ServiceLoader内部有一个LazyIterator:
private class LazyIterator implements Iterator<S> { Class<S> service; ClassLoader loader; Enumeration<URL> configs = null; String nextName = null; private LazyIterator(Class<S> 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 Iterator<String> parse(URL u) throws ServiceConfigurationError { InputStream in = null; BufferedReader r = null; ArrayList<String> 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(); }注意返回值是Iterator<String>,里面只有类名字符串。真正实例化是在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(); Enumeration<URL> 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 输出:
URL=jar: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> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.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()); ServiceLoader<ChannelGateway> loader = ServiceLoader.load(ChannelGateway.class);如果SomeFrameworkClassLoader看不到业务 jar 里的实现类,SPI 就会加载不到。JDK 源码里ServiceLoader.load明确用的是 TCCL,不是接口类的类加载器:
ClassLoader cl = Thread.currentThread().getContextClassLoader();所以如果你明确想用接口本身的类加载器,应该用ServiceLoader.load(service, service.getClassLoader()):
ServiceLoader<ChannelGateway> loader = ServiceLoader.load( ChannelGateway.class, ChannelGateway.class.getClassLoader() );这在模块化环境(JPMS)或者容器环境里尤其重要。
七、方案对比:SPI vs Spring FactoryBean vs Spring Boot starter
如果只是做"接口多实现自动发现",其实不止 SPI 一种方案。我当时总结了三种常见做法:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Java SPI | JDK 原生,无第三方依赖 | 无生命周期管理,失败静默,资源文件易被覆盖 | 简单插件、框架扩展点 |
| Spring SPI(spring.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 坑。