跳至主要內容

RPC 高级篇

holic-x...大约 102 分钟架构RPC

RPC 高级篇

学习核心

​ 基于上述RPC框架学习的知识储备,如果想要更深入地了解RPC、更好地使用RPC,就必须从RPC框架的整体性能上去考虑问题。需要知道如何去提升RPC框架的性能、稳定性、安全性、吞吐量,以及如何在分布式的场景下快速定位问题等

  • 异步RPC:压榨单机吞吐量(提升单机资源的利用率:异步化)

    • ① 问题切入:影响RPC调用吞吐量的主要因素在于服务端的业务逻辑耗时较多,RPC设计可以采用异步策略优化耗时任务处理
    • ② 解决方案:针对调用方、提供方的异步策略
      • 针对调用方:利用异步化机制并行调用多个服务,缩短整个服务调用时间
        • 采用Future进行异步调用,通过Future的get方法获取结果
      • 针对提供方:利用异步化机制将业务逻辑放到自定义线程池中执行,提升单机的QPS
        • 基于CompletableFuture(Java方案的最佳选择,Java8原生提供,无代码入侵性,使用上更加方便)
        • 集成RxJava,或者参考gRPCStreamObserver入参对象
  • 安全体系:如何建立可靠的安全体系?

    • 服务调用的安全认证问题
      • ① 问题切入:由于调用方和服务提供方的信息不对等,调用方不打招呼就调用服务,而提供方无法明确请求是否可放行,进而导致接口调用存在安全问题
      • ② 解决方案:
        • 传统方案(❌):口头约定,基于内部服务调用的流程规范,但无法从根本上控制接口调用
        • 安全认证(🟢):基于"授权、鉴权"机制,限定调用方的唯一标识,通过标识进行身份认证,只有通过授权的请求才能放行
          • 授权平台(数据收集): 授权平台的接入(基于授权平台处理,不管是单请求授权还是集中式授权,授权平台承载所有RPC流量,会成为性能瓶颈点)
          • 鉴权机制(鉴权/检票):授权方案优化(采用不可逆加密算法,由服务提供方进行集中式授权)
    • 服务发现的安全问题
      • ① 问题切入:对于暴露的jar服务接口,可能会存在伪造的服务提供方扰乱环境
        • 场景1:开发环境未完全隔离,本地调整服务接口发布到环境上和现有环境版本冲突,可能导致一些接口调用异常问题(例如开发、测试接口调用混乱等,别人测试的时候调用到还没开发完成但启动注册上去了的接口)
        • 场景2:伪造服务提供方,存在不可控因素
      • ② 解决方案:确保服务接口和服务提供是一对一的关系,确保服务接口只能由一个服务提供方(应用)提供(服务注册的时候验证接口和应用的绑定关系
  • 分布式环境下如何快速定位问题

    • ① 问题切入:在分布式环境下不免存在多服务依赖调用的场景,长链路调用、跨部门/团队合作等对于系统异常排查来说是一个非常大的成本
    • ② 解决方案:
      • (1)合理的异常封装信息:不管是对RPC框架还是服务提供方,需要封装合理的异常信息和文档梳理,便于通过异常信息快速跟踪问题节点(从异常信息中跟踪的应用信息,进而定位异常触发的服务节点)
      • (2)分布式链路追踪:在依赖关系复杂的跨部门分布式系统场景中,基于分布式链路追踪(TraceSpan概念核心),通过埋点收集链路调用信息,上游调用端将TraceSpan信息传递给下游服务,由下游服务触发埋点收集并处理信息。多个埋点可以构成Span,多个Span构成一条完整的Trace并最终上报给分布式链路追踪系统
  • 时钟轮在RPC框架中的应用

    • ① 问题切入:如何在RPC框架/应用中实现定时任务?
      • (1)每个线程创建一个定时任务(高并发场景下会导致创建大量的线程,无疑增加处理成本)
      • (2)一个线程负责处理所有定时任务,每个一段时间扫描定时任务(虽然避免了线程的消耗,但是每次扫描会导致任务的重复扫描,消耗CPU资源)
    • ② 解决方案:引入时钟轮概念,解决上述定时任务处理创建大量线程、定时任务重复扫描的痛点
      • 时钟轮:模拟时钟概念,根据任务执行时间将任务放置在指定的卡槽位置,然后以时钟轮的维度扫描每个卡槽任务并处理
    • ③ 时钟轮在RPC框架中的应用:RPC框架中涉及到定时任务的处理相关场景,均可以使用到时钟轮解决
      • (1)【调用端请求超时处理(高并发场景下的请求超时检测)】:调用端发起请求时,创建一个定时任务并放到时钟轮中,如果定时任务执行发现请求还没执行完成则介入处理
      • (2)【服务端或调用端启动超时】:例如限定1分钟内要启动服务,在启动应用的时候创建一个定时任务并放到时钟轮中,如果启动后1分钟定时任务执行发现应用启动失败则执行相应的逻辑
      • (3)【定时心跳】:RPC框架调用端定时向服务端发送心跳,来维护连接状态,可以将心跳的逻辑封装为一个心跳任务,放到时钟轮里
        • 因为时钟轮中的任务只会被执行一次,因此对于这类需要周期性重复定时启动的任务,可以在定时任务业务逻辑执行完成之后重置执行时间,让其重新加入到时钟轮中,以达到重复执行的目的
  • 流量回放:保障业务技术升级

    • ① 问题切入:当业务技术升级迭代版本的时候,一般情况下会根据现有的TestCase验证更新的应用或者做功能的回归测试,但这种情况下无法覆盖线上环境的复杂性。因此一般情况下还考虑借助线上的实际Case去验证改造后的应用。但实际上不可能直接去接入线上流量,而是采用"模拟"的方式实现,因此引入"流量回放"概念,录制一段时间内"线上流量"的请求和响应结果,然后用这些Case验证功能
    • ② 解决方案:流量回放
      • RPC本身就是用于打通服务间的通信的,它可以作为流量的载体(请求都会通过RPC)。如果在RPC中实现流量回放功能,那么对于引入RPC框架的服务来说,可以基于这个流量回放的功能,方便快捷地对改造后的应用进行测试验证,可以更加放心升级自己的应用
  • 动态分组:超高效实现秒级扩缩容(动态 & 隔离

    • ① 问题切入:
      • 业务分组:通过业务分组实现流量隔离,但对于分组容量分配和分组衍生问题仍有待商榷
        • (1)分组容量分配:一般情况下根据压测估算单机QPS、分组总调用量来估计大概所需的分组机器数量,并在此基础上预留一定的机器用于应对一些突发情况流量
        • (2)分组衍生问题:对于非常大的突发流量,单纯依赖上述的分配机制可能无法承载服务压力,因此会考虑通过扩容机器或者切换分组(借调)来增加集群的处理能力
          • 扩容(引入新机器):但是这个扩容新机器的过程通常需要耗费比较长的时间,如果时间拉的越长,业务受影响的范围就会也大
          • 切换分组并重启(借调):所谓切换分组时基于上述【分组容量分配】中为每个分组预留了一些冗余机器以应对突发流量的场景,如果当前分组无法承压则考虑借调其他分组的能力来承担。但是碍于分组(流量隔离)的设定,当前出问题调用方并不能直接将流量转发到其他有能力提供支持的分组集群上。需要通过手动调整配置并重启提供服务的分组集群,让有问题的调用方可以筛选到更多的服务提供方机器,但显然这个过程也是需要耗费一定的时间的
    • ② 解决方案:动态分组
      • 基于上述分组衍生问题分析,既然扩容和切换分组(借调)的思路耗费时间都相对较长,就要思考如何进一步切入。参考上述更改分组并重启的思路进一步分析,由于核心目的是为了让有问题的调用方可以筛选到更多的服务提供方机器,且调用方都是通过注册中心来进行服务发现,那么可以考虑通过修改注册中心的配置数据来解决映射问题
      • "逻辑映射"概念:此处引入逻辑映射概念,将注册中心里面的部分实例的别名改成指定的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。在这个过程中并不需要改动原有的业务分组,而是基于逻辑映射的思路,让调用方可以访问到其他分组的实例,以分摊流量
      • 冗余优化(共享池):上述【分组容量分配】思路中会给每个分组冗余一定数量的机器以支持突发流量,但实际上也可以采用"共享池"的概念,将机器冗余到"共享池"中做支撑,需要用就从池子中获取并配置,用完了还回去即可,这种思路可以一定程度上减少整体实例数量,提升实例应用率
  • 泛化调用:调用端如何在没有接口的情况下进行RPC调用

    • ① 问题切入:正常情况下要发布一个新的接口,需要定义接口(需注意此处的接口是接口定义API概念,而不是HTTP请求的那种接口形式),将相关jar上传到私服,然后重启服务。对于接口调用方来说需要引入/更新相关的jar,然后重新发布部署重启调用端来实现RPC调用。但是对于一些特殊场景来说,不可能做到每次发布新接口(新服务)时调用端都要同步更新、重启,例如下述两种场景中,平台/应用实际就是作为接口调用方,它无法做到实时同步服务提供方提供的所有接口,如果要同步相当于每更新一个接口就要同步刷新重启调用端
      • 【场景1】:统一测试平台
      • 【场景2】:轻量级服务网关
    • ② 解决方案:泛化调用
      • 基于"泛化调用"的思路,将要传递的信息传递给到调用端。可以理解为统一接口的概念,此处用Map形式来存储接口请求参数、响应参数,达到一种"适配"的处理方式,通过这个泛化调用的接口的指定参数可以实现在没有接口的情况下完成RPC调用
      • 序列化和反序列化处理:基于框架的自定义扩展组件设计,可以自定义序列化机制以组件的形式嵌入到PRC框架,以支持自定义的序列化和反序列化规则
  • RPC服务治理:如何在线上环境中兼容多种RPC协议?

    • ① 问题切入:不同时期引入的RPC框架的技术栈选择不同,为了解决早期遗留的技术负债问题,需要选择一个统一的治理方案来解决RPC框架混轮的问题以降低运维成本
    • ② 解决方案:
      • 【思路1】:全盘整改,在团队比较小的情况下采用断崖式更新的方案,改造应用,统一RPC框架。但是在大团队和复杂应用场景下,改造的成本之大可想而知,且会带来不可预知的风险(切换整改需要梳理各个应用的调用关系,才能推进升级)
      • 【思路2】:平滑切入,应用升级时在不影响原有的PRC框架运作的情况下接入新的RPC框架,让两种框架同时提供服务,等待所有的应用接入新RPC之后,再逐步切换到新的RPC,已达到无序平滑升级的目的(切换成本还是过高,至少需要两次上线才能完成新老RPC框架切换)
    • ③ 为了优化上述切换过程,需要思考如何经过一次上线就完成平滑过渡。从应用升级的角度分析,需要让新RPC框架兼容老RPC框架的协议,让其本身支持多种PRC调用,进而完成平滑切换
  • 扩展篇章

    • RPC框架代码实例分析:结合joyrpc框架理解RPC框架的设计思想和代码实现核心
    • 基础与进阶思考题分析
    • 常用RPC框架介绍

学习资料

异步RPC:压榨单机吞吐量

1.场景分析

❓如何提升单机吞吐量?

​ 在实际开发过程中,业务团队反馈过 TPS始终上不去,压测的时候CPU压到40%~50%就再也压不上去了,TPS也不会提高,问有没有什么解决方案可以提升业务的吞吐量?

​ 通过排查服务的业务逻辑,发现业务逻辑在执行较为耗时的业务逻辑的基础上,又同步调用了好几个其它的服务。由于这几个服务的耗时较长,才导致这个服务的业务逻辑耗时也长,CPU大部分的时间都在等待,并没有得到充分地利用,因此CPU的利用率和服务的吞吐量当然上不去。

​ **什么影响到了RPC调用的吞吐量?**在使用RPC的过程中,谈到性能和吞吐量,第一反应就是选择一款高性能、高吞吐量的RPC框架,那影响到RPC调用的吞吐量的根本原因是什么呢?

​ 其实根本原因就是由于处理RPC请求比较耗时,并且CPU大部分的时间都在等待而没有去计算,从而导致CPU的利用率不够。这就好比一个人在干活,但他没有规划好时间,并且有很长一段时间都在闲着,当然也就完不成太多工作了。

​ 那么导致RPC请求比较耗时的原因主要是在于RPC框架本身吗?事实上除非在网络比较慢或者使用方使用不当的情况下,否则,在大多数情况下,刨除业务逻辑处理的耗时时间,RPC本身处理请求的效率就算在比较差的情况下也不过是毫秒级的。可以说RPC请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢SQL的操作。所以说,在大多数情况下,影响到RPC调用的吞吐量的原因大概率是业务逻辑处理慢了,CPU大部分时间都在等待资源。

​ **该如何去提升单机吞吐量?**实际上这并不是一个新话题,比如现在经常提到的响应式开发,就是为了能够提升业务处理的吞吐量。要提升吞吐量,其实关键就两个字:“异步”。RPC框架要做到完全异步化,实现全异步RPC。试想一下,如果每次发送一个异步请求,发送请求过后请求即刻就结束了,之后业务逻辑全部异步执行,结果异步通知,这样可以增加多么可观的吞吐量?

2.异步RPC(RPC的异步策略)

❓调用端如何异步?

​ 说到异步,最常用的方式就是返回Future对象的Future方式,或者入参为Callback对象的回调方式,而Future方式可以说是最简单的一种异步方式。发起一次异步请求并且从请求上下文中拿到一个Future,之后就可以调用Future的get方法获取结果。

​ 假设在一个业务场景中,服务的业务逻辑中调用了好几个其它的服务,这时如果是同步调用,假设调用了4个服务,每个服务耗时10毫秒,那么业务逻辑执行完至少要耗时40毫秒。如果采用Future方式,会连续发送4次异步请求并且拿到4个Future,由于是异步调用,这段时间的耗时几乎可以忽略不计,之后统一调用这几个Future的get方法,如此一来业务逻辑执行完的时间在理想的情况的耗时是10毫秒,耗时整整缩短到了原来的四分之一,也就是说,吞吐量有可能提升4倍!

image-20241224093645474

那RPC框架的Future方式异步又该如何实现呢?

​ 通过基础篇的学习,可以了解到一次RPC调用的本质就是调用端向服务端发送一条请求消息,服务端收到消息后进行处理,处理之后响应给调用端一条响应消息,调用端收到响应消息之后再进行处理,最后将最终的返回值返回给动态代理。

​ 基于此,对于调用端来说,向服务端发送请求消息与接收服务端发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行。那么是不是说RPC框架的调用端,对于RPC调用的处理逻辑,内部实现就是异步的呢?

​ 对于RPC框架,无论是同步调用还是异步调用,调用端的内部实现都是异步的。

​ 调用端发送的每条消息都一个唯一的消息标识,实际上调用端向服务端发送请求消息之前会先创建一个Future,并会存储这个消息标识与这个Future的映射,动态代理所获得的返回值最终就是从这个Future中获取的;当收到服务端响应的消息时,调用端会根据响应消息的唯一标识,通过之前存储的映射找到对应的Future,将结果注入给那个Future,再进行一系列的处理逻辑,最后动态代理从Future中获得到正确的返回值。

​ 所谓的同步调用,不过是RPC框架在调用端的处理逻辑中主动执行了这个Future的get方法,让动态代理等待返回值;而异步调用则是RPC框架没有主动执行这个Future的get方法,用户可以从请求上下文中得到这个Future,自己决定什么时候执行这个Future的get方法。

image-20241224094042947

❓如何做到RPC调用全异步?

是否需要支持服务端异步?

​ Future方式异步可以说是调用端异步的一种方式,那么服务端呢?服务端是否需要异步,有什么实现方式?

​ 通过基础篇的学习,了解到RPC服务端接收到请求的二进制消息之后会根据协议进行拆包解包,之后将完整的消息进行解码并反序列化,获得到入参参数之后再通过反射执行业务逻辑。

​ 可以试着思考一个问题,在生产环境中这些操作都在哪个线程中执行呢?是在一个线程中执行吗?=》当然不会在一个,对二进制消息数据包拆解包的处理是一定要在处理网络IO的线程中,如果网络通信框架使用的是Netty框架,那么对二进制包的处理是在IO线程中,而解码与反序列化的过程也往往在IO线程中处理。

​ 那服务端的业务逻辑呢?也应该在IO线程中处理吗?=》原则上是不应该的,业务逻辑应该交给专门的业务线程池处理,以防止由于业务逻辑处理得过慢而影响到网络IO的处理

​ 那么这时问题就来了,配置的业务线程池的线程数都是有限制的,参考运营RPC的经验,业务线程池的线程数一般只会配置到200,因为在大多数情况下线程数配置到200还不够用就说明业务逻辑该优化了。那么如果碰到特殊的业务场景呢?让配置的业务线程池完全打满了,比如这样一个场景,这里启动一个服务,业务逻辑处理得就是比较慢,当访问量逐渐变大时,业务线程池很容易就被打满了,吞吐量很不理想,并且这时CPU的利用率也很低。对于这个问题,有没有想到什么解决办法呢?是不是会马上想到调大业务线程池的线程数?那这样可以吗?有没有更好的解决方式呢?=》服务端业务处理逻辑异步是个好方法

调大业务线程池的线程数:的确勉强可以解决这个问题,但是对于RPC框架来说,往往都会有多个服务共用一个线程池的情况,即使调大业务线程池,比较耗时的服务很可能还会影响到其它的服务。所以最佳的解决办法是能够让业务线程池尽快地释放,那么就需要RPC框架能够支持服务端业务逻辑异步处理,这对提高服务的吞吐量有很重要的意义。

服务端如何支持业务逻辑异步呢?

​ 这是个比较难处理的问题,因为服务端执行完业务逻辑之后,要对返回值进行序列化并且编码,将消息响应给调用端,但如果是异步处理,业务逻辑触发异步之后方法就执行完了,来不及将真正的结果进行序列化并编码之后响应给调用端。这时就需要RPC框架提供一种回调方式,让业务逻辑可以异步处理,处理完之后调用RPC框架的回调接口,将最终的结果通过回调的方式响应给调用端。

​ 说到服务端支持业务逻辑异步处理,除了上述讲解的Future方式异步,其实可以让RPC框架支持CompletableFuture,实现RPC调用在调用端与服务端之间完全异步

​ CompletableFuture是Java8原生支持的。试想一下,假如RPC框架能够支持CompletableFuture,我现在发布一个RPC服务,服务接口定义的返回值是CompletableFuture对象,整个调用过程会分为这样几步:

  • 服务调用方发起RPC调用,直接拿到返回值CompletableFuture对象,之后就不需要任何额外的与RPC框架相关的操作了(如我刚才讲解Future方式时需要通过请求上下文获取Future的操作),直接就可以进行异步处理;
  • 在服务端的业务逻辑中创建一个返回值CompletableFuture对象,之后服务端真正的业务逻辑完全可以在一个线程池中异步处理,业务逻辑完成之后再调用这个CompletableFuture对象的complete方法,完成异步通知;
  • 调用端在收到服务端发送过来的响应之后,RPC框架再自动地调用调用端拿到的那个返回值CompletableFuture对象的complete方法,这样一次异步调用就完成了;

通过对CompletableFuture的支持,RPC框架可以真正地做到在调用端与服务端之间完全异步,同时提升了调用端与服务端的两端的单机吞吐量,并且CompletableFuture是Java8原生支持,业务逻辑中没有任何代码入侵性,是一个非常好的解决方案方向

3.总结

​ 影响到RPC调用的吞吐量的主要原因就是服务端的业务逻辑比较耗时,并且CPU大部分时间都在等待而没有去计算,导致CPU利用率不够,而提升单机吞吐量的最好办法就是使用异步RPC。

​ RPC框架的异步策略主要是调用端异步服务端异步。调用端的异步就是通过Future方式实现异步,调用端发起一次异步请求并且从请求上下文中拿到一个Future,之后通过Future的get方法获取结果,如果业务逻辑中同时调用多个其它的服务,则可以通过Future的方式减少业务逻辑的耗时,提升吞吐量。服务端异步则需要一种回调方式,让业务逻辑可以异步处理,之后调用RPC框架提供的回调接口,将最终结果异步通知给调用端。

​ 另外,可以通过对CompletableFuture的支持,实现RPC调用在调用端与服务端之间的完全异步,同时提升两端的单机吞吐量。

​ 其实,RPC框架也可以有其它的异步策略,比如集成RxJava,再比如gRPC的StreamObserver入参对象,但CompletableFuture是Java8原生提供的,无代码入侵性,并且在使用上更加方便。如果是Java开发,让RPC框架支持CompletableFuture可以说是最佳的异步解决方案。

安全体系:如何建立可靠的安全体系?

1.场景分析

❓为什么要考虑安全问题?

​ 说起安全问题,可能会想到像SQL注入、XSS攻击等恶意攻击行为,还有就是相对更广义的安全,像网络安全、信息安全等,那在RPC里面我们说的安全一般指什么呢?

​ RPC是解决应用间互相通信的框架,而应用之间的远程调用过程一般不会暴露在公网,换句话讲就是说RPC一般用于解决内部应用之间的通信,而这个“内部”是指应用都部署在同一个大局域网内。相对于公网环境,局域网的隔离性更好,也就相对更安全,所以在RPC里面很少考虑像数据包篡改、请求伪造等恶意行为。

​ **那在RPC里面我们应该关心什么样的安全问题呢?**要搞清楚这个问题,可以先看一个完整的RPC应用流程。

​ 服务提供方:一般是先由服务提供方定义好一个接口,并把这个接口的Jar包发布到私服上去,然后在项目中去实现这个接口,最后通过RPC提供的API把这个接口和其对应的实现类完成对外暴露,如果是Spring应用的话直接定义成一个Bean就好了。到这儿,服务提供方就完成了一个接口的对外发布了。

​ 服务调用方:对于服务调用方来说就更简单了,只要拿到刚才上传到私服上的Jar的坐标,就可以把发布到私服的Jar引入到项目中来,然后借助RPC提供的动态代理功能,服务调用方直接就可以在项目完成RPC调用

​ 但这里面其实存在一个安全隐患问题,因为私服上所有的Jar坐标所有人都可以看到,只要拿到了Jar的坐标,就可以把发布到私服的Jar引入到项目中完成RPC调用了吗?理论上确实是这样,一般来说在公司内部这种不向服务提供方咨询就直接调用的行为很少发生,而且一般真实业务的接口出入参数都不会太简单,这样不经过咨询只靠调用方自己猜测完成调用的工作效率实在太低了。

​ 虽然这种靠猜测调用的概率很小,但是当调用方在其它新业务场景里面要用之前项目中使用过的接口,就很有可能真的不跟服务提供方打招呼就直接调用了。这种行为对于服务提供方来说就很危险了,因为接入了新的调用方就意味着承担的调用量会变大,有时候很有可能新增加的调用量会成为压倒服务提供方的“最后一根稻草”,从而导致服务提供方无法正常提供服务,关键是服务提供方还不知道是被谁给压倒的。

​ 当然这个问题往小了说就是一个开发流程问题,只要在公司内部规范好调用流程,就可以避免这种问题发生。可以通过流程宣贯让我们所有的研发人员达成一个“君子约定”,就是在应用里面每次要用一个接口的时候必须先向服务提供方进行报备,这样确实能在很大程度上避免这种情况的发生。但就RPC本身来说,是不是可以提供某种功能来解决这种问题呢?毕竟对于人数众多的团队来说,光靠口头约定的流程并不能彻底杜绝这类问题,依然存在隐患,且不可控。

2.安全体系构建

⚽调用方之间的安全保证

​ 上述问题主要是在于服务提供方和服务调用方的信息不对等,调用方不通气就直接调用服务,而提供方无法识别服务(甚至无法识别哪个服务是压死自己的最后一根稻草),所以当服务方接收到调用方请求之后,无法明确这个请求是否要放行。对于这个问题可以采用**"鉴权"**的思路处理,给定每个调用方一个唯一的身份标识,当调用方要调用服务接口时需要在服务提供方这边登记下身份,只有登记过身份信息(打过招呼)的调用方才予以放行

​ 结合现实生活场景:这就好比我们平时坐火车,乘客拿着身份证去购买火车票,买票成功就类似服务调用方去服务提供方这儿进行登记。当乘客进站准备上火车的时候,就必须同时出示身份证和火车票,这两个就是代表乘客能上这趟火车的“唯一身份”,只有验证了身份,负责检票的工作人员才会放行,否则会直接拒绝你乘车

RPC 中如何实现鉴权策略?

① 授权平台的接入(基于授权平台处理,不管是单请求授权还是集中式授权,授权平台承载所有RPC流量,会成为性能瓶颈点)

数据收集:提供一个可进行数据收集,也就是提供接口登记、接口审批的地方(授权平台),调用方可以在授权平台上申请自己应用里面要调用的接口,而服务提供方则可以在授权平台上进行审批,只有服务提供方审批后调用方才能调用

授权认证:可以基于授权平台,完成授权认证操作。当调用方每次发起业务请求的时候先去发一条认证请求到授权平台上以确认是否可以调用这个接口,如果认证通过则可以将请求继续发送到服务提供方

image-20241224103340098

​ 从使用功能的角度来说,目前这种设计是没有问题的,而且整个认证过程对RPC使用者来说也是透明的。但有一个问题就是这个授权平台承担了公司内所有RPC请求的次数总和,当公司内部RPC使用程度高了之后,这个授权平台就会成为一个瓶颈点,而且必须保证超高可用,一旦这个授权平台出现问题,影响的可就是全公司的RPC请求。

集中式授权平台:基于上述问题可以进一步思考,既然授权是针对服务应用,那么其实是不是不需要把这个认证的逻辑放到业务请求过程中,而是可以把这个认证过程挪到初始化过程中?实际上这样确实可以在很大程度上减少授权平台的压力,但本质并没有发生变化,它还是一个集中式的授权平台。

② 授权方案优化(采用不可逆加密算法,由服务提供方进行集中式授权)

​ 上述"检票"的过程中是将重心放在"授权平台",但是如果将所有RPC请求的流量都打到这个授权平台上的话,不管是单请求授权还是集中式授权都依然存在性能瓶颈问题,因此可以换个角度思考,将授权放在"服务提供方"?

​ 本质上调用方能不能调通接口是由服务提供方决定的,因此可以考虑将鉴权操作放在服务提供方,在调用方启动初始化接口的时候,带上授权平台上颁发的身份去服务提供方认证下,当认证通过后就认为这个接口可以调用。

​ 但此处需要思考一个问题,服务提供方鉴权的凭证是什么?授权数据是放在授权平台上的,服务提供方鉴权的时候要从授权平台拉数据吗?如果真是这样的话就又回到起点了(相当于切换成服务提供方请求的压力)。实际上为了解决这个问题,可以思考是不是有一种方案可以把凭证保存到服务提供方,这样就不用从授权平台上拉数据了。

​ 基于此,可以从加密算法这块入手,加密算法里面有一种叫做不可逆加密算法,HMAC就是其中一种具体实现。服务提供方应用里面放一个用于HMAC签名的私钥,在授权平台上用这个私钥为申请调用的调用方应用进行签名,这个签名生成的串就变成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,只要需要验证下这个签名跟调用方应用信息是否对应得上就行了,这样集中式授权的瓶颈也就不存在了

⚽服务发现的安全问题

​ 场景分析:回顾完整的RPC应用流程里面,服务提供方会把接口Jar发布到私服上,以方便调用方能引入到项目中快速完成RPC调用,那有没有可能有人拿到这个Jar后,发布出来一个服务提供方呢?这样的后果就是导致调用方通过服务发现拿到的服务提供方IP地址集合里面会有那个伪造的提供方。

最典型的一个场景就是在微服务模块开发的时候,例如本地修改了服务接口,想要启动调试的时候,如果本地接口服务启动则会将接口发布到注册中心(主要考虑到本地开发环境和部署的DEV环境没有隔离,将接口发布到同一个注册中心),而此时调用方会根据相关的调用策略有可能调用本地启动的接口,可能就会导致一些异常调用问题(这个是在开发过程中由于环境未隔离导致的一个小问题,既然有了这个开口,在实际生产上也有可能会存在上述伪造服务提供方的情况,进而导致业务受损)

​ 当然,这种情况相对上面说的调用方未经过咨询就直接调用的概率会小很多,但为了让系统整体更安全,也需要在RPC里面考虑这种情况。要解决这个问题的根本就是需要把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口。

​ 上述问题主要在于一个服务接口存在多个服务提供方,因此需要确保服务接口和服务提供方是一对一的关系,进而避免伪造的提供方搞搞阵。服务提供方启动的时候,需要把接口实例在注册中心进行注册登记,可以利用这个流程,注册中心可以在收到服务提供方注册请求的时候,验证下请求过来的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。

3.总结

​ RPC框架经常用于解决内网应用之间的调用,内网环境相对公网也没有那么恶劣,但也有必要去建立一套可控的安全体系,去防止一些错误行为。对于RPC来说,此处所关心的安全问题不会有公网应用那么复杂,只要保证让服务调用方能拿到真实的服务提供方IP地址集合,且服务提供方可以管控调用自己的应用即可,从上述服务调用、服务发现这两块的安全问题切入处理

分布式环境下如何快速定位问题?

1.场景分析

在开发和生产环境运行的过程中遇到问题如何进行定位?

​ 在开发过程中遇见问题最简单的方式就是通过debug方式处理,可以用IDE在自己本地的开发环境中运行一遍代码,进行debug,在这个过程中是一般比较容易定位

​ 但是如果切换到生产环境,代码在线上运行业务,是不能进行debug的。对于大部分问题的定位,最简单有效的一种方式就是通过打印日志来查看当前的异常日志

​ 那么如果是在分布式的生产环境中呢?比如下面这个场景:搭建了一个分布式的应用系统,在这个应用系统中,我启动了4个子服务,分别是服务A、服务B、服务C与服务D,而这4个服务的依赖关系是A->B->C->D,而这些服务又都部署在不同的机器上。在RPC调用中,如果服务端的业务逻辑出现了异常,就会把异常抛回给调用端,那么如果现在这个调用链中有一个服务出现了异常,该如何定位问题呢?

​ 针对上述问题可能第一反应还是打印日志,当发现服务A调用响应异常就会去跟踪服务A的异常日志信息,但实际上此时可能并不是因为服务A异常导致,而是因为其调用了服务B、C、D这条链路的某个节点出现了问题导致异常回抛。那么这样就会涉及到一系列的问题了:

​ ① 怎么确定在整个应用系统中,是哪一个调用步骤出现的问题,以及是在这个步骤中的哪台机器出现的问题呢?

​ ② 该在哪台机器上打印日志?

​ ③ 为了排查问题,如果要打印日志,就必须要修改代码,这样的话就得重新对服务进行上线

​ ④ 如果这几个服务又恰好是跨团队跨部门的呢?想想即将要面临的沟通成本会不免心塞

​ 所以,分布式环境下定位问题的难点就在于,各子应用、子服务间有着复杂的依赖关系,有时很难确定是哪个服务的哪个环节出现的问题。简单地通过日志排查问题,就要对每个子应用、子服务逐一进行排查,很难一步到位;若恰好再赶上跨团队跨部门,那不死也得去半条命了。

2.如何做到快速定位问题?

​ 理解了问题的重点难点,那么就要有针对性地去攻克它,有关RPC在分布式环境下如何快速定位问题,可以从下述方向切入

⚽方法①:借助合理封装的异常信息

​ 前面说是因为各子应用、子服务间复杂的依赖关系,所以通过日志难定位问题。那么就想办法通过日志定位到是哪个子应用的子服务出现问题。

​ 其实,在RPC框架打印的异常信息中,是包括定位异常所需要的异常信息的,比如是哪类异常引起的问题(如序列化问题或网络超时问题),是调用端还是服务端出现的异常,调用端与服务端的IP是什么,以及服务接口与服务分组都是什么等等。具体如下图所示:

image-20241224112223684

​ 基于上述图示分析,借助回抛的异常信息,可以很快定位到整个调用链路上是由于服务C出现问题,服务接口是com.demo.CSerivce,调用端IP是192.168.1.2,服务端IP是192.168.1.3,而出现问题的原因就是业务线程池满了。

​ 由此可见,一款优秀的RPC框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档。使用方可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因;并且异常信息中要包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端与服务端的IP,以及产生异常的原因。总之就是,要让使用方在复杂的分布式应用系统中,根据异常信息快速地定位到问题。

​ 以上是对于RPC框架本身的异常来说的,比如序列化异常、响应超时异常、连接异常等等。那服务端业务逻辑的异常呢?服务提供方提供的服务的业务逻辑也要封装自己的业务异常信息,从而让服务调用方也可以通过异常信息快速地定位到问题。

⚽方法②:借助分布式链路跟踪

分布式链路概念跟踪切入

​ 无论是RPC框架本身,还是服务提供方提供的服务,只要对异常信息进行合理地封装,就可以让分布式环境下定位问题变得更加容易。那这样是不是就满足定位问题的需求了呢?

​ 还是回到前面提过的那个分布式场景:搭建了一个分布式的应用系统,它由4个子服务组成,4个服务的依赖关系为A->B->C->D。假设这4个服务分别由来自不同部门的4个同事维护,在A调用B的时候,维护服务A的同事可能是不知道存在服务C和服务D的,对于服务A来说,它的下游服务只有B服务,那这时如果服务C或服务D出现异常,最终在整个链路中将异常抛给A了呢?

​ 在这种情况下维护服务A的同事该如何定位问题呢?因为对于A来说,它可能是不知道下游存在服务C和服务D的,所以维护服务A的同事会直接联系维护服务B的同事,之后维护服务B的同事会继续联系下游服务的服务提供方,直到找到问题。可这样做成本很高啊!

​ 现在可以换个思路,其实只要知道整个请求的调用链路就可以了。服务A调用下游服务B,服务B又调用了B依赖的下游服务,如果维护服务A的同事能清楚地知道整个调用链路,并且能准确地发现在整个调用链路中是哪个环节出现了问题。这就好比我们收发快递,可以在平台上看到快递配送的轨迹,实时获知快递在何时到达了哪个站点,当没有准时地收到快递时,马上就能知道快递是在哪里延误了。

​ 在分布式环境下,要想知道服务调用的整个链路,可以用"分布式链路跟踪":从字面上理解,分布式链路跟踪就是将一次分布式请求还原为一个完整的调用链路,可以在整个调用链路中跟踪到这一次分布式请求的每一个环节的调用情况,比如调用是否成功、返回什么异常,调用的哪个服务节点以及请求耗时等等。当发现服务调用出现问题,通过这个方法,就能快速定位问题,哪怕是多个部门合作,也可以一步到位。

在RPC框架中是如何整合分布式链路跟踪的?

​ 分布式链路跟踪核心概念:TraceSpan的概念

Trace就是代表整个链路,每次分布式都会产生一个Trace,每个Trace都有它的唯一标识即TraceId,在分布式链路跟踪系统中,就是通过TraceId来区分每个Trace的。

Span就是代表了整个链路中的一段链路,也就是说Trace是由多个Span组成的。在一个Trace下,每个Span也都有它的唯一标识SpanId,而Span是存在父子关系的。以上述场景案例为例子,在A->B->C->D的情况下,在整个调用链中,正常情况下会产生3个Span,分别是Span1(A->B)、Span2(B->C)、Span3(C->D),这时Span3的父Span就是Span2,而Span2的父Span就是Span1

​ Trace与Span的关系如下图所示:

image-20241224132310081

​ 分布式链路跟踪系统的实现方式有很多,但它们都脱离不开上述TraceSpan核心,掌握了这两个重要概念,其实就掌握了大部分实现方式的原理。

​ RPC在整合分布式链路跟踪需要做的最核心的两件事就是埋点传递。所谓埋点就是说,分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过RPC框架对分布式链路跟踪进行埋点。

​ RPC调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的Span,而这个链路的源头会记录一个完整的Trace,最终Trace信息会被上报给分布式链路跟踪系统

​ 那所谓“传递”就是指,上游调用端将Trace信息与父Span信息传递给下游服务的服务端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统中,每个子Span都存有父Span的相关信息以及Trace的相关信息。

核心:上游调用端将Trace信息与父Span信息传递给下游服务的服务端,由下游触发埋点以收集并处理信息,这些埋点最终可以记录一个完整的Span,多个Span构成一条完整的Trace链路,最终Trace信息会被上报给分布式链路跟踪系统

3.总结

​ 分布式系统有着较为复杂的依赖关系,很难判断出是哪个环节出现的问题,而且在大型的分布式系统中,往往会有跨部门、跨团队合作的情况,在排查问题的时候会面临非常高的沟通成本。

​ 为了在分布式环境下能够快速地定位问题,RPC框架应该对框架自身的异常进行详细地封装,每类异常都要有明确的异常标识码,并将其整理成一份简明的文档,异常信息中要尽量包含服务接口名、服务分组、调用端与服务端的IP,以及产生异常的原因等信息,这样对于使用方来说就非常便捷了。

​ 另外,服务提供方在提供服务时也要对异常进行封装,以方便上游排查问题。

​ 在分布式环境下,还可以通过分布式链路跟踪来快速定位问题,尤其是在多个部门的合作中,这样做可以一步到位,减少排查问题的时间,降低沟通成本,以最高的效率解决实际问题。

详解时钟轮在RPC中的应用

1.场景分析

❓ 定时任务带来什么问题?

​ 在开发的过程中,很多场景都会使用到定时任务,在RPC框架中也有很多地方会使用到它。以调用端请求超时的处理逻辑为例,理解RPC框架是如何处理超时请求的

​ 在【异步化解决耗时任务处理以提升单机吞吐量】的相关篇章中学习到Future的时候有提到:无论是同步调用还是异步调用,调用端内部实行的都是异步,而调用端在向服务端发送消息之前会创建一个Future,并存储这个消息标识与这个Future的映射,当服务端收到消息并且处理完毕后向调用端发送响应消息,调用端在接收到消息后会根据消息的唯一标识找到这个Future,并将结果注入给这个Future。

试想一下如果在这个过程中服务端没有及时响应消息给调用端呢?调用端该如何处理超时的请求?=》引入定时任务,每次创建一个Future,都记录这个Future的创建时间与这个Future的超时时间,并且有一个定时任务进行检测,当这个Future到达超时时间并且没有被处理时,就对这个Future执行超时逻辑。

如何实现这个定时任务?

  • 方式①:每创建一个Future都启动一个线程,之后sleep,到达超时时间就触发请求超时的处理逻辑
    • 优势:简单场景下适用,开发简单、思路易理解
    • 弊端:高并发请求场景中,如果单机每秒发送数万次请求,请求超时时间设置的是5秒,那需要创建超过10万个线程用来执行超时任务,这个数字真的够吓人了
  • 方式②:用一个线程来处理所有的定时任务,以Future超时处理的例子为例。假设要启动一个线程,这个线程每隔100毫秒会扫描一遍所有的处理Future超时的任务,当发现一个Future超时了,就执行这个任务,对这个Future执行超时逻辑。
    • 优势:解决了方式①中线程过多的问题
    • 弊端:轮询扫描,浪费CPU资源(例如上述案例中,同样针对高并发的请求,扫描任务的线程每隔100毫秒要扫描多少个定时任务呢?如果调用端刚好在1秒内发送了1万次请求,这1万次请求要在5秒后才会超时,那么那个扫描的线程在这个5秒内就会不停地对这1万个任务进行扫描遍历,要额外扫描40多次(每100毫秒扫描一次,5秒内要扫描近50次),很浪费CPU)

​ 在使用定时任务时,它所带来的问题,就是让CPU做了很多额外的轮询遍历操作,浪费了CPU,这种现象在定时任务非常多的情况下,尤其明显。

2.时钟轮

❓ 什么是时间轮?

​ 上述的场景中,主要的问题是额外的轮询遍历操作,从这个点切入思考如何减少额外的扫描操作。例如一批定时任务是5秒之后执行,如果在4.9秒之后才开始扫描这批定时任务,这样就大大地节省了CPU。此时就可以利用时钟轮的机制了

​ 先回忆下日常生活中用到的时钟,时钟有时针、分针和秒针,秒针跳动一周之后,也就是跳动60个刻度之则分针跳动1次,分针跳动60个刻度则时针走动一步。时钟轮的实现原理就是参考了生活中的时钟跳动的原理。

image-20241224141641199

​ 在时钟轮机制中,有时间槽和时钟轮的概念,时间槽就相当于时钟的刻度,而时钟轮就相当于秒针与分针等跳动的一个周期,将每个任务放到对应的时间槽位上。

​ 时钟轮的运行机制和生活中的时钟也是一样的,每隔固定的单位时间,就会从一个时间槽位跳到下一个时间槽位,这就相当于秒针跳动了一次;时钟轮可以分为多层,下一层时钟轮中每个槽位的单位时间是当前时间轮整个周期的时间,这就相当于1分钟等于60秒钟;当时钟轮将一个周期的所有槽位都跳动完之后,就会从下一层时钟轮中取出一个槽位的任务,重新分布到当前的时钟轮中,当前时钟轮则从第0槽位从新开始跳动,这就相当于下一分钟的第1秒。

时钟轮场景模拟

​ 为了进一步了解时钟轮的运行机制,可以用一个场景例子来模拟下:

① 初始化:假设时钟轮有10个槽位,而时钟轮一轮的周期是1秒,那么每个槽位的单位时间就是100毫秒1000ms/10个槽位),而下一层时间轮的周期就是10秒,每个槽位的单位时间也就是1秒,并且当前的时钟轮刚初始化完成,也就是第0跳,当前在第0个槽位。

② 任务存放:假设有3个任务,分别为:任务(90毫秒之后执行)、任务B(610毫秒之后执行)与任务C(1秒610毫秒之后执行),将这3个任务按照开始执行时间添加到时钟轮中,则任务A被放到第0槽位,任务B被放到第6槽位,任务C被放到下一层时间轮的第1槽位

③ 任务执行

  • 当任务A刚被放到时钟轮,就被即刻执行了,因为它被放到了第0槽位,而当前时间轮正好跳到第0槽位(实际上还没开始跳动,状态为第0跳);
  • 600毫秒之后,时间轮已经进行了6跳,当前槽位是第6槽位,第6槽位所有的任务都被取出执行;
  • 1秒钟之后,当前时钟轮的第9跳已经跳完,重新开始了第0跳,这时下一层时钟轮从第0跳跳到了第1跳,将第1槽位的任务取出,分布到当前的时钟轮中,这时任务C从下一层时钟轮中取出并放到当前时钟轮的第6槽位;1秒600毫秒之后,任务C被执行

image-20241224142644608

​ 基于时间轮的设计,虽然扫描周期还是100ms,但是任务并没有过多的重复扫描,解决了CPU浪费的问题。时间轮的设计核心在于可以理解为缩小扫描范围,将任务放在指定的时间槽,只扫描近期内要执行的任务,避免任务被重复扫描。虽然机制理解并不难,但是具体的实现还是需要注意非常多的细节

⚽ 时钟轮在RPC中的应用

​ 通过上述对时钟轮概念的梳理,实际上它的应用就是用于执行定时任务,避免重复的任务扫描。既然如此,可以理解为在RPC框架中只要涉及定时相关的操作,就可以使用时钟轮:核心在于创建一个定时任务,放到时钟轮中排队等待任务执行定时逻辑

​ ① 例如上述案例场景中针对【调用端请求超时处理】,可以应用时钟轮,每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的CPU。

​ ②【调用端与服务端启动超时】也可以应用到时钟轮,以调用端为例,假设想要让应用可以快速地部署(例如1分钟内启动,如果超过1分钟则启动失败),则可以在调用端启动时创建一个处理启动超时的定时任务,放到时钟轮里。

​ ③ 【定时心跳】RPC框架调用端定时向服务端发送心跳,来维护连接状态,可以将心跳的逻辑封装为一个心跳任务,放到时钟轮里

​ 此处可能会有一个疑问,定时心跳需要定时重复执行,而时钟轮的任务被执行一遍就被移除了,这就涉及到对于这种需要重复执行的定时任务该如何处理。最简单的解决方案可以在定时任务的执行逻辑的最后,可以重设这个任务的执行时间,把它重新丢回到时钟轮里

3.总结

​ 此处介绍时钟轮的机制,以及时钟轮在RPC框架中的应用。这个机制很好地解决了定时任务中,因每个任务都创建一个线程,导致的创建过多线程的问题,以及一个线程扫描所有的定时任务,让CPU做了很多额外的轮询遍历操作而浪费CPU的问题。

时钟轮的实现机制就是模拟现实生活中的时钟,将每个定时任务放到对应的时间槽位上,这样可以减少扫描任务时对其它时间槽位定时任务的额外遍历操作。

在时间轮的使用中,有些问题需要额外注意:

  • 时间槽位的单位时间越短,时间轮触发任务的时间就越精确。例如时间槽位的单位时间是10毫秒,那么执行定时任务的时间误差就在10毫秒内,如果是100毫秒,那么误差就在100毫秒内。
  • 时间轮的槽位越多,那么一个任务被重复扫描的概率就越小,因为只有在多层时钟轮中的任务才会被重复扫描。比如一个时间轮的槽位有1000个,一个槽位的单位时间是10毫秒,那么下一层时间轮的一个槽位的单位时间就是10秒,超过10秒的定时任务会被放到下一层时间轮中,也就是只有超过10秒的定时任务会被扫描遍历两次,但如果槽位是10个,那么超过100毫秒的任务,就会被扫描遍历两次。

结合这些特点,就可以视具体的业务场景而定,对时钟轮的周期和时间槽数进行设置。在RPC框架中,只要涉及到定时任务,都可以应用时钟轮,比较典型的就是调用端的超时处理、调用端与服务端的启动超时以及定时心跳等等。

流量回放:保证业务技术升级的神器

1.场景分析

什么是流量回放?

​ 所谓的流量就是某个时间段内的所有请求,通过某种手段把发送到A应用的所有请求录制下来,然后把这些请求统一转发到B应用,让B应用接收到的请求参数跟A应用保持一致,从而实现A接收到的请求在B应用里面重新请求了一遍,整个过程称之为“流量回放”。这就好比今晚有场球赛,虽然没空看,但可以利用视频录播技术把球赛录下来,随时想看都可以拿出来看,画面是一模一样的。

流量回放可以用来做什么?

​ 在团队中,经常是多个需求并行开发的,在开发新需求的过程中,还可能夹杂着应用的重构和拆分。每到这个时候,基本很难做到不改动老逻辑,那只要有改动就有可能会存在考虑不周全的情况。如果比较严谨的话,那可能在开发完成后,会把项目里面的TestCase都跑一遍,并同时补充新功能的TestCase,只有所有的TestCase都跑通后才能安心。

​ 在代码里面,算小改动的业务需求,这种做法一般不会出问题。但对于大改动的应用,比如应用中很多基础逻辑都被改动过,这时候如果还是通过已有的Case去验证功能的正确性,就很难保证应用上线后不出故障了,毕竟靠自己维护的Case相对线上运行的真实环境来说还是少了很多。

​ 这时候会向更专业的QA测试人员求助,希望他们能从QA角度多加入一些Case。但因为改动代码逻辑影响范围比较大,想要圈定一个比较确定的测试范围又很难,坦白讲这时候相对保险的方式就是QA把整个项目都回归测试一遍。这种方式已经是在最大程度上避免上线出问题了,但从概率角度上来讲也不是万无一失的,因为线上不仅环境复杂,而且使用场景也并不好评估,还有就是这种方式耗时也很长。

​ 这也是开发过程中最让人头疼的原因,靠传统QA测试的方式,不仅过程费时,结果也不是完全可靠。那有没有更可靠、更廉价的方案呢?

​ 传统QA测试出问题的根本原因就是,因为改造后的应用在上线后出现跟应用上线前不一致的行为。而测试的目的就是为了保证改造后的应用跟改造前应用的行为一致,测试Case也都是在尽力模拟应用在线上的运行行为,但仅通过自己的枚举方式维护的Case并不能代表线上应用的所有行为。因此最好的方式就是用线上流量来验证,但是直接把新应用上线肯定是不行的,因为一旦新改造的应用存在问题就可能会导致线上调用方业务受损。

​ 既然不能直接用线上流量验证,可以试着换一种思路,采用"模拟"线上流量的思路,可以先把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里重新请求一遍,最后比对一下改造前后的响应结果是否一致,这就间接达到了使用线上流量测试的效果。有了线上的请求参数和响应结果后,再结合持续集成过程,就可以让改动后的代码随时用线上流量进行验证

2.流量回放

❓ RPC 中如何支持流量回放?(核心:录制、回放)

​ 实现流量回放常见的方案有很多,比如像TcpCopyNginx等。但在线上环境要使用这些工具的时候,还得需要找运维团队帮我们把应用安装到应用实例里面,然后再按照需求给配置好才能使用,整个过程繁琐而且总数重复做无用功,那有没有更好的办法呢?尤其是在应用使用了RPC的情况下。

​ RPC是用来完成应用之间通信的,换句话就是说应用之间的所有请求响应都会经过RPC。既然所有的请求都会经过RPC,那么在RPC里面是不是就可以很方便地拿到每次请求的出入参数?拿到这些出入参数后,只要把这些出入参数旁录下来,并把这些旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量回放里面的录制功能。

​ 有了真实的请求入参之后,剩下的就是怎么把这些请求参数转发到要回归测试的应用里面。在RPC中,我们把能够接收请求的应用叫做服务提供方,也就是说只需要模拟一个应用调用方,把刚才收到的请求参数重新发送一遍到要回归测试的应用里面,然后比对录制拿到的请求结果和新请求的结果,就可以完成请求回放的效果。整个过程如下图所示:

image-20241224150508156

​ 相对其它现成的流量回放方案,在RPC里面内置流量回放功能,使用起来会更加方便,并且还可以做更多定制,比如在线启停、方法级别录制等个性化需求

3.总结

​ 保障线上应用的稳定,是开发小伙伴每天都在努力耕耘的一件事,不管是通过应用架构升级,还是修复现有问题的方式。实际情况就是不仅要保障已有业务的稳定,还需要快速去完成各种新业务的需求,这期间应用代码就会经常发生变化,而发生变化后就可能会引入新的不稳定因素,而且这个过程会一直持续不断发生。

​ 为了保障应用升级后的业务行为还能保持和升级前一样,在大多数情况下都是依靠已有的TestCase去验证,但这种方式在一定程度上并不是完全可靠的。最可靠的方式就是引入线上Case去验证改造后的应用,把线上的真实流量在改造后的应用里面进行回放,这样不仅节省整个上线时间,还能弥补手动维护Case存在的缺陷。线上流量相比手动维护TestCase的场景更丰富,用线上流量进行测试的覆盖率会更广

​ 当应用引入了RPC后,所有的请求流量都会被RPC接管,所以可以很自然地在RPC里面支持流量回放功能。虽然这个功能本身并不是RPC的核心功能,但对于使用RPC的人来说,有了这个功能之后,就可以更放心地升级自己的应用

动态分组:超高效实现秒级扩缩容

1.场景分析

​ 在【业务分组:如何实现流量隔离?】篇章中提到,在调用方复杂的情况下,如果还是让所有调用方都调用同一个集群的话,很有可能会因为非核心业务的调用量突然增长,而让整个集群变得不可用了,进而让核心业务的调用方受到影响。为了避免这种情况发生,需要把整个大集群根据不同的调用方划分出不同的小集群来,从而实现调用方流量隔离的效果,进而保障业务之间不会互相影响。基于这种业务分组的思路需要考虑相关的衍生问题:

① 分组后容量评估

​ 通过人为分组的方式确实能帮服务提供方硬隔离调用方的流量,让不同的调用方拥有自己独享的集群,从而保障各个调用方之间互不影响。但这对于服务提供方来说,又带来了一个新的问题,就是该给调用方分配多大的集群才合适呢?该怎么划分集群的分组?

​ 最理想的情况就是给每个调用方都分配一个独立的分组,但是如果在服务提供方的调用方相对比较多的情况下,对于服务提供方来说要维护这些关系还是比较困难的。因此实际在给集群划分分组的时候,一般会选择性地合并一些调用方到同一个分组里。这就需要服务提供方考虑该怎么合并,且合并哪些调用方?

​ 因为这个问题并没有统一的标准,所以一般的建议就是可以按照应用的重要级别来划分,让非核心业务应用跟核心业务应用不要公用一个分组,核心应用之间也最好别用同一个分组。但这只是一个划分集群分组的建议,并没有具体明确该如何划分集群大小。换句话就是,可以按照这个原则去规划设计自己的集群要分多少个组

​ 按照上面的原则,把整个集群从逻辑上分为不同的分组之后,接下来要做的事情就是给每个分组分配相应的机器数量。那每个分组对应的机器数量,该怎么计算呢?现实业务场景中团队常用的做法,一般会先通过压测去评估下服务提供方单台机器所能承受的QPS,然后再计算出每个分组里面的所有调用方的调用总量。有了这两个值之后,就能很容易地计算出这个分组所需要的机器数(通过压测评估单机QPS,计算分组中所有调用方的调用总量,进而得到分组所需机器数的参考值)

​ 通过计算分组内所有调用方QPS的方式来算出单个分组内所需的机器数,整体而言还是比较客观准确的。但因为每个调用方的调用量并不是一成不变的,比如商家找个网红做个直播卖货,那就很有可能会导致今天的下单量相对昨天有小幅度的上涨。就是因为这些不确定性因素的存在,所以服务提供方在给调用方做容量评估的时候,通常都会在现有调用量的基础上加一个百分比,而这个百分比多半来自历史经验总结。

​ 总之,就是在算每个分组所需要的机器数的时候,需要额外给每个分组增加一些机器,从而让每个小集群有一定的抗压能力,而这个抗压能力取决于给这个集群预留的机器数量。作为服务提供方来说,肯定希望给每个集群预留的机器数越多越好,但现实情况又不允许预留太多,因为这样会增加团队的整体成本。

② 分组带来的问题

​ 通过给分组预留少量机器的方式,以增加单个集群的抗压能力。一般情况下,这种机制能够运行得很好,但在应对大的突发流量时,就会显得有点捉襟见肘了。因为机器成本的原因,给每个分组预留的机器数量都不会太多,所以当突发流量超过预留机器的能力的时候,就会让这个分组的集群处于一个危险状态了。

​ 这时候唯一能做的就是给这个分组去扩容新的机器,但临时扩容新机器通常需要一个比较长的时间,而且花的时间越长,业务受影响的范围就越大。

​ 那有没有更便捷一点的方案呢?基于上述【分组做容量评估】思路,在进行分组做容量评估的时候,通常都会增加了一些富余。换句话就是,除了当前出问题的分组,其它分组的服务提供方在保障自己调用方质量的同时还是可以额外承担一些流量的,因此可以想办法快速利用这部分已有的能力(例如现实场景中人力不足就借调,顶一段时间之后再还回去)

​ 但因为实现了流量隔离功能,整个集群被划分成了不同的分组,所以当前出问题的调用方并不能把请求发送到其它分组的机器上。此处可能会想,既然临时去申请机器进行扩容时间长,那能不能把上面说的那些富余的机器直接拿过来,把部署在机器上的应用改成出问题的分组,然后进行重启啊?这样出问题的那个分组的服务提供方机器数就会变多了。从结果上来看,这样处理确实能够解决问题,但有一个问题就是这样处理的时间还是相对较长的,而且当这个分组的流量恢复后,还得把临时借过来的机器还回原来的分组。

2.动态分组的应用

​ 上面的问题,其根本原因就是某个分组的调用方流量突增,而这个分组所预留的空间也不能满足当前流量的需求,但是其它分组的服务提供方有足够的富余能力。但这些富余的能力,又被分组进行了强制的隔离,但此时又不能抛弃分组功能(否则老问题就要循环起来了)。因此只能在出问题的时候临时去借用其它分组的部分能力,但通过改分组进行重启应用的方式,不仅操作过程慢,事后还得恢复。因此这种生硬的方式显然并不是很合适。

​ 实际上,修改应用分组然后进行重启的目的,就是让出问题的服务调用方能通过服务发现找到更多的服务提供方机器,而服务发现的数据来自注册中心,那是不是可以通过修改注册中心的数据来解决呢?

只要把注册中心里面的部分实例的别名改成指定的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。举个例子,服务提供方有3个服务实例,其中A分组有2个实例,B分组有1个实例,调用方1调用A分组,调用方2调用B分组。把A分组里面的一个实例分组在注册中心由A分组改为B分组,经过服务发现影响后,整个调用拓扑就变成了这样

image-20241224153038045

​ 通过直接修改注册中心数据,可以让任何一个分组瞬间拥有不同规模的集群能力。不仅可以实现把某个实例的分组名改成另外一个分组名,还可以让某个实例分组名变成多个分组名,这就是在动态分组里面最常见的两种动作——追加和替换

3.总结

​ 业务分组可以帮助服务提供方实现调用方的隔离。但是因为调用方流量并不是一成不变的,而且还可能会因为突发事件导致某个分组的流量溢出,而在整个大集群还有富余能力的时候,又因为分组隔离而不能为出问题的集群直接提供帮助。为了解决这种突发流量的问题,提供了一种更高效的方案,可以实现分组的快速扩缩容。事实上还可以利用动态分组解决分组后给每个分组预留机器冗余的问题,没有必要把所有冗余的机器都分配到分组里面,可以把这些预留的机器做成一个共享的池子,从而减少整体预留的实例数量。

泛化调用:如何在没有接口的情况下进行RPC调用

1.场景分析

​ 场景介绍:统一测试平台、轻量级服务网关,在没有接口的情况下进行RPC调用

场景①:统一测试平台

​ 需求分析:要搭建一个统一的测试平台,可以让各个业务方在测试平台中通过输入接口、分组名、方法名以及参数值,在线测试自己发布的RPC服务。

​ 问题分析:搭建统一的测试平台实际上是作为各个RPC服务的调用端,而在RPC框架的使用中,调用端是需要依赖服务提供方提供的接口API的,而统一测试平台不可能依赖所有服务提供方的接口API(难以做到接口发布更新同步到平台的实时性)。不能因为每有一个新的服务发布,就去修改平台的代码以及重新上线。这时就需要让调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起RPC调用。

场景②:轻量级服务网关

​ 需求分析:要搭建一个轻量级的服务网关,可以让各个业务方用HTTP的方式,通过服务网关调用其它服务。

​ 问题分析:这时就有与场景一相同的问题,服务网关要作为所有RPC服务的调用端,是不能依赖所有服务提供方的接口API的,也需要调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起RPC调用

2.泛化调用

​ RPC框架要实现这个功能,可以使用泛化调用。要理解泛化调用,首先先学习下如何在没有接口的情况下进行RPC调用。

​ 在RPC调用的过程中,调用端向服务端发起请求,首先要通过动态代理(动态代理可以屏蔽RPC处理流程,真正地让程序发起远程调用就像调用本地一样)。那么在RPC调用的过程中,既然调用端是通过动态代理向服务端发起远程调用的,那么在调用端的程序中就一定要依赖服务提供方提供的接口API,因为调用端是通过这个接口API自动生成动态代理的。那如果没有接口API(注意此处的接口API并不是HTTP接口概念,而是接口方法的定义)呢?该如何让调用端仍然能够发起RPC调用呢?

关键在于要理解接口定义在RPC里面的作用。除了动态代理生成的过程中需要用到接口定义,剩余的其它过程中接口的定义只是被当作元数据来使用,而动态代理在RPC中并不是一个必须的环节,所以在没有接口定义的情况下同样也是可以完成RPC调用的

​ 所谓的RPC调用,本质上就是调用端向服务端发送一条请求消息,服务端接收并处理,之后向调用端发送一条响应消息,调用端处理完响应消息之后,一次RPC调用就完成了。那是不是说只要能够让调用端在没有服务提供方提供接口的情况下,仍然能够向服务端发送正确的请求消息,就能够解决这个问题了呢?=》只要调用端将服务端需要知道的信息,如接口名、业务分组名、方法名以及参数信息等封装成请求消息发送给服务端,服务端就能够解析并处理这条请求消息,这样问题就解决了。过程如下图所示

​ RPC的调用端向服务端发送消息是需要以动态代理作为入口的,现在得继续想办法让调用端发送请求消息。

​ 可以定义一个统一的接口(GenericService),调用端在创建GenericService代理时指定真正需要调用的接口的接口名以及分组名,而GenericService接口的$invoke方法的入参就是方法名以及参数信息。

​ 这样传递给服务端所需要的所有信息,包括接口名、业务分组名、方法名以及参数信息等都可以通过调用GenericService代理的$invoke方法来传递。具体的接口定义如下:

class GenericService {

  Object $invoke(String methodName, String[] paramTypes, Object[] params);
  
}

​ 这个通过统一的GenericService接口类生成的动态代理,来实现在没有接口的情况下进行RPC调用的功能,就称之为泛化调用。通过泛化调用功能,可以解决在没有服务提供方提供接口API的情况下进行RPC调用,那么这个功能是否就完美了呢?=》支持异步:RPC框架可以通过异步的方式提升吞吐量,以及全异步的RPC框架,其关键点就是RPC框架对CompletableFuture的支持。同理,泛化调用是否也可以支持异步,可以给GenericService接口再添加一个异步方法$asyncInvoke,方法的返回值就是CompletableFuture,GenericService接口的具体定义如下:

class GenericService {

  Object $invoke(String methodName, String[] paramTypes, Object[] params);

  CompletableFuture<Object> $asyncInvoke(String methodName, String[] paramTypes, Object[] params);

}

统一接口:实际上这种泛化接口的思路有点类似统一接口的概念,通过限定参数指定要调用的方法名、方法类型、参数等信息,将核心信息传递给服务端,让服务端正常处理信息。

​ 继续思考一个问题:在没有服务提供方提供接口API的情况下,可以用泛化调用的方式实现RPC调用,但是如果没有服务提供方提供接口API,就没法得到入参以及返回值的Class类,也就不能对入参对象进行正常的序列化。这时我们会面临两个问题:

**问题1:**调用端不能对入参对象进行正常的序列化,那调用端、服务端在接收到请求消息后,入参对象又该如何序列化与反序列化呢?

​ 基于【如何设计可扩展的RPC框架】,可以通过插件体系来提高RPC框架的可扩展性,在RPC框架的整体架构中就包括了序列化插件,可以为泛化调用提供专属的序列化插件,通过这个插件,解决泛化调用中的序列化与反序列化问题

**问题2:**调用端的入参对象(params)与返回值应该是什么类型呢?Map

​ 在服务提供方提供的接口API中,被调用的方法的入参类型是一个对象,那么使用泛化调用功能的调用端,可以使用Map类型的对象,之后通过泛化调用专属的序列化方式对这个Map对象进行序列化,服务端收到消息后,再通过泛化调用专属的序列化方式将其反序列成对象

3.总结

​ 通过泛化调用的功能实现在没有接口的情况下进行RPC调用,这个功能的实现原理就是RPC框架提供统一的泛化调用接口(GenericService),调用端在创建GenericService代理时指定真正需要调用的接口的接口名以及分组名,通过调用GenericService代理的$invoke方法将服务端所需要的所有信息,包括接口名、业务分组名、方法名以及参数信息等封装成请求消息,发送给服务端,实现在没有接口的情况下进行RPC调用的功能。

​ 而通过泛化调用的方式发起调用,由于调用端没有服务端提供方提供的接口API,不能正常地进行序列化与反序列化,可以为泛化调用提供专属的序列化插件,来解决实际问题

如何在线上环境中兼容多种RPC协议

1.场景分析

❓ 为什么要支持多协议?

​ 既然应用之间的通信都是通过RPC来完成的,而能够完成RPC通信的工具有很多,比如像Web ServiceHessiangRPC等都可以用来充当RPC使用。这些不同的RPC框架都是随着互联网技术的发展而慢慢涌现出来的,而这些RPC框架可能在不同时期会被引入到不同的项目中解决当时应用之间的通信问题,这样就导致线上的生成环境中存在各种各样的RPC框架。

​ 很显然,这种混乱使用RPC框架的方式肯定不利于公司技术栈的管理,最明显的一个特点就是维护RPC框架的成本越来越高,因为每种RPC框架都需要有专人去负责升级维护。

​ 为了解决早期遗留的一些技术负债,通常会去选择更高级的、更好用的工具来解决,治理RPC框架混乱的问题也是一样。为了解决同时维护多个RPC框架的困难,开发人员肯定希望能够用统一用一种RPC框架来替代线上所有的RPC框架,这样不仅能降低维护成本,而且还可以让技术人员在一种RPC上面去精进。

既然目标明确后,我们该如何实施呢?

​ 可能你会说这很简单啊,我们只要把所有的应用都改造成新RPC的使用方式,然后同时上线所有改造后的应用就可以了。如果在团队比较小的情况下,这种断崖式的更新可能确实是最快的方法,但如果是在团队比较大的情况下,要想做到同时上线所有改造后的应用,暂且不讨论这种方式是否存在风险,光从多个团队同一时间上线所有应用来看,这也几乎是一件不可能做到的事儿。

​ 那对于多人团队来说,有什么办法可以让其把多个RPC框架统一到一个工具上呢?我们先看下多人团队在升级过程中所要面临的困难,人数多就意味着要维护的应用会比较多,应用多了之后线上应用之间的调用关系就会相对比较复杂。那这时候如果单纯地把任意一个应用目前使用的RPC框架换成新的RPC框架的话,就需要让所有调用这个应用的调用方去改成新的调用方式。

​ 通过这种自下而上的滚动升级方式,最终是可以让所有的应用都切换到统一的RPC框架上,但是这种升级方式存在一定的局限性,首先要求我们能够清楚地梳理出各个应用之间的调用关系,只有这样,我们才能按部就班地把所有应用都升级到新的RPC框架上;其次要求应用之间的关系不能存在互相调用的情况,最好的情况就是应用之间的调用关系像一颗树,有一定的层次关系。但实际上我们应用的调用关系可能已经变成了网状结构,这时候想再按照这种方式去推进升级的话,就可能寸步难行了。

​ 为了解决上面升级过程中遇到的问题,你可能还会想到另外一个方案,那就是在应用升级的过程中,先不移除原有的RPC框架,但同时接入新的RPC框架,让两种RPC同时提供服务,然后等所有的应用都接入完新的RPC以后,再让所有的应用逐步接入到新的RPC上。这样既解决了上面存在的问题,同时也可以让所有的应用都能无序地升级到统一的RPC框架上。

​ 在保持原有RPC使用方式不变的情况下,同时引入新的RPC框架的思路,是可以让所有的应用最终都能升级到我们想要升级的RPC上,但对于开发人员来说,这样切换成本还是有点儿高,整个过程最少需要两次上线才能彻底地把应用里面的旧RPC都切换成新RPC。

​ 那有没有更好的方式可以让应用上线一次就可以完成新老RPC的切换呢?关键就在于要让新的RPC能同时支持多种RPC调用,当一个调用方切换到新的RPC之后,调用方和服务提供方之间就可以用新的协议完成调用;当调用方还是用老的RPC进行调用的话,调用方和服务提供方之间就继续沿用老的协议完成调用。对于服务提供方来说,所要处理的请求关系如下图所示:

2.如何优雅处理多协议?

​ 要让新的RPC同时支持多种RPC调用,关键就在于要让新的RPC能够原地支持多种协议的请求。怎么才能做到?协议的作用就是用于分割二进制数据流。每种协议约定的数据包格式是不一样的,而且每种协议开头都有一个协议编码,一般叫做magic number

​ 当RPC收到了数据包后,可以先解析出magic number来。获取到magic number后,就很容易地找到对应协议的数据格式,然后用对应协议的数据格式去解析收到的二进制数据包。

​ 协议解析过程就是把一连串的二进制数据变成一个RPC内部对象,但这个对象一般是跟协议相关的,所以为了能让RPC内部处理起来更加方便,我们一般都会把这个协议相关的对象转成一个跟协议无关的RPC对象。这是因为在RPC流程中,当服务提供方收到反序列化后的请求的时候,我们需要根据当前请求的参数找到对应接口的实现类去完成真正的方法调用。如果这个请求参数是跟协议相关的话,那后续RPC的整个处理逻辑就会变得很复杂。

​ 当完成了真正的方法调用以后,RPC返回的也是一个跟协议无关的通用对象,所以在真正往调用方写回数据的时候,我们同样需要完成一个对象转换的逻辑,只不过这时候是把通用对象转成协议相关的对象。

​ 在收发数据包的时候,通过两次转换实现RPC内部的处理逻辑跟协议无关,同时保证调用方收到的数据格式跟调用请求过来的数据格式是一样的。整个流程如下图所示:

image-20241225111013632

3.总结

​ 在日常开发的过程中,最难的环节不是从0到1完成一个新应用的开发,而是把一个老应用通过架构升级完成从70分到80分的跳跃。因为在老应用升级的过程中,不仅需要考虑既有的功能逻辑,也需要考虑切换到新架构上的成本,这就要求在设计新架构的时候要考虑如何让老应用能够平滑地升级,就像在RPC里面支持多协议一样。

​ 在RPC里面支持多协议,不仅能更从容地推进应用RPC的升级,还能为未来在RPC里面扩展新协议奠定一个良好的基础。所以平时在设计应用架构的时候,不仅要考虑应用自身功能的完整性,还需要考虑应用的可运维性,以及是否能平滑升级等一些软性能力。

RPC框架代码示例详解

1.RPC框架解读(joyrpc)

⚽ RPC 框架整体架构

​ 一个灵活的RPC框架设计,基于插件化思路构建,整体的RPC框架架构拆分为四层:入口层、集群层、协议层和传输层,而这四层中分别包含了一系列的插件,而在实际的RPC框架中插件会更多。可以通过服务端启动流程、调用端启动流程、RPC调用流程这三大流程来将RPC框架的核心模块以及核心类串联起来,基于对这三大流程的梳理进一步理解代码设计思想

image-20241225112751113
① 服务端启动流程

服务端启动示例代码

public static void main(String[] args) throws Exception {

    DemoService demoService = new DemoServiceImpl(); // 服务提供者设置
    ProviderConfig<DemoService> providerConfig = new ProviderConfig<>();
    providerConfig.setServerConfig(new ServerConfig());
    providerConfig.setInterfaceClazz(DemoService.class.getName());
    providerConfig.setRef(demoService);
    providerConfig.setAlias("joyrpc-demo");
    providerConfig.setRegistry(new RegistryConfig("broadcast"));

    providerConfig.exportAndOpen().whenComplete((v, t) -> {
        if (t != null) {
            logger.error(t.getMessage(), t);
            System.exit(1);
        }
    });

    System.in.read();

}

providerConfig通过调用exportAndOpen方法启动服务端,在这个方法中服务的启动流程被分为两个部分:export(创建Export对象)、open(打开服务),表示服务端的启动流程被分为两部分:服务端的创建流程 + 服务端的开启流程

public CompletableFuture<Void> exportAndOpen() {

    CompletableFuture<Void> future = new CompletableFuture<>();
    export().whenComplete((v, t) -> {
        if (t != null) {
            future.completeExceptionally(t);
        } else {
            Futures.chain(open(), future);
        }
    });

    return future;

}

【服务端创建流程】

image-20241225132319746

​ 此处ProviderConfig是服务端的配置对象,其中接口、分组、注册中心配置等等的相关信息都在这个配置类中配置,流程的入口是调用ProviderConfig的export方法,整个流程如下:

① 根据ProviderConfig的配置信息生成registryUrl(注册中心URL对象)与serviceUrl(服务URL对象);

② 根据registryUrl,调用Registry插件,创建Registry对象,Registry对象为注册中心对象,与注册中心进行交互;

③ 调用Registry对象的open方法,开启注册中心对象,也就是与注册中心建立连接;

④ 调用Registry对象的subscribe方法,订阅接口的配置信息与全局配置信息;

⑤ 调用InvokerManager,创建Exporter对象;

InvokerManager返回Exporter对象;

​ 服务端的创建流程实际上就是Exporter对象,Exporter对象是调用器Invoker接口的子类,Invoker接口有两个子类,分别是Exporter与Refer,Exporter用来处理服务端接收的请求,而Refer用来向服务端发送请求,这两个类可以说是入口层最为核心的两个类。

​ 在InvokerManager创建Exporter对象时实际上会有一系列的操作,而初始化Exporter也会有一系列的操作,如创建Filter链、创建认证信息等等

【服务端开启流程】

image-20241225132718539

​ 创建完服务端的Exporter对象之后,就要开启Exporter对象,开启Exporter对象最重要的两个操作就是开启传输层中Server的端口,用来接收调用端发送过来的请求,以及将服务端节点注册到注册中心上,让调用端可以发现到这个服务节点,整个流程如下:

① 调用Exporter对象的open方法,开启服务端;

Exporter对象调用接口预热插件,进行接口预热;

Exporter对象调用传输层中的EndpointFactroy插件,创建一个Server对象,一个Server对象就代表一个端口了;

④ 调用Server对象的open方法,开启端口,端口开启之后,服务端就可以提供远程服务了;

Exporter对象调用Registry对象的register方法,将这个调用端节点注册到注册中心中;

​ 这里无论是Exporteropen方法、Serveropen还是Registryregister方法,都是异步方法,返回值为CompletableFuture对象,这个流程的每个环节也都是异步的。

​ Server的open操作实际上是一个比较复杂的操作,要绑定协议适配器、初始化session管理器、添加eventbus事件监听等等的操作,而且整个流程完全异步,并且是插件化的。

② 调用端启动流程

调用端启动流程实例代码

public static void main(String[] args) {

    ConsumerConfig<DemoService> consumerConfig = new ConsumerConfig<>(); //consumer设置
    consumerConfig.setInterfaceClazz(DemoService.class.getName());
    consumerConfig.setAlias("joyrpc-demo");
    consumerConfig.setRegistry(new RegistryConfig("broadcast"));

    try {
        CompletableFuture<DemoService> future = consumerConfig.refer();
        DemoService service = future.get();

        String echo = service.sayHello("hello"); //发起服务调用
        logger.info("Get msg: {} ", echo);
    } catch (Throwable e) {
        logger.error(e.getMessage(), e);
    }

    System.in.read();

}

​ 调用端流程的启动入口就是ConsumerConfig对象的refer方法,ConsumerConfig对象就是调用端的配置对象,这里可以看到refer方法的返回值是CompletableFuture,与服务端相同,调用端的启动流程也完全是异步的

【调用端的启动流程】

① 根据ConsumerConfig的配置信息生成registryUrl(注册中心URL对象)与serviceUrl(服务URL对象);

② 根据registryUrl,调用Registry插件,创建Registry对象,Registry对象为注册中心对象,与注册中心进行交互;

③ 创建动态代理对象;

④ 调用Registry对象的Open方法,开启注册中心对象;

⑤ 调用Registr对象subscribe方法,订阅接口的配置信息与全局配置信息;

⑥ 调用InvokeManagerrefer方法,用来创建Refer对象;

InvokeManager在创建Refer对象之前会先创建Cluster对象,Cluser对象是集群层的核心对象,Cluster会维护该调用端与服务端节点的连接状态;

InvokeManager创建Refer对象;

Refer对象初始化,其中主要包括创建路由策略、消息分发策略、创建负载均衡、调用链、添加eventbus事件监听等等;

ConsumerConfig调用Referopen方法,开启调用端;

Refer对象调用Cluster对象的open方法,开启集群;

Cluster对象调用Registry对象的subcribe方法,订阅服务端节点变化,收到服务端节点变化时,Cluster会调用传输层EndpointFactroy插件,创建Client对象,与这些服务节点建立连接,Cluster会维护这些连接;

ConsumerConfig调用Refer对象封装到ConsumerInvokerHandler中,将ConsumerInvokerHandler对象注入给动态代理对象。

在调用端的开启流程中,最复杂的操作就是Cluster对象的open操作以及Client对象的open操作。

​ Cluster对象是集群层的核心对象,也是这个RPC框架中处理逻辑最为复杂的对象,Cluster对象负责维护该调用端节点集群信息,监听注册中心推送的服务节点更新事件,调用传输层中的EndpointFactroy插件,创建Client对象,并且会通过Client与服务端节点建立连接,发送协商信息、安全验证信息、心跳信息,通过心跳机制维护与服务节点的连接状态。

​ Client对象的open操作也是有着一系列的操作,比如创建Transport对象,创建Channel对象,生成并记录session信息等等。

​ Refer对象在构造调用链的时候,其最后一个调用链就是Refer对象的distribute方法,用来发送远程请求。

​ 动态代理对象内部的核心逻辑就是调用ConsumerInvokerHandler对象的Invoke方法,最终就是调用Refer对象

③ RPC调用流程

​ RPC的整个调用流程就是调用端发送请求消息以及服务端接收请求消息并处理,之后响应给调用端的流程

  • 调用端的发送流程
  • 服务端的接收流程

调用端的发送流程

image-20241225133931815

调用端发送流程如下:

  1. 动态代理对象调用ConsumerInvokerHandler对象的Invoke方法;
  2. ConsumerInvokerHandler对象生成请求消息对象;
  3. ConsumerInvokerHandler对象调用Refer对象的Invoke方法;
  4. Refer对象对请求消息对象进行处理,如设置接口信息、分组信息等等;
  5. Refer对象调用消息透传插件,处理透传信息,其中就包括隐式参数信息;
  6. Refer对象调用FilterChain对象的Invoker方法,执行调用链;
  7. FilterChain对象调用每个Filter;
  8. Refer对象的distribute方法作为最后一个Filter,被调用链最后一个执行。
  9. 调用NodeSelecter对象的select方法,NodeSelecter是集群层的路由规则节点选择器,其select方法用来选择出符合路由规则的服务节点;
  10. 调用Route对象的route方法,Route对象为路由分发器,也是集群层中的对象,默认为路由分发策略为Failover,即请求失败后可以重试请求,这里你可以回顾下[第 12 讲]open in new window,在这一讲的思考题中我就问过异常重试发送在RPC调用中的哪个环节,其实就在此环节;
  11. Route对象调用LoadBalance对象的select方法,通过负载均衡选择一个节点;
  12. Route对象回调Refer对象的invokeRemote方法;
  13. Refer对象的invokeRemote方法调用传输层中Client对象,向服务端节点发送消息。

在调用端发送流程中,最终会通过传输层将消息发送给服务端,这里对传输层的操作没有详细的讲解,其实传输层内部的流程还是比较复杂的,也会有一系列的操作,比如创建Future对象、调用FutureManager管理Future对象、请求消息协议转换处理、编解码、超时处理等等的操作。

当调用端发送完请求消息之后,服务端就会接收到请求消息并对请求消息进行处理。接下来我们看服务端的接收流程。

服务端的接收流程

image-20241225134007465

服务端的传输层会接收到请求消息,并对请求消息进行编解码以及反序列化,之后调用Exporter对象的invoke方法,具体流程如下:

  1. 传输层接收到请求,触发协议适配器ProtocolAdapter;
  2. ProtocolAdapter对象遍历Protocol插件的实现类,匹配协议;
  3. 匹配协议之后,根据Protocol对象,传输层的Server对象绑定该协议的编解码器(Codec对象)、Channel处理链(ChainChannelHandler对象);
  4. 对接收的消息进行解码与反序列化;
  5. 执行Channel处理链;
  6. 在业务线程池中调用消息处理链(MessageHandle插件);
  7. 调用BizReqHandle对象的handle方法,处理请求消息;
  8. BizReqHandle对象调用restore方法,根据连接Session信息,处理请求消息数据,并根据请求的接口名、分组名与方法名,获取Exporter对象;
  9. 调用Exporter对象的invoke方法,Exporter对象返回CompletableFuture对象;
  10. Exporter对象调用FilterChain的invoke方法;
  11. FilterChain执行所有Filter对象;
  12. Exporter对象的invokeMethod方法作为最后一个Filter,最后被调用;
  13. Exporter对象的invokeMethod方法处理请求上下文,执行反射;
  14. Exporter对象将执行反射之后得到的请求结果异步通知给BizReqHandle对象;
  15. BizReqHandle调用传输层的Channel对象,发送响应结果;
  16. 传输层对响应消息进行协议转换、序列化、编码,最后通过网络传输响应给调用端

2.总结

​ 通过剖析开源的RPC框架的代码(joyrpc),主要通过服务端启动流程、调用端启动流程、RPC调用流程这三大流程来将RPC框架的核心模块以及核心类串联起来。

​ 在服务端的启动流程中,核心工作就是创建和开启Exporter对象。ProviderConfig在创建Exporter对象之前会先创建Registry对象,从注册中心中订阅接口配置与全局配置,之后才会创建Exporter对象,在Exporter开启时,会启动一个Server对象来开启一个端口,Exporter开启成功之后,才会通过Registry对象向注册中心发起注册。

​ 在调用端的启动流程中,核心工作就是创建和开启Refer对象,开启Refer对象中处理逻辑最为复杂的就是对Cluster的open操作,Cluster负责了调用端的集群管理操作,其中有注册中心服务节点变更事件的监听、与服务端节点建立连接以及服务端节点连接状态的管理等等。

​ 调用端向服务端发起调用时,会先经过动态代理,之后会调用Refer对象的invoke方法,Refer对象会先对要透传的消息进行处理,再执行Filter链,调用端最后一个Filter会根据配置的路由规则选择出符合条件的一组服务端节点,之后调用Route对象的route方法,route方法的内部逻辑会根据配置的负载均衡策略选择一个服务端节点,最后向这个服务端节点发送请求消息。

​ 服务端的传输层收到调用端发送过来的请求消息,在对请求消息进行一系列处理之后(如解码、反序列化、协议转换等等),会在业务线程池中处理消息,关键的逻辑就是调用Exporter对象的invoke方法,Exporter对象的invoke方法会执行服务端配置的Filter链,最终通过反射或预编译对象执行业务逻辑,再将最终结果封装成响应消息,通过传输层响应给调用端。

​ 本讲在调用端向服务端发起调用时,没有讲到异步调用,实际上Refer对象的invoke方法的实现逻辑完全是异步的,同样Exporter对象的invoke方法也是异步的,Refer类与Exporter类都是调用端Invoker接口的实现类,可以看下Invoker接口中invoke方法的定义:

 /**
  * 调用
  *
  * @param request 请求
  * @return
  */

 CompletableFuture<Result> invoke(RequestMessage<Invocation> request);

​ JoyRPC框架是一个纯异步的RPC框架,所谓的同步只不过是对异步进行了等待。

入口层的核心对象就是Exporter对象与Refer对象,这两个类承担了入口层的大多数核心逻辑。

集群层的核心对象就是Cluster对象与Registry对象,Cluser对象的内部逻辑还是非常复杂的,核心逻辑就是与Registry交互,订阅服务端节点变更事件,以及对与服务端节点建立的连接的管理

协议层的核心对象就是Protocol接口的各个子类了。

传输层:传输层的具体实现在本讲也没有过多介绍,因为很难通过有限的内容把它讲解完整,建议查阅代码理解。传输层是纯异步的并且是完全插件化的,其入口就是EndpointFactroy插件,通过EndpointFactroy插件获取一个EndpointFactroy对象,EndpointFactroy对象是一个工厂类,用来创建Client对象与Server对象。

​ 对于一个完善的RPC框架,此处仅是针对服务端启动流程、调用端启动流程、RPC调用流程这三个主流程做了一个大致的讲解,真正实现起来还是要复杂许多,因为涉及到了很多细节上的问题,但主要脉络只是提供初步的理解帮助,更多的细节就还是要靠自己去阅读源码

案例拆解RPC

1.场景分析

2.xxx

基础篇与进阶篇问题复盘

1.第2讲

**思考题:**在RPC里面是怎么实现请求跟响应关联的?私有协议的消息ID标识

​ 首先要弄清楚为什么要把请求与响应关联。这是因为在RPC调用过程中,调用端会向服务端发送请求消息,之后它还会收到服务端发送回来的响应消息,但这两个操作并不是同步进行的。在高并发的情况下,调用端可能会在某一时刻向服务端连续发送很多条消息之后,才会陆续收到服务端发送回来的各个响应消息,这时调用端需要一种手段来区分这些响应消息分别对应的是之前的哪条请求消息,所以说RPC在发送消息时要请求跟响应关联。

​ 解决这个问题不难,只要调用端在收到响应消息之后,从响应消息中读取到一个标识,告诉调用端,这是哪条请求消息的响应消息就可以了。在这一讲中,你会发现我们设计的私有协议都会有消息ID,这个消息ID的作用就是起到请求跟响应关联的作用。调用端为每一个消息生成一个唯一的消息ID,它收到服务端发送回来的响应消息如果是同一消息ID,那么调用端就可以认为,这条响应消息是之前那条请求消息的响应消息。

2.第5讲

**思考题:**如果没有动态代理实现方法调用拦截,用户该怎么完成RPC调用?

​ 这个问题可以参考下gRPC框架。gRPC框架中就没有使用动态代理,它是通过代码生成的方式生成Service存根,当然这个Service存根起到的作用和RPC框架中的动态代理是一样的。

​ gRPC框架用代码生成的Service存根来代替动态代理主要是为了实现多语言的客户端,因为有些语言是不支持动态代理的,比如C++、go等,但缺点也是显而易见的。如果你使用过gRPC,你会发现这种代码生成Service存根的方式与动态代理相比还是很麻烦的,并不如动态代理的方式使用起来方便、透明。

3.第6讲

**思考题:**在 gRPC 调用的时候,有一个关键步骤就是把对象转成可传输的二进制,但是在 gRPC 里面,并没有直接转成二进制数组,而是返回一个 InputStream,你知道这样做的好处是什么吗?

​ RPC调用在底层传输过程中也是需要使用Stream的,直接返回一个InputStream而不是二进制数组,可以避免数据的拷贝。

4.第8讲

**思考题:**目前服务提供者上线后会自动注册到注册中心,服务调用方会自动感知到新增的实例,并且流量会很快打到该新增的实例。如果我想把某些服务提供者实例的流量切走,除了下线实例,你有没有想到其它更便捷的办法呢?

​ 解决这个问题的方法还是有很多的,比如改变服务提供者实例的权重,将权重调整为0,或者通过路由的方式也可以。但解决这个问题最便捷的方式还是使用动态分组,可以通过业务分组来实现流量隔离。如果业务分组是动态的,就可以在管理平台动态地自由调整,就可以实现动态地流量切换

5.第12讲

**思考题:**在整个RPC调用的流程中,异常重试发生在哪个环节?

​ 首先要理解异常重试的场景,如果在发出请求时恰好网络出现问题了,导致请求失败,可能需要进行异常重试。从这一点可以看出,异常重试的操作是要在调用端进行的。因为如果在调用端发出请求时恰好网络出现问题导致请求失败,那么这个请求很可能还没到达服务端,服务端当然就没办法去处理重试了

​ 此外在异常重试注意点上有提到需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。由此可见异常重试的操作应该发生在负载均衡之前,在发起重试的时候,会调用负载均衡插件来选择一个服务节点,在调用负载均衡插件时需要要告诉负载均衡需要刨除哪些有问题的服务节点。

​ 在整个RPC调用的过程中,从动态代理到负载均衡之间还有一系列的操作,如果你研究过开源的RPC框架,你会发现在调用端发送请求消息之前还会经过过滤链,对请求消息进行层层的过滤处理,之后才会通过负载均衡选择服务节点,发送请求消息,而异常重试操作就发生在过滤链处理之后,调用负载均衡选择服务节点之前,这样的重试是可以减少很多重复操作的。

6.第14讲

**思考题:**在启动预热那部分,特意提到过一个问题,就是“当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住”,不知道你是怎么看待这个问题的,是否有好的解决方案呢?

​ 可以考虑在非流量高峰的时候重启服务,将影响降到最低;也可以考虑分批次重启,控制好每批重启的服务节点的数量,当一批服务节点的权重与访问量都到正常水平时,再去重启下一批服务节点。

7.第15讲

**思考题:**在使用RPC的过程中业务要实现自我保护,针对这个问题你是否还有其他的解决方案?

​ 在RPC调用中无论服务端还是调用端都需要自我保护,服务端自我保护的最简单有效的方式是“限流”,调用端则可以通过“熔断”机制来进行自我保护。

​ 除了“熔断”和“限流”外,相信你一定听过“降级”这个词。简单来说就是当一个服务处理大量的请求达到一定压力的时候,可以让这个服务在处理请求时减少些非必要的功能,从而降低这个服务的压力。

​ 还有就是可以通过服务治理,降低一个服务节点的权重来减轻某一方服务节点的请求压力,达到保护这个服务节点的目的。

8.第16讲

​ **思考题:**在实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,你有什么好办法吗?

环境隔离:可以考虑配置不同的注册中心,开发人员将自己的服务注册到注册中心A上,而测试人员可以将自己的服务注册到测试专属的注册中心B上,这样测试人员在验证功能的时候,调用端会从注册中心B上拉取服务节点,开发人员重启自己的服务是影响不到测试人员的。

直连测试:利用使用dubbo框架可以配置连接方式,设定直连调用到指定的服务端IP

​ 如果使用过或者了解k8s的话,你一定知道“命名空间”的概念,RPC框架如果支持“命名空间”,也是可以解决这一问题的

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3