前言
这是我参与「第五届青训营 」伴学笔记创作活动的第 15 天
今日学习内容:
- 基本概念
- 分层设计
- 关键指标
- 企业实践
正文
基本概念
本地函数调用
func main() {
var a = 2
var b = 3
result := calculate(a, b)
fmt.Println(result)
return
}
func calculate(x, y int) {
z := x * y
return z
}
- 将 a 和 b 的值压栈
- 通过函数指针找到 calculate 函数,进入函数取出栈中的值 2 和 3, 将其赋予 x 和 y
- 计算 x * y, 并将结果保存在 z 中
- 将 z 的值压栈,然后从 calculate 返回
- 从栈中取出 z 返回值,并赋值给 result
注:以上步骤只是为了说明原理。事实上,编译器经常会做优化,对于参数和返回值少的情况会直接将其存放在寄存器,而不需要压栈弹栈的过程,甚至都不需要调用 call,而直接做 inline 操作。
远程函数调用(RPC-Remote Procedure Calls)
- 函数映射
在本地调用中,函数体是直接通过函数指针来指定的,我们调用哪个方法,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。所有函数都有自己唯一的一个 ID,在做 RPC 的时候要附上这个 ID,还得有个 ID 和函数的对照关系表,通过 ID 找到对应的函数并执行。
- 客户端怎么把参数值传给远程的函数呢?
在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端和服务端是不同的进程,不能通过内存来传递参数。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。
- 远程调用往往用在网络上,如何保证在网络上高效稳定地传输数据?
RPC 概念模型
一次 RPC 的完整过程
- IDL (Interface Description Language) 文件
IDL 通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。
- 生成代码
通过编译器工具把 IDL 文件转换成语言对应的静态库
- 编解码
从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化和反序列化。
- 通信协议
规范了数据在网络中的传输内容和格式,除必须的请求/响应数据外,通常还会包含额外的元数据
- 网络传输
通常基于成熟的网络库走 TCP/UDP 传输
- 相比于本地函数调用,远程调用的话我们不知道对方有哪些方法,以及方法参数长什么样,所以就需要一种方式来描述或者说声明我有哪些方法,方法的参数都是什么样子的,这样的话大家就能按照这个来调用,这个描述文件就是 IDL 文件 。
- 刚才我们提到服务双方是通过约定的规范进行远程调用,双方都依赖于同一份 IDL 文件,需要通过工具来生成对应的生成文件,具体调用的时候用户代码需要依赖生成代码,所以可以把用户代码和生成代码看做一个整体。
- 编码只是解决了跨语言的数据交换格式,但是如何进行通讯呢?需要制定通讯协议,以及数据如何传输?我的网络模型如何呢?那就是这里的 transfer 需要做的事情。
RPC 的好处
- 单一职责,有利于分工协作和运维开发(开发,部署及运维都是独立的)
- 可扩展性强,资源使用率更优(例如压力过大时可以独立扩充资源,底层基础服务可以复用,节省资源)
- 故障隔离,服务的整体可靠性更高
RPC 带来的问题
- 服务宕机,对方应该如何处理?
- 在调用过程中发生网络异常,如何保证消息的可达性?
- 请求量突增导致服务无法及时处理,有哪些应对措施?
小结
- 本地函数调用和 RPC 调用的区别:函数映射、数据转换成字节流、网络传输
- RPC 的概念模型:User、User-Stub、RPC-Runtime、Server0-Stub、Server
- 一次 RPC 的完整过程,并讲解了 RPC 的基本概念
- RPC 带来好处的同时也带来了不少新的问题,将由 RPC 框架来解决
分层设计
以 Apache Thrift 为例
编解码层
生成代码
数据格式
- 语言特定的格式
许多编程语言都内置了将内存对象编码为字节序列的支持,例如 Java 有 Java.io.Serializable。这种编码形式的好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复,这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。安全和兼容性也是问题。
- 文本格式
JSON、XML、CSV 等文本格式。文本格式具有人类可读性,但是其数字编码多有歧义之处。比如 XML 和 CSV 不能区分数字和字符串,JSON 虽然能区分数字和字符串,但是不能区分整数和浮点数,而且不能指定精度,处理大量数据时,这个问题更严重了;没有强制模型约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便。由于 JSON 在一些语言的序列化和反序列化需要采用反射机制,所以性能比较差。
- 二进制编码
具备跨语言和高性能等优点,常见的有 Thrift 的BinaryProtocol,Protobuf 等。实现可以有很多种,例如 TLV 编码和 Varint 编码。
下面以 TLV 编码为例:
- Tag:标签,可以理解为类型
- Length:长度
- Value:值,Value 也可以是个 TLV 结构
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
- 编解码层中的选型
-
兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
-
通用性:支持跨平台,跨语言
-
性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长。
-
协议层
协议层概念
协议是双方确定的交流语义,比如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到相应的字符串。这个协议很简单,首先发送一个 4 字节的消息总长度,然后再发送 1 字节的字符集 charset 长度,接下来就是消息的 payload,字符集名称和字符串正文。
协议构造
协议解析
网络通信层
网络通信层-Sockets API
套接字编程中的客户端必须知道两个信息:服务器的 IP 地址,以及端口号
Socket 函数创建一个套接字,bind 将一个套接字绑定到一个地址上,listen 监听进来的连接,backlog 的含义有点复杂。
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
- 封装底层 Socket API
- 连接管理和事件分发
- 功能
- 协议支持:tcp、udp 和 uds 等
- 优雅退出、异常处理等
- 性能
- 应用层 buffer 减少 copy
- 高性能定时器、对象池等
小结
- RPC 框架主要核心有三层:编解码层、协议层和网络通信层
- 二进制编解码的实现原理和选型要点
- 协议的一般构造,以及框架协议解析的基本流程
- Socket API 的调用流程,以及选型网络库时要考察的核心指标
关键指标
稳定性
稳定性-保障策略
- 熔断
一个服务 A 调用服务 B 时,服务 B 的业务逻辑又调用了服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致导致 B 的业务逻辑一直等待,而这个时候服务 A 继续频繁地调用服务 B,服务 B 就有可能会因为堆积大量的请求而导致服务宕机,由此就导致了服务雪崩的问题。
- 限流
当调用端发送请求过来时,服务端在执行业务逻辑之前先执行检查限流逻辑,如果发现访问量过大并且超出了限流条件,就让服务端直接降级处理,或者返回给调用方一个限流异常。
- 超时
当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,避免浪费资源。从某种程度上讲,限流和熔断也是一种服务降级的手段。