RPC原理介绍

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/elricboa/article/details/78836416

面向服务架构SOA

任何大型网站的发展都伴随着网站架构的演进。网站架构一般最初是单应用设计,然后逐渐经历面向对象设计和模块化设计的架构,最终发展到面向服务的服务化架构。在单应用设计架构体系当中,我们关注的是方法和实体;而在面向服务的服务化架构中,我们则关注的是服务和API。网站架构演进图如下图所示:
传统应用开发中会面临研发成本高,运维效率低等挑战。
研发成本高主要体现在:
  • 代码重复率高:在实际项目分工时,开发都是各种负责几个功能,即使开发之间存在功能重叠,往往也是选择自己实现,而不是类库共享。
  • 需求变更困难:代码重复率高后,已有功能变更和新需求加入时都会非常困难。所有重复开发的功能都需要重新修改和测试,很容易出现修改不一致或者被遗漏。
  • 无法满足新业务的快速迭代和交互等问题。
运维效率低主要体现在:
  • 测试、部署成本高:业务运行在一个进程中,因此系统中任何程序的改变,都需要对整个系统重新测试并部署
  • 可伸缩性差:水平扩展只能基于整个系统进行扩展,无法针对某一功能模块按需扩展
  • 可靠性差:某个应用BUG,会导致整个进程宕机,影响其他应用

为了解决单体架构面临的挑战,会对系统进行拆分、解耦、独立、分层。将核心业务抽取出来,作为独立的服务,逐渐形成稳定的服务中心;同时将公共API抽取出来,作为独立的公共服务供其他调用者消费,以实现服务的共享和重用,降低开发和运维成本。

既然系统都是由成千上万大大小小的服务组成,各服务部署在不同的机器上,由不同的团队负责。这时就会遇到两个问题:
  1. 要搭建一个新服务,免不了需要依赖他人的服务,而现在他人的服务都在远端,怎么调用?
  2. 其他团队要使用我们的新服务,我们的服务该怎么发布以便他人调用?

服务调用

由于各服务部署在不同机器,服务间的调用免不了网络通信过程,服务消费方每调用一个服务都要写一坨网络通信相关的代码,不仅复杂而且极易出错。
如果有一种方式能让我们像调用本地服务一样调用远程服务,并且让调用者对网络通信这些细节透明,那么将大大提高生产力,比如服务消费方在调用本地 的一个接口时,实质上调用的是远端的服务。这种方式其实就是RPC(Remote Procedure Call Protocol),如今rpc在各大互联网公司中被广泛使用,如阿里巴巴的hsf、dubbo(开源)、Facebook的thrift(开源)、Google grpc(开源)、Twitter的finagle(开源)等。当然,也可以通过HTTP + JSON的方式来进行服务间的调用,本质上HTTP的方式也是属于RPC的一种实现,但是HTTP的方式并不会像调用本地服务一样那么直观,同时也有一定的性能问题。

服务调用方式

服务的调用方式,主要有三种:
  • 同步服务调用:最常见、简单的服务调用,即同步等待服务方返回。为了防止服务端长时间不返回应答消息导致客户端线程被挂死,用户线程等待的时候需要设置超时时间。
  • 异步服务调用:异步服务调用有两种实现方式:一种是只通过Future来实现,还有一种是通过构造Listener对象并将其添加到Future中,用于服务端应答的异步回调。通过Future方式时,线程会阻塞在get结果的操作上;而使用Listener的方式是监听器异步的获取执行结果。
  • 并行服务调用:提升服务调用的并行度,降低时延。比如在结算时,会分别调用短信通知服务,订单详细服务,经验增长服务等等。这些服务即可通过并行服务调用来降低端到端的时延,最后只需对执行结果进行汇聚即可。并行服务最常见的技术实现方案是使用Fork/Join框架实现子任务的并行执行和结果汇聚。

RPC通信细节

要让网络通信细节对使用者透明,我们需要对通信细节进行封装,我们先看下一个RPC调用的流程涉及到哪些通信细节:
  1. 服务消费方(client)调用以本地调用方式调用服务;
  2. client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;
  3. client stub找到服务地址,并将消息发送到服务端;
  4. server stub收到消息后进行解码;
  5. server stub根据解码结果调用本地的服务;
  6. 本地服务执行并将结果返回给server stub;
  7. server stub将返回结果打包成消息并发送至消费方;
  8. client stub接收到消息,并进行解码;
  9. 服务消费方得到最终结果。
RPC的目标就是要2~8这些步骤都封装起来,让用户对这些细节透明。有以下的一些技术细节:
  • 如何做到透明化远程过程调用:使用代理,代理分为两种:1. jdk 动态代理 2.字节码生成。jdk代理的方式是对接口做代理,所以必须先定义接口;字节码生成方式一般使用的是cglib代理,cglib代理使用的是asm字节码框架,可以直接对类生成代理对象;虽然字节码生成的方式更加方便和高效,但是由于代码维护不易,一般还是采取jdk动态代理的方式。
  • 消息的数据结构:通信的第一步就是要确认客户端和服务端相互通信的消息结构
     客户端的请求消息一般需要包括以下内容:
    • 接口名称:用于确定调用哪个接口
    • 方法名:确定调用接口中哪个方法
    • 参数类型&参数值:
    • 超时时间
    • requestID:请求唯一标识
     服务器返回消息一般需要包括:
    • 返回值
    • 状态code
    • requestID
  • 序列化:序列化类型包括基于文本和基于二进制方式。在分布式服务通信框架中,序列化方式应该包含以下特性:
    • 通用性:比如能否支持Map等复杂数据结构
    • 性能:包括时间复杂度和空间复杂度,通信框架被会公司全部服务使用,即使性能提升一点也会引起质变
    • 可扩展性:比如支持自动增加新的业务字段
    • 多语言支持:通过定义idl,生成方式为静态编译和动态编译
  • 通信:通信框架需要支持同步(BIO)和异步(NIO)方式,一般底层使用Netty
  • RequestID:如果请求时异步的,对于客户端来说请求发出后线程即可向下执行。服务端处理完成后再以消息的形式发送给客户端。于是这里会出现以下两个问题:
    • 如何让当前线程“暂停”,等待到结果后,再向后执行
    • 如果多个线程同时进行远程方法调用,这时建立在client server之间的socket连接上会有很多双方发送的消息传递,前后顺序也可能是随机的,server处理完结果后将结果发送给client,client收到很多的消息,怎么知道哪个消息是原先哪个线程调用的?
     这时即可通过唯一自增的一个RequestID来解决这两个问题:
    • 在调用callback的get方法时,在get内部获取callback的锁,如果没有获取就等待
    • 以RequestID为Key将callback对象存放在全局ConcurrentHashMap中,先通过RequestID获取callback对象,然后再获取callback的锁,获取之后再调用notify

服务发布

如何让别人使用我们的服务呢?有同学说很简单嘛,告诉使用者服务的IP以及端口就可以了啊。确实是这样,这里问题的关键在于是自动告知还是人肉告知。
人肉告知的方式:如果你发现你的服务一台机器不够,要再添加一台,这个时候就要告诉调用者我现在有两个ip了,你们要轮询调用来实现负载均衡;调用者咬咬牙改了,结果某天一台机器挂了,调用者发现服务有一半不可用,他又只能手动修改代码来删除挂掉那台机器的ip。现实生产环境当然不会使用人肉方式。
有没有一种方法能实现自动告知,即机器的增添、剔除对调用方透明,调用者不再需要写死服务提供方地址?当然可以,现如今zookeeper被广泛用于实现服务自动注册与发现功能!
简单来讲,zookeeper可以充当一个服务注册表(Service Registry),让多个服务提供者形成一个集群,让服务消费者通过服务注册表获取具体的服务访问地址(ip+端口)去访问具体的服务提供者。如下图所示:
消费者在调用服务提供者的时候,不需要每次都去服务注册中心查询服务提供者的地址列表,消费者客户端直接从本地缓存的服务提供者路由表中查询地址信息。当服务提供者发生变更时,注册中心主动将变更内容推送给消费者,由后者动态刷新本地缓存的服务路由表,保证服务路由信息的实时准确性。采用客户端缓存服务提供者地址的方案不仅仅能提升服务调用性能,还能保证系统的可靠性。当注册中心全部宕机后,服务提供者和服务消费者仍能通过本地缓存的地址信息进行通信,只是影响新服务的注册和老服务的下线。

下面列出了两个典型的场景:
  • 服务扩容场景:如下图所示,服务B拥有者无需维护上游调用者的列表,扩容后,无需逐一通知。服务A拥有者在下游服务提供者扩容后,无需更改配置或重新发布程序代码
  • 故障结点故障场景:服务A维护一个最新获取的服务B的可用列表。当调用服务 B3不可用时,服务A会实时屏蔽,并使用下一个节点地址进行重试。当ZK发现服务 B3不可用时,也会通知服务A,并设置相应服务节点状态为DEAD。故障节点摘除时,对上游调用服务透明,无感知。

服务路由

分布式服务框架通常会提供多种服务路由策略,同时支持用户扩展负载均衡策略。常见服务路由策略为:
  • 随机:采用随机算法进行路由,消费者基于地址列表随机生成服务提供者地址进行远程调用。缺点是在一个截面上碰撞概率较高,同时如果服务提供者硬件配置差异较大,会导致各节点负载不均匀
  • 基于权重:为每个服务提供者分配一个流量占比权重。缺点是存在慢的服务提供者累积请求问题。
  • 基于负载:消费者缓存所有服务提供者的服务调用时延,周期性的计算服务调用平均时延,然后计算每个服务提供者调用时延和平均时延的差值,根据差值大小动态调整权重,保证服务时延大的服务提供者接收更少的消息,防止消息堆积。
  • 一致性hash:相同参数的请求总是发送到同一服务提供者,当某一台提供者宕机时,原本发往改提供者的请求,基于虚拟结点,平摊到其他提供者,不会引起剧烈变动。
常见的服务路由策略用于负载均衡一般能满足大部分业务的线上需求,但是在一些场景中需要对路由策略设置一些过滤条件:
  • 根据IP地址段做路由
  • 读写分离:根据不同的方法名匹配,再做相应路由
  • 灰度升级:将流量路由到新服务版本上
  • 连接绑定:据一些业务特征,把流量路由到固定到某一服务器上
如果服务是跨机房部署,那么一般服务路由遵循多机房策略,即先本地路由优先,本地服务宕机再同机房优先,其次才是跨机房访问。具体路由场景如下所示:
  • 单机房场景
    • 全部列表结点,按照权重分配流量
  • 多机房场景
    • 同机房优先,同机房内的节点,按权重分配流量
    • 同机房节点不可用时,跨机房分配流量
  • 异地多机房场景
    • 同机房优先,同机房内的节点,按权重分配流量
    • 同机房节点不可用时,优先在本地域内,避免跨机房分配流量
    • 本地域无可用节点时,跨地域分配流量

为了保障业务的扩展性,服务通信框架在默认的路由策略之外,还需要支持业务扩展路由算法,实现业务自定义路由。

服务治理

单体应用服务化之后,通常采用分布式集群的部署模式,这会带来如下问题:
  • 远端服务访问失败后,如何进行容错
  • 服务宕机后,如何进行隔离降级
  • 如何对服务质量进行监控
  • 如何对链路进行跟踪

服务容错

经过服务路由之后,选定某个服务提供者进行远程服务调用,但是服务调用可能会出错。服务调用失败后,服务调用框架需要能够在底层自行容错,容错方面最主要的一点就是故障转移。在实现容错时,要考虑服务是否是幂等的,如果一个服务不是幂等的,做故障转移时可能会出现我们本身不期望的结果。常见的容错策略有:
  • failover:失败自动切换,当出现失败时,重试其他服务器。通常用于读操作等幂等性服务
  • failback:失败自动恢复,后台记录失败请求,定时重发。通常用于通知操作,不可靠,重启丢失。
  • failfast:快速失败,只发起一次,失败立即报错,通常用于非幂等性的写操作。(如果有机器正在重启,可能会出现调用失败)
在做重试时需要注意一下是否需要重试,在高峰期出现抖动时,不适当重试会导致雪崩。某个调用方逻辑有问题或者服务挂掉,这时不应该是重试调用服务,而是需要降级。同时高峰期压力过大时,需要执行过载保护。在执行重试时,需要有一个延时,我们不可能无限制的做重试。下图列出了导致调用失败的几种场景:
  • 服务查找阶段failure:无服务提供者,直接抛出;失败结点快速降级淘汰
  • 请求阶段failure:请求确认丢失,可以重试;如果难以确认server是否收到请求,需考虑调用是否幂等来决定是否重试
  • server侧failure:分执行前、执行后失败两种情况,从client端难以区分这两种情况,需考虑调用是否幂等来决定是否重试
  • client等待response超时:需考虑调用幂等来决定是否重试          

服务限流

当资源成为瓶颈时,服务通信框架需要对消费者做限流,启动流控保护机制。比较常见的流控策略有:
  • 静态流量分配制:静态流控通常估算出服务QPS来设置域值。一般静态分配采用预分配方案,服务框架启动时就会将该域值加载到内存。静态分部策略忽略了服务端的动态变化,云端服务的弹性伸缩特性使得服务节点处理动态变化过程中,服务节点数一旦出现变化,就会使得流控不准。
  • 动态流量分配制:由服务注册中心以流控窗口T为时间单位,动态推送每个节点分配的流控域值QPS。当服务节点数发送变更时,会触发服务注册中心重新计算每个节点的配额,然后进行推送。动态流量分配制的问题在于在生产环境中,每台主机的配置可能都不同,如果采用 流控总域值/服务节点数 这种平均计算方式,那么就会导致性能高的结点配额很快用完,而性能差的结点还剩余。其中的解决方法有对考虑机器性能对分配做加权来降低偏差,还有一种就是配额指标返还和重新申请,每个服务节点根据自己分配的指标值和处理速率做预测,如果计算结果又剩余,则把多余的返还给服务注册中心,配额用完的结点再去服务中心申请配额。同时该方案缺点在于时间窗口T的值难以确定,如果T值过大,如果各节点负载情况变化太快,情况反馈到注册中心再计算会引起较大误差;如果T值过小,经过一系列的上报计算之后,会有一定的时延。
  • 动态流量申请制:系统部署时,根据服务节点数和静态流控QPS阈值,拿出一定比例的配额做初始分配,剩余的配额放在配额资源池中,如果哪个服务节点使用完了配额,就主动向服务注册中心申请配额。配额申请策略为流控窗口 未分配配额 / M(机器数量) * T(时间窗口) / N(经验值,默认为10) 
上述所描述的流控策略都是基于需要预估最大QPS值,本质上都属于静态流控的一种。而动态流控是以资源来作为流控因子,资源又分为系统资源和应用资源两大类。系统资源包括CPU使用率、内存使用率等,应用资源包括JVM堆内存使用率、消息队列积压率等等。根据不同的资源负载情况,动态流控又分为多个级别,每个级别流控系数也都不同,也就是拒绝掉的消息比例不同。并且流控系数支持在线动态调整。需要指出为了防止系统波动导致的偶发性流控,无论是进入流控状态还是从流控状态恢复,都需要连续采集N次并计算平均值以此来判断是否进行流控或恢复。
除了对请求数进行控制之外,还可以对并发数和连接数进行控制。并发控制针对线程的并发执行数,本质是限制对某个服务或者服务的方法过度消费而影响其他服务正常运行。连接控制是针对消费方和提供方采用的是长连接方式进行通信,为了防止因为消费者连接数过多导致服务端负载过大。服务通信框架应该提供设置最大并发数和最大连接数的参数,且无论是在服务消费方和服务提供方都可以设置服务的最大并发数和最大连接数。

服务降级

在业务高峰时期,为了保证核心业务的正常运行,通常会停掉一些不太重要的业务,比如商品评论,用户经验等。同时在某个服务依赖方阻塞或者服务不可用时,会导致调往该服务的请求被阻塞,阻塞的请求会消耗占用掉系统的线程、io等资源,当该类请求越来越多,占用的资源越来越多时,会导致系统瓶颈出现,最终导致业务系统崩溃。上述两种场景都使用到了服务降级,服务降级主要包括容错降级和屏蔽降级两种模式:
  • 屏蔽降级:屏蔽降级的核心在于将原属于降级业务的资源调配出来供核心业务使用。在业务高峰时期,对非核心的服务做强制降级,不发起远程服务调用,直接返回空、异常或者执行特定的本地逻辑,减少自身对公有资源的消费,把资源释放出来供核心服务使用。一般来说屏蔽降级是由运维人员手动开启,服务注册中心收到屏蔽降级消息后,分发给各服务消费集群。消费者集群获取相关内容更新本地缓存,当发起远程服务调用时,需要与屏蔽降级策略做匹配,如果匹配成功,则只需屏蔽降级逻辑,不发起远程服务调用。
  • 容错降级:容错降级的核心在于服务提供者不可用时,让消费者执行业务放通。其是根据服务调用结果,自动匹配触发的。容错降级的策略有两种:第一种是将异常转义,第二种是将异常屏蔽,直接执行模拟接口实现类。
无论是屏蔽降级还是容错降级,都支持从消费者或服务提供者两个维度去配置。从消费端配置策略更灵活,可以实现差异化降级策略。服务降级是可逆的,当系统恢复正常或者故障排除之后,可以对已经降级的服务进行恢复操作,恢复之后消费者将正常调用服务提供者,不再执行降级逻辑。

服务隔离

服务的故障隔离分为四个层次:
  • 进程隔离:进程内部隔离主要有两种方式:
    • 线程池隔离:为每个核心服务都分别独立部署到一个线程池,与其他服务做线程调度隔离,某个线程池资源耗尽,不影响其他服务。对于非核心服务,可以共享一个线程池,防止因为服务数过多导致线程数过度膨胀。线程池的方式有一定的资源消耗,好处是线程池可以堆积请求,所以可以应对突发流量。
    • 信号量隔离:使用一个原子计数器(或者信号量)来记录当前有多少个线程在运行,请求来先判断计数器的数值,若超过设置的最大线程个数则丢弃该类型的新请求,若不超过则执行计数操作请求来计数器+1,请求返回计数器-1。这种方式是严格的控制线程且立即返回模式,但是缺点是无法应对突发流量。
          尽管这两种方式能实现服务的故障鼓励,但是这种隔离并不充分,例如某个故障服务发生了内存泄露异常,它会导致整个进程不可用,即使实现了          资源调度层的隔离,仍然无法保证其他服务不受影响。
  • VM隔离:通过将基础设施层虚拟化,将应用部署到不同的VM中,利用VM对资源层的隔离,可以实现更高层次的服务故障隔离。
           
          将核心服务和非核心分别部署到不同的VM中,利用VM的CPU调度、网络I/O、磁盘和内存等资源限制,实现物理资源层的隔离。
          当非核心服务3发生故障时,无论是线程死循环、OOM还是CPU占用高,由于VM对资源的限制,这些故障并不会影响其他VM的正常运行,与线程级故障隔离配合使用可以实现逻辑+物理层的故障隔离
  • 物理机隔离
          当物理机足够多的时候,硬件的故障就会由小概率事件转变成普通事件。利用分布式服务框架的集群容错能力,可以实现位置无关的自动容错  
             
          要保证服务器宕机后服务扔能正常访问,那么首先必须采用分布式集群部署,同时服务实例需要部署到不同的物理机上。通常来说至少需要3台物理机,假如单台物理机的故障发生概率为0.1%,那么三台同时发生故障的概率为0.001%,服务可靠性将会达到5 9标准。
  • 机房隔离
          当应用规模非常大或者做容灾时,都需要使用多个机房来部署应用。路由时,优先访问同一个机房的服务提供者,当本机房的服务提供者大面积不可用或者全部不可用时,根据跨机房路由策略,访问另一个机房的服务提供者,待本机房服务提供者集群恢复到正常状态之后,重新切换到本机房访问模式。在多机房部署时,也可以选择每个机房部署一个服务注册中心,同一个服务实例,可以同时注册到多个服务注册中心中,实现跨机房的服务调用。多机房同时共用一个服务注册中心也可以,但是如果部署服务注册中心的机房宕掉,那么会导致新的依赖服务注册中心的操作不可用,例如服务治理,运行时参数调整,服务扩容等等。

服务链路跟踪

随着系统内部的服务增多,一次业务调用可能会通过系统内部多个服务协同调用来完成,这些服务可能有不同的团队开发,并且分布在不同的VM节点,甚至可能在多个地域不同的机房内。如果无法有效的梳理后端的分布式调用和依赖关系,故障定界将会非常困难。
分布式消息跟踪系统的核心就是调用链,每次业务请求都会生成一个全局的TraceID,通过跟踪ID将不同节点间的日志串接起来,形成一个完整的日志调用链。
调用链路跟踪能够用于以下场景:
  • 故障的快速定界定位:分布式服务化之后,一次业务调用可能会涉及后台上百次服务调用,传统人工到各节点人肉搜索的方式效率很低。通过调用链跟踪,将一次业务调用的完整轨迹以调用链形式展示出来,通过图形化界面查看每次服务调用结果,以及故障信息,提升故障的定位效率。
  • 调用路径分析:对调用链的调用路径分析,可以识别应用的关键路径:找出服务的热点、耗时瓶颈和易故障点。同时也为性能优化、容量规划等提供数据支撑。
  • 调用来源和去向分析:可对依赖关系进行有效梳理,通过对调用来源进行Top排序,可以识别当前服务的消费来源,以及获取各消费者的QPS、平均时延、出错率等。针对特定的消费者,可以做针对性治理,例如针对某个消费者的限流降级、路由策略修改等。

服务监控

分布式系统需要有一种方式来直观地了解系统的调用及运行状况,测量系统的运行性能,对线上故障进行及时报警。方便准确地指导系统的优化及服务化改进。通过对日志做实时采集、聚合和数据挖掘,提取各种维度的价值数据,为系统和运营提供大数据支撑,下图是日志监控系统架构:
展开阅读全文

没有更多推荐了,返回首页