第二章 Sentine 核心工作原理

源码分析

// 1. 为调用链入口创建Context + 为调用链入口创建入口节点(EntranceNode实例)
ContextUtil.enter("myEntrance", "myOrigin-main");
Entry entry = null;
try {
    // 2. 包装资源为ResourceWrapper + 为资源创建执行链(ProcessorSlotChain)
    // + 为资源创建CtEntry并赋值给当前调用链Context.curEntry + 执行ProcessorSlotChain实现统计限流等逻辑
    entry = SphU.entry("myResource1");
    System.out.println(new HelloSentinel().sayHello("baby"));
} catch (BlockException ex) {
    // 资源访问阻止,被限流或被降级
    System.out.println("blocked");
} catch (Exception ex) {
    // 若需要配置降级规则,需要通过这种方式记录业务异常
    Tracer.traceEntry(ex, entry);
    System.out.println("Tracer.traceEntry");
} finally {
    if (entry != null) {
        // 3. 调用ProcessorSlotChain.exit
        entry.exit();
    }
    // 4. 清除上下文
    ContextUtil.exit();
}
1.为调用链入口创建Context + 为调用链入口创建入口节点(EntranceNode实例)
ContextUtil.enter(String name, String origin)
--> 从上下文获取Context,若有,直接返回,若无,进行创建
// 包装资源 + 为调用链创建入口节点EntranceNode
--> EntranceNode node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null)
// 将入口节点加入根节点ROOT的childList中
--> Constants.ROOT.addChild(node)
// 创建Context,设置到上下文中
--> Context context = new Context(node, name)  context.setOrigin(origin)
--> ThreadLocal contextHolder.set(context)

2.包装资源为ResourceWrapper + 为资源创建执行链(ProcessorSlotChain)+ 执行ProcessorSlotChain实现统计限流等逻辑
SphU.entry
--> Sph.entry(String name, EntryType type, int batchCount, Object... args)
  --> CtSph.entryWithPriority(new StringResourceWrapper(name, type), int count, boolean prioritized, Object... args)
    // 先从Map找是否资源对应的chain(每个资源对应一个chain),没有则创建
    --> ProcessorSlot chain = lookProcessChain(resourceWrapper)
      --> new DefaultProcessorSlotChain() + SPI 加载 Slot + 组装成链
    --> Entry e = new CtEntry(resourceWrapper, chain, context)
    // 依次执行调用链中的slot
    --> chain.entry(context, resourceWrapper, null, count, prioritized, args)

3.调用ProcessorSlotChain.exit
entry.exit()
--> chain.exit(context, resourceWrapper, count, args)
--> 执行默认调用链上下文(如果不自己指定调用链入口上下文,则会自动创建)自动清除逻辑

4.清除当前调用链的上下文(如果不自己指定调用链入口上下文,则会在 entry.exit() 自动清除)
ContextUtil.exit()
 
 

可以看出,实际上 sentinel 的核心原理就是:为每个资源创建一条链,链上包含一系列的 slot,这些 slot 分两部分,前一部分 slot 用于做各种统计,后一部分 slot 基于前一部分 slot 的统计结果,做出相应的流控逻辑

slot 链

image.png
图片来自sentinel官网
从上图可以看出 slot 链中的 slot 分两部分,前一部分 slot 用于做各种统计,后一部分 slot 基于前一部分 slot 的统计结果,做出相应的流控逻辑。

数据统计

sentinel 的数据统计是基于 Node 结构来做,首先看下四种 Node 的类结构。

image.png

Node 相关介绍在 官网 有详细介绍。之后,来看下整个slot链中的三个数据统计相关的slot:NodeSelectorSlot/ClusterBuilderSlot/StatisticSlot 的核心作用以及滑动时间窗口(窗口可循环复用)的核心设计。
image.png

限流

image.png

在 FlowSlot 执行限流逻辑时,会根据来源(limitApp)和流控模式(strategy)选择相关的统计 Node 节点,之后再使用该 Node 节点 + 流控效果(controlBehavior)+ 限流阈值类型(QPS/并发线程数)执行限流操作。限流设计如下,后续的流控设计基本类似。


image.png

Entry 类型

image.png

以下内容摘抄自 sentinel官网

针对来源限流

限流规则中的 limitApp 字段用于根据调用方进行流量控制。该字段的值有以下三种选项,分别对应不同的场景:

  • default:表示不区分调用者,来自任何调用者的请求都将进行限流统计。如果这个资源名的调用总和超过了这条规则定义的阈值,则触发限流。
  • {some_origin_name}:表示针对特定的调用者,只有来自这个调用者的请求才会进行流量控制。例如 NodeA 配置了一条针对调用者 caller1 的规则,那么当且仅当来自 caller1 对 NodeA 的请求才会触发流量控制。
  • other:表示针对除 {some_origin_name} 以外的其余调用方的流量进行流量控制。例如,资源NodeA配置了一条针对调用者 caller1 的限流规则,同时又配置了一条调用者为 other 的规则,那么任意来自非 caller1 对 NodeA 的调用,都不能超过 other 这条规则定义的阈值。
    同一个资源名可以配置多条规则,规则的生效顺序为:{some_origin_name} > other > default

举个使用案例,假设调用关系如下:被调用方的入口 IN 流控设置了200QPS(不区分调用来源),调用来源1和调用来源2均没有设置流控规则,那么假设调用来源1是一个集聚流量,马上打满了200qps,那么整个调用来源2就无法请求成功了。为了避免这种情况,可以使用 {some_origin_name} 为调用来源1配置一条规则,然后再设置一条 other 规则为其他调用源进行设置,而二者相加的阈值就是被调方的入口接口阈值。


image.png

流控模式

  • 直接限流:直接对配置的资源进行限流。

  • 关联限流:当两个资源之间具有资源争抢或者依赖关系的时候,这两个资源便具有了关联。比如对数据库同一个字段的读操作和写操作存在争抢,读的速度过高会影响写得速度,写的速度过高会影响读的速度。如果放任读写操作争抢资源,则争抢本身带来的开销会降低整体的吞吐量。可使用关联限流来避免具有关联关系的资源之间过度的争抢,举例来说,read_db 和 write_db 这两个资源分别代表数据库读写,我们可以给 read_db 设置限流规则来达到写优先的目的:设置 FlowRule.strategy 为 RuleConstant.RELATE 同时设置 FlowRule.ref_identity 为 write_db。这样当写库操作过于频繁时,读数据的请求会被限流。


    image.png
  • 链路限流:假设来自入口 Entrance1 和 Entrance2 的请求都调用到了资源 NodeA,Sentinel 允许只根据某个入口的统计信息对资源限流。比如我们可以设置 FlowRule.strategy 为 RuleConstant.CHAIN,同时设置 FlowRule.ref_identity 为 Entrance1 来表示只有从入口 Entrance1 的调用才会记录到 NodeA 的限流统计当中,而对来自 Entrance2 的调用漠不关心。调用链的入口是通过 API 方法 ContextUtil.enter(name) 定义的。假如当前是从 Entrance2 访问进来的,那么不做限流操作;假如当前访问是从 Entrance1 进来的,那么就要进行限流操作了

    image.png

流控效果

当 QPS 超过某个阈值的时候,则采取措施进行流量控制。流量控制的手段包括下面 3 种,对应 FlowRule 中的 controlBehavior 字段:

  • 直接拒绝(RuleConstant.CONTROL_BEHAVIOR_DEFAULT)方式(计数器限流)。该方式是默认的流量控制方式,当QPS超过任意规则的阈值后,新的请求就会被立即拒绝,拒绝方式为抛出FlowException。这种方式适用于对系统处理能力确切已知的情况下,比如通过压测确定了系统的准确水位时。实际上,sentinel 还提供了一种“预占用”的机制,对于重要的访问,直接拒绝不一定合适,而这个访问又可以进行一定的等待后再返回,此时,如果当前 WindowWrapper 中的数据满了,可以占用下一个 WindowWrapper 中的名额(即当前访问线程sleep一定时间,到下一个WindowWrapper后,再进行业务逻辑处理)

  • 冷启动(RuleConstant.CONTROL_BEHAVIOR_WARM_UP)方式。该方式主要用于系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过"冷启动",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮的情况。冷启动的核心原理(令牌桶算法):核心就是控制每个时间窗口可以给出的令牌数(eg. 可以根据预热时长和QPS阈值来设定每个 WindowWrapper 的令牌数)。当 WindowWrapper 的QPS达到限流阈值时,触发冷启动,慢慢的pass更多请求,当pass的请求达到限流阈值时,冷启动结束,开始使用直接限流方式。

    image.png

  • 匀速器(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER)方式。这种方式严格控制了请求通过的间隔时间,也即是让请求以均匀的速度通过,对应的是漏桶算法。这种方式主要用于处理间隔性突发的流量,例如消息队列。想象一下这样的场景,在某一秒有大量的请求到来,而接下来的几秒则处于空闲状态,我们希望系统能够在接下来的空闲期间逐渐处理这些请求,而不是在第一秒直接拒绝多余的请求。匀速等待的核心原理(漏桶算法):根据QPS计算通过每两个请求的时间间隔(eg. QPS200,则每5ms通过一个请求,这个就是漏桶的固定速率)costTime,与上一个请求通过的时间 latestPassedTime(请求通过需要记录该值)相加可以计算出期待通过的时间,如果小于当前时间,直接通过。否则计算需要等待的时间,并且与配置的超时时间做比较,如果大于超时时间,直接拒绝;如果小于,则sleep一段时间(需等待时间)后,返回直接业务逻辑。(当然,实际上在并发中,latestPassedTime会被并发修改,所以需要考虑并发的问题,核心处理方式如下)

long oldTime = latestPassedTime.addAndGet(costTime);
waitTime = oldTime - TimeUtil.currentTimeMillis();
// 可能被并发修改,将latestPassedTime值恢复到从前,本次请求直接拒绝
if (waitTime > maxQueueingTimeMs) {
    latestPassedTime.addAndGet(-costTime);
    return false;
}
// in race condition waitTime may <= 0
if (waitTime > 0) {
    Thread.sleep(waitTime);
}
return true;
image.png

熔断降级

image.png

在1000ms内,至少有100个请求,且有(100*0.1)个请求的RT达到300ms,则进行熔断。等待2s后,开始尝试通过请求(半开),如果ok,则关闭熔断,恢复业务请求;如果不OK,则继续熔断,等待下一次的半开周期。
在 sentinel 中,熔断从半开到关闭的逻辑是在 DegradeSlot.exit 中做的(此时,业务逻辑执行完成了,可以根据其结果来处理熔断开关的状态)。
在 sentinel 中,熔断降级是通过熔断器机制来做的,慢调用熔断器和异常熔断器都有自己的滑动时间窗口(LeapArray)来进行熔断指标的单独统计。
对于异常熔断来讲,其仅关注业务异常,我们需要在程序中显示调用 Tracer.traceEntry(ex, entry) 来统计异常。

异常熔断器
public void onRequestComplete(Context context) {
    Entry entry = context.getCurEntry();
    if (entry == null) {
        return;
    }
    // 通过Tracer.traceEntry(ex, entry)写入到Entry.error中
    Throwable error = entry.getError();
    SimpleErrorCounter counter = stat.currentWindow().value();
    if (error != null) {
        counter.getErrorCount().add(1);
    }
    counter.getTotalCount().add(1);

    handleStateChangeWhenThresholdExceeded(error);
}

与 Hystrix 的对比,摘抄自 官网。
Hystrix 通过 线程池隔离 的方式,来对依赖(在 Sentinel 的概念中对应资源)进行了隔离。这样做的好处是资源和资源之间做到了最彻底的隔离。缺点是除了增加了线程切换的成本(过多的线程池导致线程数目过多),当业务调用资源时,需要将自身线程切换到给资源分配的线程池中的线程,还需要预先给各个资源做线程池大小的分配。(实际上,Hystrix 也支持信号量隔离,Hystrix 官方推荐线程池隔离方式)

Sentinel 对这个问题采取了两种手段:

  • 通过并发线程数进行限制
    和资源池隔离的方法不同,Sentinel 通过限制资源并发线程的数量,来减少不稳定资源对其它资源的影响。这样不但没有线程切换的损耗,也不需要您预先分配线程池的大小。当某个资源出现不稳定的情况下,例如响应时间变长,对资源的直接影响就是会造成线程数的逐步堆积。当线程数在特定资源上堆积到一定的数量之后,对该资源的新请求就会被拒绝。堆积的线程完成任务后才开始继续接收请求。
  • 通过响应时间对资源进行降级
    除了对并发线程数进行控制以外,Sentinel 还可以通过响应时间来快速降级不稳定的资源。当依赖的资源出现响应时间过长后,所有对该资源的访问都会被直接拒绝,直到过了指定的时间窗口之后才重新恢复。

授权流控

image.png

通过看程序传入的 Context.origin 是否在配置的流控应用(limitApp)中,再根据授权类型(白名单/黑名单)来判断是否可以需要流控。这里可以根据想要控制的目标来灵活的设计 origin。

自适应限流(系统保护规则)

image.png

以下内容摘抄自 官网。
系统保护规则是从 应用级别的入口流量 进行控制,从单台机器的总体 Load1(1min内的 load 值)、CPU利用率、RT、入口 QPS 和线程数五个维度监控应用数据,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。

系统保护规则是应用整体维度的,而不是资源维度的,并且仅对入口流量生效。入口流量指的是进入应用的流量(EntryType.IN),比如 Web 服务或 Dubbo 服务端接收的请求,都属于入口流量。sentinel 提供了一个全局的IN流量的统计节点ClusterNode(total_inbound_traffic),在 StatisticSlot 统计信息时,会将IN流量数据统计入其中。

阈值类型

  • Load(仅对 Linux/Unix-like 机器生效):当系统 load1 超过阈值,且 系统当前的并发线程数超过系统容量时(BBR) 才会触发系统保护。系统容量由系统的 maxQps * minRt 计算得出。
  • CPU usage:当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0)。
  • RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。
  • 线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。
  • 入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。

Load1 限流与 BBR 拥塞控制算法

为什么不用 Load1 的阈值直接限流?

  • load 是一个结果,如果根据 load 的情况来调节流量的通过率,那么就始终有延迟性。也就意味着通过率的任何调整,都会过一段时间才能看到效果。如果当前设置的阈值是使 load 恶化的一个动作,那么也至少要过 1 秒之后才能观测到(定时任务每秒获取一次 load1 和 cpu 使用率);同理,如果当前阈值调整是让 load 好转的一个动作,也需要 1 秒之后才能继续调整,这样就浪费了系统的处理能力。所以我们看到的曲线,总是会有抖动。
  • load1 恢复慢。想象一下这样的一个场景,出现了这样一个问题,下游应用不可靠,导致应用 RT 很高,从而 load 到了一个很高的点。过了一段时间之后下游应用恢复了,应用 RT 也相应减少。这个时候,其实应该大幅度增大流量的通过率;但是由于这个时候 load 仍然很高,通过率的恢复仍然不高。

解决方案:BBR


image.png

我们把系统处理请求的过程想象为一个水管,到来的请求是往这个水管灌水,当系统处理顺畅的时候,请求不需要排队,直接从水管中穿过,这个请求的RT是最短的;反之,当请求堆积的时候,那么处理请求的时间则会变为:排队时间 + 最短处理时间。

推论一: 如果我们能够保证水管里的水量,能够让水顺畅的流动,则不会增加排队的请求;也就是说,这个时候的系统负载不会进一步恶化。
我们用T 来表示(水管内部的水量),用RT来表示请求的处理时间,用P来表示进来的请求数,那么一个请求从进入水管道到从水管出来,这个水管会存在P * RT个请求。换一句话来说,当 T ≈ QPS * Avg(RT) 的时候,我们可以认为系统的处理能力和允许进入的请求个数达到了平衡,系统的负载不会进一步恶化。

接下来的问题是,水管的水位是可以达到了一个平衡点,但是这个平衡点只能保证水管的水位不再继续增高,但是还面临一个问题,就是在达到平衡点之前,这个水管里已经堆积了多少水。如果之前水管的水已经在一个量级了,那么这个时候系统允许通过的水量可能只能缓慢通过,RT会大,之前堆积在水管里的水会滞留;反之,如果之前的水管水位偏低,那么又会浪费了系统的处理能力。

推论二: 当保持入口的流量是水管出来的流量的最大的值的时候,可以最大利用水管的处理能力。
然而,和 TCP BBR 的不一样的地方在于,还需要用一个系统负载的值(load1)来激发这套机制启动。

你可能感兴趣的:(第二章 Sentine 核心工作原理)