RPC框架分层设计
基本概念
1.1 本地函数调用
1.2 远程函数调用(RPC Remote Procedure Calls)
需要解决的几个问题
- 函数映射: 告诉支付服务我们需要调用这个函数, 本地调用中, 函数体直接通过函数指针指定我们需要调用哪个方法, 编译器自动帮我们调用它对应的函数指针. 但是在远程调用中函数指针是不行的. 因为两个进程的地址空间不同. 函数都有自己的一个ID, 做远程RPC的时候需要附上这个ID.
- 客户端如何把参数值传给远程的函数: 本地调用时, 只需要把参数压在栈中, 然后函数自己去栈中读取就行. 但是远程调用时, 客户端和服务端是不同的进程, 不能通过内存传递函数. 这时候就需要客户端把参数先转成字节流, 传给服务端后, 再把字节流转成自己能读取的格式.
- 远程调用往往在网络上, 如何保证网络上高效稳定的传输数据呢?
1.3 RPC概念模型
1.4 一次完整RPC的过程
-
IDL(Interface description language)文件 IDL通过一种中日的方式来描述接口, 使得在不同该平台上运行的对象和用不同语言编写的程序之间可以相互通信. 相比本地函数调用, 远程调用不知道对方有哪些方法, 以及参数的样子, 所以需要一种方式来描述或者说声明我有哪些方法, 方法的参数都是什么样子的, 这样的话大家都能按照这个来调用, 这个描述文件就是IDL文件.
-
生成代码 通过编译器工具把IDL文件转换成语言对应的静态库. 刚才我们提到服务双方是通过约定的规范进行远程调用, 双方都依赖同一份IDL文件, 需要通过工具来生成对应的生成文件, 具体调用的时候用户代码需要依赖生成代码, 可以把用户代码和生成代码看做一个整体.
-
编译器 从内存中表示到直接序列的转换表示为编码, 反之为解码, 也常叫做序列化和反序列化.
-
通信协议 规范了数据在网络中的传输内容和格式. 除必须的请求/响应数据外, 通常还会包含额外的元数据. 编码只是解决了跨语言的数据交换格式, 但是如何通讯?需要制定通讯协议, 以及数据如何传输?我们的网络协议如何?那就是这里transfer需要做的事情.
-
网络传输 通常基于成熟的网络库走TCP/UDP传输
1.5 RPC的好处
- 单一职责, 有利于分工协作和运维开发 开发采用不同的语言, 部署以及运维都是独立的
- 可扩展性强, 资源利用率更优 例如压力过大时可以独立扩展资源, 底层基础服务可以复用, 节省资源
- 故障隔离, 服务的整体可靠性更高 某个模块故障, 不会影响整体的可靠性.
1.6 RPC带来的问题
- 服务宕机, 对方如何处理?
- 调用过程中发生网络异常, 如何保证消息的可达性?
- 请求量突增导致服务无法及时处理, 有哪些应对措施?
01. 小结
- 本地调用和RPC调用的区别: 函数映射, 数据转成字节流, 网络传输
- RPC的概念模型: User, User-Stub, RPC-Runtime, Server-Stub, Server
- 一次RPC的完整过程
- RPC带来好处的同时也带来了不少新的问题, 将由RPC框架解决
分层设计
2.1 分层实际 - 以Apache Thrift为例
2.2 编解码层
2.3 编解码层 - 生成代码
2.4 编解码层 - 数据格式
- 语言特定的格式, 许多编程语言都内建了将内存对象编码为字节序列的支持, 比如Java有java.io.Serializable. 这种编码的好处是非常方便, 可以用很少的额外代码实现内存对象的保存与恢复, 这类编码通常与特定的编程语言深度绑定, 其他语言很难读取这种数据.
- 文本格式 JSON, XML, CSV等文本格式, 具有人类可读性. 数字的编码多有歧义之处, 比如XML和CSV无法区分数字和字符串, JSON虽然能区分字符串和数字, 但是不区分整数和浮点数, 而且不能指定精度, 处理大量数据时, 这个问题更加严重; 没有强制模型约束, 实际操作中只能采用文档方式来进行约定, 这可能会给调试带来一些不便. 由于JSON在一些语言中的序列化和反序列化需要反射机制, 所以性能比较差.
- 二进制编码, 具备跨语言和高性能等优点, 常见有Thrift的BinaryProtocol, Protobuf等. 实现可以有很多种, TLV编码和Varint编码.
2.5 编解码层 - 二进制编码
- TLV 编码
- Tag: 标签, 可以理解为类型
- Length: 长度
- Value: 值, Value也可以是一个TLV结构
- 一个byte是类型, 住哟啊表示是string还是int还是list.
- TLV编码结构清晰, 并且扩展性较好, 但是由于增加了Type和Lenght两个冗余信息, 有额额外的内存开销, 特别是在大部分字段都是基本类型的情况下有不小的空间浪费.
2.6 编解码层 - 选型
- 兼容性, 支持自动增加新的字段, 而不影响老的服务, 这将提高系统的灵活度
- 通用性, 支持跨平台, 跨对象
- 性能, 从空间和时间两个维度来考虑, 也就是编码后数据大小和编码耗费时长.
2.7 协议层
2.8 协议层 - 概念
- 特殊结束符
- 变长协议, 以变长加不定长的部分组成, 其中定长的部分需要描述不定长的内容长度. 一边是自定义协议, 由header和payload组成, 会以定长加不定长的部分组成, 其中定长的部分需要描述不定长的内容长度, 使用比较广泛.
- 协议是双方确定的交流语义. 特殊结束符, 过于简单, 对于一个协议数据单元必须要全部读入才能进行处理, 除此之外必须要对用户传输的数据不能和结束符相同.
- HTTP协议头就是以回车加换行符号序列结尾.
2.9 协议层 - 协议构造
- length: 数据包大小, 不包含自身
- header magic: 标识版本信息, 协议解析时候快速校验
- sequence number: 表示数据包的seqID, 可用于多路复用, 单连接内递增.
- header size: 头部长度
- protocol id: 编解码方式, 有binary和compact两种
- transform id: 压缩方式, 如zlib和snappy
- info id: 传递一些定制的meta信息
- payload: 消息体
2.10 协议层 - 协议解析
2.11 网络通信层
2.12 网络通信层 - socket api
- 套接字编程中客户端必须知道两个信息, 服务器的IP地址, 以及端口号.
- socket函数创建一个套接字, bind将一个套接字绑定到一个地址上. listen监听进来的连接, backlog的含义复杂: 指定挂起的连接队列的长度, 当客户端连接的时候, 服务器可能正在处理其他逻辑而未调用accept接受连接, 此时会导致这个连接被挂起, 内核维护挂起的连接队列, backlog则指定这个队列的长度, accept函数从队列中取出连接请求并接收它, 然后这个连接就从挂起队列移除. 如果队列未满, 客户端调用connect马上成功, 如果满了可能会阻塞等待队列未满.
- connect客户端向服务端发起连接, accept接收一个连接请求, 如果没有连接则会一直阻塞直到有有连接进来. 得到客户端的fd后, 就可以调用read, write函数和客户端通讯, 读写方式和其他I/O类似.
- read从fd读数据, socket默认是阻塞式, 如果对方没有写数据, read会一直阻塞着.
- write写fd写数据. 默认阻塞模式.
- socket关闭套接字, 但一端socket关闭后, 这一端读写的情况: 尝试去读会得到一个EOF, 并返回0. 尝试写会触发SIGPIPE型号, 并返回-1和errno=EPIPE, SIGPIPE的默认行为是终止程序, 所以我们通常应该忽略这个信号, 避免程序终止.
- 如果这一端不去读写, 我们可能没有办法直到对端的socket关闭了.
2.13 网络通信层 - 网络库
- 提供易用 API, 封装socket API, 连接管理和事件分发
- 功能, 协议支持: tcp, udp和uds等, 优雅退出, 异常处理等
- 性能, 应用层buffer减少copy, 高性能定时器, 对象池.
02. 小结
- RPC框架主要核心有三层: 编解码层, 协议层和网络通信层
- 二进制编解码的实现原理和选型特点
- 协议的一般构造, 以及狂啊及协议解析的基本流程
- Socket API的调用流程, 以及选型网络库时要考虑的核心指标
关键指标
3.1 稳定性 - 保障策略
- 熔断: 保护调用方, 防止被调用的服务出现问题而影响到整个链路. 一个服务A调用服务B时, 服务B的业务逻辑又调用了服务C, 而这时服务C响应超时了, 由于服务B依赖服务C, C超时直接导致B的业务逻辑一直等待, 而这个时候服务A继续频繁调用服务B, 服务B就可能会因为大量的请求而导致服务宕机, 由此就导致了服务雪崩.
- 限流: 保护被调用方, 防止大流量把服务压垮. 当调用端发送请求过来时, 服务端在执行业务逻辑之前先执行检查限流逻辑, 如果发现访问量过大并且超出了限流条件, 就让服务端直接降级处理或者返回给调用方一个限流异常.
- 超时控制: 避免资源浪费在不可用节点上
3.2 稳定性 - 请求成功率
负载均衡和重试