java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起

上次Version06说到了咱们手写迷你版RPC的大体流程,
java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第1张图片

  • 对咱们的迷你版RPC的大体流程再做几点补充:
    • 为什么要封装网络协议,别人说封装好咱们就要封装?Java有这个特性那咱就要用?好像是这样。看看更权威的回答:
      • 网络通信说起来简单,但实际上是一个非常复杂的过程,这个过程主要包括:对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等,每一项都很复杂。你可以想象一下,在搭建一个复杂的分布式系统过程中,如果开发人员在编码时要对每个涉及到网络通信的逻辑都进行一系列的复杂编码,这将是件多么恐怖的事儿。所以说,网络通信是搭建分布式系统的一个大难题,是一点不为过的,我们必须给予足够的重视。而 RPC 对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠
  • RPC(Remote Procedure Call) 即远程过程调用【可以认为RPC 是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,远程机器返回结果的过程,而开发人员无需额外地为这个交互编程或者说不需要了解底层网络技术的协议概括来讲RPC 主要解决了:让分布式或者微服务系统中不同服务之间的调用像本地调用一样简单。】,RPC 关注的是远程调用而非本地调用。【【RPC(Remote Procedure Call Protocol,远程过程调用协议RPC是一种进程间通信方式,分布式常见的通讯方式,从跨进程到跨物理机已有几十年历史)框架:】

    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第2张图片
    • 在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。RPC采用客户机/服务器模式。请求程序就是一个客户机,而服务提供程序就是一个服务器。首先,客户机调用进程发送一个有进程参数的调用信息到服务进程,然后等待应答信息。在服务器端,进程保持睡眠状态直到调用信息到达为止。当一个调用信息到达,服务器获得进程参数,计算结果,发送答复信息,然后等待下一个调用信息,最后,客户>端调用进程接收答复信息,获得进程结果,然后调用执行继续进行。有多种 RPC模式和执行。
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第3张图片
      • RPC的方式就是远程函数调用,像RESTFul,gRPC, DUBBO 都是这种方式。R**PC的方式一般是同步的,可以马上得到结果**。在实际中,大多数应用都要求立刻得到结果,这时同步方式更有优势,代码也更简单。
    • 为什么要 RPC ?就一句话,想实现调用远程方法想调用本地方法一样简单,就得人家RPC帮咱们屏蔽网络编程细节之后,咱们才能实现愿望
      • 主要原因是由于单台服务的性能已经无法满足我们了,在这个流量剧增的时代,只有多台服务器才能支撑起来现有的用户体系,而在这种体系下,服务越来越多,逐渐演化出了现在这种微服务化的RPC框架。
      • 两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收
      • 然后,如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP还是UDP)以及网络传输过程中需要考虑的其他问题比如:采用序列化方式?需要需要其他的编解码呀之类的等等方面。RPC已经帮咱们封装好了,RPC帮咱们屏蔽了网络编程细节,直接用就行了【实现调用远程方法就跟调用本地(同一个项目中的方法)一样
    • RPC 能帮助我们做什么呢?
      • 通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节等基础上进行的。【两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。】。总结下来就是两件事:
        • 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法
          • 底层其实就是通过动态代理技术,屏蔽 RPC 调用的细节,从而让使用者能够面向接口编程
          • 因为 RPC 的主要目的就是让我们调用远程方法像调用本地方法一样简单,使用动态代理可以屏蔽远程方法调用的细节比如网络传输。也就是说 当你调用远程方法的时候,实际会通过代理对象来传输网络请求,不然的话,怎么可能直接就调用到远程方法呢
        • 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑
      • RPC真正强大的地方在于它的治理功能,比如连接管理、健康检测、负载均衡、优雅启停机、异常重试、业务分组以及熔断限流 等等,使得我们可以更加方便地构建分布式系统
      • RPC也可以用来发 MQ、分布式缓存、数据库等
        java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第4张图片
      • 常见的RPC框架都有哪些?【我们这里说的 RPC 框架指的是可以让客户端直接调用服务端方法,就像调用本地方法一样简单的框架,比如 Dubbo、Motan、gRPC这些。 如果需要和 HTTP 协议打交道,解析和封装 HTTP 请求和响应。这类框架并不能算是“RPC 框架”,比如Feign
        java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第5张图片
        • Dubbo:阿里2011年开源,仅支持 Java 语言。(官方文档:https://dubbo.apache.org/zh/docs/),后来加入了 Apache 基金会,成为 Apache 的顶级项目。【 Dubbo是 阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。目前 Dubbo 已经成为 Spring Cloud Alibaba 中的官方组件。】
          • 根据 Dubbo 官方文档的介绍,Dubbo 提供了六大核心能力【Dubbo 提供的是基于接口的远程方法调用【即客户端只需要知道接口的定义即可调用远程服务,但是在 Java 中接口并不能直接调用实例方法,必须通过其实现类对象来完成此调用操作,这意味着客户端必须为这些接口生成代理对象,对此 Java 提供了 Proxy、InvocationHandler 生成动态代理的支持;生成了代理对象,jdk 动态代理生成的代理对象调用指定方法时实际会执行 InvocationHandler 中定义的 #invoke 方法,在该方法中完成远程方法调用并获取结果】,不光可以帮助我们调用远程服务,还提供了一些其他开箱即用的功能比如智能负载均衡。】【Apache Dubbo 是一款微服务框架,为大规模微服务实践提供高性能 RPC 通信、流量治理、可观测性等解决方案, 涵盖 Java、Golang 等多种语言 SDK 实现。Dubbo 提供了从服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,支持 Triple 协议(基于 HTTP/2 之上定义的下一代 RPC 通信协议)、应用级服务发现、Dubbo Mesh (Dubbo3 赋予了很多云原生友好的新特性)等特性。】
            • 1.可视化的服务治理与运维。
              • 服务调用链路生成 : 随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。Dubbo 可以为我们解决服务之间互相是如何调用的
                java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第6张图片
              • Dubbo 除了能够应用在分布式系统中,也可以应用在现在比较火的微服务系统中。不过,由于 Spring Cloud 在微服务中应用更加广泛,所以,我觉得一般我们提 Dubbo 的话,大部分是分布式系统的情况
            • 2.面向接口代理的高性能RPC调用。
            • 3.智能容错和负载均衡。
            • 4.服务自动注册和发现。
            • 5.高度可扩展能力。
            • 6.运行期流量调度。
          • 比较:
            • gRPC 和 Thrift 虽然支持跨语言的 RPC 调用,但是它们只提供了最基本的 RPC 框架功能,缺乏一系列配套的服务化组件和服务治理功能的支撑Dubbo 不论是从功能完善程度、生态系统还是社区活跃度来说都是最优秀的。而且,Dubbo在国内有很多成功的案例比如当当网、滴滴等等,是一款经得起生产考验的成熟稳定的 RPC 框架。Dubbo 也是 Spring Cloud Alibaba 里面的一个组件。最重要的是你还能找到非常多的 Dubbo 参考资料,学习成本相对也较低。
            • 但是,Dubbo 和 Motan 主要是给 Java 语言使用。虽然,Dubbo 和 Motan 目前也能兼容部分语言,但是不太推荐。如果需要跨多种语言调用的话,可以考虑使用 gRPC
        • Motan:微博2016 年开源,仅支持 Java 语言。(Github地址:https://github.com/weibocom/motan)
          • Motan 更像是一个精简版的 Dubbo,可能是借鉴了 Dubbo 的思想,Motan 的设计更加精简,功能更加纯粹。不推荐你在实际项目中使用 Motan。如果你要是公司实际使用的话,还是推荐 Dubbo ,其社区活跃度以及生态都要好很多。
        • Tars:腾讯2017 年开源,仅支持 C++ 语言。(官方文档:https://tarscloud.github.io/TarsDocs/installation/source.html)
        • Spring Cloud Feigh:国外 Pivotal 公司 2014 年对外开源的 RPC 框架,仅支持 Java 语言(Github:https://github.com/OpenFeign/feign)【后面又出现了SpringCloud Alibaba, Spring-Cloud-Alibaba 项目由阿里巴巴的开源组件和多个阿里云产品组成,旨在实现和公开众所周知的 Spring 框架模式和抽象,为使用阿里巴巴产品的 Java 开发者带来 Spring-Boot 和 Spring-Cloud 的好处
        • GRPC:Google 2015 年开源,支持多种语言。(官方文档:https://grpc.io/docs/)
          • gRPC是可以在任何环境中运行的现代开源高性能RPC框架。它可以通过可插拔的支持来有效地连接数据中心内和跨数据中心的服务,以实现负载平衡,跟踪,运行状况检查和身份验证。它也适用于分布式计算的最后一英里,以将设备,移动应用程序和浏览器连接到后端服务
          • gRPC 是 Google 开源的一个高性能、通用的开源 RPC 框架。其由主要面向移动应用开发并基于 HTTP/2 协议标准而设计(支持双向流、消息头压缩等功能,更加节省带宽),基于 ProtoBuf 序列化协议开发,并且支持众多开发语言
            • ProtoBuf( Protocol Buffer) 是一种更加灵活、高效的数据格式,可用于通讯协议、数据存储等领域,基本支持所有主流编程语言且与平台无关。不过,通过 ProtoBuf 定义接口和数据类型还挺繁琐的,这是一个小问题
          • 不过,gRPC 的设计导致其几乎没有服务治理能力。如果你想要解决这个问题的话,就需要依赖其他组件比如腾讯的 PolarisMesh(北极星)了。
        • Thrift:最初Facebook 开发的内部框架,2007 年贡献给了 Apache 基金,成为 Apache 开源项目之一,支持多种语言。 Apache Thrift是Facebook开源的跨语言的RPC通信框架,目前已经捐献给Apache基金会管理,由于Thrift其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于thrift研发一套分布式服务框架,增加诸如服务注册、服务发现等功能
          • Apache Thrift 是 Facebook 开源的跨语言的 RPC 通信框架,目前已经捐献给 Apache 基金会管理,由于Apache Thrift 其跨语言特性和出色的性能【Thrift支持多种不同的编程语言,包括C++、Java、Python、PHP、Ruby等(相比于 gRPC 支持的语言更多 )】,在很多互联网公司得到应用,有能力的公司甚至会基于 thrift 研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。
        • Hadoop的子项目Avro-RPC
        • RMI(JDK自带): JDK自带的RPC,有很多局限性,不推荐使用

        • Hessian:caucho提供的binary-RPC实现的远程通信框架Hessian:Hessian是一个由CauchoTechnology开发的轻量级二进制RPC框架,Hessian通过Servlet提供远程服务,可以将某个请求映射到Hessian服务。Spring MVC的DispatcherServlet支持该功能,DispatcherServlet可将匹配模式的请求转发到Hessian服务。Hessian的Server端提供一个Servlet基类,用来处理发送的请求,而Hessian的远程过程调用则使用动态代理来实现,采用面向接口编程。因此,Hessian 服务通常通过Java接口对外暴露
          • Hessian的优点如下:
            • 简单易用,面向接口编程,通过接口暴露服务,轻量级,可以穿透防火墙;
            • 采用二进制传输,序列化效率高;支持多语言,包括Java、Python、C++、.NET、C#、PHP、Ruby等;
            • Hessian是一个轻量级的remoting on http工具,使用简单的方法提供了RMI的功能。 相比WebService,Hessian更简单、快捷。采用的是二进制RPC协议,因为采用的是二进制协议,所以它很适合于发送二进制数据
            • 可与spring集成,配置比较简单。
        • dubbo RPC
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第7张图片
          • 在通常情况下,这四种主要序列化方式的性能从上到下依次递减。对于dubbo RPC这种追求高性能的远程调用方式来说,实际上只有1、2两种高效序列化方式比较般配,而第1个dubbo序列化由 于还不成熟,所以实际只剩下2可用,所以dubbo RPC默认采用hessian2序列化
  • 咱再看看人家一次完整的RPC调用流程是怎样的?
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第8张图片
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第9张图片
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第10张图片
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第11张图片
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第12张图片
  • 整个RPC的过程:或者说完整的调用流程
    • 步骤一:服务消费端(client) 以本地调用的方式 调用远程服务;调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接
      • 客户端从服务列表中选取其中一个的服务地址,并将数据通过网络发送给服务端
    • 步骤二:客户端的Client Stub(client stub)【本地代理模块 Proxy】 接收到调用请求后 Client Stub负责将方法、参数等组装成能够进行网络传输的消息体(序列化),也就是RpcRequest;或者说客户端会通过本地代理模块 Proxy 调用服务端,Proxy 模块收到负责将方法、参数等数据转化成网络字节流
      • 网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”
      • 根据协议【我们在数据包上面标明数据包的类型和长度,这样就可以正确的解析数据了。我们把数据格式的约定内容叫做“协议”。大多数的协议会分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。】格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。
    • 步骤三:客户端 Stub(client stub) 通过Sockets找到远程服务的地址,并将消息发送到服务提供端;
      • 调用方持续地把请求参数序列化成二进制后,经过 TCP 传输【RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输】给了服务提供方
    • 步骤四:服务端 server stub(桩)收到RpcRequest类型的消息将RpcRequest类型的消息反序列化为Java对象,然后紧接着服务端 server stub(桩)根据从RpcRequest中反序列化出来的对象中的类、方法、方法参数等信息调用本地的方法【服务调用者调用的远程服务实际是远程服务的本地代理,本质上是通过动态代理的拦截机制,将本地调用封装成远程服务调用】
      • 服务端接收到数据后进行解码,得到请求信息。然后服务端根据解码后的请求信息调用对应的服务,然后将调用结果返回给客户端
    • 步骤五:服务端server stub(桩)得到方法执行结果并 将执行结果组装成能够进行网络传输的消息体RpcResponse(序列化)并发送至消费方
    • 步骤六:客户端 Stub (client stub)接收到RpcResponse类型的消息后并将RpcResponse类型的消息反序列化为Java对象,这样也就得到了最终远程调用的结果。over!
      • 服务端在启动后,会将它提供的服务列表发布到注册中心,客户端向注册中心订阅服务地址
  • 那上面的过程或者说图中的流程到底是谁帮咱们实现的呢?这就要归功于RPC中的五大金刚(RPC的五个角色)
    • 整个 RPC的 核心功能可以分为如下五个部分或者五个角色
      • 1.客户端(服务消费端) :调用远程方法的一端。
      • 2.客户端 Stub(桩) : 这其实就是 客户端的代理类代理类 主要做的事情很简单,就是 把你客户端要调用方法、类、方法参数等信息传递到服务端
        • 远程代理对象:服务调用者调用的服务实际是远程服务的本地代理,对于Java语言,它的实现就是JDK的动态代理,通过动态代理的拦截机制,将本地调用封装成远程服务调用
          • 动态代理:点点这里先看看
        • 服务调用者需要通过一定的途径获取远程服务调用相关信息,例如服务端接口定义Jar包导入、获取服务端IDL文件等
      • 3.网络传输 : 既然要调用远程的方法就要发请求,请求中至少要包含你调用的类名、方法名以及相关参数吧。网络传输就是你要把你调用的方法的信息比如说参数啊这些东西通过网络传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。【Client在不知道调用细节的情况下调用存在于远程计算机上的某个对象(就像调用本地应用程序中的对象一样)、调了跨进程的方法
        • 网络传输的实现方式有很多种:
          • 比如最近基本的 Socket或者性能
          • 以及 封装更加优秀的 Netty(推荐)
      • 4.服务端 Stub(桩) :这个桩就不是代理类了这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去指定对应的方法然后返回结果给客户端的类
        • 服务端最重要的任务便是提供服务接口的真正实现并在某个端口上监听网络请求,监听到请求后从网络请求中获取到对应的参数(比如服务接口、方法、请求参数等),再根据这些参数通过反射的方式调用接口的真正实现获取结果并将其写入对应的响应流中。】
      • 5.服务端(服务提供端) :提供远程方法的一端。【服务端最重要的任务便是提供服务接口的真正实现并在某个端口上监听网络请求,监听到请求后从网络请求中获取到对应的参数(比如服务接口、方法、请求参数等),再根据这些参数通过反射的方式调用接口的真正实现获取结果并将其写入对应的响应流中。】
        • 远程服务提供者需要以某种形式提供或者说暴露服务调用相关的信息,包括但不限于服务接口定义、数据结构,或者中间态的服务定义文件,例如Thrift的IDL文件,WS-RPC的WSDL文件定义,甚至也可以是服务端的接口说明文档。
  • 其实呢,说到上面的五个角色,还是有点冰山一脚,一个比较完善的RPC框架有很多:
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第13张图片
    • 上面只是咱们考虑了一些很基本的功能:
      • 你网络传输得可靠吧,所以得有TCP
      • 你网络传输得转二进制吧,你对象不能直接传输嘛,所以得序列化
      • 你怕包太大,你又压缩解压缩
      • 你单机版本扛不住大并发访问量,所以得搞集群,你有了集群你不用负载均衡等帮你确定唯一的服务提供者(虽然服务消费者视角他只会感觉只有一台服务器,但人家就是有集群)?【也就是服务发现】有了服务器集群之后你服务之间关系的管理你不用服务治理能行?配置管理你不用难道你要有改动时自己一个配置文件一个配置文件去改?你不用动态代理能行,这是RPC的基本操作…
    • 系统就得讲究一个扩展性,所以我们要给RPC考虑插件化的架构。
      • 可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。【在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制, SPI它可以动态地为某个接口寻找服务实现使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。】
      • 但在实际项目中,我们其实很少使用到 JDK 自带的 SPI 机制,首先它不能按需加载,ServiceLoader 加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是 扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个 Spring Bean,原生的 Java SPI 就不支持。你像人家Dubbo中有自己的SPI,没用原生的
    • 所以,RPC 框架就包含了两大核心体系:核心功能体系与插件体系
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第14张图片
      • 整个架构就变成了一个微内核架构,其实关键点就是“插件化”,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题
    • 整体架构上面说完了,那里面每一条每一块得值得咱们细看一下:【特此感谢何小锋老师的RPC实战与核心原理这门课,没有这没课也就没有我的笔记】
      • (RPC 框架的)服务发现机制:【完成了接口跟服务提供者 IP 的映射,这个映射就是一种命名服务】咱们Dubbo中的Zookeeper不就是干这个的嘛
        java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第15张图片
        • 【为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的这些 IP 随时可能变化,我们需要用一本“通信录”及时获取到对应的服务节点【对于服务调用方和服务提供方来说,其契约就是接口,相当于“通信录”中的姓名,服务节点就是提供该契约或者说该接口的一个具体实现,这个 获取对应的服务提供者端的服务节点的过程我们一般叫作“服务发现”。】
          • 注册中心比如,ZooKeeper,可以实现更好地将客户端和服务端解耦【采用注册中心的好处是可以解耦客户端和服务端之间错综复杂的关系,并且能够实现对服务的动态管理。服务配置可以支持动态修改,然后将更新后的配置推送到客户端和服务端,无须重启任何服务】,以及实现服务优雅上线和下线。也就是实现服务注册和发现的功能。服务端节点上线后自行将服务名称及其对应的地址(ip+port)向注册中心注册服务列表,节点下线时需要从注册中心将节点元数据信息移除。客户端向服务端发起调用时,服务调用方自己负责根据服务名称从注册中心获取服务端的服务列表,然后在通过负载均衡算法选择其中一个服务节点进行调用。这是最简单直接的服务端和客户端的发布和订阅模式,不需要再借助任何中间服务器,性能损耗也是最小的。
            • 注册中心负责服务地址的注册与查找,相当于目录服务
          • 这就产生了一个问题,服务在下线时需要从注册中心移除元数据,那么注册中心怎么才能感知到服务下线呢?我们最先想到的方法就是节点主动通知的实现方式,当节点需要下线时,向注册中心发送下线请求,让注册中心移除自己的元数据信息但是如果节点异常退出,例如断网、进程崩溃等,那么注册中心将会一直残留异常节点的元数据,从而可能造成服务调用出现问题。为了避免上述问题,实现服务优雅下线比较好的方式是采用主动通知 + 心跳检测的方案除了 主动通知注册中心下线外,还需要增加节点与注册中心的心跳检测功能,这个过程也叫作探活。心跳检测可以由节点或者注册中心负责,例如注册中心可以向服务节点每 60s 发送一次心跳包,如果 3 次心跳包都没有收到请求结果,可以任务该服务节点已经下线
        • 基于 ZooKeeper 的服务发现:
          • 整体思路:
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第16张图片
            • Zookeeper将数据保存在内存中,性能非常棒。
          • 当后期微服务化程度越来越高时【zookeeper作为注册中心时,当服务节点数量达到一定规模时,会出现性能问题,主要是由于其保证强一致性】,ZooKeeper 集群整体压力也越来越高,尤其在集中上线的时候越发明显【比如”集中爆发”,在一次大规模上线的时候,当有超大批量的服务节点在同时发起注册操作,ZooKeeper 集群的 CPU 突然飙升,导致 ZooKeeper 集群不能工作了,而且也无法立马将 ZooKeeper 集群重新启动,一直到 ZooKeeper 集群恢复后业务才能继续上线。当连接到 ZooKeeper 的节点数量特别多,对 ZooKeeper 读写特别频繁,且 ZooKeeper 存储的目录达到一定数量的时候,ZooKeeper 将不再稳定,CPU 持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper 就因无法承受瞬间的读写压力,马上宕机。转自RPC实战与核心原理】。这也说明了ZooKeeper 集群性能显然已经无法支撑现有规模的服务集群了,需要重新考虑服务发现方案
        • 基于消息总线的最终一致性的注册中心
          • ZooKeeper 的一大特点就是强一致性,ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。它要求**保证每个节点的数据能够实时的完全一致,这也就直接导致了 ZooKeeper 集群性能上的下降**。这不跟IO模型中的有些想法很类似嘛
          • RPC 框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以我们**可以牺牲掉 CP(强制一致性),而选择 AP(最终一致),来换取整个注册中心集群的性能和稳定性【大部分高并发或者、高性能场景中一般不使用强一致性 而采用最终一致性】**。
          • 可以考虑采用消息总线机制。注册数据可以全量缓存在集群中的每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第17张图片
            • 服务调用方拿到的服务节点如果不是最新的,或者说目标节点存在已经下线或不提供指定接口服务的情况,这个时候是有问题的?这个问题我们放到了 RPC 框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点
            • 在 RPC 领域精耕细作后,你会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略
      • (RPC 框架的)负载均衡:负载均衡可以先点点看,这一篇:RPC 框架中设计自适应的负载均衡,其关键点就是调用端收集服务端每个节点的指标数据,再根据各方面的指标数据进行计算打分,最后根据每个节点的分数,将更多的流量打到分数较高的节点上
        • (转自RPC实战与核心原理的实例):有一天碰上了流量高峰,突然发现线上服务的可用率降低了,经过排查发现,是因为其中有几台机器比较旧了当时最早申请的一批容器配置比较低,缩容的时候留下了几台,当流量达到高峰时,这几台容器由于负载太高,就扛不住压力了。业务问我们有没有好的服务治理策略?
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第18张图片
          • 解决方法:治理平台上调低这几台老旧机器的权重,这样的话,老旧机器上来的访问的流量自然就减少了
            • 虽说我们的服务治理平台能够动态地控制线上服务节点接收的访问量,但当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了
          • 但是呢,当发现服务可用率降低的时候,业务请求已经受到影响了这时再如此解决,需要时间啊,那这段时间里业务可能已经有损失了。咱们强大的RPC 框架有没有什么智能负载的机制?能否及时地自动控制服务节点接收到的访问量---->不就说的是咱们负载均衡策略嘛?设计一种自适应的负载均衡策略
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第19张图片
        • 设计一种自适应的负载均衡策略过程中,咱们要清楚RPC 实现的负载均衡所采用的策略与传统的 Web 服务实现负载均衡所采用策略是不同的,然后,开始设计思路的组织
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第20张图片
          • RPC 负载均衡策略一般包括随机权重、Hash、轮询。其中的随机权重策略应该是我们最常用的一种了,通过随机算法,我们基本可以保证每个节点接收到的请求流量是均匀的;同时我们还可以通过控制节点权重的方式,来进行流量控制。比如我们默认每个节点的权重都是 100,但当我们把其中的一个节点的权重设置成 50 时,它接收到的流量就是其他节点的 1/2
          • 由于负载均衡机制完全是由 RPC 框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时我们可以通过控制权重的方式,对负载均衡进行治理。【服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。按需分配】
          • 其实就是只要调用者知道 每个服务节点处理请求的能力
          • 再根据 服务处理节点处理请求的能力 来判断要打给它多少流量就可以了
            • 当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。
            • 这里我们采用一种打分的策略来判定一个服务节点的处理能力。服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数,比如总分 10 分,如果 CPU 负载达到 70%,就减它 3 分,当然了,减 3 分只是个类比,需要减多少分是需要一个计算策略的。给服务节点打分时我们可以为每个指标都设置一个 指标权重占比, 然后再根据这些指标数据,计算分数。
          • 服务调用者给每个服务节点都打完分之后,会发送请求,那这时候我们又该如何根据分数去控制给每个服务节点发送多少流量呢?
            • 我们可以 配合随机权重的负载均衡策略 去控制,通过最终的指标分数修改服务节点最终的权重。例如给一个服务节点综合打分是 8 分(满分 10 分),服务节点的权重是 100,那么计算后最终权重就是 80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的 80%(这里假设其他节点默认权重都是 100,且指标正常,打分为 10 分的情况)
      • (RPC 框架的)服务的健康监测:帮助调用方应用来管理所有服务提供方的连接,并动态维护每个连接的状态,方便服务调用方在每次发起请求的时候都可以拿到一个可用的连接解决方案是让调用方实时感知到节点的状态变化
        • 因为有了集群,所以每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址。为了保证请求成功,我们就需要确保每次选择出来的 IP 对应的连接是健康的【因为调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,所以咱们得保证选择出来的连接一定是可用的】。但是呢,咱们不可能每次提前加悲观锁,挨个检查一遍,而应该是 有一套反馈机制,大部分关键部位的状态变化,我作为调用方,都能够第一时间了解。所以 解决方案是让调用方实时感知到节点的状态变化
        • 转自RPC实战与核心原理的一个例子:某天发现线上业务的某个接口可用性并不高【也就是基本上十次调用里总会有几次失败】,查看了具体的监控数据之后发现只有请求具体打到某台机器的时候才会有这个问题,也就是说,集群中有某台机器出了问题。
          • 比较快的解决问题的策略就是,先下线这个有问题机器
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第21张图片
          • 彻底解决问题的方法是:“接口调用某台机器的时候这台机器已经出现不能及时响应了,那为什么 RPC 框架还会继续把请求发到这台有问题的机器上呢?。 RPC 框架还会把请求发到这台机器上,也就是说从服务调用方的角度看,它没有觉得这台服务器有问题。这是为啥呢”。一步一步查看问题时间点的监控和日志,发现了...
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第22张图片
          • 通过上面四个分析后得出的结果,那台问题服务器**在某些时间段出现了网络故障,但也还能处理部分请求**。换句话说,它处于半死不活的状态【它还没彻底“死”,还有心跳,这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。】。说明我们的 服务检测机制有问题,有的服务本来都已经病危了,但我们还以为人家只是个感冒
        • 健康检测的逻辑:
          • 正常情况下,当服务方下线,我们肯定会收到连接断开的通知事件,在这个事件里面直接加处理逻辑。但是 因为应用健康状况不仅包括 TCP 连接状况,还包括应用本身是否存活,很多情况下 TCP 连接没有断开,但应用可能已经“僵死了”。所以 业内常用的检测方法就是用心跳机制。【心跳机制其实就是 服务调用方每隔一段时间就问一下服务提供方,“兄弟,你还好吧?”,然后服务提供方很诚实地告诉调用方它目前的状态。服务提供方的状态一般会有三种情况,一个是我很好【健康状态:建立连接成功,并且心跳探活也一直成功】,一个是我生病了【亚健康状态:建立连接成功,但是心跳请求连续失败】,一个是没回复【死亡状态:建立连接失败】。用专业的词来对应这三个状态就是:】。
            • 节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化
              java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第23张图片
        • 优化策略:
          • 一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值,比如 3 次(具体看你怎么配置了)
            • 但如果像上面例子中的节点的心跳日志只是间歇性失败,也就是时好时坏,这样,失败次数根本没到阈值,调用方会觉得它只是“生病”了,并且很快就好了。
              • 解决方法一:治标不治本,改下配置,调低阈值
                • 调用方跟服务节点之间网络状况瞬息万变,这种在网络波动的时候会导致误判。并且在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接
              • 解决方法二:核心是 服务节点网络有问题,心跳间歇性失败。我们现在判断节点状态只有一个维度,那就是心跳检测,那是不是可以再加上业务请求的维度呢?用 可用率 这个参数解决
                java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第24张图片
      • (RPC 框架的)路由策略:让请求按照设定的规则发到不同的节点上
        • 在真实环境中我们的 服务提供方是以一个集群的方式 提供服务,这对于服务调用方来说,就是一个接口会有多个服务提供方同时提供服务,所以我们的 RPC 在每次发起请求的时候,都需要 从多个服务提供方节点里面选择一个用于发请求的节点。既然这些节点都可以用来完成这次请求,那么我们就可以简单地认为这些节点是同质的【这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的】
          • 我们每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候,导致我们应用不稳定的因素就变得很多
          • 为了减少这种风险,我们一般会选择 灰度发布 我们的应用实例,比如我们可以 先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例
            • 但这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。因为对于我们的服务提供方来说,我们的服务会同时提供给很多调用方来调用,尤其是像一些基础服务的调用方会更复杂,比如商品、价格等等,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损,人家业务已经办完的人该咋办
          • 所以我们得想办法减少上线变更导致的风险----->路由在 RPC 中的应用
            • 有种办法是,可以在上线前把所有的场景都重新测试一遍,但是由于线上环境太复杂了,单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的。也就是没办法100% 规避风险
            • 所以,另外的办法就是尽量减小上线出问题导致业务受损的范围,也就是可以在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果
        • 实现路由策略【路由策略是我们常见的 IP 路由策略,用于限制可以调用服务提供方的 IP】:
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第25张图片
          • 当我们选择要灰度验证功能的时候,是不是就可以让注册中心在推送的时候区别对待,而不是一股脑的把服务提供方的 IP 地址推送到所有调用方。换句话说就是,注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个 IP 地址的
            • 通过服务发现的方式来隔离调用方请求,从逻辑上来看确实可行,但注册中心在 RPC 里面的定位是用来存储数据并保证数据一致性的。如果把这种复杂的计算逻辑放到注册中心里面,当集群节点变多之后,就会导致注册中心压力很大,而且大部分情况下我们一般都是采用开源软件来搭建注册中心,要满足这种需求还需要进行二次开发。所以从实际的角度出发,通过影响服务发现来实现请求隔离并不划算
          • 所以,我们可以在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),那我们是不是可以在选择节点前加上“筛选逻辑”,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则
        • 参数路由:
          • 有了 IP 路由之后,上线过程中我们就可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布功能来说,这样做我们可以把试错成本降到最低
          • 除此之外,有时候比如 在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。在流量切换的过程中,为了保证整个流程的完整性,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。因为 IP 路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。所以我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,我们可以很容易地拿到请求参数
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第26张图片
      • (RPC 框架的)异常重试:
        • 假如,当我们发起一次 RPC 调用去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功。此时我们就需要重新发起一次 RPC 调用【low low的做法是在代码逻辑里 catch 一下,失败了就再发起一次调用;正好咱们RPC中有个重试机制可以解决这个重发一次调用请求这个问题】
        • RPC 框架的重试机制:【考虑了业务逻辑必须是幂等的超时时间需要重置以及去掉有问题的服务节点后,这样的异常重试机制】当调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第27张图片
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第28张图片
          • 但是要知道并不是所有的异常都会触发重试机制,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常 等等。
          • 用户在使用异常重试时需要注意哪些问题:
            • 比如当网络突然抖动了一下导致请求超时了,但这个时候调用方的请求信息可能已经发送到服务提供方的节点上,也可能已经发送到服务提供方的服务节点上,那如果请求信息成功地发送到了服务节点上,那这个节点就要执行业务逻辑了,如果这个时候发起了重试,业务逻辑还是会被执行,但是如果这个服务业务逻辑不是幂等的,比如插入数据操作,那么触发重试机制就会引发一致性等问题
              • 所以,可以总结出:在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了
            • 还有,【连续的异常重试可能会出现一种不可靠的情况,那就是连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。】比如我把调用端的请求超时时间设置为 5s,结果连续重试 3 次,每次都耗时 2s,那最终这个请求的耗时是 6s,那这样的话,调用端设置的超时时间是不是就不准确了呢?
              • 解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间【当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,我们可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。】
            • 还有,当调用端设置了异常重试策略,发起了一次 RPC 调用,通过负载均衡选择了节点,将请求消息发送到这个节点,这时这个节点由于负载压力较大,导致这个请求处理失败了,调用端触发了重试,再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点,就会影响重试的效果。因此,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率
            • 还有,只有 RPC 框架中特定的异常才会如此,比如连接异常、超时异常。而像服务端业务逻辑中抛回给调用端的异常是不能重试的【比如服务端的业务逻辑是对数据库某个数据的更新操作,更新失败则抛出个更新失败的异常,调用端可以再次调用,来触发服务端重新执行更新操作。那这个时候对于调用端来说,它接收到了更新失败异常,虽然是服务端抛回来的业务异常,但也是可以进行重试的。】所以,RPC 框架是不会知道哪些业务异常能够去进行异常重试的,我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,我们就可以采用这样的异常处理策略。如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,我们就允许对这个请求进行重试
      • (RPC 框架的)关闭流程:【优雅 停机,就是为了让服务提供方在停机应用的时候,保证所有调用方都能“安全”地切走流量,不再调用自己,从而做到对业务无损。其中实现的关键点就在于,让正在停机的服务提供方应用有状态,让调用方感知到服务提供方正在停机。】
        • 之前在架构那里咱们知道,在“单体应用”复杂到一定程度后,我们一般会进行系统拆分,也就是单体应用变为微服务架构。但是拆分之后有四个问题,其中服务间通信中就用到了咱们的RPC框架【用来解决各个子系统之间的通信问题】
          • 其实为啥要拆分,就是为了更方便、更快速地迭代业务【因为咱们要经常更新应用系统,时不时还老要重启服务器】
        • 所以,咱们就要考虑在重启服务的过程中,RPC 怎么做到让调用方系统不出问题呢,因为在服务重启的时候,对于调用方来说,这时候可能会存在以下几种不良情况
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第29张图片
          • 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中
          • 调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。被选中了不就坏事了嘛。
            • 所以咱们要想办法先通过“某种方式【可以通过人工通知调用方,让他们手动摘除要下线的机器;当然手动太麻烦了,都自动化时代了,RPC 里面不是有服务发现吗?它的作用不就是用来“实时”感知服务提供方的状态吗?当服务提供方关闭前,是不是可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除】”把要下线的机器从调用方维护的“健康列表”里面删除,这样负载均衡就选不到这个节点了。整个关闭过程中依赖了两次 RPC 调用:
              • 一次是服务提供方通知注册中心下线操作:
              • 一次是注册中心通知服务调用方下线节点操作:注册中心通知服务调用方都是异步的【服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以通过服务发现并不能做到应用无损关闭】
                java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第30张图片
        • 因为服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。所以我们可以在关闭的时候,设置一个请求“挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。就相当于人家窗口交接工作时会在窗口放一个该窗口已打烊。
          • 所以,我们的关闭设计思路是:当 服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,并没有处理这个请求”,然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点【不然你处理了一半交给半天,别人万一处理不了半截业务怎么办???】,这样就可以实现对业务无损
          • 但如果只是靠等待被动调用,就会让这个关闭过程整体有点漫长。因为有的调用方那个时刻没有业务请求,就不能及时地通知调用方了,所以我们可以加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。人家服务消费者没法调用的时候你如何去捕获到关闭事件呢,你咋知道谁发生上面的异常事件了呢?
            • 在 Java 语言里面,对应的是 Runtime.addShutdownHook 方法,可以注册关闭的钩子。在 RPC 启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常
      • (RPC 框架的)应用启动:其实就一句话,就好比我们日常生活中的热车,行驶之前让发动机空跑一会,可以让汽车的各个部件都“热”起来,减小磨损。【就是让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。】
        • 因为运行了一段时间后的应用,执行速度会比刚启动的应用更快【。这是因为在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度】,but这些临时的加载好的数据在我们应用重启后就消失了,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。所以 我们是不是可以通过某些方法,让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态。—>RPC中的启动预热
        • RPC中的启动预热具体实现:【启动预热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线。】
          • 咱们回想一下RPC 调用流程是怎样的,调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程
          • 首先对于调用方来说,我们要知道或者说获取服务提供方启动的时间:【在真实环境中机器都会默认开启 NTP 时间同步功能,来保证所有机器时间的一致性。so,不用担心有误差】
            • 第一种方法:服务提供方在启动的时候,把自己启动的时间告诉注册中心
            • 第二种方法:将服务提供方启动时间等同于注册中心收到的服务提供方的请求注册时间
          • 然后,问题来了,当我在大批量重启服务提供方的时候,会不会导致没有重启的机器因为扛的流量太大而出现问题?
            • 当你大批量重启服务提供方的时候,对于调用方来说,这些刚重启的机器权重基本是一样的,也就是说这些机器被选中的概率是一样的,大家都是一样得低,也就不存在权重区分的问题了。所以这个不用担心
        • 延迟暴露:
          • 这个是从服务提供方本身出发的一种应用启动方案而不是像应用预热一样从调用者角度出发
          • 我们应用启动的时候都是通过 main 入口,然后顺序加载各种相关依赖的类。不仅仅说的是SpringBoot。在Spring中应用启动加载的过程中,Spring 容器会顺序加载 Spring Bean,如果某个 Bean 是 RPC 服务的话,我们不光要把它注册到 Spring-BeanFactory 里面去,还要把这个 Bean 对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;当调用方收到这个服务提供方地址的时候,就会去建立连接发请求
            • 对于调用方来说,只要获取到了服务提供方的 IP,就有可能发起 RPC 调用,调用方的请求就过来了【调用方请求过来的原因是,服务提供方应用在启动过程中把解析到的 RPC 服务注册到了注册中心,这就导致在后续加载没有完成的情况下服务提供方的地址就被服务调用方感知到了。】,但如果这时候服务提供方没有启动完成的话,就会导致调用失败,从而使业务受损。
            • 所以,解决这个问题的话,我们就可以**把接口注册到注册中心的时间挪到应用启动完成后**。具体做法是:在应用启动加载、解析 Bean 的时候,如果遇到了 RPC 服务的 Bean,只先把这个 Bean 注册到 Spring-BeanFactory 里面去,而并不把这个 Bean 对应的接口注册到注册中心,只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址
            • 又有问题了,这时候应用虽然启动完成了,但并没有执行相关的业务代码,所以 JVM 内存里面还是冷的。如果这时候大量请求过来,还是会导致整个应用在高负载模式下运行,从而导致不能及时地返回请求结果。而且在实际业务中,一个服务的内部业务逻辑一般会依赖其它资源的,比如缓存数据。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,我们就可以降低重启后第一次请求出错的概率
              java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第31张图片
      • (RPC 框架的)熔断限流:这算是在使用 RPC 时,业务实现的自我保护
        • RPC 框架的自我保护方式不止有限流,而且光限流的方式也不止一种。
        • RPC 框架的服务端的自我保护
          • 其实说到底也就是,假如我们要发布一个 RPC 服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高,你就得保护一下这个节点,不让它再接收太多的请求,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了,不然这老小子就宕机了,这就叫做限流
          • RPC框架中实现限流的常用方式:
            • 方式一:可以在 RPC 框架中集成限流的功能,让使用方自己去配置限流阈值
            • 方式二:可以在服务端添加限流逻辑,当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑
              • 限流逻辑或者说限流算法有:
                • 计数器
                • 可以做到平滑限流的滑动窗口
                • 漏斗算法
                • 令牌桶算法
          • 然后,限流中要考虑的细节有:
            • 比如:我发布了一个服务,提供给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其它的应用大很多,这时我们就应该对这个应用下的调用端发送过来的请求流量进行限流。所以说我们在做限流的时候要考虑应用级别的维度,甚至是 IP 级别的维度,这样做不仅可以让我们对一个应用下的调用端发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流
              • 实现:RPC 框架真正强大的地方在于它的治理功能,而治理功能大多都需要依赖一个注册中心或者配置中心,我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置
                java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第32张图片
            • 再比如:我提供了一个服务,而这个服务的业务逻辑依赖的是 MySQL 数据库,由于 MySQL 数据库的性能限制,我们是需要对其进行保护。假如在 MySQL 处理业务逻辑中,SQL 语句的能力是每秒 10000 次,那么我们提供的服务处理的访问量就不能超过每秒 10000 次,而我们的服务有 10 个节点,这时我们配置的限流阈值应该是每秒 1000 次。那如果之后因为某种需求我们对这个服务扩容了呢?扩容到 20 个节点,我们是不是就要把限流阈值调整到每秒 500 次呢?这样操作每次都要自己去计算,重新配置,显然太麻烦了
              • 实现:我们可以让 RPC 框架自己去计算,当注册中心或配置中心将限流阈值配置下发的时候,我们可以将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值
            • 再比如:在实际情况下,一个服务节点所接收到的访问量并不是绝对均匀的,比如有 20 个节点,而每个节点限流的阈值是 500,其中有的节点访问量已经达到阈值了,但有的节点可能在这一秒内的访问量是 450,这时调用端发送过来的总调用量还没有达到 10000 次,但可能也会被限流,这样是不是就不精确了?那有没有比较精确的限流方式呢?
              • 实现:我们可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。【由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。至于要选择哪种限流方式,就要结合具体的应用场景进行选择了。】
        • RPC 框架的调用端的自我保护
          • 假如我要发布一个服务 B,而服务 B 又依赖服务 C,当一个服务 A 来调用服务 B 时,服务 B 的业务逻辑调用服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 在频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机。由此可见,服务 B 调用服务 C,服务 C 执行业务逻辑出现异常时,会影响到服务 B,甚至可能会引起服务 B 宕机。这还只是 A->B->C 的情况,试想一下 A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第33张图片
          • 熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器,所以我们可以在RPC的调用流程中的动态代理步骤中整合熔断器
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第34张图片
      • (RPC 框架的)业务分组:
        • 这个就像随着汽车的普及以及猛增,我们的道路越来越宽,慢慢地有了高速、辅路、人行道等等,让人车分流。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。同样的道理,我们用在 RPC 治理上也是一样的
          • 早期业务简单可以把服务实例统一管理,把所有的请求都用一个共享的“大池子”来处理。为了管理方便,我们把接口都放到了同一个分组下面,所有的服务实例是以一个整体对外提供能力的
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第35张图片
          • 后期因为业务发展丰富了**,调用接口的调用方就会越来越多,流量也会渐渐多起来**。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降
          • 此时我们就可以尝试 把应用提供方这个大池子划分出不同规格的小池子,再分配给不同的调用方,而不同小池子之间的隔离带,就是我们在 RPC 里面所说的分组,它可以实现流量隔离。Docker中的镜像与容器隔离、JVM中的线程私有空间、内存分段分页、ThreadLocal等。虽然不一定作用完全相同,但确实有相似的地方
        • RPC 里实现分组
          • 在RPC调用流程中,服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要 重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数【就像协议头中好多东西...】,通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用
          • 这个分组并没有一个可衡量的标准,但是可以自己选择标准,比如按照应用重要级别划分
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第36张图片
        • 分组后,问题来了:分组隔离后,个调用方在发 RPC 请求的时候可选择的服务节点数相比没有分组前减少了,那对于单个调用方来说,出错的概率就升高了。比如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方,从而导致这个调用方业务受损。正常情况下我们是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,我们一般都是允许对方借道行驶一段时间,直到道路完全恢复
          • 调用方应用服务发现的时候,除了带上对应的接口名,还需要带上一个特定分组名,所以对于调用方来说,它是拿不到其它分组的服务节点的,那这样的话调用方就没法建立起连接发请求了。因此问题的核心就变成了调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了。一个最简单的办法就是,允许调用方可以配置多个分组。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现我们想要的“借道”的效果所以我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用
  • 那如果当咱们设计一个RPC框架时,应该如何思考设计步骤,或者说一个成熟的RPC框架可以完成哪些功能:【RPC,个人感觉,说到底 RPC就是把拦截到的服务消费者端发出的调用服务所需的方法参数,通过序列化压缩编解码等转成可以在网络中传输的二进制,并保证通信过程正常,然后在服务提供方能正确地还原出语义,也就是反序列化出请求参数或者请求对象,最终找到对应的具体实现,然后实现像调用本地一样地调用远程的目的
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第37张图片
    • 首先得思考下面这五步骤:
      • 1.首先我们得需要一个注册中心,去管理消费者和提供者的节点信息,这样才会有消费者和提供才可以去订阅服务,注册服务。当然这个不是必要,因为有长连接缓存通信双方信息,所以这个注册中心不要也行。有了方便,可以在集群中随意玩耍
        • 通信框架解决客户端和服务端如何建立连接、管理连接以及服务端如何处理请求的问题。
      • 2.当有了注册中心后,可能会有很多个provider节点,那么我们肯定会有一个负载均衡模块来负责节点的调用,至于用户指定路由规则可以使一个额外的优化点。
      • 3.具体的调用肯定会需要牵扯到通信协议,所以需要一个模块来对通信协议进行封装,网络传输还要考虑序列化
        • 通信协议解决客户端和服务端采用哪种数据传输协议的问题。
        • 序列化和反序列化解决客户端和服务端采用哪种数据编解码的问题
      • 4.当调用失败后怎么去处理?所以我们还需要一个容错模块,来负责失败情况的处理
      • 5.其实做完这些一个基础的模型就已经搭建好了,我们还可以有更多的优化点比如一些请求数据的监控,配置信息的处理,日志信息的处理等等
    • 然后在上面几个步骤中再抽取一下,主要要考虑的问题有:
      • 服务消费者如何获取可用的远程服务器(服务注册与发现
      • 如何传递数据(网络通讯)
        • 网络传输过程中如何表示数据(序列化与反序列化
      • 服务消费者找到了可用的远程服务提供者之后,你服务提供者不能只是个摆设呀,你得找到人家 服务消费者调用的目标方法或者说服务(调用方法映射),那是不是得通信呀。
      • 那对于通信来说,我觉得可以和发快递类比一下,最重要的就是三点
        • 第一点,谁帮咱们来传,肯定是快递点帮咱们往过传,至于是坐火车、飞机、汽车还是人家自己送,咱不管(肯定是网络协议帮咱们传呀因为 RPC 用于业务系统之间的数据交互,要保证数据传输的可靠性,所以它一般默认采用 TCP 来实现网络数据传输)----->网络协议
          • 在这个过程中,其实道理很简单,A<---->B相互通信,那么A和B怎么相关找到对方呢。体现到咱们这里就是客户端或者说 服务消费者如何获取可用的远程服务器以及服务端如何确定并调用目标方法
        • 第二点,送,处于对于数据的安全性或者完整性考虑(网络传输的数据必须是二进制数据,可是在 RPC 框架中,调用方请求的出入参数都是对象,对象不能直接在网络中传输,所以需要提前把对象转成可传输的二进制数据,转换算法还要可逆,这个过程就叫“序列化”和“反序列化”。),要对咱们要送的东西进行包装,也就是序列化。----->序列化(其实序列化、反序列化以及编解码也可以算作是网络通信过程中的事情)
          • 另外,在网络传输中,RPC 不会把请求参数的所有二进制数据一起发送到服务提供方机器上,而是拆分成好几个数据包(或者把好几个数据包封装成一个数据包),所以服务提供方可能一次获取多个或半个数据包,这也就是网络传输中的粘包和半包问题
            • 为了解决这个问题,需要提前约定传输数据的格式,即“RPC 协议”。 大多数的协议会分成数据头和消息体
              • 数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;
              • 消息体主要是请求的业务参数信息和扩展属性等。
        • 第三点:实际上现在 大多系统都是集群部署的多台主机/容器对外提供相同的服务,如果 集群的节点数量很大的话,那么管理服务地址 也将是一件十分繁琐的事情。
          • 常见的做法是 各个服务节点将自己的地址和提供的服务列表注册到一个 注册中心,由 注册中心 来统一管理服务列表;这样的做法解决了一些问题同时 为客户端增加了一项新的工作——那就是服务发现【因为客户端要从注册中心中找到远程方法对应的服务列表并通过某种策略从中选取一个服务地址来完成网络通信。我客户端怎么知道要访问哪一个呢,是不是需要借助负载均衡咯】
  • 然后上面不是也说过RPC虽然说帮咱们实现的是屏蔽底层的一些编写起来很复杂的网络协议相关代码,然后让咱们调用远程服务像调本地服务一样简单(但实际上RPC就是以本地动态代理与反射实现的本地服务实现所谓的远程调用)。但是人家RPC说到底和HTTP一样都是应用层协议呀,那咱们对于协议而言,是通信双方都要遵守的约定,你看人家HTTP、TCP、UDP这些,都有自己的头部格式或者说协议格式,那你RPC玩呢,你的协议格式呢(这里借鉴一下何小锋老师课里面的图和设计思路)
    • 第一步
      在这里插入图片描述
    • 第二步:第一步中有个问题,你这样跟裸奔有啥区别,RPC中通信双方不得互相知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数我们可以统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。除了序列化方式,还有协议标示、消息 ID、消息类型这样的参数,这才是头部的。协议体呢,人家一般只放请求接口方法、请求的业务参数值和一些扩展属性
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第38张图片
    • 第三步:第二步中又有一个问题,你像上面设计后往后就不能再往协议头里加新参数了,如果加参数就会导致线上兼容问题。为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第39张图片
      • 举个具体例子,假设你设计了一个 88Bit 的协议头,其中协议长度占用 32bit,然后你为了加入新功能,在协议头里面加了 2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照 88bit 读取协议头,新加的 2 个 bit 会当作协议体前 2 个 bit 数据读出来,但原本的协议体最后 2 个 bit 会被丢弃了,这样就会导致协议体的数据是错的。。所以说明设计耦合的太死,得留一定的扩展性
      • 那能不能把参数加在不定长的协议体里面?不行。协议体里面会放一些扩展属性,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。但在某些场景下,这样做的代价有点高啊!你想,本来咱们是用序列化来得到原本调用对象的,而你现在就是为了得到一个小参数或者说小属性,这代价谁受得了
  • HTTP:既然已经有了HTTP这种那为什么还要有RPC这种。
    • 因为TCP是基于字节流的,裸用TCP会发生粘包拆包的现象,所以出现了RPC来解决裸TCP的问题,早期RPC出现只是为了用于能访问自家的服务器(Server)【比如xx管家、xx卫士作为客户端连接访问自己家的服务器】,但是如果我想访问别人家的服务器就得需要统一的标准,所以统一标准HTTP就出来了。后来HTTP在统一这方面慢慢做大做强了,然后RPC只能开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。那么为什么各个公司内部集群里各个微服务之间为什么不直接用HTTP呢。经过下面几个点之后,在服务发现、底层连接形式以及传输内容上HTTP和RPC大差不差,但是在传输内容这个过程中因为RPC 定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,基于这一点RPC成为天选之子
    • HTTP和RPC协议都属于应用层协议
    • HTTP与RPC的几个区别的点:
      • 服务发现:客户端找到要调用的服务对应的服务端的 IP 端口的过程,其实就是 服务发现。这块俩不分高低
        • 首先客户端要向某个服务器发起请求,你客户端得先和服务器建立连接,而建立连接的前提是,你客户端得知道服务器 IP 地址和端口,因为服务器那么多呀,集群?
          • 在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第40张图片
            • 如果我们用 DNS 来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过 DNS 拿到随机的一个服务提供者的 IP,并与之建立长连接,这看上去并没有太大问题,但在我们业界为什么很少用到这种方案呢?就是因为下面两个问题
              • 当这个 IP 端口下线了,服务调用者不能及时摘除服务节点
              • 如果在之前已经上线了一部分服务节点,这时我突然对这个服务进行扩容,那么新上线的服务节点不能及时接收到流量【这是因为为了提升性能和减少 DNS 服务的压力,DNS 采取了多级缓存机制,一般配置的缓存时间较长,特别是 JVM 的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化】,有一种想法是 加一个负载均衡设备,将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP,这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发【在kubernetes中其实就是这样做的:DNS + service】。但是在RPC中依旧不常用,缺点在图中:
                java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第41张图片
          • RPC 的话,就有些区别,一般会有专门的中间服务【注册中心,比如Zookeeper】去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS。
      • 底层连接形式:这块俩不分高低
        • 比如主流的 HTTP1.1 协议,会支持长连接,也就是默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。
          • 由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池,比如 Go 就是这么干的
        • RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保
      • 传输的内容
        • 基于 TCP 传输的消息,说到底无非都是 消息头 Header 【Header 是用于标记一些特殊信息,其中最重要的是 消息体长度】和消息体 Body【Body 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 JSON,Protocol Buffers (Protobuf) 。这个将结构体转为二进制数组的过程就叫 序列化 ,反过来将二进制数组复原成结构体的过程叫 反序列化。】
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第42张图片
          • 主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON 来 序列化 结构体数据
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第43张图片
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第44张图片
          • RPC,因为它 定制化程度更高可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。【but,上面说的 HTTP,其实 特指的是现在主流使用的 HTTP1.1,HTTP2在前者的基础上做了很多改进,所以 性能可能比很多 RPC 协议还要好,甚至连gRPC底层都直接用的HTTP2。】
            java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第45张图片
          • 为什么既然有了 HTTP2,还要有 RPC 协议?这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了
    • 具体一点说,TCP 是传输层的协议 ,而 基于 TCP 造出来的 HTTP 和各类 RPC 协议它们都只是定义了不同消息格式的 应用层协议 而已【RPC(Remote Procedure Call)又叫做 远程过程调用,它和HTTP还不一样,HTTP人家实打实就是一个协议【平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议】,RPC本身并不是一个具体的应用层协议,而是一种 调用方式 。】
      • 虽然大部分 RPC 协议底层使用 TCP,但实际上 它们不一定非得使用 TCP,RPC 改用 UDP 或者 HTTP,其实也可以做到类似的功能

PART2-1:网络协议:我们的通信框架基于Netty进行设计和开发
java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第46张图片

  • 回顾一下,咱们前面的网络协议工作流程是这样的:
    • 通过ip和port,建立Socket连接,然后通过反射得到要传给服务端的方法名、参数类型以及参数,并通过网络将这些参数传递过去
    • 下来就是,服务端这边通过BIO的方式监听Socket,等待客户端或者代理类的连接,然后开启一个线程去处理~五种网络IO模型
  • 或者总结一下,一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络 IO 发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次 RPC 调用便结束了。可以说,网络通信是整个 RPC 调用流程的基础
    //通过ip和port,建立Socket连接,然后通过反射得到要传给服务端的方法名、参数类型以及参数,并通过网络将这些参数传递过去
    Socket socket = new Socket("127.0.0.1", 2221);   	
    ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
    ...
    //下来就是,服务端这边通过BIO的方式监听Socket,等待客户端或者代理类的连接,然后开启一个线程去处理
    ServerSocket serverSocket = new ServerSocket(2221);
    	while(flag){
    		Socket socket = serverSocket.accept();
    		/**
    		 * 对客户端的连接进行处理
    		 */
    		process(socket);
    		socket.close();
    	}
    
    java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第47张图片
  • RPC层中的底层通信框架:
    • RPC通讯协议
    • http
    • http2.0 (gRPC)
    • TCP
    • 同步/异步阻塞/非阻塞
    • WebService
  • step1:我们首先得分析通信框架中一些技术点的选择:长连接?NIO?NIO选好使用JDK提供的NIO库还是直接集成开源的NIO框架Netty?
    • 绝大多数的分布式服务框架(RPC框架)都推荐使用长连接进行内部通信,选择长连接而不是短连接的原因如下:
      • 正是因为下面两个原因,分布式服务框架服务提供者和消费者之间通常采用长连接进行通信。
        • 1)相比于短连接,长连接更节省资源。如果每发送一条消息就要创建链路、发起握手认证、关闭链路释放资源,会损耗大量的系统资源。长连接只在首次创建时或者链路断连重连才创建链路,链路创建成功之后服务提供者和消费者会通过业务消息和心跳维系链路,实现多消息复用同一个链路节省资源。
        • 2)远程通信是常态,调用时延是关键指标:服务化之后,本地API调用变成了远程服务调用,大量本地方法演化成了跨进程通信,网络时延成为关键指标之一。相比于一次简单的服务调用,链路的重建通常耗时更多,这就会导致链路层的时延消耗远远大于服务调用本身的损耗,这对于大型的业务系统而言是无法接受的。
    • 绝大多数的分布式服务框架(RPC框架)都推荐使用NIO:咱们这个通信框架采用BIO还是NIO,还是两者都支持,需要在设计之初就选择好。BIO通信模型、NIO通信模型
      • BIO通信模型也就是阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式则正好相反一般来说,低负载、低并发的应用程序可以选择同步阻塞I/O以降低编程复杂度,但是对于高负载、高并发的网络应用,需要使用NIO的非阻塞模式进行开发
      • NIO采用多路复用技术,一个多路复用器Selector可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现,所以它并没有最大连接句柄1024/2048的限制。这也就意味着只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端,所以咱们在通信框架中应该使用更高效的NIO非阻塞IO模型
        • IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求,咱们Java中对咱们原生API做了封装,也就是Netty框架。还有类似Go语言,本身对 IO 多路复用的封装就已经很简洁了。
        • 服务端接收到客户端的请求后,常见的处理方式有三种,分别是BIO、NIO和AIO。
          • 同步阻塞方式(BIO):客户端发一次请求,服务端生成一个对应线程去处理。当客户端同时发起的请求很多时,服务端需要创建多个线程去处理每一个请求,当达到了系统最大的线程数时,新来的请求就无法处理了
            • BIO 适用于连接数比较小的业务场景,这样的话不至于系统中没有可用线程去处理请求。这种方式写的程序也比较简单直观,易于理解。阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低
          • 同步非阻塞方式 (NIO):客户端发一次请求,服务端并不是每次都创建一个新线程来处理,而是通过 I/O 多路复用技术进行处理。就是把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使系统在单线程的情况下可以同时处理多个客户端请求。这种方式的优势是开销小,不用为每个请求创建一个线程,可以节省系统开销
            • NIO 适用于连接数比较多并且请求消耗比较轻的业务场景,比如聊天服务器。这种方式相比 BIO,相对来说编程比较复杂。
          • 异步非阻塞方式(AIO):客户端发起一个 I/O 操作然后立即返回,等 I/O 操作真正完成以后,客户端会得到 I/O 操作完成的通知,此时客户端只需要对数据进行处理就好了,不需要进行实际的 I/O 读写操作,因为真正的 I/O 读取或者写入操作已经由内核完成了。这种方式的优势是客户端无需等待,不存在阻塞等待问题。
            • AIO 适用于连接数比较多而且请求消耗比较重的业务场景,比如涉及 I/O 操作的相册服务器。这种方式相比另外两种,编程难度最大,程序也不易于理解。
    • 选择好了用非阻塞NIO,那么自研还是选择开源的NIO框架?
      • 尽管JDK提供了丰富的NIO类库,网上也有很多NIO学习例程,但是直接使用JavaNIO类库想要开发出稳定可靠的通信框架却并非易事,原因如下。
        • 可靠性能力补齐,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等问题,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐的工作量和难度都非常大。
        • JDK NIO的BUG,例如臭名昭著的epollbug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该BUG发生概率降低了一些而已, 它并没有被根本解决。
      • 随着开源NIO框架的发展,目前越来越多的商用系统采取 直接集成开源NIO框架 的方式代替之前的自研方案。以最成熟的NIO框架Netty为例,它已经得到成百上千的商用项目验证。例如Hadoop的RPC框架avro使用Netty作为底层通信框架、实时流式计算框架Storm底层通信框架也采用的是Netty,还有Twitter内部使用的RPC框架Finagle,其底层通信框架也基于Netty构建。
        • RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,我们会选择 IO 多路复用的方式。开发语言的网络通信框架的选型上,我们最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)
        • Netty的优势总结如下:
          • API使用简单,开发门槛低。
          • 功能强大,预置了多种编解码功能,支持多种主流协议。
          • 定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展。
          • 性能高,通过与其他业界主流的NIO框架对比,Netty的综合性能最优。
          • 成熟、稳定,Netty 修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼。
          • 社区活跃,版本迭代周期短,发现的BUG可以被及时修复。同时,更多的新功能会加入。
          • 经历了大规模的商业应用考验,质量得到验证。在互联网、大数据、网络游戏、企业应用、电信软件等众多行业得到成功商用,证明了它已经完全能够满足不同行业的商业应用了。
  • step2:选择好通信框架中的技术点之后,咱们要开始考虑设计细节:
    • 通信框架服务端:
      • 1)提供上层API(屏蔽底层NIO框架),用于初始化服务端实例,设置服务端通信相关参数,包括服务端的I/O线程池(线程组参数)、监听地址、TCP相关参数、接收和发送缓冲区大小等。(一般服务端设计时,服务端只提供上层的API,不与任何具体协议绑定;另外服务端提供给用户的API要尽量屏蔽底层的通信细节,防止底层的变更引起上层级联变更。例如Netty3升级到Netty4,整个包路径都发生了改变,如果在底层屏蔽了Netty接口,上层不受影响。服务端功能上不要求全,重点在可扩展性上。)
        • 利用Netty提供的NIO服务端类库,可以非常轻松地实现通信框架服务端的开发。Netty为了向使用者屏蔽NIO通信的底层细节,在和用户交互的边界做了封装,目的就是为了减少用户开发工作量,降低开发难度。ServerBootstrap是Socket服务端的启动辅助类,用户通过ServerBootstrap可以方便地创建Netty的服务端
          • 基于Netty开发通信框架服务端,有以下几个关键点需要掌握:
            • 1.用于接收客户端连接的线程池:通常被称为bossGroup, 它的构造方法与处理I/O读写的线程池相同( workerGroup),都是通过new NioEventLoopGroup创建。bossGroup的线程数建议设置为1,因为它仅负责接收客户端的连接,不做复杂的逻辑处理,为了尽可能少地占用资源,它的取值越小越好。
            • 2 .TCP 参数设置:建议在API层面开放TCP参数设置,为一些特殊的私有协议预留扩展点,对于大多数的开发者,使用默认值即可。Netty 的ChannelOption类提供了对TCP参数的封装,由于是工具常量类,未来发生变更的可能性很小,因此可以直接开放给上层使用。
            • 3.编解码框架的定制:通信框架封装Netty的MessageToByteEncoder和LengthFieldBasedFrameDecoder,提供统- -一、通用的编解码接口或者抽象类,由用户实现编解码接口,实现编解码的灵活定制。
            • 4通信层业务逻辑的定制:利用Netty的ChannelPipeline,将ChannelHandler的链式编排能力以参数或者配置化的方式开放出来,由用户灵活扩展,实现协议层逻辑定制。例如示例代码中的超时检测、握手认证等。需要指出的是,如果担心底层通信框架API发生变更直接影响用户的代码,可以在Netty的ChannelHandler上面再抽象包装一层,通过Facade模式屏蔽底层的实现细节。
      • 2)提供可扩展的编解码插件,用户可以通过扩展的方式实现自定义协议的编码和解码。
      • 3)提供拦截面,用于私有协议栈开发。例如通过新增鉴权插件实现服务端对客户端的安全认证。
    • 通信框架客户端:
      • 需要考虑网络连接超时、连接失败等异常场景
  • step3:项目中使用Netty,咱们已经选择直接集成开源NIO框架
    • 那咱们web项目那里已经很熟悉了,maven项目嘛,pom.xml文件引入netty
      <dependency>
          <groupId>io.nettygroupId>
          <artifactId>netty-allartifactId>
          <version>4.1.51.Finalversion>
      dependency>
      
  • 补充:成熟的 RPC 框架一般会提供四种调用方式【RPC 框架的性能和吞吐量与合理使用调用方式是息息相关的】,分别为同步 Sync、异步 Future、回调 Callback和单向 Oneway
    • Sync同步调用:
      • Sync 同步调用一般是 RPC 框架默认的调用方式
        java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第48张图片
    • Future 异步调用。客户端发起调用后不会再阻塞等待,而是拿到 RPC 框架返回的 Future 对象,调用结果会被服务端缓存,客户端自行决定后续何时获取返回结果。当客户端主动获取结果时,该过程是阻塞等待的
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第49张图片
    • Callback 回调调用。客户端发起调用时,将 Callback 对象传递给 RPC 框架,无须同步等待返回结果,直接返回。当获取到服务端响应结果或者超时异常后,再执行用户注册的 Callback 回调。所以 Callback 接口一般包含 onResponse 和 onException 两个方法,分别对应成功返回和异常返回两种情况。
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第50张图片
    • Oneway 单向调用。客户端发起请求之后直接返回,忽略返回结果。Oneway方式是最简单的
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第51张图片

PART2-2:序列化:自己实现encode与decode【为啥要序列化,再叨叨一遍:网络传输的数据必须是二进制数据【在网络传输的时候,我们需要经过 IO,而 IO 传输支持的就是字节数组这种格式,所以序列化过后可以更好的传输。】但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求 转换算法是可逆的,这个过程我们一般叫做“序列化”】
java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第52张图片

  • RPC层中的序列化和反序列化框架:常用的序列化方式分为两类【文本类如 XML/JSON 等;二进制类如 PB/Thrift 等】
    • 为什么要进行序列化和反序列化【序列化最终的目的是为了对象可以跨平台存储和进行网络传输。】
      • 解决内存中数据结构到字节序列的映射过程中,如何保留各个结构和字段间的关系而生的技术 。
      • 解决异构系统的数据传输,比如大小端、远端的持久存储;
      • 压缩数据,加快网络传输。【 网络传输耗时一方面取决于网络带宽大小,另一方面取决于数据传输量。想加快网络传输,要么提高带宽,要么减小数据传输量,而对数据进行编码的主要目的就是减小数据传输量。比如一部高清电影原始大小为 30GB,如果经过特殊编码格式处理,可以减小到 3GB,同样是 100MB/s 的网速,下载时间可以从 300s 减小到 30s。】
    • 对于不想进行序列化的变量,使用 transient 关键字修饰
      • transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复
        • transient 只能修饰变量,不能修饰类和方法。
        • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0
        • static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化
    • 序列化协议对应于 TCP/IP 4 层模型的哪一层?
      • OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么?OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分
    • 常见的序列化框架:除了对应的官网和官方文档,还有javaGuide老师的Gitee和Github里面的项目对应的序列化源码【https://gitee.com/SnailClimb/guide-rpc-framework】,对笔记整理很有帮助,特此感谢
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第53张图片
      • java.io.Serializable :Java的JDK自带的Serializable(需要序列化的Java对象只需要实现java.io.Serializable接口并生成序列化ID,这个对象对应的类就能通过java.io.ObjectInput和java.io.ObjectOutput序列化和反序列化),这个Java自带的JDK的序列化方式有以下缺点:
        在这里插入图片描述
        import java.io.*;
        
        public class Student implements Serializable {
            //学号
            private int studentId;
            //姓名
            private String name;
        
            public int getStudentId() {
                return studentId;
            }
        
            public void setNo(int studentId) {
                this.studentId = studentId;
            }
        
            public String getName() {
                return name;
            }
        
            public void setName(String name) {
                this.name = name;
            }
        
            @Override
            public String toString() {
                return "Student{" +
                        "studentId =" + studentId +
                        ", name='" + name + '\'' +
                        '}';
            }
        
            public static void main(String[] args) throws IOException, ClassNotFoundException {
                String home = System.getProperty("user.home");
                String basePath = home + "/Desktop";
                FileOutputStream fos = new FileOutputStream(basePath + "student.dat");
                Student student = new Student();
                student.setStudentId(100);
                student.setName("TEST_STUDENT");
                //JDK 自带的序列化机制具体的实现是由 ObjectOutputStream 完成的
                ObjectOutputStream oos = new ObjectOutputStream(fos);
                oos.writeObject(student);
                oos.flush();
                oos.close();
        
                FileInputStream fis = new FileInputStream(basePath + "student.dat");
                //反序列化的具体实现是由 ObjectInputStream 完成的
                ObjectInputStream ois = new ObjectInputStream(fis);
                Student deStudent = (Student) ois.readObject();
                ois.close();
        
                System.out.println(deStudent);
        
            }
        }
        
        • 在 Java 中我们经常就可以看到很多实体类或者 POJO 都会实现 Serializable 接口,这个Serializable接口是一个空接口,只是用来标记的
        • Java的JDK自带的Serializable序列化方式的缺点:
          • 这个序列化方式只支持Java语言,不够灵活。java自带序列化方式(Java序列化写入不仅是完整的类名,也包含整个类的定义,包含所有被引用的类),不够通用,不够高效
          • 效率很低
          • 序列化之后的字节数组体积较大,导致传输成本加大
      • JSON:JSON 是典型的 Key-Value 方式,没有数据类型,是一种文本型序列化框架,易用且应用最广泛且可读性很高,跨平台跨语言支持,基于 HTTP 协议的 RPC 框架都会选择 JSON 序列化方式,但它的空间开销很大,在通信时需要更多的内存。【JSON序列化方式还是很常用的,无论是前台 Web 用 Ajax 调用、用磁盘存储文本类型的数据,还是基于 HTTP 协议的 RPC 框架通信,都会选择 JSON 格式。但是基于JSON的两个缺点,如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能】
        • 用 JSON 进行序列化有这样两个问题:
          • JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销。因为很存在很多冗余内容,比如双引号,花括号
          • JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。所以如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者
        • Jackson :
        • google Gson
        • Ali Fast]son:阿里提供的 fastjson 包是我们项目中必不可少的一个依赖
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第54张图片
      • Hessian:Hessian 是一个轻量级的,自定义描述的二进制 RPC 协议,是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架,在性能和体积上表现比较好【Hessian 协议要比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小,有非常好的兼容性和稳定性。】。另外,Hessian可以很好地跟 Spring 进行集成,我们可以直接把 Java 类对外进行暴露,用作 RPC 接口的定义
        //代码示例
        Student student = new Student();
        student.setStudentId(101);
        student.setName("HESSIAN");
        
        //把student对象转化为byte数组
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        Hessian2Output output = new Hessian2Output(bos);
        output.writeObject(student);
        output.flushBuffer();
        byte[] data = bos.toByteArray();
        bos.close();
        
        //把刚才序列化出来的byte数组转化为student对象
        ByteArrayInputStream bis = new ByteArrayInputStream(data);
        Hessian2Input input = new Hessian2Input(bis);
        Student deStudent = (Student) input.readObject();
        input.close();
        
        System.out.println(deStudent);
        
        • Hessian 本身也有问题,官方版本对 Java 里面一些常见对象的类型不支持:
          • Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通过扩展 CollectionDeserializer 类修复
          • Locale 类,可以通过扩展 ContextSerializerFactory 类修复
          • Byte/Short 反序列化的时候变成 Integer
        • Hessian另外的缺点就是没有服务发现功能的,我们只能通过 VIP 暴露的方式完成调用,我们需要给每个应用分配一个 VIP,把同一接口的所有服务提供方实例挂载到同一个 VIP 上。这种集中式流量转发架构就会使得提供 VIP 服务的 LVS 存在很大的压力,而且集中式流量的转发会让调用方响应时间相对变长。
      • google protobuf:Google 公司的序列化标准,序列化后体积相比 JSON、Hessian 还要小,兼容性也做得不错(也就是Protobuf这种序列化协议与语言和平台无关)。【Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等多种语言但是Protobuf 使用的时候需要定义 IDL(Interface description language)文件,然后使用不同语言的 IDL 编译器,生成序列化工具类以及序列化代码
        • Protobuf 是一种接口定义语言,说明也是一种语言,既然是语言那Protobuf就有自己的关键字以及规则,所以 对于Protobuf 协议,我们需要创建一个后缀为 .proto 的文件,在文件里面我们需要定义出我们的协议内容
        • Protobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下使用者需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言。下面的两个例子:
          /**
           *
           * // IDl 文件格式
           * synax = "proto3";
           * option java_package = "com.test";
           * option java_outer_classname = "StudentProtobuf";
           *
           * message StudentMsg {
           * //序号
           * int32 studentId = 1;
           * //姓名
           * string name = 2;
           * }
           * 
           */
           
          StudentProtobuf.StudentMsg.Builder builder = StudentProtobuf.StudentMsg.newBuilder();
          builder.setStudentId(103);
          builder.setName("protobuf");
          
          //把student对象转化为byte数组
          StudentProtobuf.StudentMsg msg = builder.build();
          byte[] data = msg.toByteArray();
          
          //把刚才序列化出来的byte数组转化为student对象
          StudentProtobuf.StudentMsg deStudent = StudentProtobuf.StudentMsg.parseFrom(data);
          
          System.out.println(deStudent);
          
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第55张图片
        • 优点:序列化后体积相比 JSON、Hessian 小很多,性能高,体积小;IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;序列化反序列化速度很快,不需要通过反射获取类型【Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反 / 序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架】;消息格式升级和兼容性不错,可以做到向后兼容
          • 我们定义模型的结构一次,然后就可以使用生成的源代码轻松地使用 Java、Python、Go、Ruby 和 C++ 等各种语言在各种数据流中写入和读取结构化数据。
        • 缺点:不支持 null;ProtoStuff 不支持单纯的 Map、List 集合对象,需要包在对象里面。咱们使用者也得学习一下特定的关键词以及要下载按照 Protobuf 命令工具
      • ProtoStuff: Protobuf的哥哥,protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。
      • facebook Thrift
        • Thrift 也是一种序列化协议,具体的使用方式跟 Protobuf 类似,只不过 Thrift 是 Facebook 提出来的一种协议。Thrift 是一个 RPC 通讯框架,Thrift采用自定义的二进制通讯协议设计相比于传统的HTTP协议,Thrift效率更高,Thrift传输占用带宽更小。另外,Thrift是跨语言的。Thrift的接口描述文件,通过其编译器可以生成不同开发语言的通讯框架
        • Thrift 的使用方式跟 Protobuf 类似,也是有一个 .thrift 后缀的文件,然后通过命令生成各种语言的代码
      • Kryo
        • Kryo 是一个高性能的序列化/反序列化工具,由于 Kryo 其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用
        • Dubbo 官网的一篇文章中提到说 推荐使用 Kryo 作为生产环境的序列化方式Kryo不支持没有无参构造函数的对象进行反序列化,因此如果某个对象希望使用Kryo来进行序列化操作的话,需要有相应的无参构造函数才可以
        • Kryo 进行序列化和反序列化相关的代码如下:
          /**
           * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language
           *
           * @author shuang.kou
           * @createTime 2020年05月13日 19:29:00
           */
          @Slf4j
          public class KryoSerializer implements Serializer {
          
              /**
               * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects
               */
              private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
                  Kryo kryo = new Kryo();
                  kryo.register(RpcResponse.class);
                  kryo.register(RpcRequest.class);
                  return kryo;
              });
          
              @Override
              public byte[] serialize(Object obj) {
                  try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
                       Output output = new Output(byteArrayOutputStream)) {
                      Kryo kryo = kryoThreadLocal.get();
                      // Object->byte:将对象序列化为byte数组
                      kryo.writeObject(output, obj);
                      kryoThreadLocal.remove();
                      return output.toBytes();
                  } catch (Exception e) {
                      throw new SerializeException("Serialization failed");
                  }
              }
          
              @Override
              public <T> T deserialize(byte[] bytes, Class<T> clazz) {
                  try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
                       Input input = new Input(byteArrayInputStream)) {
                      Kryo kryo = kryoThreadLocal.get();
                      // byte->Object:从byte数组中反序列化出对对象
                      Object o = kryo.readObject(input, clazz);
                      kryoThreadLocal.remove();
                      return clazz.cast(o);
                  } catch (Exception e) {
                      throw new SerializeException("Deserialization failed");
                  }
              }
          
          }
          
      • fst
      • xmlrpc (xstream)
        • SSM框架在前些时间还是很流行的,也很实用,我在实验室的项目中还是使用 Spring,其中也都会有很多 XML 的配置文件,现在很多被注解代替了,但是 XML 还是支持使用的。另外有一些广电或者银卡等老系统里面会有很多基于 XML 的协议开发的系统和服务。
        • XML 协议的优缺点跟 JSON 类似,优点也是可读性很强,跨平台跨语言支持,缺点也是 体积大,容易内容多。可以看到为了记录一个字段的值,每个标签都需要成对存在,过于冗余了
    • RPC序列化框架,序列化可以分为文本类和二进制类两种。
      • 序列化:也叫做编码,将对象序列化为字节数组,再用于网络传输、数据持久化等
        java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第56张图片
      • 反序列化:也叫解码,把从网络、磁盘等读取的字节数组还原成为原始对象(通常是原始对象的副本)
        java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第57张图片
    • 序列化框架的选择
      java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第58张图片
      • 在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。我们更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次我们才会去考虑性能、效率和空间开销序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。以 JDK 原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵】
        • 综上以及综下各个选择标准来看,RPC【或者说如果是微服务之间的数据传输,因为RPC用来干啥的你忘了,那我们就可以选择 Protobuf 或者 Thrift 这种更高效的协议来进行传输,因为这种场景我们对于协议序列化的体积和速度都有很高的要求。】首选的还是 Hessian 与 Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中 Hessian 在使用上更加方便,在对象的兼容性上更好;Protobuf 则更加高效,通用性上更有优势
        • 比如说如果是 前后端对接,那么自然是 JSON 最合适,因为网页的交互要求不需要太高,秒级别是可以接受的,所以我们可以更加关注可读性
      • 考虑时间与空间开销切勿忽略通用性兼容性
        • (在 RPC 迭代中,常常会因为序列化协议的兼容性问题使 RPC 框架不稳定。比如某个类型为集合类的入参服务调用者不能解析了,服务提供方将入参类加一个属性之后服务调用方不能正常调用,升级了 RPC 版本后发起调用时报序列化异常了…)。
      • RPC框架中使用的序列化方式需要进一步进行选择:通过测试对比发现Protobuf的性能全面占优(因为Protobuf在时间开销、空间开销、兼容性等关键指标上表现良好。)。需要指出的是,分布式服务框架在面向不同领域应用时,需求也不同。例如大型网站内部的分布式服务框架的特点是组网规模大、高并发、海量的小消息通信、以内部服务调用为主,此类场景就比较适合使用高性能的Protobuf 作为序列化框架。对于企业内部IT系统,服务框架序列化可以选择Json/XML等文本类数据格式,它的可读性更好。
      • 按照序列化和反序列化的三个性能指标:
        • 序列化之后的码流大小:如果序列化后的传输数据体积较大,也会使网络吞吐量下降(空间开销)
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第59张图片
        • 序列化和反序列化的速度:在大量并发请求下,如果序列化的速度慢,势必会增加请求和响应的时间(时间开销)
          java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第60张图片
        • 资源占用,主要是CPU和堆内存
      • 在RPC 框架使用过程中需要注意哪些序列化上的问题【在 RPC 框架的使用过程中,我们要尽量构建简单的对象作为入参和返回值对象
        • 对象构造得过于复杂:属性很多,并且存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高
        • 对象过于庞大: RPC 请求经常超时,排查后发现入参对象非常得大,比如为一个大 List 或者大 Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
          • 比如,RPC 服务调用方反馈服务有时会有超时,查看监控平台发现有客户端调用超时,调用方的超时时间设置为 1s。那自己是不是先猜猜有可能是哪些原因引起的:码哥字节老师的文章很详细,很赞
            • JVM GC 时业务线程停顿,导致客户端超时。查看节点的内存使用率,发现在有大量超时异常时,服务节点的内存使用率并没有明显的变化。此时觉得应该不是 GC 导致的问题。
            • RPC服务 请求处理线程太少,大量请求在队列等待处理,导致客户端超时。
              • 查看 RPC 服务配置的线程
              • 查看服务排队总量,且大量超时发生时有没有排队的请求量。
            • 批量调用接口时,所有请求都没用命中缓存,导致客户端超时。服务处理请求时,如果没有命中缓存会从 DB,Wtable(内部组件,类Redis),HTTP 获取原始数据,然后逐个设置缓存,方便下次使用。获取了每个原始数据后,挨个设置缓存,过期时间统一为 1 小时。一小时后这些缓存同时过期,过期后的请求就会再次获取原始数据,导致请求响应时间变长。同一批设置缓存的过期时间有一个随机数误差,让这一批缓存数据不至于同时过期,部分缓存过期后的请求时间相比全部缓存过期就会变短。分散同时获取原始数据的数量,降低延迟。
            • 数据库连接池不够用,需要 DB 操作时等待连接,导致客户端超时。
              • 尽量不要为了方便,在循环里有任何 IO 操作,最好批量 IO
        • 使用序列化框架不支持的类作为入参类:比如 Hessian 框架,他天然是不支持 LinkedHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList
        • 对象有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题 1 一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题

PART3:RPC:To the Future,面向未来编程
java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起_第61张图片

知道了这些有关RPC的八股文后,再理一遍或者说总结一遍咱们写一个RPC到了Version06之后,接下来要干什么,然后和人家著名的RPC框架Dubbo源码比较一下,见Version08,

巨人的肩膀
Spring源码深度解析
网上很多好的RPC课程
JavaGuide
Github上面好多RPC源码
程序员田螺~手写一个RPC框架
RPC实战与核心原理(何小锋)
极客时间
SpringForAll老师公众号中的关于微服务的几种调用方式

你可能感兴趣的:(rpc,网络协议,java)