Sentinel1.8.0源码分析

22 篇文章 11 订阅
1 篇文章 1 订阅

Sentinel介绍

sentinel主要用来做资源保护,也就是限流降级熔断调度,一般我们说的限流主要是针对于接口来说的,当接口的处理能力达到了上线过后,如果这个时候接口还继续接受请求,那么导致的结果就是服务崩溃,无法正常的处理业务,而sentinel的主要作用就是最服务要调用的接口进行保护,根据接口的可承受请求数进行保护,当请求达到一定的级别过后进行限流,并且友好的返回给调用方,简单来说sentinel做的事情就是对接口做资源保护,那么它的做法其实很简单,一般我们能想到的都是做一个aop切面,切的就是具体受保护的资源,在真正调用的时候先对资源进行保护,如果没有达到限流的标准,那么就放行这个请求,如果达到了限流的阈值,那么就抛出一个BlockException,如果远程业务报错,那么抛出一个业务异常,那么在sentinel中都可以是对异常进行拦截处理;简单来说sentinel自动注入了两个类,一个是切面AOP(SentinelResourceAspect),这是一个AOP,它的切点是@SentinelResource,一个是HandlerInterceptor,sentinel对它的实现是SentinelWebInterceptor,其实这两个的作用都是拦截到请求,然后做资源保护,根据一定的限流规则确定是否放行,如果放行则正常调用,如果不放行,那么抛出BlockException,区别是切面AOP处理的是@SentinelResource注解,而拦截器主要处理的是controller中的restful服务地址,如果你在sentinel中配置了restful地址的限流,那么这个拦截器就会进行资源保护从而进行限流。

Sentinel涉及的限流算法

我们来看一下,目前已知的有哪些限流算法,比较常用的就是计数器限流、滑动时间窗口、漏桶算法、令牌桶算法,这几个限流算法是比较常见的,但是他们都有特点以及不足呢?

计数器法

计数器法是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个 请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter。
在这里插入图片描述
具体算法的伪代码:

/**
 * 最简单的计数器限流算法
 */
public class Counter {
    public long timeStamp = System.currentTimeMillis();  // 当前时间
    public int reqCount = 0;  // 初始化计数器
    public final int limit = 100; // 时间窗口内最大请求数
    public final long interval = 1000 * 60; // 时间窗口ms

    public boolean limit() {
        long now = System.currentTimeMillis();
        if (now < timeStamp + interval) {
            // 在时间窗口内
            reqCount++;
            // 判断当前时间窗口内是否超过最大请求控制数
            return reqCount <= limit;
        } else {
            timeStamp = now;
            // 超时后重置
            reqCount = 1;
            return true;
        }
    }
}

滑动时间窗口算法

滑动时间窗口,又称rolling window。为了解决计数器法统计精度太低的问题,引入了滑动窗口算法。下面这张图,很好地解释了滑动窗口算法:
在这里插入图片描述
在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。
计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。
由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
具体算法的伪代码:

/**
 * 滑动时间窗口限流实现
 * 假设某个服务最多只能每秒钟处理100个请求,我们可以设置一个1秒钟的滑动时间窗口,
 * 窗口中有10个格子,每个格子100毫秒,每100毫秒移动一次,每次移动都需要记录当前服务请求的次数
 */
public class SlidingTimeWindow {
    //服务访问次数,可以放在Redis中,实现分布式系统的访问计数
    Long counter = 0L;
    //使用LinkedList来记录滑动窗口的10个格子。
    LinkedList<Long> slots = new LinkedList<Long>();

    public static void main(String[] args) throws InterruptedException {
        SlidingTimeWindow timeWindow = new SlidingTimeWindow();

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    timeWindow.doCheck();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

        while (true){
            //TODO 判断限流标记
            timeWindow.counter++;
            Thread.sleep(new Random().nextInt(15));
        }
    }

    private void doCheck() throws InterruptedException {
        while (true) {
            slots.addLast(counter);
            if (slots.size() > 10) {
                slots.removeFirst();
            }
            //比较最后一个和第一个,两者相差100以上就限流
            if ((slots.peekLast() - slots.peekFirst()) > 100) {
                System.out.println("限流了。。");
                //TODO 修改限流标记为true
            }else {
                //TODO 修改限流标记为false
            }

            Thread.sleep(100);
        }
    }
}

漏桶算法

漏桶算法,又称leaky bucket。
在这里插入图片描述
从图中我们可以看到,整个算法其实十分简单。首先,我们有一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率。而且,当桶满了之后,多余的水将会溢出。
我们将算法中的水换成实际应用中的请求,我们可以看到漏桶算法天生就限制了请求的速度。当使用了漏桶算法,我们可以保证接口会以一个常速速率来处理请求。所以漏桶算法天生不会出现临界问题。
具体的伪代码如下:

/**
 * 漏桶限流算法
 */
public class LeakyBucket {
        public long timeStamp = System.currentTimeMillis();  // 当前时间
        public long capacity; // 桶的容量
        public long rate; // 水漏出的速度(每秒系统能处理的请求数)
        public long water; // 当前水量(当前累积请求数)

        public boolean limit() {
            long now = System.currentTimeMillis();
            water = Math.max(0, water - ((now - timeStamp)/1000) * rate); // 先执行漏水,计算剩余水量
            timeStamp = now;
            if ((water + 1) < capacity) {
                // 尝试加水,并且水还未满
                water += 1;
                return true;
            } else {
                // 水满,拒绝加水
                return false;
        }
    }
}

令牌桶算法

令牌桶算法,又称token bucket。同样为了理解该算法,我们来看一下该算法的示意图:
在这里插入图片描述
从图中我们可以看到,令牌桶算法比漏桶算法稍显复杂。首先,我们有一个固定容量的桶,桶里存放着令牌(token)。桶一开始是空的,token以 一个固定的速率r往桶里填充,直到达到桶的容量,多余的令牌将会被丢弃。每当一个请求过来时,就会尝试从桶里移除一个令牌,如果没有令牌的话,请求无法通过。
具体的伪代码如下:

/**
 * 令牌桶限流算法
 */
public class TokenBucket {
    public long timeStamp = System.currentTimeMillis();  // 当前时间
    public long capacity; // 桶的容量
    public long rate; // 令牌放入速度
    public long tokens; // 当前令牌数量

    public boolean grant() {
        long now = System.currentTimeMillis();
        // 先添加令牌
        tokens = Math.min(capacity, tokens + (now - timeStamp) * rate);
        timeStamp = now;
        if (tokens < 1) {
            // 若不到1个令牌,则拒绝
            return false;
        } else {
            // 还有令牌,领取令牌
            tokens -= 1;
            return true;
        }
    }
}

限流算法小结
计数器 VS 滑动窗口:
计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。
滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。
也就是说,如果滑动窗口的精度越高,需要的存储空间就越大。

漏桶算法 VS 令牌桶算法:
漏桶算法和令牌桶算法最明显的区别是令牌桶算法允许流量一定程度的突发。
因为默认的令牌桶算法,取走token是不需要耗费时间的,也就是说,假设桶内有100个token时,那么可以瞬间允许100个请求通过。
当然我们需要具体情况具体分析,只有最合适的算法,没有最优的算法。

@SentinelResource注解的源码解析

前面已经知道了@SentinelResource注解是通过spring对的自动装配进行注入的,自动注入的类是SentinelAutoConfiguration,在这个自动装配类中通过@Bean导入了一个Bean,这个bean是一个Aop的切面,这个切面切的就是@SentinelResource注解,这个切面的类的名字是SentinelResourceAspect,我们关注下它的大概的逻辑:

SentinelResourceAspect

在这里插入图片描述
在这里有几行关键的代码

entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs());
Object result = pjp.proceed();

第一行代码很显然就是sentinel的资源保护的具体逻辑,这个逻辑中对受保护的资源进行过滤判断,判断是否达到了限流或者降级的条件,如果达到了限流的条件,那么就会抛出BlockException,所以简单的来理解就是在真正的调用之前pjp.proceed()方法之前进行资源保护降级限流,如果没有降级和限流,则正常执行代码逻辑,否则进行降级限流,如果降级限流的逻辑中达到了限流的条件则抛出BlockException,这样就表示已经被限流了,我们可以编写ExceptionHandler来获取这个异常并且向客户端做一个友好的提示。所以接下来我们就要详细的分析SphU.entry这个方法的具体逻辑是怎么实现的了。

com.alibaba.csp.sentinel.CtSph#entryWithType
限流逻辑的真正入口

@Override
public Entry entryWithType(String name, int resourceType, EntryType entryType, int count, boolean prioritized,
                           Object[] args) throws BlockException {
    //这里将资源的名称,资源的entryType,资源的类型封装成了一个Wrapper,然后调用
    StringResourceWrapper resource = new StringResourceWrapper(name, entryType, resourceType);
    //这里的prioritized是false,表示不安优先级来
    return entryWithPriority(resource, count, prioritized, args);
}
private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args)
    throws BlockException {
    Context context = ContextUtil.getContext();
    if (context instanceof NullContext) {
        // The {@link NullContext} indicates that the amount of context has exceeded the threshold,
        // so here init the entry only. No rule checking will be done.
        return new CtEntry(resourceWrapper, null, context);
    }

    if (context == null) {
        // Using default context.
        context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME);
    }

    // Global switch is close, no rule checking will do.
    if (!Constants.ON) {
        return new CtEntry(resourceWrapper, null, context);
    }
    /**
     * 这里是整个架构的核心所在,这里是在构建一个处理链,这个处理链是一个单向链表结构,类似于Filter一样,构建这个链条的
     * 原因是对业务进行解耦,像限流资源保护有很多,比如限流、降级、热点参数、系统降级等等,如果都写在一起就耦合很严重,我们知道oop的
     * 思想就是让每个类确定各自的职责,不要让他做不相干的事情,所以这里将业务进行全面解耦,然后在解耦的同事又通过链式编程将它们窜起来
     */
    ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);

    /*
     * Means amount of resources (slot chain) exceeds {@link Constants.MAX_SLOT_CHAIN_SIZE},
     * so no rule checking will be done.
     */
    if (chain == null) {
        return new CtEntry(resourceWrapper, null, context);
    }

    //resourceWrapper是受保护的资源封装的一个Wrapper对象,chain就是这个资源要经过的过滤slot
    //封装成了一个CtEntry
    Entry e = new CtEntry(resourceWrapper, chain, context);
    try {
        //启用链条的调用,我们看了之前的代码知道了chain的类型是DefaultProcessorSlotChain,所以调用的开始路径是DefaultProcessorSlotChain的
        //entry方法,在这里方法里面开始chain调用
        chain.entry(context, resourceWrapper, null, count, prioritized, args);
    } catch (BlockException e1) {
        e.exit(count, args);
        throw e1;
    } catch (Throwable e1) {
        // This should not happen, unless there are errors existing in Sentinel internal.
        RecordLog.info("Sentinel unexpected exception", e1);
    }
    return e;
}

上面最重要的两个方法lookProcessChain和chain.entry
lookProcessChain

ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) {
    //构建的SlotChain是可以放入缓存的,也就是说是一个懒加载的,第一次过来SlotChain还没有
    //构建的时候,就会自动构建出slotChain,然后放入缓存,第二次就直接从缓存中获取
    //当然了这里需要做dbcheck,这个是并发编程的最基本要考虑的因素,没个资源拥有单独的一份slotChain
    ProcessorSlotChain chain = chainMap.get(resourceWrapper);
    if (chain == null) {
        synchronized (LOCK) {
            chain = chainMap.get(resourceWrapper);
            if (chain == null) {
                // Entry size limit.
                if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {
                    return null;
                }

                //构建一个slotchain
                chain = SlotChainProvider.newSlotChain();
                Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<ResourceWrapper, ProcessorSlotChain>(
                    chainMap.size() + 1);
                //放入缓存
                newMap.putAll(chainMap);
                newMap.put(resourceWrapper, chain);
                chainMap = newMap;
            }
        }
    }
    return chain;
}

SlotChainProvider.newSlotChain()

public static ProcessorSlotChain newSlotChain() {

    //slotChainBuilder是构建slotchain的一个对象

    if (slotChainBuilder != null) {
        return slotChainBuilder.build();
    }

    // Resolve the slot chain builder SPI.
    //通过spi(JDK内置的SPI机制)去加载slotChainBuilder对应的实现类
    //那么必然在sentinel-core的META-INF/services下面有一个文件com.alibaba.csp.sentinel.slotchain.SlotChainBuilder
    //这个文件中记录的就是slotChainBuilder对应的实现类DefaultSlotChainBuilder,所以这里是通过JDK内置的SPI找到这个实现类然后反射得到这个对象
    slotChainBuilder = SpiLoader.loadFirstInstanceOrDefault(SlotChainBuilder.class, DefaultSlotChainBuilder.class);

    if (slotChainBuilder == null) {
        // Should not go through here.
        RecordLog.warn("[SlotChainProvider] Wrong state when resolving slot chain builder, using default");
        //如果spi没有配置,就默认创建这个DefaultSlotChainBuilder
        slotChainBuilder = new DefaultSlotChainBuilder();
    } else {
        RecordLog.info("[SlotChainProvider] Global slot chain builder resolved: "
            + slotChainBuilder.getClass().getCanonicalName());
    }
    //通过DefaultSlotChainBuilder的build方法构建slotchain
    return slotChainBuilder.build();
}

真正构建SlotChain的方法build
com.alibaba.csp.sentinel.slots.DefaultSlotChainBuilder#build

@Override
public ProcessorSlotChain build() {
    ProcessorSlotChain chain = new DefaultProcessorSlotChain();

    // Note: the instances of ProcessorSlot should be different, since they are not stateless.
    //这里也是通过spi机制去加载Slotchain的实现,就是所有实现了ProcessorSlot的接口都是通过spi去找到的
    //根据JDK内置的SPI,那么在core包下面的META-INF/services下面比有一个文件com.alibaba.csp.sentinel.slotchain.ProcessorSlot
    //这个文件的内容如下:
    //com.alibaba.csp.sentinel.slots.nodeselector.NodeSelectorSlot:资源路径的收集
    //com.alibaba.csp.sentinel.slots.clusterbuilder.ClusterBuilderSlot:集群资源路径的收集
    //com.alibaba.csp.sentinel.slots.logger.LogSlot:日志的打印,主要出了异常过后进行日志打印,可以收集到日志平台
    //com.alibaba.csp.sentinel.slots.statistic.StatisticSlot:资源的统计,比如资源放行增加资源数
    //com.alibaba.csp.sentinel.slots.block.authority.AuthoritySlot:授权的过滤
    //com.alibaba.csp.sentinel.slots.system.SystemSlot:系统的过滤
    //com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowSlot:热点参数的过滤
    //com.alibaba.csp.sentinel.slots.block.flow.FlowSlot:限流的过滤
    //com.alibaba.csp.sentinel.slots.block.degrade.DegradeSlot:降级的过滤
    List<ProcessorSlot> sortedSlotList = SpiLoader.loadPrototypeInstanceListSorted(ProcessorSlot.class);
    for (ProcessorSlot slot : sortedSlotList) {
        if (!(slot instanceof AbstractLinkedProcessorSlot)) {
            RecordLog.warn("The ProcessorSlot(" + slot.getClass().getCanonicalName() + ") is not an instance of AbstractLinkedProcessorSlot, can't be added into ProcessorSlotChain");
            continue;
        }

        //这里将所有找到的slot通过链表进行构建起来
        //first->NodeSelectorSlot->ClusterBuilderSlot->LogSlot->StatisticSlot->AuthoritySlot->
        //SystemSlot->ParamFlowSlot->FlowSlot->DegradeSlot->end->null
        //其中first是AbstractLinkedProcessorSlot,所以调用的时候也是从AbstractLinkedProcessorSlot开始的
        chain.addLast((AbstractLinkedProcessorSlot<?>) slot);
    }

    return chain;
}

上面是构建SlotChain的一个责任链,构建好以后,对一个请求资源保护的流程就是执行这个SlotChain,所以下来就分析这个责任链到底做了哪些事情
NodeSelectorSlot#entry

public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count, boolean prioritized, Object... args)
    throws Throwable {
    //资源路径收集:
    //负责收集资源路径,将这些路径通过树状结构存储起来,用户根据调用路径来进行限流
    DefaultNode node = map.get(context.getName());
    if (node == null) {
        synchronized (this) {
            node = map.get(context.getName());
            if (node == null) {
                node = new DefaultNode(resourceWrapper, null);
                HashMap<String, DefaultNode> cacheMap = new HashMap<String, DefaultNode>(map.size());
                cacheMap.putAll(map);
                cacheMap.put(context.getName(), node);
                map = cacheMap;
                // Build invocation tree
                ((DefaultNode) context.getLastNode()).addChild(node);
            }

        }
    }

    //收集好了以后设置到当前路径下
    context.setCurNode(node);
    //调用下一个slot
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}

ClusterBuilderSlot#entry

public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args)
    throws Throwable {
    //这里是集群模式下的资源路径的收集
    if (clusterNode == null) {
        synchronized (lock) {
            if (clusterNode == null) {
                // Create the cluster node.
                clusterNode = new ClusterNode(resourceWrapper.getName(), resourceWrapper.getResourceType());
                HashMap<ResourceWrapper, ClusterNode> newMap = new HashMap<>(Math.max(clusterNodeMap.size(), 16));
                newMap.putAll(clusterNodeMap);
                newMap.put(node.getId(), clusterNode);

                clusterNodeMap = newMap;
            }
        }
    }
    node.setClusterNode(clusterNode);
    ....

LogSlot#entry

public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode obj, int count, boolean prioritized, Object... args)
    throws Throwable {
    try {
        //log这里没有做什么事情,只是对调用下一个slot做了异常捕获,然后打印异常信息,所以这里就可以将这个异常信息接入到日志平台
        //然后展示。调用下一个slot StatisticSlot
        fireEntry(context, resourceWrapper, obj, count, prioritized, args);
    } catch (BlockException e) {
        EagleEyeLogUtil.log(resourceWrapper.getName(), e.getClass().getSimpleName(), e.getRuleLimitApp(),
            context.getOrigin(), count);
        throw e;
    } catch (Throwable e) {
        RecordLog.warn("Unexpected entry exception", e);
    }

}

StatisticSlot#entry
这个是统计的slot,就是这个entry方法中会先去调用其他的slot,然后它来统计,如果没有抛出异常,那么这个里面会将通过数+1,如果出现了异常,那么block数+1,最后抛出异常,所以这个统计做的是事情就是统计qps和异常的,然后决定怎么抛出这个异常;它里面还要做的事情还有exit的时候会去设置调用完成的时间的设置。
statistic.StatisticSlot#entry

public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args) throws Throwable {
    try {
        // Do some checking.
        //这个slot是统计的意思,进入这个方法什么都没有做就开始调用下一个slot了AuthoritySlot
        /**
         * 然后这里在调用完成以后进行了很多的处理,那么这里调用完成以后的处理是处理的什么呢?这里主要是
         * 如果后面的slot都调用成功了,那么资源数也就是对应的qps也就需要+1,也就是在这个时间窗口上通过了一次资源
         * 包括异常的处理以及限流算法的处理,当调用完成以后或者异常过后,会调用 node.addPassRequest(count);
         * 表示通过数加1,在这个时间窗口上通过数+1,那么达到配置的限流qps就会进行限流
         */
        fireEntry(context, resourceWrapper, node, count, prioritized, args);

        // Request passed, add thread count and pass count.
        node.increaseThreadNum();
        //sentinel这里使用的是滑动窗口的限流法,所以这里的node.addPassRequest就是通过滑动窗口来限流的
        node.addPassRequest(count);
        .....

这里统计还有一个很重要的点就是增加通过数使用的滑动窗口,我们看下滑动窗口的计算方式
node.addPassRequest(count)

public void addPassRequest(int count) {
    super.addPassRequest(count);
    this.clusterNode.addPassRequest(count);
}
public void addPassRequest(int count) {
    //这里的滑动时间窗口,sentinel添加了两个滑动时间窗口
    //一个是秒的,一个是分钟的
    rollingCounterInSecond.addPass(count);
    rollingCounterInMinute.addPass(count);
}

com.alibaba.csp.sentinel.slots.statistic.metric.ArrayMetric#addPass

@Override
public void addPass(int count) {
    /**
     * 这里是获取当前的窗口,也就是说这里要根据当前请求的时间来计算这个请求数应该落在那个窗体的格子里面
     * 就拿秒的来说,窗口只有两个2个格子,所以这里要计算的是我的这个当前请求应该落在那个格子里面,计算的方式
     * 是通过当前的时间通过一些算法进行计算的
     *
     */
    WindowWrap<MetricBucket> wrap = data.currentWindow();
    wrap.value().addPass(count);
}

public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null;
    }

    /**
     * timeMillis是当前求的当前时间,这里就开始计算一个idx,这个idx
     * 就是当前请求应该落在那个格子里面,这个方法有两行代码
     * long timeId = timeMillis / windowLengthInMs;
     * return (int)(timeId % array.length());
     *
     * timeId用当前时间除以windowLengthInMs,对于秒的来说,windowLengthInMs=1000/2得到的,也就是500
     * 所以timeId就是当前时间除以500,比如当前请求的时间是800ms,那么timeId=800/500=1
     * 第二行代码用这个1 % 2取模得到的也是1,也就是说这个时候当前请求是落在了第二个给子里面,所以idx=1
     */

    int idx = calculateTimeIdx(timeMillis);
    // Calculate current bucket start time.
    /**
     * 这里计算的是当前请求在窗口的开始时间,这个方法只有一行代码
     *  return timeMillis - timeMillis % windowLengthInMs;
     *  就是当前时间减去当前时间 和时间窗口长度的取模,简单来说就是800 - 800 % 500=500
     *  也就是说windowStar=500,也就是说这个当前请求的格子的开始时间是500ms
     *
     *  明白了calculateTimeIdx和calculateWindowStart才能看懂下面的几行滑动时间窗口的算法
     *
     *  idx:当前请求应该落在那个格子的下标
     *  windowStart:当前请求的所在格子的开始时间
     */
    long windowStart = calculateWindowStart(timeMillis);

    /*
     * Get bucket item at given time from the array.
     *
     * (1) Bucket is absent, then just create a new bucket and CAS update to circular array.
     * (2) Bucket is up-to-date, then just return the bucket.
     * (3) Bucket is deprecated, then reset current bucket and clean all deprecated buckets.
     */
    while (true) {
        //array是滑动窗口的格子数组,对于秒来说,这个array只有两个格子,如果是第一次请求的话,那么这个array肯定是空的
        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.
             */
            //空的就直接创建一个新的格子,然后通过cas添加到array中
            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()) {
            /**
             * 如果是array的格子都是满的,这个时候过来的请求就可能走到这里,比如上面说的计算的idx=1,windowStart=500
             * 那么如果windowStart == old.windowStart(),也就是当前计算出出来的格子的时间开始时间本来就等于
             * 通过计算出来的idx的格子,那么是不是就表示当前时间窗口的格子的时间窗口还未结束,那么当前请求数就应该落在
             * idx=1的这个格子,所以只需要把这个格子给返回就可以了
             */
            /*
             *     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()) {
            /**
             * 这个条件的意思和上面差不多,就是计算出来的idx和windowStart不在一个窗口格子里面,简单来说就是
             * 计算出来的idx=1,但是时间窗口是不在这个idx=1的windowStart,所以这里就有一个滑动的概念
             * 举个例子:比如1700ms的时候过来了,那么根据上面的两个方法的计算
             * idx=(1700/500) % 2=1
             * windowStart=1700-1700 % 500 = 1500
             * 那么idx=1的这个格子的windowStart是500,所以1500肯定大于了这个窗体的开始时间,那么就代表当前时间
             * 已经大于了这个格子的时间了,所以需要滑动窗口了,这里的滑动窗口就是重置窗口的信息,重置计数器和时间
             * resetWindowTo所做的事情就是将窗口的开始时间设置为1500这里,然后计数器清零,并且增加计数器为1,因为当前请求通过
             */
            /*
             *   (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));
        }
    }
}

AuthoritySlot#entry
授权的处理

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args)
    throws Throwable {
    //这里是对资源授权也就是黑白名单的过滤
    checkBlackWhiteAuthority(resourceWrapper, context);
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
void checkBlackWhiteAuthority(ResourceWrapper resource, Context context) throws AuthorityException {
    //得到系统中的所有授权规则
    Map<String, Set<AuthorityRule>> authorityRules = AuthorityRuleManager.getAuthorityRules();

    //如果没有配置授权规则,直接返回,不进行过滤
    if (authorityRules == null) {
        return;
    }

    //得到配置的根据资源名称所得到的授权规则
    Set<AuthorityRule> rules = authorityRules.get(resource.getName());
    if (rules == null) {
        return;
    }

    for (AuthorityRule rule : rules) {
        //循环所有的授权规则,进行校验
        if (!AuthorityRuleChecker.passCheck(rule, context)) {
            throw new AuthorityException(context.getOrigin(), rule);
        }
    }
}

SystemSlot#entry


@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args) throws Throwable {
    //系统的过滤
    SystemRuleManager.checkSystem(resourceWrapper);
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
public static void checkSystem(ResourceWrapper resourceWrapper) 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
    //系统规则的过滤,这里是过滤qps,就是设置了系统的所有入口qps不能大于多少,如果配置了就需要验证
    double currentQps = Constants.ENTRY_NODE == null ? 0.0 : Constants.ENTRY_NODE.successQps();
    //如果大于配置的入口qps,则直接限流降级
    if (currentQps > qps) {
        throw new SystemBlockException(resourceWrapper.getName(), "qps");
    }

    // total thread
    //这里限流的是最大线程数
    int currentThread = Constants.ENTRY_NODE == null ? 0 : Constants.ENTRY_NODE.curThreadNum();
    if (currentThread > maxThread) {
        throw new SystemBlockException(resourceWrapper.getName(), "thread");
    }

    double rt = Constants.ENTRY_NODE == null ? 0 : Constants.ENTRY_NODE.avgRt();
    //最大相应时间rt
    if (rt > maxRt) {
        throw new SystemBlockException(resourceWrapper.getName(), "rt");
    }

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

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

FlowSlot#entry流控

public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args) throws Throwable {
    //这个是流控的限流检查
    checkFlow(resourceWrapper, context, node, count, prioritized);

    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}

com.alibaba.csp.sentinel.slots.block.flow.FlowRuleChecker#checkFlow

public void checkFlow(Function<String, Collection<FlowRule>> ruleProvider, ResourceWrapper resource,
                      Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
    if (ruleProvider == null || resource == null) {
        return;
    }
    //根据资源名称得到这个资源配置的所有流控规则
    Collection<FlowRule> rules = ruleProvider.apply(resource.getName());
    if (rules != null) {
        //循环这个流控规则,进行流控检查
        for (FlowRule rule : rules) {
            if (!canPassCheck(rule, context, node, count, prioritized)) {
                //流控返回false,则抛出FlowExecption
                throw new FlowException(rule.getLimitApp(), rule);
            }
        }
    }
}
public boolean canPassCheck(/*@NonNull*/ FlowRule rule, Context context, DefaultNode node, int acquireCount,
                                                boolean prioritized) {
    String limitApp = rule.getLimitApp();
    if (limitApp == null) {
        return true;
    }

    if (rule.isClusterMode()) {
        return passClusterCheck(rule, context, node, acquireCount, prioritized);
    }

    //单机本地的流控检查 prioritized传过来默认是false
    return passLocalCheck(rule, context, node, acquireCount, prioritized);
}
private static boolean passLocalCheck(FlowRule rule, Context context, DefaultNode node, int acquireCount,
                                      boolean prioritized) {
    Node selectedNode = selectNodeByRequesterAndStrategy(rule, context, node);
    if (selectedNode == null) {
        return true;
    }

    //rule.getRater()得到流控模式,流控模式有三种
    //DefaultController:快速失败,采用的是滑动时间窗口
    //RateLimiterController:排队等待,采用的是漏桶算法
    //WarmUpController:预热模式(Warm up),采用的是令牌桶算法
    return rule.getRater().canPass(selectedNode, acquireCount, prioritized);
}

com.alibaba.csp.sentinel.slots.block.flow.controller.DefaultController#canPass

public boolean canPass(Node node, int acquireCount, boolean prioritized) {
    //得到当前窗口的流量总和,然后和最大的流量qps或者线程数进行对比,当前窗口的总流量+当前请求数如果大于配置的值,则进行流控
    int curCount = avgUsedTokens(node);
    if (curCount + acquireCount > count) {
        if (prioritized && grade == RuleConstant.FLOW_GRADE_QPS) {
            long currentTime;
            long waitInMs;
            currentTime = TimeUtil.currentTimeMillis();
            waitInMs = node.tryOccupyNext(currentTime, acquireCount, count);
            if (waitInMs < OccupyTimeoutProperty.getOccupyTimeout()) {
                node.addWaitingRequest(currentTime + waitInMs, acquireCount);
                node.addOccupiedPass(acquireCount);
                sleep(waitInMs);

                // PriorityWaitException indicates that the request will pass after waiting for {@link @waitInMs}.
                throw new PriorityWaitException(waitInMs);
            }
        }
        return false;
    }
    return true;
}

com.alibaba.csp.sentinel.slots.block.flow.controller.RateLimiterController#canPass(com.alibaba.csp.sentinel.node.Node, int, boolean)

public boolean canPass(Node node, int acquireCount, boolean prioritized) {
        // Pass when acquire count is less or equal than 0.
        if (acquireCount <= 0) {
            return true;
        }
        // Reject when count is less or equal than 0.
        // Otherwise,the costTime will be max of long and waitTime will overflow in some cases.
        if (count <= 0) {
            return false;
        }

        long currentTime = TimeUtil.currentTimeMillis();
        // Calculate the interval between every two requests.
        //计算两个请求之间的耗时间隔,就是请求之间的间隔时间
        long costTime = Math.round(1.0 * (acquireCount) / count * 1000);

        // Expected pass time of this request.
        //计算期望通过的时间
        long expectedTime = costTime + latestPassedTime.get();

        //如果当前的时间大于了了期望通过的时间,则证明已经交易时间点已经到了后面了,直接通过
        if (expectedTime <= currentTime) {
            // Contention may exist here, but it's okay.
            //通过的时候设置上一次通过时间为当前时间
            latestPassedTime.set(currentTime);
            return true;
        } else {
            // Calculate the time to wait.
            //时间等待时间,期望通过的时间减去当前时间就是要等待排队的时间
//            如果要等待排队的时间大于了最大的等待时间,则流控,丢弃这个请求
            long waitTime = costTime + latestPassedTime.get() - TimeUtil.currentTimeMillis();
            if (waitTime > maxQueueingTimeMs) {
                return false;
            } else {
                //oldtime也是一个期望等待的时间
                long oldTime = latestPassedTime.addAndGet(costTime);
                try {
                    waitTime = oldTime - TimeUtil.currentTimeMillis();
                    if (waitTime > maxQueueingTimeMs) {
                        latestPassedTime.addAndGet(-costTime);
                        return false;
                    }
                    // in race condition waitTime may <= 0
                    //如果最后等待的时间验证没有问题了,就开会睡眠,到了指定的等待时间就唤醒执行
                    if (waitTime > 0) {
                        Thread.sleep(waitTime);
                    }
                    return true;
                } catch (InterruptedException e) {
                }
            }
        }
        return false;
    }

DegradeSlot.entry降级

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args) throws Throwable {
    /**
     * 这里是降级的限流规则检测,使用的是断路器来实现的,降级的规则有:
     * 可设置当慢调用比例达到配置的指定值的时候
     * 可设置当异常数达到配置的值得时候
     * 可设置当异常数达到配置的值得时候
     * 如果配置了上面的一种就,如果达到降级的条件就可以进行降级,降级的时长为配置的时长
     *
     * 降级规则这里使用的是熔断器来实现的,如果没有达到降级规则,熔断器的状态是关闭状态,可以放行请求
     * 当达到降级规则的时候,熔断器将被打开,当熔断器打开的时候,如果这个时候来了请求,那么就会判断当前熔断时长是否已经达到,如果
     * 达到了熔断时长,那么这个时候就先放过这个请求,并且熔断器是半开状态,如果这个请求正常执行成功以后,那么这个时候的熔断器就是
     * 关闭状态,如果这个请求还是达到了降级的条件,那么熔断器就由半开状态变为打开状态
     *
     * 方法performChecking只做了两件事
     * 1.首先判断熔断器是否是关闭状态,如果是关闭状态,直接放过这个请求;
     * 2.如果熔断器是打开状态,那么在判断熔断时长是否已经达到了指定的熔断时长,如果是则将熔断器由打开状态变为半开状态,并且放过这个请求;
     * 3.如果熔断时长没有达到配置的熔断时长,那么这个请求将会被丢弃;
     * 方法exit在资源回收的时候做的事情就是判断这个请求是否是慢调用、异常比例、异常数等有一个条件如果是满足的,也就是
     * 达到了降级条件,那么这个时候判断:
     * 1.如果熔断器是关闭状态,则打开熔断器;
     * 2.如果熔断器是半开状态,这个请求是放过的一个请求还是达到了降级条件,那么熔断器由半开修改为打开;
     * 3.如果熔断器是打开状态,直接返回;
     */

    performChecking(context, resourceWrapper);

    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
void performChecking(Context context, ResourceWrapper r) throws BlockException {
    //根据资源名称获取断路器的降级规则
    List<CircuitBreaker> circuitBreakers = DegradeRuleManager.getCircuitBreakers(r.getName());
    if (circuitBreakers == null || circuitBreakers.isEmpty()) {
        return;
    }
    for (CircuitBreaker cb : circuitBreakers) {
        //断路器的判断,这里的判断分为两步,打开和关闭
        //打开的时候判断熔断时长是否达到指定的时长,如果达到将断路器置为半开并放过这个请求,如果没有达到熔断时长则放弃这个请求
        //关闭的时候直接放过这个请求
        if (!cb.tryPass(context)) {
            throw new DegradeException(cb.getRule().getLimitApp(), cb.getRule());
        }
    }
}

com.alibaba.csp.sentinel.slots.block.degrade.circuitbreaker.AbstractCircuitBreaker#tryPass

public boolean tryPass(Context context) {
    // Template implementation.
    if (currentState.get() == State.CLOSED) {
        return true;
    }
    if (currentState.get() == State.OPEN) {
        //断路器是打开状态,retryTimeoutArrived() 判断熔断时长是否达到,达到了熔断器置为半开状态
        // For half-open state we allow a request for probing.
        return retryTimeoutArrived() && fromOpenToHalfOpen(context);
    }
    return false;
}

当请求方法调用完成以后,最后进入了finally以后(aop),会调用entry.exit也会执行slot责任链,执行这个责任链也会执行到降级的这里的exit方法,这里主要看下这个exit方法

public void exit(Context context, ResourceWrapper r, int count, Object... args) {
    Entry curEntry = context.getCurEntry();
    if (curEntry.getBlockError() != null) {
        fireExit(context, r, count, args);
        return;
    }
    List<CircuitBreaker> circuitBreakers = DegradeRuleManager.getCircuitBreakers(r.getName());
    if (circuitBreakers == null || circuitBreakers.isEmpty()) {
        fireExit(context, r, count, args);
        return;
    }

    //如果没有出现BlokException则进入资源回收的逻辑
    if (curEntry.getBlockError() == null) {
        // passed request
        for (CircuitBreaker circuitBreaker : circuitBreakers) {
            //这里的断路器有两个,一个是异常数和异常比例的处理,一个是RT(响应时间也就是慢调用比例的判断)的处理
            //ExceptionCircuitBreaker:处理异常比例和异常数的熔断器
            //ResponseTimeCircuitBreaker:响应时间的RT的熔断器
            circuitBreaker.onRequestComplete(context);
        }
    }

    fireExit(context, r, count, args);
}

慢调用的熔断器ResponseTimeCircuitBreaker#onRequestComplete

public void onRequestComplete(Context context) {
    //慢调用比例这里使用的也是滑动时间窗口,这里得到滑动窗口的计数器,也就是记录请求通过数的计数器
    SlowRequestCounter counter = slidingCounter.currentWindow().value();
    Entry entry = context.getCurEntry();
    if (entry == null) {
        return;
    }
    //completeTime是在调用统计StatisticSlot中调用完成会记录调用完成的时间
    //这里获取,当调用SphU.exit的时候会调用StatisticSlot的exit方法会记录这个completeTime,这个就是资源请求调用完成的时间
    long completeTime = entry.getCompleteTimestamp();
    if (completeTime <= 0) {
        completeTime = TimeUtil.currentTimeMillis();
    }
    //这里计算出的请求被放过以后调用的rt响应时间,就是调用资源申请通过的是时间(completeTime)- 资源创建的时间(创建Entry的时候会创建这个时间)
   //当使用SphU.entry的时候回创建createTimestamp这个时间,当调用完成的时候会设置completetime
    //所以这里的rt简单来说就是开始调用的时候记录一个时间,调用完成了记录一个时间,相减就是响应时间
    long rt = completeTime - entry.getCreateTimestamp();
    //如果响应时间rt大于了配置的最大允许通过的响应时间则视为慢调用,满调用时间窗口+1
    if (rt > maxAllowedRt) {
        counter.slowCount.add(1);
    }
    counter.totalCount.add(1);

    //这里是去处理慢调用的熔断器,判断熔断器是打开和还是关闭
    handleStateChangeWhenThresholdExceeded(rt);
}
private void handleStateChangeWhenThresholdExceeded(long rt) {
    //如果熔断器是打开的,则直接返回不用管
    if (currentState.get() == State.OPEN) {
        return;
    }

    //如果熔断器是半开的状态,那么就证明之前的熔断器是打开的状态,打开的时候来了一个新的请求,这个请求来的时候熔断时长已经达到了
    //所以放过了这个请求,并且将熔断器置为半开状态,那么下面的if判断里面的代码逻辑就是之前放过的请求
    //所以这里判断放过的这个请求是否正常执行,没有达到降级的条件,而这里的条件就是rt响应时间不能大于配置的最大响应时间
    if (currentState.get() == State.HALF_OPEN) {
        // In detecting request
        // TODO: improve logic for half-open recovery
        //如果放过的这个请求还是大于了最大的响应时间,那么这个时候熔断器又开始需要打开了,由半开状态置为打开状态
        if (rt > maxAllowedRt) {
            fromHalfOpenToOpen(1.0d);
        } else {
            //如果rt响应时间是没有达到配置的响应时间rt,则熔断器由半开置为关闭状态,正常执行,后续的请求可以正常执行
            //并且会重置滑动窗口的计数器
            fromHalfOpenToClose();
        }
        return;
    }

    List<SlowRequestCounter> counters = slidingCounter.values();
    long slowCount = 0;//慢调用次数
    long totalCount = 0;//总的调用次数
    //累计慢调用次数和总的调用次数
    for (SlowRequestCounter counter : counters) {
        slowCount += counter.slowCount.sum();
        totalCount += counter.totalCount.sum();
    }
    //这里的条件就是说总的调用次数没有达到配置的最小调用次数也不做判断
    if (totalCount < minRequestAmount) {
        return;
    }
    //计算慢调用比例
    double currentRatio = slowCount * 1.0d / totalCount;
    //如果慢调用比例大于了配置的慢调用比例值,则达到了熔断条件,将熔断器打开,熔断器打开过后,没有达到熔断时长都将被打开,所有请求都将被丢弃
    if (currentRatio > maxSlowRequestRatio) {
        transformToOpen(currentRatio);
    }
}

异常数和异常比例的熔断器ExceptionCircuitBreaker#onRequestComplete

public void onRequestComplete(Context context) {
    Entry entry = context.getCurEntry();
    if (entry == null) {
        return;
    }
    Throwable error = entry.getError();
    SimpleErrorCounter counter = stat.currentWindow().value();
    //如果erro不为null则认为是异常数,异常数的计数器+1
    if (error != null) {
        //出现异常,异常数+1
        counter.getErrorCount().add(1);
    }
    //总的调用次数+1
    counter.getTotalCount().add(1);

    handleStateChangeWhenThresholdExceeded(error);
}
private void handleStateChangeWhenThresholdExceeded(Throwable error) {
    if (currentState.get() == State.OPEN) {
        return;
    }
    
    if (currentState.get() == State.HALF_OPEN) {
        // In detecting request
        //和慢调用一样,如果熔断器是半开,放过的这个请求如果没有初学异常,则熔断器关闭
        if (error == null) {
            fromHalfOpenToClose();
        } else {
            //如果放过的这个请求还是出现了异常,则熔断器由半开置为打开状态
            fromHalfOpenToOpen(1.0d);
        }
        return;
    }
    
    List<SimpleErrorCounter> counters = stat.values();
    long errCount = 0;//调用异常次数
    long totalCount = 0;//调用总的次数
    //根据滑动时间窗口得到所有的窗口统计异常次数和总的调用次数
    for (SimpleErrorCounter counter : counters) {
        errCount += counter.errorCount.sum();
        totalCount += counter.totalCount.sum();
    }
    //如果总的调用次数没有达到最小的调用次数不做熔断处理
    if (totalCount < minRequestAmount) {
        return;
    }
    //默认是异常数的统计
    double curCount = errCount;
    //如果策略选择的是异常比例,则计算异常比例
    if (strategy == DEGRADE_GRADE_EXCEPTION_RATIO) {
        // Use errorRatio
        //计算阈值,这里策略采用的是异常比例,所以这里计算异常比例
        curCount = errCount * 1.0d / totalCount;
    }
    //这里判断异常比例或者异常数是否大于了配置的阈值,如果大于则达到熔断条件,达到熔断条件就打开熔断器
    if (curCount > threshold) {
        transformToOpen(curCount);
    }
}

基本的执行拦截流程就是一个slot责任链,如果要清楚他的执行流程就看这个slot责任链就可以了,我们知道sentinel还可以拦截controller中没有加@SentinelResource注解的资源,它是怎么做到的呢?其实和aop原理一样,只不过它使用的是拦截器,也就是spring的HandlerIntercepter,和SentinelResourceAspect一样的,也是通过自动装配注入进来的,具体的自动注入类是SentinelWebAutoConfiguration,注入的web拦截器是SentinelWebInterceptor,因为是web拦截器,所以它的处理就在preHandler方法里面了
在这里插入图片描述
和aop处理的那个一模一样,也是调用SphU.entry进行处理的。其他的就不多说了,我的源码注释只能作为参考,可根据这个参考研究源码的执行流程。

源码结构图

在这里插入图片描述

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值