熔断调试失败引发的sentinel降级熔断原理探究

文章目录

  • sentinel降级熔断原理探究
    • 背景
    • 熔断降级应用
    • 熔断降级原理
      • @SentinelResource注解解析
      • @SentinelResource注解执行流程
      • 降级的实现原理
        • 创建资源对象
          • ResourceWrapper
        • 获取当前资源对象对应的插槽链
          • slot介绍
          • 获取slotChain
          • 创建插槽链
        • 顺序执行SlotChain中的各个Slot的逻辑
        • DegradeSlot实现降级
          • 根据平均响应时间降级
          • 根据异常比例降级
          • 根据异常数降级
    • 总结
    • 参考

sentinel降级熔断原理探究

背景

最近依赖的系统偶尔会出现超时的情况,由于该依赖属于弱依赖,所以笔者决定在依赖系统超时的情况下对该依赖进行熔断降级。

目前使用较多的限流与降低较多的框架是Hystrix与Sentinel。两者的具体对比可以参考sentinel与Hystrix对比

Sentinel支持响应平均RT等多种方式降级,支持公司目前使用的duboo框架,并且引入依赖简单配置即可快速使用,所以本次使用sentinel作为降级工具。

由于之前对sentinel的熔断降级具体原理不甚了解,导致本地调试时没有达到想要的熔断目的。当时满脸问号,源码面前无秘密,决定对它的原理一探究竟。

熔断降级应用

Sentinel通过定义的资源保护具体的业务代码或其他后方服务,用户只需要为受保护的代码或服务定义一个资源,然后定义规则就可以了。常用的SphU.entry("resourceName")会对业务代码造成入侵,一般用注解@SentinelResource的方式。

下面通过一个简单的栗子介绍Sentinel降级的使用方法。
假设我们有个获取用户信息的接口,如下

@RestController
@RequestMapping("/api/user")
public class SentinelTestController {
     

    @Autowired
    private UserService userService;

    @GetMapping("/detail")
    public BaseResponse<User> info(@RequestParam("id") Long userId) {
     
        User user = userService.getDetails(userId);
        return BaseResponse.success(user);
    }
}

获取用户信息的方法userService.getDetails(useId)方法如下,当触发降级时,自动调用blockHandlerMethod方法。

在spring应用中,注意@SentinelResource不能用于内部调用的方法;原因是类的内部方法调用是进入不了aop的。

@Service
public class UserServicesImpl implements UserService {
     

    @Override
    @SentinelResource(value = "user.test", blockHandler = "blockHandlerMethod")
    public User getDetails(Long id) {
     
        try {
     
            //睡眠模拟执行时长
            TimeUnit.MILLISECONDS.sleep(10);
        } catch (InterruptedException e) {
     
            e.printStackTrace();
        }
        return new User(id);
    }
    // 熔断降级对应的处理方法
    public User blockHandlerMethod(Long id, BlockException e) {
     
        System.out.println("blockHandlerMethod invoke");
        return null;
    }
}

这里我们用TimeUnit.MILLISECONDS.sleep(10)来模拟调用依赖方法所需的时间,触发熔断条件。用@SentinelResource定义资源,并对该资源配置相应的降级策略。降级配置如下

{
    "resource": "user.test", //资源名,与@SentinelResource中的value保持一致
    "count": 5, //阈值,当策略为RT时表示5ms
    "grade": 0, //熔断降级策略,支持秒级 RT(0)/秒级异常比例(1)/分钟级异常数(2)
    "timeWindow": 10 //降级的时间,单位为s
}

由于本文使用的sentinel版本小于1.7.0,所以没有rtSlowRequestAmount配置,该配置为RT模式下1 秒内连续多少个请求的平均RT超出阈值方可触发熔断,默认为5。

最后由于本应用是基于Springboot,需要将SentinelResourceAspect注册为一个bean,代码如下

@Configuration
public class SentinelAspectConfiguration {
     

    @Bean
    public SentinelResourceAspect sentinelResourceAspect() {
     
        return new SentinelResourceAspect();
    }
}

以上就完成了对一个方法进行熔断降级的初步栗子。

接下来尝试熔断降级的效果。当连续多次访问接口后,成功触发熔断机制,如下图
熔断调试失败引发的sentinel降级熔断原理探究_第1张图片

在这里要说下自己为何最开始在测试环境没有模拟出熔断!由于Sentinel的平均rt超时熔断是基于秒级的,也就是说它会统计滑动窗口1秒内请求的平均耗时,当平均耗时大于设定阈值时,不会马上熔断,而是会将超时通过的passCount加1。当该秒内无请求或平均耗时小于阈值时,passCount会重置为0。只有当passCount大于等于5时,才会触发熔断机制。当时访问的速度和次数不够导致无法熔断。

接下来详细解析。

熔断降级原理

通过一个简单的示例程序,我们了解了sentinel可以对请求进行熔断降价。现在我们就拨开云雾,深入源码内部去一窥sentinel熔断降级的实现原理吧。

@SentinelResource注解解析

首选我们看下@SentinelResource注解,它是Sentinel用于定义资源的注解,并提供了可选的异常处理和 fallback 配置项。该注解源码与解释如下,具体可见官网。

@Target({
     ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface SentinelResource {
     
    // Sentinel资源的名称
    String value() default "";
    //资源调用的流量类型,是入口流量EntryType.IN,还是出口流量EntryType.OUT。默认为 EntryType.OUT
    EntryType entryType() default EntryType.OUT;
    //blockHandler对应处理 BlockException 的函数名称,可选项。
    //blockHandler 函数访问范围需要是 public,返回类型需要与原方法相匹配,参数类型需要和原方法相匹配并且最后加一个额外的参数,类型为 BlockException。
    //blockHandler 函数默认需要和原方法在同一个类中
    String blockHandler() default "";
    //当blockHandler函数在其他类中时,需要指明函数对应的类的 Class 对象,并且对应的函数必需为 static 函数。
    Class<?>[] blockHandlerClass() default {
     };
    //fallback 函数名称,用于在抛出异常的时候提供 fallback 处理逻辑。
    //函数返回类型与参数列表需与原函数一致,方法入参可以额外多一个 Throwable 类型的参数用于接收对应的异常。
    String fallback() default "";
    //用于通用的 fallback 逻辑(即可以用于很多服务或方法)
    //若同时配置了 fallback 和 defaultFallback,则只有 fallback 会生效。
    String defaultFallback() default "";
    //当fallback函数与原方法不在同一个类中,通过fallbackClass指定class
    Class<?>[] fallbackClass() default {
     };
    //用于指定哪些异常被统计
    Class<? extends Throwable>[] exceptionsToTrace() default {
     Throwable.class};
    //用于指定哪些异常被排除掉,不会计入异常统计中,也不会进入 fallback 逻辑中,而是会原样抛出。
    Class<? extends Throwable>[] exceptionsToIgnore() default {
     };
}

注意当blockHandlerfallback 都进行了配置,则被限流降级而抛出 BlockException 时只会进入 blockHandler 处理逻辑,之后在源码中可以看出。若未配置 blockHandlerfallbackdefaultFallback,则被限流降级时会将 BlockException直接抛出。

@SentinelResource注解执行流程

在执行@SentinelResource注解的方法前,该注解对应的SentinelResourceAspect切面进行拦截,判定是否触发限流或降级。我们来看下这个切面的实现:

@Aspect
public class SentinelResourceAspect extends AbstractSentinelAspectSupport {
     
    //将@SentinelResource注解定义为切点
    @Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)")
    public void sentinelResourceAnnotationPointcut() {
     
    }

    @Around("sentinelResourceAnnotationPointcut()")
    public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable {
     
        Method originMethod = resolveMethod(pjp);
        //获取注解
        SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class);
        if (annotation == null) {
     
            throw new IllegalStateException("Wrong state for SentinelResource annotation");
        }
        //1. 获取资源名称,@SentinelResource注解上带有value则直接取,否则解析方法名作为资源名
        String resourceName = getResourceName(annotation.value(), originMethod);
        //获取流量类型
        EntryType entryType = annotation.entryType();
        Entry entry = null;
        try {
     
            //2. 申请entry进入资源,如果申请成功,则表明没有限流或降级
            entry = SphU.entry(resourceName, entryType, 1, pjp.getArgs());
            //执行原方法
            Object result = pjp.proceed();
            //返回结果
            return result;
        } catch (BlockException ex) {
     
           //3. entry资源申请不成功,抛出BlockException,根据注解上定义的异常处理函数处理该异常
            return handleBlockException(pjp, annotation, ex);
        //4. 处理非BlockException异常    
        } catch (Throwable ex) {
     
            //获取注解上定义的exceptionsToIgnore
            Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore();
            //如果在列表中,则直接抛出,不会计入异常统计中,也不会进入 fallback 逻辑中
            if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) {
     
                throw ex;
            }
            //如果属于注解上的exceptionsToTrace标记的异常,则计入异常统计,并执行fallback 逻辑中
            if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) {
     
                traceException(ex, annotation);
                return handleFallback(pjp, annotation, ex);
            }
            //否则直接抛出
            throw ex;
        } finally {
     
            //退出资源
            if (entry != null) {
     
                entry.exit(1, pjp.getArgs());
            }
        }
    }
}

SentinelResourceAspect的执行逻辑主要分为以下四步

  1. 获取方法对应的资源名,优先从@SentinelResource注解中获取,没有则从方法名中解析。
  2. 执行SphU.entry()申请进入资源,进入成功则执行原方法并返回结果
  3. 如果进入资源失败,捕获抛出的BlockException异常。
  4. 如果抛出非BlockException异常,则获取@SentinelResource注解上的异常处理类型与方式,处理异常。

上述第三步中的handleBlockException()方法是处理BlockException异常,源码如下:

// 处理BlockException异常
protected Object handleBlockException(ProceedingJoinPoint pjp, SentinelResource annotation, BlockException ex)
        throws Throwable {
     
        // 提取配置的blockHandler方法
        Method blockHandlerMethod = extractBlockHandlerMethod(pjp, annotation.blockHandler(),
            annotation.blockHandlerClass());
        //如果blockHandler方法不为空,则执行该方法   
        if (blockHandlerMethod != null) {
     
            // 获取标记注解的业务方法上的参数
            Object[] originArgs = pjp.getArgs();
            //拷贝并添加BlockException为最后一个参数
            Object[] args = Arrays.copyOf(originArgs, originArgs.length + 1);
            args[args.length - 1] = ex;
            //根据是否是静态方法执行处理逻辑并返回
            if (isStatic(blockHandlerMethod)) {
     
                return blockHandlerMethod.invoke(null, args);
            }
            return blockHandlerMethod.invoke(pjp.getTarget(), args);
        }
        // 如果没有配置blockhandler方法,则执行配置的fallback方法
        return handleFallback(pjp, annotation, ex);
    }

处理BlockException异常的主要逻辑如下

  1. 获取注解上的设置的blockHandler方法,
  2. 如果配置了blockHandler方法则执行该方法
  3. 没有配置blockHandler方法则执行fallBack方法。

所以上文说过,当blockHandlerfallback 都进行了配置,则被限流降级而抛出 BlockException 时只会进入 blockHandler 处理逻辑。

这里的其他方法不再继续做展开,我们主要再看下handleFallback方法,它与处理非BlockException异常的方法相同,具体实现逻辑如下:

    protected Object handleFallback(ProceedingJoinPoint pjp, SentinelResource annotation, Throwable ex)
        throws Throwable {
     
        return handleFallback(pjp, annotation.fallback(), annotation.defaultFallback(), annotation.fallbackClass(), ex);
    }

    protected Object handleFallback(ProceedingJoinPoint pjp, String fallback, String defaultFallback, Class<?>[] fallbackClass, Throwable ex) throws Throwable {
     
        Object[] originArgs = pjp.getArgs();

        // 提取 fallback 方法
        Method fallbackMethod = extractFallbackMethod(pjp, fallback, fallbackClass);
        if (fallbackMethod != null) {
     
            // 获取方法参数个数
            int paramCount = fallbackMethod.getParameterTypes().length;
            Object[] args;
            //确认fallback方法是否包含Throwable类型的参数
            if (paramCount == originArgs.length) {
     
                args = originArgs;
            } else {
     
                args = Arrays.copyOf(originArgs, originArgs.length + 1);
                args[args.length - 1] = ex;
            }
            //根据是否是静态方法执行处理逻辑并返回
            if (isStatic(fallbackMethod)) {
     
                return fallbackMethod.invoke(null, args);
            }
            return fallbackMethod.invoke(pjp.getTarget(), args);
        }
        // 如果没有配置fallback方法,则执行defaultFallback方法
        return handleDefaultFallback(pjp, defaultFallback, fallbackClass, ex);
    }

handleFallback的方法的执行逻辑与处理handleBlockException的逻辑基本类似,获取注解上配置的fallback方法,如果有则根据是否是静态方法执行处理逻辑并返回。否则执行handleDefaultFallback方法。handleDefaultFallback方法不再详细展开,其实就是获取注解上配置的defaultFallback方法,有则执行,没有则直接抛出异常。

综上,处理BlockException的优先级如下

处理BlockException的优先级顺序为blockHandler > fallBack > defaultFallback > throw ex,并只会执行其中一个。

降级的实现原理

在了解@SentinelResource注解的整理执行逻辑后,接下来,我们将详细分析Sentinel的降级原理。在SentinelResource切面的执行流程中,申请资源的SphU.entry()是限流,降级的入口方法。我们将从该方法深入,了解Sentinel降级的奥秘。

限流,降级的入口方法SphU.entry()。这个方法会对指定资源申请一个entry,如果能够申请成功,则说明没有被限流或降级,否则会抛出BlockException。那么它是怎么定义资源能否申请成功呢,我们继续往下。

创建资源对象

SphU.entry()方法往下执行会进入到 Env.sph.entry(),Env.sphSph类型 ,Sph是一个接口,它的默认实现类是 CtSph ,所以SphU.entry()最终会执行到在CtSph中的entry方法:

@Override
public Entry entry(String name, EntryType type, int count, Object... args) throws BlockException {
     
    StringResourceWrapper resource = new StringResourceWrapper(name, type);
    return entry(resource, count, args);
}

该方法根据注解上定义的资源名与资源调用的流量类型创建了一个StringResourceWrapper资源对象,并调用了重载的entry方法。这里我们先理解一下StringResourceWrapper

ResourceWrapper

在上文中我们已经说过sentinel是通过资源resource来保护具体的业务代码。而在sentinel中具体表示资源的类是:ResourceWrapper,它是一个抽象的包装类,包装了资源的名称 Name 和资源调用的流量类型EntryType。他有两个实现类,分别是:StringResourceWrapperMethodResourceWrapper。顾名思义,StringResourceWrapper 是通过将字符串包装为资源,是一个通用的资源包装类,MethodResourceWrapper 是对方法的包装。结构图如下
熔断调试失败引发的sentinel降级熔断原理探究_第2张图片

获取当前资源对象对应的插槽链

创建了StringResourceWrapper资源对象后,重载的entry方法源码如下


public Entry entry(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException {
     
        return entryWithPriority(resourceWrapper, count, false, args);
}

private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args) throws BlockException {
     
        //获取当前线程的上下文;context是保存在ThreadLocal中的,每次执行的时候会优先到ThreadLocal中获取
        Context context = ContextUtil.getContext();
        // 上下文名称对应的入口节点是否已经超过阈值2000,超过则会返回空 CtEntry。
        // 不会进行规则校验
        if (context instanceof NullContext) {
     
            return new CtEntry(resourceWrapper, null, context);
        }
        //如果当前执行时还没有 context,那么会使用默认的 context,通过 MyContextUtil.myEnter() 创建的。
        if (context == null) {
     
            context = MyContextUtil.myEnter(Constants.CONTEXT_DEFAULT_NAME, "", resourceWrapper.getType());
        }

        // Global switch is close, no rule checking will do.
        if (!Constants.ON) {
     
            return new CtEntry(resourceWrapper, null, context);
        }
        // 获取插槽链
        ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);

        //表示资源(插槽链)超过6000,因此不会进行规则检查。
        if (chain == null) {
     
            return new CtEntry(resourceWrapper, null, context);
        }
        // 生成 Entry 对象
        Entry e = new CtEntry(resourceWrapper, chain, context);
        try {
     
            // 开始执行插槽链 调用逻辑
            chain.entry(context, resourceWrapper, null, count, prioritized, args);
        } catch (BlockException e1) {
     
            // 清除上下文
            e.exit(count, args);
            //抛出BlockExecption异常,上层捕获
            throw e1;
        } catch (Throwable e1) {
     
            RecordLog.info("Sentinel unexpected exception", e1);
        }
        return e;
    }

entryWithPriority包含了Sentinel的核心逻辑,主要步骤为

  1. 获取上下文对象,如果上下文对象还未初始化,则使用默认名称初始化。
  2. 规则校验,不符合要求直接返回一个CtEntry对象,不会进行限流检测
  3. 根据给定的资源生成插槽链slotChain,slotChain是跟资源相关的,Sentinel最关键的逻辑也都在各个slot中。
  4. 依顺序执行每个slot逻辑
slot介绍

为了便于后续理解,我们先简单了解下sentinel中slot。

sentinel的工作流程是围绕着一个个插槽slot所组成的插槽链slotchain来展开的。每个插槽都有自己的职责,通过一定的编排顺序,来达到最终的限流降级的目的。slot的执行顺序有一定要求,因为有的slot需要依赖其他的slot计算出来的结果才能进行工作。常见的7个slot及其功能职责如下:

  1. NodeSelectorSlot: 负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级;
  2. ClusterBuilderSlot: 用于存储资源的统计信息以及调用者信息,例如该资源的 RT, QPS, thread count 等等,这些信息将用作为多维度限流,降级的依据;
  3. StatistcSlot: Sentinel 的核心功能插槽之一,用于统计实时的调用数据。
  4. FlowSlot: 用于根据预设的限流规则,以及前面 slot 统计的状态,来进行限流;
  5. AuthorizationSlot: 根据黑白名单,来做黑白名单控制;
  6. DegradeSlot: 通过统计信息,以及预设的规则,来做熔断降级;
  7. SystemSlot:根据对于当前系统的整体情况,对入口资源的调用进行动态调配。

详细的介绍可以参考Sentinel的Slot介绍

获取slotChain

其中插槽链的获取逻辑在lookProcessChain(resourceWrapper),我们接下来看该方法逻辑

ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) {
     
        //根据资源尝试从全局缓存中获取,这里为传入的StringResourceWrapper
        ProcessorSlotChain chain = chainMap.get(resourceWrapper);
        if (chain == null) {
     
            //双重检查锁
            synchronized (LOCK) {
     
                chain = chainMap.get(resourceWrapper);
                if (chain == null) {
     
                    // 判断插槽链数是否大于6000
                    if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {
     
                        return null;
                    }
                    // 初始化插槽链
                    chain = SlotChainProvider.newSlotChain();
                    //将该插槽链添加到全局缓存map中
                    Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<ResourceWrapper, ProcessorSlotChain>(
                        chainMap.size() + 1);
                    newMap.putAll(chainMap);
                    newMap.put(resourceWrapper, chain);
                    chainMap = newMap;
                }
            }
        }
        return chain;
    }

获取slotChain方法的主要逻辑为

  1. 根据资源尝试从全局缓存中获取插槽链,缓存中没有,则创建。
  2. 创建插槽链上的插槽,并将该资源的插槽链加入到缓存中。
创建插槽链

我们接下来详细了解下创建插槽链的源码

private static volatile SlotChainBuilder builder = null;

public static ProcessorSlotChain newSlotChain() {
     
    // 判断builder是否已经初始化过
    if (builder != null) {
     
        return builder.build();
    }
    //加载SlotChain
    resolveSlotChainBuilder();
    //如果加载之后builder仍为null,使用默认DefaultSlotChainBuilder
    if (builder == null) {
     
        RecordLog.warn("[SlotChainProvider] Wrong state when resolving slot chain builder, using default");
        builder = new DefaultSlotChainBuilder();
    }
    //返回构建的插槽链
    return builder.build();
}

/**
* 通过SPI机制加载自定义SlotChainBuilder
*/
private static void resolveSlotChainBuilder() {
     
    List<SlotChainBuilder> list = new ArrayList<SlotChainBuilder>();
    boolean hasOther = false;
    // 尝试获取自定义SlotChainBuilder,通过JAVA SPI机制扩展
    for (SlotChainBuilder builder : LOADER) {
     
        if (builder.getClass() != DefaultSlotChainBuilder.class) {
     
            hasOther = true;
            list.add(builder);
        }
    }
    //如果有自定义的SlotChainBuilder,利用该builder构建slotchain
    //否则,使用默认的DefaultSlotChainBuilder
    if (hasOther) {
     
        builder = list.get(0);
    } else {
     
        builder = new DefaultSlotChainBuilder();
    }

    RecordLog.info("[SlotChainProvider] Global slot chain builder resolved: "
            + builder.getClass().getCanonicalName());
    }

创建插槽链的主要流程为

  1. 创建插槽链首先判断SlotChainBuilder是否已经初始化过,是的话直接创建。
  2. 尝试获取自定义的SlotChainBuilder来构建插槽链,如果有则利用该builder构建slotchain;否则,使用默认的DefaultSlotChainBuilder。
  3. 构建失败则使用默认插槽链

SlotChainBuilder是插槽链构建器接口,该接口用于构建插槽链中的插槽类型与顺序,提供了三个实现类,并且支持自定义扩展,即通过实现 SlotsChainBuilder 接口加入自定义的 slot 并自定义编排各个 slot 之间的顺序,从而可以给 sentinel 添加自定义的功能。类图如下
熔断调试失败引发的sentinel降级熔断原理探究_第3张图片

本文中暂不对自定义扩展进行展开介绍,我们主要看下最常用的DefaultSlotChainBuilder

Chain是链条的意思,从build的方法可看出,ProcessorSlotChain是一个链表,里面添加了很多个Slot。

public class DefaultSlotChainBuilder implements SlotChainBuilder {
     
    @Override
    public ProcessorSlotChain build() {
     
        ProcessorSlotChain chain = new DefaultProcessorSlotChain();
        chain.addLast(new NodeSelectorSlot());
        chain.addLast(new ClusterBuilderSlot());
        chain.addLast(new LogSlot());
        chain.addLast(new StatisticSlot());
        chain.addLast(new SystemSlot());
        chain.addLast(new AuthoritySlot());
        chain.addLast(new FlowSlot());
        chain.addLast(new DegradeSlot());
        return chain;
    }
}

builder方法主要构建了一个DefaultProcessorSlotChain对象,并将7个默认的Slot依次加到chain的末尾,形成slot责任链,最终整个链表的结构图如下(引用 https://www.jianshu.com/p/0e218ef7f505
first和end是指向链表的头结点和尾节点
熔断调试失败引发的sentinel降级熔断原理探究_第4张图片

顺序执行SlotChain中的各个Slot的逻辑

通过lookProcessChain获取得到SlotChain责任链后,执行chain.entry()方法依次执行Slot的entry方法。我们看下chain.entry是如何顺序执行各Slot的entry方法的。

此处lookProcessChain方法获得的ProcessorSlotChain的实例是DefaultProcessorSlotChain,那么执行chain.entry方法,就会执行DefaultProcessorSlotChainentry方法,该方法如下

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, Object t, int count, boolean prioritized, Object... args) throws Throwable {
     
    first.transformEntry(context, resourceWrapper, t, count, prioritized, args);
}

chain.entry实际上是执行执行了first属性的transformEntry方法。该方法就是执行当前当前Slot节点的entry方法,从上图中我们了解到first的下个Slot是 NodeSelectorSlot

void transformEntry(Context context, ResourceWrapper resourceWrapper, Object o, int count, boolean prioritized, Object... args) throws Throwable {
     
    T t = (T)o;
    entry(context, resourceWrapper, t, count, prioritized, args);
}

DefaultProcessorSlotChain中first节点重写了entry方法,最终会调用到fireEntry方法,该方法会调用下个Slot节点的transformEntry方法,触发下个节点的entry方法。

@Override
public void fireEntry(Context context, ResourceWrapper resourceWrapper, Object obj, int count, Object... args)
    throws Throwable {
     
    if (next != null) {
     
        next.transformEntry(context, resourceWrapper, obj, count, args);
    }
}

与first节点同理,各个Slot的entry方法有不同的业务处理逻辑,执行完自身的逻辑后会触发下一个节点的entry方法,从而完成责任链的传递。

总结chain.entry的执行逻辑如下

  1. 执行first节点的entry逻辑,first的entry方法逻辑就是调用下一个Slot的entry方法
  2. 执行Slot的业务逻辑,并传递调用下一个Slot的entry方法
  3. next Slotnull,结束SlotChain的调用。

在上述Slot中,StatisticSlot用于资源的调用信息统计,如请求量。由于是责任链模式,当下游Slot执行结束返回时,根据执行结果,统计资源调用信息。大致是如果成功增加响应的成功请求数,抛出BlockException,增加Block的数量,如果是Exception,则增加exception的数量。这里不做展开介绍。

DegradeSlot是针对资源的平均响应时间(RT)或异常比率,来决定资源是否在接下来的时间被自动熔断掉的Slot, 我们接下来将分析该Slot的实现原理。

DegradeSlot实现降级

DegradeSlotentry的方法如下:

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable {
     
    DegradeRuleManager.checkDegrade(resourceWrapper, context, node, count);
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}

DegradeSlot会直接调用DegradeRuleManager进行是否降级的检查,并调用下一个Slot的entry。我们进入到checkDegrade方法中

public static void checkDegrade(ResourceWrapper resource, Context context, DefaultNode node, int count) throws BlockException {
     
    //根据resource来获取降级策略
    Set<DegradeRule> rules = degradeRules.get(resource.getName());
    if (rules == null) {
     
        return;
    }
    //遍历规则集合调用规则的passCheck,如果返回false那么就抛出异常进行降级。
    for (DegradeRule rule : rules) {
     
        if (!rule.passCheck(context, node, count)) {
     
            throw new DegradeException(rule.getLimitApp(), rule);
        }
    }
}

这个方法逻辑比较清晰,首先是根据资源名获取到降级规则,然后遍历规则集合调用规则的passCheck,如果返回false那么就抛出异常进行降级。我们详细看下passCheck如何判断是否达到降级标准,大致源码如下

    @Override
    public boolean passCheck(Context context, DefaultNode node, int acquireCount, Object... args) {
     
        //资源是否已经降级标志,为true表示已经降级了
        if (cut.get()) {
     
            return false;
        }
        //获取资源的全局节点,降级是根据全局节点来进行判断降级策略的
        ClusterNode clusterNode = ClusterBuilderSlot.getClusterNode(this.getResource());
        if (clusterNode == null) {
     
            return true;
        }
        //根据响应时间降级策略
        if (grade == RuleConstant.DEGRADE_GRADE_RT) {
     
           ....
        // 根据异常比例降级    
        } else if (grade == RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO) {
     
           ......
        // 根据异常数降级    
        } else if (grade == RuleConstant.DEGRADE_GRADE_EXCEPTION_COUNT) {
     
           ......
        }
        //达到降级标准,设置降级标志,并根据设置的时间窗口进行定时重置
        if (cut.compareAndSet(false, true)) {
     
            ResetTask resetTask = new ResetTask(this);
            pool.schedule(resetTask, timeWindow, TimeUnit.SECONDS);
        }
        return false;
    }

Sentinel有三种降级策略,分别为根据平均响应时间降级,根据异常比例降级,根据异常数降级。具体使用哪种策略是配置资源信息时设定。

所以passCheck判断是否进行降级的逻辑主要为

  1. 如果资源已经降级,则直接返回需要降级
  2. 根据配置的降级策略判定是否达到降级标准
  3. 达到降级标准,设置降级标志,并根据设置的时间窗口进行定时重置

接下来,我们解析三种不同降级策略的具体实现逻辑

根据平均响应时间降级

逻辑如下:

private static final int RT_MAX_EXCEED_N = 5;
//获取全局节点一秒内的平均响应时间
double rt = clusterNode.avgRt();
//如果该段时间平均响应时间小于设定的降级时间阈值,则重置统计信息
if (rt < this.count) {
     
    passCount.set(0);
    return true;
}
// passCount加1,如果passCount大于5,那么直接降级
if (passCount.incrementAndGet() < RT_MAX_EXCEED_N) {
     
    return true;
}

首先获取资源的平均响应时间,如果平均响应时间小于用户设定的阈值,则重置大于阈值的请求数passCount,不降级。如果平均响应时间大于阈值,那么passCount加1,如果passCount大于5,那么直接降级。

所以根据平均响应时间降级策略,前5个请求即使响应过长也不会立马降级,而是要等到第6个请求到来才会进行降级。每当有一秒内的平均响应时间小于阈值,passCount就会被重置为0。

RT_MAX_EXCEED_N在1.7之前不支持设定,在1.7之后支持用户设定。

那么全局节点的平均响应时间是如何获取的,我们继续看下

public class StatisticNode implements Node {
     
    ........
    @Override
    public double avgRt() {
     
        //获取一秒时间窗口内的成功请求数
        long successCount = rollingCounterInSecond.success();
        if (successCount == 0) {
     
            return 0;
        }
        return rollingCounterInSecond.rt() * 1.0 / successCount;
    }
    ........
}

这个方法主要是调用rollingCounterInSecond获取成功次数,然后再获取窗口内的响应时间,用总响应时间除以次数得到平均每次成功调用的响应时间。注意这里是获取一秒时间窗口内的平均rt

Sentinel是基于滑动时间窗口统计实时指标。滑动窗口可以参考常用的限流算法。

rollingCounterInSecond是按秒来进行统计的滑动时间窗口。它在StatisticNode类的初始化如下:

//按秒统计,分成两个窗口,每个窗口500ms。SAMPLE_COUNT为2,IntervalProperty.INTERVAL为1000
private transient volatile Metric rollingCounterInSecond = new ArrayMetric(SampleCountProperty.SAMPLE_COUNT, IntervalProperty.INTERVAL);

rollingCounterInSecond是一个按秒统计的时间窗口,将1秒的窗口分成2格,每格的时间大小为500ms。它对应的ArrayMetric类包含LeapArray类型的对象,new ArrayMetric 最终会初始化一个LeapArray对象,设置每个小窗口的跨度(这里为500ms),窗口的长度(这里为1s),窗口中小窗口的个数(这里为2个),以及窗口的数组(这里数组长度为2)。

    public LeapArray(int sampleCount, int intervalInMs) {
     
        ....
        //每个小窗口的时间跨度
        this.windowLengthInMs = intervalInMs / sampleCount;
        //窗口的长度
        this.intervalInMs = intervalInMs;
        //小窗口个数
        this.sampleCount = sampleCount;
        //窗口数组 数组大小 = sampleCount
        this.array = new AtomicReferenceArray<>(sampleCount);
    }

我们再看下ArrayMetric类rollingCounterInSecond.success()的逻辑

    @Override
    public long success() {
     
        //更新当前的窗口
        data.currentWindow();
        long success = 0;
        //获取窗口数组中每个小窗口的统计值
        List<MetricBucket> list = data.values();
        //得到整个大窗口的成功请求数
        for (MetricBucket window : list) {
     
            success += window.success();
        }
        return success;
    }

data.currentWindow()方法在这里的作用主要是设置或更新当前时间的窗口。

    public WindowWrap<T> currentWindow(long timeMillis) {
     
        if (timeMillis < 0) {
     
            return null;
        }
        // 计算当前时间戳所属的窗口数组索引下标
        int idx = calculateTimeIdx(timeMillis);
        // 计算当前窗口的开始时间戳
        long windowStart = calculateWindowStart(timeMillis);

        /*
         * 从窗口数组中获取当前窗口项,分为三种情况
         * (1) 当前小窗口为空还未创建,则初始化一个并设置
         * (2) 当前窗口的开始时间和上面计算出的窗口开始时间一致,表明当前窗口还未过期,直接返回当前窗口
         * (3) 当前窗口的开始时间  小于  上面计算出的窗口开始时间,表明当前窗口已过期,需要替换当前窗口
         */
        while (true) {
     
            WindowWrap<T> old = array.get(idx);
            //当前小窗口为空还未创建,则初始化一个并设置
            if (old == null) {
     
                WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
                if (array.compareAndSet(idx, null, window)) {
     
                    return window;
                } else {
     
                    // Contention failed, the thread will yield its time slice to wait for bucket available.
                    Thread.yield();
                }
            //当前窗口的开始时间和上面计算出的窗口开始时间一致,表明当前窗口还未过期,直接返回当前窗口    
            } else if (windowStart == old.windowStart()) {
     
                return old;
            //当前窗口已过期,需要替换当前窗口
            } else if (windowStart > old.windowStart()) {
     
                if (updateLock.tryLock()) {
     
                    try {
     
                        // Successfully get the update lock, now we reset the bucket.
                        return resetWindowTo(old, windowStart);
                    } finally {
     
                        updateLock.unlock();
                    }
                } else {
     
                    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));
            }
        }
    }
    //根据当前时间戳计算当前所属的窗口数组索引下标
    private int calculateTimeIdx(/*@Valid*/ long timeMillis) {
     
        long timeId = timeMillis / windowLengthInMs;
        return (int)(timeId % array.length());
    }
    //计算当前窗口的开始时间戳
    protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
     
        return timeMillis - timeMillis % windowLengthInMs;
    }

currentWindow()是整个滑动窗口逻辑的核心代码,该方法主要分为以下几步

  1. 根据当前时间戳 和 窗口数组大小 获取到当前的窗口数组索引下标idx,如果窗口数是2,那其实idx只有两种值(0 或 1)
  2. 根据当前时间戳(windowStart) 计算得到当前窗口的开始时间戳值。
  3. 根据数组下标与当前时间窗口开始值 从窗口数组中获取当前窗口项,分为三种情况
    1. 当前小窗口为空还未创建,则初始化一个并设置
    2. 当前窗口的开始时间和上面计算出的窗口开始时间一致,表明当前窗口还未过期,直接返回当前窗口
    3. 当前窗口的开始时间 小于 上面计算出的窗口开始时间,表明当前窗口已过期,需要替换当前窗口。

示意图如下

1. 如果array数据里面的bucket数据如下:
  NULL      B4
|_______|_______|
800     1000    1200   
    ^
   time=888
正好当前时间所对应的槽位里面的数据是空的,那么就用CAS更新

2. 如果array里面已经有数据了,并且槽位里面的窗口开始时间和当前的开始时间相等,那么直接返回
      B3      B4
 |_______|_______|
800     1000    1200  
      ^
    time=888

3. 例如当前时间是1676,所对应窗口里面的数据的窗口开始时间小于当前的窗口开始时间,那么加上锁,然后设置槽位的窗口开始时间为当前窗口开始时间,并把槽位里面的数据重置
   (old)
             B0      
 |_______||_______|
        1200     1400
    ^
  time=1676

通过rollingCounterInSecond.success获取窗口的调用的成功数后,再调用rollingCounterInSecond.rt() * 1.0 / successCount获取平均rt。ollingCounterInSecond.rt()方法如下,这个方法和上面的success方法差不多,获取所有的MetricBucket的rt数据求和返回。

    @Override
    public long rt() {
     
        data.currentWindow();
        long rt = 0;
        //获取当前时间窗口的统计数据
        List<MetricBucket> list = data.values();
        //统计当前时间窗口的响应时间之和
        for (MetricBucket window : list) {
     
            rt += window.rt();
        }
        return rt;
    }

至此,根据请求平均响应时间的的逻辑介绍完了。主要逻辑顺序为

  1. 获取资源的平均响应时间
  2. 如果平均响应时间小于用户设定的阈值,则重置大于阈值的请求数passCount,不降级。
  3. 如果平均响应时间大于阈值,那么passCount加1,如果passCount大于5,那么直接降级。
根据异常比例降级
    //获取每秒异常的次数
    double exception = clusterNode.exceptionQps();
    //获取每秒请求完成的次数,返回的是成功执行完了Slot链且没有被规则拦截的数量
    double success = clusterNode.successQps();
    //获取每秒总调用次数 总qps=passQps+blockQps
    double total = clusterNode.totalQps();
    // 如果总调用次数少于5,那么不进行降级
    if (total < RT_MAX_EXCEED_N) {
     
        return true;
    }
    // 在相同的对齐统计时间窗口中,
    // 如果调用全部失败,并且失败次数小于5,不降级
    double realSuccess = success - exception;
    if (realSuccess <= 0 && exception < RT_MAX_EXCEED_N) {
     
        return true;
    }
    //异常率比设定的阈值小,不降级
    if (exception / success < count) {
     
        return true;
    }
    ...
    return false;

注意:clusterNode.successQps()返回的是成功执行Slot链且没有被规则拦截的数量。clusterNode.exceptionQps()返回的是成功执行Slot链且没有被规则拦截的数量并且业务处理中出现异常的数量,该异常被Tracer.trace(t)捕获,才会计入统计。
所以realSuccess需要减去重合的部分才是真正成功的数量

上述获取完成调用的Qps和异常调用的Qps,验证后,然后求一下比率,如果没有大于设定的阈值或调用次数小于5次,那么就返回true,否则返回false抛出异常

获取异常请求数,完成调用请求数与总的调用请求数的逻辑如下

    public double exceptionQps() {
     
        return rollingCounterInSecond.exception() / rollingCounterInSecond.getWindowIntervalInSec();
    }
    
    public double successQps() {
     
        return rollingCounterInSecond.success() / rollingCounterInSecond.getWindowIntervalInSec();
    }

    public double totalQps() {
     
        return passQps() + blockQps();
    }
    
    public double passQps() {
     
        return rollingCounterInSecond.pass() / rollingCounterInSecond.getWindowIntervalInSec();
    }
    
    public double blockQps() {
     
        return rollingCounterInSecond.block() / rollingCounterInSecond.getWindowIntervalInSec();
    }

上述这些方法的逻辑与rollingCounterInSecond.success()的基本类似,这里不再继续展开分析。

根据异常数降级
double exception = clusterNode.totalException();
if (exception < count) {
     
    return true;
}

根据异常数降级是非常的直接的,直接根据统计的异常总次数判断是否超过count。注意这里是rollingCounterInMinute,也就是异常数只能分钟纬度设定。

public long totalException() {
     
    return rollingCounterInMinute.exception();
}

到这里就讲完了降级的实现。

总结

本文介绍了sentinel熔断机制的使用及其实现原理,在Sentinel的流量统计与加载限流规则的原理上未做详细介绍,后续继续学习。上述描述如有问题,欢迎大家批评指正。

参考

  • Sentinel官网
  • sentinel中的概念
  • 常用的限流算法
  • 滑动窗口流量统计

你可能感兴趣的:(setinel,java,java,后端)