RPC 轮子源码地址:https://github.com/killlowkey/rpc-server
RPC(remote produce call)是一个计算机协议,广泛用于分布式系统。该协议允许当前计算机程序去调用其它计算机的程序,就像调用本地方法一样,用户无需关注底层实现的细节。
最近造了个 RPC 框架轮子,通过造轮子来学习 RPC 。并阅读开源 RPC 框架源码,借鉴其优秀的设计,来发现自己设计不足,从而提升工程能力。
造完轮子后,对其整体设计进行复盘,自己得到了些感悟,通过这篇文章来记录 RPC 框架设计思路。
服务注册/发现
由一个大型的单体系统向微服务改造,首先要进行模块的拆分,拆分后模块数量少则几十个,多则上百个。进行微服务部署时,这些模块会被当成单体应用进行部署,每个模块就是一个服务,一个服务中部署多个应用。当服务 A 去调用服务 B,首先我们得知道服务 B 的地址,建立起 TCP 链接,才可以进行 RPC 调用。
此时,会引发一个问题,我们应该如何去管理这些服务呢?当服务数量比较少时,可以通过配置的方式,指定服务 B所有的地址,在服务 A进行调用时,从配置中获取服务 B 地址。这种方式可以解决部分问题,但当服务增多时,配置会很繁琐,而且我们无法从配置中对服务进行动态的摘除和增加。
服务动态摘除和增加是分布式系统中核心的功能,当服务 B 流量太高,此时就需要对服务 B 进行动态的扩容,简而言之就是通过加机器的方式,新增的机器会加入到集群,从而对外部提供服务。服务进行升级时,首先需要从集群中将该服务部分应用进行摘除,也就是说这部分应用停止对外进行服务,升级之后在注册到集群中,以此类推从而完成整个服务的升级。
最优解是采用服务中心来对整个集群服务进行管理,当服务启动时向服务中心注册,服务调用时从服务中心拉取对应的服务地址,常见的服务中心有:
- Zookeeper
- Eurek
- Naco
- Consul

服务中心需要保持高可用,否则服务中心挂掉,会导致整个集群都无法工作,因为进行服务调用时,不知道调用服务地址。所以服务中心需要部署多个,但又会引发数据一致性问题。数据一致性一直是分布式系统热点,目前可以归为 AP、CP 两派,主要是在可用性和一致性进行抉择,当服务中心需要管理的服务很多时,AP 的性能要优于 CP,因为 CP 系统同步开销要高于 AP。
当然,也可以在应用层面来预防服务中心挂掉问题,从服务中心拉取服务后,将所有的服务缓存到本地。后续能从服务中心正常拉取服务的话,则对缓存的服务进行更新,当服务中心挂了,从本地拿取服务的地址。这种设计,可以保证服务中心挂了,还可以让集群正常的工作。
健康检查
当应用引入注册中心的依赖,会向外部暴露一个健康检查的接口,注册中心向服务定时的发送心跳进行健康检查。所谓的健康检查就是判断该服务是否能正常的提供服务,倘若无法服务,那么从注册中心将该服务进行摘除,然后通知其它的服务。
注册中心进行健康检查时,发生网络波动,没有接收到客户端(服务)的心跳,此时不应该判定该服务挂了,而是标记为不健康的状态,后续还一直无法收到心跳时,则从注册中心进行移除。进行网络分区时,当前的注册中心无法与该客户端进行通讯,所以一直收不到该客户端的心跳,但是其它的注册中心能正常收到该客户端心跳,注册中心需要进行协商,来判断该客户端是否需要下线。如果贸然将客户端下线的话,会影响整个集群的性能。
负载均衡
负载均衡是分布式系统高可用的基础组件,用于将工作负载分布到不同的服务器,简言之就是将连接分布到不同的服务器进行处理。
对于 RPC 实现负载均衡,该服务需要连接到注册中心,拉取特定服务的服务器地址。进行服务调用时,采用负载均衡算法从服务列表中选择,当注册中心添加或摘除服务时,会进行一个 push 操作,本地根据特定的事件来对本地服务列表进行更改。
特定服务:服务A只需要调用服务B与服务C,此时只需要从注册中心拉取服务B与服务C的节点即可
下面,介绍几个目前常用负载均衡算法
- 轮训:第一个连接对应列表第一个服务器,以此类推,直到到达列表尾部,然后循环
- 随机:随机从列表中选择一个服务
- 最少连接:选择连接数量最少的服务
- Hash:对请求源IP进行 Hash 运算,然后对列表进行取余操作,这种方式在一定程度上保证特定用户连接到同一台服务器
序列化
网络进行通讯时,通过字节来传输数据。在应用层面上,将对象转成字节数据,称为序列化;从字节数据转成对象,称为反序列化。流行的 RPC 框架,一般都会采用多种序列化框架,简言之支持多种序列化方式。因为不同的序列化适用的场景不同,可以根据业务场景来对序列化方式进行调整,比如进行跨平台使用,可以采用 Protobuf 序列化。
当然 RPC 框架不一定要去兼容多种序列化方式,兼容只是让系统更加具有扩展性。如果为了实现简单的话,可以仅采用 JSON 序列化。
下面对几个常用的序列化框架进行介绍。
JSON
目前最简单最通用的序列化协议,使用广泛,开发效率高,性能相对较低,维护成本较高。
序列化后体积大,影响高并发。并且数据类型单一,在反序列化时影响数据的精度。
Protobuf
Goole 提供的序列化框架,简单快速上手,支持跨平台使用,拥有高效的兼容性。对字段进行编码,新添加的字段不会影响老结构,解决了向后兼容的问题。支持序列化与 RPC 一站式解决。采用 IDL 接口定义语言,自动生成多语言代码。
序列化后二进制格式,可读性比较差,不便于调试分析。对象冗余,字段很多,生成的类较大,占用空间。
Thrift
Facebook 提供的序列化框架,跨语言,实现简单。支持序列化与 RPC 一站式解决。采用 IDL 接口定义语言,自动生成多语言代码。
Thrift 与 Protobuf 提供的功能类似,缺点也相同,同样采用二进制格式,不便于阅读。并且 Thrift 开发环境,编译比较麻烦。
网络通讯
谈及 RPC 框架设计,一定绕不过网络通讯。采用合理的 IO 模型,可以极大的提高框架的性能。在 Java 平台上,比较流行的网络框架是 Netty,Netty 对 Java NIO 进行封装,屏蔽了 NIO 实现的复杂性,提供了简易的接口。
下面对三大 IO 模型进行介绍,从而引入 Reactor 模型,最后采用 Netty 作为 RPC 网络通讯框架。
BIO、NIO、AIO
BIO 是同步阻塞的模型,当服务器接收到连接后,会创建一个新的线程来处理,如果连接不进行读写时,该线程会进行阻塞,从而浪费线程资源。当连接数量不多时,并且数据传输的很频繁,此时 BIO 是一个很好选择,可以充分的利用线程的性能,相比 NIO ,可以更快的处理连接的数据,因为 NIO 需要对多个连接进行处理。

对连接读写操作是阻塞的,当没有数据可读时,线程就会阻塞。线程的创建和释放需要性能开销,为了解决 BIO 问题,引入了 同步非阻塞 NIO,NIO 采用多用复用的机制,一个线程处理多个连接,当连接可进行读写时,则进行处理。在 Netty 中默认使用的是 NIO,这种模型可以应对更高的并发,在真实场景中,并不是所有的连接都在频繁的传输数据,大部分的连接还是处于空闲状态。

AIO 是异步模型,引入异步通道概念,采用了 Proactor 模式,用户通过注册数据处理的 callback,之后对连接数据的读写都是由操作系统来完成。目前这种模型并未被广泛的使用,Linux 默认采用的还是 NIO 模型。
谈到 IO 模型,需要了解同步、异步、阻塞、非阻塞概念,才能更好理解每种 IO 模型的优缺点,从而在正确场景应用。
- 同步:用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行
- 异步:用户线程发起I/O请求后仍需要继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数
- 阻塞:I/O操作彻底完成后才能返回用户空间
- 非阻塞:I/O操作被调用后返回一个状态值,无需等I/O操作完成
Reactor 模型
Reactor 是 Douglas C. Schmidt 发表的 An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events 论文中提出的模型。这种模型采用了 Boss-Worker 机制,在 Boss 里面只含有一个线程,而 Worker 采用线程池的机制。前者用于接收客户端连接,后者用于处理连接,它们底层都是采用了多路复用的机制。Boss 接收到连接之后,会对 Worker 中的线程进行轮训操作,注册连接到指定的 Worker 线程,该连接的生命就与该线程进行绑定了,直到该连接关闭,后续都是由绑定线程处理。

这种设计可以使得系统拥有很高的并发量,同时也对角色进行职责划分。Netty 底层采用了这套模型, 所以不建议在 Netty 的 Worker 线程中进行耗时操作,因为会影响整个服务性能,对于耗时操作,可以开辟额外的线程池进行处理。
Netty
Netty 的线程模型,还查看 Reactor 模型
Netty 是一个异步事件驱动的网络框架,对 Java NIO 进行封装,并屏蔽了大量实现细节。用于快速开发可维护的高性能协议服务器和客户端。
框架内置了大量的编解码器,可以对常见的协议或内容进行编解码操作。此外 Netty 提供内存池设计,通过引用计数对内存进行管理,当内存没有引用时,对内存进行回收,从而让其它线程进行复用。这种设计可以避免对内存进行反复的创建和释放的性能开销,此外 Netty 还对其它操作进行优化,使得 Netty 性能很优。
该章节,并不会介绍 Netty 基本操作,主要讲述 Netty 最佳实践和一些遇到的坑。
RPC 是基于 TCP 的协议,之所以不采用 UDP,因为 UDP 传输具有不可靠性,对于 RPC 框架是不容忍数据丢失的。随之而来的问题,TCP 是面向字节流传输协议,当进行传输数据时,数据可能会被拆分成多个包。所以需要在应用层面将这些包进行组合拼装,这就是 TCP 沾包/拆包的处理,所谓的处理,就是当数据不是完整数据时,需要等到下一个 TCP 包传输,直到数据变得完整。例如,当进行传输 JSON 数据时,第一个包只收到了 JSON 一半的数据,另一半数据还在下一个包中,这种情况需要等待下一个包传输过来,从而才能进行 JSON 解析。
Netty 自带了几种解决方案,当进行协议设计时,可以选择合适的沾包处理。
消息定长:FixedLengthFrameDecoder
读取指定长度的数据,数据长度不满足则进行等待
包尾增加特殊字符分割
- 行分隔符类:LineBasedFrameDecoder
- 自定义分隔符类 :DelimiterBasedFrameDecoder
将消息分为消息头和消息体:LengthFieldBasedFrameDecoder 类。分为有头部的拆包与粘包、长度字段在前且有头部的拆包与粘包、多扩展头部的拆包与粘包。
采用 JSON 作为传输数据,需要在 Pipeline 头部添加 JsonObjectDecoder 处理器,会等待当前的数据是一个完整的 JSON,才交给下一个 ChannelHandler 进行处理。关于其它的协议的编解码器,请参考 Netty 官方 API 文档。
编写 rpc-server 时,还踩到一个 Netty 坑,就是编解码的顺序要放的正确,否则会导致尾部的 ChannelInboundHandler 无法处理消息。在 rpc-server 中请求和响应的编解码器继承了 ByteToMessageCodec,可以在同一类中同时进行编解码操作。这种方式虽然带来了便捷,但是也埋下了隐患。当时服务端和客户端的编解码器在 Pipeline 中的顺序都是相同的,所以导致客户端无法处理服务响应。
1 | ch.pipeline() |
后来排查了好久,才发现是编解码器位置不正确的问题,当客户端接收服务端响应,首先经过请求解码器,但是请求解码器是无法对响应进行解码的,数据也没有交由下一个解码器进行处理,从而导致响应数据被吞了。面对这种情况,只需要将客户端的编解码位置调整一下,就可以对响应进行解码操作。
1 | ch.pipeline() |
目前在 Netty 中遇到的问题就是这些,当然 Netty 也比较容易发生内存泄露问题,这是由于申请 ByteBuf,使用之后并没有及时释放,从而导致内存不断积压,从而导致泄露。解决也很简单,ByteBuf 使用后调用 ReferenceCountUtil#release 释放即可,但后续还需使用该 ByteBuf 话,请不要进行释放操作,否则会引发连锁反应。
传输安全
到现在为止,RPC 数据还是通过明文进行传输,会导致数据被拦截并修改。此时需要引入传输加密的协议 SSL/TLS,该协议在应用层与传输层之间进行工作。当服务端与客户端进行通讯时,服务端进行对原始数据加密,加密数据到达客户端,客户端对加密数据进行解密操作。
SSL/TLS 协议采用公钥加密法,简言之,客户端向服务端索要公钥,然后用公钥进行加密,服务器收到密文之后,用自己的私钥进行解密。这会引出一个问题,如何保证公钥不会被修改?可以采用证书的方式,将公钥放入到证书中,证书是可信的,公钥就是可行的。
上述提到的是单向认证,只有一个对象来校验对端证书的合法性,一般由客户端来进行校验。客户端只需要 ca.crt,服务端需要 server.crt、server.key。要是进行双向认证,那客户端需要持有 ca.crt、client.crt、client.key;服务端需要持有 ca.crt、server.crt、server.key。
Netty 中实现 SSL/TLS 加密,通过 SslHandler 进行实现。看到如下例子, initChannel 方法通过 SslContext 创建 SSLEngine 实例,并设置 SslEngine 是 client 或者是 server 模式。之后将 SslHandler 添加到 Pipeline 头部进行加解密操作。
SslContext 通过证书来进行创建,SSLHandler 需要在服务端与客户端都添加。
1 | public class SslChannelInitializer extends ChannelInitializer<Channel> { |
方法调用
RPC 服务启动时,会收集所有注册的方法,提供给远程服务进行调用。RPC 请求进行解析后,从请求中提取出调用方法名与参数,在本地服务中找到注册的方法进行调用。在调用之前,请求中方法参数需要适配本地方法参数。比如,对 say 方法进行调用,该方法接收一个 String 作为参数,所以需要将请求方法参数转为 String,然后才进行调用,这就是方法参数类型适配。
1 | public String say(String name) { |
RPC 方法调用并不向普通方法调用那么简单,方法调用在运行时完成,所以无法在源码级别编写代码来去调用特定方法,Java 平台提供如下几种方法调用方式
- Reflect
- MethodHandle
- ASM
Reflect
反射在框架设计中使用的很广泛,并且反射使用也很简单。比如我们想去调用 String 的 replaceAll 方法,首先得到 String 的 Class 对象,该对象有三种方式获得。
- String 实例的 getClass() 方法
- String.class
- Class 的 forName 方法,通过类加载器得到 Class 对象
有了 Class 对象后,可以通过其 getXXXMethod 方法传入方法名、参数类型获取方法对象,将方法访问设置为 true,最后调用 invoke 方法传入对象实例和参数调用即可。对于静态方法而言,无需传入对象实例,只需传入 null。
1 | try { |
MethodHandle
MethodHandle 是 JDK1.7 引入的新特性,本质上将某个具体的方法映射到 MethodHandle,通过MethodHandle直接调用该句柄所引用的底层方法,实际就是对可执行方法的引用。
还拿 String 的 replcaeAll 方法举例,首先获得 MethodHandles.Lookup 对象,根据调用方法的返回类型和参数类型得到 MethodType 对象,之后根据方法的类型,来调用 Lookup 对象特定的方法获得 MethodHandle。
- findVirtual:实例方法
- findStatic:静态方法
获取到 MethodHanle 对象后,通过其 invoke 传入对象实例和方法参数进行调用。
1 | try { |
ASM
ASM 是一种比较高阶的方式,通过生成字节码完成方法的调用,从而避免反射的开销。这种设计思路来源于 reflectasm 开源项目,rpc-server 对其进行一些改良,从而支持多个类的方法调用。
本质上通过生成 switch-case 字节码来实现,RPC 服务启动时,获取所有注册的 RPC 方法,然后为每个方法分配索引。当 doInvoke 方法调用时,根据方法名获取方法索引,用于在 switch-case 中匹配 case。
1 | public Object doInvoke(Object obj, String methodName, Object[] args) |
之所以 switch-case 不采用方法名(String)而是采用索引(int)作为条件,因为 switch-case 在底层实现是采用 number。我们编写如下代码,编译后查看生成的字节码文件。
1 | String name = "hello"; |
得到如下生成的字节码,如果采用 String,首先会调用其 hashCode 方法得到 hash 值,为了解决 hash 冲突的问题,还进行了 equals 判断,符合要求则分配索引。之后来到了下一个 switch-case,通过索引来找到对应的 case 分支。
综合考虑,采用 number 作为条件,可以减少 ASM 实现难度。
1 | String name = "hello"; |
对于 ASM 具体实现在此不做讲解,感兴趣的读者请阅读 rpc-server 的 AsmGenerator 类。
我们对 doInvoke 方法进行字节码生成,方法参数为 Object、String、Object[] 数组类型。通过方法索引匹配指定的 case,在调用其目标方法。在进行 ASM 生成时,遇到一些坑,在此进行列出。
需要doInvoke 方法第一个参数进行 checkcast 操作,将泛型(Object)转为具体类型(调用方法所属的类)
调用方法方法中含有原生类型,需要对 Object[] 中指定元素进行 unbox 操作
doInvoke 方法传递是引用类型,而方法需要原生类型,如果不进行 unbox 操作,那么会出现类型不匹配,导致无法进行方法调用。
调用方法返回的是原生类型,需要进行 box 操作
调用方法中含有引用或者数组引用类型,也需要对 Object[] 中指定元素进行 checkcast 操作
客户端代理
客户端向RPC服务发送请求,服务处理后返回响应,客户端对响应进行解析。对于客户端,请求和处理的流程相对繁琐了一些,可以使用动态代理方式来屏蔽底层的细节,让我们只关注业务。因为RPC方法就是Java普通方法,客户端与服务端采用相同的方法签名,然后使用动态代理对其处理。简言之,就是将方法名和方法参数封装成请求发送给服务端,服务端返回响应,将响应内容解析成方法返回的类型。
来看到一个代理例子,RPC 服务提供了 say 方法,在客户端也提供了相同的方法签名
服务端
1 | public String say(String name) { |
客户端
1 | public interface RpcService { |
使用 Proxy#newInstnace 方法对 RpcService 接口进行代理,我们将方法名称和参数提取出来组成 RPC 请求,然后发送给服务端,得到响应后提取出方法返回值,根据方法的返回值类型进行转换。
下面用伪代码进行表示
1 | ClassLoader classLoader = this.getClass().getClassLoader(); |
通过对接口的代理,屏蔽了请求和处理细节,用户调用本地方法,实则调用的是远程方法。
系统指标收集
指标可以有效的反馈系统的运行状态,知道其系统是否健康。收集系统 CPU、内存、磁盘等信息,上传到第三方中间件,便于后续的统计和分析。通过指标生成火焰图,来发现系统的性能毛刺,从而定位系统问题。也可以使用系统指标来实现自定义负载均衡算法,比如节点A机器的性能要比节点B好,那么可以将多一些请求交由节点A进行处理。
实现指标收集有两种方案,其一在 RPC 中编写指标收集的代码,这种方式对代码有侵入性,还会增加维护的难度。其二通过 javaagent 机制,在应用启动时,提前启动指标收集服务,该方式对代码无侵入性,还能有效减少代码复杂度。
链路追踪
分布式系统中,服务之间会进行频繁调用,如果某个服务出现问题,导致整体的调用时间过长影响到业务。为了解决该问题,链路追踪就应景而出了,它会追踪每次调用情况,当出现问题时,可以快速的定位服务的故障点。
Google 的 Dapper 论文是现代分布式链路追踪鼻祖,业界知名的 skywalking、zipkin 链路追踪框架都是基于该论文进行设计。分布式链路追踪系统核心是收集调用信息,通过收集 HTTP、RPC、数据库、中间件调用信息,标记每次调用的时间与tag,来组合成一条完整的链路。
一条链路由多个 span 构成,用户发起调用时,首先会分配 TraceId,作为该链路唯一标识。后续服务A调用服务B或调用数据库等其它中间件,使用 span 表示调用信息,并将其添加到 Trace 中。响应返回前端,意味着请求全部处理完成,所有的调用信息则形成一条完整的链路。