RPC(Remote Procedure Call),即远程过程调用,是一个分布式系统间通讯的必备技术,本文体系性地介绍了 RPC 包含的核心概念和技术,但愿读者读完文章,一提到 RPC,脑中不是零碎的知识,而是具体的一个脑图般的体系。本文并不会深刻到每个主题剖析,只作提纲挈领的介绍。html
RPC 最核心要解决的问题就是在分布式系统间,如何执行另一个地址空间上的函数、方法,就仿佛在本地调用同样,我的总结的 RPC 最核心的概念和技术包括以下,如图所示:react
(点击放大图像)git
下面依次展开每一个部分。github
TCP 协议是 RPC 的 基石,通常来讲通讯是创建在 TCP 协议之上的,并且 RPC 每每须要可靠的通讯,所以不采用 UDP。web
这里重申下 TCP 的关键词:面向链接的,全双工,可靠传输(按序、不重、不丢、容错),流量控制(滑动窗口)。算法
另外,要理解 RPC 中的嵌套 header+body,协议栈每一层都包含了下一层协议的所有数据,只不过包了一个头而已,以下图所示的 TCP segment 包含了应用层的数据,套了一个头而已。apache
(点击放大图像)编程
那么 RPC 传输的 message 也就是 TCP body 中的数据,这个 message 也一样能够包含 header+body。body 也常常叫作 payload。设计模式
TCP 就是可靠地把数据在不一样的地址空间上搬运,例如在传统的阻塞 I/O 模型中,当有数据过来的时候,操做系统内核把数据从 I/O 中读出来存放在 kernal space,而后内核就通知 user space 能够拷贝走数据,用以腾出空间,让 TCP 滑动窗口向前移动,接收更多的数据。安全
TCP 协议栈存在端口的概念,端口是进程获取数据的渠道。
作一个高性能 /scalable 的 RPC,须要可以知足:
CPU 和 I/O 之间自然存在着差别,网络传输的延时不可控,最简单的模型下,若是有线程或者进程在调用 I/O,I/O 没响应时,CPU 只能选择挂起,线程或者进程也被 I/O 阻塞住。
而 CPU 资源宝贵,要让 CPU 在该忙碌的时候尽可能忙碌起来,而不须要频繁地挂起、唤醒作切换,同时不少宝贵的线程和进程占用系统资源也在作无用功。
Socket I/O 能够看作是两者之间的桥梁,如何更好地协调两者,去知足前面说的两点要求,有一些模式(pattern)是能够应用的。
RPC 框架可选择的 I/O 模型严格意义上有 5 种,这里不讨论基于 信号驱动 的 I/O(Signal Driven I/O)。这几种模型在《UNIX 网络编程》中就有提到了,它们分别是:
这里不细说每种 I/O 模型。这里举一个形象的例子,读者就能够领会这四种 I/O 的区别,就用 银行办业务 这个生活的场景描述。
下图是使用 传统的阻塞 I/O 模型。一个柜员服务全部客户,可见当客户填写单据的时候也就是发生网络 I/O 的时候,柜员,也就是宝贵的线程或者进程就会被阻塞,白白浪费了 CPU 资源,没法服务后面的请求。
下图是上一个的进化版,若是一个柜员不够,那么就 并发处理,对应采用线程池或者多进程方案,一个客户对应一个柜员,这明显加大了并发度,在并发不高的状况下性可以用,可是仍然存在柜员被 I/O 阻塞的可能。
下图是 I/O 多路复用,存在一个大堂经理,至关于代理,它来负责全部的客户,只有当客户写好单据后,才把客户分配一个柜员处理,能够想象柜员不用阻塞在 I/O 读写上,这样柜员效率会很是高,这也就是 I/O 多路复用的精髓。
下图是 异步 I/O,彻底不存在大堂经理,银行有一个自然的“高级的分配机器”,柜员注册本身负责的业务类型,例如 I/O 可读,那么由这个“高级的机器”负责 I/O 读,当可读时候,经过 回调机制,把客户已经填写完毕的单据主动交给柜员,回调其函数完成操做。
重点说下高性能,而且工业界广泛使用的方案,也就是后两种。
基于内核,创建在 epoll 或者 kqueue 上实现,I/O 多路复用最大的优点是用户能够在一个线程内同时处理多个 Socket 的 I/O 请求。用户能够订阅事件,包括文件描述符或者 I/O 可读、可写、可链接事件等。
经过一个线程监听所有的 TCP 链接,有任何事件发生就通知用户态处理便可,这么作的目的就是 假设 I/O 是慢的,CPU 是快的,那么要让用户态尽量的忙碌起来去,也就是最大化 CPU 利用率,避免传统的 I/O 阻塞。
这里重点说下同步 I/O 和异步 I/O,理论上前三种模型都叫作同步 I/O,同步是指用户线程发起 I/O 请求后须要等待或者轮询内核 I/O 完成后再继续,而异步是指用户线程发起 I/O 请求直接退出,当内核 I/O 操做完成后会通知用户线程来调用其回调函数。
进程 / 线程模型每每和 I/O 模型有联系,当 Socket I/O 能够很高效的工做时候,真正的业务逻辑如何利用 CPU 更快地处理请求,也是有 pattern 可寻的。这里主要说 Scalable I/O 通常是如何作的,它的 I/O 须要经历 5 个环节:
Read -> Decode -> Compute -> Encode -> Send
使用传统的阻塞 I/O + 线程池的方案(Multitasks)会遇 C10k问题。
https://en.wikipedia.org/wiki/C10k_problem
可是业界有不少实现都是这个方式,好比 Java web 容器 Tomcat/Jetty 的默认配置就采用这个方案,能够工做得很好。
可是从 I/O 模型能够看出 I/O Blocking is killer to performance,它会让工做线程卡在 I/O 上,而一个系统内部可以使用的线程数量是有限的(本文暂时不谈协程、纤程的概念),因此才有了 I/O 多路复用和异步 I/O。
I/O 多路复用每每对应 Reactor 模式,异步 I/O 每每对应 Proactor。
Reactor 通常使用 epoll+ 事件驱动 的经典模式,经过 分治 的手段,把耗时的网络链接、安全认证、编码等工做交给专门的线程池或者进程去完成,而后再去调用真正的核心业务逻辑层,这在 *nix 系统中被普遍使用。
著名的 Redis、Nginx、Node.js 的 Socket I/O 都用的这个,而 Java 的 NIO 框架 Netty 也是,Spark 2.0 RPC 所依赖的一样采用了 Reactor 模式。
Proactor 在 *nix 中没有很好的实现,可是在 Windows 上大放异彩(例如 IOCP 模型)。
关于 Reactor 能够参考 Doug Lea 的 PPT
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
以及 这篇 paper
http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf
关于 Proactor 能够参考 这篇 paper
http://www.cs.wustl.edu/~schmidt/PDF/proactor.pdf
说个具体的例子,Thrift 做为一个融合了 序列化 +RPC 的框架,提供了不少种 Server 的构建选项,从名称中就能够看出他们使用哪一种 I/O 和线程模型。
(点击放大图像)
当 I/O 完成后,数据能够由程序处理,那么如何识别这些二进制的数据,是下一步要作的。序列化和反序列化,是作对象到二进制数据的转换,程序是能够理解对象的,对象通常含有 schema 或者结构,基于这些语义来作特定的业务逻辑处理。
考察一个序列化框架通常会关注如下几点:
序列化方式很是多,常见的有 Protocol Buffers, Avro,Thrift,XML,JSON,MessagePack,Kyro,Hessian,Protostuff,Java Native Serialize,FST。
下面详细展开 Protocol Buffers(简称 PB),看看为何做为工业界用得最多的高性能序列化类库,好在哪里。
首先去官网查看它的 Encoding format
https://developers.google.com/protocol-buffers/docs/encoding
紧凑高效 是 PB 的特色,使用字段的序号做为标识,而不是包名类名(Java 的 Native Serialization 序列化后数据大就在于什么都一股脑放进去),使用 varint 和 zigzag 对整型作特殊处理。
PB 能够跨各类语言,可是前提是使用 IDL 编写描述文件,而后 codegen 工具生成各类语言的代码。
举个例子,有个 Person 对象,包含内容以下图所示,通过 PB 序列化后只有 33 个字节,能够对比 XML、JSON 或者 Java 的 Native Serialization 都会大很是多,并且序列化、反序列化的速度也不会很好。记住这个数据,后面 demo 的时候会有用。
(点击放大图像)
图片来源
https://www.slideshare.net/SergeyPodolsky/google-protocol-buffers-56085699
再举个例子,使用 Thrift 作一样的序列化,采用 Binary Protocol 和 Compact Protocol 的大小是不同的,可是 Compact Protocol 和 PB 虽然序列化的编码不同,可是一样是很是高效的。
(点击放大图像)
图片来源
https://www.slideshare.net/SergeyPodolsky/google-protocol-buffers-56085699
这里给一个 Uber 作的序列化框架比较
https://eng.uber.com/trip-data-squeeze/
能够看出 Protocol Buffers 和 Thrift 都是名列前茅的,可是这些 benchmark 看看就好,知道个大概,不必细究,由于样本数据、测试环境、版本等均可能会影响结果。
Socket 范畴里讨论的包叫作 Frame、Packet、Segment 都没错,可是通常把这些分别映射为数据链路层、IP 层和 TCP 层的数据包,应用层的暂时没有,因此下文没必要计较包怎么翻译。
协议结构,英文叫作 wire protocol 或者 wire format。TCP 只是 binary stream 通道,是 binary 数据的可靠搬用工,它不懂 RPC 里面包装的是什么。而在一个通道上传输 message,势必涉及 message 的识别。
举个例子,正以下图中的例子,ABC+DEF+GHI 分 3 个 message,也就是分 3 个 Frame 发送出去,而接收端分四次收到 4 个 Frame。
Socket I/O 的工做完成得很好,可靠地传输过去,这是 TCP 协议保证的,可是接收到的是 4 个 Frame,不是本来发送的 3 个 message 对应的 3 个 Frame。
这种状况叫作发生了 TCP 粘包和半包 现象,AB、H、I 的状况叫作半包,CDEFG 的状况叫作粘包。虽然顺序是对的,可是分组彻底和以前对应不上。
这时候应用层如何作语义级别的 message 识别是个问题,只有作好了协议的结构,才能把一整个数据片断作序列化或者反序列化处理。
通常采用的方式有三种:
方式 1:分隔符。
方式 2:换行符。好比 memcache 由客户端发送的命令使用的是文本行\r\n 作为 mesage 的分隔符,组织成一个有意义的 message。
图片来源
https://www.kancloud.cn/kancloud/essential-netty-in-action/52643
图中的说明:
方式 3:固定长度。RPC 常常采用这种方式,使用 header+payload 的方式。
好比 HTTP 协议,创建在 TCP 之上最普遍使用的 RPC,HTTP 头中确定有一个 body length 告知应用层如何去读懂一个 message,作 HTTP 包的识别。
在 HTTP/2 协议中,详细见 Hypertext Transfer Protocol Version 2 (HTTP/2)
https://tools.ietf.org/html/rfc7540
虽然精简了不少,加入了流的概念,可是 header+payload 的方式是绝对不能变的。
图片来源
https://tools.ietf.org/html/rfc7540
下面展现的是做者自研的一个 RPC 框架,能够在 github 上找到这个工程
neoremind/navi-pbrpc:
https://github.com/neoremind/navi-pbrpc
能够看出它的协议栈 header+payload 方式的,header 固定 36 个字节长度,最后 4 个字节是 body length,也就是 payload length,可使用大尾端或者小尾端编码。
RPC 框架不光要处理 Network I/O、序列化、协议栈。还有不少不肯定性问题要处理,这里的不肯定性就是由 网络的不可靠 带来的麻烦。
例如如何保持长链接心跳?网络闪断怎么办?重连、重传?链接超时?这些都很是的细碎和麻烦,因此说开发好一个稳定的 RPC 类库是一个很是系统和细心的工程。
可是好在工业界有一群人就致力于提供平台似的解决方案,例如 Java 中的 Netty,它是一个强大的异步、事件驱动的网络 I/O 库,使用 I/O 多路复用的模型,作好了上述的麻烦处理。
它是面向对象设计模式的集大成者,使用方只须要会使用 Netty 的各类类,进行扩展、组合、插拔,就能够完成一个高性能、可靠的 RPC 框架。
著名的 gRPC Java 版本、Twitter 的 Finagle 框架、阿里巴巴的 Dubbo、新浪微博的 Motan、Spark 2.0 RPC 的网络层(能够参考 kraps-rpc:https://github.com/neoremind/kraps-rpc)都采用了这个类库。
RPC 是须要让上层写业务逻辑来实现功能的,如何优雅地启停一个 server,注入 endpoint,客户端怎么连,重试调用,超时控制,同步异步调用,SDK 是否须要交换等等,都决定了基于 RPC 构建服务,甚至 SOA 的工程效率与生产力高低。这里不作展开,看各类 RPC 的文档就知道他们的易用性如何了。
上述列出来的都是如今互联网企业经常使用的解决方案,暂时不考虑传统的 SOAP,XML-RPC 等。这些是有网络资料的,实际上不少公司内部都会针对本身的业务场景,以及和公司内的平台相融合(好比监控平台等),自研一套框架,可是异曲同工,都逃不掉刚刚上面所列举的 RPC 的要考虑的各个部分。
为了使读者更好地理解上面所述的各个章节,下面作一个简单例子分析。使用 neoremind/navi-pbrpc:https://github.com/neoremind/navi-pbrpc 来作 demo,使用 Java 语言来开发。
假设要开发一个服务端和客户端,服务端提供一个请求响应接口,请求是 user_id,响应是一个 user 的数据结构对象。
首先定义一个 IDL,使用 PB 来作 Schema 声明,IDL 描述以下,第一个 Request 是请求,第二个 Person 是响应的对象结构。
而后使用 codegen 生成对应的代码,例如生成了 PersonPB.Request 和 PersonPB.Person 两个 class。
server 端须要开发请求响应接口,API 是 PersonPB.Person doSmth(PersonPB.Request req),实现以下,包含一个 Interface 和一个实现 class。
server 返回的是一个 Person 对象,里面的内容主要就是上面讲到的 PB 例子里面的。
启动 server。在 8098 端口开启服务,客户端须要靠 id=100 这个标识来路由到这个服务。
至此,服务端开发完毕,能够看出使用一个完善的 RPC 框架,只须要定义好 Schema 和业务逻辑就能够发布一个 RPC,而 I/O model、线程模型、序列化 / 反序列化、协议结构均由框架服务。
navi-pbrpc 底层使用 Netty,在 Linux 下会使用 epoll 作 I/O 多路复用,线程模型默认采用 Reactor 模式,序列化和反序列化使用 PB,协议结构见上文部分介绍的,是一个标准的 header+payload 结构。
下面开发一个 client,调用刚刚开发的 RPC。
client 端代码实现以下。首先构造 PbrpcClient,而后构造 PersonPB.Request,也就是请求,设置好 user_id,构造 PbrpcMsg 做为 TCP 层传输的数据 payload,这就是协议结构中的 body 部分。
经过 asyncTransport 进行通讯,返回一个 Future 句柄,经过 Future.get 阻塞获取结果而且打印。
至此,能够看出做为一个 RPC client 易用性是很简单的,同时可靠性,例如重试等会由 navi-pbrpc 框架负责完成,用户只须要聚焦到真正的业务逻辑便可。
下面继续深刻到 binary stream 级别观察,使用嗅探工具来看看 TCP 包。通常使用 wireshark 或者 tcpdump。
客户端的一次请求调用以下图所示,第一个包就是 TCP 三次握手的 SYN 包。
(点击放大图像)
根据 TCP 头协议,可看出来。
三次握手成功后,下面客户端发起了 RPC 请求,以下图所示。
(点击放大图像)
能够看出 TCP 包含了一个 message,由 navi-pbrpc 的协议栈规定的 header+payload 构成,
继续深刻分析 message 中的内容,以下图所示:
(点击放大图像)
其中
服务端响应 RPC 请求,仍是由 navi-pbrpc 的协议栈规定的 header+payload 构成,能够看出 body 就是 PB 例子里面的二进制数据。
(点击放大图像)
最后,客户端退出,四次分手结束。
本文系统性地介绍了 RPC 包含的核心概念和技术,带着读者从一个实际的例子去映射理解。不少东西都是走马观花,每个关键字都能成为一个很大的话题,但愿这个提纲挈领的介绍可让读者在大脑里面有一个系统的体系去看待 RPC。