- 《架构师》2017年11月
- InfoQ中文站
- 2020-06-26 06:04:10
理论派 | Theory
体系化认识RPC
RPC(Remote Procedure Call),即远程过程调用,是一个分布式系统间通信的必备技术,本文体系性地介绍了RPC包含的核心概念和技术,希望读者读完文章,一提到RPC,脑中不是零碎的知识,而是具体的一个脑图般的体系。本文并不会深入到每一个主题剖析,只做提纲挈领的介绍。
RPC最核心要解决的问题就是在分布式系统间,如何执行另外一个地址空间上的函数、方法,就仿佛在本地调用一样,个人总结的RPC最核心的概念和技术包括如下,如图所示。
下面依次展开每个部分。
传输(Transport)
TCP协议是RPC的基石,一般来说通信是建立在TCP协议之上的,而且RPC往往需要可靠的通信,因此不采用UDP。
这里重申下TCP的关键词:面向连接的,全双工,可靠传输(按序、不重、不丢、容错),流量控制(滑动窗口)。
另外,要理解RPC中的嵌套header+body,协议栈每一层都包含了下一层协议的全部数据,只不过包了一个头而已,如下图所示的TCP segment包含了应用层的数据,套了一个头而已。
那么RPC传输的message也就是TCP body中的数据,这个message也同样可以包含header+body。body也经常叫做payload。
TCP就是可靠地把数据在不同的地址空间上搬运,例如在传统的阻塞I/O模型中,当有数据过来的时候,操作系统内核把数据从I/O中读出来存放在kernal space,然后内核就通知user space可以拷贝走数据,用以腾出空间,让TCP滑动窗口向前移动,接收更多的数据。
TCP协议栈存在端口的概念,端口是进程获取数据的渠道。
I/O模型(I/O Model)
做一个高性能/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网络编程》中就有提到了,它们分别是:
1. 传统的阻塞I/O(Blocking I/O)
2. 非阻塞I/O(Non-blocking I/O)
3. I/O多路复用(I/O multiplexing)
4. 异步I/O(Asynchronous I/O)
这里不细说每种I/O模型。这里举一个形象的例子,读者就可以领会这四种I/O的区别,就用银行办业务这个生活的场景描述。
下图是使用传统的阻塞I/O模型。一个柜员服务所有客户,可见当客户填写单据的时候也就是发生网络I/O的时候,柜员,也就是宝贵的线程或者进程就会被阻塞,白白浪费了CPU资源,无法服务后面的请求。
下图是上一个的进化版,如果一个柜员不够,那么就并发处理,对应采用线程池或者多进程方案,一个客户对应一个柜员,这明显加大了并发度,在并发不高的情况下性能够用,但是仍然存在柜员被I/O阻塞的可能。
下图是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操作完成后会通知用户线程来调用其回调函数。
进程/线程模型(Thread/Process Model)
进程/线程模型往往和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。
以及这篇paper。关于Proactor可以参考这篇paper。
说个具体的例子,Thrift作为一个融合了序列化+RPC的框架,提供了很多种Server的构建选项,从名称中就可以看出他们使用哪种I/O和线程模型。
Schema和序列化(Schema & Data Serialization)
当I/O完成后,数据可以由程序处理,那么如何识别这些二进制的数据,是下一步要做的。序列化和反序列化,是做对象到二进制数据的转换,程序是可以理解对象的,对象一般含有schema或者结构,基于这些语义来做特定的业务逻辑处理。
考察一个序列化框架一般会关注以下几点:
• Encoding format。是human readable还是binary。
• Schema declaration。也叫作契约声明,基于IDL,比如Protocol Buffers/Thrift,还是自描述的,比如JSON、XML。另外还需要看是否是强类型的。
• 语言平台的中立性。比如Java的Native Serialization就只能自己玩,而Protocol Buffers可以跨各种语言和平台。
• 新老契约的兼容性。比如IDL加了一个字段,老数据是否还可以反序列化成功。
• 和压缩算法的契合度。跑benchmark和实际应用都会结合各种压缩算法,例如gzip、snappy。
• 性能。这是最重要的,序列化、反序列化的时间,序列化后数据的字节大小是考察重点。
序列化方式非常多,常见的有Protocol Buffers, Avro, Thrift, XML, JSON, MessagePack, Kyro, Hessian, Protostuff, Java Native Serialize, FST。
下面详细展开Protocol Buffers(简称PB),看看为什么作为工业界用得最多的高性能序列化类库,好在哪里。
首先去官网查看它的Encoding format。
紧凑高效是PB的特点,使用字段的序号作为标识,而不是包名类名(Java的Native Serialization序列化后数据大就在于什么都一股脑放进去),使用varint和zigzag对整型做特殊处理。
PB可以跨各种语言,但是前提是使用IDL编写描述文件,然后codegen工具生成各种语言的代码。
举个例子,有个Person对象,包含内容如下图所示,经过PB序列化后只有33个字节,可以对比XML、JSON或者Java的Native Serialization都会大非常多,而且序列化、反序列化的速度也不会很好。记住这个数据,后面demo的时候会有用。
再举个例子,使用Thrift做同样的序列化,采用Binary Protocol和Compact Protocol的大小是不一样的,但是Compact Protocol和PB虽然序列化的编码不一样,但是同样是非常高效的。
这里给一个Uber做的序列化框架比较。
可以看出Protocol Buffers和Thrift都是名列前茅的,但是这些benchmark看看就好,知道个大概,没必要细究,因为样本数据、测试环境、版本等都可能会影响结果。
协议结构(Wire Protocol)
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。
• 方式3:固定长度。RPC经常采用这种方式,使用header+payload的方式。
比如HTTP协议,建立在TCP之上最广泛使用的RPC, HTTP头中肯定有一个body length告知应用层如何去读懂一个message,做HTTP包的识别。
在HTTP/2协议中,详细见Hypertext Transfer Protocol Version 2 (HTTP/2)。
虽然精简了很多,加入了流的概念,但是header+payload的方式是绝对不能变的。
下面展示的是作者自研的一个RPC框架,可以在github上找到这个工程。
neoremind/navi-pbrpc:
可以看出它的协议栈header+payload方式的,header固定36个字节长度,最后4个字节是body length,也就是payload length,可以使用大尾端或者小尾端编码。
可靠性(Reliability)
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)都采用了这个类库。
易用性(Ease of use)
RPC是需要让上层写业务逻辑来实现功能的,如何优雅地启停一个server,注入endpoint,客户端怎么连,重试调用,超时控制,同步异步调用,SDK是否需要交换等等,都决定了基于RPC构建服务,甚至SOA的工程效率与生产力高低。这里不做展开,看各种RPC的文档就知道他们的易用性如何了。
工业界的RPC框架一览
国内
• Dubbo。来自阿里巴巴http://dubbo.I/O
• Motan。新浪微博自用https://github.com/weibocom/motan
• Dubbox。当当基于dubbo的https://github.com/dangdangdotcom/dubbox
• rpcx。基于Golang的https://github.com/smallnest/rpcx
• Navi&Navi-pbrpc。作者开源的https://github.com/neoremind/navi https://github.com/neoremind/navi-pbrpc
国外
• Thrift from facebook https://thrift.apache.org
• Avro from hadoop https://avro.apache.org
• Finagle by twitter https://twitter.github.I/O/finagle
• gRPC by Google http://www.grpc.I/O (Google inside use Stuppy)
• Hessian from cuacho http://hessian.caucho.com
• Coral Service inside amazon (not open sourced)
上述列出来的都是现在互联网企业常用的解决方案,暂时不考虑传统的SOAP, XML-RPC等。这些是有网络资料的,实际上很多公司内部都会针对自己的业务场景,以及和公司内的平台相融合(比如监控平台等),自研一套框架,但是殊途同归,都逃不掉刚刚上面所列举的RPC的要考虑的各个部分。
Demo展示
为了使读者更好地理解上面所述的各个章节,下面做一个简单例子分析。使用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头协议,可看出来。
• ff 15 = 65301是客户端的端口
• 1f a2 = 8098是服务端的端口
• header的长度44字节是20字节头+20字节option+padding构成的。
三次握手成功后,下面客户端发起了RPC请求,如下图所示。
可以看出TCP包含了一个message,由navi-pbrpc的协议栈规定的header+payload构成,
继续深入分析message中的内容,如下图所示:
其中
• 61 70 = ap是头中的的provider标识
• body length是2,注意navi-pbrpc采用了小尾端。
• payload是08 7f,08在PB中理解为第一个属性,是varint整型,7f表示传输的是127这个整型。
服务端响应RPC请求,还是由navi-pbrpc的协议栈规定的header+payload构成,可以看出body就是PB例子里面的二进制数据。
最后,客户端退出,四次分手结束。
总结
本文系统性地介绍了RPC包含的核心概念和技术,带着读者从一个实际的例子去映射理解。很多东西都是蜻蜓点水,每一个关键字都能成为一个很大的话题,希望这个提纲挈领的介绍可以让读者在大脑里面有一个系统的体系去看待RPC。
欢迎访问作者的博客。
作者介绍
张旭,目前工作在Hulu,从事Big data领域的研发工作,曾经在百度ECOM和程序化广告部从事系统架构工作,热爱开源,在github贡献多个开源软件,id:neoremind,关注大数据、Web后端技术、广告系统技术以及致力于编写高质量的代码。