Sentinel底层原理解析(超详细)

1 Sentinel基本概念

这里先介绍一下Sentinel的一些基本概念,引用内容都是主要来自官方文档:
https://github.com/alibaba/Sentinel/wiki

1.1 资源

资源是 Sentinel 的关键概念。它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它应用提供的服务,甚至可以是一段代码。在接下来的文档中,我们都会用资源来描述代码块。只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。

1.2 规则

围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。

1.3 流量控制

流量控制有以下几个角度:
资源的调用关系,例如资源的调用链路,资源和资源之间的关系;
运行指标,例如 QPS、线程池、系统负载等;
控制的效果,例如直接限流、冷启动、排队等。
Sentinel 的设计理念是让您自由选择控制的角度,并进行灵活组合,从而达到想要的效果。

1.4 熔断降级

1.4.1 什么是熔断降级

除了流量控制以外,降低调用链路中的不稳定资源也是 Sentinel 的使命之一。由于调用关系的复杂性,如果调用链路中的某个资源出现了不稳定,最终会导致请求发生堆积。这个问题和 Hystrix 里面描述的问题是一样的。Sentinel 和 Hystrix 的原则是一致的: 当调用链路中某个资源出现不稳定,例如,表现为 timeout,异常比例升高的时候,则对这个资源的调用进行限制,并让请求快速失败,避免影响到其它的资源,最终产生雪崩的效果。

1.4.2 熔断降级设计理念

在限制的手段上,Sentinel 和 Hystrix 采取了完全不一样的方法。
Hystrix 通过线程池的方式,来对依赖(在我们的概念中对应资源)进行了隔离。这样做的好处是资源和资源之间做到了最彻底的隔离。缺点是除了增加了线程切换的成本,还需要预先给各个资源做线程池大小的分配。

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

1.通过并发线程数进行限制

和资源池隔离的方法不同,Sentinel 通过限制资源并发线程的数量,来减少不稳定资源对其它资源的影响。这样不但没有线程切换的损耗,也不需要您预先分配线程池的大小。当某个资源出现不稳定的情况下,例如响应时间变长,对资源的直接影响就是会造成线程数的逐步堆积。当线程数在特定资源上堆积到一定的数量之后,对该资源的新请求就会被拒绝。堆积的线程完成任务后才开始继续接收请求。

2.通过响应时间对资源进行降级

除了对并发线程数进行控制以外,Sentinel 还可以通过响应时间来快速降级不稳定的资源。当依赖的资源出现响应时间过长后,所有对该资源的访问都会被直接拒绝,直到过了指定的时间窗口之后才重新恢复。

系统负载保护

Sentinel 同时对系统的维度提供保护。防止雪崩,是系统防护中重要的一环。当系统负载较高的时候,如果还持续让请求进入,可能会导致系统崩溃,无法响应。在集群环境下,网络负载均衡会把本应这台机器承载的流量转发到其它的机器上去。如果这个时候其它的机器也处在一个边缘状态的时候,这个增加的流量就会导致这台机器也崩溃,最后导致整个集群不可用。针对这个情况,Sentinel 提供了对应的保护机制,让系统的入口流量和系统的负载达到一个平衡,保证系统在能力范围之内处理最多的请求。

1.5 Sentinel 是如何工作的

Sentinel 的主要工作机制如下:
对主流框架提供适配或者显示的 API,来定义需要保护的资源,并提供设施对资源进行实时统计和调用链路分析。根据预设的规则,结合对资源的实时统计信息,对流量进行控制。同时,Sentinel 提供开放的接口,方便您定义及改变规则。Sentinel 提供实时的监控系统,方便您快速了解目前系统的状态。

2 Sentinel底层原理解析(版本1.8.6)

官网原理介绍:
https://github.com/alibaba/Sentinel/wiki/Sentinel%E5%B7%A5%E4%BD%9C%E4%B8%BB%E6%B5%81%E7%A8%8B

2.1 SlotChain和chainMap

以官方给出的代码为例子:

public static void main(String[] args) {
        initFlowRules();
        while (true) {

            Entry entry = null;
            try {
                entry = SphU.entry("HelloWorld");
                /*您的业务逻辑 - 开始*/
                System.out.println("hello world");
                /*您的业务逻辑 - 结束*/
            } catch (BlockException e1) {
                /*流控逻辑处理 - 开始*/
                System.out.println("block!");
                /*流控逻辑处理 - 结束*/
            } finally {
                if (entry != null) {
                    entry.exit();
                }
            }
        }
    }


private static void initFlowRules(){
    List<FlowRule> rules = new ArrayList<>();
    FlowRule rule = new FlowRule();
    rule.setResource("HelloWorld");
    rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
    // Set limit QPS to 20.
    rule.setCount(20);
    rules.add(rule);
    FlowRuleManager.loadRules(rules);
}

引用官方的原理介绍:
在 Sentinel 里面,所有的资源都对应一个资源名称以及一个 Entry。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 API 显式创建;每一个 Entry 创建的时候,同时也会创建一系列功能插槽(slot chain)。这些插槽有不同的职责,例如:
NodeSelectorSlot 负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级;
ClusterBuilderSlot 则用于存储资源的统计信息以及调用者信息,例如该资源的 RT, QPS, thread count 等等,这些信息将用作为多维度限流,降级的依据;
StatisticSlot 则用于记录、统计不同纬度的 runtime 指标监控信息;
FlowSlot 则用于根据预设的限流规则以及前面 slot 统计的状态,来进行流量控制;
AuthoritySlot 则根据配置的黑白名单和调用来源信息,来做黑白名单控制;
DegradeSlot 则通过统计信息以及预设的规则,来做熔断降级;
SystemSlot 则通过系统的状态,例如 load1 等,来控制总的入口流量;

上面提到了一系列的slot都是在

entry = SphU.entry("HelloWorld");

中创建的,并且是每个资源对应唯一一个slot chain(ProcessorSlotChain)。
在这里插入图片描述
所有的slot实现了ProcessorSlot接口 ,并且按顺序被加入到ProcessorSlotChain里面
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
资源与slot chain的对应关系存放在CtSph类全局静态变量chainMap中,CtSph继承于SphU,注意这个变量的修饰关键字。资源对应的ProcessorSlotChain都是pre-source的,即在第一次访问资源的时候ProcessorSlotChain就创建好,以后再也不用创建,因为创建好之后会存放在chainMap中。
在这里插入图片描述
这也意味着系统所有资源的访问都会经过chainMap,这也意味着chainMap是一个竞态热点访问数据。这就要求访问chainMap是高性能的同时,chainMap的更新也是线程安全的。看下源码

根据资源获取对应的SlotChain:
在这里插入图片描述
我们看到代码没有对chainMap加任何锁,只是在更新chainMap时是通过额外加锁和复制替换的形式。这里面用到的技巧包括了volatile特性、copyOnWrite、synchronized。这样高并发下读写操作是并行的,只有写写操作之间串行。但注意的是写操作是一个纯内存操作,只有第一次访问资源时才会触发,其时间花费只与资源的数量成正比,正常应用资源个数一般在数千以内,并且对象是共享的,这个花费的时间是非常的少。另外阿里也做了资源数量的限制: MAX_SLOT_CHAIN_SIZE = 6000。所以写写操作也是非常的快,比例也很少。再加上volatile关键字的特性,chainMap更新后对所有线程都可见,线程安全。

除了chainMap是这种套路之外,Sentinel 里面资源对应的统计信息的更新存储也是类似的套路。

2.2 StatisticSlot

因为一个资源与对应唯一一个SlotChain,所以在应用中一个资源就唯一对应一个NodeSelectorSlot、ClusterBuilderSlot、LogSlot、StatisticSlot、SystemSlot、AuthoritySlot、FlowSlot、DegradeSlot。这里着重介绍StatisticSlot,因为它是 Sentinel 的核心功能插槽之一,用于统计所有的实时数据,并且是很多Slot的数据基础。

引用官方文档:
clusterNode:资源唯一标识的 ClusterNode 的 runtime 统计
origin:根据来自不同调用者的统计信息
defaultnode: 根据上下文条目名称和资源 ID 的 runtime 统计入口的统计

Sentinel 底层采用高性能的滑动窗口数据结构 LeapArray 来统计实时的秒级指标数据,可以很好地支撑写多于读的高并发场景。

Sentinel 总体的框架如下:
在这里插入图片描述
通过架构图我们可以看到StatisticSlot中的LeapArray采用了一个环性数组的数据结构,这个和一致性hash算法的图类似,如图:
在这里插入图片描述
在这个结构中,每一个下标位就代表一个滑动窗口,至于这个窗口是怎么滑动的我们可以结合源码来看。

2.2.1 LeapArray 源码

2.2.1.1 数据结构

文档中提到的LeapArray数据结构,内部是一个数组

public LeapArray(int sampleCount, int intervalInMs) {
    AssertUtil.isTrue(sampleCount > 0, "bucket count is invalid: " + sampleCount);
    AssertUtil.isTrue(intervalInMs > 0, "total time interval of the sliding window should be positive");
    AssertUtil.isTrue(intervalInMs % sampleCount == 0, "time span needs to be evenly divided");

    this.windowLengthInMs = intervalInMs / sampleCount;
    this.intervalInMs = intervalInMs;
    this.intervalInSecond = intervalInMs / 1000.0;
    this.sampleCount = sampleCount;

    this.array = new AtomicReferenceArray<>(sampleCount);
}

数组里面的真正元素是用WindowWrap类封装好的MetricBucket
的数据结构,里面记录了在窗口时间内通过的请求数、block、异常数、RT(响应时间)这些指标,当前线程数则是在另一个地方计算。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
注意关键字LongAdder,Sentinel参考了java 8里面关于AtomicLong的改进,采用了全新LongAdder进行计数统计。

概括起来使用LongAdder, 高并发下计数统计比之前用AtomicLong高效得多。不过令人感到意外的是Sentinel在线程数统计时却没有用LongAdder而用的是AtomicInteger

    private AtomicInteger curThreadNum = new AtomicInteger(0);
2.2.1.2 调用流程

StatisticSlot作为统计的入口,在其entry()方法中我们可以看到StatisticSlot会使用StatisticNode,然后StatisticNode会去引用ArrayMetric,最终使用LeapArray。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.2.2 根据当前时间获取滑动窗口

public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null;
    }
    // 根据当前时间计算出当前时间属于那个滑动窗口的数组下标
    int idx = calculateTimeIdx(timeMillis);
    // 根据当前时间计算出当前滑动窗口的开始时间
    long windowStart = calculateWindowStart(timeMillis);
/* 
* 根据下脚标在环形数组中获取滑动窗口(桶) 
* (1) 如果桶不存在则创建新的桶,并通过CAS将新桶赋值到数组下标位。 
* (2) 如果获取到的桶不为空,并且桶的开始时间等于刚刚算出来的时间,那么返回当前获取到的桶。 
* (3) 如果获取到的桶不为空,并且桶的开始时间小于刚刚算出来的开始时间,那么说明这个桶是上一圈用过的桶,重置当前桶 
* (4) 如果获取到的桶不为空,并且桶的开始时间大于刚刚算出来的开始时间,理论上不应该出现这种情况。 
*/
    while (true) {
        WindowWrap<T> old = array.get(idx);
        if (old == null) {
            /*
             *     B0       B1      B2    NULL      B4
             * ||_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *                             ^
             *                          time=888
             *            bucket is empty, so create new and update
             *
             * If the old bucket is absent, then we create a new bucket at {@code windowStart},
             * then try to update circular array via a CAS operation. Only one thread can
             * succeed to update, while other threads yield its time slice.
             */
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
            if (array.compareAndSet(idx, null, window)) {
                // Successfully updated, return the created bucket.
                return window;
            } else {
                // Contention failed, the thread will yield its time slice to wait for bucket available.
                Thread.yield();
            }
        } else if (windowStart == old.windowStart()) {
            /*
             *     B0       B1      B2     B3      B4
             * ||_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *                             ^
             *                          time=888
             *            startTime of Bucket 3: 800, so it's up-to-date
             *
             * If current {@code windowStart} is equal to the start timestamp of old bucket,
             * that means the time is within the bucket, so directly return the bucket.
             */
            return old;
        } else if (windowStart > old.windowStart()) {
            /*
             *   (old)
             *             B0       B1      B2    NULL      B4
             * |_______||_______|_______|_______|_______|_______||___
             * ...    1200     1400    1600    1800    2000    2200  timestamp
             *                              ^
             *                           time=1676
             *          startTime of Bucket 2: 400, deprecated, should be reset
             *
             * If the start timestamp of old bucket is behind provided time, that means
             * the bucket is deprecated. We have to reset the bucket to current {@code windowStart}.
             * Note that the reset and clean-up operations are hard to be atomic,
             * so we need a update lock to guarantee the correctness of bucket update.
             *
             * The update lock is conditional (tiny scope) and will take effect only when
             * bucket is deprecated, so in most cases it won't lead to performance loss.
             */
            if (updateLock.tryLock()) {
                try {
                    // Successfully get the update lock, now we reset the bucket.
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                // Contention failed, the thread will yield its time slice to wait for bucket available.
                Thread.yield();
            }
        } else if (windowStart < old.windowStart()) {
            // Should not go through here, as the provided time is already behind.
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

根据下脚标在环形数组中获取滑动窗口(桶)的规则:

(1) 如果桶不存在则创建新的桶,并通过CAS将新桶赋值到数组下标位。
(2) 如果获取到的桶不为空,并且桶的开始时间等于刚刚算出来的时间,那么返回当前获取到的桶。
(3) 如果获取到的桶不为空,并且桶的开始时间小于刚刚算出来的开始时间,那么说明这个桶是上一圈用过的桶,重置当前桶,并返回。
(4) 如果获取到的桶不为空,并且桶的开始时间大于刚刚算出来的开始时间,理论上不应该出现这种情况。

这里有一个比较值得学习的地方是:

1.对并发的控制:当一个新桶的创建直接是使用的CAS的原子操作来保证并发;但是重置一个桶的时候因为很难保证其原子操作(1. 需要重置多个值;2. 重置方法是一个抽象方法,需要子类去做实现),所以直接使用一个ReentrantLock锁来做并发控制。
2.对Thread.yield();方法的使用,这个方法主要的作用是交出CPU的执行权,并重新竞争CPU执行权。这个方法在我们业务代码中其实很少用到。

2.2.2.1 如何实现滑动的

通过上面这个方法我们可以看到我们是如果根据当前时间获取到一个桶的(滑动窗口)。但是如何实现滑动效果的呢?实现滑动效果主要看上面那个方法的如何找到桶的下标和如何更加当前时间找到当前桶的开始时间,如下:

// 根据当前时间计算出当前时间属于那个滑动窗口的数组下标
int idx = calculateTimeIdx(timeMillis);
// 根据当前时间计算出当前滑动窗口的开始时间
long windowStart = calculateWindowStart(timeMillis);

// 根据当前时间计算出当前时间属于那个滑动窗口的数组下标
private int calculateTimeIdx(/*@Valid*/ long timeMillis) {
    // 利用除法取整原则,保证了一秒内的所有时间搓得到的timeId是相等的
    long timeId = timeMillis / windowLengthInMs;
    // 利用求余运算原则,保证一秒内获取到的桶的下标位是一致的
    return (int) (timeId % array.length());
}

// 根据当前时间计算出当前滑动窗口的开始时间
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
    // 利用求余运算原则,保证一秒内获取到的桶的开始时间是一致的
    // 100 - 100 % 10 = 100 - 0 = 100
    // 101 - 101 % 10 = 101 - 1 = 100
    // 102 - 102 % 10 = 102 - 2 = 100
    return timeMillis - timeMillis % windowLengthInMs;
}

timeMillis:表示当前时间的时间戳
windowLengthInMs:表示一个滑动窗口的时间长度,根据源码来看是1000ms即一个滑动窗口统计1秒内的数据。

这两个方法巧妙的利用了除法取整和求余原则实现了窗口的滑动。通过最上面的结构图我们可以发现滑动窗口会根据时间戳顺时针旋转。

桶的数量就决定了滑动窗口的统计时长,根据源码来看是60个桶,即一个统计1分钟内的数据。
内部是利用并发工具类LongAdder的特性来实现的高效的数据的统计。

2.3 ClusterBuilderSlot

引用官方文档
此插槽用于构建资源的 ClusterNode 以及调用来源节点。ClusterNode 保持资源运行统计信息(响应时间、QPS、block 数目、线程数、异常数等)以及原始调用者统计信息列表。来源调用者的名字由 ContextUtil.enter(contextName,origin) 中的 origin 标记。可通过如下命令查看某个资源不同调用者的访问情况:curl http://localhost:8719/origin?id=caller:

id: nodeA
idx origin  threadNum passedQps blockedQps totalQps aRt   1m-passed 1m-blocked 1m-total 
1   caller1 0         0         0          0        0     0         0          0        
2   caller2 0         0         0          0        0     0         0          0

看功能描述ClusterBuilderSlot似乎与StatisticSlot有些重叠,其实准确来说StatisticSlot的用于统计的数据结构是由ClusterBuilderSlot传递给StatisticSlot的,并且这个数据结构是ClusterBuilderSlot的内部属性,同时ClusterBuilderSlot还记录了调用来源的结点originNode,并把它也传递给StatisticSlot,去实时更新。

所以,资源的所有实时统计信息通过ClusterBuilderSlot就可以实时获取。另外所有的资源统计信息都保存在一个全局静态的map中

private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<ResourceWrapper, ClusterNode>();

Sentinel 控制台上面显示的实时监控数据就是拿的clusterNodeMap的数据。

2.4 NodeSelectorSlot

这个 slot 主要负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级。ClusterBuilderSlot的originNode就是NodeSelectorSlot生成的。

2.5 LogSlot

记录统计时抛出的未知异常用的。

2.6 SystemSlot

引用官方文档
这个 slot 会根据对于当前系统的整体情况,对入口的资源进行调配。其原理是让入口的流量和当前系统的 load 达到一个动态平衡。

注意这个功能的两个限制:
1.只对入口流量起作用(调用类型为EntryType.IN),对出口流量无效。可通过 SphU.entry() 指定调用类型,如果不指定,默认是EntryType.OUT。
2.Entry entry = SphU.entry(“resourceName”,http://EntryType.IN);
只在 Unix-like 的操作系统上生效

看下SystemSlot中的主要代码逻辑。

SystemRuleManager.checkSystem(resourceWrapper);
fireEntry(context, resourceWrapper, node, count, args);

校验系统的整体的QPS,线程数,RT时间,不通过则进行调整。

public static void checkSystem(ResourceWrapper resourceWrapper, int count) throws BlockException {
    if (resourceWrapper == null) {
        return;
    }
    // Ensure the checking switch is on.
    if (!checkSystemStatus.get()) {
        return;
    }

    // for inbound traffic only
    if (resourceWrapper.getEntryType() != EntryType.IN) {
        return;
    }

    // total qps
    double currentQps = Constants.ENTRY_NODE.passQps();
    if (currentQps + count > qps) {
        throw new SystemBlockException(resourceWrapper.getName(), "qps");
    }

    // total thread
    int currentThread = Constants.ENTRY_NODE.curThreadNum();
    if (currentThread > maxThread) {
        throw new SystemBlockException(resourceWrapper.getName(), "thread");
    }

    double rt = Constants.ENTRY_NODE.avgRt();
    if (rt > maxRt) {
        throw new SystemBlockException(resourceWrapper.getName(), "rt");
    }

    // load. BBR algorithm.
    if (highestSystemLoadIsSet && getCurrentSystemAvgLoad() > highestSystemLoad) {
        if (!checkBbr(currentThread)) {
            throw new SystemBlockException(resourceWrapper.getName(), "load");
        }
    }

    // cpu usage
    if (highestCpuUsageIsSet && getCurrentCpuUsage() > highestCpuUsage) {
        throw new SystemBlockException(resourceWrapper.getName(), "cpu");
    }
}

2.7 FlowSlot

引用官方文档
这个 slot 主要根据预设的资源的统计信息,按照固定的次序,依次生效。如果一个资源对应两条或者多条流控规则,则会根据如下次序依次检验,直到全部通过或者有一个规则生效为止:
指定应用生效的规则,即针对调用方限流的;
调用方为 other 的规则;
调用方为 default 的规则。

回到开头的例子:
在这里插入图片描述
再看FlowSlot里面的代码逻辑,简单明了。传参里面的 DefaultNode node就是ClusterBuilderSlot里面的统计数据。
在这里插入图片描述
在这里插入图片描述
slot间调用关系
在这里插入图片描述

2.8 Context请求上下文

在Sentinel 中所有资源的访问都会生成一个Context(请求上下文)

	         Context context = ContextUtil.getContext();

这个Context是基于ThreadLocal的,所以一个请求是横跨多个资源的同时,都是在同一个Context下面。这样可以实现跨资源的链路访问统计。

Context在entry创建的时候生成和获取,在exit的时候清除掉。所以如果entry不为空,最后一定要调用exit方法,否则会有内存泄露的风险。

entry = SphU.entry("HelloWorld"); 
entry.exit();

2.9 entry.exit

前面提到entry不为空,最后一定要调用exit方法,否则会有内存泄露的风险。除了这个原因之外,请求的RT时间统计,完成后当前线程数的更新也是在exit方法中完成。所以entry创建和entry.exit一定是要成对出现。否则就出大问题了。

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值