文章目录
- RPC 的通信流程
- 02 | 协议:怎么设计可扩展且向后兼容的协议?
- 03 | 序列化:对象怎么在网络中传输?
- 04 | 网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?
- 05 | 动态代理:面向接口编程,屏蔽RPC处理流程
- 08 | 服务发现:到底是要 CP 还是 AP?
- 09 | 健康检测:这个节点都挂了,为啥还要疯狂发请求?
- 10 | 路由策略:怎么让请求按照设定的规则发到不同的节点上?
- 11 | 负载均衡:节点负载差距这么大,为什么收到的流量还一样?
- 12 | 异常重试:在约定时间内安全可靠地重试
- 13 | 优雅关闭:如何避免服务停机带来的业务损失?
- 14 | 优雅启动:如何避免流量打到没有启动完成的节点?
- 15 | 熔断限流:业务如何实现自我保护?
- 16 | 业务分组:如何隔离流量?
RPC 的通信流程
帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样的体验,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。
- 调用方将数据 进行序列化,并 根据 约定的协议 进行编码。
- 网络传输
- 根据 约定的协议 进行解码,反序列化出对象。
- 提供方再根据反序列化出来的请求对象找到对应的实现类,完成真正的方法调用,然后把执行结果序列化后,回写到对应的 TCP 通道里面。调用方获取到应答的数据包后,再反序列化成应答对象,这样调用方就完成了一次 RPC 调用
02 | 协议:怎么设计可扩展且向后兼容的协议?
- 为什么不直接使用 Http 协议?
- A:请求头中的其他信息太多,比如 \r \n,http 版本信息等等。
一般 的设计就是这样:
03 | 序列化:对象怎么在网络中传输?
JDK 的序列化过程是怎样完成的呢?我们看下下面这张图:
序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。
- 头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
- 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
- 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑
实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。
JSON 的序列化方式
JSON 是典型的 Key-Value 方式,没有数据类型,是一种文本型序列化框架
缺点:
-
JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;
-
JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。
-
Hessian:动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian 协议要比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。
-
Protobuf:Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类,它的优点是:
- 序列化后体积相比 JSON、Hessian 小很多
- IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器
- 序列化反序列化速度很快,不需要通过反射获取类型
- 消息格式升级和兼容性不错,可以做到向后兼容
RPC 框架中如何选择序列化?
需要注意哪些问题 ?
- 对象构造得过于复杂:属性很多,并且存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
- 对象过于庞大
- 使用序列化框架不支持的类作为入参类:比如 Hessian 框架,他天然是不支持 LinkedHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList。
- 对象有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题 1 一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。
04 | 网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?
常见的网络 IO 模型分为四种:
- 同步阻塞 IO(BIO)
- 同步非阻塞 IO(NIO)
- IO 多路复用
- 异步非阻塞 IO(AIO)。
在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。
【性能篇】多路复用之 Select,Poll,Epoll 的差异与选择
05 | 动态代理:面向接口编程,屏蔽RPC处理流程
08 | 服务发现:到底是要 CP 还是 AP?
为什么需要服务发现?
其实就是调用者在调用具体接口时,需要找到对应的 IP 地址集合。
- DNS 和 VIP 方案都存在大量的人工操作和生效延迟。
基于 ZooKeeper 的服务发现(接口跟服务提供者 IP 之间的映射)
- 服务平台管理端先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径下再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来 存储服务提供方的节点信息和服务调用方的节点信息。
- 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
- 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方 watch 该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
- 当服务提供方目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的服务调用方。
ZooKeeper 的问题(强一致性)
主要在于强一致性,每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了 ZooKeeper 集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。
- 如何解决呐?
- 可以 基于消息总线的最终一致性的注册中心(略)
09 | 健康检测:这个节点都挂了,为啥还要疯狂发请求?
接口可用率 这个突破口,应该相对完美了。可用率的计算方式是
某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。
当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
10 | 路由策略:怎么让请求按照设定的规则发到不同的节点上?
即:调用方如何选择服务提供方的节点。
如何实现路由策略?
调用方发起 RPC 调用的流程。在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),那我们是不是可以在选择节点前加上“筛选逻辑”,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则。
举个具体例子你可能就明白了,比如 我们要求新上线的节点只允许某个 IP 可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,按照这个例子的逻辑,最后会过滤出一个节点,这个节点就是我们刚才新上线的节点。
11 | 负载均衡:节点负载差距这么大,为什么收到的流量还一样?
什么是负载均衡?
如何设计自适应的负载均衡?
- 添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
- 运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取。
- 请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999 等。
- 可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
- 通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。
12 | 异常重试:在约定时间内安全可靠地重试
调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。
总结:在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了。
13 | 优雅关闭:如何避免服务停机带来的业务损失?
在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:
- 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。
- 调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。
最终方案
- 当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,并没有处理这个请求”
- 然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,
- 并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。
14 | 优雅启动:如何避免流量打到没有启动完成的节点?
启动预热
回顾下调用方发起的 RPC 调用流程是怎样的,调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。
那么现在的问题就是:调用方怎么知道哪个服务方是刚启动的?哪个不是刚启动的呐?
- 服务在启动后,都需要注册,所以存在注册的时间,即可作为启动时间。
利用服务提供方把接口注册到注册中心的那段时间。我们可以在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:
15 | 熔断限流:业务如何实现自我保护?
服务端的自我保护
当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑。
那服务端的限流逻辑又该如何实现呢?方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。上述这几种限流算法我就不一一讲解了,资料很多,不太清楚的话自行查阅下就可以了。
使用方该如何配置应用维度以及 IP 维度的限流呢?在代码中配置是不是不大方便?我之前说过,RPC 框架真正强大的地方在于它的治理功能,而治理功能大多都需要依赖一个注册中心或者配置中心,我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。
综上,有一个问题就是:限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。比如:有 20 个节点,而每个节点限流的阈值是 500,其中有的节点访问量已经达到阈值了,但有的节点可能在这一秒内的访问量是 450,这时调用端发送过来的总调用量还没有达到 10000 次,但可能也会被限流,这样是不是就不精确了?那有没有比较精确的限流方式呢?
- 解决就是:单独抽象出一个限流服务,让每个节点都依赖一个限流服务,在这里判断限流逻辑和阈值。
调用端的自我保护
所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断。
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。
在 RPC 调用的流程中,动态代理是 RPC 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。
16 | 业务分组:如何隔离流量?
就是 将服务拆分为一个个的集群。
其实就是 美团的 Set化