Dubbo SPI 与扩展点
Dubbo 的很多能力都不是写死的,而是通过 SPI 扩展点加载。负载均衡、协议、序列化、Filter、路由、集群容错等都可以扩展。理解 Dubbo SPI,才能真正理解 Dubbo 为什么能做成一个可插拔的 RPC 框架。
学习目标
| 目标 | 说明 |
|---|---|
| 理解 SPI | 知道 SPI 是一种按接口发现实现类的机制 |
| 区分 Java SPI | 知道 Dubbo SPI 和 JDK SPI 的区别 |
| 掌握扩展点 | 知道 Filter、LoadBalance、Protocol、Serialization 等如何扩展 |
| 会写 Demo | 能写一个自定义 Filter 或 LoadBalance |
| 会面试 | 能讲清 Dubbo 自适应扩展和扩展点加载思想 |
SPI 是什么
SPI 全称 Service Provider Interface,可以理解为:
框架定义接口,第三方或业务方提供实现,框架运行时按名称加载实现。
普通写法是代码里直接 new:
LoadBalance loadBalance = new RandomLoadBalance();这样框架和实现强耦合。SPI 写法是通过配置选择:
random=org.apache.dubbo.rpc.cluster.loadbalance.RandomLoadBalance
roundrobin=org.apache.dubbo.rpc.cluster.loadbalance.RoundRobinLoadBalance调用时根据名称加载:
LoadBalance loadBalance = ExtensionLoader
.getExtensionLoader(LoadBalance.class)
.getExtension("random");Dubbo SPI 和 JDK SPI 区别
| 对比 | JDK SPI | Dubbo SPI |
|---|---|---|
| 配置目录 | META-INF/services/ | META-INF/dubbo/ 等 |
| 加载方式 | 一次加载所有实现 | 按名称加载指定实现 |
| IOC 能力 | 弱 | 支持依赖注入 |
| AOP 包装 | 不支持 | 支持 Wrapper 包装 |
| 自适应扩展 | 不支持 | 支持 Adaptive |
| 性能 | 实现多时可能浪费 | 按需加载更适合框架 |
Dubbo 自己实现 SPI,是因为 RPC 框架扩展点很多,JDK SPI 无法很好满足按名加载、自适应选择、包装增强和依赖注入。
Dubbo SPI 加载流程
flowchart TD
A["调用 ExtensionLoader"] --> B["读取 SPI 配置文件"]
B --> C["解析 name 到 class 映射"]
C --> D["按名称创建扩展对象"]
D --> E["注入依赖"]
E --> F["Wrapper 包装增强"]
F --> G["缓存并返回扩展实例"]这条链路解释了为什么 Dubbo 扩展点可以做到:
- 配置里写
loadbalance=random就能切换实现。 - 扩展类可以依赖其他扩展。
- Filter、Protocol 等可以被包装增强。
- 不同 URL 参数可以选择不同扩展实现。
ExtensionLoader 内部为什么不是简单反射
如果 SPI 只是 Class.forName(name).newInstance(),它只能创建对象,无法支撑 Dubbo 的协议、路由、负载均衡、Filter、Cluster 等复杂组合。Dubbo SPI 的关键是把“按名加载、单例缓存、依赖注入、Wrapper 包装、自适应选择、自动激活”拆成多个阶段。
可以把一次 getExtension("random") 理解成:
flowchart TD
A["调用getExtension(name)"] --> B["检查扩展实例缓存"]
B --> C{"缓存是否命中"}
C -- "命中" --> D["返回已包装实例"]
C -- "未命中" --> E["读取并解析SPI资源"]
E --> F["找到name对应实现Class"]
F --> G["反射创建原始实例"]
G --> H["按setter注入依赖扩展"]
H --> I["按Wrapper类逐层包装"]
I --> J["缓存最终实例"]
J --> D这里要特别注意三类缓存:
| 缓存对象 | 缓存什么 | 为什么需要 |
|---|---|---|
| ExtensionLoader缓存 | 每个扩展接口一个Loader | 避免重复解析同一扩展接口 |
| 扩展类映射缓存 | name -> Class | 避免每次调用重新读取配置文件 |
| 扩展实例缓存 | name -> instance | 扩展通常按名称复用,减少对象创建和状态分裂 |
这也是为什么扩展实现要尽量无状态或线程安全。一个自定义 LoadBalance 如果把“上一次请求的用户ID”存在成员变量里,多线程调用时就会互相污染。
SPI 文件路径和命名为什么容易错
Dubbo SPI 不是 Java 原生 ServiceLoader,配置位置和格式不同。常见资源目录包括:
META-INF/dubbo/
META-INF/dubbo/internal/
META-INF/services/业务自定义扩展通常放在:
META-INF/dubbo/接口全限定名例如自定义 Filter:
META-INF/dubbo/org.apache.dubbo.rpc.Filter文件内容是 扩展名=实现类全限定名:
tenant=com.example.dubbo.filter.TenantProviderFilter常见错误:
| 错误 | 现象 |
|---|---|
| 文件名写成实现类名 | ExtensionLoader 找不到扩展 |
放到 META-INF/services 但按 Dubbo SPI 加载 | 配置不生效 |
| 扩展名和配置使用名不一致 | No such extension |
| 依赖包没有打进最终 jar | 本地能跑,部署后找不到 |
| 实现类没有无参构造或构造失败 | 扩展创建失败 |
常见扩展点
| 扩展点 | 作用 | 商业用途 |
|---|---|---|
| Filter | 调用前后增强 | 日志、鉴权、traceId、限流、指标 |
| LoadBalance | 负载均衡 | 按机房、权重、延迟、租户选择实例 |
| Router | 路由过滤 | 灰度、标签、地域隔离 |
| Protocol | RPC 协议 | 自定义协议或适配其他通信方式 |
| Serialization | 序列化 | 替换为 Protobuf、JSON、公司内部格式 |
| Cluster | 集群容错 | 自定义失败处理策略 |
| Configurator | 动态配置 | 运行时调整超时、权重、路由 |
| ProxyFactory | 代理生成 | JDK、Javassist 等代理方式 |
Demo:自定义 Filter 传递租户 ID
商业系统经常需要把租户 ID、traceId、用户 ID 从 Consumer 传到 Provider。Dubbo 可以通过 RpcContext 附件传递。
Consumer 侧 Filter:
package com.example.dubbo.filter;
import org.apache.dubbo.common.extension.Activate;
import org.apache.dubbo.rpc.Filter;
import org.apache.dubbo.rpc.Invocation;
import org.apache.dubbo.rpc.Invoker;
import org.apache.dubbo.rpc.Result;
import org.apache.dubbo.rpc.RpcContext;
import org.apache.dubbo.rpc.RpcException;
@Activate(group = "consumer")
public class TenantConsumerFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
RpcContext.getClientAttachment().setAttachment("tenantId", tenantId);
}
return invoker.invoke(invocation);
}
}Provider 侧 Filter:
package com.example.dubbo.filter;
import org.apache.dubbo.common.extension.Activate;
import org.apache.dubbo.rpc.Filter;
import org.apache.dubbo.rpc.Invocation;
import org.apache.dubbo.rpc.Invoker;
import org.apache.dubbo.rpc.Result;
import org.apache.dubbo.rpc.RpcContext;
import org.apache.dubbo.rpc.RpcException;
@Activate(group = "provider")
public class TenantProviderFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
String tenantId = RpcContext.getServerAttachment().getAttachment("tenantId");
try {
TenantContext.setTenantId(tenantId);
return invoker.invoke(invocation);
} finally {
TenantContext.clear();
}
}
}线程上下文工具:
public final class TenantContext {
private static final ThreadLocal<String> LOCAL = new ThreadLocal<>();
private TenantContext() {
}
public static void setTenantId(String tenantId) {
LOCAL.set(tenantId);
}
public static String getTenantId() {
return LOCAL.get();
}
public static void clear() {
LOCAL.remove();
}
}SPI 配置文件:
META-INF/dubbo/org.apache.dubbo.rpc.Filter内容:
tenantConsumer=com.example.dubbo.filter.TenantConsumerFilter
tenantProvider=com.example.dubbo.filter.TenantProviderFilter为什么要在 finally 里清理 ThreadLocal?因为 Provider 线程池会复用线程,如果不清理,租户信息可能串到下一次请求,造成严重数据隔离事故。
Demo:自定义负载均衡
下面是教学版示例:优先选择 URL 参数里 zone 与本机 zone 一致的 Provider,否则退化成第一个。
package com.example.dubbo.loadbalance;
import org.apache.dubbo.common.URL;
import org.apache.dubbo.rpc.Invocation;
import org.apache.dubbo.rpc.Invoker;
import org.apache.dubbo.rpc.cluster.LoadBalance;
import java.util.List;
public class SameZoneLoadBalance implements LoadBalance {
@Override
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String localZone = System.getProperty("zone", "default");
for (Invoker<T> invoker : invokers) {
String providerZone = invoker.getUrl().getParameter("zone", "default");
if (localZone.equals(providerZone)) {
return invoker;
}
}
return invokers.get(0);
}
}SPI 文件:
META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance内容:
sameZone=com.example.dubbo.loadbalance.SameZoneLoadBalance使用:
@DubboReference(loadbalance = "sameZone")
private StockService stockService;真实项目里不能简单返回第一个,要处理权重、可用性、慢实例和空列表。这段代码主要用于理解扩展机制。
自适应扩展是什么
Dubbo 的 Adaptive 扩展可以根据 URL 参数动态选择具体实现。
例如同一个 LoadBalance 接口:
loadbalance=random
loadbalance=roundrobin
loadbalance=leastactive框架运行时根据 URL 参数选择不同实现,而不是业务代码手写 if else。
简化流程:
flowchart TD
A["拿到 URL 参数"] --> B{"loadbalance 是什么"}
B -- "random" --> C["加载 RandomLoadBalance"]
B -- "roundrobin" --> D["加载 RoundRobinLoadBalance"]
B -- "leastactive" --> E["加载 LeastActiveLoadBalance"]
C --> F["执行 select"]
D --> F
E --> F这就是 Dubbo 高度可配置的基础。
自适应扩展更像“运行时生成的路由器”
自适应扩展不是把所有实现都执行一遍,而是生成一个“根据 URL 参数选择扩展名”的代理类。以 LoadBalance 思路简化后,大概相当于下面的 JDK 8 代码:
import java.util.HashMap;
import java.util.Map;
public class AdaptiveExtensionDemo {
interface LoadBalance {
String select(String serviceUrl);
}
static class RandomLoadBalance implements LoadBalance {
public String select(String serviceUrl) {
return "random provider for " + serviceUrl;
}
}
static class RoundRobinLoadBalance implements LoadBalance {
public String select(String serviceUrl) {
return "roundrobin provider for " + serviceUrl;
}
}
static class MiniExtensionLoader {
private final Map<String, LoadBalance> extensions = new HashMap<String, LoadBalance>();
MiniExtensionLoader() {
extensions.put("random", new RandomLoadBalance());
extensions.put("roundrobin", new RoundRobinLoadBalance());
}
LoadBalance getExtension(String name) {
LoadBalance extension = extensions.get(name);
if (extension == null) {
throw new IllegalArgumentException("找不到扩展: " + name);
}
return extension;
}
}
static class AdaptiveLoadBalance implements LoadBalance {
private final MiniExtensionLoader loader;
AdaptiveLoadBalance(MiniExtensionLoader loader) {
this.loader = loader;
}
public String select(String serviceUrl) {
String name = getParameter(serviceUrl, "loadbalance", "random");
return loader.getExtension(name).select(serviceUrl);
}
private String getParameter(String url, String key, String defaultValue) {
String mark = key + "=";
int index = url.indexOf(mark);
if (index < 0) {
return defaultValue;
}
int start = index + mark.length();
int end = url.indexOf('&', start);
return end < 0 ? url.substring(start) : url.substring(start, end);
}
}
public static void main(String[] args) {
LoadBalance adaptive = new AdaptiveLoadBalance(new MiniExtensionLoader());
System.out.println(adaptive.select("dubbo://10.0.0.1:20880?loadbalance=random"));
System.out.println(adaptive.select("dubbo://10.0.0.2:20880?loadbalance=roundrobin"));
}
}真实 Dubbo 会根据扩展接口、@Adaptive、URL 参数名和默认扩展名生成或使用自适应类。这个 Demo 只说明核心思想:调用方依赖扩展接口,运行时从 URL 或 Invocation 上下文取参数,再按名称选择具体实现。
如果没有自适应扩展,框架就会在很多地方写死:
if protocol == dubbo then ...
if protocol == triple then ...
if loadbalance == random then ...这样每增加一个协议、序列化或负载均衡算法,都要修改框架核心代码,扩展性和稳定性都会变差。
Wrapper 包装扩展
Wrapper 可以理解成 Dubbo SPI 层面的装饰器。
flowchart TD
A["原始 Protocol"] --> B["ProtocolFilterWrapper"]
B --> C["ProtocolListenerWrapper"]
C --> D["最终 Protocol"]它允许框架在不改原始实现的情况下增加 Filter、Listener 等能力。这和设计模式里的装饰器模式类似。
Wrapper 顺序为什么影响调用链
Wrapper 本质是构造函数接收同类型扩展对象,再返回一个增强后的对象。例如:
public class MetricsProtocolWrapper implements Protocol {
private final Protocol protocol;
public MetricsProtocolWrapper(Protocol protocol) {
this.protocol = protocol;
}
}如果一个扩展同时被多个 Wrapper 包装,调用顺序会影响日志、Filter、Listener、异常映射和资源释放。抽象模型如下:
flowchart TD
A["原始扩展"] --> B["Wrapper-1"]
B --> C["Wrapper-2"]
C --> D["最终暴露给调用方的扩展"]因此排查时不能只问“加载的是哪个 Protocol”,还要看它外面包了哪些 Wrapper。很多能力不是原始协议类直接完成,而是在 Wrapper 中织入。
@Activate 自动激活、分组和顺序
Filter 这类扩展不能每次都手工指定,否则框架默认的监控、上下文、鉴权、限流等能力很难统一装配。Dubbo 通过 @Activate 支持按条件自动激活。
常见维度:
| 维度 | 含义 |
|---|---|
| group | 在 Consumer 侧、Provider 侧或两侧激活 |
| value | URL 中存在某些参数时激活 |
| order | 多个扩展的相对顺序 |
| before/after | 与其他扩展的前后关系,具体能力看版本 |
Filter 顺序会影响业务正确性:
flowchart TD
A["收到RPC调用"] --> B["Trace Filter生成上下文"]
B --> C["Auth Filter校验身份"]
C --> D["Tenant Filter设置租户"]
D --> E["Limit Filter并发或QPS限制"]
E --> F["真实Invoker执行"]
F --> G["finally清理ThreadLocal"]如果顺序错了会怎样:
| 错误顺序 | 后果 |
|---|---|
| 租户上下文晚于业务执行 | SQL 或缓存 Key 拿不到租户,可能串数据 |
| 异常映射早于指标统计 | 指标看到的是加工后的假成功 |
| 限流在昂贵鉴权之后 | 大量非法请求仍消耗认证和数据库资源 |
| 没有 finally 清理 | Provider 线程复用导致身份串号 |
所以自定义 Filter 不只要“能跑”,还要说明它在 Consumer 还是 Provider 侧执行、放在谁前后、失败时返回什么、是否影响重试和幂等。
扩展点使用原则
| 原则 | 原因 |
|---|---|
| 优先使用官方扩展点 | 兼容性和维护成本更好 |
| 不在 Filter 写慢逻辑 | Filter 每次调用都执行,慢逻辑会放大性能问题 |
| ThreadLocal 必须清理 | Dubbo 线程池复用线程,容易串数据 |
| 扩展要有开关 | 出问题时能快速关闭 |
| 扩展要可观测 | 加日志、指标和异常告警 |
| 不把业务逻辑塞进框架扩展 | 会造成隐式依赖,难测试难排查 |
生产排查:自定义扩展不生效或导致故障
扩展不生效
flowchart TD
A["扩展不生效"] --> B["确认SPI资源是否进入最终jar"]
B --> C["确认文件名是否为扩展接口全限定名"]
C --> D["确认扩展名和配置使用名一致"]
D --> E["确认@Activate group和值是否匹配"]
E --> F["确认URL最终参数是否包含期望key"]
F --> G["打开Dubbo启动日志或ExtensionLoader诊断"]常见证据:
| 证据 | 看什么 |
|---|---|
| jar 内容 | META-INF/dubbo/... 是否存在 |
| 启动日志 | 扩展加载失败、重复扩展名、类创建异常 |
| URL 参数 | loadbalance、filter、protocol 等最终值 |
| 调用链日志 | Filter 是否进入、顺序是否符合预期 |
| 版本依赖 | Dubbo 2.x/3.x 包名、接口签名、激活语义是否变化 |
扩展导致线上故障
| 故障 | 可能原因 | 止血 |
|---|---|---|
| 全链路变慢 | Filter 做远程调用、写日志过多、同步落库 | 配置关闭扩展,改异步或采样 |
| 租户串数据 | ThreadLocal 未清理或附件信任外部传入 | 立即下线扩展,补鉴权和清理 |
| 灰度串流 | Router/LoadBalance 无匹配时静默回退 | 改成严格失败或受控回退 |
| 重试放大 | Filter 把业务异常包装成可重试异常 | 区分业务错误和瞬时传输错误 |
| Provider 线程打满 | Provider Filter 在业务线程中做慢操作 | 前置限流、异步化或移出RPC链路 |
扩展点是框架能力入口,不是业务逻辑垃圾桶。越靠近调用链底层,影响面越大,越需要开关、灰度、监控、回滚和压测。
常见面试追问
| 问题 | 标准回答 | 深入原理 |
|---|---|---|
| Dubbo SPI 为什么不用 JDK SPI | JDK SPI 一次加载全部实现、缺少按名加载、自适应、依赖注入和 Wrapper,不适合大量扩展点的 RPC 框架。 | ExtensionLoader内部流程 |
| Filter 能做什么 | 可做日志、鉴权、trace、限流、指标、参数校验,但不能写慢业务。 | Filter Demo、@Activate顺序 |
| 自适应扩展是什么 | 根据 URL 参数运行时选择具体扩展实现,本质像一个运行时生成的路由器。 | 自适应扩展 Demo |
| Wrapper 有什么用 | 在扩展对象外层包一层增强逻辑,类似装饰器;很多默认能力通过 Wrapper 织入。 | Wrapper顺序 |
| 自定义扩展怎么排查不生效 | 检查 SPI 文件路径、接口名、扩展名、最终 jar、URL 参数、@Activate group 和版本兼容。 | 扩展排查Runbook |
本章小结
Dubbo SPI 是 Dubbo 可插拔架构的基础。Dubbo 通过 ExtensionLoader 按名称加载扩展,通过 URL 参数实现自适应选择,通过 Wrapper 做装饰增强,通过 Filter 等扩展点把日志、鉴权、限流、监控等能力插入调用链。掌握 SPI 后,Dubbo 就不再是黑盒,而是一套可以理解和扩展的框架。
