1. Sentinel架构大致流程

  1. Sentinel其实就是一个AOP,通过AspectJ切入要进行限流的接口,为其添加@Around环绕通知,并使用try-catch包裹起来,源码在SentinelAutoConfiguration

  2. 每一个对该限流接口的请求,都要经过AOP的增强,先执行过一系列流控、熔断规则组成的责任链,然后才执行真正的接口逻辑。责任链的组装使用了原生的spi机制,流控规则可以在sentinel控制台去配置,配置完毕后会填入sentinel服务端,也就是我们的某一个服务,请求流控接口时,就会触发流控逻辑!
    Sentinel计算QPS限流算法_java

    @Aspect //使用的AOP
    public class SentinelResourceAspect extends AbstractSentinelAspectSupport {
    
    	//切入点
        @Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)")
        public void sentinelResourceAnnotationPointcut() {
        }
        
    	//环绕通知
        @Around("sentinelResourceAnnotationPointcut()")
        public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable {
            Method originMethod = resolveMethod(pjp);
    		//1.获取 @SentinelResource 注解
            SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class);
            if (annotation == null) {
                // Should not go through here.
                throw new IllegalStateException("Wrong state for SentinelResource annotation");
            }
            String resourceName = getResourceName(annotation.value(), originMethod);
            EntryType entryType = annotation.entryType();
            int resourceType = annotation.resourceType();
            Entry entry = null;
            try {
            	// 2.执行流控组成的责任链
                entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs());
                // 3.执行业务方法
                Object result = pjp.proceed();
                return result;
            } catch (BlockException ex) {
            	//4. 抛出流控异常
                return handleBlockException(pjp, annotation, ex);
            } catch (Throwable ex) {
            	// 5. 抛出业务异常
                Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore();
                // The ignore list will be checked first.
                if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) {
                    throw ex;
                }
                if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) {
                    traceException(ex);
                    return handleFallback(pjp, annotation, ex);
                }
    
                // No fallback function can handle the exception, so throw it out.
                throw ex;
            } finally {
                if (entry != null) {
                	//6.所有流控的收尾逻辑,比如:断路器半开状态重试
                    entry.exit(1, pjp.getArgs());
                }
            }
        }
    }
    
  3. 如果执行中抛出异常,异常分为流控异常BlockException 和 业务异常Throwable,流控异常可以使用blockHandler进行处理,业务异常使用fallback处理!

    @RequestMapping(value = "/findOrderByUserId/{id}")
    @SentinelResource(value = "findOrderByUserId",
    				  //业务异常,ExceptionUtil类中的fallback方法来处理
                      fallback = "fallback",fallbackClass = ExceptionUtil.class, 
                      //流控异常,ExceptionUtil类中的handleException方法来处理
                      blockHandler = "handleException",blockHandlerClass = ExceptionUtil.class
                     )
    public R  findOrderByUserId(@PathVariable("id") Integer id) {
        //ribbon实现
        String url = "http://xx/order/findOrderByUserId/"+id;
        R result = restTemplate.getForObject(url,R.class);
    
        if(id==4){
            throw new IllegalArgumentException("非法参数异常");
        }
    
        return result;
    }
    
    public class ExceptionUtil {
    	//业务异常
        public static R fallback(Integer id,Throwable e){
            return R.error(-2,"===被异常降级啦===");
        }
    	//流控异常
        public static R handleException(Integer id, BlockException e){
            return R.error(-2,"===被限流啦===");
        }
    }
    
  4. Sentinel中统计单位时间QPS进行流控时,采用的是滑动时间窗算法!流控效果有三种

    • 快速失败,直接抛出流控异常,底层使用的是滑动时间窗口算法
    • 预热Warm up,把突然爆发的大流量变为缓慢增加
    • 匀速排队,使用的漏桶算法
  5. Sentinel中服务熔断降级有三个指标

    • 慢调用比例
    • 异常比例
    • 异常个数

       

2. Sentinel断路器的三种状态

  • Sentinel中服务熔断降级的断路器有三个状态,分别是关闭(close)、打开(open)和半开(halfOpen)状态。
    • 如果在单位时间内达到断路条件,则把断路器置为打开(open)状态,抛出流控异常,进行服务熔断
    • 下一次请求过来时,如果断路器是关闭(close)状态,直接通行;如果是打开(open)状态,则会查看当前时间是否大于断路后的最小等待时间,如果大于则把断路器置为半开(halfOpen)状态;如果小于,继续阻塞
    • 最后会在try-catch-finallyfinally中判断断路器的状态是否是半开(halfOpen)状态,如果是,则请求一次接口,如果请求正常,则把断路器置为打开(open)状态,如果不正常把断路器置为关闭(close)状态

       

3. 计算QPS的限流算法

       

①:计数器限流

Sentinel计算QPS限流算法_重置_02

       计数器法是限流算法里最简单也是最容易实现的一种算法。对于A接口来说,1分钟的访问次数不能超过100个。

那么可以这么做:

  • 在一开始的时候,我们可以设置一个计 数器counter,每当一个请求过来的时候,counter就加1,
  • 如果counter的值大于100并且该请求 与第一个 请求的间隔时间还在1分钟之内,那么说明请求数过多;
  • 如果该请求与第一个请求的间 隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter

       计数器实现限流的缺点就是:精度低,如果在 0.5分钟 和 1.5 分钟之间有超过100个请求,这种限流算法就不起作用了。此外,计数器限流算法的实现还可以使用redis,设置一个key,一分钟过期,进来一个请求就使用incr命令自增一,代码中拿key的值与limt进行比较!


public class _10_限流算法_计数器 {

    //开始统计时间
    private long beginTime = System.currentTimeMillis();

    //请求数
    private int reqCount = 0;

    //请求限制数
    private int limit = 100;

    //单位时间:1分钟
    private long window = 1000 * 60;


    public boolean limitReq() {

        //当前时间
        long currTime = System.currentTimeMillis();

        if (currTime < beginTime + window) {
            //如果当前时间在统计期内,则递增请求数,并于限制数100比较
            reqCount++;
            return reqCount <= limit;
        } else {
            //如果当前时间不在统计期内,则重置请求数,并设置当前时间为统计开始时间
            reqCount = 1;
            beginTime = currTime;
            return true;
        }

    }
}

       

②:滑动时间窗算法限流

Sentinel计算QPS限流算法_限流_03

       为了解决计数器法统计精度太低的问题,引入了滑动窗口算法。滑动时间窗其实就是把计数器限流算法的时间窗口再做进一步划分,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。Sentinel底层在做统计QPS做快速失败时用的也是滑动时间窗算法

滑动时间窗口限流实现:

  • 假设某个服务最多只能每秒钟处理100个请求,可以设置一个1秒钟的滑动时间窗口,用LinkedList表示,该窗口分为10个格子。
  • 每个格子100毫秒,每100毫秒移动一次,每次移动都需要记录当前服务100ms内请求的次数counter到格子中,counter的值是累计请求的值。不会被重置
  • 如果格子数大于10个,删除最前边的各自,格子数始终保留10个
  • 用最后一个格子的counter值减去最前边格子的counter值,如果大于限流请求数,则会被限流。否则不做限流
public class _11_限流算法_滑动时间窗 {

    //服务访问次数,可以放在Redis中,实现分布式系统的访问计数
    Long counter = 0L;

    //使用LinkedList来记录滑动窗口的10个格子。
    LinkedList<Long> slots = new LinkedList<Long>();

    public static void main(String[] args) throws InterruptedException {
    
        _11_限流算法_滑动时间窗 timeWindow = new _11_限流算法_滑动时间窗();

        //开启一个子线程执行滑动时间窗检测请求个数
        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) {
            //每过100ms,就把总请求数加入linkedList末尾节点,该linkedList长度也自增一
            slots.addLast(counter);

            //如果linkedList长度大于10个,则删除最前面的一个,体现滑动时间窗
            if (slots.size() > 10) {
                slots.removeFirst();
            }
            //比较最后一个节点和第一个节点,两者相差100以上就限流
            if ((slots.peekLast() - slots.peekFirst()) > 100) {

                System.out.println("限流了。。");
                //TODO 修改限流标记为true

            } else {
                //TODO 修改限流标记为false
            }
            //每100毫秒执行一次
            Thread.sleep(100);
        }
    }
}

Sentinel底层在做统计QPS做快速失败时用的也是滑动时间窗算法,只不过与上面的稍有不同

  • Sentinel在做QPS统计时,滑动时间窗有两个维度
    • 毫秒级维度:初始化一个跨度为1000ms,包含两个500ms的时间窗口
    • 秒级维度:还有一个跨度为60s的,包含601s的时间窗口

在毫秒级维度中,仅仅使用两个时间窗口就完成了QPS的计算,并没有做删除时间窗口节点的操作,而是清空原本节点的内容。两个时间窗口代表数组的两个下标。

  • 通过 (当前时间 / 500ms) % 数组长度2的取模结果,得到当前时间的请求数落在那个时间窗口内
  • 然后拿 (当前时间 / 500ms) * 500ms比较时间窗口的起始位置,
    • 如果与之前 (比如:500ms) 一致,就把当次请求数加入到该窗口内用作统计
    • 如果与之前不一致,就清空之前时间窗口内统计的数据,并放入当前时间的请求数,完成滑动的操作

       

③:漏桶算法限流

Sentinel计算QPS限流算法_重置_04

       首先,需要有一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率。而且,当桶满了之后,多余的水将会溢出。

       将算法中的水换成实际应用中的请求,就可以看到漏桶算法天生就限制了请求的速度。 当使用了漏桶算法,可以保证接口会以一个常速速率来处理请求。所以漏桶算法天生不会出现临界问题。

public class _12_限流算法_漏桶算法 {


    //初始时间
    private long initTime = System.currentTimeMillis();

    //漏桶算法一般都有三个指标 桶的容量 流出速度 当前水位

    private long capacity; //容量,代表最大接受请求个数

    private long rate;  //水流速度

    private long water; //当前水位 ,桶内剩余请求数


    public boolean limit() {

        //当有请求进入时的时间
        long now = System.currentTimeMillis();


        //计算一下当时水位:请求进来之间一直在匀速滴水,当前水位要减去这部分滴出去的水!
        water = Math.max(0, water - ((now - initTime) / 1000) * rate);

        if (water + 1 <= capacity) {

            //如果当前水位没满,返回true,并把当前水位+1
            water += 1;
            return true;

        } else {
            //否则返回false,水满了 不让进!
            return false;
        }
    }

}

       

④:令牌桶限流

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

       漏桶算法和令牌桶算法最明显的区别是令牌桶算法允许流量一定程度的突发。 因为默认的令牌桶算法,取走token是不需要耗费时间的,也就是说,假设桶内有100个token时,那么可以瞬间允许100个请求通过。

伪代码:

/**
 * 令牌桶限流算法
 */
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;
        }
    }
}