本人最早接触RPC是在豚厂实习的时候,当时只是照着“mentor”的指导去调用别人的服务,只知道豚厂的RPC服务是基于Facebook的Thrift框架。 对于底层的原理没太理解。现在工作后用的是鹅厂Taf (开源项目项目叫Tars)提供的RPC解决方案。对RPC有了大致了解,在此做一下总结。
其实不管你在哪家大型互联网公司,都会用到RPC框架,而且他们用的RPC框架都是有很多共性在里面。
本文会通过一个简单的例子大致讲一下RPC用的最核心概念和技术,但并不对这些技术深入太多。
下图列出的是关于RPC涉及的相关技术。
张旭大神在体系化认识RPC中有对这些技术点的介绍。
什么是RPC
RPC 全称 Remote Procedure Call——远程过程调用。在学校学编程,我们写一个函数都是在本地调用就行了。但是在互联网公司,服务都是部署在不同服务器上的分布式系统,如何调用呢?
RPC技术简单说就是为了解决远程调用服务的一种技术,使得调用者像调用本地服务一样方便透明。
下图是客户端调用远端服务的过程:
1、客户端client发起服务调用请求。
2、client stub 可以理解成一个代理,会将调用方法、参数按照一定格式进行封装,通过服务提供的地址,发起网络请求。
3、消息通过网络传输到服务端。
4、server stub接受来自socket的消息
5、server stub将消息进行解包、告诉服务端调用的哪个服务,参数是什么
6、结果返回给server stub。
7、sever stub把结果进行打包交给socket
8、socket通过网络传输消息
9、client slub 从socket拿到消息。
10、client stub解包消息将结果返回给client。
一个RPC框架就是把步骤2到9都封装起来。
为什么需要RPC
1、首先要明确一点:RPC可以用HTTP协议实现,并且用HTTP是建立在 TCP 之上最广泛使用的 RPC,但是互联网公司往往用自己的私有协议,比如鹅厂的JCE协议,私有协议不具备通用性为什么还要用呢?因为相比于HTTP协议,RPC采用二进制字节码传输,更加高效也更加安全。
2、现在业界提倡“微服务“的概念,而服务之间通信目前有两种方式,RPC就是其中一种。RPC可以保证不同服务之间的互相调用。即使是跨语言跨平台也不是问题,让构建分布式系统更加容易。
3、RPC框架都会有服务降级、流量控制的功能,保证服务的高可用。
一个简单例子
下面就举一个1+1=2
的远程调用的例子。客户端发送两个参数,服务端返回两个数字的相加结果。RpcConsumer
类调用CalculateService
中的Calculate
方法, 首先通过RpcFramework
中的call
方法,注册自己想要调用那个服务,返回代理,然后就像本地调用一样去调用Calculate
方法,计算People
,````People```有两个属性都被赋值成1,返回这两个属性相加后的结果。
public class RpcConsumer {
public static void main(String args[]) {
CalculateService service=RpcFramework.call(CalculateService.class,"127.0.0.1",8888);
People people=new People(1,1);
String hello=service.Calculate(people);
System.out.println(hello);
}
}
生成动态代理的代码如下。客户端在调用方法前会先执行invoke
方法,建立socket连接,把方法名和参数传递给服务端,然后获取返回结果。
//动态代理机制
public static T call(final Class> interfaceClass,String host,int port){
if(interfaceClass==null){
throw new IllegalArgumentException("调用服务为空");
}
if(host==null||host.length()==0){
throw new IllegalArgumentException("主机不能为null");
}
if(port<=0||port>65535){
throw new IllegalArgumentException("端口不合法"+port);
}
return (T)Proxy.newProxyInstance(interfaceClass.getClassLoader(),new Class>[]{interfaceClass},new CallerHandler(host,port));
}
static class CallerHandler implements InvocationHandler {
private String host;
private int port;
public CallerHandler(String host, int port) {
this.host = host;
this.port = port;
SERVER = new InetSocketAddress(host, port);
}
public Object invoke(Object proxy, Method method, Object[] arguments) throws Throwable {
Socket socket = new Socket(host, port);
try {
ObjectOutputStream output = new ObjectOutputStream(socket.getOutputStream());
try {
output.writeUTF(method.getName());
output.writeObject(method.getParameterTypes());
output.writeObject(arguments);
ObjectInputStream input = new ObjectInputStream(socket.getInputStream());
try {
Object result = input.readObject();
if (result instanceof Throwable) {
throw (Throwable) result;
}
return result;
} finally {
input.close();
}
} finally {
output.close();
}
} finally {
socket.close();
}
}
}
RpcProvider
类实现具体的Calculate
方法。通过RpcFramework
中的publish
方法,发布自己的服务。
public class RpcProvider{
public static void main(String[] args) throws Exception {
CalculateService service =new CalculateServiceImpl();
RpcFramework.publish(service,8888);
}
}
interface CalculateService{
String Calculate(People p);
}
class CalculateServiceImpl implements CalculateService{
public String Calculate(People people){
int res=people.getA()+people.getB();
return "计算结果 "+res;
}
}
发布服务的代码如下。服务端循环监听某个端口,采用java原生的序列化方法,读取客户端需要调用的方法和参数,执行该方法并将结果返回。
public static void publish(final Object service,int port) throws IOException {
if(service==null)
throw new IllegalArgumentException("发布服务不能是空");
if(port<=0 || port >65535)
throw new IllegalArgumentException("端口不合法"+port);
ServerSocket server=new ServerSocket(port);
while (true) {
try{
final Socket socket=server.accept();
new Thread(new Runnable() {
@Override
public void run() {
try {
try {
ObjectInputStream input = new ObjectInputStream(socket.getInputStream());
try {
String methodName = input.readUTF();
Class>[] parameterTypes = (Class>[]) input.readObject();
Object[] arguments = (Object[]) input.readObject();
ObjectOutputStream output = new ObjectOutputStream(socket.getOutputStream());
try {
Method method = service.getClass().getMethod(methodName, parameterTypes);
Object result = method.invoke(service, arguments);
output.writeObject(result);
} catch (Throwable t) {
output.writeObject(t);
} finally {
output.close();
}
} finally {
input.close();
}
} finally {
socket.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}catch(Exception e){
e.printStackTrace();
}
}
}
可以看到正确返回计算结果2。
以上就是一个简单的RPC例子,下面我们看一下如何优化这个例子。
序列化和I/O模型的优化
- 数据序列化:
什么是序列化?序列化就是编码的过程,把对象或者数据结构转化成二进制字节码的过程。而反序列化就是把二进制字节码转化成数据结构或者对象。只有经过序列化后的数据才能在网络中传输。 - I/O模型:
客户端和服务端的通信依赖Socket I/O。I/O 模型又可以分为:传统的阻塞 I/O(Blocking I/O)、非阻塞 I/O(Non-blocking I/O)、I/O 多路复用(I/O multiplexing)、异步 I/O(Asynchronous I/O)
下面针对上面的两个技术点进行优化。
用Protobuf优化数据序列化
数据序列化方法有很多种方法,常见的有Avro,Thrift,,XML,JSON,Protocol Buffer等工具。本文主要介绍的是Protobuf。Protobuf 全称““Protocol Buffer”” 是google 推出的高性能序列化工具。现已经在Github上开源。Protobuf采用tag来标识字段序号,用varint 和zigzag两种编码方式对整形做特殊的处理,Protobuf序列化后的数据紧凑,而且序列化时间短。下面两张图分别是Uber对不同的序列化框架做的比较结果。
从上面两张看出从数据压缩和时间维度上看,Protobuf 和 Thrift的性能都是非常优秀的。至于如何做到这么好的性能,本文不在这边细究,有兴趣的同学可以参考图解Protobuf编码以及Protocol Buffer 序列化原理大揭秘。
用Netty优化I/O模型
网络通信中I/O模型可以大致分为以下四种(准确说是5种,这里不讨论信号驱动I/O,因为在真正的编程中,我们很少使用这种模型):
1、阻塞I/O
2、非阻塞I/O
3、I/O多路复用
4、异步I/O
我们知道I/O处理是非常耗时的,CPU的处理速度非常快,如何最大化的利用CPU的性能,就是要避免线程阻塞在I/O处理上。业界目前比较多的采用I/O多路复用和异步I/O提高性能。
如何理解这四种I/O模型,大家可以参照银行业务办理例子。
Netty 正是采用了第三种 I/O多路复用的方法,I/O多路复用对应Reactor模式。Reactor把耗时的网络操作编码交给专门的线程或者线程池去处理。比如下面这张图是Reactor模式示意图。图中mainReactor线程、subReactor线程、work线程这些不同的线程,分别干不同专业的事情,吞吐量自然上去了。
这里要再多说一句异步I/O。前面提到的三种I/O模型都归属于同步I/O,用户发起I/O请求后需要等待或者轮询内核I/O的完成。我现在用的是PHP中的swoole框架, 一款异步网络通信框架。当时我第一次听到异步I/O的感到很奇怪,因为之前看到有些文章里都有说到异步I/O往往对应的是Proactor模式,而Proactor在Linix中没有很好的实现。那么Swoole是如何实现异步I/O。这里就要提协程的概念了。协程可以理解为用户态的线程,他有两个特点:1、占用的资源更少。2、所有切换和调度都发生在用户态。Swoole底层就是借鉴了Go语言的协程,而Go语言之所有能受到关注和部分青睐,也是因为他引入了协程。这里特别要推荐知乎专栏里的协程,高并发IO的终级杀器的文章,通过简单的例子帮你理解协程。
那为什么协程可以提升IO效率?传统的阻塞IO模型中,一个线程在处理IO请求时就被阻塞了,不能再去处理其他IO请求,而服务器创建线程的数量是有限的(线程消耗比较高的内存资源),一个服务器能处理多少个客户端的连接又取决于可以创建多少个线程,这也是造成传统阻塞IO模型不能支持高并发的原因。协程提供了另一种角度去解决高并发问题:把线程占用的资源降下来。所以协程是十分轻量的,只有线程的几十分之一,通过创建更多的协程实现同步的写法。这里多说一句,目前Java对协程的支持可以通过开源的协程库Quasar实现,不过看到消息说Oracle已经在准备把协程引入到新的Java版本中。
优化结果的比较
下面给出的是压测代码,parallel
变量控制并发的请求数。对阻塞I/O模型+java原生序列化方法压测。
public static void main(String[] args) throws Exception {
//并行度10
int parallel = 10;
//开始计时
StopWatch sw = new StopWatch();
sw.start();
CountDownLatch signal = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(parallel);
for (int index = 0; index < parallel; index++) {
CalcParallelRequestThread client = new CalcParallelRequestThread(signal, finish, index);
new Thread(client).start();
}
//10个并发线程瞬间发起请求操作
signal.countDown();
finish.await();
sw.stop();
String tip = String.format("RPC调用总共耗时: [%s] 毫秒", sw.getTime());
System.out.println(tip);
}
下图是并发请求数是10的时候,返回了10个结果,QPS大致在181。
下图是将
parallel
调整到100。可以看到部分请求开始报错了。不能拿到正确结果。
优化后并发10000个请求,QPS高达10214。
我们用tcpdump抓包工具看一下具体的TCP包
可以看到client的端口是51332,Server端口8090。发送了“beidou”字符串给Server, 对应到代码中
Provider
属性。代码中的temp1 和temp2都是字符串“1”。Server返回相加结果1。
让我们看一下Server的返回结果
0200 0000 一共32位表示body 长度为2个字节。 08 表示tag为采用Protobuf Variant 编码, 02表示值为2。
总结
本文只是通过一个简单例子介绍了RPC中的I/O模型以及序列化,其实RPC本身是一个很大的话题,比如如何保证在不可靠的网络中保证RPC的可靠性?如何实现客户端的重试调用、超时控制?如何优雅的起停服务、发现与注册服务?还有很多问题值得大家研究学习,这里不做过多探讨了。虽然国内的阿里巴巴、腾讯、新浪,国外的Google、Facebook都有自己的RPC框架,但是他们都绕不开之前需要考虑的那些技术点。
PS:比较遗憾高校并没有提供RPC相关的课程,建议大家在去大公司之前先了解一下RPC,在脑海中形成一个体系,最后祝大家新年快乐!