分布式与架构

微服务

服务发现

基本流程

  1. 通过appid(服务名)和Addr(IP:Port)定位实例

  2. Provider 注册后定期(30s)心跳一次,注册, 心跳,下线都需要进行同步,注册和下线需要进行长轮询推送

  3. Consumer 启动时拉取实例,发起30s长轮询

  4. Server 定期(60s) 检测失效(90s)的实例,失效则剔除。短时间里丢失了大量的心跳连接(15分钟内心跳低于期望值*85%),开启自我保护,保留服务不过期。

重要步骤

1.心跳的复制(Peer to Peer)检查数据一致性(网络抖动或分区导致数据不一致):

注册时根据当前时间生成dirtyTime, A向B同步实例心跳时:

返回-404 表示 (1)B中不存在实例 (2)B中dirtyTime较小 A携带自身dirtyTime向B发起注册请求,把最新信息同步过去。

返回-409 表示 (1)B中dirtyTime较大 不同意采纳A的信息而是将自身实例的信息放在消息体里返回。

2.网络分区时的自我保护

当网络分区发生时,每个Discovery节点,会持续的对外提供服务,接收该分区下新实例的服务注册和发现。

短时间丢失大量心跳,进入自我保护,保证健康的实例不被剔除,同时保留”好数据“与”坏数据“

自我保护:每分钟的心跳数小于阈值(实例数量*2*85%),每15分钟重置心跳阈值, 在自我保护状态下, 不对任何实例进行剔除

非自我保护下,随机分批逐次剔除,尽量避免单个应用被全部过期。

3.客户端

长轮询+服务端推送变化,让服务发现的实时性更高

订阅式客户端,推送客户端关注的实例变化

Eureka客户端缓存,即便所有的都失效,依旧可以调用服务

多注册中心

1.同一个机房内部,discovery节点通过广播进行注册信息同步。

2.跨机房注册信息同步时,discovery节点会将信息推送到其他的机房slb,再由slb通过负载均衡把信息推送到机房内部节点。机房内部在进行广播同步。

  • 注意: 跨机房同步时,注册信息为单向同步,slb收到信息后LB到机房内部节点,内部节点只在内部广播,不会推送到其他slb。

系统架构

架构图
image-20220917165555068

多注册中心架构图

image-20220917165617494

熔断模式的介绍

熔断模式

该模式借鉴了电路熔断的理念,如果一条线路电压过高,保险丝会熔断,防止火灾。

如果某个目标服务调用慢或者有大量超时,此时,熔断该服务的调用,对于后续调用请求,不在继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用。

还是之前的银行柜员的例子,假定处理每个业务的时间是5分钟,当某个柜员的处理速度降低了,超过了5分钟,或者干脆去吃午饭等等原因根本不在座位上,此时熔断机制将不会允许再有客户到其窗口前排队(如果有排队的也无法正常办理业务,只能造成阻塞的发生)。

下面来看看Hystrix是如何熔断的。

熔断器:Circuit Breaker

熔断器是位于线程池之前的组件。
用户请求某一服务之后,Hystrix会先经过熔断器,此时如果熔断器的状态是打开(跳起),则说明已经熔断,这时将直接进行降级处理,不会继续将请求发到线程池。

套用银行柜员的例子,柜员相当于服务,窗口前排队的是线程池,大堂经理则可以看成是熔断器。通常的流程是:客户进门,告诉大堂经理要办什么业务,这时他会判断客户请求的窗口是否在正常处理业务,如果正常,他就会让客户到该窗口排队(也就是进入了线程池),如果不正常,他根本不会让客户去排队。

熔断器相当于在线程池之前的一层屏障。

下面来看一下熔断器的工作原理

image-20211221173154849

每个熔断器默认维护10个bucket
每秒创建一个bucket
每个blucket记录成功,失败,超时,拒绝的次数
当有新的bucket被创建时,最旧的bucket会被抛弃

熔断算法

判断是否进行熔断的依据是:
根据bucket中记录的次数,计算错误率。
当错误率超过预设的值(默认是50%)且10秒内超过20个请求,则开启熔断。

1
max(0, (requests - K*accepts) / (requests + 1))
image-20220829175951270

熔断恢复

对于被熔断的请求,并不是永久被切断,而是被暂停一段时间(默认是5s)之后,允许部分请求通过,若请求都是健康的(RT<250ms)则对请求健康恢复(取消熔断),如果不是健康的,则继续熔断。

服务调用的各种结果(成功,异常,超时,拒绝),都会上报给熔断器,计入bucket参与计算。

kratos熔断支持

目前kratos支持熔断的能力,用户可以自定义熔断器,目前框架中集成的熔断有http熔断,grpc熔断,db熔断这3种,熔断在默认情况下是开启的,目前不能选择关闭熔断,但是可以通过参数调整起到关闭熔断的效果

熔断在不配置的情况下,默认参数如下

1
2
3
4
5
6
7
SwitchOff = true  //开启熔断的开关,此开关目前是无效的,也就是设置任何值,熔断器都是开启的
window ="3s" //统计成功数和失败数的滑动窗口周期,默认为3s
Sleep ="500ms" //本意为熔断之后重新探测下游是否恢复的时间,由于Kratos并没有半开状态,所以该值也无效
Bucket = 10 //分桶的数量,该参数跟window结合决定了滑动窗口能有多平滑,window=3s,Bucket=10,那么每个桶能统计到的时间范围为300ms
Ratio = 0.5 //本意为失败率,由于目前Kratos并没有使用这个参数作为熔断条件的判断,暂时无效
Request = 100 //最低请求数量,在窗口周期内,请求数如果小于该数,则不会触发熔断,在大于该数值时,才会去判断失败率来决定是否需要熔断
K = 1.5 //熔断条件,K值代表的是成功数*K > 总请求数时(注: 成功率[success/total] < 1/k时触发熔断),才不会触发熔断,如果success*K < 总请求数,则success*K的请求不会熔断,total-success*K的请求会被熔断

kratos熔断机制

  1. kratos的熔断在http请求,rpc请求,db请求下强制为开启状态,如果想要关闭,可以配置熔断参数K为一个很大的值,但是不建议去关闭熔断器,本身也不应该通过该参数控制(因为SwitchOff参数无效)
  2. 2.kratos的熔断默认配置如上面描述所示,如果需要调整参数,请参考kratos熔断配置进行调整
  3. kratos的熔断不会一刀切,也没有半开状态(或者说可以说只有关闭和半开两个状态)

不会熔断的请求数为周期内已统计的成功数K,其他的请求会被熔断,当前请求会不会熔断是基于概率计算的,熔断率为(total-successK)/(total+1) (注: 请求放行率约等于k*success/total,成功率越低,放行率越低)

服务限流

限流算法的整理

在资源有限的情况下,遇到突发流量(如双十一)或系统 RT 剧增,为了保证系统不被拖垮引起更大规模的雪崩,必须进行限流。也就是说限流是系统的自我保护。限流本质上是根据系统处理能力,限制单位时间内处理的请求数量。

滑动窗口

这里的窗口是一个时间窗口,比如把一分钟划分为 6 个窗口,则每个窗口的时间范围是 10 秒。通过移动窗口,统计窗口期内流量,可以实现窗口期的限流。但是滑动窗口存在精度问题,在精度范围内统计到的窗口期流量可能在限流范围内,但进一步细分就会看到仍有突增流量。

滑动窗口时间范围越小,就越平滑,限流的效果越精确,但时间细分的粒度受到客观限制。

image-20211221142426805

漏桶算法

漏桶算法,英文名 Leaky Bucket,是网络中流量整形(Traffic Shaping)和速率限制(Rate Limiting)常用的一种算法。漏桶算法调控了访问流量,使得突发流量可以被整形、去毛刺,为系统提供稳定的访问流量。如果漏桶溢出,请求就会被丢弃。

image-20211221142633064

桶以固定的速率流出流量,无论水龙头进入的流量是多少,都不改变流出速率。上图中,水龙头处存在突发流量,一共进入 30Mb 数据,分布不均匀,对系统有冲击。经过漏桶算法处理,漏桶以 3 Mbps 速率持续流出数据,为系统做了很好的缓冲。

漏桶算法中,如果桶未满,可以持续接收流量;如果桶已满,流量溢出,后续的流量将无法入桶,会被丢弃。漏桶算法限制的是流出速率,无论流入是多少,都能保证后续系统的请求是平稳的。后续系统感知的流量相对固定,但可能在系统仍有能力处理更多流量的时候,也会被漏桶限制住。

令牌桶算法

令牌桶算法,英文名 token bucket,也是网络中流量整形或速率限制常用的一种算法。令牌桶算法以一个恒定的速率向桶里放入令牌,如果有新的请求进来希望进行处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。令牌桶算法限制的是流入速率,允许一定规模的突增流量,最大速率受限于桶的容量和令牌生成速率。可以支持一定程度的突发流量,更适合具有突发特性的流量。

image-20211221143113566

令牌桶算法的简单描述如下:

  • 每隔 1/r 秒向桶中增加一个 token;

  • 桶最多只能存放 b 个 token。如果放置 token 时桶已经满了,丢弃这个 token;

  • 当一个包含 n 个字节的数据包进来的时候,

    • 如果桶中有足够的 token,将从桶中移除 n 个 token,然后把这个数据包发送出去;
    • 如果桶中没有足够的 token,不会移除任何 token,返回拒绝服务;

自适应限流

使用自适应阈值可以动态调整限流,通过运行中不断试探,最终可以在系统处理能力和限流之间找到一个动态平衡。既能最大限度利用系统处理能力,又能确保系统稳定性。目前比较简单的自定义算法是参考 TCP 拥塞算法的斜率算法。斜率算法设置一个基准值,可以结合应用的 Load、CPU 使用率、总体平均 RT、入口 QPS 和并发线程数等几个维度的监控指标评估系统负载,负载高了降低限流阈值,负载低了提高限流阈值。

目前比较简单的方案是使用平均 rt (响应时间)计算斜率,公式为

gradient =(RTTnoload / RTTactual)

newLimit = currentLimit×gradient + queueSize

其中,RTTnoload 表示无负载时的 rt,RTTactual 表示当前实际 rt。gradient 小于 1 表示负载偏高,大于 1 表示负载偏低。经过一段时间的试探,就能把系统流量控制在合理范围。

image-20211221143314919

B站的自适应限流

kratos 借鉴了 Sentinel 项目的自适应限流系统,通过综合分析服务的 cpu 使用率、请求成功的 qps 和请求成功的 rt 来做自适应限流保护。

####核心目标

  • 自动嗅探负载和 qps,减少人工配置
  • 削顶,保证超载时系统不被拖垮,并能以高水位 qps 继续运行

#####限流规则

吞吐量介绍

image-20211221151344842

计算吞吐量:利特尔法则 L = λ * W

如上图所示,如果我们开一个小店,平均每分钟进店 2 个客人(λ),每位客人从等待到完成交易需要 4 分钟(W),那我们店里能承载的客人数量就是 2 * 4 = 8 个人

同理,我们可以将 λ 当做 QPS, W 呢是每个请求需要花费的时间,那我们的系统的吞吐就是 L = λ * W ,所以我们可以使用利特尔法则来计算系统的吞吐量。

指标介绍
指标名称 指标含义
cpu 最近 1s 的 CPU 使用率均值,使用滑动平均计算,采样周期是 250ms
inflight 当前处理中正在处理的请求数量
pass 请求处理成功的量
rt 请求成功的响应耗时
滑动窗口

在自适应限流保护中,采集到的指标的时效性非常强,系统只需要采集最近一小段时间内的 qps、rt 即可,对于较老的数据,会自动丢弃。为了实现这个效果,kratos 使用了滑动窗口来保存采样数据。

image-20211221141117026

如上图,展示了一个具有两个桶(bucket)的滑动窗口(rolling window)。整个滑动窗口用来保存最近 1s 的采样数据,每个小的桶用来保存 500ms 的采样数据。
当时间流动之后,过期的桶会自动被新桶的数据覆盖掉,在图中,在 1000-1500ms 时,bucket 1 的数据因为过期而被丢弃,之后 bucket 3 的数据填到了窗口的头部。

限流公式

判断是否丢弃当前请求的算法如下:

1
cpu > 800 AND (Now - PrevDrop) < 1s AND (MaxPass * MinRt * windows / 1000) < InFlight

MaxPass 表示最近 5s 内,单个采样窗口中最大的请求数。
MinRt 表示最近 5s 内,单个采样窗口中最小的平均响应时间。
windows 表示一秒内采样窗口的数量,默认配置中是 5s 50 个采样,那么 windows 的值为 10。

算法

image-20211221151455612

请求对列优化
image-20211221174643029

除了自适应限流,我们还做了 Codel 队列,传统的队列都是先进先出,但是我们发现微服务可能不太适合这种做法,这是因为微服务会有超时,肯定不可能无限期的等下去,可能你的 SLP 已经设置了 800 毫秒的超时,如果这时放行的是一个老的请求,该请求的成功率就会变低,因为它可能已经排队了好长时间。

所以这时我们需要一个基于处理时间丢弃的队列,当系统处于高负载的时候,实行后进先出的策略,也就是说要主动丢弃排队久的请求,并让新的请求直接通过,利用这个队列来弥补之前算法中的缓冲问题,吸收突增的流量。

方案

接口幂等性,有哪些方案?

Select 查询天然支持幂等,而写操作 多次执行可能会导致数据错误,下面简单列举常见的解决方案:

  • 表中加唯一约束, 有些表不适合添加唯一约束,可以单独建一张防重表,在防重表插入成功,在操作其它业务表
  • 加悲观锁 , select * from order where order_id= 100000 for update; 对操作行锁定。
  • 加乐观锁,表中增加一个attribute_cc自增字段,借助CAS机制控制并发
  • 采用 分布式锁,第一次请求可以成功加锁,后续请求加锁失败,认为是重复请求。
  • token机制,client 首先请求获取token,提交时除了业务参数外还要带上这个token,server端会对这个token核销,只能核销一次。如果 server 查询不到token,则认为是重复请求。

分布式服务接口的幂等性如何设计?

所谓幂等性,就是说一个接口,多次发起同一个请求,你这个接口得保证结果是准确得。比如不能多扣款。不能多插入一条数据,不能将统计值多加了 1,这就是幂等性。

其实保证幂等性主要是三点:

  • 对于每个请求必须有一个唯一的标识,举个例子:订单支付请求,肯定得包含订单 ID,一个订单 ID 最多支付一次。
  • 每次处理完请求之后,必须有一个记录标识这个请求处理过了,比如说常见得方案是再 MySQL 中记录个状态啥得,比如支付之前记录一条这个订单得支付流水,而且支付流水采用 order id 作为唯一键(unique key)。只有成功插入这个支付流水,才可以执行实际得支付扣款
  • 每次接收请求需要进行判断之前是否处理过得逻辑处理,比如说,如果有一个订单已经支付了,就已经有了一条支付流水,那么如果重复发送这个请求,则此时先插入支付流水,order id 已经存在了,唯一键约束生效,报错插入不进去得。然后你就不用再扣款了

架构

一个基础的rpc框架包含哪些部分

  • 客户端和服务端建立网络连接模块( server模块、client模块 )
  • 服务端处理请求模块
  • 传输协议模块
  • 序列化反序列模块。
  • 服务发现

设计一个RPC框架,可以从PRC包含的几个模块去考虑,对每一个模块分别进行设计。

  • 客户端服务端如何建立网络连接

  • 服务端如何处理请求

  • 数据传输采用什么协议

  • 数据该如何序列化反序列化

  • 服务发现采用什么方式

  • 如何设计一个rpc 框架

    1. 注册中心:首先服务就得去[注册中心]注册吧,你是不是得有个注册中心,保留各个服务的信息,可以用zookeeper来做吧
    2. 客户端服务发现:然后你的消费者需要去注册中心拿对应的服务信息吧,而且每个服务可能会存在于多台机器上
    3. 动态代理:接着你就该发起一次请求了,怎么发起请求?懵逼了吧,当然是基于动态代理了! 你面向接口获取到一个动态代理,这个动态代理就是接口在本地的一个代理,然后这个代理会找到服务对应的机器地址
    4. 负载均衡算法:然后找哪个机器发送请求?那肯定得有个负载均衡算法了,比如最简单的可以随机轮询是不是
    5. io 模型 + 发送协议:找到一台机器后,就可以跟它发送请求了 - 咋发送呢? 你可以说用netty了,nio方式 - 发送什么格式的数据? 你可以说用hessian序列化协议了,或者是别的,对吧。然后请求过去了
    6. 服务端的服务代理: 那边一样的,需要针对你自己的服务生成一个动态代理,监听某个网络端口,然后代理你本地的服务代码。接收到请求的时候,就调用对应的服务代码.
  • grpc 为什么选择http2.0 作为 传输协议

    • HTTP/2 是一个经过实践检验的标准
      HTTP/2是先有实践再有标准,这个很重要。很多不成功的标准都是先有一大堆厂商讨论出标准后有实现,导致混乱而不可用,比如CORBA。HTTP/2的前身是Google的SPDY,没有Google的实践和推动,可能都不会有HTTP/2
    • HTTP/2 天然支持物联网、手机、浏览器
      实际上先用上HTTP/2的也是手机和手机浏览器。移动互联网推动了HTTP/2的发展和普及。
    • 基于HTTP/2 多语言的实现容易
      只讨论协议本身的实现,不考虑序列化。
    • 每个流行的编程语言都会有成熟的HTTP/2 Client
      HTTP/2 Client是经过充分测试,可靠的
      用Client发送HTTP/2请求的难度远低于用socket发送数据包/解析数据包
      HTTP/2支持Stream和流控
      在业界,有很多支持stream的方案,比如基于websocket的,或者rsocket。但是这些方案都不是通用的。
  • 选择http2.0 作为grpc传输协议的缺点

    • rpc的元数据的传输不够高效

      尽管HPAC可以压缩HTTP Header,但是对于rpc来说,确定一个函数调用,可以简化为一个int,只要两端去协商过一次,后面直接查表就可以了,不需要像HPAC那样编码解码。
      可以考虑专门对gRPC做一个优化过的HTTP/2解析器,减少一些通用的处理,感觉可以提升性能。

    • HTTP/2 里一次gRPC调用需要解码两次

      一次是HEADERS frame,一次是DATA frame。

      HTTP/2 标准本身是只有一个TCP连接,但是实际在gRPC里是会有多个TCP连接,使用时需要注意。

领域模型相关

公司层面领域拆分维度

  • 核心领域
  • 通用领域
  • 支撑领域

微服务架构和SOA巨石架构

微服务即是SOA(面向服务的架构)的一种具体实现, 但是微服务的职责更单一、服务即产品、可移植性更好、错误影响面更小;

DDD 的核心思想

让业务架构和系统架构行成绑定的关系

微服务划分原则

  • 按照部门职能
  • 确定模块的边界和紧密程度
  • 按照服务的请求负载
  • 按照故障率和变化率

传统三层模型和DDD领域模型

传统三层模型(贫血模型): 容易导致service层流水账,逻辑冗余在api方法中,难以迁移和维护

DDD: 将数据处理和装载逻辑交给领域模型对象,service只做对领域对象的编排

DDD主要需要考虑的点 1. 对领域的划分 2. 领域模型的装载和逻辑交给领域模型(对领域模型的处理和逻辑都在单独领域内完成) 3. service 层只负责对领域的编排,用以对外提供服务

1
DTO -> DO 需要在领域模型内完成, 数据装载的interface 需要在领域模型内repo中完成, 数据实际获取以及 data -> DO 需要在data层完成

服务端 高并发、高可用可以考虑的点

  • 微服务的拆分, 数据资源、计算资源、运行资源都进行拆分和隔离(根据服务等级和业务范围),;
  • 读多服务, 可以考虑redis缓存、local 缓存、多级缓存、缓存降级等; 写多服务可以考虑优先写kv, 通过消息队列异步更新db和缓存;
  • 数据库层面可以做到分库分表(水平和垂直),主从读写分离;
  • 索引优化, 查询优化;
  • 部署方面可以做到微服务容器的动态伸缩;
  • 服务的限流、熔断、重试、降级等机制,避免对整条调用链路的影响;
  • 多机房主备部署;

常见负载均衡算法

  • 轮询法

  • 加权轮询法

  • 加权随机法

  • 最小连接数法

  • 随机法

  • 源地址哈希法

  • 最闲轮训

  • P2C

    B站负载均衡算法

    参考JSQ(最闲轮训)负载均衡算法带来的问题,缺乏的是服务端全局视图,因此我们目标需要综合考虑:负载+可用性。

    参考了《The power of two choices in randomized load balancing》的思路,我们使用 p2c 算法,随机选取的两个节点进行打分,选择更优的节点:

    • 选择 backend:CPU,client:成功率、inflight(处理中请求)、RT(响应时间) 作为指标,使用一个简单的线性方程进行打分。
    • 对新启动的节点使用常量惩罚值(penalty),以及使用探针方式最小化放量,进行预热;
    • 打分比较低的节点,避免进入“永久黑名单”而无法恢复,使用统计衰减的方式,让节点指标逐渐恢复到初始状态(即默认值)。

reids 和zookeeper 分布式锁的区别

redis 分布式锁

  • 优点:redis本身的读写性能很高,因此基于redis的分布式锁效率比较高
  • 缺点:锁删除失败 过期时间不好控制;非阻塞,操作失败后,需要轮询,占用cpu资源;

zookeeper的分布式锁

  • 优点:不存在redis的超时、数据一致性可以保障,可靠性很高,基于watch + 回调的方式获取锁,实现简单。
  • 缺点:保证了可靠性的同时牺牲了一部分效率(但是依然很高)。性能不如redis。
刘小恺(Kyle) wechat
如有疑问可联系博主