Y::分布式计算

Stream:不尽长江滚滚来

Stream 业务诞生于实时性要求极高并且数据流量高而绵长的场景,比如对访问日志的监控统计过滤和处理,网络流量监控,业务消息队列,多媒体信息流,这些场景下往往有这些要求:

  • 数据大量产生且没有边界,比如网络访问日志信息
  • 数据流是可以理解成无因果无状态的时间数据序列,对实时性的要求比较高,且前后数据依赖性基本没有,比如这一秒的视频流和下一秒的视频流基本没有太大关系,这一秒的网络流量日志和上一秒的日志也没有关系。
  • 计算任务完全无状态,不存储任何数据。

高吞吐量,低时延,持续吞吐无边界数据,是 Stream 模式的特征。

Storm 为例分析一个 Stream 模式的分布式系统的架构:

  • Master 节点:分配流处理作业,这里的作业一般指无状态的纯计算函数式代码,可能存在上下游的依赖,比如计算节点依赖上一个过滤节点,但仍然没有对作业的状态依赖。
  • Superior 节点:监督节点,这个节点不参与流数据的业务过程,但是起到和 Master 节点直接交互的作用,接受 Master 节点的作业分配,并传递给自己的 Worker,同时承担启动和结束任务的调度功能。Superior 也作为监督节点监控 Worker 的健康状态,和 Master 节点进行通信。
  • Worker 节点:预编译处理好作业代码,接收实时的流数据,并输出结果。

Worker 节点内部也有细分成两种组件,SpoutBolt 组件,这两种组件以 Spout 为起点,以有向无环图的方式构建在一起,形成一个流数据处理模式的网络,也就是一个数据帧的具体处理流程。

Spout 节点承担数据的提取功能,Bolt 则承担真正的数据计算,Worker 内部需要自己分配好 Bolt 的数据流向和每个 Bolt 的计算作业,一般来说单个 Bolt 的作业都十分简单,需要多个 Bolt 整体编排组合在一起以完成复杂的计算任务。

  +-------+        +------+                                   
  | Spout |------->| Bolt |\                                  
  +-------+        +------+ \     +------+                    
                             *--->| Bolt |\                   
  +-------+        +------+ /     +------+ \    +------+      
  | Spout |------->| Bolt |/                *-->| Bolt |      
  +-------+\       +------+       +------+ /    +------+      
            \                *--->| Bolt |*                   
             \     +------+ /     +------+ \    +------+
              *--->| Bolt |/                *-->| Bolt |
                   +------+                     +------+

这些 Bolt 会分别承担过滤,清洗,增强,聚合,联结等功能,最后生成我们想要的数据。

在 Stream 作业的处理过程中,最小的数据单元可以称为“数据帧”,这一帧往往非常小,数据帧在 Stream 整个系统中输入输出的的前后顺序往往是不确定的,这也是 Stream 模式需要注意的地方。

Actor:排队等叫号

Actor 分布式业务的特征和 OOP 很类似,自身有状态,内部对外黑盒,只暴露接口,但是 Actor 和 OOP 的方法调用不一样的是,Actor 的接口是消息队列式的异步调用,不存在同步并发死锁的问题。

Actor 的特点就是并发度高(全都异步了肯定高啊),实时性低,同步顺序无法确定,可扩展性强,没有同步锁的开销。

Actor 结构上没有什么很难理解的地方,1973提出的框架现在已经广泛用于很多语言中,所以了解就行。

流水线:我们称其为高效

写过 CPU 流水线指令的同学们都知道,一个 CPU 五级指令流水线:取指(IF)、译码(ID)、执行(EX)、访存(MEM)、回写(WB)可以让CPU在同一个时钟周期中同时处理多条指令,极大提高了CPU的运行效率。

CLK   |     |     |     |     |     |     |     |     |     |     |
CMD1  | IF  | ID  | EX  | MEM | WB  |
CMD2    ... | IF  | ID  | EX  | MEM | WB  |
CMD3          ... | IF  | ID  | EX  | MEM | WB  |
CMD4                ... | IF  | ID  | EX  | MEM | WB  |
CMD5                      ... | IF  | ID  | EX  | MEM | WB  |
CMD6                                | IF  | ID  | EX  | MEM | WB  |

因为一个CPU上 IF,ID,EX,MEM,WB 都是可以并行执行的,且相互基本不干扰,所以流水线式的处理模式效率更高。

把这个逻辑搬到一个分布式系统中,每个节点的子任务可以并行,相互之间几乎没有干扰,采用子任务流水线的方式,可以显著提高整个系统的 TPS,最重要的就是要将一个大型的任务合理拆分成互相之间耦合性很小的子任务

X::分布式调度

跌跌撞撞,走完Y轴技术设计方案,终于搭建起一个分布式系统的骨架的时候,这个时候我们就该去考虑整个分布式系统该如何稳定长线运营了,这就是我们分布式系统的X轴技术相关的内容了,首先我们考虑一下一个任务来到分布式系统后,我们应该怎么调度吧。

单体调度:Brog->K8S

单体调度顾名思义,有一个掌控全局的节点去调度一切,这个节点拥有全局节点和全局资源的上帝视角,通过自己的计算判断如何将一个任务分配下去,如何调动相关的资源和安排各个节点的关系,这个节点我们一般称作 Master 节点。

显而易见的是,这种调度方式几乎是所有分布式系统的主流,一般来讲,常见的一个分布式系统最多不会超过百来个节点(这里的节点是指功能划分的节点,而不是集群式的副本节点),而 Master 的调度控制一般采用快捷有效的算法和简短的通信方式,QPS 和 TPS 都很小,这种单体调度的方式简单高效且方便,简直是不二之选。

所以大家现在可以看到的是,很多分布式系统都采用了单体调度的方式,比如 K8S 的调度就是单体调度,现在 K8S 已经是一个用于大型分布式系统的容器编排框架,可见单体调度的可用性有多够用了

以 K8S 的前辈 Borg 为例,Borg 的调度算法简单来说就是:筛出可用,选出最合适。而这个选出最合适就又有两种,一开始 Brog 使用的称为最差匹配的算法,即尽可能把任务均匀分散到不同机器上,这种朴实的想法看起来很有效,但是最大的问题在于:小任务往往不断压缩机器的资源导致全是资源碎片,大型任务只能一直等。所以后面 Brog 改为了最佳匹配算法,尽可能地把资源往一个机器上塞,因为机器上的任务越多,资源越少,能继续接受的任务越少(限制越大),所以优先匹配限制大的机器。

题外话:在单体调度的视角里,一群副本的集群不能算一群机器而是一个集群联邦,所以是按一个节点调度的。

两层调度:Mesos&上万节点集群

了解过容器战争的同学们都知道,Mesos 在和 Docker 的战争中不落下风的一个重要因素就是,在那个年代,Mesos 就已经有了调度上百万个节点的能力,所谓的两层调度,实际也就是 Mesos 选择的调度实现方式,当然这个两层调度的架构看起来还是挺直观的,只是真要实现起来怕是要汗流浃背了。

两层调度简单来讲就是资源任务分开调度,这时,根据物质基础决定上层建筑的通用思想,资源调度器就成为了第一层调度,调度的资源分配给任务调度器,任务调度器再根据自己手上拿到的资源进行第二层调度。

一般在这这个时候,资源调度就从单体调度的任务主动申请资源调度,逐渐改变为了资源管理器主动分配资源的调度,由资源调度器去分配资源交给任务调度器去匹配,当然,这其中也是会有各种资源调度的设计在里面,在减少资源调度器的通信压力的同时避免旱的旱死涝的涝死。

Mesos 分布式调度架构长这样(我好喜欢 ASCII 画

=========================== Framework ===========================
  +------------+   +-----------+  +---------+  +--------------+
  |   Hadoop   |   |   Spark   |  |   MPI   |  |   Marathon   |
  +------------+   +-----------+  +---------+  +--------------+
=================================================================
                 ^                      |
                 |               Schedule Task
          Resource Offer                |
                 |                      v
              +-----------------------------+
              |        Mesos Master         |
              |  +-----------------------+  |
              |  |   Allocation Module   |  |
              |  | [Resource Scheduler]  |  |
              |  +-----------------------+  |
              +-----------------------------+
                    ^                  |
                    |            Assign Task
         Report Remain Resource        |
                    |                  v
     +-------+     +-------+     +-------+     +-------+
     | Mesos |     | Mesos |     | Mesos |     | Mesos |
     | Slave |     | Slave |     | Slave |     | Slave |
     |=======|     |=======|     |=======|     |=======|
     | Task1 |     | Task8 |     | TaskB |     | TaskA |
     | Task4 |     | Task9 |     | Task2 |     |       |
     | Task5 |     |       |     |       |     |       |
     +-------+     +-------+     +-------+     +-------+


Hadoop:用于大数据处理的分布式计算框架,主要包括 HDFS 和 MapReduce。
Spark:一个快速、通用的分布式计算系统,支持批处理、流处理和机器学习等多种任务。
MPI:一种用于并行计算的通信协议,常用于高性能计算领域。
Marathon:用于容器编排和管理的平台,支持应用部署、自动扩展和故障恢复。

在 Mesos 框架中,Framework 是 Mesos 的一个组件,也是一个开放接口,用于其他程序根据接口构建自己的任务代码,所以可以当做是一个任务调度层,提供任务调度和资源匹配。

Allocation Module 在 Mesos 框架中承担资源调度器的功能,接下来我们通过一个任务的申请流程来了解这个调度架构的运行原理。

  • 在 Mesos 框架运行时,每一个 Mesos Slave 需要定期向 Mesos Master 上报自己的可用资源情况,以便运行在 Master 上的 Allocation Module 可以掌控全局的资源视角。

  • 当需要发起一个任务时,首先会将任务需求注册到框架 Framework,此时 Framework 自己手上仍有 Master 下发的 Resource Offer,表示目前 Framework 可以自由调度和匹配的资源的情况。

注意:Offer 发到 Framework 之后 Master 就不会再询问或者干涉,全部交给 Framework 自己调度任务匹配资源,不需要 Master 了解细节,减轻 Master 的运行压力。

  • 如果 Framework 手上的 Offer 暂时不足以调配新的任务需求,Framework 就会向 Master 发送资源调度的请求,希望能拿到足够的 Resource Offer 来调度新的任务。

  • Master 接到 Framework 的请求后,不会去询问这个资源是用于什么任务的,只做分配,这个时候就有很多资源调度算法,比如最大最小公平算法调度,主导资源公平调度算法,去尽可能分配 Framework 需要的资源。

  • 当 Framework 手上有足够的资源匹配任务需求的时候,Framework 就会自己分配好任务所需要的具体 CPU 和内存的资源(指定机器或者节点),然后将任务调度策略交给 Master,Master 不需要过多关心这些这些任务是怎么分配的,按照 Framework 的策略直接交给对应的节点去执行就行。

这样,就完成了一次两层调度框架下的任务调度,Mesos 是一个管理百万节点的分布式容器平台,两层调度才能满足如此庞大的通信量和调度强度,不过哪怕是在 2024 年,K8S 很多时候作为一个大集群管理框架去管理上百个复杂且性质不一节点,也是单体调度就绰绰有余,说明技术不一定越复杂越好,而是越合适的越好。

共享状态调度:Omega

Omega 又是谷歌内部一个分布式框架,是谷歌为了解决两层调度中,Framework 只拥有局部视野确实全局一致性和资源调度器的单点瓶颈而提出的一个新的框架,吸收了 Brog 的优点并且做了很多的改进。

分布式系统里面很容易看到谷歌的身影,整个分布式技术的发展就是大公司玩的越来越花,小公司玩的越来越轻量越来越简化,两头促进发展。

Omega 的系统架构大致如下

  ==== Cluster ====
  +----------------+       +----------------+
  |   Schedulers   |       |  State Storage |
  | +------------+ | <====>| +------------+ |
  | | Cell State | |       | | Cell State | |
  | +------------+ |       | +------------+ |
  +----------------+       +----------------+
          ^
          |
          v
   +---------------+
   | Cells Cluster |
   +---------------+

Cells 是 Omega 集群中物理资源调度的最小单位,本质是一群物理机的集合。

Scheduler 从单个节点变为一整个集群,集群中的每个 Scheduler 都私有一个自己的全局 Cell State,本质是 State Storage 中 Cell State 的副本,当然这里要求保证每一个 Cell State 的全局一致性。

State Storage 持久化保存整个框架的节点的状态

Scheduler 各自接收处理自己的任务调度策略,

X::分布式高可用

链路追踪:

链路追踪是一个分布式服务兴起过程中一个不可忽视的问题,在单机时代,一个请求一个线程,一个线程日志就可以解决一切,但是在分布式系统中,一个请求从达到服务边界到返回用户响应的这段时间里,不但要经历漫长的服务调用链路,还需要经历分布式服务群网络链路的各种通信意外,一旦出现错误,想要靠每个服务节点的单机日志还原整个链路的信息是几乎不可能的,这个时候,就有必要通过一些外部手段来进行链路追踪和错误定位。

目前公认的链路追踪的开山之作是谷歌在2010年发表的论文:Dapper,这篇论文里面抽象总结了一个完整的狭义的链路追踪系统记录方案。

如何设计链路追踪数据的结构

一个请求达到系统的边界,进入系统,经历漫长链路,最终返回客户响应,这整个过程中,需要关注的元素可以抽象成两个部分:Trace 和 Span。

Trace 其实就代表这一次的请求,请求在系统中的复杂调用链路形成一个调用树,每一个 trace 严格对应一个调用树,处理记录好 trace 和调用树之间的关系就完成了一次完整的链路追踪。

Span 则是这个调用树上的树节点,一个 Span 记录了自己对应服务节点的调用信息,对应的trace,调用时间,调用顺序,调用记录的尽可能详细信息,以便于完成足够精确的链路追踪。

直到今天,这种链路追踪记录的设计思路依旧是主流。(谷歌真的是江湖上四处都有的传说)

如何记录调用的链路信息

目前来说有三套比较成熟的技术方案:

  • 日志追踪:这个需要服务自己主动提供,一般是一个日志框架把 trace 和 span 等信息直接输出到日志中,然后持久化之后通过一个总的数据存储系统汇总到全局日志,再通过数据分析搜索系统完成链路信息的归集。这种方案性能损耗低,也没有什么侵入性,就是时效性和精度特别差,毕竟日志进程和真正的服务调用之间的时间差从计算机角度来看还是很大的,并且日志容易出现确是和延时,得到的数据并不准确。
  • 侵入式探针追踪:探针相当于一个微服务的守护服务或者寄生服务,一般会有自己的服务注册,心跳检测,隶属于自己的追踪系统,有自己的数据收集协议,监控被寄生的服务得到调用信息,并通过独立的RPC或者HTTP上报给追踪系统。这种方式精度高,稳定性好,就是CPU,网口和内存不太友好,会占据目标服务的资源。
  • 边车代理追踪:理论上最透明和侵入性最低的链路追踪方式,但是只使用于服务网格(Server Mesh),这种方案保证只要调用走网络就能追踪到,但是问题在于前期基础设施的搭建需要很大的成本,不是所有的微服务集群都是用服务网格搭建的,所以实际上不怎么见到。(顺带一提,因为是基于网路的追踪,所以追踪的粒度只在服务级,只是精度和可靠性非常高)

写到这里,也可以简单总结一下一个好的链路追踪方案应该有哪些优秀品质:

  • 低性能消耗
  • 精度高,可靠性好
  • 对应用隐形,尽可能减少侵入性
  • 对应用扩展,可以完美衔接应用的弹性扩容收缩

题外话:从链路追踪到 APM

狭义上,链路追踪的技术只用于一个请求来到系统之后,服务之间的通信和调用信息的记录追踪。但是从链路追踪系统发散不断发展出去的,通过服务数据采集数据存储归集数据分析展示三个子系统整合成大型分布式服务追踪系统的这一套方案,也自然孵化出了一个全新的系统:Application Performance Management 应用性能管理系统。APM系统的工作辖区比链路追踪系统要宽的多,底层的设计思想却大同小异,APM 给复杂的分布式系统带来里极高的可观测性,现在很多的链路追踪系统,也在不断向 APM 系统发展。

负载均衡

经常打游戏的同学们都知道,如果一核有难,八核围观,就会听到电脑哀转久绝的悲鸣。分布式系统也是这样,如果一个高峰流量不小心全都打在了一个服务器上,而其他服务器隔岸观火,那大概率也会听到运维同学尖锐的爆鸣声,负载均衡就是这样一个避免单点爆破的技术。

如果要我们自己来设计一个负载均衡算法,从最朴素的想法来说,首先需要保证这个算法能最大程度上保证均衡,这是第一个要求,再进一步思考,很多服务器都会有自己的本地缓存和一些用户信息的记录,比如 Session,也就是一些服务是有状态的,另一个想法就是,我们肯定希望用户能尽可能访问到上次访问的那个服务,这样可以更大程度利用状态缓存,这样就可以总结出一个好的负载均衡算法应该满足的条件:

  • 尽可能保证均衡
  • 对于有状态的服务,尽可能保证路由一致性

值得一提的是,主流架构设计要求下,基本所有的服务都是无状态的,所以很多负载均衡的主流方案都只尽可能保证了均衡,而有状态的服务现在用的最多的只有一种,那就是各种各样的分布式外部缓存,比如 Redis,Memcached,这些服务的负载均衡方案都尽量满足了两个要求。

目前主流的负载均衡策略:

轮询策略

典型代表就是 Nginx,这种情况下服务器可能会处理不同的请求,有的很复杂有的很简单,所以每个请求带开的负载压力是不一样的,这个时候采用轮询策略,将新的请求交给轮询空闲的服务器,避免交给正在处理复杂请求的服务器带来额外压力。当然,还有一种情况,就是服务器之间的性能可能是不一致的,这个时候可以给轮询加上优先级,采用甲醛轮询的方式,每次轮询优先让性能好的服务器先上。

随机策略

典型代表是 Dubbo,作为一个RPC框架,Dubbo 负责的是接口粒度的负载均衡,假如大家都很遵守开发规范的话,在同一个 Interface 里面的接口都应该尽量保持业务上和逻辑上的大同小异,这个时候每个服务调用不同的接口在性能消耗和资源消耗上也不会差距很大,随机策略又快又方便,尤其是在集群数量很大的时候,随机策略能表现出十分惊人的均衡性和高性能。

如果一个 RPC 的 Interface 里面一个方法只要 20ms/500kb/0.5CPU/1Thread 另一个方法却要 400ms/15mb/8CPU/40Thread,那还是比较建议先找一下开发的问题,是不是有反社会人格

Hash/一致性Hash

典型代表是各种外部缓存系统,比如 Redis 就是用的 Hash 算法,而 Memcached 就是用的带虚拟节点的一致性 Hash 算法。这种算法的好处就是可以根据发起请求的用户ID或者IP地址等用户信息来保证 Hash 结果的一致性,定位到同一个服务节点上,提高有状态服务的命中率。缺点就是一致性Hash一直有负载不均衡的问题,因为服务节点在 Hash 环上不是均匀分布的,而是通过特定 Hash 算法定位上去的,Hash 算法在大量数据下肯定有很可靠的均匀性,但是一般用的集群也就不到十个服务节点,均匀性就大打折扣了。同时还有节点异构的问题,一个性能很好的节点在一致性Hash环上反而有可能匹配不了几个请求。

扩展一下:关于有虚拟节点的一致性Hash。众所周知,一致性Hash是请求在一个2^32的Hash环上找最近服务节点的故事,服务节点和新的请求都会Hash到这个固定的环上,节点如果冲突的话就是经典的步跳策略。但这就导致少量的服务节点在 Hash 环上极度容易分不不均匀导致负载不均衡,毕竟 Hash 算法是不可控的。

虚拟节点就正好解决了这个问题,虚拟节点并非真实的服务节点,而是指向真实服务节点的一个“快捷方式”,大量虚拟节点的加入可以使得服务节点在整个 Hash 环上分布更加均匀,同时虚拟节点均匀地充当真实节点的分身也更好地实现了均衡负载的问题,同时,如果某个服务节点有更高的性能,就可以有更多的虚拟节点分身,也解决了节点异构的问题。

虚拟节点还有一个好处就是,当一个真实服务节点挂掉之后,他的虚拟节点会顺时针归顺到最近的节点代表的服务节点上,避免了一致性Hash当一个服务挂掉之后压力全都被不超过两个节点承担了.

流量控制

流量控制可以参考TCP那边的滑动窗口协议拥塞控制来看,基本思路是一致的,一个缓冲区作为应对流量高峰的拦截机制,流量控制的主要目的是为了保证在尽可能利用现有的机器资源的前提下减少流量的积压负担。

有的时候如果高峰流量全都是重要流量(比如618抢购的高峰交易量那种的)不能拒绝,一般是用 MQ 来做流量平缓,不属于这个流量控制的范围。

流量控制三种策略:

漏桶策略:

就是一个请求缓冲区,请求流量来的时候先进入缓冲区,这个缓冲区是有限制的,满了之后会拒绝所有请求。缓冲区会定时向服务发送内部缓冲的请求,就像一个一滴一滴漏水的桶一样,所以也叫漏桶策略。漏桶策略是一个强制流量平缓控制的策略,无论外部流量如何变化,内部都是岁月静好,这种方案简单但是灵活性太低,并且对资源的利用率也不高。

令牌桶策略;

这个策略和漏桶策略的名字很像,但是思想完全相反,漏桶是对请求流量缓冲,但是令牌桶正好相反,是对服务器的空闲资源缓冲。服务端会定时向令牌桶中发放令牌,令牌桶有上限,上线基本为服务器能处理请求的极限。每当有新请求时,需要在令牌桶中拿到令牌才能被服务。令牌桶的一个好处就是缓存处理能力,如果一段时间服务器很空闲,则会缓存大量的空闲令牌,在遇到高峰流量的时候能迅速处理,更高效利用服务器的资源。

题外话:Sentinel-阿里开源流量控制框架,里面有三种流量控制策略,分别是令牌桶式的线程池控制,可变速令牌桶式的 QPS 控制,漏桶策略的匀速排队控制。

故障隔离与恢复

感觉这个不是很好说,隔离比如说进程间隔离,线程间隔离,资源间隔离,然后隔离之后仔通信之类的。

故障检测用的最多的还是心跳检测,或者复杂一点的心跳检测,大差不差来着。

恢复有主备恢复,存在脑裂问题,有的恢复是 AP 的,主要为了快速响应,有的恢复是 CP 的,对一致性有非常高的要求。

以后有时间单独水一篇


X:: 分布式协同又将会是另一个专题的内容咯,挖坑挖坑