虽然 Dubbo 是个很优秀的 SOA 框架,在国内也是非常流行,但在 service mesh 的大风下,有些跟不上时代了。一方面官方还没有给出权威的 mesh 化解决方案,另一方面,基于云原生的 Istio 确实太优秀,为了拥抱云原生,本文尝试罗列 dubbo 需要的改动,并基于 2.7.7 的源码实际尝试了去除 dubbo 路由和负载均衡功能。
1. dubbo mesh 面临的问题
1.1 服务注册发现模型
Dubbo 是基于服务接口进行的服务注册和发现,Dubbo provider 每个实例启动时,都会在注册中心的 service interface 的 providers 目录下写上 provider 的信息;每个 consumer 实例启动时,都会在 consumers 目录下写上 consumer 的信息。在 Istio 的实现方式下,是按服务名发起请求的,因而 dubbo 服务接口级的注册发现就行不通了。
首先,在 Istio 的解决方案下,服务的注册发现由 sidecar 完成。如果还保留 dubbo 的注册发现就显得臃肿和无效了。然而 consumer 在发起 RPC 调用时,需要知道上图中 org.apache.dubbo.demo.DemoService 对应的服务名是什么,才能发出类似 http://service-name:port/org.apache.dubbo.demo.DemoService
这样的请求,所以一个 provider 对应的 service name 以及该 provider 提供了哪些服务接口,这个信息是需要知道的,否则 consumer 无法进行服务调用。
1.2 集群容错
Dubbo consumer 从注册中心拉取所有 provider 实例后,会将这些实例集抽象成一个 cluster invoker 对外使用。在一次具体 RPC 调用时,cluster invoker 会执行路由、负载均衡和容错功能。比如 FailOverClusterInvoker 在一次 RPC 调用失败后,会重新拉取 provider 实例,重新负载均衡,并进行3次默认的重试。这些功能,Istio 都已经提供了,所以需要去除,否则会影响到服务性能和效率。
1.3 路由和负载均衡功能
Istio 的 envoy 已经提供了路由和负载均衡,因而 dubbo 本身提供的路由和负载均衡功能需要去掉,否则一次请求两次路由和负载均衡,对应性能和延迟都会打折扣。由于 dubbo 的路由和负载均衡耦合在 consumer 的 RPC 调用流程中,因而去除路由和负载功能实现起来还是有点麻烦的。
综上所述,要想比较干净的实现基于云原生的 dubbo mesh 方案,至少需要对 Dubbo SDK 做以下两个大改动:
- 服务注册发现模型和机制
- 剥离集群容错,路由和负载均衡等功能模块
1.4 部分服务迁移下的互通问题
客户的 dubbo 服务向 TKE Mesh 迁移时,一般都是分批进行的,所以就存在部分服务已经上了 TKE Mesh 部分没有,如何解决两个服务岛之间的互通,也是个需要解决的问题。
2 服务注册发现
URL 是 Dubbo 的核心模型之一,Provider 启动时会在注册中心相应的目录下写上 URL 信息。以官方的 dubbo-demo 和 zookeepr 注册中心为例,一个 demo-provider 实例启动后,会在 /dubbo/org.apache.dubbo.demo.DemoService/providers
目录里写上下面的 URL 信息:
dubbo zookeeper 服务注册示例
E.G.: URL decode 后的 provider 注册信息 dubbo://192.168.3.4:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=demo-provider&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&interface=org.apache.dubbo.demo.DemoService&metadata-type=remote&methods=sayHello,sayHelloAsync&pid=78217&release=&side=provider×tamp=1590482485444
采用 Istio 云原生的 mesh 化后,服务的注册发现由 sidecar 来处理,provider 不需要发起注册动作。但这会有个问题: consumer RPC 调用不通。因为当我们很爽快的写下 consumer 代码 demoService.sayHello()
执行 RPC 调用时,dubbo 框架是以 org.apache.dubbo.demo.DemoService
作为 service key 从注册中心 /dubbo/org.apache.dubbo.demo.DemoService/providers
的实例列表中选择一个 provider, 并使用它 IP 和 Port 执行远程调用的。但现在 provider 不注册了,consumer 肯定拿不到 provider 的 IP 和 port,RPC 调用自然存在问题。
mesh 化后冗余的 dubbo 的服务注册发现 Istio 已经提供了注册发现,dubbo 自身的服务注册发现确实不再需要,可以去除。去除 dubbo 的服务注册发现功能,就是对 dubbo 的基础功能动刀子,涉及改动的细节会很多,包括 dubbo-registry 模块;耦合在 provider 启动过程里的服务暴露的代码;以及 consumer 启动过程中进行服务订阅生成 Invoker 的代码。除此之外,还有 provider 和 consumer 运行过程中,和注册中心交互的代码都可以去除。
如何使用 service key 找到 provider 的服务名? Istio 的服务注册解决了 service name <---> IP:Port 映射问题,但解决不了 service key 到 service name 的映射问题,即 org.apache.dubbo.demo.DemoService
这个 service key 是由哪个服务提供的?可行的方案包括:
- 将 “Java service interface <---> 微服务名” 映射关系放到配置中心里,consumer 启动时,去配置中心拉取映射关系,在发起 RPC 调用时,由 Java service interface 找微服务名,组装出类似
http://service-name/Java-service-interface
URL 再发起请求。 - 采用配置文件的方式,conumer 的代码中添加类似下面的配置:
order:
- org.apache.dubbo.demo.CreateOrderService
- org.apache.dubbo.demo.OrderPayService
- org.apache.dubbo.demo.GetOrderService promotion:
- org.apache.dubbo.demo.CouponService
- org.apache.dubbo.demo.ManjianService
不论采用上面哪种方式,都需要提供一个相应的 SDK,并且还要改动 Dubbo SDK,相应的个工作谁来进行?
3 集群容错
下图是 dubbo 官方给的集群容错架构,其中也展示了一个 dubbo RPC 调用过程中的关键环节:目录服务、路由、负载均衡。Consumer 端首先会去注册中心拉取 provider 实例,然后根据路由条件过滤出可用的 provider 实例集合,最后再由负载均衡找出一个具体调用的实例。
dubbo 集群容错架构
AbstractClusterInvoker 的 invoke 方法中会调用目录服务从注册中心拉取所有 provider 实例信息,并通过路由筛选出可用的 provider 实例。
代码语言:javascript复制// 以下代码位于 AbstractClusterInvoker
public Result invoke(final Invocation invocation) throws RpcException {
……
// list 方法会调从注册中心拉取所有 provider 实例信息,并通过路由筛选出可用的 provider 实例
List<Invoker<T>> invokers = list(invocation);
LoadBalance loadbalance = initLoadBalance(invokers, invocation);
RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation);
// doInvoke 方法会进行负载均衡,并选出最终的 provider 实例进行远程调用
return doInvoke(invocation, invokers, loadbalance);
}
AbstractDirectory 中的 list 方法会调用具体实现类中的 doList 方法实现路由过滤,使用 zookeeper 作为注册中心时,会使用 RegistryDirectory。多种路由会组成一个路由链 RouteChain,路由选择过程中会挨个调用 routechain 中的每个路由算法过滤出满足条件的 provider 集合
dubbo 默认的负载均衡算法是随机算法 RandomLoadbalance, 默认的集群容错是 FailoverCluserInvoker, 即失败自动切换,当出现失败,重试其它服务器。
代码语言:javascript复制public Result doInvoke(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
List<Invoker<T>> copyInvokers = invokers;
checkInvokers(copyInvokers, invocation);
String methodName = RpcUtils.getMethodName(invocation);
// RPC 调用重试次数,默认为 3 次,如果配置成零 len 为 1 即发起一次调用
int len = getUrl().getMethodParameter(methodName, RETRIES_KEY, DEFAULT_RETRIES) 1;
if (len <= 0) {
len = 1;
}
// 由于是 failover 模式,所以下面的for循环,就是调用失败后重试的逻辑
RpcException le = null;
List<Invoker<T>> invoked = new ArrayList<Invoker<T>>(copyInvokers.size());
Set<String> providers = new HashSet<String>(len);
for (int i = 0; i < len; i ) {
if (i > 0) {
checkWhetherDestroyed();
// 重新从目录服务拉取可用的 provider 集合, 这一步可以避免调用了不可用的 provider,
// 比如一个 provider实例下线,这里就可以过滤了。
copyInvokers = list(invocation);
checkInvokers(copyInvokers, invocation);
}
// 执行负载均衡逻辑。负载均衡算法从可用的provider集合(copyInvokers-invoked )中选择一个实例
Invoker<T> invoker = select(loadbalance, invocation, copyInvokers, invoked);
invoked.add(invoker);
RpcContext.getContext().setInvokers((List) invoked);
try {
// 执行 RPC 调用,并返回结果
Result result = invoker.invoke(invocation);
// 之前的调用若有异常,这里会打印 providers 相关信息
if (le != null && logger.isWarnEnabled()) {
logger.warn(...)
}
return result;
} catch (RpcException e) {
if (e.isBiz()) { // biz exception.
throw e;
}
le = e;
} catch (Throwable e) {
le = new RpcException(e.getMessage(), e);
} finally {
providers.add(invoker.getUrl().getAddress());
}
}
...
}
4 剥离路由和负载均衡等功能模块
4.1 剥离路由
Dubbo consumer 在初始化一个远程服务引用生成 Invoker 时,会先请求目录服务从注册中心获取 provider 实例集,其中会生成 RouteChain。因而,剥离 dubbo 路由功能,需要从 RegistryDirectory 的 buildRouterChain 着手。此外,在 provider 实例变动,重新生成远程调用 invoker 时,也会调用 buildRouterChain。因而,所有涉及到 directory.buildRouterChain 调用的地方都要剥离。
- RouterChain Dubbo 启动时,自动加载自带的路由4个RouterFactory,即:MockRouterFactory、TagRouterFactory、AppRouterFactory、ServiceRouterFactory。除此之外,dubbo 也会加载用户指定的 RouterFactory,这些 RouterFactory 连在一块,就构成了 RouterChain,在 consumer 发起 RPC 调用过程中按规则过滤 provider 实例。所以在剥离 dubbo 路由功能模块时,这些 RouterFactory 的子类,都可以去除。
- 目录服务 目录服务从注册中心拉取到 provider 实例后会调用 RouterChain 进行过滤,因而抽象目录服务类 AbstractDirectory 中定义了 routerChain 一个字段,并提供了设置 routerChain 以及向 routerChain 中添加 router 的功能。此外,在目录服务的具体实现类 RegistryDirectory 和 StaticDirectory 中执行具体的路由选择功能,这些涉及路由功能的地方在剥离 dubbo 路由时都可以去掉。
- 路由规则变更 对于路由规则的变更,比如用户配置或修改了路由规则,RegistryDirectory 会收到通知,并触发其 notify 方法生成 Router 对象,然后调用 AbstractDirectory 的 addRouters 方法添加到 RouterChain 中。因而,路由规则变更的流程代码,在剥离 dubbo 路由时都可以去掉。
4.2 实际代码和配置改动
下面罗列下剥离 dubbo 路由功能,所涉及到的配置和代码:
配置改动
删除 dubbo-cluster 模块中 META-INF 里的 org.apache.dubbo.rpc.cluster.RouterFactory 文件。改文件定义了 RouterFactory,dubbuo 启动时会加载。
删除 RouterFactory 及其子类
- RouterFactory
- FileRouterFactory
- ScriptRouterFactory
- ConditionRouterFactory
- ServiceRouterFactory
- AppRouterFactory
- TagRouterFactory
- MockRouterFactory
AbstractDirectory 的改动
- 删除字段
protected RouterChain<T> routerChain
- 构造器
public AbstractDirectory(URL url, RouterChain<T> routerChain)
中去除语句 setRouterChain(routerChain); - 删除方法:
public RouterChain<T> getRouterChain()
- 删除方法:
public void setRouterChain(RouterChain<T> routerChain)
- 删除方法:
protected void addRouters(List<Router> routers)
RegistryDirectory 的改动
- 删除方法:
private Optional<List<Router>> toRouters(List<URL> urls)
- 删除方法:
public void buildRouterChain(URL url)
public synchronized void notify(List<URL> urls)
方法中去除下面两行:List<URL> routerURLs = categoryUrls.getOrDefault(ROUTERS_CATEGORY, Collections.emptyList()); toRouters(routerURLs).ifPresent(this::addRouters);
private void refreshInvoker(List<URL> invokerUrls)
方法中去除下面代码:routerChain.setInvokers(this.invokers);
public List<Invoker<T>> doList(Invocation invocation)
方法修改如下:// 删除下面的代码 List<Invoker<T>> invokers = null; try { invokers = routerChain.route(getConsumerUrl(), invocation); } catch (Throwable t) { logger.error("Failed to execute router: " getUrl() ", cause: " t.getMessage(), t); } // return 语句直接将返回 this.invokers; // return invokers == null ? Collections.emptyList() : invokers; return this.invokers;
private List<Invoker<T>> toMergeInvokerList(List<Invoker<T>> invokers)
方法去除下面代码:staticDirectory.buildRouterChain();
StaticDirectory 的改动
- 删除方法:public void buildRouterChain()
protected List<Invoker<T>> doList(Invocation invocation) throws RpcException
删除下面的代码段:if (routerChain != null) { try { finalInvokers = routerChain.route(getConsumerUrl(), invocation); } catch (Throwable t) { logger.error("Failed to execute router: " getUrl() ", cause: " t.getMessage(), t); } }
RegistryProtocol 的改动
private <T> Invoker<T> doRefer(Cluster cluster, Registry registry, Class<T> type, URL url)
方法中去除下面代码:directory.buildRouterChain(subscribeUrl);
public <T> void reRefer(Invoker<T> invoker, URL newSubscribeUrl)
方法中去除下面代码:directory.buildRouterChain(newSubscribeUrl);
4.2 剥离负载均衡
LoadBalance 接口只定义了一个 select 方法,用于从可选的 provider 实例中挑选一个实例,执行 RPC 调用。AbstractLoadBalance 中定义了抽象方法 doSelect 由具体的负载均衡算法子类实现,也就是真正进行负载均衡的地方。Dubbo 支持以下5种负载均衡算法:
- RandomLoadBalance 加权随机算法
- LeastActiveLoadBalance 最小活跃数算法
- ConsistentHashLoadBalance 一致性 hash 算法
- RoundRobinLoadBalance 加权轮询算法
- ShortestResponseLoadBalance 最小响应时间算法
理论上,剥离 dubbo 的负载均衡功能,只需要在调用 select 方法的地方动手脚,去除具体的负载均衡算法类执行即可,但会影响到 cluster invoker 的调用。Dubbo 将所有的 provider 实例抽象为一个 cluster invoker,并在 AbstractClusterInvoker 中定义了抽象方法 doInvoke 留给具体的集群容错子类实现。比如 FailoverClusterInvoker 在实现 doInvoke 方法时,添加了 RPC 调用失败后的重试机制,重试前会排除调用失败的 provider 实例,并使用负载均衡算法,在可用的 provider 实例中找出具体一个 provider 实例。所以去除所有 select 方法的调用的同时,必须找个抽象的 Invoker 不代表具体某个 provider 实例,将寻找具体 provider 实例的工作留给 sidecar 去做。
5 迁移中状态下的服务互通
客户的 dubbo 服务向 TKE Mesh 分批迁移时,会存在部分已迁移部分还是原生dubbo的“迁移中”状态。这种状态下,解决原生 dubbo 和 dubbo-mesh 的互通有很多种方案,比如让客户写个 adapter 服务做桥接,如何让客户改动最小化,平滑完成迁移?这里用电商场景下,用户在购物车下单触发的部分后台服务调用链示意图,来探讨可行的互通方案。
5.1 迁移策略
系统级的技术更新迁移,需要事先指定好迁移规则,否则就会录像众生,掉坑填坑的痛苦谁都不愿意接受。原生 dubbo 服务向 TKE Mesh 分批迁移时,需要客户预习梳理好服务之间的上下游关系,然后沿着服务调用链有步骤的迁移。针对“下单调用链示意图”的场景,迁移前需要知道服务的上下游关系,需要知道订单是购物车的下游服务,优惠是订单的下游服务,超级会员是优惠的下游服务。知道了服务的上下游关系后,就可以按 top-down 或 down-top 的方式进行迁移。对于 dubbo 向 TKE Mesh 迁移前需要确保:
- 服务上下游关系梳理
- 使用 down-top 方式,逆着调用链方向迁移(见下节分析)
5.2 互通模型
下图是迁移中状态下,dubbo 和 dubb-mesh 互通方案架构图。该方案简单说起来就是,采用两个服务注册中心,并让已经 mesh 改造的服务向原先的 dubbo 注册中心进行服务注册,同时 envoy 也向 Istio 进行注册。下游服务先迁移到 dubbo-mesh,让原生 dubbo 调用 dubbo-mesh。
至于为什么不让 dubbo-mesh 调用原生 dubbo,主要原因是 dubbo consumer 的发起的 RPC 调用过程中,会触发路由、负载均衡和集群容错等服务治理相关的功能,而且这次功能 Istio 已经提供了,触发两次影响效率和性能,同时也增加了调用延迟。此外,down-top 方式更方便改造平稳过度,因为下游业务的依赖少好改造。
5.3 互通方案总结
综上所述,整个互通方案总结如下:
- 迁移中采用两套注册中心,位于 dubbo-mesh 边缘节点的 provider 同时向 dubbo 注册中心注册服务,等服务完全迁移完成后,可下线 dubbo 注册中心。
- 事先指定好迁移策略:
- 梳理好服务上下游和依赖关系
- 按 down-top 方式,从下游服务开始迁移,最终完成所有服务的 mesh 改造
Dubbo on Istio 的实际方案很多,可以直接基于 spring boot 改造业务代码,也可以修改 dubbo 源码只保留 rpc 相关功能。本文从后者的角度进行讨论和部分尝试,如果要完整的改造 dubbo,只保留 rpc 功能也是可行的,只是限于时间和精力,本文就不进行具体的尝试和讨论了。