深入浅出RPC框架 | 青训营

71 阅读10分钟

RPC要解决的问题:

  • 函数映射
  • 数据转换成字节流
  • 网络传输

RPC的好处

  1. 单一职责,有利于分工协作和运维开发
  2. 可扩展性强,资源使用率更优
  3. 故障隔离,服务的整体可靠性更高

RPC带来的问题

  1. 服务宕机,对方应该如何处理
  2. 调用过程中发生网络异常,如何保证消息的可达性
  3. 请求量突增导致服务无法及时处理,有哪些应对措施

编解码层

数据格式

  • 语言特定的格式

    • 许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java有java.io.Serializable
  • 文本格式

    • JSON、XML、CSV等文本格式,具有人类可读性
  • 二进制编码

    • 具备跨语言和高性能等优点,常见有Thrift的BinaryProtocol,Protobuf等

二进制编码

TLV编码

  • tag 标签,可以理解为类型
  • length 长度
  • value 值,value也可以是一个TLV结构

选型

  • 兼容性

    • 支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
  • 通用性

    • 支持跨平台跨语言
  • 性能

    • 从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长

协议层

概念

  • 特殊结束符

    • 一个特殊字符作为每个协议单元结束的标志
  • 变长协议

    • 以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度

网络通信层

Sockets API

套接字编程中的客户端必须知道两个信息:服务器的 IP 地址,以及端口号。

socket函数创建一个套接字,bind 将一个套接字绑定到一个地址上。

listen 监听进来的连接

backlog的含义有点复杂,这里先简单的描述:指定挂起的连接队列的长度,当客户端连接的时候,服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,backlog则指定这个队列的长度,accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。如果队列未满,客户端调用connect马上成功,如果满了可能会阻塞等待队列未满(实际上在Linux中测试并不是这样的结果,这个后面再专门来研究)。Linux的backlog默认是128,通常情况下,我们也指定为128即可。

connect 客户端向服务器发起连接,accept 接收一个连接请求,如果没有连接则会一直阻塞直到有连接进来。

得到客户端的fd之后,就可以调用read, write函数和客户端通讯,读写方式和其他I/O类似∂read 从fd读数据,socket默认是阻塞模式的,如果对方没有写数据,read会一直阻塞着:write 写fd写数据,socket默认是阻塞模式的,如果对方没有写数据,write会一直阻塞着:socket 关闭套接字,当另一端socket关闭后,这一端读写的情况:尝试去读会得到一个EOF,并返回0。尝试去写会触发一个SIGPIPE信号,并返回-1和errno=EPIPE,SIGPIPE的默认行为是终止程序,所以通常我们应该忽略这个信号,避免程序终止。如果这一端不去读写,我们可能没有办法知道对端的socket关闭了。

网络库

  • 提供易用API

    • 封装底层Scoket API
    • 连接管理和事件分发
  • 功能

    • 协议支持:tcp、udp和uds等
    • 优雅退出、异常处理等
  • 性能

    • 应用层buffer减少copy
    • 高性能定时器、对象池等

稳定性

保障策略

  • 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
  • 限流:保护被调用方,防止大流量把服务压垮
  • 超时控制:避免浪费资源在不可用节点上

请求成功率

注意,因为重试有放大故障的风险,首先,重试会加大直接下游的负载。如下图,假设 A 服务调用 B 服务,重试次数设置为 r(包括首次请求),当 B 高负载时很可能调用不成功,这时 A 调用失败重试 B ,B 服务的被调用量快速增大,最坏情况下可能放大到 r 倍,不仅不能请求成功,还可能导致 B 的负载继续升高,甚至直接打挂。防止重试风暴,限制单点重试和限制链路重试

长尾请求

长尾请求一般是指明显高于均值的那部分占比较小的请求。 业界关于延迟有一个常用的P99标准, P99 单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99 值,那后面这 1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动,GC,系统调度。我们预先设定一个阈值 t3(比超时时间小,通常建议是 RPC 请求延时的 pct99 ),当 Req1 发出去后超过 t3 时间都没有返回,那我们直接发起重试请求 Req2 ,这样相当于同时有两个请求运行。然后等待请求返回,只要 Resp1 或者 Resp2 任意一个返回成功的结果,就可以立即结束这次请求,这样整体的耗时就是 t4 ,它表示从第一个请求发出到第一个成功结果返回之间的时间,相比于等待超时后再发出请求,这种机制能大大减少整体延时。

注册中间件

Kitex Client 和 Server 的创建接口均采用 Option 模式,提供了极大的灵活性,很方便就能注入这些稳定性策略

高性能

这里分两个维度,高性能意味着高吞吐和低延迟,两者都很重要,甚至大部分场景下低延迟更重要。多路复用可以大大减少了连接带来的资源消耗,并且提升了服务端性能,我们的测试中服务端吞吐可提升30%。右边的图帮助大家理解连接多路复用调用端向服务端的一个节点发送请求,并发场景下,如果是非连接多路复用,每个请求都会持有一个连接,直到请求结束连接才会被关闭或者放入连接池复用,并发量与连接数是对等的关系。而使用连接多路复用,所有请求都可以在一个连接上完成,大家可以明显看到连接资源利用上的差异

企业实践

core是它的的主干逻辑,定义了框架的层次结构、接口,还有接口的默认实现,如中间蓝色部分所示,最上面client和server是对用户暴露的,client/server option的配置都是在这两个package中提供的,还有client/server的初始化,在第二节介绍kitex_gen生成代码时,大家应该注意到里面有client.go和server.go,虽然我们在初始化client时调用的是kitex_gen中的方法,其实大家看下kitex_gen下service package代码就知道,里面是对这里的 client/server的封装。client/server下面的是框架治理层面的功能模块和交互元信息,remote是与对端交互的模块,包括编解码和网络通信。右边绿色的byted是对字节内部的扩展,集成了内部的二方库还有与字节相关的非通用的实现,在第二节高级特性中关于如何扩展kitex里有介绍过,byted部分是在生成代码中初始化client和server时通过suite集成进来的,这样实现的好处是与字节的内部特性解耦,方便后续开源拆分。左边的tool则是与生成代码相关的实现,我们的生成代码工具就是编译这个包得到的,里面包括idl解析、校验、代码生成、插件支持、自更新等,未来生成代码逻辑还会做一些拆分,便于给用户提供更友好的扩展

自研网络库

背景

  1. Go Net 使用 Epoll ET ,Netpoll 使用 LT。
  2. Netpoll 在大包场景下会占用更多的内存。
  3. Go Net 只有一个 Epoll 事件循环(因为 ET 模式被唤醒的少,且事件循环内无需负责读写,所以干的活少),而 Netpoll 允许有多个事件循环(循环内需要负责读写,干的活多,读写越重,越需要开更多 Loops)。
  4. Go Net 一个连接一个 Goroutine,Netpoll 连接数和 Goroutine 数量没有关系,和请求数有一定关系,但是有 Gopool 重用。
  5. Go Net 不支持 Zero Copy,甚至于如果用户想要实现 BufferdConnection 这类缓存读取,还会产生二次拷贝。Netpoll 支持管理一个 Buffer 池直接交给用户,且上层用户可以不使用 Read(p []byte) 接口而使用特定零拷贝读取接口对 Buffer 进行管理,实现零拷贝能力的传递。

Netpoll

  1. go net 无法检测连接对端关闭(无法感知连接状态)
  • 在使用长连接池时,池中存在失效连接,严重影响了连接池的使用和效率
  • 希望通过引入 epoll 主动监听机制,感知连接状态。
  1. go net 缺乏对协程数量的管理
  • Kite 采取一个连接一个 goroutine 模式,由于连接利用率低,服务存在较多无用的 goroutine,占用调度开销,影响性能。
  • 希望建立协程池,提升性能。netpoll基于epoll,同时采用Reactor模型,对于服务端则是主从Reactor模型,如右图所示:服务端的主reactor 用于接受调用端的连接,然后将建立好的连接注册到某个从Reactor上,从Reactor负责监听连接上的读写事件,然后将读写事件分发到协程池里进行处理。
  1. 为了提升性能,引入了 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝

扩展性设计

kitex支持多协议的并且也是可扩展的,交互方式上前面已经说过支持ping-pong、streaming、oneway编解码支持thrift、Protobuf应用层协议支持TTHeader、Http2、也支持裸的thrift协议传输层目前支持TCP,未来考虑支持UDP、kernel-bypass的RDMA如右图所示,框架内部不强依赖任何协议和网络模块,可以基于接口扩展,在传输层上则可以集成其他库进行扩展。目前集成的有自研的Netpoll,基于netpoll实现的http2库,用于mesh场景通过共享内存高效通信的shm-ipc,以后也可以增加对RDMA支持的扩展

编解码优化

序列化和反序列的性能优化从大的方面来看可以从时间和空间两个维度进行优化。从兼容已有的 Binary 协议来看,空间上的优化似乎不太可行,只能从时间维度进行优化,包括下面的几点:...代码生成 code-gen 的优点是库开发者实现起来相对简单,缺点是增加业务代码的维护成本和局限性。 JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。即时编译 JIT 则将编译过程移到了程序的加载(或首次解析)阶段,可以一次性编译生成对应的 codec 并高效执行,目前公司内部正在尝试,压测数据表明性能收益还是挺不错的,目的是不损失性能的前提下,减轻用户的维护负担生成代码的负担。