前言
大家好,我是「周三不Coding」。
很多公司秋招提前批已经启动啦,相信小伙伴们一定在疯狂地备战秋招。
可能很多同学都已经做过 RPC 项目啦,但是担心到底能不能将其写到简历上,担心 RPC 是烂大街的项目。
其实,我在面试之前,也犹豫过。但是面试的结果却出乎意料,除了面试腾讯时没有问过我 RPC 相关的问题,其它厂几乎上来第一个问题就是:说一说你的 RPC 项目,惊人的一致。这就导致每次面试开始,我都会滔滔不绝的说 10 分钟左右关于 RPC 的那套东西。
所以,我想送给大家一句话:
在面试中,「RPC」并没有想象中的 “不堪”,关键在于熟练掌握其要点,做到应对自如,即是加分点。
接下来,我们一起结合实际的面试题,看看如何做到 「应对自如」!
RPC 模拟面试
此时省略一番自我介绍和寒暄,面试官开始夺命十八连问~
👨💼 面试官:小T同学,你先说说你的 RPC 项目是怎么实现的?
👦 小T:(竟然一上来就问这么硬的,还好我早有准备)
👦 小T:我先给您说说我这个 RPC 项目的核心原理和组件吧,请看这张图:
RPC 全称为 Remote Procedure Call,意思为远程调用,并且这个过程就像调用本地方法一样简单!我们不需要关注底层的网络传输细节,只需要按照调用本地方法的流程去调用远程方法即可。
通常,一个 RPC 框架有这么几个核心组件:
- Server
- Client
- Server Stub
- Client Stub
Server 和 Client 比较简单,就是常规意义的服务端和客户端,而在 RPC 中,又引入了一个新的概念 「Stub」。
它起到的作用其实就是代理,处理一些琐碎的事情:
- 对于 Client Stub,它主要是将客户端的请求参数、请求服务地址、请求服务名称做一个封装,并发送给 Server Stub
- 对于 Server Stub,它主要用于接收 Client Stub 发送的数据并解析,去调用 Server 端的本地方法
以上就是 RPC 的核心啦 …
(面试官突然打断)
👨💼 面试官:了解,但这些内容属于比较基础的 RPC,实际应用场景中的 RPC 远不止这么简单,你的 RPC 框架还有其它设计吗?
👦 小T:(我正准备说呢!😭)
是这样的,接下来我给您看看我这个 RPC 项目的层次结构吧!(好戏还在后面)
首先,我讲一讲代理层。它其实对应到我之前提到的:按照调用本地方法的流程去调用远程方法。这一功能就是通过代理层来加以实现。通过使用代理模式,我们可以屏蔽远程方法的调用细节,如:网络连接建立、序列化、发送请求数据、获取返回结果、解析结果等一系列操作。
对于调用者、框架使用者来说,他们只需要直接调用远程方法即可,复杂的逻辑都封装在 RPC 框架中处理。
我顺便说一下,除了屏蔽调用细节,代理层的其他优点吧:
- 代理层可以扩展目标对象的功能
- 代理层可以与客户端进行解耦,提升系统的可扩展性
(又被面试官打断)
👨💼 面试官:那你的代理层是如何转发请求的呢?在微服务分布式场景下,是有许多服务的,一个服务也可能对应多个实例,你是如何处理的?
👦 小T:(这不就是注册中心需要解决的事情吗?简单!)
这正是我准备说的「注册中心层」。如果只使用代理层的话,是很难处理您所说的这种情况的,因为我们需要考虑如何记录众多的服务的地址信息,并在某个服务上下线时,通知其他服务。若这个时候单纯使用代理层去管理这些琐碎的事情,就会造成代码复杂度、耦合度上升,不易于扩展与维护。
因此,我在 RPC 框架中抽象出了「注册中心层」,专门用于处理服务注册、服务信息查找、服务上下线通知。
更具体地来说,负责以下三类事项:
- 服务发现:客户端需要订阅注册中心。在需要远程调用时,从注册中心中获取信息,然后进行方法调用
- 服务注册:服务提供者将地址、接口、分组等信息存放在注册中心模块,当服务上线、下线均会通知注册中心
- 服务管理:提供服务的上下线管理、服务配置管理、服务健康检查等功能,以保证服务的可靠性和稳定性
就像我图中所画的一般:
👨💼 面试官:那你的注册中心具体是如何实现的?你是手写了一个注册中心组件吗?
👦 小T:(糟了,他不会以为我是手写的吧!我得迂回一下!)
呃,并没有,因为正如之前所说,这里涉及到了数据存储、事件监听机制、心跳机制等多个复杂的工作,而市面上恰好有满足这些特性的开源组件,考虑到整体项目的进度以及手写的复杂程度,最后我还是选择了开源的解决方案。
👨💼 面试官:那你是如何做选择的?换句话说,你之前有仔细了解过这些开源组件吗?
👦 小T:(就知道会问这个,好在我早有准备~)
在谈如何选择注册中心之前,请让我先简单介绍一下 CAP 理论哈~因为之后我会根据 CAP 理论选择注册中心!
CAP 理论是分布式系统中的重中之重!
敲黑板!注册中心重点来喽!觉得有压力的同学可以先跳过,先接着往后读~
CAP 是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。
一致性(Consistency) : 所有节点访问同一份最新的数据副本
可用性(Availability): 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。
分区容错性(Partition Tolerance) : 分布式系统出现网络分区的时候,仍然能够对外提供服务。
CAP 并不是简单的 3 选 2,因为分区容错性是必须实现的。以分区容错性作为前提,在一致性与可用性中做选择。
接下来,我说一下我是如何在 Zookeeper、Nacos、Eureka、Consul 中做技术选型的!
-
Zookeeper
-
Zookeeper 通过 znode 节点来存储数据。因此我们可以利用这一特性进行服务注册,节点用于存储服务 IP、端口、协议等信息。
- 例如:服务提供者上线时,Zookeeper 创建该节点 - /provider/{serviceName}:{ip}:{port}
-
Zookeeper 提供 Watcher 机制,可以监听相应的节点路径。因此我们可以利用这一机制监听对应的路径,一旦路径上的数据发生了变化,我们便向其他订阅该服务的服务发送数据变更消息。收到消息的服务便去更新本地缓存列表。
-
Zookeeper 提供心跳检测功能,定时向各个服务提供者发送心跳请求,确保各个服务存活。如果服务一直未响应,则说明服务挂了,将该节点删除。
-
Zookeeper 遵循一致性原则,即 「CP」
- 对于注册中心而言,最重要的是可用性,我们需要随时能够获取到服务提供者的信息,即使它可能是几分钟以前的旧信息。
- 但是 Zookeeper 由于其核心算法是 ZAB,主要适用于分布式协调系统(分布式配置、集群管理等场景)。当 master 节点故障后,剩余节点会重新进行 leader 选举,导致在选举期间整个 Zookeeper 集群不可用。
-
-
Nacos
-
服务提供者启动时,会向 Nacos Server 注册当前服务信息,并建立心跳机制,检测服务状态。
-
服务消费者启动时,从 Nacos Server 中读取订阅服务的实例列表,缓存到本地。并开启定时任务,每隔 10s 轮询一次服务列表并更新。
-
Nacos Server 采用 Map 保存实例信息。当配置持久化后,该信息会被保存到数据库中。
-
对于服务健康检查,Nacos 提供了 agent 上报与服务端主动监测两种模式
-
Nacos 支持 CP 和 AP 架构,根据 ephemeral 配置决定
- ephemeral = true,则为 AP
- ephemeral = false,则为 CP
-
-
Eureka
- 服务提供者启动时,会到 Eureka Server 去注册服务
- 服务消费者会从 Eureka Server 中定时以全量或增量的方式获取服务提供者信息,并缓存到本地
- 各个服务会每隔 30s 向 Eureka Server 发送一次心跳请求,确认当前服务正常运行。若 90s 内 Eureka Server 未收到心跳请求,则将对应服务节点剔除。
- Eureka 遵循可用性原则,即「AP」。
- Eureka 为「去中心化结构」,没有 master / slave 节点之分。只要还有一个 Eureka 节点存活,就仍然可以保证服务可用。但是可能会出现数据不一致的情况,即查到的信息不是最新的。
- Eureka 节点收到请求后,会在集群节点间进行复制操作,复制到其他节点中。
-
Consul
-
服务提供者启动时,会向 Consul Server 发送一个 Post 请求,注册当前服务信息
-
服务消费者发起远程调用时,会向 Consul Server 发送一个 Get 请求,获取对应服务的全部节点信息
-
Consul Server 每隔 10s 会向服务提供者发送健康检查请求,确保服务存活,并更新服务节点列表信息。
-
Consul 遵循一致性原则,即「CP」
-
这 4 种开源组件均满足注册中心需求。在这种场景下,技术选型就是一个 Trade-off 了,我们需要选择一个最适合的组件!
- 对于 Consul,它底层语言是 Go,更支持容器化场景,而当前 RPC 框架采用的是 Java 语言,所以就先淘汰啦~
- 对于 Eureka,它很适合作为注册中心,但是其维护更新频率很低,目前国内使用的人很少,所以在这里就先不使用啦~
- 对于 Nacos,它是目前国内非常主流的一种注册中心,而且由 Alibaba 开源。
- 最后,我还是选择了 Zookeeper,虽然 Zookeeper 追求一致性导致其不太适合于注册中心场景,但是国内 Dubbo 框架选用了 Zookeeper 作为注册中心,能从 Dubbo 框架中参考到许多优秀的实现技巧。并且,我们可以通过操作 Zookeeper 节点,从更加底层的角度感受如何实现注册服务。
(啊~终于说完了,好累)
👨💼 面试官:嗯,说的很不错,看来在技术选型上做了很多功课!你最终选用了 Zookeeper,那你还知道 Zookeeper 的其它应用场景吗?
👦 小T:(竟然问这么细)除了注册中心,Zookeeper 还可以实现分布式锁、分布式 ID、配置中心等功能。
对于分布式锁:
- Zookeeper 有一种节点为临时节点,它可以保证服务宕机后节点自动被删除,不需要额外考虑添加节点过期时间来解决死锁问题。
- Zookeeper 可以通过使用顺序节点,满足公平锁特性。
- Zookeper 节点加锁时,通过监听前驱节点状态,判断是否获取到锁。
- 如果监听到它的前驱节点被删除时,则相当于获取到锁;否则阻塞。
对于分布式 ID:
- Zookeeper 可以通过其顺序节点,实现分布式 ID,确保分布式环境下 ID 不重复。
对于配置中心:
- 通过 znode 节点实现配置存储
- 通过 Watcher 监听节点信息是否发生变化,若发生变化,则通知客户端更新配置信息。
👨💼 面试官:掌握得可以呀~那你接着说吧。
👦 小T:之前我们有提到,一个服务可能对应着多个实例节点,从注册中心中获取到的可能不止有一个服务地址,可能是一个地址信息 List。这时候我们就需要借助「路由层」,帮助我们从多个实例节点中选取一个,这就是「负载均衡」。在我的 RPC 框架中,我提供了如下 5 种负载均衡策略:
-
随机选取策略
-
轮询策略
-
加权轮询策略
-
最少活跃连接策略
-
一致性 Hash 策略
-
对于「随机选取」策略,顾名思义,即从多个节点中随机选取一个节点进行访问。这种方式最大的优点就是简单,但是当请求数量较少时,随机性可能不强,可能会出现单实例节点负载过大的情况。当请求数量很大时,每个实例节点接受的请求数量会接近于均衡,效果较好。
-
对于「轮询」策略,即轮转调度。假设当前服务有 3 个实例节点,第一次请求发送给 A 节点,第二次请求发送给 B 节点,第三次请求发送给 C 节点,那么第四次请求就会再次发送给 A 节点,实现均衡请求的效果。
-
对于「加权轮询」策略,是为了解决「轮询」策略所面临的问题。试想一种场景,在当前服务的集群中,有的实例节点配置较高,内存大且多核处理器,那么它就可以承载更多请求。有的实例配置低,那么它的承载能力就会弱一些。这时候「轮询」策略不足以满足这一使用场景。
因此,我们需要考虑为每个实例节点设置权重,使权重大配置高的节点处理更多的请求,这就是「加权轮询」策略。
-
对于「最少活跃连接」策略,是为了解决以上策略所面临的共同问题。我们再试想一种场景,某些请求的处理时间更长,比如拉取用户粉丝列表,对于头部博主来说,其粉丝数多,拉取时间长,而对于普通用户来说,其粉丝数少,很快就可以拉取完毕。这就导致拉取一个大 V 粉丝列表的时间远长于拉取 100 个普通用户粉丝列表的时间。
这时候如果还是按照「轮询」策略,会导致 A 节点即使收到的请求比 B 节点少,但却超过所能承受的最大负载。
而「最少活跃连接」策略的意思是选取当前活跃请求最少的服务节点。
因此,在这种场景下,更适合使用「最少活跃连接」策略,会得到更合理的负载均衡效果。
-
对于「一致性 Hash」策略,是通过请求中携带的参数来定位对应的实例节点。
比如,请求参数中携带了用户 ID。用户 ID 为 1 ~ 10 的请求永远对应到 A 节点,用户 ID 为 11 ~ 20 的请求永远对应到 B 节点,依次类推…
这就是路由层的核心作用 —— 「负载均衡」啦~
👨💼 面试官:我刚才听你提到一致性 Hash 策略,但好像没有提到 Hash,你能在具体说说吗?
👦 小T:(竟然听的这么细!)好嘞,可能是我漏啦~我们可以从一致性 Hash 环的原理讲起!
我们可以看到如图「圆环」中存在有 3 个节点,分别为 NodeA / Node B / Node C,4 个请求,分别为 Req 1 / Req 2 / Req 3 / Req 4。
为什么叫这个「圆环」为「一致性 Hash 环」呢?这是因为我们要对每一个节点根据 Hash 算法计算得到一个 Hash 值,并将其映射到圆环中的某一个位置。对于请求也是如此,根据参数来计算具体的 Hash 值,也映射到圆环中对应的位置。
接下来的事情就很简单啦,每一个请求沿着当前圆环 顺时针 寻找,找到的第一个节点就是对应的处理请求节点。
如图对应关系为,Node A 处理 Req 3 和 Req 4,Node B 处理 Req 1,Node C 处理 Req 2。
但是一致性 Hash 环容易造成一个问题,看下面这个图就一目了然啦!
当服务节点过少时,节点 Hash 值映射到圆环的位置可能聚集于某一处,容易因为节点分布不均匀而造成请求均衡问题,即「数据倾斜」。
在图中,Node A 承担了大部分请求,Node C 只承担了一个请求,Node B 一个都没有。
为此,我们需要引入「虚拟节点」解决这一问题。
图中黄色节点对应的就是「虚拟节点」,起到均衡请求、避免数据倾斜的作用。
👨💼 面试官:理解的很到位!你接着说路由层吧~
👦 小T:(感觉稳了!)路由层除了实现核心的「负载均衡」功能之外,还承担了分配流量的作用。在 RPC 框架中,我们可以将流量标签、实例标签、路由规则等信息存储在请求中,这样一来,我们就可以随意控制流量,将请求分配到不同的流量环境。
基于此,我们可以实现「泳道测试」,即对于生产环境请求,打上对应的 prod 标签,对于测试环境请求,打上对应的 test 标签。这样就可以让大部分请求转发到生产环境服务,而新版本测试请求转发到测试环境服务。
👨💼 面试官:时间不多啦,你接着说下一层吧~
关于路由层的知识点,面试一般只会考到「负载均衡」算法。所以为了考虑到大部分读者快速准备面试的需求,对于路由层的高级路由部分,如:条件路由、泳道测试、灰度测试等具体内容,我会在「周三不Coding」中详细讲解~
👦 小T:好嘞!咱们接着说序列化层!由于 RPC 调用的底层是网络请求,当我们的请求携带参数时,请求发送方需要将参数进行「序列化」,从而实现在网络中传输。而请求接收方也需要进行「反序列化」操作,获取到可理解的参数。
- 序列化:将数据结构或对象转化为二进制字节流
- 反序列化:将在序列化过程中生成的二进制字节流转化为数据结构或对象
为了整合多种序列化框架,我抽象出了序列化层,使用工厂模式,定义了抽象工厂接口,其中有两个方法:serialize 和 deserialize
并定义相应的序列化实现类,包含如下序列化框架:
- JDK序列化
- 通过ObjectOutputStream的writeObject和readObject方法实现,可通过重写指定其他序列化方式
- JDK默认序列化方式性能差,且只适用于Java
- Protocol Buffer
- 支持跨语言、跨平台,可扩展性强
- 需要使用IDL来定义Schema描述文件,定义完描述文件后,可以直接使用protoc来直接生成序列化与反序列化代码
- 性能低于Kyro,但是高于大部分序列化协议,序列化后的size也较小
- Kyro
- 主要适用于Java,不支持字段扩展
- 使用简洁,直接使用Input、Output对象
- 高性能,序列化与反序列化时间开销都很低,序列化后的size也很小
- Hessian
- 支持跨语言、跨平台,可扩展性强
- 易用:只需要实现Serializable接口即可
- 序列化时间与大小都比较小
👨💼 面试官:可以的~那在数据传输过程中,可能会出现粘包和半包问题,你是如何解决的?
👦 小T:(好问题,正好我复习了!)我在做之前,是有了解调研过业界的解决方案,有以下几种:
- 固定长度传输:固定好每次数据包的长度,比如规定每次传输长度为 32 个字节。当接收方接满 32 个字节时,代表接受到了完整的信息。
- 该方法灵活性太低
- 特殊字符分割:即每次接收方读取数据时,读到事先约定好的特殊字符时,代表接受到了完整的信息。
- 如果消息内容中刚好有这一特殊字符,需要提前做转义,还是比较麻烦。
- 自定义消息结构
最后,考虑到灵活性,决定自定义消息结构,因此我抽象出了「协议层」,专门用于定义消息收发格式。
自定义协议结构体如下:
- MagicNumber 魔数:用于做安全检测,能够快速确认当前请求是否合法。请求发送方和接收方可以提前约定好魔数。
- ContentLength 请求长度:协议长度。
- Content 核心传输数据:封装请求的接收方名称、请求的方法、请求参数等内容
- 这里的 Content 为二进制字节流,对应的就是「序列化层」序列化后得到的二进制字节流。
👨💼 面试官:似乎还有「链路层」和「容错层」,你再展开说说吧~
👦 小T:好的。我先说说为什么需要「链路层」,其主要用于解决以下两个问题:
- 对 RPC 请求做鉴权:请求在到达请求接收方之前,先校验其是否有请求凭证(Token)
- 记录请求过程中的调用日志信息
「链路层」的核心设计思想是责任链设计模式:
- 责任链模式可以使我们任意添加请求的「前处理」和「后处理」对象,并调整处理顺序,提高了维护性和可拓展性,可以根据需求新增处理类,满足开闭原则。
- 责任链简化了对象之间的连接。每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句。
- 责任分担,职责分离。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。
对于「容错层」,我主要是为了实现服务稳定性治理,确保服务的高可用性。主要通过以下几种手段实现容错层:
- 超时重试机制
- 请求一般可以分为「幂等」与「非幂等」请求,幂等性指的是多次请求某一个资源,最后的结果相同,对系统产生的影响相同。
- 在请求重试时,我们需要额外考虑「非幂等请求」重试所带来的风险(比如转账、下单等涉及资金业务场景),当请求超时时,很难判断数据包是否已经到达服务接收方。因此,在请求参数中添加 retry 参数,重试次数由用户自行决定。当 retry = 0 时,则代表请求失败不进行重试。
- 服务限流
- 在高并发场景下,通过限制瞬发 QPS 最大值,从而防止系统被流量击溃,最大限度保证服务高可用。
- 服务熔断
- 在调用过程中,可能出现 Bug、故障等问题。为了防止由于此类问题导致故障在调用链路中扩散,引起「链路雪崩」,我们选择触发「服务熔断」,直接抛出异常,放弃继续调用下游服务,最大程度保护其他服务。
👨💼 面试官:不错,说得很好。但是你的 RPC 框架最后有做压力测试吗?所有框架最后都得使用呀,一定要能投入到生产环境使用。
👦 小T:(这也要问吗?还好我做了这一步!)您说的没错,我也有考虑到这一点。在写完代码后,我做了一次压力测试:
我通过设置连续请求次数为 100 / 1000 / 10000,对框架进行压力测试,发现随着请求次数梯度上升,整体接口的响应速度和结果并没有发生变化,说明框架稳定。
👨💼 面试官:好的。最后我再问一个问题,你觉得你的 RPC 框架还有什么设计亮眼的地方?
👦 小T:(啊,终于快要问完了)我在框架中多次运用到了「异步设计」,对各个操作进行解耦。
-
对于服务端:
当请求抵达服务器时,将其直接丢入业务阻塞队列中,然后开辟一个新的线程,从阻塞队列中循环获取Handler请求任务。
将获取到的任务对象交付于业务线程池进行消费处理。
-
对于客户端:
代理层在发送完请求之后,不需要同步阻塞等待响应结果。结果的返回为异步。
并且用户可以通过配置文件的方式,自行选择异步或同步。
👨💼 面试官:咦,你提到了你有用线程池技术,那么你是如何选择线程数的呀?
👦 小T:(什么?不是最后一个问题吗?怎么还有?)
通用的选择方式是根据线程池处理任务的类型进行选择:
- 如果是CPU密集型任务,如:加密、解密、压缩、计算,应该根据当前服务器CPU核心数进行选择,最好是CPU核心数的1~2倍
- 如果是IO密集型任务,如:数据库、网络传输、文件读写,应该尽可能提升线程数
- 公式为:线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)
- 平均等待时间越长,说明是IO密集型,需要增大线程数
- 平均工作时间越长,说明是CPU密集型,需要减少线程数
对于我这个框架来说,大部分都是 IO 密集型任务,因此我调大了线程数。
👨💼 面试官:好的,可以看出 小T同学 对于 RPC 设计掌握得确实不错。行吧,回去等通知吧~
👦 小T:(????????????)
总结
终于写完了,洋洋洒洒将近 1w 字。
总结一下全文:我们从面试的角度出发,将 RPC 框架拆分了多个层次,逐层剖析 RPC 框架的具体实现原理,基本涵盖了 RPC 框架常考的知识点!
如果大家觉得部分知识点不够细致,我会在后续的文章中继续补充。
大家感兴趣的话请搜一下「周三不Coding」,我会在这里对路由层、服务治理等内容进行详细的补充。
个人认为应付面试应该足够使用啦。
此外,我还想再提一下文章开头的那句话:在面试中,「RPC」并没有想象中的 “不堪”,关键在于熟练掌握其要点,做到应对自如,即是加分点。
如果能很好的掌握 RPC 实现细节,在面试中绝对是大大大大大的加分点!如果你很会 RPC,放心大胆地写到简历上!
这就是本期的全部内容啦,如果大家觉得本篇文章对你有帮助,麻烦帮忙点个赞、收藏一下呀~那么今天就到这里啦,下期再见!