新闻详情

新闻详情

首页 / 资讯中心 / 详情

同一个接口 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 坑。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

OpenWAM世界动作模型:七校联合开源,破解机器人sim-to-real难题 2026/9/26 8:16:40

OpenWAM世界动作模型:七校联合开源,破解机器人sim-to-real难题

1. 这个「世界模拟器」到底在解决什么问题机器人圈子里有个老生常谈的尴尬:真机上跑一个抓取策略,调参调到怀疑人生,一天下来机械臂没动几次,日志倒是刷了几百兆。强化学习在仿真里能飞檐走壁,一上真机就变成「人工智障…

阅读更多 →
金融数据服务架构设计与工程实践:模块化分层、数据模型与实时推送 2026/9/26 8:16:33

金融数据服务架构设计与工程实践:模块化分层、数据模型与实时推送

1. 金融数据服务项目的整体架构设计思路1.1 为什么选择模块化分层架构做金融数据服务这些年,我最大的体会就是:千万别把数据采集、清洗、存储、接口这四件事揉在一起写。早期我接手过一个项目,所有逻辑塞在一个脚本里,行情数据拉取…

阅读更多 →
AI Agent开发利器:BrowserSkill本地桥如何接管真实浏览器 2026/9/26 8:16:33

AI Agent开发利器:BrowserSkill本地桥如何接管真实浏览器

刚接触 AI Agent 开发那阵子,最容易困惑的一件事就是:Agent 到底长什么样?很多人的第一反应是“Agent 就是调用大模型 API,让它说一段话”,后来发现还要接工具(Tool Use)、接记忆、接流程编排。…

阅读更多 →
nRF54LC10A休眠电流实测:从50nA到整板低功耗设计 2026/9/26 8:16:32

nRF54LC10A休眠电流实测:从50nA到整板低功耗设计

1. 这颗芯片到底在卷什么:从休眠电流到电池寿命的账 第一次看到“休眠电流不到 50 nA”这个数字,我的反应是——这基本等于把“待机耗电”这件事按在地上摩擦了。做过低功耗产品的人都知道,nA 级别的休眠电流不是随便标标的,它背后…

阅读更多 →
Atlas 300V 24G推理加速卡部署YOLO全流程解析与踩坑指南 2026/9/26 8:16:26

Atlas 300V 24G推理加速卡部署YOLO全流程解析与踩坑指南

手头正好在研究 Atlas 相关的东西,最近搜“atlas 部署 YOLO”的朋友明显变多了。我在实际项目里踩过不少 Atlas 系列的坑,尤其是 Atlas 300V 24G 这块卡,很多人一上来就问同一个问题:这玩意儿到底是不是运算加速卡?它能…

阅读更多 →
基于WiFi与RS485的园区有氧健身设备物联网监测系统设计与落地 2026/9/26 8:16:26

基于WiFi与RS485的园区有氧健身设备物联网监测系统设计与落地

1. 项目缘起与整体设计思路1.1 这个项目到底在做什么科技园区里配几台有氧健身设备,跑步机、椭圆机、动感单车,这事儿不新鲜。但设备装完之后,使用率怎么样、有没有人用、设备是不是坏了、耗材什么时候该换,这些数据如果全靠人工去…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉