01 背景
随着 Docker 和 Kubernetes 的呈现,一个宏大的单体利用能够被拆分成多个独立部署的微服务,并被打包运行于对应的容器中。不同利用之间互相通信,以共同完成某一功能模块。微服务架构与容器化部署带来的益处是不言而喻的,它升高了服务间的耦合性,利于开发和保护,能更无效地利用计算资源。当然,微服务架构也存在相应的毛病:
- 强依赖于 SDK,业务模块与治理模块耦合较为重大。除了相干依赖,往往还须要在业务代码中嵌入 SDK 代码或配置。
- 对立治理难。每次框架降级都须要批改 SDK 版本,并从新进行回归测试,确认性能失常后再对每一台机器重新部署上线。不同服务援用的 SDK 版本不对立、能力参差不齐,增大了对立治理的难度。
- 短少一套对立解决方案。目前市场不存在一整套功能完善、无死角的微服务治理与解决方案。在理论生产环境往往还须要引入多个治理组件来实现像灰度公布、故障注入等性能。
为解决这些痛点,Service Mesh 诞生了。以经典的 side car 模式为例,它通过在业务 Pod 中注入 Sidecar 容器,对代理流量施行治理和管控,将框架的治理能力上层到 side car 容器中,与业务零碎解耦,从而轻松实现多语言、多协定的对立流量管控、监控等需要。通过剥离 SDK 能力并拆解为独立过程,从而解决了强依赖于 SDK 的问题,从而使开发人员能够更加专一于业务自身,实现了根底框架能力的下沉,如下图所示(源自 dubbo 官网):
经典的 Sidecar Mesh 部署架构有很多劣势,如缩小 SDK 耦合、业务侵入小等,但减少了一层代理,也带来了一些额定的问题,比方:
- SideCar 代理会损耗一部分性能,当网络结构层级比较复杂时尤其显著,对性能要求很高的业务造成了肯定的困扰。
- 架构更加简单,对运维人员要求高。
- 对部署环境有肯定的要求,须要其能反对 SideCar 代理的运行。
为解决这些痛点,Proxyless Service Mesh 模式诞生了。传统服务网格通过代理的形式拦挡所有的业务网络流量,代理须要感知到管制立体下发的配置资源,从而依照要求管制网络流量的走向。以 istio 为例,Proxyless 模式是指利用间接与负责管制立体的 istiod 过程通信,istiod 过程通过监听并获取 k8s 的资源,例如 Service、Endpoint 等,并将这些资源对立通过 xds 协定下发到不同的 rpc 框架,由 rpc 框架进行申请转发,从而实现服务发现和服务治理等能力。
Dubbo 社区是国内最早开始对 Proxyless Service Mesh 模式进行摸索的社区,这是因为相比于 Service Mesh,Proxyless 模式落地老本较低,对于中小企业来说是一个较好的抉择。Dubbo 在 3.1 版本中通过对 xds 协定进行解析,新增了对 Proxyless 的反对。Xds 是一类发现服务的总称,利用通过 xds api 能够动静获取 Listener(监听器),Route(路由),Cluster(集群),Endpoint(集群成员) 以及 Secret(证书) 配置。
通过 Proxyless 模式,Dubbo 与 Control Plane 间接建设通信,进而实现管制面对流量管控、服务治理、可观测性、平安等的对立管控,从而躲避 Sidecar 模式带来的性能损耗与部署架构复杂性。
02 Dubbo Xds 推送机制详解
@startuml
'======== 调整款式 =============' 单个状态定义示例:state 未提交 #70CFF5 ##Black
'hide footbox 可敞开时序图上面局部的模块' autoactivate on 是否主动激活
skinparam sequence {
ArrowColor black
LifeLineBorderColor black
LifeLineBackgroundColor #70CFF5
ParticipantBorderColor #black
ParticipantBackgroundColor #70CFF5
}
' ======== 定义流程 =============
activate ControlPlane
activate DubboRegistry
autonumber 1
ControlPlane <-> DubboRegistry : config pull and push
activate XdsServiceDiscoveryFactory
activate XdsServiceDiscovery
activate PilotExchanger
DubboRegistry -> XdsServiceDiscoveryFactory : request
XdsServiceDiscoveryFactory --> DubboRegistry: get registry configuration
XdsServiceDiscoveryFactory -> XdsChannel: 返回列表信息(若数据没有导入实现,则不可见)XdsServiceDiscoveryFactory-> XdsServiceDiscovery: init Xds service discovery
XdsServiceDiscovery-> PilotExchanger: init PilotExchanger
alt PilotExchanger
PilotExchanger -> XdsChannel: 初始化 XdsChannel
XdsChannel --> PilotExchanger: return
PilotExchanger -> PilotExchanger: get cert pair
PilotExchanger -> PilotExchanger: int ldsProtocol
PilotExchanger -> PilotExchanger: int rdsProtocol
PilotExchanger -> PilotExchanger: int edsProtocol
end
alt PilotExchanger
XdsServiceDiscovery --> XdsServiceDiscovery: 解析 Xds 协定
XdsServiceDiscovery --> XdsServiceDiscovery: 依据 Eds 初始化节点信息
XdsServiceDiscovery --> XdsServiceDiscovery: 将 Rds、Cds 的的负载平衡和路由规定写入结点的运行信息中
XdsServiceDiscovery --> XdsServiceDiscovery: 回传给服务自省框架,构建 invoker
end
deactivate ControlPlane
deactivate XdsServiceDiscovery
deactivate XdsServiceDiscoveryFactory
@enduml
从整体上看,istio control plane 和 dubbo 的交互时序图如上。Dubbo 里 xds 解决的次要逻辑在 PilotExchanger 和各个 DS(LDS、RDS、CDS、EDS) 的对应协定的具体实现里。PilotExchanger 对立负责串联逻辑,次要有三大逻辑:
- 获取授信证书。
- 调用不同 protocol 的 getResource 获取资源。
- 调用不同 protocol 的 observeResource 办法监听资源变更。
例如对于 lds 和 rds,PilotExchanger 会调用 lds 的 getResource 办法与 istio 建设通信连贯,发送数据并解析来自 istio 的响应,解析实现后的 resource 资源会作为 rds 调用 getResource 办法的入参,并由 rds 发送数据给 istio。当 lds 产生变更时,则由 lds 的 observeResource 办法去触发本身与 rds 的变更。上述关系对于 rds 和 eds 同样如此。现有交互如下,上述过程对应图里红线的流程:
在第一次胜利获取资源之后,各个 DS 会通过定时工作去一直发送申请给 istio,并解析响应后果和放弃与 istio 之间的交互,进而实现管制面对流量管控、服务治理、可观测性方面的管控,其流程对应上图蓝线局部。
03 以后 Dubbo Proxyless 实现存在的有余
Dubbo Proxyless 模式通过验证之后,曾经证实了其可靠性。现有 dubbo proxyless 的实现计划存在以下问题:
- 目前与 istio 交互的逻辑是推送模式。getResource 和 observeResource 是两条不同的 stream 流,每次发送新申请都须要从新建设连贯。但咱们建设的 stream 流是双向流动的,istio 在监听到资源变动后由被动推送即可,LDS、RDS、EDS 别离只须要保护一条 stream 流。
- Stream 流模式改为建设长久化连贯之后,须要设计一个本地的缓存池,去存储曾经存在的资源。当 istio 被动推送更新后,须要去刷新缓存池的数据。
- 现有 observeResource 逻辑是通过定时工作去轮询 istio。当初 observeResource 不再须要定时去轮询,只须要将须要监听的资源退出到缓存池,等 istio 主动推送即可,且 istio 推送回来的数据须要依照 app 切分好,实现多点监听,后续 dubbo 反对其余 DS 模式,也可复用相应的逻辑。
- 目前由 istio 托管的 dubbo 利用在 istio 掉线后会抛出异样,断线后无奈从新连贯,只能重新部署利用,减少了运维和治理的复杂度。咱们需减少断线重连的性能,等 istio 恢复正常后无需重新部署即可重连。
革新实现后的交互逻辑:
04 Xds 监听模式实现计划
4.1 资源缓存池
目前 Dubbo 的资源类型有 LDS,RDS,EDS。对于同一个过程,三种资源监听的所有资源都与 istio 对该过程所缓存的资源监听列表一一对应。因而针对这三种资源,咱们应该设计别离对应的本地的资源缓存池,dubbo 尝试资源的时候先去缓存池查问,若有后果则间接返回;否则将本地缓存池的资源列表与想要发送的资源聚合后,发送给 istio 让其更新本身的监听列表。缓存池如下,其中 key 代表单个资源,T 为不同 DS 的返回后果:
protected Map<String, T> resourcesMap = new ConcurrentHashMap<>();
有了缓存池咱们必须有一个监听缓存池的构造或者容器,在这里咱们设计为 Map 的模式,如下:
protected Map<Set<String>, List<Consumer<Map<String, T>>>> consumerObserveMap = new ConcurrentHashMap<>();
其中 key 为想要监听的资源,value 为一个 List, 之所以设计为 List 是为了能够反对反复订阅。List 存储的 item 为 jdk8 中的 Consumer 类型,它能够用于传递一个函数或者行为,其入参为 Map,其 key 对应所要监听的单个资源,便于从缓存池中获取。如上文所述,PilotExchanger 负责串联整个流程,不同 DS 之间的更新关系能够用 Consumer 进行传递。以监听 LDS observeResource 为例, 大抵代码如下:
// 监听
void observeResource(Set<String> resourceNames, Consumer<Map<String, T>> consumer, boolean isReConnect);
// Observe LDS updated
ldsProtocol.observeResource(ldsResourcesName, (newListener) -> {
// LDS 数据不统一
if (!newListener.equals(listenerResult)) {
// 更新 LDS 数据
this.listenerResult = newListener;
// 触发 RDS 监听
if (isRdsObserve.get()) {createRouteObserve();
}
}
}, false);
Stream 流模式改为建设长久化连贯之后,咱们也须要把这个 Consumer 的行为存储在本地的缓存池中。Istio 收到来自 dubbo 的推送申请后,刷新本身缓存的资源列表并返回响应。此时 istio 返回的响应内容是聚合后的后果,Dubbo 收到响应后,将响应资源拆分为更小的资源粒度,再推送给对应的 Dubbo 利用告诉其进行变更。
▧ 踩坑点
- istio 推送的数据可能为空字符串,此时缓存池子无需存储,间接跳过即可。否则 dubbo 会绕过缓冲池,一直向 istio 发送申请。
- 思考以下场景,dubbo 利用同时订阅了两个接口,别离由 app1 和 app2 提供。为防止监听之间的互相笼罩,因而向 istio 发送数据时,须要聚合所有监听的资源名一次性发动。
4.2 多点独立监听
在第一次向 istio 发送申请时会调用 getResource 办法先去 cache 查问,缺失了再聚合数据去 istio 申请数据,istio 再返回相应的后果给 dubbo。咱们解决 istio 的响应有两种实现计划:
- 由用户在 getResource 计划中 new 一个 completeFuture,由 cache 剖析是否是须要的数据,若确认是新数据则由该 future 回调传递后果。
- getResource 建设资源的监听器 consumerObserveMap,定义一个 consumer 并把取到的数据同步到原来的线程,cache 收到来自 istio 的推送后会做两件事:将数据推送所有监听器和将数据发送给该资源的监听器。
以上两种计划都能实现,但最大的区别就是用户调用 onNext 发送数据给 istio 的时候需不需要感知 getResource 的存在。综上,最终抉择计划 2 进行实现。具体实现逻辑是让 dubbo 与 istio 建设连贯后,istio 会推送本身监听到资源列表给 dubbo,dubbo 解析响应,并依据监听的不同 app 切分数据,并刷新本地缓存池的数据,并发送 ACK 响应给 istio,大抵流程如下:
@startuml
object Car
object Bus
object Tire
object Engine
object Driver
Car <|- Bus
Car *-down- Tire
Car *-down- Engine
Bus o-down- Driver
@enduml
局部要害代码如下:
public class ResponseObserver implements XXX {
...
public void onNext(DiscoveryResponse value) {
// 承受来自 istio 的数据并切分
Map<String, T> newResult = decodeDiscoveryResponse(value);
// 本地缓存池数据
Map<String, T> oldResource = resourcesMap;
// 刷新缓存池数据
discoveryResponseListener(oldResource, newResult);
resourcesMap = newResult;
// for ACK
requestObserver.onNext(buildDiscoveryRequest(Collections.emptySet(), value));
}
...
public void discoveryResponseListener(Map<String, T> oldResult,
Map<String, T> newResult) {....}
}
// 具体实现交由 LDS、RDS、EDS 本身
protected abstract Map<String, T> decodeDiscoveryResponse(DiscoveryResponse response){
// 比对新数据和缓存池的资源,并将不同时存在于两种池子的资源取出
...
for (Map.Entry<Set<String>, List<Consumer<Map<String, T>>>> entry : consumerObserveMap.entrySet()) {
// 本地缓存池不存在则跳过
...
// 聚合资源
Map<String, T> dsResultMap = entry.getKey()
.stream()
.collect(Collectors.toMap(k -> k, v -> newResult.get(v)));
// 刷新缓存池数据
entry.getValue().forEach(o -> o.accept(dsResultMap));
}
}
▧踩坑点
- 本来多个 stream 流的状况下,会用递增的 requestId 来复用 stream 流,改成长久化连贯之后,一种 resource 会有多个 requestid,可能会互相笼罩,因而必须去掉这个机制。
- 初始实现计划并没有对资源进行切分,而是一把梭,思考到后续对其余 DS 的反对,对 istio 返回的数据进行切分,也导致 consumerObserveMap 有点奇形怪状。
- 三种 DS 在发送数据时能够共享同一 channel,但监听所用到的必须是同一 channel,否则数据变更时 istio 不会进行推送。
- 建设双向 stream 流之后,初始计划 future 为全局共享。但可能有这样的场景:雷同的 ds 两次相邻工夫的 onNext 事件,记为 A 事件和 B 事件,可能是 A 事件先发送,B 随后;但可能是 B 事件的后果先返回,不确定 istio 推送的工夫,因而 future 必须是局部变量而不是全局共享。
4.3 采纳读写锁防止并发抵触
监听器 consumerObserveMap 和缓存池 resourcesMap 均可能产生并发抵触。对于 resourcemap,因为 put 操作都集中在 getResource 办法,因而能够采纳乐观锁就能锁住相应的资源,防止资源的并发监听。
对于 consumerObserveMap,同时存在 put、remove 和遍历操作,从时序上,采纳读写锁可躲避抵触,对于遍历操作加读锁,对于 put 和 remove 操作加写锁,即可防止并发抵触。综上,resourcesMap 加乐观锁即可,consumerObserveMap 波及的操作场景如下:
- 近程申请 istio 时候会往 consumerObserveMap 新增数据,加写锁。
- CompleteFuture 跨线程返回数据后,去掉监听 future,加写锁。
- 监听缓存池时会往 consumerObserveMap 新增监听,加写锁。
- 断线重连时会往 consumerObserveMap 新增监听,加写锁。
- 解析 istio 返回的数据,遍历缓存池并刷新数据,加读锁。
▧踩坑点
- 因为 dubbo 和 istio 建设的是是双向 stream 流,雷同的 ds 两次相邻工夫的 onNext 事件,记为 A 事件和 B 事件,可能是 A 事件先发送,B 随后;但可能是 B 事件的后果先返回,不确定 istio 推送的工夫。因而须要加锁。
4.4 断线重连
断线重连只须要用定时工作去定时与 istio 交互,尝试获取授信证书,证书获取胜利即可视为 istio 胜利从新上线,dubbo 会聚合本地的资源去 istio 申请数据,并解析响应和刷新本地缓存池数据,最初再敞开定时工作。
▧踩坑点
- 采纳全局共享的定时工作池,不能进行敞开,否则会影响其余业务。
05 感想与总结
在这次性能的革新中,笔者着实掉了一波头发,怎么找 bug 也找不到的情景不在少数。除了上述提到的坑点之外,其余的坑点包含但不局限于:
- dubbo 在某一次迭代里更改了获取 k8s 证书的形式,受权失败。
- 本来的性能没问题,merge 了下 master 代码,grpc 版本与 envoy 版本不兼容,各种报错,最初靠升高版本胜利解决。
- 本来的性能没问题,merge 了下 master 代码,最新分支代码里 metadataservice 发成了 triple,然而在 Proxyless 模式下只反对 dubbo 协定,debug 了三四天,最初发现须要减少配置。
……
但不得不抵赖,Proxyless Service Mesh 的确有它本身的劣势和广大的市场前景。自 dubbo3.1.0 release 版本之后,dubbo 曾经实现了 Proxyless Service Mesh 能力,将来 dubbo 社区将深度联动业务,解决更多理论生产环境中的痛点,更好地欠缺 service mesh 能力。