[心得] RPC学习

深入浅出RPC
对两篇博文做一下笔记:
http://blog.youkuaiyun.com/mindfloating/article/details/39473807
http://blog.youkuaiyun.com/mindfloating/article/details/39474123

服务化和微服务化渐渐成为中大型分布式系统架构的主流方式,而 RPC 在其中扮演着关键的作用。
RPC 的全称是 Remote Procedure Call 是一种进程间通信方式。它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。

RPC优点:
1. 简单:RPC 概念的语义十分清晰和简单,这样建立分布式计算就更容易。
2. 高效:过程调用看起来十分简单而且高效。
3. 通用:在单机计算中过程往往是不同算法部分间最重要的通信机制。 

RPC选型需要考虑
1. 性能指标
2. 是否需要跨语言平台
3. 内网开放还是公网开放
4. 开源 RPC 框架本身的质量、社区活跃度

围绕 RPC 的功能目标和实现考量:
RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用,基于 stub 的结构来实现。

RPC调用分类
1. 同步调用  
   客户方等待调用执行完成并返回结果。  
2. 异步调用  
   客户方调用后不用等待执行结果返回,但依然可以通过回调通知等方式获取返回结果。  
   若客户方不关心调用返回结果,则变成单向异步调用,单向调用不用返回结果。

RPC 服务方通过 RpcServer 去导出(export)远程接口方法,而客户方通过 RpcClient 去引入(import)远程接口方法。客户方像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理RpcProxy 。代理封装调用信息并将调用转交给RpcInvoker 去实际执行。在客户端的RpcInvoker 通过连接器RpcConnector 去维持与服务端的通道RpcChannel,并使用RpcProtocol 执行协议编码(encode)并将编码后的请求消息通过通道发送给服务方。

RPC 服务端接收器 RpcAcceptor 接收客户端的调用请求,同样使用RpcProtocol 执行协议解码(decode)。解码后的调用信息传递给RpcProcessor 去控制处理调用过程,最后再委托调用给RpcInvoker 去实际执行并返回调用结果。

传输服务

协议编码之后,自然就是需要将编码后的 RPC 请求消息传输到服务方,服务方执行后返回结果消息或确认消息给客户方。RPC 的应用场景实质是一种可靠的请求应答消息流,和 HTTP 类似。因此选择长连接方式的 TCP 协议会更高效,与 HTTP 不同的是在协议层面我们定义了每个消息的唯一 id,因此可以更容易的复用连接。

既然使用长连接,那么第一个问题是到底 client 和 server 之间需要多少根连接?实际上单连接和多连接在使用上没有区别,对于数据传输量较小的应用类型,单连接基本足够。单连接和多连接最大的区别在于,每根连接都有自己私有的发送和接收缓冲区,因此大数据量传输时分散在不同的连接缓冲区会得到更好的吞吐效率。所以,如果你的数据传输量不足以让单连接的缓冲区一直处于饱和状态的话,那么使用多连接并不会产生任何明显的提升,反而会增加连接管理的开销。

连接是由 client 端发起建立并维持。如果 client 和 server 之间是直连的,那么连接一般不会中断(当然物理链路故障除外)。如果 client 和 server 连接经过一些负载中转设备,有可能连接一段时间不活跃时会被这些中间设备中断。为了保持连接有必要定时为每个连接发送心跳数据以维持连接不中断。心跳消息是 RPC 框架库使用的内部消息,在前文协议头结构中也有一个专门的心跳位,就是用来标记心跳消息的,它对业务应用透明。

本地调用和 RPC 调用的一些差异:
1. 本地调用一定会执行,而远程调用则不一定,调用消息可能因为网络原因并未发送到服务方。
2. 本地调用只会抛出接口声明的异常,而远程调用还会跑出 RPC 框架运行时的其他异常。
3. 本地调用和远程调用的性能可能差距很大,这取决于 RPC 固有消耗所占的比重。

由于 RPC 固有的消耗相对本地调用高出几个数量级,本地调用的固有消耗是纳秒级,而 RPC 的固有消耗是在毫秒级。那么对于过于轻量的计算任务就并不合适导出远程接口由独立的进程提供服务,只有花在计算任务上时间远远高于 RPC 的固有消耗才值得导出为远程接口提供服务。

对源码分析也做下笔记:
http://blog.youkuaiyun.com/iter_zc/article/details/39496439

Thrift的核心组件有:

TProtocol 协议和编解码组件

TTransport 传输组件

TProcessor 服务调用组件

TServer,Client 服务器和客户端组件

IDL 服务描述组件,负责生产跨平台客户端


Thrift的协议比较简单,它把协议和编解码整合在了一起。抽象类TProtocol定义了协议和编解码的顶层接口。TProtocol关联了一个TTransport传输对象,而不是提供一个类似getTransport()的接口,导致抽象类的扩展性比接口差。

TProtocol主要做了两个事情:

1. 关联TTransport对象

2.定义一系列读写消息的编解码接口,包括两类,一类是复杂数据结构比如readMessageBegin, readMessageEnd,  writeMessageBegin, writMessageEnd.还有一类是基本数据结构,比如readI32, writeI32, readString, writeString


Thrift的协议约定如下事情:

1. 先writeMessageBegin表示开始传输消息了,写消息头。Message里面定义了方法名,调用的类型,版本号,消息seqId

2. 接下来是写方法的参数,实际就是写消息体。如果参数是一个类,就writeStructBegin

3. 接下来写字段,writeFieldBegin, 这个方法会写接下来的字段的数据类型和顺序号。这个顺序号是Thrfit对要传输的字段的一个编码,从1开始
4. 如果是一个集合就writeListBegin/writeMapBegin,如果是一个基本数据类型,比如int, 就直接writeI32

5. 每个复杂数据类型写完都调用writeXXXEnd,直到writeMessageEnd结束

6. 读消息时根据数据类型读取相应的长度

TBinaryProtocol的实现。

1. writeMessgeBegin方法写了消息头,包括4字节的版本号和类型信息,字符串类型的方法名,4字节的序列号seqId

2. writeFieldBegin,写了1个字节的字段数据类型,和2个字节字段的顺序号

3. writeI32,写了4个字节的字节数组
4. writeString,先写4字节消息头表示字符串长度,再写字符串字节

5. writeBinary,先写4字节消息头表示字节数组长度,再写字节数组内容

6.readMessageBegin时,先读4字节版本和类型信息,再读字符串,再读4字节序列号

7.readFieldBegin,先读1个字节的字段数据类型,再读2个字节的字段顺序号
8. readString时,先读4字节字符串长度,再读字符串内容。字符串统一采用UTF-8编码


Thrift的不同版本定义IDL的语法也不太相同,这里使用Thrift-0.8.0这个版本来介绍Java下的IDL定义

1. namespace 定义包名

2. struct 定义服务接口的参数,返回值使用到的类结构。如果接口的参数都是基本类型,则不需要定义struct

3. service 定义接口


生成的类主要有5个部分

1. 接口类型,默认名称都是Iface。这个接口类型被服务器和客户端共同使用。服务器端使用它来做顶层接口,编写实现类。客户端代码使用它作为生成代理的服务接口。

自动生成的接口有两个,一个是同步调用的Iface,一个是异步调用的AsyncIface。异步调用的接口多了一个回调参数。

2. 客户端类型,一个同步调用的客户端Client,一个异步调用的客户端AsyncClient

3. Processor,用来支持方法调用,每个服务的实现类都要使用Processor来注册,这样最后服务器端调用接口实现时能定位到具体的实现类。后面会有专门的文章介绍

4.方法参数的封装类,以"方法名_args"命名

5.方法返回值的封装类,以"方法名_result”命名



看一下生成的同步调用客户端Client的具体代码

1. 提供一个工厂方法来创建Client对象

2.接口方法的客户端代理,只做了两件事,发送方法调用请求;接收返回值



发送方法调用请求做了2件事

1. 创建方法参数对象,封装方法参数

2. 调用父类的sendBase方法来发送消息。发送消息时先通过writeMessageBegin发送消息头,再调用方法参数对象的write(TProtocol)方法发送消息体,最后结束发送



接受调用返回值做了2件事

1. 创建方法返回值对象,封装方法返回值
2. 调用父类的receiveBase方法接收方法返回值。先通过receiveMessage接收消息体,处理异常,然后调用方法参数对象的read(TProtocol)方法来接收消息体,最后结束接收

方法参数对象主要做了2件事

1. 创建每个参数的元数据,包括参数类型,顺序号。顺序号是在IDL定义的时候设置的,用来识别参数的位置,在编解码的时候有用

2. 实现自己的编解码方法, read(TProtocol), write(TProtocol)。这里又把具体的编解码功能委托给了XXXScheme类


RPC调用本质上就是一种网络编程,客户端向服务器发送消息,服务器拿到消息之后做后续动作。只是RPC这种消息比较特殊,它封装了方法调用,包括方法名,方法参数。服务端拿到这个消息之后,解码消息,然后要通过方法调用模型来完成实际服务器端业务方法的调用。


Thrift的方法调用模型很简单,就是通过方法名和实际方法实现类的注册完成,没有使用反射机制,类加载机制。



和方法调用相关的几个核心类:

1. 自动生成的Iface接口,是远程方法的顶层接口
2. 自动生成的Processor类及相关父类,包括TProcessor接口,TBaseProcess抽象类

3. ProcessFunction抽象类,抽象了一个具体的方法调用,包含了方法名信息,调用方法的抽象过程等

4. TNonblcokingServer,是NIO服务器的默认实现,通过Args参数来配置Processor等信息
5. FrameBuffer类,服务器NIO的缓冲区对象,这个对象在服务器端收到全包并解码后,会调用Processor去完成实际的方法调用

6. 服务器端的方法的具体实现类,实现Iface接口

看TProcess相关类和接口
1. TProcessor就定义了一个顶层的调用方法process,参数是输入流和输出流

2. 抽象类TBaseProcessor提供了TProcessor的process的默认实现,先读消息头,拿到要调用的方法名,然后从维护的一个Map中取ProcessFunction对象。ProcessFunction对象是实际方法的抽象,调用它的process方法,实际是调用了实际的方法。

3. Processor类是自动生成了,它依赖Iface接口,负责把实际的方法实现和方法的key关联起来,放到Map中维护

非阻塞同步IO的NIO服务器都会使用缓冲区来存放读写的中间状态。FrameBuffer就是这样的一个缓冲区,它由于涉及到方法调用,所以提供了invoke()方法来实现对Processor的调用。
FrameBuffer是Thrift NIO服务器端的一个核心组件,它一方面承担了NIO编程中的缓冲区的功能,另一方面还承担了RPC方法调用的职责。



FrameBuffer读数据时,

1. 先读4字节的Frame消息头,

2. 然后改变FrameBufferState,从READING_FRMAE_SIZE到READING_FRAME,并根据读到的Frame长度修改Buffer的长度

3. 再次读Frame消息体,如果读完就修改状态到READ_FRAME_COMPLETE,否则还是把FrameBuffer绑定到SelectionKey,下次继续读

internalRead方法实际调用了SocketChannel来读数据。注意SocketChannel返回值小于0的情况:
n 有数据的时候返回读取到的字节数。
0 没有数据并且没有达到流的末端时返回0。
-1 当达到流末端的时候返回-1。

当Channel有数据时并且是最后的数据 时,实际会读两次,第一次返回字节数,第二次返回-1。这个是底层Selector实现的。

写缓冲时的情况
1. 写之前必须把FrameBuffer的状态改成WRITING,后面会有具体例子

2. 如果没写任何数据,就返回false

3. 如果写完了,就需要把SelectionKey注册的写事件取消。Thrift是直接把SelectionKey注册事件改成读了,而常用的做法一般是把写事件取消就行了。关于更多NIO写事件的注册问题,看这篇:http://blog.youkuaiyun.com/iter_zc/article/details/39291129

FrameBuffer提供了invoker方法,当读满包时,从消息头拿到要调用的方法,然后通过它管理的Processor来完成实际方法调用。然后切换到写模式来写消息体

具体的调用模型看这篇: http://blog.youkuaiyun.com/iter_zc/article/details/39692951

写消息体responseReday()方法时,我们看到Thrift是如何处理写的

1. 创建ByteBuffer

2. 修改状态到AWAITING_REGISTER_WRITE

3. 调用requestSelecInteresetChange()方法来注册Channel的写事件
4. 当Selector根据isWriteable状态来调用要写的Channel时,会调用FrameBuffer的write方法,上面说了write方法写满包后,会取消注册的写事件。

RPC作为一种特殊的网络编程,会封装一层传输层来支持底层的网络通信。Thrift使用了Transport来封装传输层,但Transport不仅仅是底层网络传输,它还是上层流的封装。

关于Transport的设计,从架构上看,IO流和网络流都是IO的范畴,用一个统一的接口来抽象并无不可。TTransport作为顶层的抽象,使用了抽象类,没有使用接口。个人感觉这种做法还是没有使用接口作为顶层抽象来得好,接口扩展性更好。




有几个关注点:

1. TIOStreamTransport和TSocket这两个类的结构对应着阻塞同步IO, TSocket封装了Socket接口

2. TNonblockingTrasnsort,TNonblockingSocket这两个类对应着非阻塞IO

3. TMemoryInputTransport封装了一个字节数组byte[]来做输入流的封装
4. TMemoryBuffer使用字节数组输出流ByteArrayOutputStream做输出流的封装

5. TFramedTransport则封装了TMemoryInputTransport做输入流,封装了TByteArryOutPutStream做输出流,作为内存读写缓冲区的一个封装。TFramedTransport的flush方法时,会先写4个字节的输出流的长度作为消息头,然后写消息体。和FrameBuffer的读消息对应起来。FrameBuffer对消息时,先读4个字节的长度,再读消息体

6. TFastFramedTransport是内存利用率更高的一个内存读写缓存区,它使用自动增长的byte[](不够长度才new),而不是每次都new一个byte[],提高了内存的使用率。其他和TFramedTransport一样,flush时也会写4个字节的消息头表示消息长度。

使用包装流和节点流的概念来区分一下各个Transport。

节点流表示自身采用byte[]来提供IO读写的类:

AutoExpandingBufferReadTransport

AutoExpandingBufferWriteTransport
TMemoryInputTransport

TByteArrayOutputStream
TMemoryBuffer

包装流表示封装了其他Transport,流来提供IO读写的类:

TFramedTransport

TFastFramedTransport



Thrift提供的包装流主要就是两个以TFrame开头的Transort,这两个Transport在写完消息flush的时候,会加上4字节表示长度的消息头,读消息是会先读4字节表示长度的消息头。



既然Thrift的NIO服务器端读消息时,使用了FrameBuffer来做缓冲区,并且解码时先读4字节长度的消息头,那么可以推断出,客户端发消息时,是使用TFramedXXXTransport包装流来传输数据的。

Thrift采用了TServer来作为服务器的抽象,提供了多种类型的服务器实现。用TServerTransport作为服务器的Acceptor抽象,来监听端口,创建客户端Socket连接

先来看看TServerTransport。主要有两类
1. TNonblockingServerTransport和TNonblockingServerSocket作为非阻塞IO的Acceptor,封装了ServerSocketChannel
2. TServerSocket作为阻塞同步IO的Acceptor,封装了ServerSocket

再看TServer的类层次结构,主要也是两类,非阻塞IO和同步IO

非阻塞IO的Server有:
1. TNonblockingServer是单线程的,只有一个SelectAcceptThread线程来轮询IO就绪事件,调用就绪的channel来相应Accept, Read, Write事件,并且还是使用这个线程来同步调用实际的方法实现。

2. THsHaServer是所谓的半同步半异步的服务器。所谓半同步是说使用一个SelectAcceptThread线程来轮询IO就绪事件,调用就绪的channel来相应Accept, Read, Write事件。所谓的半异步是说方法的调用是封装成一个Runnable交给线程池来执行的,交给线程池立刻返回,不同步等待方法执行完毕,方法执行完毕的写返回是有线程池中的线程来做的,实现了所谓的异步访问的模式。
3. TThreadSelectorServer,这个服务器类比较有意思,是多线程Reactor模式的一种实现。
3.1 采用了一个AcceptorThread来专门监听端口,处理Accept事件,然后创建SocketChannel。创建完成之后交给一个线程池来处理后续动作,将SocketChannel放到SelecotrThread的阻塞队列acceptedQueue中
3.2 采用多个SelectorThread来处理创建好的SocketChannel。每个SelectorThread绑定一个Selector,这样将SocketChannel分给多个Selector。同时SelectorThread又维护了一个阻塞队列acceptedQueue,从acceptedQueue中拿新创建好的SocketChannel,来注册读事件


同步的TServer有TThreadPoolServer,关联一个TServerSocket,采用同步IO的方式来Accept,然后交给一个线程池来处理后续动作

有一篇老外写的文章比较各种服务器的性能,https://github.com/m1ch1/mapkeeper/wiki/Thrift-Java-Servers-Compared

结论是TThreadSelectorServer在吞吐量和服务器响应时间的表现都是最优的
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值