读书笔记#深度剖析ApacheDubbo核心技术内幕

深度剖析ApacheDubbo核心技术内幕 读书笔记

前言

  • Dubbo框架从整体上分为了业务(Business)层、RPC层和远程调用(Remoting)层,其中业务层提供API,让使用者方便地发布与引用服务;RPC层则是对服务注册与发现、服务代理、路由、负载均衡等功能的封装。远程调用层则是对网络传输与请求数据序列/反序列化等的抽象
  • Dubbo就是一个扩展性极强的框架,其RPC层中的所有组件都是基于SPI扩展接口实现的,每个组件都可以被替换
  • Dubbo框架则提供了分布式系统中常见的集群容错策略,并且提供了扩展接口
  • 当我们访问一个具体服务时到底访问哪一台机器提供的服务呢?这就是分布式系统中负载均衡器与路由规则要做的事情,作为分布式RPC框架,其自身也必须具有负载均衡的能力。Dubbo框架提供了分布式系统中常见的负载均衡策略,并且提供了扩展接口。另外,路由规则提供了服务治理的一种策略,在Dubbo中我们可以通过管理控制台来配置路由规则,让消费者只可访问那些服务提供者
  • 在分布式系统中,当我们要消费某个服务时,如何找到其地址是一个要解决的问题。在分布式RPC中,一个通用解决方案是引入服务注册中心,当服务提供者启动时,会自动把自己的服务注册到服务注册中心;当消费者启动时,会去服务注册中心订阅自己感兴趣的服务的地址列表。在Dubbo框架中,提供了扩展接口来方便地让我们使用ZooKeeper、Redis等作为服务注册中心
    • TODO 消费者也可以订阅自己感兴趣的服务的地址列表
  • 如何让使用者无感知地发起远程过程调用,也就是让使用者在发起远程调用时有和本地调用一样的体验。Dubbo框架和其他RPC框架一样,采用代理来实现该功能。在Dubbo框架中扩展接口Proxy就是专门来做代理使用的,并且其提供了扩展接口的JDK动态代理与Cglib的实现。研究Dubbo的原理,我们可以学习到消费端如何对服务接口进行代理以实现透明调用,服务提供端如何使用代理与JavaAssist技术来减少反射调用开销
    • TODO 游戏业务逻辑方法调用时也可以参考
  • 在Dubbo的分层架构中,Transport网络传输层把Mina和Netty抽象为统一接口,并且在默认情况下使用Netty作为底层网络通信。
  • Dubbo则基于Netty的异步非阻塞能力和JDK 8中的CompletableFuture轻松地实现RPC请求的异步调用,提高了资源利用率

架构图

  • Provider为服务提供者集群,服务提供者负责暴露提供的服务,并将服务注册到服务注册中心
  • Consumer为服务消费者集群,服务消费者通过RPC远程调用服务提供者提供的服务
  • Registry负责服务注册与发现
  • Monitor为监控中心,统计服务的调用次数和调用时间

调用关系

  • 服务提供方在启动时会将自己提供的服务注册到服务注册中心

  • 服务消费方在启动时会去服务注册中心订阅自己需要的服务的地址列表,然后服务注册中心异步把消费方需要的服务接口的提供者的地址列表返回给服务消费方,服务消费方根据路由规则和设置的负载均衡算法选择一个服务提供者IP进行调用

    TODO 这个是在消费方根据路由规则和负载均衡算法选择,zk只是作为服务发现

  • 监控平台主要用来统计服务的调用次数和调用耗时,即服务消费者和提供者在内存中累计调用服务的次数和耗时,并每分钟定时发送一次统计数据到监控中心,监控中心则使用数据绘制图表来显示

demo & 基础篇

  • provider
    • ServiceConfig、RegistryConfig
  • consumer
    • ReferenceConfig、RegistryConfig
  • 同步、异步、泛化
  • 服务消费端本地服务mock主要用来做本地测试用,当服务提供端服务不可用时,使用本地mock服务可以模拟服务提供端来让服务消费方测试自己的功能,而不需要发起远程调用
  • Dubbo提供了一些服务降级措施,当服务提供端某一个非关键的服务出错时,可以手动对消费端的调用进行降级,这样服务消费端就避免了再去调用出错的服务,以避免加重服务提供端的负担
  • Dubbo还提供了一种本地服务暴露与引用的方式,这在同一个JVM进程中同时发布与调用同一个服务时显得比较重要,因为如果当前JVM内要调用的服务在本JVM进程内有提供,则避免了一次远程过程调用,而是直接在JVM内进行通信

Dubbo框架内核原理pouxi

  • 分层架构概述

    Registry服务注册中心层:服务提供者启动时会把服务注册到服务注册中心,消费者启动时会去服务注册中心获取服务提供者的地址列表,Registry层主要功能是封装服务地址的注册与发现逻辑,扩展接口Registry对应的扩展实现为ZookeeperRegistry、RedisRegistry、MulticastRegistry、DubboRegistry等。

    Cluster路由层:封装多个服务提供者的路由规则、负载均衡、集群容错的实现,并桥接服务注册中心;扩展接口Cluster对应的实现类有FailoverCluster(失败重试)、FailbackCluster(失败自动恢复)、FailfastCluster(快速失败)、FailsafeCluster(失败安全)、ForkingCluster(并行调用)等;负载均衡扩展接口LoadBalance对应的实现类为RandomLoadBalance(随机)、RoundRobinLoadBalance(轮询)、LeastActiveLoadBalance(最小活跃数)、ConsistentHashLoadBalance(一致性Hash)等。

远程服务发布与引用流程剖析

  • Dubbo 2.7.0使用更专业的配置中心,如Nacos、Apollo、Consul和Etcd等

  • 服务注册到ZooKeeper后,ZooKeeper服务端的最终树图结构

    • 第一层Root节点说明ZooKeeper的服务分组为Dubbo,第二层Service节点说明注册的服务为com.books.dubbo.demo.api.GreetingService接口,第三层Type节点说明是为服务提供者注册的服务,第四层URL记录服务提供者的地址信息
  • 服务注册到ZooKeeper后,消费端就可以在Providers节点下找到com.books.dubbo.demo.api.GreetingService服务的所有服务提供者,然后根据设置的负载均衡策略选择机器进行远程调用了

  • 每个服务消费端与服务提供者集群中的所有机器都有连接吗?

    • 也就是说服务消费端与服务提供者的所有机器都有连接 TODO 这也就是gate的作用
  • 由于同一个服务提供者机器可以提供多个服务,那么消费者机器需要与同一个服务提供者机器提供的多个服务共享连接,还是与每个服务都建立一个连接?

    • 在默认情况下当消费端引用同一个服务提供者机器上多个服务时,这些服务复用一个Netty连接
  • 消费端是启动时就与服务提供者机器建立好连接吗?

    • 当消费端启动时就与提供者建立了连接
  • 在RegistryDirectory里维护了所有服务者的invoker列表,消费端发起远程调用时就是根据集群容错和负载均衡算法以及路由规则从invoker列表里选择一个进行调用的,当服务提供者宕机的时候,ZooKeeper会通知更新这个invoker列表

    • TODO 考虑节点宕机情况
  • Dubbo服务消费端一次远程调用过程

    • 步骤2和步骤3调用了默认的集群容错策略FailoverClusterInvoker,其内部首先根据设置的负载均衡策略LoadBalance的扩展实现,选择一个invoker作为FailoverClusterInvoker具体的远程调用者,如果调用发生异常,则根据FailoverClusterInvoker的策略重新选择一个invoker进行调用。

      FailoverClusterInvoker.java
      
      // 负载均衡策略选择
      Invoker invoker = select(loadbalance, invocation, copyInvokers, invoked);
      

Directory目录与Router路由服务

  • Directory代表了多个invoker(对于消费端来说,每个invoker代表了一个服务提供者),其内部维护着一个List,并且这个List的内容是动态变化的,比如当服务提供者集群新增或者减少机器时,服务注册中心就会推送当前服务提供者的地址列表,然后Directory中的List就会根据服务提供者地址列表相应变化。
  • 在服务消费端应用中,每个需要消费的服务都被包装为ReferenceConfig,在应用启动时会调用每个服务对应的ReferenceConfig的get()方法,然后会为每个服务创建一个自己的RegistryDirectory对象,每个RegistryDirectory管理该服务提供者的地址列表、路由规则、动态配置等信息,当服务提供者的信息发生变化时,RegistryDirectory会动态地得到变化通知,并自动更新。
    • 从ZooKeeper返回的服务提供者的信息里获取对应的路由规则,并使用步骤7保存到RouterChain里,这个路由规则是通过管理控制台进行配置的
    • RouterChain里也保存了可用服务提供者对应的invokers列表和路由规则信息,当服务消费方的集群容错策略要获取可用服务提供者对应的invoker列表时,会调用RouterChain的route()方法,其内部根据路由规则信息和invokers列表来提供服务
    • TODO 路由规则是在配置中心的,从zk返回的,即zk也作为了一个配置中心

Dubbo消费端服务mock与服务降级策略原理

  • 降级策略就会写入ZooKeeper服务器com.books.dubbo.demo.api.GreetingService子树中Type为configurators的下面
  • 当服务消费者启动时,会去订阅com.books.dubbo.demo.api.GreetingService子树中的信息,比如Providers(服务提供者列表)、Routes(路由信息)、Configurators(服务降级策略)等信息
    • ZK的子树中有不少信息

Dubbo集群容错与负载均衡策略

  • 当服务消费方调用服务提供方的服务出现错误时,Dubbo提供了多种容错方案,默认模式为Failover Cluster,也就是失败重试

  • Failover Cluster:失败重试.当服务消费方调用服务提供者失败后,会自动切换到其他服务提供者服务器进行重试,这通常用于读操作或者具有幂等的写操作。

  • Failfast Cluster:快速失败.当服务消费方调用服务提供者失败后,立即报错,也就是只调用一次。通常,这种模式用于非幂等性的写操作

  • Failsafe Cluster:安全失败.当服务消费者调用服务出现异常时,直接忽略异常。这种模式通常用于写入审计日志等操作

  • Failback Cluster:失败自动恢复.当服务消费端调用服务出现异常后,在后台记录失败的请求,并按照一定的策略后期再进行重试。这种模式通常用于消息通知操作

  • Forking Cluster:并行调用.当消费方调用一个接口方法后,Dubbo Client会并行调用多个服务提供者的服务,只要其中有一个成功即返回。这种模式通常用于实时性要求较高的读操作

  • Broadcast Cluster:广播调用.当消费者调用一个接口方法后,Dubbo Client会逐个调用所有服务提供者,任意一台服务器调用异常则这次调用就标志失败。这种模式通常用于通知所有提供者更新缓存或日志等本地资源信息

    • TODO 支持广播
  • Dubbo负载均衡策略概述

    • 当服务提供方是集群时,为了避免大量请求一直集中在一个或者几个服务提供方机器上,从而使这些机器负载很高,甚至导致服务不可用,需要做一定的负载均衡策略。Dubbo提供了多种均衡策略,默认为random,也就是每次随机调用一台服务提供者的服务

    • Random LoadBalance:随机策略。按照概率设置权重,比较均匀,并且可以动态调节提供者的权重。

      如果所有服务提供者权重不一样,那么在正常情况下应该选择权重最大的提供者来提供服务,但是Dubbo还考虑到另外一个因素,就是服务预热时间。如果服务提供者A的权重比服务提供者B的权重大,但服务提供者A是刚启动的,而服务提供者B已经服务了一些时间,则这时候Dubbo会选择服务提供者B而不是服务提供者A来进行调用。

    • RoundRobin LoadBalance:轮循策略。轮循,按公约后的权重设置轮循比率。会存在执行比较慢的服务提供者堆积请求的情况,比如一个机器执行得非常慢,但是机器没有宕机(如果宕机了,那么当前机器会从ZooKeeper的服务列表中删除),当很多新的请求到达该机器后,由于之前的请求还没处理完,会导致新的请求被堆积,久而久之,消费者调用这台机器上的所有请求都会被阻塞。

    • LeastActive LoadBalance:最少活跃调用数。如果每个提供者的活跃数相同,则随机选择一个。在每个服务提供者里维护着一个活跃数计数器,用来记录当前同时处理请求的个数,也就是并发处理任务的个数。这个值越小,说明当前服务提供者处理的速度越快或者当前机器的负载比较低,所以路由选择时就选择该活跃度最底的机器。如果一个服务提供者处理速度很慢,由于堆积,那么同时处理的请求就比较多,也就是说活跃调用数目较大(活跃度较高),这时,处理速度慢的提供者将收到更少的请求。

    • ConsistentHash LoadBalance一致性Hash策略。一致性Hash,可以保证相同参数的请求总是发到同一提供者,当某一台提供者机器宕机时,原本发往该提供者的请求,将基于虚拟节点平摊给其他提供者,这样就不会引起剧烈变动。

      • TODO 看一下宕机之后的处理,宕机之后,内存数据如果没有了,那么用哪个节点都是一样的,都需要重新load数据
    • 降级检查-> 先经过路由规则 -> 过滤服务器提供者 -> 负载均衡策略

    • 一致性Hash负载均衡策略原理

      分布式系统中,负载均衡的问题可以使用Hash算法让固定的一部分请求落到同一台服务器上,这样每台服务器就会固定处理一部分请求(并维护这些请求的信息),从而起到负载均衡的作用。

      但是普通的余数Hash(用户ID)算法伸缩性很差,当新增或者下线服务器机器时,用户ID与服务器的映射关系会大量失效。一致性Hash则利用Hash环对其进行了改进。

      假设有4台服务器,地址为IP1、IP2、IP3、IP4

      • 一致性Hash,首先计算4个IP地址对应的Hash值:Hash(IP1)、Hash(IP2)、Hash(IP3)、Hash(IP4),计算出来的Hash值是0~最大正整数之间的一个值
      • 在Hash环上按顺时针从整数0开始,一直到最大正整数,我们根据4个IP计算的Hash值肯定会落到这个Hash环上的某一个点,至此我们把服务器的4个IP映射到了一致性Hash环上
      • 用户在客户端进行请求时,首先根据Hash(用户ID)计算路由规则(Hash值),然后看Hash值落到了Hash环的哪个地方,根据Hash值在Hash环上的位置顺时针找距离最近的IP作为路由IP
      • 当IP2的服务器宕机时,根据顺时针规则可知,user1、user2的请求会被服务器IP3进行处理,而其他用户的请求所对应的处理服务器不变,也就是只有之前被IP2处理的一部分用户的映射关系被破坏了,并且其负责处理的请求被顺时针的下一个节点处理
      • 在新增一个IP5的服务器后,根据顺时针规则可知,之前user5的请求应该被IP1服务器处理,现在被新增的IP5服务器处理,其他用户的请求处理服务器不变,也就是说距新增的服务器顺时针最近的服务器的一部分请求,会被新增的服务器所替代

      单调性是指如果已经有一些请求通过Hash分派到了相应的服务器进行处理,当又有新的服务器加入到系统中时,应保证原有的请求可以被映射到原有的或者新增的服务器上,而不会被映射到原来的其他服务器上

      在分布式环境中,当客户端请求时可能不知道所有服务器的存在,可能只知道其中一部分服务器

      衡性也就是指负载均衡,是指客户端Hash后的请求应该能够分散到不同的服务器上。一致性Hash可以做到每个服务器都进行处理请求,但是不能保证每个服务器处理的请求的数量大致相同

      虽然三个机器都在处理请求,但明显每个机器的负载不均衡,这样称为一致性Hash的倾斜,虚拟节点的出现就是为了解决这个问题

      当服务器节点比较少的时候会出现上面所说的一致性Hash倾斜问题,一种解决方法是多加机器,但加机器是有成本的,那么就加虚拟节点,比如上面三台机器,每台机器引入1个虚拟节点后

      如果生成虚拟节点的算法不够好,每个服务节点引入1个虚拟节点后,相比没有引入前均衡性有所改善,但是并不均衡

      • 均匀一致性Hash的目标是,如果服务器有N台,客户端的Hash值有M个,那么每台服务器应该处理大概M/N个用户的请求,也就是说每台服务器负载尽量均衡

      根据所有服务提供者的invoker列表,生成从Hash环上的节点到服务提供者机器的映射关系,并存放到virtualInvokers中.默认虚拟节点个数,160

      • 算该key对应Hash环上哪一个点,并选择该点对应的服务提供者
  • Dubbo线程模型与线程池策略

    • Dubbo默认的底层网络通信使用的是Netty,服务提供方NettyServer使用两级线程池,其中EventLoopGroup(boss)主要用来接收客户端的链接请求,并把完成TCP三次握手的连接分发给EventLoopGroup(worker)来处理,我们把boss和worker线程组称为I/O线程。

    • 如果服务提供方的逻辑处理能迅速完成,并且不会发起新的I/O请求,那么直接在I/O线程上处理会更快,因为这样减少了线程池调度与上下文切换的开销。

    • 但如果处理逻辑较慢,或者需要发起新的I/O请求,比如需要查询数据库,则I/O线程必须派发请求到新的线程池进行处理,否则I/O线程会被阻塞,导致不能接收其他请求。

      • TODO 这个就是vertx的不同,全部用异步
    • 根据请求的消息类是被I/O线程处理还是被业务线程池处理,Dubbo提供了下面几种线程模型。 TODO 线程模型这块可以借鉴

      • all(AllDispatcher类):所有消息都派发到业务线程池,这些消息包括请求、响应、连接事件、断开事件、心跳事件等
      • direct(DirectDispatcher类):所有消息都不派发到业务线程池,全部在IO线程上直接执行
      • message(MessageOnlyDispatcher类):只有请求响应消息派发到业务线程池,其他消息如连接事件、断开事件、心跳事件等,直接在I/O线程上执行
      • execution(ExecutionDispatcher类):只把请求类消息派发到业务线程池处理,但是响应、连接事件、断开事件、心跳事件等消息直接在I/O线程上执行
      • connection(ConnectionOrderedDispatcher类):在I/O线程上将连接事件、断开事件放入队列,有序地逐个执行,其他消息派发到业务线程池处理
      • 在Dubbo中,线程模型的扩展接口为Dispatcher,其提供的上述扩展实现都实现了该接口,其中all模型是默认的线程模型
    • AllChannelHandler

      • AllDispatcher返回了自封装的AllChannelHandler
      • 封装了一个ChannelEventRunnable,指定ChannelState,投递到业务线程池
      • 覆写connected,链接完成事件,交给业务线程池处理
      • 覆写disconnected,链接断开事件,交给业务线程池处理
      • 覆写received, 请求响应事件,交给业务线程池处理
        • todo 源码里面有一个todo 临时解决线程池满后异常信息无法发送到对端问题,线程池满了拒绝调用不返回,导致消费者一直等待超时
      • caught,异常处理事件,交给业务线程池处理
      • 该线程模型把所有事件都直接交给业务线程池进行处理
    • DirectDispatcher

      • dispatch方法直接返回了参数的ChannelHandler,所以其所有事件的处理都是在I/O线程上进行的
    • MessageOnlyDispatcher#MessageOnlyChannelHandler

      • 只覆写了receive方法,只有请求响应消息派发到业务线程池,其他耗时比较短的连接事件、断开事件、心跳事件等消息则直接在I/O线程上执行
    • ExecutionDispatcher#ExecutionChannelHandler

      • 覆写了receive方法,判断消息类型如果是Request,则派发到业务线程池处理,否则,直接在io线程处理,直接调用参数handler#receive
      • 这里与message模型不同之处在于,响应类型的事件也是在I/O线程上执行的
    • ConnectionOrderedDispatcher#ConnectionOrderedChannelHandler

      • 构造函数创建了一个只含有一个线程的线程池和一个有限元素的队列,如果设置的参数connect.queue.capacity大于0,则设置为线程池队列容量,否则线程池队列容量为整数最大值,这个线程池用来实现把链接建立和链接断开事件进行顺序化处理,connectionExecutor。connected,disconnected都扔到这个线程池,因为是只有1个线程,所有是单线程处理
      • 处理链接建立、链接断开事件,可以先使用代码9检查线程池队列的元素个数,个数超过阈值则打印日志,然后把事件放入线程池队列,并使用单线程进行处理。由于是单线程处理,所以其实是“多生产-单消费”模型,实现了把链接建立、链接断开事件的处理变为顺序化处理
      • 理请求事件和异常事件,这里是直接交给了线程池(这个线程池不是connectionExecutor,指业务线程池)进行异步处理
    • 根据URL里的线程模型来选择具体的Dispatcher实现类

    • 为了尽量早地释放Netty的I/O线程,某些线程模型会把请求投递到线程池进行异步处理,那么这里所谓的线程池是什么样的线程池呢?其实这里的线程池ThreadPool也是一个扩展接口SPI,Dubbo提供了该扩展接口的一些实现 TODO 这些自定义的线程池后面也可以直接用来参考

      FixedThreadPool:创建一个具有固定个数线程的线程池。

      • 这里把ThreadPoolExecutor的核心线程个数和最大线程个数都设置为threads,所以创建的线程池是固定线程个数的线程池。另外,当队列元素为0时,阻塞队列使用的是SynchronousQueue;当队列元素小于0时,使用的是无界阻塞队列LinkedBlockingQueue;当队列元素大于0时,使用的是有界的LinkedBlockingQueue。
      • 线程池拒绝策略选择了AbortPolicyWithReport,意味着当线程池队列已满并且线程池中线程都忙碌时,新来的任务会被丢弃,并抛出RejectedExecutionException异常

      LimitedThreadPool:创建一个线程池,这个线程池中的线程个数随着需要量动态增加,但是数量不超过配置的阈值。另外,空闲线程不会被回收,会一直存在。

      • 回收空闲超时设置的是Long.MAX_VALUE,分别设置了核心线程个数和最大线程个数

      EagerThreadPool:创建一个线程池,在这个线程池中,当所有核心线程都处于忙碌状态时,将创建新的线程来执行新任务,而不是把任务放入线程池阻塞队列。

      • EagerThreadPoolExecutor与JUC包中的ThreadPoolExecutor不同之处在于,对于后者来说,当线程池核心线程个数达到设置的阈值时,新来的任务会被放入线程池队列,等队列满了以后,才会开启新线程来处理任务(前提是当前线程个数没有超过线程池最大线程个数);而对于前者来说,当线程池核心线程个数达到设置的阈值时,新来的任务不会被放入线程池队列,而是会开启新线程来处理任务(前提是当前线程个数没有超过线程池最大线程个数),当线程个数达到最大线程个数时,才会把任务放入线程池队列
      • 当目前线程池线程个数大于等于核心线程个数时会执行代码4。在正常情况下,代码4会把任务添加到队列而不是开启新线程,但是EagerThreadPoolExecutor使用了自己的队列TaskQueue。
      • 如果提交线程池的任务个数小于当前线程池线程个数,则提交到队列,让空闲线程处理
      • 如果当前线程池线程个数小于线程池设置的最大个数,则开启线程来处理当前任务
      • 如果当前线程池线程个数大于等于线程池设置的最大个数,则添加到队列

      CachedThreadPool:创建一个自适应线程池,当线程空闲1分钟时,线程会被回收;当有新请求到来时,会创建新线程

      • 用JUC包的ThreadPoolExecutor创建线程池,需要注意的是,这里设置了线程池中线程空闲时间,当线程空闲时间达到后,线程会被回收
  • Dubbo如何实现泛化引用

    • 基于Dubbo API搭建Dubbo服务时,服务消费端引入了一个SDK二方包,里面存放着服务提供端提供的所有接口类
    • 泛化接口调用方式主要是在服务消费端没有API接口类及模型类元(比如入参和出参的POJO类)的情况下使用,其参数及返回值中没有对应的POJO类,所以全部POJO均转换为Map表示。使用泛化调用时服务消费模块不再需要引入SDK二方包
    • 服务消费端:如果为泛化调用,则代码1.1获取泛化参数,分别判断是哪种方式
    • 服务提供端: 如果是泛化调用,则执行代码1.1以获取调用方法的参数信息,然后使用代码1.2来获取调用方法的实例,对参数饭序列化处理,根据泛化类型对服务执行结果进行序列化处理
  • Dubbo并发控制

    • 服务消费端并发控制:可设置接口所有方法最大同时并发请求,也可以只设置接口中某一个方法的并发请求限制数目
      • ReferenceConfig#setActives
      • MethodConfig#setActives
    • 客户端并发控制中,如果当激活并发量达到指定值后,当前客户端请求线程会被挂起。如果在等待超时期间激活并发请求量少了,那么阻塞的线程会被激活,然后发送请求到服务提供方;如果等待超时了,则直接抛出异常,这时服务根本就没有发送到服务提供方服务器
      • RpcStatus#beginCount
    • 服务提供端并发控制
      • ServiceConfig#setExecutes
      • MethodConfig#setExecutes
      • 服务提供方设置并发数后,如果同时请求数超过了设置的executes的值,则会抛出异常,而不是像消费端设置actives时那样去等待
  • Dubbo隐式参数传递

    • Dubbo提供了隐式参数传递的功能,即服务调用方可以通过RpcContext.getContext().setAttachment()方法设置附加属性键值对,然后设置的键值对可以在服务提供方服务方法内获取
    • 当请求发出去后,会清除当前与调用线程关联的线程变量里面的附加属性
    • 等服务逻辑执行完毕后清除当前线程所关联的上下文对象,由于附加属性属于上下文对象,所以附加属性也会被回收
  • Dubbo全链路异步

    • 从2.7.0版本开始,Dubbo以CompletableFuture为基础支持所有异步编程接口,解决了2.7.0之前的版本异步调用的不便与功能缺失
    • 异步调用是基于NIO的非阻塞能力实现并行调用,服务消费端不需要启动多线程即可完成并行调用多个远程服务,相对多线程开销较小
    • 流程图
      • 步骤1是当服务消费端发起RPC调用时使用的用户线程,用户线程首先使用步骤2创建一个Future对象,接着步骤3会把请求转换为I/O线程来执行,步骤3为异步过程,所以会马上返回,然后用户线程使用步骤4把其创建的Future对象设置到RpcContext中,其后用户线程就返回了
      • 步骤5中,用户线程可以在某个时间点从RpcContext中获取设置的Future对象,并且使用步骤6来等待调用结果
      • 步骤7中,当服务提供方返回结果后,调用方线程模型中的线程池中的线程则会把结果使用步骤8写入Future,这时用户线程就可以得到远程调用结果了
    • 2.7.0版本之前的异步调用能力比较弱
      • 代码2设置调用为异步方式,代码3直接调用sayHello()方法会马上返回null。如果要想获取远程调用的真正结果,需要使用代码4获取future对象,并且调用future的get()系列方法来获取真正的结果
      • 代码2创建了请求对象,然后代码3创建了一个future对象,代码4使用底层通信异步发送请求(使用Netty的I/O线程把请求写入远端)。因代码4是非阻塞的,所以会马上返回。
      • 从用户线程发起远程调用到返回request,使用的都是用户线程。由于代码4 channel.send(req)会马上返回,所以不会阻塞用户线程
      • lock和done是为了实现线程之间的通知等待模型,比如调用DefaultFuture的get()方法的线程为了获取响应结果,内部会调用done.await()方法挂起调用线程。当接收到响应结果后,调用方线程模型中线程池里的线程会调用received()方法,其内部会把响应结果设置到DefaultFuture内,然后调用done的signal()方法激活一个因调用done的await()系列方法而挂起的线程(比如调用get()方法被阻塞的线程)
      • 本节一开始讲解了Dubbo异步调用链路流程图,当服务消费端业务线程发起请求后,会创建一个DefaultFuture对象并设置到RpcContext中,然后在启动I/O线程发起请求后调用线程就返回了null的结果;当业务线程从RpcContext获取future对象并调用其get()方法获取真实的响应结果后,当前线程会调用条件变量done的await()方法而挂起;当服务提供端把结果写回调用方之后,调用方线程模型中线程池里的线程会把结果写入DefaultFuture对象内的结果变量中,接着调用条件变量的signal()方法来激活业务线程,然后业务线程就会从get()方法返回响应结果。
      • 这种实现异步调用的方式基于从返回的future调用get()方法,其缺点是,当业务线程调用get()方法后业务线程会被阻塞,这不是我们想要的,所以Dubbo提供了在future对象上设置回调函数的方式,让我们实现真正的异步调用。
      • 这种方式在业务线程获取了future对象后,在其上设置回调函数后马上就会返回,接着等服务提供端把响应结果写回调用方,然后调用方线程模型中线程池里的线程会把结果写入future对象,其后对回调函数进行回调。由此可知,这个过程中是不需要业务线程干预的,实现了真正的异步调用。
      • 2.7.0版本之前提供的异步调用方式,Future方式只支持阻塞式的get()接口获取结果。虽然通过获取内置的ResponseFuture接口,可以设置回调,但获取ResponseFuture的API使用起来很不便,并且无法满足让多个Future协同工作的场景,功能比较单一
    • 2.7.0版本提供的异步调用实现
      • 代码4可以直接获取到CompletableFuture,然后设置回调,基于CompletableFuture已有的能力,我们可以对CompletableFuture对象进行一系列的操作,以及可以让多个请求的CompletableFuture对象之间进行运算(比如合并两个CompletableFuture对象的结果为一个CompletableFuture对象等)
      • 当业务线程发起远程调用时,会创建一个DefaultFuture实例,接着经过FutureAdapter把DefaultFuture转换为CompletableFuture实例,然后把CompletableFuture实例设置到RpcContext内。业务线程从RpcContext获取该CompletableFuture后,设置业务回调函数
      • 服务提供方把结果写回调用方之后,调用方的线程模型中线程池里的线程会调用DefaultFuture的received()方法,并把响应结果写入DefaultFuture,接着调用这里的代码6所设置的回调,回调内部的done()方法再把结果写入CompletableFuture,然后CompletableFuture会调用业务设置的业务回调函数
    • 服务提供端异步执行
      • 在Provider端非异步执行时,对调用方发来的请求的处理是在Dubbo内部线程模型的线程池里的线程中执行的
      • 在Dubbo中,服务提供方提供的所有服务接口都是使用这一个线程池来执行的,所以当一个服务执行比较耗时时,可能会占用线程池中很多线程,从而导致其他服务的处理收到影响
      • Provider端异步执行则将服务的处理逻辑从Dubbo内部线程池切换到业务自定义线程,避免Dubbo线程池中的线程被过度占用,有助于避免不同服务间的互相影响
      • 但需要注意的是,Provider端异步执行对节省资源和提升RPC响应性能是没有效果的,这是因为如果服务处理比较耗时,虽然不是使用Dubbo框架内部线程处理,但还是需要业务自己的线程来处理,另外还有副作用,即会新增一次线程上下文切换(从Dubbo内部线程池线程切换到业务线程)
      • Provider端在同步提供服务时是使用Dubbo内部线程池中的线程来进行处理的,在异步执行时则是使用业务自己设置的线程从Dubbo内部线程池中的线程接收请求进行处理的
      • 基于定义CompletableFuture签名的接口实现异步执行需要接口方法的返回值为CompletableFuture,并且方法内部使用CompletableFuture.supplyAsync让本来该由Dubbo内部线程池中的线程处理的服务,转换为由业务自定义线程池中的线程来处理,CompletableFuture.supplyAsync()方法会马上返回一个CompletableFuture对象(所以Dubbo内部线程池中的线程会得到及时释放),传递的业务函数则由业务线程池bizThreadpool执行
      • 调用sayHello()方法的线程是Dubbo线程模型线程池中的线程,而业务处理是由bizThreadpool中的线程来处理的,所以代码2.1保存了RPC上下文对象,以便在业务处理线程中使用
      • 使用AsyncContext实现异步执行
    • 异步调用与执行引入的新问题
      • Filter链:2.7.0版本之前,在消费端采用异步调用后,由于异步结果在异步线程(Dubbo框架线程模型线程池中的线程)中单独执行,因此DubboInvoker的invoke()方法在发起远端请求后,会将空的RpcResult对象返回Filter调用链,也就是说,Filter链上的所有Filter获取的远端调用结果都是null,最终null值也直接返回给调用方法。而真正的远端调用结果需要调用方从RpcContext获取future对象来获取,当真正远端结果返回时,已经不会再次走Filter链进行处理了。
        • 2.7.0版本中为Filter接口增加了回调接口onResponse
        • 异步调用流程是这样的:当服务提供端把结果写回调用方后,调用方的线程模型中的线程会把结果写入CompletableFuture对象,接着Filter链中的Filter会执行自己的onResponse()方法,然后把结果以新的CompletableFuture对象形式返回给Filter链上的一个节点,最后把最终结果写入业务调用方调用的get()方法的future对象内,这样就获得最终调用结果了
      • 上下文对象传递
        • 在服务提供端使用异步调用之前,业务可以直接在服务提供方服务代码内使用RpcContext.getContext()获取上下文对象,进而可以访问其中保存的内容
        • 但在服务提供端使用异步执行后,由于真正执行服务处理的是另外的线程,而RpcContext内部的ThreadLocal变量是不能跨线程访问的,因此在启动异步执行后,需要业务先保存上下文对象,然后在业务线程里再访问
        • 使用signalContextSwitch()方法实现上下文的切换
  • 本地服务暴露与引用原理

    • Dubbo还提供了一种本地服务暴露与引用的方式,在同一个JVM进程中同时发布与调用同一个服务时,这种方式显得比较重要,因为如果当前JVM内要调用的服务在本JVM进程内已有,则避免了一次远程过程调用,而是直接在JVM内进行通信
    • 本地导出使用了injvm协议,这是一个伪协议,它不开启端口,不发起远程调用,只在JVM内直接关联,但执行Dubbo的Filter链
  • Dubbo协议与网络传输

    • Dubbo协议

      • Dubbo协议作为建立在TCP之上的一种应用层协议,协议内容由header和body两部分组成 TODO 协议这块可做参考

      • header总包含了16字节的数据

      • 其中,前两字节为魔数,类似Class类文件里的魔数,这里用来标识一个帧的开始,固定为0xdabb,第一字节固定为0xda,第二字节固定为0xbb

      • 紧跟其后的一字节是请求类型和序列化标记ID的组合结果:requstflag|serializationId。其中,高四位标示请求类型,低四位标示序列化方式

        FLAG_REQUEST、FLAG_TWOWAY、FLAG_EVENT

        Dubbo、Hession2、Java、CompactedJava、FastJson、NativeJava、Kryo、Fst、Protostuff...Serialization

      • 再后面的一字节是只在响应报文里才设置(在请求报文里不设置),用来标示响应的结果码

        OK、CLIENT_TIMEOUT、SERVER_TIMEOUT...

      • 其后的8字节是请求ID

      • 最后的4字节是body内容的大小,也就是指定在协议头header内容后的多少字节是协议body的内容

    • 服务消费方面编码原理

      • 当网络传输使用Netty时,实际上是把请求转换为任务并投递到了NettyClient对应的Channel管理的异步队列里,这样当前的业务线程就会返回了,Netty会使用I/O线程去异步地执行该任务,把请求通过TCP链接发送出去
      • 在Netty中,每个Channel(NioSocketChannel)与NioEventLoopGroup中的某一个NioEventLoop固定关联,业务线程就是异步地把请求转换为任务,并写入与当前Channel关联的NioEventLoop内部管理的异步队列中,然后NioEventLoop关联的线程就会去异步执行任务
      • NioEventLoop关联的线程会把请求任务进行传递,即传递给该Channel管理的管线中的每个Handler,其中的一个Handler就是编解码处理器,它又把任务委托给DubboCodec对请求任务进行编码,编码完毕执行步骤11,让编码后的数据沿管线继续流转下去
    • 服务发布方解码原理

      • 客户端与服务端进行网络通信时,客户端会通过socket把需要发送的内容序列化为二进制流后发送出去,接着二进制流通过网络流向服务器端,服务端接收到该请求后会解析该请求包,然后反序列化后对请求进行处理
      • 在客户端发送数据时,实际是把数据写入TCP发送缓存里,如果发送的包的大小比TCP发送缓存的容量大,那么这个数据包就会被分成多个包,通过socket多次发送到服务端。而服务端获取数据是从接收缓存里获取的,假设服务端第一次从接收缓存里获取的数据是整个包的一部分,这时就产生了半包现象。半包不是说只收到了全包的一半,是说收到了全包的一部分
      • 如果发送的数据包大小比TCP发送缓存的容量小,并且假设TCP缓存可以存放多个包,那么客户端和服务端的一次通信就可能传递了多个包,这时服务端就可能从接收缓存一下读取了多个包,这样就出现了粘包现象
      • 出现粘包和半包的原因是TCP层不知道上层业务的包的概念,它只是简单地传递流,所以需要上层的应用层协议来识别读取的数据是不是一个完整的包
      • decode()方法在过程中使用“自定义协议header+body”的方式来解决粘包、半包问题,其中header记录了body的大小,这种方式便于协议的升级。另外需要注意的是,在读取header后,message的读取指针已经后移了,如果后面发现出现了半包现象,则需要把读取指针重置。
  • Dubbo实践

    • Arthas TODO Arthas实践
    • 适配器类是使用动态编译技术生成的,一般情况下只有使用Debug才能看到适配器的源码,但是使用Arthas我们就可以在服务启动的情况下查看某个适配器源码
    • Dubbo会给每个服务提供者的实现生成一个Wrapper类,在这个Wrapper类里最终是调用服务提供者的接口实现类,Wrapper类的存在是为了减少反射的调用。那么我们可以使用jad命令方便地查看被包装后的某一个服务实现类
    • Dubbo中,Filter链是一个亮点,通过Filter链可以对服务请求和服务处理流程进行干预,有时候我们想要知道运行时到底有哪些Filter在工作,这时使用Arthas的trace命令显得比较重要
    • Dubbo的服务消费端基于CompletableFuture实现了纯异步调用,其实还不单单是CompletableFuture的功劳,归根到底是Netty的NIO非阻塞功能提供的底层实现
      • @Sharable注解是让服务端所有接收的链接对应的Channel复用同一个NettyServerHandler的实例。这里可以使用@Sharable方式,因为NettyServerHandler内的处理是无状态的,不会存在线程安全问题

你可能感兴趣的:(读书笔记#深度剖析ApacheDubbo核心技术内幕)