基本概念
远程函数调用
RPC 需要解决的问题(本地函数调用和 RPC 的区别):
-
函数映射
本地调用使用函数指针,但是远程调用需要一个函数 ID,通过 ID 和函数对照关系表,找到对应的函数执行。
-
数据转换成字节流
用于参数传递。
-
网络传输
RPC 概念模型
RPC 的概念模型:User、User-Stub、RPC-Runtime、Server-Stub、Server。
一次 RPC 的完整过程
-
IDL(Interface description language)文件
IDL 通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。
用来描述或说明有哪些方法,方法的参数是什么样子的,由此大家就能按照这个来调用,这个描述文件就是 IDL 文件。
-
生成代码
通过编译器工具把 IDL 文件转换成语言对应的静态库。
服务双方通过约定的规范进行远程调用,双方都依赖同一份 IDL 文件,需要通过工具来生成对应的生成文件,具体调用的时候用户代码需要依赖生成代码,所以可以把用户代码和生成代码看做一个整体。
-
编解码
从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化和反序列化。
编码解决了跨语言的数据交换格式。
-
通讯协议
规范了数据在网络中的传输内容和格式。除必须的请求 / 响应数据外,通常还会包含额外的元数据。
-
网络传输
通常基于成熟的网络库走
TCP/UDP传输。
好处
-
单一职责,有利于分工协作和运维开发。
开发(可以采用不同的语言),部署、运维(上线独立)都是独立的。
-
可扩展性强,资源使用率更优。
压力过大的时候可以独立扩充资源,底层基础服务可以复用,节省资源。
-
故障隔离,服务的整体可靠性更高。
带来的问题
- 服务宕机,对方应该如何处理?
- 在调用过程中发生网络异常,如何保证消息的可达性?
- 请求量突增导致服务无法及时处理,有哪些应对措施?
【由 RPC 框架解决】
分层设计
编解码层
数据格式
-
语言特定的格式
-
许多编程语言都内建了将内存对象编码为字节序列的支持,如
java.io.Serializable。 -
这种形式的好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复。
-
但是,通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那就和这门语言绑死在一起了。
-
安全和兼容性也是问题。
-
-
文本格式
-
JSON、XML、CSV 等文本格式。
-
具有人类可读性。
-
数字的编码多有歧义之处:
- XML 和 CSV 不能区分数字和字符串。
- JSON 不能区分整数和浮点数,而且不能指定精度。处理大量数据时,这个问题更加严重。
-
没有强制模型约束,往往只能采用文档方式来进行约定,可能会给调试带来一些不便。
-
JSON 在一些语言中的序列化和反序列化需要采用反射机制,性能比较差。
-
-
二进制编码
- 具备跨语言和高性能等优点,常见有 Thrift 的
BinaryProtocol,Google 的Protobuf等。 - 实现可以有多种,如 TLV 编码和 Varint 编码。
- 具备跨语言和高性能等优点,常见有 Thrift 的
TLV 二进制编码
-
Tag:标签,可以理解为类型
-
Length
-
Value:值,也可以是一个 TLV 结构
-
编码的第一个 byte 是类型,主要用来表是 string 还是 int 还是 list。
-
编码中没有 key 的字符串(变量名),取而代之的是
field tag,会设置成1, 2, 3和上面的结构体定义对应。这样减少了编码串的长度。 -
TLV 编码结构简单清晰,并且扩展性较好。但是由于增加了 Type 和 Length 两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。
选型因素
-
兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度。
移动互联时代,新的需求不断涌现,但是老的需求需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的字段,而不影响老的服务,将大大提高系统的灵活度。
-
通用性:支持跨平台、跨语言。
- 技术层面:如果序列化协议不支持跨平台、跨语言,通用型就大大降低了。
- 流行程度:序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本。另外,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
-
性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长。
- 空间开销(Verbosity):序列化需要在原有的数据上加上描述字段,以为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络、磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以 TB 为单位,巨大的额外空间开销意味着高昂的成本。
- 时间开销(Complexity):复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。
协议层
概念
协议是双方确定的交流语义,比如:设计一个字符串传输协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。这个协议很简单,首先发送 4 字节的消息总长度,然后再发送 1 字节的字符集长度,之后是消息的 payload,字符集名称和字符串正文。
在网络传输协议中,Payload(有效载荷)是指在通信中要传输的实际数据。它是通信数据包中除去通信协议头部信息之外的部分。简单来说,payload 就是我们要传送的原始数据,比如文本、图片、音频和视频等。在网络通信中,如 HTTP、SMTP 和 FTP 协议中,payload 是包含在通信协议的数据部分中的。通过有效载荷,通信的发送方可以向接收方传递信息,并且接收方也能够正确地解析和处理这些信息。
特殊结束符
一个特殊字符作为每个协议单元结束的标志
-
对于一个协议单元,必须要全部读入才能够进行处理。
-
除此之外,必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱。
HTTP 协议头以回车加换行符号序列结尾。
变长协议
以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度。
一般都是自定义协议,有 header 和 payload 组成,会以定长加不定长的部分组成,使用比较广泛。
协议构造
- LENGTH(32 bits):数据包大小,不包含自身。
- HEADER MAGIC(16 bits):标识版本信息,协议解析时侯快速校验。
- FLAGS(16 bits):预留字段,暂未使用,默认值为
0x0000。 - SEQUENCE NUMBER(32 bits):表示数据包的
seqID,可以用于多路复用,单链接内递增。 - HEADER SIZE(16 bits):等于
头部长度字节数/4,头部长度计算从第 14 个字节开始计算,一直到 PAYLOAD 前。(header 的最大长度为 64k)。 - PROTOCOL ID(uint8 编码):编解码方式,有 Binary(0) 和 Compact(2) 两种。
- NUM TRANSFORMS(uint8 编码):表示 TRANSFORM 个数。
- TRANSFORM ID(uint8 编码):表示压缩方式,如 zlib 和 snappy。
- INFO ID(uint8 编码):传递一些定制的 meta 信息。
- PAYLOAD:消息体
网络通信层
Sockets API
-
套接字编程中 client 必须要知道服务器的 IP 地址和端口号。
-
socket 函数创建一个套接字,bind 将一个套接字绑定到一个地址上。
-
listen 监听进来的链接。
-
backlog 指定挂起的连接队列的长度,当客户端连接的时候,服务器可能正在处理其他逻辑而未调用 accept 接受连接,此时会导致这个链接被挂起,内核维护挂起的连接队列的长度,backlog 则指定这个队列的长度,accept 函数从队列中取出连接请求并接受它,然后这个链接就从挂起队列移除。如果队列未满,客户端调用 connect 马上成功,如果满了可能会阻塞等待队列未满。Linux 的 backlog 默认是 128,通常情况下,指定 128 即可。
在 Socket 编程中,backlog(也称之为连接队列长度)是指服务器正在等待客户端连接时允许排队的最大连接数量。当一个服务器收到一个新的客户端连接请求时,它可以选择立即处理该连接,也可以将这个连接放入等待队列中,并继续处理其他连接请求。此时等待队列中已经存在的连接就是 Backlog,也就是已经插入到全连接队列中的但尚未被服务器接受的客户端连接。
当等待队列已满时,服务器拒绝新的客户端连接请求。如果服务器接受的连接数超过了 Backlog 的大小,那么后来的连接会因为无法被插入而导致客户端连接失败或者阻塞,所以适当地设置 Backlog 非常重要,可以避免客户端因为连接被阻塞而无法正常使用服务。通常来说,Backlog 应该根据服务器的性能和预估负载进行设置,典型情况下取值为 5-128 之间。
-
connect 客户端向服务器发起连接,accept 接受一个连接请求,如果没有连接则会一直阻塞直到有连接进来。
-
得到客户端的 fd 之后,就可以调用 read、write 函数和客户端通讯。
-
read 从 fd 读数据,socket 默认是阻塞模式的,如果对方没有写数据,read 会一直阻塞。
-
write 向 fd 写数据。
在 Socket 通信中,fd(File Descriptor)是指文件描述符,它是操作系统用来标识一个打开的文件或者其他 I/O 物件的标识符,也包括一个 socket 。该标识符是非负整数,并且每个进程都从 0 开始独立地编号。
在客户端 Socket 编程中,fd 指代的是客户端 Socket 的描述符。当客户端向服务器发起连接请求时,系统会创建一个与服务器相对应的 Socket,并返回给客户端一个文件描述符,表示客户端 Socket 的句柄。在随后的客户端通信过程中,客户端通过 fd 来唯一标识自己的 Socket,并使用 fd 进行收发数据等操作。
在使用 fd 进行客户端 Socket 编程时,需要注意以下几点:
- 在发送和接收数据之前,客户端必须先建立 Socket 连接,并将 fd 传递给 send() 和 recv() 函数。
- 在 Socket 通信结束后,需要使用 close() 函数关闭 fd 对应的 Socket 连接,以释放相关资源并防止泄漏。
- fd 只在当前进程中有效,不同进程中的 fd 值可能相同但实际上代表不同的 Socket 连接。
fd 是客户端 Socket 的标识符,是进行 Socket 通信操作时必不可少的重要参数。
-
-
socket 关闭套接字,当另一端 socket 关闭后,这一端的读写情况:
- 尝试读,会得到一个 EOF,并返回 0.
- 尝试写,会触发一个 SIGPIPE 信号,并返回 -1 和
errno=EPIPE,SIGPIPE 的默认行为是终止程序,通常应该忽略这个信号,避免程序终止。 - 如果不去读写,可能就无法知道对端的 socket 是否关闭。
网络库
-
提供易用 API
- 封装底层 Socket API
- 连接管理和事件分发
-
功能
-
协议支持:tcp、udp、uds等
UDS(Unix Domain Socket)网络协议是一种基于本地文件系统的进程间通信方式,它允许同一台主机上不同进程之间进行 Socket 通信,使用的是类似于 TCP/IP 协议簇中的 Socket 接口,提供了一种高效、可靠的本地IPC(Inter-Process Communication)机制。
与网络 Socket 不同,UDS 通过文件系统上的命名管道实现数据传输,而不依赖于网络协议栈。在 UDS 通信中,调用进程可以将一个特殊类型的 socket 文件作为“地址”来建立连接。这个 socket 文件像一个本地文件一样存在于文件系统中,因此 UDS 只能被同一台主机上的进程所使用。
UDS 具有以下优点:
- 速度快:与网络 Socket 相比,UDS 通信无需经过网络协议栈,减少了数据传输时的拷贝和转换操作,因此具有较高的传输速度和较低的延迟。
- 安全性高:由于 UDS 仅在本地进行通信,因此不会受到网络攻击和嗅探等安全威胁,比网络 Socket 更加安全可靠。
- 共享内存资源:在 UDS 通信中,可以利用共享内存进行数据传输,提高了数据传输效率,同时也可以减少 CPU 的 IO 操作。
- 方便管理:UD S使用文件系统来表示 Socket 地址,因此可以像管理文件一样管理 Socket 地址,方便实用。
-
优雅退出、异常处理等
-
-
性能
- 应用层 buffer 减少 copy
- 高性能定时器、对象池等
小结
-
RPC 框架主要核心有三层:编解码层、协议层、网络通信层。
-
选型网络库时要考察的核心指标
在选型网络库时,需要考虑以下核心指标:
- 性能:网络库的性能是评估其是否适合应用场景的重要因素,包括数据传输速度、延迟、处理并发连接数等。应选择具有高性能的网络库,以满足应用程序对性能的要求。
- 可靠性:可靠性是网络库必须具备的重要特质,包括对错误和异常情况的处理能力,以及保证数据传输的完整性和正确性等。应选择具有高可靠性的网络库,以确保应用程序的稳定性和安全性。
- 平台兼容性:网络库应该支持多个操作系统平台,如 Windows、Linux、macOS 等,以满足应用程序的跨平台需求。
- 易用性:网络库的易用性也是选型时需要考虑的重要因素,在 API 设计、文档、示例等方面应该尽可能简单易懂,方便开发者使用和维护。
- 社区支持:网络库的开发社区的活跃程度和参与度,以及对技术问题的解答和支持程度,也是选型时需要考虑的因素。应选择具有较大用户社区和良好口碑的网络库,以提升开发效率和稳定性。
在选型网络库时,需要综合考虑性能、可靠性、兼容性、易用性和社区支持等方面的因素,以选择最适合应用场景的网络库,提高开发效率和应用程序的稳定性。