sidecar技术体系落地

1. 背景

1.1 现状痛点分析

阿里巴巴全球化中台通过「镜像/Jar」的方式交付给业务方,被业务容器集成,同时开放了代码修改权限,业务方在一定程度上具备足够的自主权,但是对于业务方团队的要求也会变得很高,既要理解平台的领域逻辑,还有面向较大体量应用的运维工作。而在这些工作中,中台除了定时进行架构升级,整理下代码结构,并不能帮业务方做其他事情,特别是对于小团队的业务方,不管是研发复杂度,还是运维成本都太高了;同时针对多区域、多行业的研发闭环模式也是无法快速响应,总结下来当前基于中台的研发以及集成模式存在如下几个问题:

  • 业务迭代:业务日常迭代过程中,针对业务代码维度的修改 ,也需要完整的业务容器发布,尤其是核心应用,一次部署时长需要10几分钟,部署效率低下。
  • 业务集成:基于镜像/Jar包的方式不具备依赖隔离性,平台依赖跟业务依赖存在冲突等问题,需要通过依赖仲裁的方式解决,导致集成复杂度高,且每个业务方的对平台代码的集成都是个性化的,无法复用;与此同时,平台代码镜像化的交付方式也让业务的理解成本上升,同时也一定程度上影响了构建的效率。
  • 业务运维:全球化复杂的部署架构以及相关的路由逻辑直接暴露给了业务研发,导致业务在研发运维过程中存在较大的门槛,中台本身也存在大量的答疑成本,如何屏蔽掉这些部署架构带来的理解复杂度,让业务研发更关注在业务逻辑本身,也是需要解决的问题。
  • 业务扩张:面临一个新的站点时,从0-1初始化一个应用,需要完整搭建一个应用,并不存在可复用的业务应用、业务容器模板,同时运维能力也要完整的从0-1搭建,成本较高。
  • 中台迭代:中台以镜像/Jar包的方式被业务容器集成,同时将实现类直接暴露给业务做修改,导致业务跟平台属于强耦合状态;因此,中台的版本迭代面临着较大的兼容性风险,同时迭代节奏也跟业务强耦合,导致中台基本上不在关注日常的需求迭代,都是业务闭环完成,导致中台的能力进一步无法沉淀,形成了恶性循环; 中长期规划思考。

1.2 中台被集成架构

问题1:中台跟业务的能力边界是什么?

  • 关键点在于如何在业务与中台的协同过程中,保证业务自主性,同时能够保持中台能力的可持续迭代,不额外增加协同成本;本质上需要做到关注点分离,平台需要提供稳定的能力以及稳定的扩展,业务具备基于业务场景灵活编排中台能力的机制,并具备开放二次扩展的机制,业务可以自主的决定对上层行业的开放能力设计,而不在受限于中台的扩展限制;—— 核心的考虑指标是业务自助率。

问题2:中台被集成的方式是什么?

  • 在问题1被定义清楚之后,业务跟平台有了较清晰的边界,那么对于集成方式而言,服务化API被集成的方式将是耦合度最低的方式。

问题3:中台未来的交付形态是什么?

  • 中台未来的交付形态,也一定程度上影响了业务集成的复杂度,业务研发除了代码开发外,还要维护整个应用/容器相关的内容,同时还需要感知因为部署架构复杂度带来了流量相关的复杂问题,所以中台除了交付稳定性较高的平台代码外,还需要解决业务容器运行时相关的问题。

1.3 如何实现

在梳理清楚现状和痛点并且确定了中台被集成的方向之后,接下来就是探索有哪些方式可以实现,以及每一个方式应当如何落地。

单纯对“中台能力供前台业务使用”这一诉求来说,可以实现的架构有很多种:

中台以jar包当时提供给前台业务,部署时采用 classload 隔离。

中台以独立部署的sidecar容器的方式提供 IPC 服务给前台业务。

中台以独立应用的方式提供 RPC 服务给前台业务。

等等…

可以实现这一诉求的方式有很多,但是我们要搞明白的是哪一种架构是前台业务能接受并且维护和理解成本最低的,哪一种架构是中台能力可复用并且可独立发布运维和版本管理的,又是哪一种架构是面对高并发的时候可以扛得住压力的。

从全局角度出发,我们首先考虑的就是sidecar模式下的部署架构,为什么呢?首先sidecar本身就具备隔离性,可以解决业务和中台耦合的问题以及依赖冲突的问题;并且现在我们可以做到容器级别的独立发布和并行发布,可以解决发布效率的问题;而且sidecar模式下,我们会提供一整套的面向中台 sidecar 维度的独立运维、版本管理产品化能力,这也有利于中台能力的可复用性、有利于业务的自主性。上面提到了,中台服务化被集成对于业务来说对中台能力更清晰也更容易理解。

  • 隔离性:容器化可以实现隔离,这意味着业务容器和 sidecar 容器可以独立地运行,互不干扰。提高了安全性和稳定性。
  • 可复用性:中台以 sidecar 容器级别提供中台能力,可以被不同的业务集成。对于从0-1的新业务或者新站点来说,大大减少了业务理解和维护成本,并且增加了中台能力的复用性。
  • 快速部署:容器化可以快速部署和并行启动,这使得 sidecar 模式下可以尽可能的减少发布时长,从而提高部署效率。
  • 独立部署:我们支持了 sidecar 容器级别的独立发布,这就表示做到中台能力可独立运维的目标,在不影响业务进程的前提下,对 sidecar 容器独立发布。
  • 独立监控:将中台能力容器化之后,我们做到了容器粒度的监控体系,包括基础监控,中间件监控等。这也就表示业务同学和中台同学可以只对各自需要关注的容器进行告警和监控,这也减少了业务同学的维护成本和排查问题的复杂度。

对于完整的 sidecar 架构,涉及到容器生命周期管理、IPC容器进程间通讯,容器监控运维和产品化等等。本篇单以探索 sidecar 模式下的 IPC 进程间通讯框架的设计细节和落地这一个模块为主,不涉及容器生命周期和产品化等模块的设计实现细节**。**

这里先放一张架构图,大家可以带着这张图的知识和心里的疑问继续看下面的详细介绍和分析。

img

2. IPC介绍

对于IPC可以从广义和狭义多方面的解读,这里我们先统一语言,IPC指的是进程间通讯专业术语,IPC框架指的是在IPC进程间通讯能力的基础上,建立类传统RPC框架一样完整的通讯框架,包括通讯、协议、序列化、扩展、服务等等。

IPC通讯是指进程间通信(Inter-Process Communication),它是计算机系统中不同进程(或线程)之间传递信息或数据的机制。在一个计算机系统中,不同的进程(或线程)运行在不同的地址空间中,它们之间不能直接访问彼此的变量和数据,这时就需要通过IPC机制来实现它们之间的数据交换和通信。

常见的IPC通讯方式包括管道、消息队列、共享内存、信号量、套接字等,下面介绍一下这几种的工作方式和优缺点:

2.1 管道

它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。管道本质其实是内核的缓冲区进行数据传输,可以看做是一个循环队列,所以他的局限性很大。

优点:实现简单,不需要额外的硬件支持。

缺点:只能用于亲缘关系进程之间的通信,无法实现非亲缘关系进程之间的通信,数据传输的方向单一。

2.2 消息队列

消息队列是一种在不同应用程序或不同组件之间传递消息的机制。它允许将消息从一个应用程序发送到另一个应用程序,而不需要直连。消息队列系统通常括一个消息传递服务器,它负责接收并分发消息,以及一个客户端库,用于在应用程序中发送和接收消息。消息队列可以用于解耦、异步通信、流量控制、负载均衡等场景。常见的消息队列系统包括 RocketMQ、RabbitMQ、Apache Kafka、ActiveMQ、ZeroMQ 等

优点:支持多个进程之间的通信,可实现异步通信,消息的格式和数据量比较灵活。

缺点:系统开销较大,消息的传输效率较低。

2.3 共享内存

共享内存是一种在多个进程之间共享数据的机制。它可以在多个进程之间实现快速高效的数据交换,而不需要像消息队列一样进行进程间通信。共享内存是通过在多个进程之间共享一块内存来实现的,这样不同的进程就可以在同一块内存上读写数据。常见的共享内存技术包括 POSIX 共享内存、System V 共享内存等。

优点:数据传输效率高,可用于大量数据的传输。

缺点:需要使用同步机制来协调进程之间的访问,共享内存区域可能会被多个进程同时访问而导致数据不一致等问题。

img

2.4 信号量

信号量是一种用于进程间同步和互斥的机制,它用于解决多个进程之间的竞争资源问题。每个进程在访问临界资源时需要先获取信号量,如果信号量的值大于0,则表示资源可用,进程可以访问资源,并将信号量的值减1;如果信号量的值等于0,则表示资源已经被占用,进程需要等待其他进程释放资源并增加信号量的值。当进程结束访问临界资源后,需要将信号量的值加1以通知其他进程可以访问临界资源了。信号量通常被用于实现进程间的同步和互斥操作,例如生产者和消费者问题、读者和写者问题等等。在多个进程同时访问同一资源时,使用信号量可以确保资源的正确访问顺序,避免了数据不一致和竞争条件的问题。 常用的信号量实现包括 POSIX信号量和 System V信号量。

优点:可用于进程同步和互斥,易于使用。

缺点:无法传递数据,仅支持进程同步和互斥,可能出现死锁和饥饿问题。

2.5 套接字

套接字是一种通信机制,用于在不同进程之间传输数据。套接字通常表示为 IP 地址和端口号的组合。套接字可以用于本地进程间通信或跨网络连接远程主机。它们是实现网络通信的基本工具,被广泛用于互联网通信Web服务等各种应用场景中。通常套接字是通过 socket 来实现的。通过 socket 进程可以创建、绑定、监听、连接、发送和接收套接字等操作。

优点:支持不同计算机之间的进程通信,可用于实现分布式系统。

缺点:套接字通信需要进行网络协议的处理和数据传输,可能会影响系统的性能。

总结下来,对于常见的IPC进程间通讯方式,大致分为三类,第一种是走传统的网络通讯,比如 http、tcp 或者 udp;第二种就是利用中间件的能力进行通讯,比如中心化的消息队列;第三种是利用机器自身的内存、文件或者通道进行直接通讯,梳理清晰之后也为我们下面的技术调研方向带来了更多的设计思路。

3. 核心能力梳理

在设计之初,我们也考虑过使用传统 RPC 框架实现(比如直接用 HSF ),RPC 可以理解是一种特殊的 IPC,但是在进程间通信场景下,传统 RPC 框架还是”重了”一些,而且对于进程间通讯的性能要求可能不能很好的满足,因为RPC更多的关注点是在于不同机器不同进程之间的通讯能力完整性,但是对于 IPC 来说,在我们的场景下,更关注的是同机器下的不同进程之间的通讯能力完整性,所以 IPC 前期可能是不需要这么多的扩展能力和容错能力, IPC 的核心目标,还是在于如何简化通讯框架,并且在保证稳定性的前提下最大的提高性能和吞吐**,**这个是 IPC 的挑战。

不过在对 IPC 的探索落地之前,我们先梳理了一下 RPC 的架构以及模型作为参考

RPC(Remote Procedure Call)是指远程过程调用,是一种用于实现分布式应用的通讯机制。 RPC 通讯框架通常包括客户端、服务器端和注册中心三个部分,通过特定的协议和接口实现进程间的通信。比如常见的 RPC 框架有公司内部使用的Hsf,以及开源项目:Dubbo,gRPC,Thrift,Spring Cloud 等等。

img

这里我贴了一张 Dubbo 官网的一张全局能力图,从这张图里面,我们可以知道一个完整的通讯框架需要具备的能力有:

  • 服务注册:服务提供者使用 SPI 或者 springAPI 的方式以多元组形式定义并暴露自己。

  • 服务发现:服务使用者使用 SPI 或者 springAPI 的方式以多元组形式定义并获取服务提供者的服务列表。

  • 注册中心:服务提供者与服务使用者以注册中心为服务元数据的交换桥梁。

  • 服务调用:

    • *序列化:对象的状态信息转换为可以存储或传输的形式的过*程。
    • 路由:当存在多个相同多元组的服务时,根据指定路由算法路由到算法结果的某一服务。
    • 容错:当目标服务不可用的时候,主动采用某种容错机制,保证不会出现服务不可用或雪崩效应。
    • 通讯:采用指定通讯协议,进行数据传输,常见的有:HTTP,TCP,UDP,SOCKET 等等。
    • 可观测:上报 QPS、RT、请求次数、成功率等多维度的可观测指标帮助了解服务运行状态。
    • ……

当然完整的 RPC 框架内部细节还有很多,我们只在这里简单梳理一下核心具备的能力,从而作为设计 IPC 的出发点。

在梳理完 RPC 的架构之后,针对 IPC 进程间通讯,我们的目标是低延迟、高吞吐、高稳定性,也就是说,对比 RPC 通讯框架,IPC 的进程间通讯,要做的在保证高稳定性的前提下,让延迟更低,吞吐更高。

所以需要从下面几个方面入手:

  1. 因为是进程间通讯,尽可能的减少网络延迟,使用非网络连接。
  2. 使用更优的序列化框架,包括高压缩比,和序列化/反序列化高性能。
  3. 保证服务状态的实时性。
  4. 尽可能的靠近去注册中心架构。
  5. 保持和容器生命周期的一致性。
  6. 做到服务降级。

除了要保证上面的核心能力之外,还要保证一些非功能性上的能力完整性:

  1. 易用性(接入方式)
  2. 友好性(本地化开发)。
  3. 做到业务/鹰眼/HSF的上下文透传。
  4. 监控并实时上报。
  5. 框架的隔离。

**总结一句话就是:**在保证具备传统 RPC 框架能力的基础上,针对于 IPC 进程间通讯场景,做扩展维度的减法和性能上的加法。

4. 技术调研和选型

4.1 通讯协议

在前面我们说到提高 IPC 通讯性能其中一个理念就是:减少网络阻塞和开销,在确定了注册中心不使用远程 ServerCenter 架构之外,对于通讯过程中,能否减少网络阻塞和开销,减少链接握手会不会性能更优一些,所以在上面梳理了常见的IPC通讯之后,我们从中挑选了几种比较合理和可行性较高的方式,进行二次调研和探讨。

从易用性和可行性出发,我们首先淘汰了信号量、管道、消息队列的通讯方式,所以目前还剩下网络通讯(http/tcp/udp)、共享内存这两种方式。

对于http和tcp来说,虽然这种方式很成熟,而且很多RPC框架都是使用的这种方式,比如 Spring Cloud 的 Restful,但是它最大的问题就是在建立握手的消耗上和网络通信的效率不如本地IPC通信,可能会受到网络延迟、拥塞等问题的影响,而对于udp,虽然它的性能要比tcp高一些,但是它是不可靠链接,无法保证数据的可靠性和通讯的稳定性,所以基于 tcp 的方式我们作为了备选项

现在只剩下共享内存的方式了,我们一开始确实也是采用了这种方式进行测试,但是随着各种 demo 场景的不断测试下发现,共享内存需要进行同步和互斥操作,容易出现数据竞争等问题,最核心的问题是可能出现内存溢出等问题,从市场成熟度来说,目前没有比较成熟的底层框架可以很好的支持,如果再自己实现一套的话确实较复杂,从人力和时间上来说,都不是很友好,到这里我们发现好像只剩下网络通讯方式了,不过经过小伙伴的推荐,发现了一个既不走网络而且通讯性能很好的协议:uds,至此 uds 正式上场。于是开始对 uds 进行调研。

uds(Unix Domain Socket),是 unix 系统上,利用文件套接字实现数据传输的一种通讯协议,uds 与 tcp 最明显的区别在于,tcp 是 ip + port,而 uds 的地址是一个 Socket 类型的文件在文件系统中的路径,一般名字以 .sock 结尾。这个文件可以被系统进程引用,两个进程可以同时打开一个 uds 进行通信,而且这种通信方式只会发生在系统内核里,不会在网络上进行传播。客户端先创建一个自己用的 socket,然后调用 connect 来和服务器建立连接。在 connect 的时候,会申请一个新 socket 给 server 端将来使用,和自己的 socket 建立好连接关系以后,就放到服务器正在监听的 socket 的接收队列中。 这个时候,服务器端通过 accept 就能获取到和客户端配好对的新 socket 了。没有三次握手,也没有全连接队列、半连接队列,更没有超时重传。发送过程也很简单:发送方是直接将数据写到接收方的接收队列里。

img

优点:

  1. 高效:与TCP/IP相比,Unix Domain Socket在进程间通信时无需经过网络协议栈,因此传输效率更高,延迟更低。
  2. 安全:Unix Domain Socket只在本机之间通信,因此不会受到网络攻击的影响,安全性更高。
  3. 可靠性:与TCP/IP相比,Unix Domain Socket在进程间通信时不会因为网络故障等原因导致连接断开,因此更加可靠。
  4. 方便:Unix Domain Socket是基于文件系统的通信方式,使用方便,可以通过文件系统权限来控制进程间通信的权限。

缺点:

  1. 局限性:Unix Domain Socket只能在同一台机器上的进程之间通信,无法用于跨机器的通信。

最终选型:uds,选它的原因我在上文中已经说明了,这里补充一下:uds 在具备高效、安全、可靠和方便的优势上,netty框架 4+版本也原生支持了 uds 协议,解决了 uds 的底层支持,保证了稳定性,所以综上所述,我们决定使用 uds 作为IPC框架的通讯协议

4.2 序列化框架

4.2.1 如何衡量

序列化框架是整个通讯框架的筑石之一,所以序列化框架的性能很大比重的决定了一个通讯框架性能的好坏。从市场上来看,目前比较常见的序列化框架有:JSON,Hession2,Protobuf,蚂蚁的Fury等等。而衡量一个序列化框架好坏的方式可以从下面几个角度出发。

**跨语言:**序列化框架应该具有良好的兼容性,支持不同编程语言的序列化/反序列化。

**功能:**序列化框架应该具有完备、灵活的功能,能够支持多种数据格式、数据类型的序列化和反序列化,并能够支持复杂的数据结构,如嵌套对象、数组、枚举等。

压缩比:好的序列化框架可以将数据的压缩比提高,从而减少通讯传输上的压力。

性能:序列化框架的序列化/反序列化耗时肯定是越短越好,而且相同数据的序列化/反序列化耗时尽可能的平稳。

**易用:**序列化框架应该易于使用,对开发人员友好。例如,序列化框架应该提供简单易懂的 API、文档和示例代码,可以快速上手使用。

**安全性:**序列化框架应该具有良好的安全性,能够防范序列化攻击和反序列化漏洞,保障系统的安全性。

经过大量调研,我们最终选择了蚂蚁的Fury作为我们的默认序列化框架。Fury是蚂蚁开发的一个基于JIT动态编译加速的多语言动态序列化框架,通过在运行时基于对象类型信息动态生成序列化代码,和基于JDK底层的高性能内存操作,在保证类型前后兼容的情况下,实现了全自动的动态序列化能力,能够提供相比于别的框架数十倍的性能。在Java序列化场景,Fury能够跟JDK序列化保持100%的兼容性,对用户有着极低的使用成本,在安全性、压缩比方面也有着不错的表现,很好地满足了我们场景的需要。

img

4.2.2 测试对比

序列化耗时

img

反序列化耗时

img

序列化压缩比

Lib Sample(bytes) MediaContent(bytes)
Fury 365 356
Protobuf 375 301
Flatbuffers 504 520

可以看到Fury作为一个动态序列化框架,在序列化压缩比上取得了跟protobuf等静态序列化框架一样的效率,因此使用Fury在取得足够易用性和高性能的同时,也不会对网络流量、存储空间等带来负面影响。

测试总结

完整测试下来如下表格:

跨语言 性能 压缩比 功能 易用性 安全性 POJO需依赖Serializable
hessian2 支持 一般 优秀
protobuf 支持 一般 较差
fury 支持 优秀 优秀

最终选型:Fury

我们的 IPC 框架采用的是 Fury 作为默认的序列化框架,不过我们也支持用户使用指定的序列化框架, 目前我们支持的有:Fury (默认),ProtobufHessian2。在使用Fury的过程当中,我们也为Fury贡献了池化等能力,后续也会持续根据我们框架的需求持续跟Fury团队合作共建

4.2.3 感谢

我们也和 Fury 团队持续沟通,在保证性能的同时,为我们提供稳定性保障,如果遇到问题会第一时间同步和解决,这里还是要为 Fury 的同学点个赞。

感谢 Fury 团队@慕白同学的持续支持与合作。

4.3 通讯框架

对于通讯框架的选型,我们倒是没有过多的纠结,还是选择了目前市场认可度比较高的netty,毕竟 netty 的成熟度和优势就在那里。

虽然大家对 netty 都有比较全面的认知,但是我还是在这里说一下我们在设计框架的时候,比较关注的几个 netty 的核心优势,这也是为什么我们会选择它。

img

NIO 编程模型Pipeline 模型以及支持UDS协议

因为 IPC 进程间通讯不像 RPC 那样可能存在大量的服务链接和服务调用,毕竟一个 POD 里面常规最多也就十几个进程通信,所以对于 Reactor 线程模型内存池等 netty 的特性并不是我们最关注的**,当然不关注不代表不重要。**同时前文说到了,我们想要提高吞吐,还要有足够的扩展性可以实现我们自定义的通讯协议和序列化,除了 netty对uds的支持,这里 netty 的 pipeline 模型的好处也体现出来了,我们动态切换序列化框架、自定义 Package 的相关逻辑都利用 pipeline 扩展实现,完成定制化。

4.3.1 IO编程模型

首先我需要明确说明一下,IO 模型贯穿着整个调用链路,比如大家经常讨论的阻塞/非阻塞,异步/同步这些词,在整个通讯过程都会体现,而我这里是狭义针对的进程和资源之间的 IO,以及在这个过程中的内核态与资源之间阻塞/非阻塞,以及用户态与内核态之间异步/同步。用户态和内核态是操作系统的两种运行状态,操作系统主要是为了对访问能力进行限制,用户态的权限较低,而内核态的权限较高。

用户态:用户态运行的程序只能受限地访问内存,只能直接读取用户程序的数据,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。

内核态:内核态运行的程序可以访问计算机的任何数据和资源,不受限制,包括外围设备,比如网卡、硬盘等。处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况。

Socket:一般指通讯模块,与其他进程进行通讯的网卡或其他。

资源:资源文件,可能是一个文件,也可能是一组数据。

4.3.1.1 阻塞IO

同步并阻塞(传统阻塞型),服务端实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。在进行 IO 的过程,服务端的内核在接收到请求之后,会通知用户态进行资源操作,而为了资源保护,用户态调用内核态,内核态进行资源读写,而资源读写的过程中,内核态是阻塞的,用户态线程是同步等待的,所以说这是一个同步并且阻塞的过程。当资源写入内个太的 buffer 之后,用户线程再从内核态的 buffer 读取数据。

img

4.3.1.2 非阻塞IO

同步非阻塞型 IO,这里说的并不算真正意义上的 NIO,这里说的是用户态以线程池或者其他方式,优化 IO 同步等待过程,服务端会初始化一个线程池处理 IO 请求,当客户端有 IO 操作请求的时候,服务端从线程池中获取一个空闲线程进程 IO,在进行 IO 的过程,服务端的内核在接收到请求之后,会通知用户态进行资源操作,用户态用户态调用内核态,内核态进行资源读写,而资源读写的过程中,内核态是非阻塞的(但并不是异步),用户态线程虽然不是同步等待的,但是会以一定的频率询问内核态的 IO 状态,所以说这是一个同步非阻塞的过程。当资源写入内个太的 buffer 之后,用户线程下一次询问的之后发现 buffer 数据准备好了,再从内核态的 buffer 中读取数据。

img!

4.3.1.3 多路复用IO

多路复用型 IO 可以理解为真正意义上的 NIO 模型,在进行 IO 的过程,服务端的内核在接收到请求之后,会通知用户态进行资源操作,用户态调用内核态,内核态进行资源读写,而资源读写的过程中,内核态是非阻塞的,用户态线程是非同步等待的(但并不是异步),用户线程会去处理其他事情,当内核态的数据准备好之后,会向 CPU 发送中断指令,然后用户态主动去内核态的 buffer 种获取数据,所以说这是一个真正意义上 NIO 的过程。这也是目前主流的 IO 模型,windows/macOS/linux 目前都是支持的。

img点击并拖拽以移动编辑

4.3.1.4 AIO

异步非阻塞型 IO (理想型),AIO 是最理想的 IO 模型,相比于 NIO,AIO 最大的优势是解决了用户态同步去内核态读写数据的过程。在进行 IO 的过程,服务端的内核在接收到请求之后,会通知用户态进行资源操作,用户态调用内核态,内核态进行资源读写,而资源读写的过程中,内核态是非阻塞的(真正的异步),用户态线程是非同步等待的,用户线程会去处理其他事情,并且向内核态注册 accept 事件,当内核态的数据准备好之后,内核态会回调用户态 CompletionHandler,主动将数据写入用户态 buffer 中,这个过程是不需要用户线程参与的,所以说这个模型是真正的基于事件的异步非阻塞IO。但是由于目前 macOS 和 Linux 的环境都没有很好的支持,只有 window 操作系统有对应的底层 API。

img!

这里详细介绍了一下 IO 模型,上面说到了,为了提高吞吐量,一个好的 IO 模型至关重要,而且对于 IPC 进程间通讯,NIO 模型的重要性更能体现出来,所以这也是我们选取 netty 的另外一个主要原因。

4.4 注册中心

4.4.1 中心化注册中心架构

这也是常规 RPC 框架采用的注册中心模型。

  • 服务端启动注册自身服务到注册中心
  • 客户端启动后以固定时间从注册中心轮询拉取服务列表
  • 注册中心与服务提供者建立心跳
  • 当服务提供者异常,注册中心推送事件给客户端,客户端更新服务列表
  • 客户端以负载均衡调用服务端

img

4.4.2 去中心化注册中心架构

  • 没有独立的注册中心服务
  • 注册中心集成在 SDK 中,去中心化
  • 服务端启动注册自身服务到自身 SDK 的注册中心
  • 注册中心之间建立心跳,采用分布式一致性协议同步数据,例如 Raft 协议
  • 客户端直接从自身 SDK 的注册中心轮询拉取服务列表
  • 当服务提供者异常,注册中心更新服务列表
  • 客户端以负载均衡调用服务端

img

4.4.3 无注册中心架构

虽然归于无注册中心架构,其实还是有注册中心的,只不过不会有独立的注册中心服务或者进程,而是使用特殊方式实现注册中心的能力。在功能上与中心化注册中心类似,但是却没有独立的注册中心集群服务。

  • 没有独立的注册中心服务
  • 注册中心采用共享资源的读写方式体现
  • 服务端启动注册自身服务到自身 SDK 的共享资源
  • 客户端直接从共享资源轮询拉取服务列表
  • 当服务提供者异常,发送 event 事件更新共享资源

img

最终选型:无注册中心架构,由于 IPC 面对的是容器间通讯场景,是基于同一个 POD 中的,所以我们利用共享目录和共享文件的方式,实现注册中心的能力,当服务注册的时候,会根据多元组定义一个唯一文件夹和文件,写入共享目录;服务发现则是轮询拉取共享目录的文件,刷新本地服务缓存;同时为了保证服务的实时性,客户端启动的时候会监听文件变化,当服务不可用的时候,容器生命周期的监控会主动删除共享文件,客户端由于监听了文件变化事件,所以能够实时感知到服务的可用性。

4.5 链路监控

在满足上面说的核心能力之外,我们还需要考虑的是调用链路的监控和可视化,这里我们保持了与集团其他中间件一样的链路监控平台-鹰眼,这里不过多叙述,按照接入手册接入即可。

4.6 生命周期一致性

由于 voyager-boot IPC 是 sidecar 模式下的进程间通讯,那么 IPC 服务的生命周期就应该与 sidecar 容器的生命周期保持一致性。而前文介绍了,我们的 voyager-boot 除了 IPC 之外,还有其他几个模块,其中一个就是容器的生命周期管理,具体细节会在系列文章的另一篇文章中详述,简述就是在云原生技术体系下,利用 k8s 扩展和 kubelet 调度能力,实现对容器的状态和生命周期的统一管理。

  1. 客户端

    1. 客户端在与服务端建立链接的时候,会检查服务容器的生命周期状态是否可用,从而决定是否建立链接。
    2. 客户端在进行服务调用的时候,会检查服务状态是否可用,不可用的话则拒绝发送请求。
  2. 服务端

    1. 启动时,容器生命周期会做针对于 IPC 的一系列初始化动作。
    2. 当服务容器不可用或出现问题的时候,会刷新当前容器状态元数据,通知客户端。

img编辑

4.7 服务可观测

为了监测主应用和 sidecar 之间 IPC 通信的服务效率、稳定性和可用性, 我们也建立了一套监控告警体系。

img

  • voyagerboot 框架中 IPC 调用产生的链路指标会通过标准的alimetric注册到 pandora 中, 并通过和 HSF、MQ等中间件一致的指标暴露形式被 agent 采集
  • voyager-agent 用 go 语言编写, 占用内存极低(10MB 左右), 几乎不会额外消耗 pod 资源。采集链路上, 利用 kmonitor 将指标上报到 TSDB 中, 并使用配套的 grafana 搭建出整套监控看板。
  • 后续, 为了更好的产研体验, 也会将监控告警页面迁移到 voyager2.0 平台上

下钻到 IPC 服务调用链路细节上,我们梳理了一次完整链路通信耗时、序列化耗时、RT、业务耗时、QPS 等

img

Kmonitor控制台看板:

img

img

5. 架构设计

5.1 服务注册

方法签名:sidecar端口号 + 接口名 + 方法名 + 自定义扩展(支持环境变量)

服务注册过程:

  1. 服务端启动,利用 spring AotuConfiguration 注册 ProviderAnnotationBeanPostProcessor 后置处理器
  2. 扫描添加 @IpcProvider 的 bean,包装成 ServiceBean 并注册到 IOC 容器
  3. 提供手动注册 Bean 为代理对象的 API
  4. 基于 spring 事件机制发布服务注册事件
  5. 监听到事件后,将 ServiceBean 动态代理
  6. 根据服务多元组将服务生成唯一签名,并且注册到注册中心(共享目录)
  7. 启动 netty 监听客户端链接(uds)
  8. 并且将服务注册成 HSF 服务

img点击并拖拽以移动编辑

5.2 服务发现

方法签名:sidecar端口号+类名+方法名+自定义扩展(支持环境变量或者入参)

服务发现过程:

  1. 服务端启动,利用 spring AotuConfiguration 注册 ReferenceAnnotationBeanPostProcessor 后置处理器
  2. 在执行 PopulateBean 进行属性装填的时候 将服务引用包装成 ReferenceBean 实例,并且注册到 IOC 容器
  3. 扩展:提供服务列表查询方法,可以获取所有服务元数据列表
  4. 扩展:提供手动方式注册 ReferenceBean,根据 getObject() 方法获取 IPC 代理对象,并注册成 Bean(参考HSF)
  5. 启动客户端 netty 链接服务端
  6. 启动定时任务定时从注册中心(共享目录)拉取服务列表
  7. 启动 FileListener 文件监听,实时监听注册中心服务状态变化
  8. 订阅对应的 HSF 服务

img

从上面的服务注册和服务发现过程中可以发现:

服务提供者不仅注册了 IPC 服务,也注册了 HSF 服务;服务调用者不仅订阅了 IPC 服务列表,也订阅了对应的HSF服务;

这里的 HSF 设计其实就是为了实现服务降级和**容错,**因为业务容器的代码开发一般是业务团队,而 siedecar 容器一般是中台团队维护,在日常开发过程中,或者本地开发过程中,IPC 通讯肯定是无法满足联调需求的,所以需要退化成 HSF 的 RPC 通讯模式。

5.3 服务调用

服务的注册和发现在上面说明了,这里主要说明一下调用流程

服务调用过程:

  1. 客户端发起调用
  2. 客户端代理类 RefenceBean 执行内部 invoke 逻辑
  3. 确定服务签名,并发起指定服务调用
  4. 针对指定服务在所容器进行健康检查(这里使用的是容器生命周期管理的内部能力)
  5. 当容器不可用,则降级调用 HSF 服务
  6. 当容器可用,则继续执行 IPC 链路
  7. 鹰眼打标
  8. 执行 IPC 客户端的拦截器链SPI,做一些扩展逻辑和自定义逻辑
  9. netty 执行 pipeline 链路,序列化、拆包等
  10. 进行 uds 协议通信,macOS 使用的 KQueue 模型,linux 使用的 Epoll 模型
  11. 服务端 netty 接受到请求后,执行 pipeline 链路,粘包、反序列化等
  12. 执行 IPC 服务端的拦截器链 SPI,做一些扩展逻辑和自定义逻辑
  13. 鹰眼打标
  14. 根据服务签名找到代理类,并执行目标方法
  15. 执行业务逻辑
  16. 服务端返回
  17. 客户端上报完整链路监控数据到 Kmonitor

完整的服务发现,服务注册,服务调用的完整链路图。

img

5.4 框架隔离

框架隔离单独强调一下,也是因为我们在初期踩了坑,给用户带来了不好的体验,在框架落地初期,我们框架还是以 jar 包的形式给用户使用,但是带来了一些列的依赖冲突和版本冲突的问题,比如 spring 版本,netty 版本,各种二方包三方包的冲突等等,所以我们在优化版本中,将框架做成了 pandora 插件,以插件化的方式提供给用户使用,这样技能避免依赖冲突的问题,也规范了我们的开发形式。pandora 隔离机制和插件化开发这里也不过多叙述了,因为大家都很了解了,ata上也有很多关于 pandora 和其插件化的文章,大家自行搜索。

6. 性能测试

介绍完了上面所有的架构设计细节和技术细节之后,还有一个很重要的事情,就是做性能测试,要对其性能、吞吐和稳定性做完整性测试,下面介绍一下我们的压测参数和场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
● Pod:8C16G
● main容器:-Xms8g -Xmx8g -Xmn4g -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g
● sidecar容器:-Xms6G -Xmx6G -Xmn3G -XX:MaxDirectMemorySize=3G -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m

序列化:
● 框架:fury
● 版本号:0.10.2

通讯框架:
● 框架:netty
● 版本号:4.1.81
● 协议:unix domain socket

场景:
● 使用DO对象测试(100K,2M,10M)
● 请求和响应传输相同大小的数据

6.1 报表

IPC压测数据: QPS: 100K 2M
500 0.41ms cpu:2.67%,memory:47.9% 1.73ms cpu:14.1%,memory:45.5%
1000 0.22ms 1.67ms cpu:28.9%,memory:45.8%
2000 0.16ms cpu:7.08%,memory:45.4% 1.63ms cpu:56.7%,memory:46.3%
5000 0.16ms cpu:16.4%,memory:45.6% 12.42ms cpu:74.8%,memory:46.7%
10000 0.18ms cpu:30.3%,memory:45.5% -

6.2 曲线图

img

img

备注:这里对比测试了HSF,但是HSF都是使用的默认参数,没有尝试进行调优,所以对HSF的压力测试可能不是最优值。

6.3 CPU火焰图

img

7. 接入方式

为了贴合用户在 HSF 使用上的使用习惯,IPC 的使用方式尽可能和 HSF 接入方式一致。

感兴趣的同学可以从文章最后的用户手册链接查看详细的接入方式,这里只简单列举一下。

7.1 服务注册

  • 注解方式:
1
2
3
4
5
6
7
8
9
10
/**
* version(非必填):版本号, 默认1.0.0
* tag(非必填): 自定义标签,可以是字符串,可以是环境变量
* serviceInterface(非必填):接口,当上层接口超过2个,需要指定serviceInterface
* serializationType(非必填): 序列化类型,默认FURY
*/
@IpcProvider(version = "1.0", tags = {"abc","${env}"}, serviceInterface = TestApi.class, serializationType = SerializationType.FURY)
public class TestApiImpl implements TestApi{
// ...
}
  • 手动注册方式:
1
2
3
4
5
6
7
/**
* bean(必填): Bean对象
* injectedType(非必填): 服务接口, 但是当接口>=2个的时候必填
* version(非必填):版本号, 默认1.0.0
* serializationType : 序列化类型
*/
ServiceManager.registerService(Object bean, Class<?> injectedType, String version, SerializationType serializationType);

7.2 服务发现

  • 注解方式:
1
2
3
4
5
6
7
8
/**
* version(非必填):版本号
* tag(非必填): 自定义标签,可以是字符串,可以是环境变量
* timeout(非必填):指定请求超时时间,单位:毫秒,默认20
* serializationType(非必填): 序列化类型,默认FURY
*/
@IpcConsumer(version = "1.0", tags = {"abc","${env}"}, timeout = 5000, serializationType = SerializationType.FURY)
private TestApi testApi;
  • 手动注册客户端代理Bean:
1
2
3
4
5
6
7
8
9
10
@Configuration
public class ClientIpcTest {

@Bean
public TestApi getTestApi(){
ReferenceBean referenceBean = new ReferenceBean("1.0.0", 5000, TestApi.class, SerializationType.FURY);
return (TestApi) referenceBean.getObject();
}

}

7.3 SPI

8. 结语

目前我们框架正在和全球化业务平台类目团队和营销团队场景落地中,未来与更多全球化业务团队合作共进。

感谢全球化业务平台类目团队营销团队同学的支持。

感谢国内中台同学的建议和意见。

感谢蚂蚁 Fury 框架组同学的支持与合作。

最后感谢团队所有小伙伴们的帮助和指导。

全球化业务平台在云原生的道路上持续探索,未来会在全球化特色业务背景下,向中台被集成serverless 方向持续演进。

9. 相关链接

Fury序列化框架:https://github.com/apache/fory

Github: https://github.com/leeco-cloud/ipc


sidecar技术体系落地
https://leenotes.cn/posts/45495.html
作者
Lee
发布于
2023年1月1日
许可协议