CloudCanal通信层设计与实现

前言

之前的创业项目CloudCanal中使用了RSocket来作为通信层实现管控和sidecar节点之间的交互。本文主要分享下这种技术选型背后的思考以及CloudCanal基于RSocket实现的统一通信层实现细节。

技术选型核心动机

正常第一反应,通信层技术选型肯定考虑是大家所熟知的成熟通信层框架例如grpc或者dubbo之类的,而我们当时缺选择了比较新的通信层框架RSocket,最主要的原因是我们需要通信框架支持通信的两端进行完全对等通信,也就是说任意一个通信的节点都可以作为server或者client,这样我们可以更好的控制哪一测负责开启端口监听以及灵活的全双工通信,这对于构建一个更加安全的CloudCanal来说至关重要。像dubbo和grpc这类框架提供的能力或者使用模式上基本都是基于OSI 7层上的,这也就意味着消息收发语义必须要区分server和client的角色,这不太符合CloudCanal的场景。

在CloudCanal的架构中,console一般比较像传统client角色的定位,作为发起请求的主动方,而sidecar更像是server的角色,但是cloudcanal出于sidecar安全性的考虑必须需要由sidecar主动向console发起创建链接的请求。在这种模式下,sidecar就无需开启端口,这对于当时我们想构建安全的SAAS化数据集成平台来说是十分重要的。 传统基于http OSI7层协议上的通信层都没法满足诉求,所以我们选择了RSocket。
image.png

RSocket的缺点

引入RSocket以后,其实我们也是踩了不少坑,主要的问题是如下:

  • 缺乏服务注册、发现机制:在CloudCanal的架构中,管控console节点会作为请求发起方和sidecar集群内的节点通信。核心的rsocket能力中是不包含服务注册能力的,因此这块能力我们自己进行了重建。
  • 缺乏服务负载均衡能力:核心的rsocket能力主要是点对点通信,缺乏服务负载均衡的控制
  • 缺乏可观测性:核心的rsocket能力不包含服务观测性方面的建设,出问题的时候排查就比较麻烦。因此这点需要应用自己进一步优化改进。
  • 早期版本存在一些BUG: 比较严重的一个坑是rsocket内部处理bytebuf的时候没有做copy,直接修改了共享的内存块,并且做了释放,导致server重启时,client重连的时候会没法正常收到关键的setup frame,从而直接导致依赖setup frame信息的鉴权过程失败。
  • 本质上不是一个RPC通信框架:rsocket本质是一个更加接近于netty的通讯框架,而不是为了RPC的场景服务的。所以后来他们自己又出了个rsocket-rpc包括springboot-rsocket。springboot-rsocket提供了基本的基于注解的路由能力,但是远程方法调用体验仍然不佳,用起来比较麻烦。我们希望封装好的通信层暴露给其他开发者使用时,像使用本地方法一样简单、透明,这块后面通信层设计会提到。
  • reactive模式的使用方式不太符合团队开发习惯:rsocket是面向响应式编程风格应用的通信层框架。团队习惯传统同步调用风格的使用方式很容易导致远程方法调用误用,导致通信直接阻塞,因此必须构建一个通信中间层,让开发人员可以采用同步的写法来使用rsocket这个异步非阻塞的通信框架

通信层设计

为了更好地解决原生RSocket产生的问题,我们进一步封装和优化了rsocket,使得其能够作为一个通用通信层来适配未来企业的所有产品(主要是CloudCanal和CloudDM)。

核心架构

在rsocket提供的核心通信能力之上,我们额外封装了一层,拓展了rsocket的能力也使得其更加易用。核心架构如下。
image.png

RequestManager

RequestManager是收发两端都依赖的类,最核心的作用是:支持上层开发以同步方式来使用rsocket远程调用。这个是非常关键的,假设a向b进行请求并且等待响应,如果b这一侧处理该请求是个很慢的I/O操作,如果a直接利用rsocket提供的接口同步等待,会阻塞rsocket长连接信道上的所有通信,直接导致通信不可用。rsocket作为一个reactor模型的RPC框架,关键是异步非阻塞。因此RequestManager就是帮助上层应用构建一个异步非阻塞的中间层,解耦应用层的调用和通信层的数据收发,这样整个通信层可以完全以异步非阻塞的方式工作。

核心流程是:

  • 发送请求时向RequestManager根据requestId注册请求
  • 注册的请求会放到一个key为requestId,value为guava SettableFuture的Map当中,该map会等待异步返回结果的填充
  • 外部远程方法调用可以直接阻塞等待SettableuFuture结果的业务线程上,而不是直接阻塞在nio thread上。
  • 异步结果填充到SettableFuture对象后上层调用方则自然拿到响应结果

sender模块

发送模块提供的能力屏蔽了底层rsocket发送的细节。用户通过jar包依赖感知远程节点提供的API,然后像调用本地方法一样调用远程方法。核心技术主要是动态代理自定义bean注入。spring本身提供了非常丰富的扩展点,配合JDK动态代理以及spring扩展点提供的自定义bean注入,即可非常简单的注入服务对象,然后直接调用方法。下图是一个使用示例,调用方直接注入服务对象xxRService, 然后直接像调用普通方法一样即可完成远程方法调用。
image.png

完成透明代理和bean装饰的大体过程如下:
image.png

此外sender模块还包含一个重要职责,就是负载均衡的能力。CloudCanal中的状态信息包括节点的健康信息都存储在数据库中。在sender中可以依据节点的统计信息做个性化的服务路由。例如默认情况下可以依据低负载优先的方式路由,如果涉及相关绑定信息的,则直接点对点路由。

Receive模块

接收端的主要职责是接收到到当前机器的网络请求后,再将其分派给本机的具体接口。如果说服务节点选择是1级路由,这个本地的服务寻址就是2级路由了。核心的工作流程如下。
image.png

receive模块的关键实现点主要是:dispatcher处理时必须采用异步线程池处理,避免阻塞nio thread

functionarlity

该模块主要是一些功能性的建设,主要包括:

  • 鉴权:对接入服务节点的权限进行验证。服务节点都是按照租户划分的。
  • SSL: 支持SSL加密通信
  • Session Manager: CloudCanal通信层中是自己管理服务节点的,这里就包括节点注册、鉴权、注销、健康探测、负载均衡等内容
  • Log Aspect: 封装的CloudCanal通信层是所有RPC请求共享的,相当于一个切面,在这个切面层做好一些日志埋点可以方便问题追踪。这些日志包括记录正常请求的requestId、通信方向、路由名的普通日志、异常日志、慢通信日志,可以关联系统自身的报警能力。
  • Metric: 主要是内置的一些RService,搜集节点的统计信息用于路由时判断节点的离在线情况、健康情况

commons

一些工具类、注解定义、模型类、顶层接口

关于RSocket本身的一些设计思考

  • 提供更丰富的交互模型:其提供了4种交互模型支持了注入流传输、push等语义。包括其对等通信的理念也是和提供更加丰富交互模型的理念相匹配的。对等通信配合丰富的交互模型大大提升了rsocket的适用性,灵活性很强。像基于HTTP协议之上构建的通信层就会受限于其request-response的交互模型,原生情况下没法实现彻底的对等双向通信。
  • 提供应用层的流控的机制: 像gRPC以及其他构建与HTTP/2上的通信协议,对流控的支持都是基于字节的,比如接收端指定特定字节的window size来做流控。RSocket把应用层的流控看的比较重,协议上通过REQUEST_N帧来指定还需要接收多少条message。按照message而不是按照字节流来做流控我理解最主要的好处是一定程度上可以降低buffer bloat的问题(这也是传统拥塞控制算法效率低的原因),另外还可以对其应用层和TCP层的流控。不过这个点gRPC[4]基于BDP估算的动态流量控制我觉得才是未来,性能好也不需要用户自己配合来管理流控。
  • 高层协议:RSocket一般我们将其划到OSI 5/6层,属于高层协议,底下可以适配其他传输层协议甚至是应用层协议,像其也可以支持Aeron、WebSocket、netty、tcp等协议。
  • 二进制传输:数据是二进制封装成frame再给底下传输的,和http2一样,更加高效也便于解析。
  • 多路复用:也应用了http2的设计思路,一个信道可以进行多路复用,避免队头阻塞。

总结

如果不是出于像CloudCanal有对等通信这样的强诉求,选择gRPC一般是没啥毛病的。随着云原生越来越有作为互联网软件基座的趋势,其使用的gRPC基本上也成为通信层的事实标准。RSocket瞄准了Reactor这个点要和gRPC抗衡还是比较困难,当时立项瞄准云原生的市场或许又是另外一番光景了。无论如何,gRPC背靠google还是优势太大,reactor、better flow control这些能力现在gRPC都是具备的。

参考资料