去年双十一前一周,我们的网关在一次灰度发布后直接启动失败,报错java.util.ServiceConfigurationError: xxx.Codec: Provider com.xxx.RedisCodec could not be instantiated。定位到根因那一刻,我对 SPI(Service Provider Interface)这套"看起来人畜无害"的机制彻底改观。它确实解耦了接口与实现,但ServiceLoader的懒迭代设计埋了不止一个雷,偏偏大多数教程只教你ServiceLoader.load()三行代码怎么写,没人告诉你失败时会怎么炸。
下面把这次事故拆开讲,并顺带把ServiceLoader的源码、JDK 9 模块化之后的变化、以及在真实项目里怎么用才稳,一并说清楚。
事故现场:一个坏实现拖垮四个好实现
网关的限流模块用 SPI 加载一组RateLimitStrategy(令牌桶、滑动窗口、并发数、热点参数、兜底策略),META-INF/services/com.xxx.gateway.RateLimitStrategy文件里列了 5 个实现类。某次迭代新加了一个RedisClusterStrategy,同事在它的构造函数里直接new RedisTemplate()并afterPropertiesSet()——注意这时候 Spring 容器还没起来,于是构造函数抛NullPointerException。
诡异的点在于:启动日志里只有 RedisClusterStrategy 这一个类报错,但另外 4 个明明没问题的策略也全部没被加载,结果所有接口限流失效,上游请求直接 500。这不是 4 个策略自己有问题,而是ServiceLoader的迭代机制把它们的命一起搭进去了。
最小复现
把场景简化成一个可运行的例子,问题立刻清晰:
// META-INF/services/demo.spi.Plugin 里写了三行: // demo.spi.impl.GoodA // demo.spi.impl.BadB <-- 构造函数里抛 RuntimeException // demo.spi.impl.GoodC ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class); for (Plugin p : loader) { // 第 1 行:增强 for 触发迭代 System.out.println(p.name()); // 第 2 行:逐个调用 name() }逐行解释:
- 第 1 行ServiceLoader.load(Plugin.class)只是返回一个LazyIterator 的壳,这时还没读文件、没实例化任何类,什么都没发生。
- 第 2 行for循环本质是调用迭代器的hasNext()+next()。hasNext()第一次被调用时,ServiceLoader才会去读META-INF/services文件、按行解析出全部实现类名,然后从第一个开始逐个Class.forName并newInstance()。
- 当迭代到BadB时,newInstance()触发它的构造函数抛异常,ServiceLoader把这个异常包装成ServiceConfigurationError直接往外抛,整个 for 循环中断。GoodA已经输出,但排在BadB后面的GoodC永远没机会被加载。
这就是"一个坏实现拖垮后面所有实现"的来源:ServiceLoader是顺序迭代、遇到第一个错误就整体失败,不是"跳过坏的、继续加载好的"。
源码逐行:LazyIterator 到底做了什么
JDK 8 的ServiceLoader.LazyIterator关键逻辑(简化后)长这样:
public boolean hasNext() { if (nextName != null) { return true; } if (configs == null) { // 第 1 行:第一次才读配置文件 configs = loader.getResources(fullName); // META-INF/services/接口全限定名 } while ((nextName = parseNext(configs)) != null) { // 第 2 行:一次性解析出下一个实现类名 Class<?> c = Class.forName(nextName, false, loader); // 第 3 行:加载类 if (service.isAssignableFrom(c)) { return true; // 第 4 行:只记录类名,还没实例化 } } return false; } public S next() { String cn = nextName; nextName = null; S p = service.cast(c.newInstance()); // 第 5 行:这才真正 new 实例 return p; }逐行解释:
- 第 1 行configs == null的判断说明:配置文件的读取被推迟到第一次hasNext(),这正是"懒加载"的懒字所在——你哪怕load了但不迭代,文件根本不会被碰。
- 第 2 行parseNext是按行读实现类名的解析器。注意它不是只读一个,而是把整份文件扫一遍、维护一个类名队列,所以BadB的类名在hasNext阶段就已经进了队列。
- 第 3 行Class.forName(nextName, false, loader)第二个参数是false,表示只加载类、不执行静态初始化。也就是说静态代码块里的错误在这一步不会爆,真正爆在下一步。
- 第 4 行只把类名存到nextName返回true,此时还没newInstance。
- 第 5 行c.newInstance()才是实例化,构造函数里的异常就是在这里抛出,且没有被 try-catch 吞掉,直接上抛为ServiceConfigurationError。
这里有个反直觉的细节:类名解析(hasNext)和实例化(next)是分开的两步。如果你用while(loader.iterator().hasNext())这种错误写法——每次循环都新建一个迭代器——会反复从头解析文件,但不会帮你跳过坏实现。
排查过程:我们走过的两条弯路
第一次看到ServiceConfigurationError,我们本能地以为是META-INF/services文件路径写错或打包时被过滤了(Spring Boot 的repackage有时会吞掉META-INF,需要spring-boot-maven-plugin的<includeSystemScope>之类处理)。这条弯路排查了 20 分钟,结论是没有问题。
第二条弯路更坑:因为报错只点名RedisClusterStrategy,我们本能地把范围缩小到这个类,花了半小时review它的构造函数,才发现它依赖了 Spring 容器里的RedisConnectionFactory,而 SPI 加载发生在SpringApplication.run()之前。真相是——ServiceLoader没有"容器感知",它只知道用当前线程上下文类加载器去newInstance(),至于你的 Bean 有没有就绪,它一概不管。
更稳的落地:把隔离交给 Spring 容器
如果项目本来就是 Spring 应用,我更推荐直接放弃原生 SPI,改用容器扫描注入。每个策略标@Component,框架启动时一次性收集进一个Map,由容器保证单 Bean 失败不影响其他 Bean:
@Component("tokenBucket") public class TokenBucketStrategy implements RateLimitStrategy { public boolean allow(String key) { /* 令牌桶逻辑 */ return true; } } @Component("slideWindow") public class SlideWindowStrategy implements RateLimitStrategy { public boolean allow(String key) { /* 滑动窗口逻辑 */ return true; } } @Service public class StrategyRouter { // 第 1 行:Spring 把所有 RateLimitStrategy 实现按 beanName 注入成 Map private final Map<String, RateLimitStrategy> strategies; public StrategyRouter(List<RateLimitStrategy> list) { // 第 2 行:构造注入全部实现 this.strategies = list.stream() .collect(Collectors.toMap( // 第 3 行:beanName -> 实例 b -> ((BeanNameAware) b).toString(), b -> b)); } public RateLimitStrategy get(String name) { return strategies.getOrDefault(name, strategies.get("tokenBucket")); } }逐行解释:
- 第 1 行@Component("tokenBucket")给每个策略一个稳定的名字,作为路由 key。
- 第 2 行List<RateLimitStrategy>让 Spring 把容器中所有该接口实现自动聚合成列表注入,新增策略只要加@Component,零配置。
- 第 3 行Collectors.toMap把列表转成beanName -> 实例的 Map,路由时按名字取。
- 关键优势:某个策略 Bean 构造失败,Spring 会在启动期单独报错并指出是哪个类,而不会像 SPI 那样"一个坏、全盘崩且只报最后一个"。Bean 之间的隔离由容器保证,比ServiceLoader的懒迭代安全得多。
这种写法在网关、规则引擎这类"策略多、要稳"的场景里,比原生 SPI 省心至少一个量级。
方案对比:SPI 不是插件化的唯一解
| 方案 | 加载时机 | 单点失败影响 | 是否依赖容器 | 适用场景 |
|---|---|---|---|---|
ServiceLoader(JDK SPI) | 懒迭代,首次调用 | 一个坏实现全盘中断 | 否 | JDK 原生、轻量、无框架依赖 |
java.util.spi+ThreadContextClassLoader手动管理 | 可控 | 可 try-catch 跳过 | 否 | 需要精细控制类加载 |
SpringApplicationContext扫描@Component | 容器启动期 | 单 Bean 失败可设autowireCandidate=false隔离 | 是 | 已经是 Spring 项目 |
| 配置化 SPI(读配置中心拿实现类名反射) | 运行时 | 可降级到默认实现 | 否 | 需要动态开关、热更新 |
我们的结论很直接:网关这种"启动就要稳"的基础设施,不该把插件加载的命交给 SPI 的懒迭代。要么在 SPI 实现类的构造函数里做一个"无 Spring 依赖的轻量初始化",把真正依赖 Bean 的逻辑延迟到首次execute()时从 Spring 上下文取;要么干脆改成 Spring 扫@Component+Map<String, Strategy>注入,由容器保证隔离。
复盘真实数字
- 故障窗口:灰度发布后约 18 分钟,期间网关 100% 请求 500。
- 影响接口:83 个,峰值 QPS 约 1.2 万。
- 定位耗时:从告警到定位根因 52 分钟,其中 20 分钟花在"路径/打包"这条错误方向上。
- 修复成本:把
RedisClusterStrategy的RedisTemplate初始化从构造函数挪到首次调用,并给其他 4 个策略补了"构造函数零副作用"的约束,改动 6 个文件、约 40 行。
我的一些取舍判断
说实话,我不建议在新项目里滥用 JDK 原生 SPI 做业务插件化。它在框架层(JDBC 驱动、日志桥接、序列化器)非常合适,因为那些实现都是"无状态、零依赖、构造函数纯"的。但一旦你的策略/插件实现要碰数据库、碰缓存、碰 Spring Bean,原生 SPI 的"构造函数即实例化、失败即全盘崩"就是定时炸弹。
我更倾向于两种落地方式:
1.构造函数保持纯函数式,所有外部依赖延迟到execute/apply时通过参数传入或从全局上下文取。这样 SPI 加载阶段永远不会因为依赖没就绪而炸。
2.如果实现确实重,直接交给 Spring 管理,用List<Strategy>注入 + 名称路由,让容器的 Bean 隔离替你挡住单点失败。
另一个容易忽略的点:JDK 9 之后ServiceLoader还能load(ModuleLayer, Class)配合模块描述符provides ... with ...使用,模块系统会强制校验实现类可访问性,反而比META-INF/services更不容易配错。但代价是模块化的学习成本和打包改造,中小项目未必划算。
思考题
如果你的 SPI 文件里列了 20 个插件,其中第 11 个构造函数抛异常,前 10 个已经成功实例化的对象会怎样?它们会随ServiceConfigurationError一起被 GC 掉,还是继续留在内存里?要回答它,得想清楚ServiceLoader内部那几个LinkedHashMap缓存的引用关系——这恰恰是我们这次事故之后补的一道面试题。
下一篇会接着讲
CompletableFuture,同样是"看起来简单、炸起来要命"的那一类。