RPC核心原理
Posted dinl_vin
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了RPC核心原理相关的知识,希望对你有一定的参考价值。
了解RPC
RPC全称Remote Procedure Call,即远程过程调用.其中远程需要跨机器,跨机器需要可靠的网络编程技术实现,无论是Java原生的网络编程模型还是Netty都会让代码中出现大量与业务无关的网络编程代码,RPC技术则是为了解决这个问题的.它帮助我们屏蔽网络编程的细节,实现调用远程方法和调用本地方法一样的体验,让我们更专注于业务逻辑的编写.
RPC通信流程
前文提及,RPC的作用是完成远程调用,我们将发起调用请求的那一方叫做调用方,被调用的叫做服务提供方,为了实现远程调用,一个完整的RPC会涉及哪些步骤呢?
RPC需要通过网络传输数据,并且通常用于系统之间的交互,所以需要保证可靠性,在网络协议上一般会采用TCP,常用的HTTP就是TCP协议之上的.
对于调用方来说,使用远程调用的是某段业务代码,对于一段面向对象的程序来说,调用请求出入参都会是对象,而网络传输的是二进制,所以需要有对象转字节再转对象的这么一套可逆转化机制,不难看出RPC需要一套"序列化"机制.
为了准确接收并解析调用方的请求,计算机通信通常会设计一个双方都能明白的"协议",比如消息长度多少,消息类型是什么,传入的参数是什么,需要调用什么方法等等.这些就是RPC的协议.
上述几个流程就是RPC的全部?
上述过程只是RPC基本通信原理或者说细节,看起来跟HTTP请求没什么两样,RPC还要求屏蔽网络编程的细节.否则就算不上一套易用的API.
我们知道Spring 的 AOP技术,通过动态代理的方式为用户屏蔽了很多细节,我们可以用AOP技术作权限校验,日志记录等功能,而使用的时候,往往只需要一个注解.那么这个动态代理技术,应用到RPC里,就可以解决封装网络编程的细节的作用.
一图言之
RPC的应用场景
RPC 框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远程方法。利用 RPC 我们不仅可以很方便地将应用架构从“单体”演进成“微服务化”,而且还能解决实际开发过程中的效率低下、系统耦合等问题,这样可以使得我们的系统架构整体清晰、健壮,应用可运维度增强
RPC在分布式系统和微服务系统中被大量应用.
RPC协议知多少
在工作开发中,我们可能遇到最多的就是HTTP协议,上图是HTTP协议的请求数据包.仔细看这个数据包,我们能认出很多"老朋友",‘GET’,‘HTTP/1.1’,‘Accept: */*’,'keep-alive’等等,即便是不包含请求体消息的HTTP数据包,也会包含这么多的信息.有了这些信息,接收方就可以按照约定的去作出相应的响应.
可以说协议规定了交互的语义和边界,按照边界分割,按照语义解析,才能保证一次正确的交互.
“为什么不直接用HTTP协议,而是要设计RPC协议呢”?
不直接使用HTTP协议,主要有以下原因:
- HTTP协议的数据包大小比请求本身要大很多,其中包含了很多无用的内容,浪费宝贵的网络带宽;
- HTTP协议是无状态协议,客户端没办法关联请求和响应,而且每次请求都要建立和关闭连接,效率较低.
那怎么设计一个合适的RPC协议呢?
协议设计主要考虑分为以下两种: 定长协议和不定长协议, 所谓定长协议,就是消息头长度固定:
一个完整的数据包一般包括消息头和消息体.消息头要尽量完整的包括一次消息的元数据的同时,尽可能少的使用存储空间,定长协议在保证协议完整性的同时,尽可能少的占用存储.
如下图是一张定长协议的例子:
- 8位的魔数一般是为了安全,像Java的 0xCAFEBABE一样,如果不是该魔数开头的消息直接过滤;
- 32位的整体长度,规定了消息的长度上限是0~2^32;
- 16位的消息ID,用于关联一次消息的请求和响应;
- 8位的协议版本用于迭代升级;
- 8位的消息类型,可以用于解码时的反射区分;
- 8位的序列化方式,允许使用不同的序列化算法;
- 还可以设计压缩算法等其他元信息.
如同Kafka v2版本消息的升级一般,单纯的定长协议有时候不能满足用户可变消息头的需求.所以还有一种不定长协议的版本,如下图
总结: 设计RPC协议的重点在于完整性,兼容性和降低资源消耗
序列化算法怎么选
网络传输是二进制字节,编程调用的是对象,所以一般需要有将对象转化成字节然后再把字节转化为对象的可逆机制,一个完整的RPC,会涉及两次序列化反序列化转换,在实际生产开发中,选择一种快速而稳定的序列化算法,也是一个好的RPC框架重要部分.
下面来盘点一下常用的序列化反序列化的机制:
JDK原生
jdk原生的序列化机制对于使用者而言比较简单,通过ObjectOutputStream序列化,在读取对象数据的时候加入特殊的分隔符和对象元数据信息,如果有继承或者对象引用就会递归写对象,再通过读取ObjectInputStream,按照分隔符切割,读取元数据信息生成对象,再写入对象完成反序列化.
优点: 使用简单,对象转换不存在偏差,性能比较好
缺点: 消息较大,存在安全漏洞,只能用于两端Java系语言的框架.
JSON
json可能是我们最熟悉的序列化机制了,可读性强,应用广泛都是它的优点,被大量用于前后端传输,还有基于HTTP的RPC框架中(一般基于HTTP的RPC框架,传输的数据量相对较小.)
优点: 使用简单, 可读性强, 适用于所有编程语言
缺点: 额外空间开销比较高, 而且Java这种强类型语言一般需要反射完成反序列化,性能不会太高.
Hessian
Hessian 是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。
优点: Hessian 协议要比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小,有非常好的兼容性和稳定性.
缺点: 不支持部分常见的对象类型:Liked系列需要扩展CollectionDeserializer;Locale需要扩展ContextSerializerFactory; Byte/Short 反序列化的时候变成 Integer。
Protobuf/Protostuff
Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持Java、Python、C++、Go等语言。Protobuf使用的时候需要定义 IDL(Interface description language),然后使用不同语言的IDL编译器,生成序列化工具类.
Protobuf非常高效,但是对于具有反射和动态能力的语言来说,这样用起来很费劲,这一点就不如Hessian,比如用Java的话,这个预编译过程不是必须的,可以考虑使用Protostuff。Protostuff不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反 / 序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java版本的Protobuf序列化框架.
缺点: 不支持null,不支持单纯的Map,List集合对象,需要包在对象里面
如何选用
考察原则: 安全性 > 通用性 > 兼容性 > 性能 > 效率 > 空间开销
基于上述原则,总结一下序列化算法选型: 首选的还是Hessian与Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中Hessian在使用上更加方便,在对象的兼容性上更好;Protobuf则更加高效,通用性上更有优势。
网络IO
说完了协议和序列化,再看一下RPC对于网络IO模型的选择:
常见的IO模型
阻塞IO(BIO)
同步阻塞IO是最简单,最常见的IO模型,Linux的默认Socket都是同步阻塞IO,阻塞是指发起请求之后,发起线程进入阻塞态,转到内核态等待数据,等到数据之后,再将内核态数据拷贝到用户内存,唤醒阻塞线程完成后续业务逻辑.
同步阻塞IO每个请求都需要占用一个线程到操作结束.使用简单但不适合高并发场景.
同步非阻塞IO(NIO)
与BIO不同的是,NIO的线程在发起请求后,会调用recvfrom尝试读取数据,并立即返回是否已经准备好数据,不会造成线程阻塞,通过多次调用recvfrom,最终会读取到想要的数据.
与BIO相比,NIO不会占用线程,但是每个线程都要多次调用recvfrom,会对服务器造成巨大压力.
IO多路复用
为了解决NIO带来的多个线程调用recvfrom的问题,recvfrom的时候采用了"复用",由一个线程来监视是否有数据准备好.具体就是所有发起调用的线程都注册到一个选择器(selector)上在Netty编程模型中对应bossGroup,该select轮询所有的socket,任何一个socket有数据,就分发该数据到指定线程,这就是Douge Lee的Reactor经典模型.总结起来就是专门一个selector解决所有recvfrom,有数据了分发到对应的socket.
IO多路复用模型是高并发场景下最常见的编程模型,Netty框架可以减少编写多路复用代码的难度.
AIO 异步非阻塞模型
异步模型是指,应用只用发送一次读取请求,其他的都由内核自动完成.这是一种操作简单,高效的IO模型,但是对操作系统要求比较高(Windows支持,但是常用于做服务器的Linux不支持),Netty5也尝试过但是没能实现所以目前最好用的高并发IO模型还是IO多路复用.
总结: RPC的出现多用于微服务分布式,一般对于高并发有一定要求,所以RPC最青睐的IO模型应该是IO多路复用.
动态代理
使用过Spring AOP的朋友们都知道,AOP可以通过代理类对bean进行功能增强,将统一拦截,授权认证,性能统计等非业务功能与业务代码解耦.
那动态代理在RPC中是怎么用上的呢?
在项目中,当我们要用到RPC的时候,一般做法是服务方提供一个接口模块,调用方通过Maven或Gradle将接口类引入到项目中,如果要调用远程方法,直接在业务中注入该接口即可.
对于原理图可以看出来,其实调用方在导入接口类之后,还会自动生成一个接口代理类,当我们注入接口调用的时候,运行时其实绑定的是接口的代理类,在代理类中完成请求的序列化反序列化和网络IO.
有哪些方法可以实现动态代理呢?
JDK默认的InvocationHandler
单纯从代理功能上来看,JDK 默认的代理功能是有一定的局限性的,它要求被代理的类只能是接口。原因是因为生成的代理类会继承Proxy类,如果被代理类不是接口,是没法多继承的.
虽说这个局限很重要,但是对于约定好了基于接口编程双方来说,这不是特别大的问题,其实JDK代理的最大问题是性能问题,因为代理类是通过反射来完成方法调用的,这比直接编码性能要差.
Javassist
相对 JDK 自带的代理功能,Javassist 的定位是能够操纵底层字节码,所以使用起来并不简单,要生成动态代理类恐怕是有点复杂了。但好的方面是,通过 Javassist 生成字节码,不需要通过反射完成方法调用,所以性能肯定是更胜一筹的。在使用中,我们要注意一个问题,通过 Javassist 生成一个代理类后,此 CtClass 对象会被冻结起来,不允许再修改;否则,再次生成时会报错。
ByteBuddy
Byte Buddy 则属于后起之秀,在很多优秀的项目中,像 Spring、Jackson 都用到了 Byte Buddy 来完成底层代理。相比 Javassist,Byte Buddy 提供了更容易操作的 API,编写的代码可读性更高。更重要的是,生成的代理类执行速度比 Javassist 更快。
面试官:RPC的核心原理了解么?
搞懂 RPC 的原理还是挺重要的!我之前为了深入研究 RPC 的原理,还自己动手写了。关注我公号的大部分小伙伴应该都知道。
一年不到,这个项目的 star 数量就达到了 1.5k, 有 700 位小伙伴 fork 了这个项目。
最近一朋友和我吐槽,说看到几个不错的简历,但一面试发现水分太大。简历上写着“熟练掌握 RPC 框架”,问了几个问题“大概说下 RPC 框架的核心原理”“描述下序列化部分的逻辑”,聊了半天,发现他都是在说怎么用,而不懂底层原理。
以上是关于RPC核心原理的主要内容,如果未能解决你的问题,请参考以下文章