0.1. 异步RPC 0.1.1. 吞吐量 0.1.2. 异步化 0.1.2.1. 调用方 0.1.2.2. 提供方 0.2. 安全 0.2.1. 调用方之间的安全保证 0.2.2. 提供方之间的安全保证 0.3. 问题定位 0.3.1. 合理封装异常信息 0.3.2. 分布式链路追踪 0.4. 时钟轮 0.4.1. 定时任务 0.4.2. 时钟轮 0.5. 流量回放 0.6. 动态分组 0.7. 无接口调用RPC 0.8. 兼容多种RPC协议 0.8.1. 优雅处理多协议 0.1. 异步RPC 调用方利用异步化机制实现并行调用多个服务,以缩短整个调用时间; 提供方利用异步化把业务逻辑放到自定义线程池里面去执行,以提升单机的 OPS。 0.1.1. 吞吐量 影响RPC吞吐量的根本原因就是由于处理 RPC 请求比较耗时,并且 CPU 大部分的时间都在等待而没有去计算,从而导致 CPU 的利用率不够。 除非在网络比较慢或者调用方使用不当的情况下,否则,在大多数情况下,刨除业务逻辑处理的耗时时间,RPC 本身处理请求的效率就算在比较差的情况下也不过是毫秒级的。 RPC 请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢 SQL 的操作。 在大多数情况下,影响到 RPC 调用的吞吐量的原因也就是业务逻辑处理慢了,CPU大部分时间都在等待资源。要提升吞吐量,其实关键就两个字:“异步”。 0.1.2. 异步化 0.1.2.1. 调用方 一次 RPC 调用的本质就是: 调用方向提供方发送一条请求消息 提供方收到消息后进行处理 处理之后响应给调用方一条响应消息 调用方收到响应消息之后再进行处理 最后将最终的返回值返回给动态代理 对于调用方来说,向提供方发送请求消息与接收提供方发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行。 所以,对于 RPC 框架,无论是同步调用还是异步调用,调用方的内部实现都是异步的。 0.1.2.2. 提供方 RPC提供方接收到请求的二进制消息之后: 根据协议进行拆包解包 将完整的消息进行解码并反序列化 获得到入参参数之后再通过反射执行业务逻辑 对二进制消息数据包拆解包的处理是在处理网络 IO 的线程中处理,而解码与反序列化的过程也往往在 IO 线程中处理,业务逻辑交给专门的业务线程池处理,以防止由于业务逻辑处理得过慢而影响到网络 IO 的处理。 业务线程池的线程数一般只会配置到 200,因为在大多数情况下线程数配置到 200 还不够用就说明业务逻辑该优化了。 提供方支持业务逻辑异步是个比较难处理的问题,因为提供方执行完业务逻辑之后,要对返回值进行序列化并且编码,将消息响应给调用方,如果是异步处理,业务逻辑触发异步之后方法就执行完了,来不及将真正的结果进行序列化并编码之后响应给调用方。 因此,需要 RPC 框架提供一种回调方式,让业务逻辑可以异步处理,处理完之后调用 RPC 框架的回调接口,将最终的结果通过回调的方式响应给调用方。 0.2. 安全 RPC 一般用于解决内部局域网应用之间的通信,相对于公网环境,局域网的隔离性更好,相对更安全,所以在 RPC 里面很少考虑像数据包篡改、请求伪造等恶意行为。 0.2.1. 调用方之间的安全保证 服务提供方收到请求后,不知道这次请求是哪个调用方发起的,没法判断这次请求是属于之前打过招呼的调用方还是没有打过招呼的调用方,所以也就没法选择拒绝这次请求还是继续执行。 解决方案:给每个调用方设定一个唯一的身份,每个调用方在调用之前都先来服务提供方登记身份,只有登记过的调用方才能继续放行,没有登记过的调用方一律拒绝。 调用方进行调用接口登记的地方,称为“授权平台”,调用方在授权平台上申请自己应用里面要调用的接口,服务提供方在授权平台上进行审批,只有服务提供方审批后调用方才能调用。这就解决了调用数据收集的问题,还需要授权认证功能。 调用方每次发起业务请求先发一条认证请求到授权平台上,整个流程如下图所示: 授权平台承担所有 RPC 请求,就会成为瓶颈点,而且必须保证高可用,一旦授权平台出现问题,影响所有的 RPC 请求。 服务提供方存放 HMAC(Hash-based Message Authentication Code) 签名的私钥,在授权平台上用私钥为调用方进行签名,生成的串就成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,只需验证签名跟调用方应用信息是否对应,这样集中式授权的瓶颈也就不存在了。 在调用方启动初始化接口的时候,带上授权平台上颁发的身份去服务提供方认证,当认证通过后就可以调用这个接口。 0.2.2. 提供方之间的安全保证 服务提供方将服务发布后,调用方调用服务后同样发布一个服务,导致调用方通过服务发现拿到的服务提供方 IP 地址集合里面会有那个伪造的提供方。 解决方案:把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口。 服务提供方启动时,把接口实例在注册中心进行注册登记。注册中心在收到服务提供方注册请求时,验证请求的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。 0.3. 问题定位 0.3.1. 合理封装异常信息 在 RPC 框架打印的异常信息中,包括定位异常所需要的异常信息的,比如: 是哪类异常引起的问题(如序列化问题或网络超时问题), 是调用端还是服务端出现的异常,调用端与服务端的 IP 是什么, 服务接口与服务分组都是什么等。 具体如下图所示: 优秀的 RPC 框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档。 使用方可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因;并且异常信息中要包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端与服务端的 IP,以及产生异常的原因。总之就是,要让使用方在复杂的分布式应用系统中,根据异常信息快速地定位到问题。 以上是对于 RPC 框架本身的异常来说的,比如: 序列化异常、 响应超时异常、 连接异常等等。 服务提供方提供的服务的业务逻辑也要封装自己的业务异常信息,从而让服务调用方也可以通过异常信息快速地定位到问题。 0.3.2. 分布式链路追踪 用“分布式链路跟踪”知道分布式环境下服务调用的整个链路。 分布式链路跟踪就是将一次分布式请求还原为一个完整的调用链路,可以在整个调用链路中跟踪到这一次分布式请求的每一个环节的调用情况,比如调用是否成功,返回什么异常,调用的哪个服务节点以及请求耗时等。 分布式链路跟踪有 Trace 与 Span 的概念: 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。 RPC 在整合分布式链路跟踪需要做的最核心的两件事就是“埋点”和“传递”。 “埋点”就是分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过 RPC 框架对分布式链路跟踪进行埋点。 RPC 调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的 Span,而这个链路的源头会记录一个完整的 Trace,最终 Trace 信息会被上报给分布式链路跟踪系统。 “传递”就是上游调用端将 Trace 信息与父 Span 信息传递给下游服务的服务端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统中,每个子 Span 都存有父 Span 的相关信息以及 Trace 的相关信息。 0.4. 时钟轮 0.4.1. 定时任务 每发生一个请求就启动一个线程,之后sleep,到达超时时间就触发请求超时的处理逻辑。弊端是面临高并发的请求,单机每秒发送数万次请求,请求超时时间设置的是 5 秒,那要创建超过 10 万个线程。 用一个线程处理定时任务,这个线程每隔 100 毫秒会扫描一遍所有请求的超时任务,当发现一个超时了,就执行这个任务,对这个请求执行超时逻辑。它解决了第一种方式线程过多的问题,但也有明显的弊端,同样是高并发的请求,描任务的线程每隔 100 毫秒要扫描多少个定时任务呢?如果调用端刚好在 1 秒内发送了 1 万次请求,这 1 万次请求要在 5 秒后才会超时,那么那个扫描的线程在这个 5 秒内就会不停地对这 1 万个任务进行扫描遍历,要额外扫描 40 多次(每 100 毫秒扫描一次,5 秒内要扫描近 50 次),很浪费 CPU。 在使用定时任务时,它所带来的问题,就是让 CPU 做了很多额外的轮询遍历操作,浪费了 CPU,这种现象在定时任务非常多的情况下,尤其明显。 0.4.2. 时钟轮 我们要找到一种方式,减少额外的扫描操作。 时钟轮的实现原理就是参考了生活中的时钟跳动的原理。在时钟轮机制中,有时间槽和时钟轮的概念: 时间槽就相当于时钟的刻度, 时钟轮就相当于秒针与分针等跳动的一个周期,我们将每个任务放到对应的时间槽位上。 时钟轮的运行机制和生活中的时钟也是一样的: 每隔固定的单位时间从一个时间槽位跳到下一个时间槽位,相当于秒针跳动一次; 时钟轮分为多层,下一层时钟轮中每个槽位的单位时间是当前时间轮整个周期的时间,相当于 1 分钟等于 60 秒钟; 当时钟轮将一个周期的所有槽位都跳动完后,会从下一层时钟轮中取出一个槽位的任务,重新分布到当前的时钟轮中,当前时钟轮则从第 0 槽位从新开始跳动,相当于下一分钟的第 1 秒。 我们每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的 CPU。 这个机制很好地解决了定时任务中,因每个任务都创建一个线程,导致的创建过多线程的问题,以及一个线程扫描所有的定时任务,让 CPU 做了很多额外的轮询遍历操作而浪费 CPU 的问题。 在时间轮的使用中,有些问题需要你额外注意: 时间槽位的单位时间越短,时间轮触发任务的时间就越精确。例如时间槽位的单位时间是 10 毫秒,那么执行定时任务的时间误差就在 10 毫秒内,如果是 100 毫秒,那么误差就在 100 毫秒内。 时间轮的槽位越多,那么一个任务被重复扫描的概率就越小,因为只有在多层时钟轮中的任务才会被重复扫描。比如一个时间轮的槽位有 1000 个,一个槽位的单位时间是 10 毫秒,那么下一层时间轮的一个槽位的单位时间就是 10 秒,超过 10 秒的定时任务会被放到下一层时间轮中,也就是只有超过 10 秒的定时任务会被扫描遍历两次,但如果槽位是 10 个,那么超过 100 毫秒的任务,就会被扫描遍历两次。 0.5. 流量回放 所谓的流量就是某个时间段内的所有请求,通过某种手段把发送到 A 应用的所有请求录制下来,然后把这些请求统一转发到 B 应用,让 B 应用接收到的请求参数跟 A 应用保持一致,从而实现 A 接收到的请求在 B 应用里面重新请求了一遍。整个过程我们称之为“流量回放”。 RPC 是用来完成应用之间通信的,即应用之间的所有请求响应都会经过 RPC。所以在 RPC 里面可以很方便地拿到每次请求的出入参数,把这些出入参数旁录下来,并把这些旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量回放里面的录制功能。 有了真实的请求入参之后,剩下的就是怎么把这些请求参数转发到要回归测试的应用里面。 在 RPC 中,只要模拟一个应用调用方,把刚才收到的请求参数重新发送一遍到要回归测试的应用里面,然后比对录制拿到的请求结果和新请求的结果,就可以完成请求回放的效果。整个过程如下图所示: 相对其它现成的流量回放方案,在 RPC 中内置流量回放功能,使用起来会更加方便,还可以做更多定制,比如在线启停、方法级别录制等个性化需求。 0.6. 动态分组 某个分组的调用方流量突增,而这个分组所预留的空间也不能满足当前流量的需求,但是其它分组的服务提供方有足够的富余能力,但这些富余的能力,又被分组进行了强制的隔离。这样的话,就只能在出问题的时候临时去借用其它分组的部分能力,但通过改分组进行重启应用的方式,不仅操作过程慢,事后还得恢复。 因此这种生硬的方式显然并不是很合适。 让出问题的服务调用方能通过服务发现找到更多的服务提供方机器,而服务发现的数据来自注册中心,只要把注册中心里面的部分实例的别名改成想要的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。 通过直接修改注册中心数据,可以让任何一个分组瞬间拥有不同规模的集群能力。 不仅可以实现把某个实例的分组名改成另外一个分组名,还可以让某个实例分组名变成多个分组名,这就是动态分组里面最常见的两种动作——追加和替换。 我们没有必要把所有冗余的机器都分配到分组里面,可以把这些预留的机器做成一个共享池,从而减少整体预留的实例数量。 0.7. 无接口调用RPC 场景一:一个统一的测试平台,让各个业务方在测试平台中通过输入接口、分组名、方法名以及参数值,在线测试自己发布的 RPC 服务。统一的测试平台实际上是作为各个 RPC 服务的调用端,而在 RPC 框架的使用中,调用端是需要依赖服务提供方提供的接口 API 的,而统一测试平台不可能依赖所有服务提供方的接口 API。不能因为每有一个新的服务发布,就去修改平台的代码以及重新上线。这时就需要让调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起 RPC 调用。 场景二:一个轻量级的服务网关,让各个业务方用 HTTP 的方式,通过服务网关调用其它服务。服务网关要作为所有 RPC 服务的调用端,是不能依赖所有服务提供方的接口 API 的,也需要调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起 RPC 调用。 让调用方在没有服务提供方提供接口 API 的情况下仍然可以发起 RPC 调用的功能,在 RPC 框架中是非常有价值的。 使用泛化调用可以实现这个功能。 所谓的 RPC 调用,本质上就是调用端向服务端发送一条请求消息,服务端接收并处理,之后向调用端发送一条响应消息,调用端处理完响应消息之后,一次 RPC 调用就完成了。 只要调用端将服务端需要知道的信息,如接口名、业务分组名、方法名以及参数信息等封装成请求消息发送给服务端,服务端就能够解析并处理这条请求消息。过程如下图所示: 可以定义一个统一的接口(GenericService),调用端在创建 GenericService 代理时指定真正需要调用的接口的接口名以及分组名,而 GenericService 接口的 $invoke 方法的入参就是方法名以及参数信息。 这个通过统一的 GenericService 接口类生成的动态代理,来实现在没有接口的情况下进行 RPC 调用的功能,称之为泛化调用。 我们可以用泛化调用的方式实现 RPC 调用,但是如果没有服务提供方提供接口 API,就没法得到入参以及返回值的 Class 类,也就不能对入参对象进行正常的序列化。这时面临两个问题: 调用端不能对入参对象进行正常的序列化,那调用端、服务端在接收到请求消息后,入参对象又该如何序列化与反序列化呢?为泛化调用提供专属的序列化插件,通过这个插件,解决泛化调用中的序列化与反序列化问题。 调用端的入参对象(params)与返回值应该是什么类型呢?使用 Map 类型的对象,再通过泛化调用专属的序列化方式对这个 Map 对象进行序列化,服务端收到消息后,再通过泛化调用专属的序列化方式将其反序列成对象。 0.8. 兼容多种RPC协议 应用之间的通信都是通过 RPC 来完成的,而能够完成 RPC 通信的工具有很多,比如像 Web Service、Hessian、gRPC 等都可以用来充当 RPC 使用。这些不同的 RPC 框架都是随着互联网技术的发展而慢慢涌现出来的,它们在不同时期会被引入到不同的项目中解决当时应用之间的通信问题,这就导致线上的生成环境中存在各种各样的 RPC 框架。 让应用上线一次就可以完成新老 RPC 的切换,关键就在于要让新的 RPC 能同时支持多种 RPC 调用: 当调用方切换到新的 RPC 之后,调用方和服务提供方之间就可以用新的协议完成调用; 当调用方还是用老的 RPC 调用,调用方和服务提供方之间就继续用老的协议完成调用。 对于服务提供方来说,所要处理的请求关系如下图所示: 0.8.1. 优雅处理多协议 要让新的 RPC 同时支持多种 RPC 调用,关键就在于要让新的 RPC 能够原地支持多种协议的请求。 协议的作用是分割二进制数据流。每种协议约定的数据包格式是不一样的,而且每种协议开头都有一个协议编码,一般叫做 magic number。 当 RPC 收到了数据包后,先解析出 magic number,就很容易地找到对应协议的数据格式,然后用对应协议的数据格式去解析收到的二进制数据包。 协议解析过程是把一连串的二进制数据变成一个 RPC 内部对象,但这个对象一般是跟协议相关的,所以为了能让 RPC 内部处理起来更加方便,一般都会把这个协议相关的对象转成一个跟协议无关的 RPC 对象。 因为在 RPC 流程中,当服务提供方收到反序列化后的请求时,根据当前请求的参数找到对应接口的实现类去完成真正的方法调用。如果这个请求参数是跟协议相关的话,那后续 RPC 的整个处理逻辑就会变得很复杂。 当完成了真正的方法调用以后,RPC 返回的也是一个跟协议无关的通用对象,所以在真正往调用方写回数据的时候,同样需要完成一个对象转换的逻辑,只不过这时候是把通用对象转成协议相关的对象。 在收发数据包的时候,通过两次转换实现 RPC 内部的处理逻辑跟协议无关,同时保证调用方收到的数据格式跟调用请求过来的数据格式是一样的。整个流程如下图所示:
0.1. RPC架构 0.1.1. 传输模块 0.1.2. 协议模块 0.1.3. 集群模块 0.2. 可扩展的架构 0.3. 服务发现 0.4. 健康检测 0.5. 路由策略 0.5.1. IP路由 0.5.2. 参数路由 0.6. 负载均衡 0.6.1. 自适应负载均衡 0.6.1.1. 判断请求处理能力 0.7. 异常重试 0.7.1. 在约定时间内安全可靠重试 0.7.2. 业务逻辑异常重试 0.8. 优雅关闭 0.9. 优雅启动 0.9.1. 启动预热(调用方角度) 0.9.2. 延迟暴露(提供方角度) 0.10. 熔断限流 0.10.1. 限流(提供方自我保护) 0.10.2. 熔断(调用方自我保护) 0.11. 业务分组 0.11.1. 实现分组的高可用 其实 RPC 就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的。 0.1. RPC架构 RPC框架一般包括的功能:传输模块、协议模块、集群模块、Bootstrap模块 0.1.1. 传输模块 传输模块:屏蔽网络传输的复杂性,同时收发二进制数据,考虑到可靠性的话,一般默认采用 TCP 协议 0.1.2. 协议模块 协议封装:将基于方法调用的参数和返回值对象数据按照协议转换为二进制数据 数据压缩:在协议模块中加入压缩功能,压缩过程也是对传输的二进制数据进行操作 以上两个不同的过程,其目的都是为了保证数据在网络中可以正确传输,把这两个处理过程放在架构中的同一个模块,统称为协议模块。 因为在实际的网络传输中,请求数据包可能会因为太大而在数据链路层被拆分为多个数据包进行传输,减少拆分次数可以降低传输时长。 在RPC调用中,当方法调用的参数或者返回值的二进制数据大于某个阈值,就通过压缩框架进行无损压缩,在另一端进行解压缩,保证数据可还原。 0.1.3. 集群模块 有了上述两个模块,屏蔽RPC细节,实现远程调用透明化,就实现了一个单机版(点对点)的RPC框架,还需要增加集群能力,即针对同一个接口有多个服务提供者,它们对于调用方都是透明的。 服务发现:给调用方找到所有的服务提供方,并维护好接口跟服务提供者地址的关系,这样调用方在发起请求时才能快速地找到对应的接收地址 但服务发现只是解决了接口和服务提供方地址映射关系的查找问题,这更多是一种“静态数据”。 连接管理:每次发送请求都是需要用 TCP 连接,相对服务提供方 IP 地址,TCP 连接状态是瞬息万变的,需要维护 TCP 连接的状态。 有了集群之后,提供方就需要管理好这些服务,因此 RPC 框架就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。 而服务调用方只需要在每次调用前,根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。 至此,一个比较完善的 RPC 框架基本就完成了,功能也差不多了。 按照分层设计的原则,将这些功能模块分为了四层,具体内容见图示: 0.2. 可扩展的架构 在 RPC 框架里面,支持插件化架构,可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。 加上了插件功能之后,RPC 框架就包含了两大核心体系——核心功能体系与插件体系,如下图所示: 这时,整个架构就变成了一个微内核架构,相比之前的架构,有很多优势。 可扩展性好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身; 保持核心包的精简,依赖外部包少,有效减少开发人员引入 RPC 导致的包版本冲突问题。 软件开发的过程复杂,不仅因为业务需求经常变化,更难的是在开发过程中要保证团队成员的目标统一。需要用一种可沟通的话语(领域驱动设计中的概念就是通用语言)、可“触摸”的愿景达成目标,这就是软件架构设计的意义。 仅从功能角度设计出的软件架构并不够健壮,系统不仅要能正确地运行,还要以最低的成本进行可持续的维护,因此十分有必要关注系统的可扩展性。只有这样,才能满足业务变化的需求,让系统的生命力不断延伸。 0.3. 服务发现 为了高可用,在生产环境中服务提供方以集群的方式对外提供服务,集群里面的 IP 随时可能变化,需要用一本“通信录”及时获取到对应的服务节点,这个过程叫作“服务发现”。 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。 服务发现的本质,完成接口跟服务提供者 IP 的映射。 用DNS实现服务发现,所有的服务提供者节点都配置在同一个域名下,调用方可以通过DNS拿到随机的一个服务提供者IP,并建立长连接。但是对于服务提供者的摘除和扩容不够及时,为了提升性能和减少DNS服务器的压力,DNS采用多级缓存机制,且缓存时间较长,所以服务调用者不能及时感知到服务节点的变化。 用负载均衡设备实现服务发现,将域名绑定到负载均衡设备上,通过 DNS 拿到负载均衡的 IP。服务调用方直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发。但是,负载均衡设备或四层代理需要额外成本,请求流量多经过一次网络传输有性能损失,负载均衡设备增删需要手动操作有较大延迟,所以这个方式不够灵活。 基于Zookeeper、etcd、consul实现服务发现,能够完成实时变更推送。 在出现集中大规模服务上线的情况时,Zookeeper集群的CPU飙升,导致集群不能正常工作。因为,当连接到Zookeeper的节点数很多时,读写特别频繁,当存储目录达到一定数量时,集群就不稳定,CPU飙升导致宕机。 基于消息总线的最终一致性实现服务发现。 Zookeeper的问题主要是强一致性(CP)导致的,换成最终一致性(AP),来获得服务发现的稳定性和性能。 服务上线的注册数据全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示: 服务发现的特性是允许在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。 0.4. 健康检测 集群中,每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址。为了保证请求成功,就需要确保每次选择出来的 IP 对应的连接是健康的。 怎么保证选择出来的连接一定是可用,终极的解决方案是让调用方实时感知到节点的状态变化。 服务健康状况不仅包括TCP连接状况,还包括服务自身是否存活,很多时候TCP连接没有断开,但服务可能已经僵死。 服务方的状态一般三种情况: 健康状态:建立连接成功,并且心跳探活也一直成功 亚健康状态:建立连接成功,但是心跳请求连续失败 死亡状态:建立连接失败 节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化。 一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值,比如 3 次。 注意点: 第一,调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判。 第二,在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接。 因此,除了心跳检测还可以加上业务请求的维度。比如,增加可用率,即一个时间窗口内接口调用成功次数的百分比(成功次数/总调用次数)。 当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。 在设计健康检测方案的时候,不能简单地从 TCP 连接是否健康、心跳是否正常等简单维度考虑,因为健康检测的目的就是要保证“业务无损”。正常情况下,我们大概 30S 会发一次心跳请求。 让每个应用实例提供一个“健康检测”的 URL,检测程序定时通过构造 HTTP 请求访问该 URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判。 0.5. 路由策略 服务提供方以集群的方式对外提供服务,可以认为集群中的节点都是同质的,即请求无论发送到集合中的哪个节点上,返回的结果都是一样的。 在上线新功能时,一般会采用灰度发布,但这种方式不好的一点就是,一旦出现问题,影响范围较大。对于服务提供方来说,同时提供给很多调用方来调用,尤其是一些基础服务的调用方会更复杂,一旦刚上线的实例有问题,将会导致所有的调用方业务都会受损。 0.5.1. IP路由 在 RPC 发起真实请求时,从服务提供方节点集合里面选择一个合适的节点(负载均衡),在选择节点前加上“筛选逻辑”,把符合要求的节点筛选出来。 所以RPC调用的流程就变成了这样: 0.5.2. 参数路由 有了 IP 路由之后,上线过程中可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布,这样可以把试错成本降到最低。但在有些场景下,如百分比流量切换,需要更细粒度的路由方式。 在流量切换的过程中,为了保证整个流程的完整性,必须保证某个主题对象的所有请求都使用同一种应用来承接。 假设改造的是商品应用,那主题对象肯定是商品 ID,在切流量的过程中,必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。 IP路由并不能满足这个需求,它只能限制调用方来源,并不会更具请求参数请求到预设的服务提供方。 可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,根据请求参数,来判断当前请求是过滤掉新应用还是老应用的节点。 规则对所有的调用方都是一样的,从而保证对应同主题的请求要么是新应用的节点,要么是老应用的节点。 相比 IP 路由,参数路由支持的灰度粒度更小,为服务提供方应用提供了另外一个服务治理的手段。 灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。 0.6. 负载均衡 负载均衡主要分为软负载和硬负载, 软负载就是在一台或多台服务器上安装负载均衡的软件,如 LVS、Nginx 等, 硬负载就是通过硬件设备来实现的负载均衡,如 F5 服务器等。 负载均衡的算法主要有随机法、轮询法、最小连接法等。 以上负载均衡主要还是应用在 Web 服务上,Web 服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上。 在RPC中不是依赖一个负载均衡设备或者负载均衡服务器,具体原因看上面服务发现一节,而是由 RPC 框架本身实现。 而且在服务治理时,针对不同接口服务、服务的不同分组,负载均衡策略是需要可配的,经过一个负载均衡设备,就不容易根据不同的场景来配置不同的负载均衡策略。 RPC 的负载均衡完全由 RPC 框架自身实现,RPC 的服务调用者会与“注册中心”下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。 RPC 负载均衡策略一般包括随机权重、Hash、轮询。随机权重是最常用的一种,基本可以保证每个节点接收到的请求流量是均匀的;同时可以通过控制节点权重的方式,来进行流量控制。 0.6.1. 自适应负载均衡 RPC 的负载均衡完全由 RPC 框架自身实现,服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。只要调用者知道每个服务节点处理请求的能力,以此来判断要打给它多少流量。 0.6.1.1. 判断请求处理能力 采用打分策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如: 服务节点的负载指标、 CPU 核数、 内存大小、 请求处理的耗时指标(如请求平均耗时、TP99、TP999)、 服务节点的状态指标(如正常、亚健康)。 为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。 服务调用者给每个服务节点都打完分之后,配合随机权重的负载均衡策略去控制发送的流量,通过最终的指标分数修改服务节点最终的权重。 0.7. 异常重试 在某些场景下,希望这些请求能够尽可能地执行成功,当调用端发起的请求失败时,RPC 框架自身进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。 当消息发送失败或收到异常消息时,可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数时,就返回给调用端动态代理一个失败异常,否则就一直重试下去。 RPC 框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,但并不是所有的异常都要触发重试,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等。 在使用 RPC 框架的时候,要确保被调用的服务的业务逻辑是幂等的,这样才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区。 0.7.1. 在约定时间内安全可靠重试 连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间,因此,如果发送请求发生异常并触发了异常重试,先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。 同时,在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。 0.7.2. 业务逻辑异常重试 RPC 框架的异常重试机制,是调用端发送请求之后,如果发送失败会捕获异常,触发重试,而像服务端业务逻辑中抛回给调用端的异常是不能重试的。 RPC 框架不知道哪些业务异常能够去进行重试,可以加个重试异常的白名单,用户将允许重试的异常加入到白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,就可以采用这样的异常处理策略。 如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,就允许对这个请求进行重试。 业务逻辑幂等时,异常重试过程: 判断是否超时,是则直接返回超时,否则重试 判断是否为业务异常,是否在白名单中,是则重试,否则直接返回异常内容 重置超时时间,进行重试 上述重试过程中要移除失败的节点,同时重试的时间间隔要不断调整,如以2的幂次方增加重试的等待时间。 0.8. 优雅关闭 在服务重启的时候,对于调用方来说,会存在以下几种情况: 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。 调用方发请求时,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。 为了避免第二种情况对调用方业务产生影响,先通过如下几种方式把要下线的机器从调用方维护的“健康列表”里面删除。 人工通知调用方,手动移除节点 通知注册中心进行下线,注册中心告知调用方进行节点移除 服务提供方通知服务调用方进行节点移除 第二种方式,整个关闭过程中依赖了两次 RPC,一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,在大规模集群里面,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以,通过服务发现并不能做到应用无损关闭。 第三种方式,在 RPC 里面调用方跟服务提供方之间是长连接,可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个通知调用方下线这个服务。整个调用链路就一次 RPC,可以确保调用的成功率很高。但是通知时间与关闭服务时间之间的间隔过短,加上网络延迟,调用方还没有处理完,服务已经开始关闭流程了,后续的请求就没有正确被处理。 服务提供方开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。可以在关闭时,设置一个请求“挡板(一个特定的异常)”告诉调用方已经进入关闭流程,不能再处理这个请求,调用方收到这个异常后,把节点移除,并进行重试,因为这个请求没有被提供方处理过,所以不需要考虑幂等性。 被动响应特定异常的方式,可能会使得关闭过程缓慢,可以增加主动通知机制,这样即保证实时性,又避免通知失败。 在提供方通过捕获操作系统的信号来获取关闭通知。在 RPC 启动时,提前注册关闭钩子(ShutdownHook),并在里面添加两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时在调用链里加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。 如果进程结束过快造成正在被处理的请求得不到应答,因此在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器可以快速判断是否有正在处理的请求。 服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。 考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,可以在整个 关闭钩子里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。超时时间建议设定成 10s,基本可以确保请求都处理完了。 整个流程如下图所示。 挡板 计数器 计时器 0.9. 优雅启动 运行了一段时间后的应用,执行速度会比刚启动的应用更快。如果让刚启动的应用就承担像停机前一样的流量,会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。让应用一开始只接少许流量,低功率运行一段时间后,再逐渐提升至最佳状态。 在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度。 0.9.1. 启动预热(调用方角度) 让刚启动的服务提供方应用不承担全部的流量,让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。 通过控制调用方发送到服务提供方的流量来实现,让负载均衡在选择连接的时候,根据服务启动时间进行区分,刚启动的服务被选择的概率特别低,同时,这个概率会随着时间的推移慢慢变大,实现一个动态增加流量的过程。 获取服务提供方的启动时间,有以下两个选择: 启动时间:服务提供方在启动时将启动时间告诉注册中心 注册时间:注册中心记录收到服务提供方请求注册的时间 集群启动NTP时间同步服务,保证相互之间的时间差不大。 服务调用方通过服务发现获取IP列表和对应的启动时间,把这个时间作用在负载均衡上。 这样可以保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。 大批量服务重启,可能导致未重启服务受到大流量冲击,因此自适应负载均衡很重要,分批重启更重要,如Kubernetes中服务的重启就是分批次进行的。 0.9.2. 延迟暴露(提供方角度) 服务启动的时候都是通过 main 入口,然后顺序加载各种相关依赖的类。类加载的过程中或触发服务注册的操作,此时调用方获取到服务地址后发起RPC连接,但是类加载可能还没有完成(正在加载其他类),那么调用会失败导致业务受损。 解决方法是把服务注册放在服务启动完成后,从而实现服务调用方延迟获取服务提供方地址。利用服务提供方把接口注册到注册中心的那段时间。 在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。 整个应用启动过程如下图所示: 虽然启停机流程看起来不属于 RPC 主流程,但是如果在 RPC 里面把这些“微小”的工作做好,能感受到更多的微服务带来的好处。 0.10. 熔断限流 RPC 是解决分布式系统通信问题的利器,而分布式系统的特点就是高并发,所以说 RPC 也会面临高并发的场景。 在这样的情况下,提供服务的每个服务节点都可能由于访问量过大而引起一系列的问题,比如: 业务处理耗时过长 CPU 飘高 频繁 Full GC 服务进程直接宕机 但是在生产环境中,要保证服务的稳定性和高可用性,这时就需要业务进行自我保护,从而保证在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。 0.10.1. 限流(提供方自我保护) 在 RPC 调用中服务端的自我保护策略就是限流,限流是一个通用的功能,可以在 RPC 框架中集成,让使用方自己去配置限流阈值。 在提供方添加限流逻辑,当调用方发送请求时,提供方在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,提供方直接抛回给调用方一个限流异常,否则就执行正常的业务逻辑。 限流的实现方式有很多,比如: 最简单的计数器 平滑限流的滑动窗口 漏斗算法 令牌桶算法(最常用) 在做限流时要考虑应用级维度,甚至是 IP级维度,这样做不仅可以对一个应用下的调用方发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流。通常不在代码中配置,而是通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。 在服务提供方实现限流,配置的限流阈值是作用在每个服务节点上的。 当注册中心或配置中心将限流阈值配置下发时,将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值。 这种限流方式存在不精确的问题,因为限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。 可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。 甚至可以将限流逻辑放在调用端,在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求不发送,直接返回给动态代理一个限流异常。 这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。 0.10.2. 熔断(调用方自我保护) 在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机。 所以,在一个服务作为调用方调用另外一个服务时,为了防止提供方出现问题而影响到调用方,调用方自己也需要进行自我保护。而最有效的自我保护方式就是熔断。 熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。 在正常情况下,熔断器是关闭的; 当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑; 当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用方发送一个请求给提供方,如果这次请求能够正常地得到响应,则将状态置为关闭状态,否则设置为打开。 熔断机制主要是保护调用方,调用方在发出请求的时候会先经过熔断器。在下面的RPC调用流程中,动态代理是第一个关口在这里增加熔断器比较好。 0.11. 业务分组 面对复杂的业务以及高并发场景时,还有别的手段,可以最大限度地保障业务无损,那就是隔离流量。 为了实现分组隔离逻辑,需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要加一个分组参数,相应的服务提供方在注册时也要带上分组参数。 通过改造后的分组逻辑,可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。 这个分组并没有可衡量的标准,可以按照应用重要级别(核心应用与非核心应用)划分。 通过分组方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是日常治理服务过程中一个高频使用的手段。 0.11.1. 实现分组的高可用 调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,因此,允许调用方可以配置多个分组,同时,把配置的分组区分为主次分组: 只有在主分组上的节点都不可用的情况下才去选择次分组节点; 只要主分组里面的节点恢复正常,就必须把流量都切换到主分组的节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。
0.1. 协议 0.1.1. 定长协议头 0.1.2. 可扩展协议 0.2. 序列化 0.2.1. 常用序列化方式 0.2.2. 序列化方式选择 0.2.3. 序列化注意事项 0.3. 网络通信 0.3.1. 常见网络IO模型 0.3.1.1. 同步阻塞 IO(BIO) 0.3.1.2. IO 多路复用(IO multiplexing) 0.3.2. 网络IO模型选择 0.3.3. 零拷贝 0.4. 动态代理 0.1. 协议 RPC 协议是围绕应用层协议展开的。 只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。 但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据(包合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。 在 RPC 传输数据的时候,在应用发送请求的数据包里面加入消息的边界,用于标示请求数据的结束位置,这样接收方应用才能从数据流里面分割出正确的数据。 这个边界语义的表达,就是协议。 RPC 更多的是负责应用间的通信,所以性能要求相对更高。 HTTP 协议的数据包大小相对请求数据本身要大,又需要加入很多无用的内容,它是无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。 因此,对于要求高性能的 RPC 来说,HTTP 协议很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。 0.1.1. 定长协议头 RPC 每次请求的大小都是不固定的,所以协议必须能让接收方正确地读出不定长的内容。 先固定一个长度(比如 4 个字节)用来保存整个请求数据大小, 收到数据后,先读取固定长度的位置里面的值,值的大小就代表协议体的长度, 再根据值的大小来读取协议体的数据, 整个协议可以设计成这样: 上面这种协议,只实现了正确的断句效果,对于服务提供方来说,不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。 如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。 因此把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。 在协议头里面,除了会放协议长度、序列化方式,还有如协议标示、消息 ID、消息类型等参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。 这就形成一个完整的 RPC 协议,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示: 0.1.2. 可扩展协议 上面协议属于定长协议头,之后不能再往协议头里加新参数,如果加参数就会导致线上兼容问题。 如果将新参数放在协议体中,有一个关键问题,协议体的内容是经过序列化的,要获取新加的参数值,必须把整个协议体反序列化。但在某些场景下,这样做的代价有点高啊! 比如,要在原协议中增加请求超时这个参数,如果放在请求体中,需要消耗CPU解析出这个参数后再丢弃请求,想对于直接在请求头中判断请求已经过期直接丢弃代价高太多。 为了保证能平滑地升级改造前后的协议,有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头长度不固定。 要实现读取不定长的协议头中的内容,需要有一个固定的地方读取协议头长度,因此,需要固定的写入协议头的长度。 整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分可以统称为“协议头”,具体协议如下: 设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可“升级”的协议。不仅要在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。 0.2. 序列化 在不同的场景下合理地选择序列化方式,对提升 RPC 框架整体的稳定性和性能是至关重要的。 网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。 对象是不能直接在网络中传输的,所以需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程叫做“序列化”。 服务提供方可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程称为“反序列化”。 0.2.1. 常用序列化方式 JDK 原生序列化:序列化具体的实现是由 ObjectOutputStream 完成的,而反序列化的具体实现是由 ObjectInputStream 完成的。序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。 JSON:典型的 Key-Value 方式,没有数据类型,是一种文本型序列化框架。JSON 进行序列化的额外空间开销比较大,JSON 没有类型,需要通过反射统一解决,所以性能不会太好。 Hessian:是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。 Protobuf:一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类。序列化后体积相比 JSON、Hessian 小很多;IDL 能清晰地描述语义,序列化反序列化速度很快,可以做到向后兼容。 Protobuf 非常高效,但是对于具有反射和动态能力的语言来说,这样用起来很费劲,这一点不如 Hessian,比如用 Java 的话,这个预编译过程不是必须的,可以考虑使用 Protostuff。Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反/序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架,但也有不支持的情况,1-不支持 null;2-ProtoStuff 不支持单纯的 Map、List 集合对象,需要包在对象里面。 0.2.2. 序列化方式选择 序列化与反序列化过程是 RPC 调用的一个必须过程,它的性能和效率将直接关系到 RPC 框架整体的性能和效率。 空间开销,序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,RPC 是远程调用,网络传输的速度将直接关系到请求响应的耗时。 序列化协议的通用性和兼容性,优先级更高一些,这直接关系到服务调用的稳定性和可用率。 序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵。 安全性 > 通用性 > 兼容性 > 性能 > 效率 > 空间开销 0.2.3. 序列化注意事项 对象构造得过于复杂:属性很多,并且存在多层的嵌套,序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。 对象过于庞大,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。 使用序列化框架不支持的类作为入参类,大多数情况下最好不要使用第三方集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。 对象有复杂的继承关系,和第一个问题一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。 在 RPC 框架的使用过程中,要尽量构建简单的对象作为入参和返回值对象,避免上述问题。 在使用 RPC 框架的过程中,我们构造入参、返回值对象,主要记住以下几点: 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚; 入参对象与返回值对象体积不要太大,更不要传太大的集合; 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类; 对象不要有复杂的继承关系,最好不要有父子类的情况。 0.3. 网络通信 一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。 服务调用者通过网络 IO 发送一条请求消息, 服务提供者接收并解析, 处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者, 服务调用者接收并解析响应消息, 处理完相关的响应逻辑,一次 RPC 调用便结束了。 网络通信是整个 RPC 调用流程的基础。 0.3.1. 常见网络IO模型 常见的网络 IO 模型分为四种: 同步阻塞 IO(BIO) 同步非阻塞 IO(NIO) IO 多路复用 异步非阻塞 IO(AIO) 在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。其中,最常用的就是同步阻塞 IO 和 IO 多路复用。 0.3.1.1. 同步阻塞 IO(BIO) 同步阻塞 IO 是最简单、最常见的 IO 模型,在 Linux 中,默认情况下所有的 socket 都是 blocking 的。 首先,应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。 之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后返回进程。 最后,应用的进程解除阻塞状态,运行业务逻辑。 这里可以看到,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态。 0.3.1.2. IO 多路复用(IO multiplexing) 多路复用 IO (又称事件驱动) 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO 模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。 多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个复用器上。 多个网络连接的 IO 可以注册到一个复用器(select)上,当用户进程调用了 select,那么整个进程会被阻塞。 同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。 这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。 当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。 但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。 而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。 实际在网络 IO 的应用上,需要的是系统内核的支持以及编程语言的支持。 在系统内核的支持上,现在大多数系统内核都会支持阻塞 IO、非阻塞 IO 和 IO 多路复用,但像信号驱动 IO、异步 IO,只有高版本的 Linux 系统内核才会支持。 在编程语言上,无论 C++ 还是 Java,在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO 多路复用的。当然,在非高并发场景下,同步阻塞 IO 是最为常见的。 0.3.2. 网络IO模型选择 IO 多路复用更适合高并发的场景,用较少的进程(线程)处理较多的 socket 的 IO 请求,但使用难度比较高。当然高级的编程语言支持得还是比较好的,比如 Java 语言有很多的开源框架对 Java 原生 API 做了封装,如 Netty 框架,使用非常简便;而 GO 语言,语言本身对 IO 多路复用的封装就已经很简洁了。 阻塞 IO 与 IO 多路复用相比,阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低。 RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,选择 IO 多路复用的方式。 开发语言的网络通信框架的选型上,最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)。 0.3.3. 零拷贝 阻塞 IO 中,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。 等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中; 拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。 应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。 零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。 用户空间与内核空间都将数据写到一个地方即虚拟内存,这样就不需要拷贝数据了。这是操作系统层面上的零拷贝,主要目标是避免用户空间与内核空间之间的数据拷贝操作,可以提升 CPU 的利用率。 零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,mmap+write 方式的核心原理就是通过虚拟内存来解决的。 0.4. 动态代理 RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入远程调用逻辑。 通过这种“偷梁换柱”的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验,整体流程如下图所示: 对于服务调用方来说,在使用 RPC 的时候本来就是面向接口来编程的。 动态代理是一种具体的技术框架,选型从这三个角度去考虑: 代理类是在运行中生成的,代理框架生成代理类的速度、生成代理类的字节码大小等,都会影响到其性能——生成的字节码越小,运行所占资源就越小。 生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。 从使用角度出发的,选择一个使用起来很方便的代理类框架,如API 设计是否好理解、社区活跃度、依赖复杂度等。 gRPC详解点这里。 gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。
0.1. RPC定义 0.1.1. RPC通信流程 0.1.2. RPC在架构中的位置 0.1. RPC定义 RPC 的全称是 Remote Procedure Call,即远程过程调用。它是帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样的体验,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。 RPC 的作用: 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑 RPC 最大的特点就是可以让我们像调用本地一样发起远程调用,这一特点让人感觉 RPC 就是为“微服务”或 SOA 而生的。 RPC 不是只应用在“微服务”中,只要涉及到网络通信,我们就可能用到 RPC,它是解决分布式系统通信问题的一大利器,它对网络通信的整个过程做了完整封装,在搭建分布式系统时,使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠。 分布式系统中的网络通信一般都会采用四层的 TCP 协议或七层的 HTTP 协议,其中,前者占大多数,这主要得益于 TCP 协议的稳定性和高效性。网络通信是一个复杂的过程,主要包括:1-对端节点的查找、2-网络连接的建立、3-传输数据的编码解码、4-网络连接的管理等。 使用 RPC 可以像调用本地一样发起远程调用,用它解决通信问题: 序列化 编解码 网络传输 这些是 RPC 的基础,它真正强大的地方是治理功能: 连接管理 健康检测 负载均衡 优雅启停 异常重试 业务分组 熔断限流 0.1.1. RPC通信流程 0.1.2. RPC在架构中的位置 RPC 是解决应用间通信的一种方式,应用架构最终都会从“单体”演进成“微服务化”,RPC 框架帮助我们解决系统拆分后的通信问题,让我们像调用本地一样去调用远程方法,可以说 RPC 对应的是整个分布式应用系统,就像是“经络”一样的存在。 在这个应用中,使用了 MQ 来处理异步流程、Redis 缓存热点数据、MySQL 持久化数据,还有就是在系统中调用另外一个业务系统的接口,对应用来说这些都是属于 RPC 调用,而 MQ、MySQL 持久化的数据也会存在于一个分布式文件系统中,他们之间的调用也是需要用 RPC 来完成数据交互的。 RPC 是日常开发中经常接触的东西,只是被包装成了各种框架,导致我们很少意识到这就是 RPC。