【RPC】RPC实战与核心原理

2022-12-18 12:05:35 浏览数 (2)

1、一致性的选择

强一致性要求相对会比较苛刻一些,相比之下,最终一致性才是系统设计中比较常用的一种策略,在系统的强健壮性/强一致性的选择下,应该根据需求去判断。 RPC 的服务发现中,如果选用 zk 则可以达到强一致性的目的,但在服务量大的情况下容易造成节点不受控的宕机,因而如果在考虑系统的强健壮性情况下,可以选择使用消息总线机制来完成服务发现功能,采用异步推拉的模式来保证最终一致性,也即是舍弃 CP 选择 AP。 推拉结合实际上就是对最终一致性的实践,新服务节点上线的时候向服务注册中心推送一个消息,告知服务中心有新节点上线了,但调用服务的节点并不马上去同步到消息,而是等待拉操作的发生,进而去同步节点的信息,这一过程最终总会实现一致,但不是强一致。

2、健康监测机制

对于一个服务来说可能有三种状态,分别为健康状态、亚健康状态、死亡状态,这三种也就分别对应着正常服务、服务质量不佳、服务异常三种表现。一般的根据连接状态来判断服务的方式只能探测出服务是否异常,而对于服务质量不佳的情况是无法及时发现的,也就容易造成整体服务质量的下降。因此在常规的连接检测之外,还需要有具体的根据业务维度来做的服务质量检测,比如服务的可用率这一指标(成功请求次数/总请求次数),可以及时探测出亚健康状态的机器。这一块对应的其实就是心跳检测机制。

3、路由策略

在请求方从注册中心拿到服务方信息的同时,把相应的隔离规则也一并发送下去,之后请求方在做负载均衡的时候会先经过一层路由策略,将相应的不符合条件的机器给排除在外,之后将符合规则的机器作为一个请求集,再去负载分担。

4、参数路由

除了整体的机器的剔除,还应该考虑在新旧应用交替过程中的平滑过渡,也就是说,需要确保同一个主题对象的所有请求都应该打到同一台机器上,这就需要用到参数路由了。首先给新老机器分别打上标签,之后在请求发生时拿到请求参数,进而根据请求参数判断是应该分发到新机器还是老机器上,这样也就是参数路由的实际操作。

5、负载均衡

在多个服务节点组成的集群中,使用上负载均衡策略可以避免同一台机器调用频繁但其他节点却无调用发生的情况,也就是将请求根据一定的策略分发到不同的节点上去。 负载均衡分为软负载和硬负载,就看利用的是软件还是硬件,常见的负载均衡算法有随机法、轮询法、最小连接法。 RPC 中的负载均衡完全由框架实现,一般策略包括随机权重、hash、轮询等,因为由框架自己实现,所以也就不会有负载设备的点单故障问题,进而还允许对其中的负载策略进行拓展。 同时为了满足根据机器生存状态去调整负载策略的需求,可以设计自适应的负载策略根据机器的状态信息进行打分,打分完成之后去计算对应的权重信息,进而根据权重选择机器。

6、异常重试

RPC 调用过程中如果出现异常不应该直接放弃本次调用,还需要进行重试(可能是短时的网络异常导致的),但并不是所有的异常都应该重试,对于一些业务异常的捕获就不应该重试,而应该直接返回给调用方做进一步处理。 进一步,在捕获异常重试时还应该重置调用的超时时间,避免在未达到单次调用超时时间之前就异常返回;同时,异常重试功能的开启应该建立在业务调用具有幂等性的基础上,如果业务不能确保每次调用都是幂等的,那么就不应该开启重试功能;如果需要对部分业务异常进行重试操作,可以建立异常重试的白名单,在捕获到白名单中的异常类型之后不直接返回给调用方而是进行重试操作。

7、优雅关闭

在 RPC 服务提供方关闭服务时并不是直接结束即可,还应该考虑是否有请求正在发生或者将要发生,这也就要求在准备关闭时就要停止接受新的请求,进而结束当前正在处理的请求之后再完成服务关闭,当然这里的等待请求完成并不是无休止地等待,应该考虑进来具体的超时参数设置,超过阈值就直接结束服务。

8、优雅启动

主要考虑启动预热和延迟暴露。应用刚启动时属于冷状态,无法跟其他持续工作的机器的效率相比,因此一台机器新上线的时候,可以通过动态调整权重来达到预热的效果,也就是设定一个预热时间,如果启动时间小于预热时间那么就让其权重尽量拉小,而等到充分预热之后才让其恢复到正常的权重。 如果业务应用是 spring 应用的话,大致会经过一些 bean 装载的过程,而这个过程至少没有直接什么都不做来得快,那么如果在 bean 充分加载之前,服务相关信息就暴露给了注册中心,注册中心立即下发到服务调用方,进而调用服务,而此时需要注意应用实际上并未加载完整,因此可能会出现业务问题。 实际上解决这个问题就是等到应用充分完成加载之后再暴露相关信息给注册中心,可以在服务注册到注册中心之前添加一个 hook 的过程,这样既可以等待应用完成加载,也可以让用户自行添加业务逻辑到其中,比如缓存预热逻辑等。

9、限流

这属于服务治理中的自我保护手段。一个服务提供方并不能无止境地提供服务,对于某些服务调用较频繁的情况,应该适当采取限流措施,为的是减少大批次多请求的侵入,避免服务被短时的大批量请求打崩,对于 RPC 框架来说,可以在其中集成限流的逻辑,也可以将限流作为单独的模块导入其中,常见的限流方式有:计数器、滑动窗口、漏斗算法、令牌桶算法等。 这里还应该考虑限流的维度,是应用层面的限流还是 IP 层面的限流,这需要针对不同的业务情况进行权衡。实际操作中可以由注册中心或配置中心将限流逻辑下发,根据总的可接受的请求量以及节点数目去平均限流数,但这种限流方法是单机的限流,因为每个节点接受到的流量并不是一定的均匀,如果想要进一步精进限流操作,可以引入单独的限流器,但在性能和可靠性上会有一定的问题。

10、熔断

这也是一种服务治理中的自我保护手段。RPC 调用中并不是所有的服务都是点对点的调用,大多数情况下都是有两个以上服务的调用链存在,那么如果调用链上的任何一个服务出问题,将会导致同一调用链上的服务崩盘,那么此时就需要有熔断机制来保证一个服务异常不影响其他服务运行。 熔断机制有打开、半打开、关闭三种状态,触发熔断之后会进入熔断器打开状态,进而后续所有请求都直接返回异常信息,而一段时间之后熔断器会转至半打开状态,此时可以通过一个请求,如果该请求正常得到响应,那么熔断器会进入关闭状态,否则重新进入打开状态。 在 RPC 框架中,建议在动态代理阶段插入熔断器的机制,因为这是一个请求发起的第一步,在发起请求时可先经过熔断器的检验,正常才让请求进入后续流程。

11、业务分组

在服务越来越多,调用越来越繁杂的情况下,如果只是将所有的请求都纳入到一个大池子中,那么出现业务事故的概率将大大增加,因此我们需要考虑使用上业务分组来对流量进行隔离。 常见的方式是将核心应用和非核心应用置于不同分组,隔离开分组之间的请求流量,进而避免影响核心应用的运行。 具体实现方法也是应用在写节点信息到注册中心这一步,在写入接口信息之外还需要考虑将分组参数带上,以便识别出服务的分组信息,服务之间按照重要程度进行组划分。 在分组完成之后还需要考虑高可用的情况,因为流量已经被归到不同的组了,如果恰好其中的核心交换机出现问题,那么分组可能就直接挂掉了,也就代表着服务完全挂了,这是不允许出现的,因此在写入分组时应该考虑冗余分组,也就是加入一个备用的分组信息,避免主分组出现异常导致服务完全不可用。

12、异步化思想

在业务中有时候会遇上 TPS 死活上不去,CPU 占用不够充分的情况,这时候应该考虑一下是否因为业务等待时间过长导致的利用率不高,这种情况下就应该考虑使用 RPC 异步调用来优化。 异步主要分为两个方面:调用端和服务端。 在调用端这里,主要思路就是在调用发起后并不等待结果返回,而是直接返回 future 对象,在服务端返回响应结果时,调用端会根据消息的唯一标识信息将结果注入到具体的 future 对象中,之后由具体的业务去使用 get 方法获取结果信息。这适合在需要同步调用多个业务逻辑的场景下使用,可以有效缩短响应时间。 服务端的异步调用需要借助回调来实现,这里主要实现的是业务逻辑的异步执行,也就是收到业务请求之后不等待结果返回,而是通过回调方法来实现调用结果的通知,进一步缩短业务调用的等待。 一般情况下为了实现 RPC 的全异步调用,会使用上 Java 原生的 CompletableFuture,在提升吞吐量的同时可以有效避免代码侵入。调用端在发起一次 RPC 调用之后会马上拿到一个 CompletableFuture 对象,此后无需任何额外的操作;服务端收到请求之后会创建一个 C.F.返回对象,之后可以在其他线程中去执行业务操作,完成之后调用 C.F.的 complete 方法完成异步通知。调用端在收到服务端的响应结果时再调用 C.F.的 get 方法获取返回结果值。

13、安全问题

需要考虑的是内部应用之间的通信安全,一般情况下服务提供方会定义好一个接口,并将 jar 包发布到私服上,之后定义对应的实现并向外暴露,服务调用方拿到 jar 包之后通过动态代理去完成调用。实际上在这个过程中只要拿到 jar 包就可以完成调用,无需知会服务提供方,这也就导致服务提供方无法得知调用方的情况,如果被恶意利用,那么会造成流量异常高,从而打垮服务。 最简单的解决方案就是引入一个授权平台,每次请求之前先去授权平台查看是否具有权限调用,没有就拒绝执行,提供方也可以自行调控授权机器,这样就解决了应用的权限问题。但是一旦引入一个新的系统势必会对整体的稳定性造成影响,在这种情况下授权平台需要保证超高可用,否则一旦出现问题,系统的调用就都被影响到了。 更进一步的解决方案是,引入不可逆的加密算法(例如 HMAC 算法),在提供方上设置私钥,之后发布到授权平台上,调用方向授权平台请求获取授权信息,平台通过提供方的私钥去生成一个唯一的授权密钥发送给调用方,之后调用方只需要在请求时带上该授权信息即可。 除此之外还应该关心服务发现的安全问题,如果不做限制,那么所有应用都可以成为某个接口的提供方,进而发布一些伪造的应用。在这种情况下,可以将接口和应用进行绑定,只允许相应的应用发布对应接口的提供服务,以此来限制其他应用发布伪造服务信息。

14、分布式环境定位问题

在本地开发中如果要定位问题通常只需要进行 debug,但在实际的线上服务中只能通过日志来查看异常信息,单机服务很好定位,如果是分布式的服务,并且调用链较为冗长那么想要排错就较为困难了。这时主要的解决方式有两种。 第一种是 RPC 框架内部对异常信息进行合理封装,需要做到在调用方收到异常信息的时候可以很明确清楚是哪一台机器发生的异常,在哪一个调用中出现的问题,之后对应解决,这种方式在调用链不复杂的情况下是很有效的,但如果调用链较长,那么很可能最后一个应用的异常信息被第一个应用所捕获,之后就是冗长的业务异常确认步骤,如果是跨部门的多个服务沟通起来将无比复杂。 第二种方式就是引入分布式链路追踪系统,其作用是将一次分布式请求还原成一个完整的调用链路,这样我们就可以追踪到链路上的各个节点的情况了。一个完整的链路成为 Trace,一个链路段称为 Span,对应地都有唯一的 ID,而在 RPC 中需要整合的两点是埋点和传递。 埋点就是为了完成数据的收集,在一次请求/回应中都会触发分布式跟踪埋点,这些埋点最终可以记录一个完整的 Span,链路的源头会记录一个完整的 Trace,后续上报给链路追踪系统;传递指的是,上游调用端将 Trace 信息和父 Span 信息传递到下游服务端,由下游去触发埋点,在分布式链路追踪系统中每个 Span 都持有父 Span 和 Trace 的相关信息。

0 人点赞