单机/分布式限流-漏桶/令牌桶/滑动窗口/redis/nginx/sentinel

本文深入解析了单机限流算法(计数器、固定窗口、滑动窗口)、分布式限流技术(Nginx限流、Redis+Lua、Sentinel集群限流),并对比了它们的适用场景和实现细节,特别关注了Sentinel的滑动窗口改进和集群模式应用。
摘要由CSDN通过智能技术生成

限流:限流是指在系统面临高并发、大流量请求的情况下,限制新的流量对系统的访问,从而保证系统服务的安全性。限流的目的是通过对并发访问或请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、或排队或等待等处理

常用的限流算法有( 令牌桶和,漏桶,滑动窗口 )

限流方式分为( 单机限流和分布式限流 )

一、单机限流

限流算法

1. ) 计数器(又叫固定窗口)

        该算法原理是,系统会自动选定一个时间窗口的起始零点,然后按照固定长度将时间轴划分为若干定长的时间窗口。所以该算法也称为“固定时间窗算法”。

        当请求到达时,系统会查看该请求到达的时间点所在的时间窗口当前统计的数据是否超出了预先设定好的阈值。未超出,则请求通过,否则被限流。

存在的问题

        该算法存在这样的问题:连续两个时间窗口中的统计数据都没有超出阈值,但在跨窗口的时间窗长度范围内的统计数据却超出了阈值。

2. )滑动窗口

2.1 滑动窗口是什么

        滑动窗口为固定窗口的改良版,解决了固定窗口在窗口切换时会受到两倍于阈值数量的请求,滑动窗口在固定窗口的基础上,将一个窗口分为若干个等份的小窗口,每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阀值。

        将原来的一个时间窗口划分成多个时间窗口,并且不断向右滑动该窗口。流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。 在临界位置的突发请求都会被算到时间窗口内。

详细:

1.这条线就是队列list,当第一个事件进来,队列大小是0,时间是第1秒:

2.因为size=0,小于5,都没有到限制的次数,完全不用考虑时间窗口,直接把这次事件的时间戳放到0的位置:

3.第2.8秒的时候,第二个事件来了。因为此时size=1,还是小于5,把这次事件的时间戳放到0的位置,原来第1秒来的事件时间戳会往后移动一格:

4.陆续的又来了3个事件,队列大小变成了5,先来的时间戳依次向后移动。此时,第6个事件来了,时间是第8秒:

5.因为size=5,不小于5,此时已经达到限制次数,以后都需要考虑时间窗口了。所以取出位置4的时间(离现在最远的时间),和第6个事件的时间戳做比较:

6.得到的差是7秒,小于时间窗口10秒,说明在10秒内,来的事件个数大于5了,所以本次不允许通过:

2.2 滑动窗口的作用

        为了解决计数器限流方式中在窗口切换时产生2倍于阈值的缺点。

2.3 适用场景

        例如每秒限制 100 个请求。希望请求每 10ms 来一个,这样我们的流量处理就很平滑,但是真实场景很难控制请求的频率。因此可能存在 5ms 内就打满了阈值的情况。因此时间窗口可以解决计数器算法的临界问题。

2.4 代码实现(怎么用)

import java.util.LinkedList;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * 滑动窗口可以理解为细分之后的计数器,
 * 计数器粗暴的限定1分钟内的访问次数,
 * 而滑动窗口限流将1分钟拆为多个段,
 * 不但要求整个1分钟内请求数小于上限,
 * 而且要求每个片段请求数也要小于上限。
 * 相当于将原来的计数周期做了多个片段拆分。更为精细。
 * 定时器每一秒滑动一步,滑动窗口LinkList,当前时间在Last节点,假如当前时间为T秒,
 * 那么前面为T-1,T-2
 * 用map记录每片的计数,有几片就有几个key值,T-2=1;T-2=2;T=2
 */
public class WindowLmt {
    //整个窗口的流量上限,超出会被限流
    final int totalMax = 5;
    //每片的流量上限,超出同样会被拒绝,可以设置不同的值
    final int sliceMax = 5;
    //分多少片
    final int slice = 3;
    //窗口,分3段,每段1s,也就是总长度3s
    final LinkedList<Long> linkedList = new LinkedList<Long>();
    //计数器,每片一个key,可以使用HashMap,这里为了控制台保持有序性和可读性,采用TreeMap
    Map<Long,AtomicInteger> map = new TreeMap();
    //心跳,每1s跳动1次,滑动窗口向前滑动一步,实际业务中可能需要手动控制滑动窗口的时机。
    ScheduledExecutorService service = Executors.newScheduledThreadPool(1);
    //获取key值,这里即是时间戳(秒)
    private Long getKey(){
        return System.currentTimeMillis()/1000;
    }
    public WindowLmt(){
        //初始化窗口,当前时间指向的是最末端,前两片其实是过去的2s
        Long key = getKey();
        for (int i = 0; i < slice; i++) {
            linkedList.addFirst(key-i);
            map.put(key-i,new AtomicInteger(0));
        }
        //启动心跳任务,窗口根据时间,自动向前滑动,每秒1步
        service.scheduleAtFixedRate(new Runnable() {

            public void run() {
                Long key = getKey();
                //队尾添加最新的片
                linkedList.addLast(key);
                map.put(key,new AtomicInteger());
                //将最老的片移除
                map.remove(linkedList.getFirst());
                linkedList.removeFirst();
                System.out.println("step:"+key+":"+map);;
            }
        },1000,1000,TimeUnit.MILLISECONDS);
    }
    //检查当前时间所在的片是否达到上限
    public boolean checkCurrentSlice(){
        long key = getKey();
        AtomicInteger integer = map.get(key);
        if (integer != null){
            return integer.get() < sliceMax ;
        }
        //默认允许访问
        return true;
    }
    //检查整个窗口所有片的计数之和是否达到上限
    public boolean checkAllCount(){
        return map.values().stream().mapToInt(value -> value.get()).sum()<totalMax;
//        return map.values().stream().mapToInt(value ‐> value.get()).sum()  < totalMax;
    }
    //请求来临....
    public void req(){
        Long key = getKey();
        //如果时间窗口未到达当前时间片,稍微等待一下
        //其实是一个保护措施,放置心跳对滑动窗口的推动滞后于当前请求
        while (linkedList.getLast()<key){
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //开始检查,如果未达到上限,返回ok,计数器增加1
        //如果任意一项达到上限,拒绝请求,达到限流的目的
        //这里是直接拒绝。现实中可能会设置缓冲池,将请求放入缓冲队列暂存
        if (checkCurrentSlice() && checkAllCount()){
            map.get(key).incrementAndGet();
            System.out.println(key+"=ok:"+map);
        }else {
            System.out.println(key+"=reject:"+map);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        WindowLmt window = new WindowLmt();
        //模拟10个离散的请求,相对之间有200ms间隔。会造成总数达到上限而被限流
        for (int i = 0; i < 10; i++) {
            Thread.sleep(200);
            window.req();
        }
        //等待一下窗口滑动,让各个片的计数器都置零
        Thread.sleep(3000);
        //模拟突发请求,单个片的计数器达到上限而被限流
        System.out.println("‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐");
        for (int i = 0; i < 10; i++) {
            window.req();
        }
    }
}

2.5 滑动窗口相关

TCP滑动窗口详解_张孟浩_jay的博客-CSDN博客_tcp滑动窗口

这可能是全网Spring Cloud Gateway限流最完整的方案了!_Java笔记虾的博客-CSDN博客(里面有双窗口滑动限流算法的思路)

2.6 滑动窗口拓展

3 )滑动日志算法(pass掉)

3.1 滑动日志是什么

        滑动日志算法是实现限流的另一种方法,这种方法比较简单。基本逻辑就是记录下所有的请求时间点,新请求到来时先判断最近指定时间范围内的请求数量是否超过指定阈值,由此来确定是否达到限流,这种方式没有了时间窗口突变的问题,限流比较准确,但是因为要记录下每次请求的时间点,所以占用的内存较多

3.2 滑动日志的作用(准确但耗内存) 3. 适用场景(略) 4. 代码实现(略)

4 )漏桶

4.1 漏桶算法是什么

        漏桶(Leaky Bucket)可以看作是一个带有常量服务时间的单服务器队列 。算法思路简单,水(请求)先进入到漏桶中,漏桶以一定的速度出水(接口有响应速率),当水流速度过大桶满了,直接溢出(访问频率超过接口响应频率),然后就拒绝请求。

4.2 漏桶的作用

        可以看出漏桶算法能强制限制数据的传输速率,很好的控制流量的访问速度,超过速度就拒绝服务;对流量进行整形。

4.3 漏桶算法优缺点

优点:

        保证别人的系统不被打垮,将系统的处理能力维持在一个比较平稳的水平,平滑访问。

缺点:

        因为当流出速度固定,大规模持续突发量,无法多余处理,浪费网络带宽

4.4 适用场景

        漏桶算法,用来保护他人所调用的系统。主要场景是,当调用的第三方系统本身没有保护机制,或者有流量限制的时候,我们的调用速度不能超过他的限制,由于我们不能更改第三方系统,所以只有在主调方控制。这个时候,即使流量突发,也必须舍弃。因为消费能力是第三方决定的。

4.5代码实现

import com.google.common.util.concurrent.Monitor;
import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.function.Consumer;
import static java.lang.Thread.currentThread;
/**
 * @create 2022-06-18 11:36
 * @desc 漏桶算法的应用
 **/
public class RateLimiterBucket{
    //漏桶采用线程安全的容器
    private final ConcurrentLinkedQueue<Request> bucket = new ConcurrentLinkedQueue<>();
    //定义漏桶的上沿容量
    private final static long BUCKET_CAPACITY = 100*1024*1024;
    //定义漏桶的下沿水流速率,每秒匀速放行 5 个 Request
    private final RateLimiter rateLimiter = RateLimiter.create(5.0D);
    //提交请求时需要用到的 Monitor
    private final Monitor requestMonitor = new Monitor();
    //处理请求时需要用到的 Monitor
    private final Monitor handleMonitor = new Monitor();

    public void submitRequest(int data) {
        this.submitRequest(new Request(data));
    }

    // 该方法主要用于接受来自客户端提交的请求数据
    public void submitRequest(Request request) {

        /**
         *  enterIf() 方法: 主要用于判断当前的 Guard 是否满足临界值的判断,
         *                  也是使用比较多的一个操作,调用该方法,当前线程
         *                  并不会进入阻塞之中
        //
        if (requestMonitor.enterIf(new Monitor.Guard(requestMonitor) {
            @Override
            public boolean isSatisfied() {
                return bucket.size() < BUCKET_CAPACITY;
            }
        })) {
            try {
                // 向桶中加入新的 request
                boolean result = bucket.offer(request);
                if (result) {
                    System.out.println(currentThread() + " 提交请求 : " + request.getData() +
                            " 成功.");
                } else {
                    // 此处可以将请求数据存入“高吞吐量的 MQ 中”,然后从 MQ 中消费请求,再尝试提交
                    System.out.println("生成到 MQ 中,稍后再试.");
                }
            } finally {
                requestMonitor.leave();
            }
        } else {
            // 当漏桶溢出的时候做“降权”处理
            System.out.println("请求:" + request.getData() + "由于桶溢出将被降权处理");
            // 此处可以将请求数据存入“高吞吐量的 MQ 中”,然后从 MQ 中消费请求,再尝试提交
            //System.out.println("produce into MQ and will try again later.");
        }
    }

    // 该方法主要从漏桶中匀速地处理相关请求
    public void handleRequest(Consumer<Request> consumer) {
        // 若漏桶中存在请求,则处理
        if (handleMonitor.enterIf(new Monitor.Guard(handleMonitor) {
            @Override
            public boolean isSatisfied() {
                return !bucket.isEmpty();
            }
        })) {
            try {
                // 匀速处理
                rateLimiter.acquire();
                // 处理数据
                consumer.accept(bucket.poll());
            } finally {
                handleMonitor.leave();
            }
        }
    }
}
public class Request {
    private long data;
    private String topic;
    get/set/tostring
    }
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * @create 2022-06-18 15:15
 * @desc 测试自定义的 RateLimiterBucket
 **/
public class Test
{
    private static final AtomicInteger data = new AtomicInteger(0);
    private static final RateLimiterBucket bucket = new RateLimiterBucket();

    public static void main(String[] args)
    {
        //启动 10 个线程模拟高并发的业务请求
        for(int i = 0 ; i < 20 ; i++)
        {
            new Thread(
                    ()->
                    {
                        while(true)
                        {
                            bucket.submitRequest(data.getAndIncrement());
                            try
                            {
                                TimeUnit.SECONDS.sleep(3);
                            }
                            catch (InterruptedException e)
                            {
                                e.printStackTrace();
                            }
                        }
                    }
            ).start();
        }
        //启动 10 个线程模拟匀速地对漏桶中的请求进行处理
        for(int i = 0 ; i < 10 ; i++)
        {
            new Thread(
                    ()->
                    {
                        while(true)
                        {
                            bucket.handleRequest(System.out::println);
                        }
                    }
            ).start();
        }
    }
}

5 )令牌桶

5.1 令牌桶是什么

令牌桶算法最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。

大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。

传送到令牌桶的数据包需要消耗令牌。不同大小的数据包,消耗的令牌数量不一样。

令牌桶这种控制机制基于令牌桶中是否存在令牌来指示什么时候可以发送流量。令牌桶中的每一个令牌都代表一个字节。如果令牌桶中存在令牌,则允许发送流量;而如果令牌桶中不存在令牌,则不允许发送流量。因此,如果突发门限被合理地配置并且令牌桶中有足够的令牌,那么流量就可以以峰值速率发送。如下图所示

算法描述:

假如用户配置的平均发送速率为r,则每隔1/r秒一个令牌被加入到桶中(每秒会有r个令牌放入桶中);

假设桶中最多可以存放b个令牌。如果令牌到达时令牌桶已经满了,那么这个令牌会被丢弃;

当一个n个字节的数据包到达时,就从令牌桶中删除n个令牌(不同大小的数据包,消耗的令牌数量不一样),并且数据包被发送到网络;

如果令牌桶中少于n个令牌,那么不会删除令牌,并且认为这个数据包在流量限制之外(n个字节,需要n个令牌。该数据包将被缓存或丢弃);

算法允许最长b个字节的突发,但从长期运行结果看,数据包的速率被限制成常量r。对于在流量限制外的数据包可以以不同的方式处理:

(1)它们可以被丢弃;

(2)它们可以排放在队列中以便当令牌桶中累积了足够多的令牌时再传输;

(3)它们可以继续发送,但需要做特殊标记,网络过载的时候将这些特殊标记的包丢弃。

5.2 令牌桶的作用

令牌桶算法在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在令牌桶算法中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限。

5.3 适用场景

如果要让自己的系统不被打垮,用令牌桶。

对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候令牌桶算法就很适合。

5.4 代码实现

import com.cvnavi.network.NetUsage;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
//令牌桶
class TokenLimiterUtils {
    //饿汉单例,私有化构造,无法通过new实例创建
    private TokenLimiterUtils() {
    }

    private static ConcurrentHashMap<String, TokenLimiter> limiter = new ConcurrentHashMap<>();
    //尝试获取令牌
    public static boolean tryAcquire(String topic) {
        synchronized (topic) {
            if (limiter.get(topic) == null) {
                limiter.put(topic, new TokenLimiter(topic,80,500));
            }
            return limiter.get(topic).tryAcquire();
        }
    }
    //内部内定义
    private static class TokenLimiter {
        //线程安全数组阻塞队列
        private static ArrayBlockingQueue<String> blockingQueue;
        //容量大小
        private int limit;
        //令牌的产生间隔
        private int period;
        private String topic;
        public TokenLimiter(String topic,int limit, int period) {
            this.limit = limit;
            this.period = period;
            this.topic = topic;
            blockingQueue = new ArrayBlockingQueue<>(limit);
            Object lock = new Object();
            init(lock);
            //让线程先产生2个令牌(溢出)
            start(lock);
        }
        //默认初始化令牌
        private void init(Object lock) {
            for (int i = 0; i < limit; i++) {
                if (blockingQueue.size() >= limit) {
                    break;
                }
                //不超过队列容量插入队列尾部
                blockingQueue.add(topic);
            }
        }
        //添加令牌
        private void addToken(int amount) {
            for (int i = 0; i < amount; i++) {
                //溢出返回false
                blockingQueue.offer(topic);
            }
        }
        //获取令牌
        public boolean tryAcquire() {
            //队首元素出队, 获取并移除此队列的头,如果此队列为空,则返回 null,等待空间变有用
            return blockingQueue.poll() != null ? true : false;
        }

        //生产令牌
        private void start(Object lock) {
            //定时任务 启动生产令牌
            Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {

                if (blockingQueue.size() < limit) {
                    synchronized (lock) {
                        //最大值填充令牌,size()包含的元素数,计算队列容量和队列大小之间的差异来查看队列包含多少元素以及可以向此队列添加多少元素。
                        addToken(limit - blockingQueue.size());
                        lock.notify();
                    }
                }
            }, 500, this.period, TimeUnit.MILLISECONDS);
        }
    }
    public static void main(String[] args) throws InterruptedException {

        //模拟洪峰5个请求,前3个迅速响应,后两个排队
        new Thread(() -> {
            for (int i = 0; i < 1200; i++) {
                int finalI = i;
                new Thread(() -> {
                    System.out.println("洪峰:" + finalI + "[获取令牌]" + TokenLimiterUtils.tryAcquire("topicname1"));

                }).start();
                if (i % 80 == 0) {
                    try {
                        Thread.sleep(300);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
            }).start();

        //模拟洪峰5个请求,前3个迅速响应,后两个排队
        //模拟洪峰5个请求,前3个迅速响应,后两个排队
        new Thread(() -> {
            for (int i = 0; i < 1200; i++) {
                int finalI = i;
                new Thread(() -> {
                    System.err.println("洪峰测试2:" + finalI + "[获取令牌 topic2]" + TokenLimiterUtils.tryAcquire("topic2"));

                }).start();
                if (i % 80 == 0) {
                    try {
                        Thread.sleep(300);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }
}

6 )限流算法的比较

1) 漏桶算法的出水速度是恒定的,那么意味着如果瞬时大流量的话,将有大部分请求被丢弃掉(也就是所谓的溢出)。漏桶算法通常可以用于限制访问外部接口的流量,保护其他人系统,比如我们请求银行接口,通常要限制并发数。

2) 令牌桶算法生成令牌的速度是恒定的,而请求去拿令牌是没有速度限制的。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,可以处理瞬时流量,而且拿令牌的过程并不是消耗很大的事情。令牌桶算法通常可以用于限制被访问的流量,保护自身系统。

两者主要区别在于“漏桶算法”能够强行限制数据的传输速率,而“令牌桶算法”在能够限制数据的平均传输速率外,还允许某种程度的突发传输。

计数器 VS 固定窗口 VS 滑动窗口

计数器可以说是固定窗口的低精度实现,固定窗口又可以说是滑动窗口的低精度实现。

因此在限流时间精度上,三种算法关系为:计数器<固定窗口<滑动窗口

但精度的精确同时也要消耗内存,因此三种算法内存占用关系为:计数器<固定窗口<滑动窗口

但令牌桶算法使用不当也有弊端,比如上线初期未对桶内的令牌做初始化,在第一批令牌还未生成时接受到请求会被丢弃,造成误杀。

总的来说

  • 计数器比较适合简单、粗力度限流需求
  • 固定/滑动窗口则适合对响应时间要求较高的限流场景,比如一些微服务接口
  • 漏桶算法则比较适合后台任务类的限流场景
  • 令牌桶则适用于大流量接口,特别是有瞬时流量较高的接口限流场景。

开源单机限流

1. guava 的ratelimiter

1.1 ratelimiter是什么

    小白包子铺因为小笼包皮薄馅香汤汁多而生意兴隆,每天都会有很多人排队来吃。因为蒸笼个数有限,后厨师傅的人力也有限,每分钟只能出锅一屉小笼包,每个小时一共能蒸出60屉小笼包供顾客享用。如果这60屉小笼包没人买,就先不蒸了,反正也没地方放。
    每位顾客需要先在门口付买包子的钱,然后进到铺子里吃包子。如小笼包都卖没了,门口的顾客就只能付完钱等着,等有新出炉的小笼包可卖时,再进门享用。
    包子铺每天早上7点准时开门,师傅开始制作这60屉小笼包【RateLimiter rateLimiter =RateLimite r.create(60)】。顾客蜂拥而至排队,第一个顾客先付一屉小笼包的钱【rateLimiter.acquire()】,等一分钟后小笼包出锅,便可以进屋吃包子了。这时伙计会和顾客说:“您本次等待了1分钟,久等了。”【rateLim iter.acquire()返回的值】。之后是第二个顾客,付钱,等一分钟,进屋吃包子。于是,早上大家就这样一直稳定有序的进屋吃包子。这事来了个火急火燎的上班族,说:“我着急吃饭,不想等,有现成的包子吗?”【 rateLimiter.tryAcquire()】伙计看了一眼后厨说:“没有现成的包子,需要等会哦。”一看还要等,第三个顾客就走了。
    早高峰过去后,买包子的人少了,师傅做完60个包子后,就休息了。
    中午12点,隔壁科技园的程序员们下班了,顾客蜂拥而至。因为已经有了60个库存,大家不用等,付了钱就可以直接进屋吃包子,屋子里一下就涌入了60个顾客。看到小笼包都买光了,后厨师傅又赶紧忙了起来。
    这时来了个大胃王,大嗓门喊道:“我想要30屉包子。”【rateLimiter.acquire(30)】伙计一愣,心想,这一下也做不出这么多包子呀。但是本着客户至上的原则,他对大胃王说:“好的先生,不过我们的包子都是现做的,您先吃着,我们蒸好了陆陆续续给您端上来可以吗?”大胃王说:“哈哈,好!好饭不怕晚!”于是交钱进门吃包子了。这时候又来了个顾客A,说我想要一屉包子。伙计心里一算,现在是一点,等刚才那个大胃王要的包子上齐了,才能给这位顾客上包子,所以需要等到1:30才能给这位顾客吃上,好惨一顾客。于是说:“不好意思这位顾客,小笼包现在没有了,您需要等一段时间。”顾客A说:“好吧,我交了钱等着吧。”于是过了半个小时,后厨做出来了第31屉包子,顾客A就满足的进屋吃包子了。

        Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法(Token Bucket)来完成限流,非常易于使用.RateLimiter经常用于限制对一些物理资源或者逻辑资源的访问速率.它支持两种获取permits接口,一种是如果拿不到立刻返回false,一种会阻塞等待一段时间看能不能拿到.RateLimiter和Java中的信号量(java.util.concurrent.Semaphore)类似,Semaphore通常用于限制并发量.RateLimiter限制的是速率。

1.2 ratelimiter 的特点

a) 以固定的速率生成令牌

b) RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。

c) RateLimiter在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。

1.3 ratelimiter适用场景

        单机令牌桶限流容许有突发量

1.4 ratelimiter怎么用

1.4.1ratelimiter关键属性及方法

关键属性

/** 桶中当前拥有的令牌数. */

double storedPermits;

/** 桶中最多可以保存多少秒存入的令牌数*/

double maxBurstSeconds;

/** 桶中能存储的最大令牌数,等于storedPermits*maxBurstSeconds. */

double maxPermits;

/** 放入令牌的时间间隔*/

double stableIntervalMicros;

/** 下次可获取令牌的时间点,可以是过去也可以是将来的时间点*/

private long nextFreeTicketMicros = 0L;

关键方法:

调用 RateLimiter.create(double permitsPerSecond) 方法时,创建的是 SmoothBursty 实例,默认设置 maxBurstSeconds 为1s。SleepingStopwatch 是guava中的一个时钟类实现。

@VisibleForTesting
static RateLimiter create(double permitsPerSecond, SleepingStopwatch stopwatch) {
        RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
        rateLimiter.setRate(permitsPerSecond);
        return rateLimiter;
}

SmoothBursty(SleepingStopwatch stopwatch, double maxBurstSeconds) {
    super(stopwatch);
    this.maxBurstSeconds = maxBurstSeconds;
}

通过调用 SmoothBursty.doSetRate(double, long) 方法进行初始化,该方法中:

  1. 调用 resync(nowMicros) 对 storedPermits 与 nextFreeTicketMicros 进行了调整——如果当前时间晚于 nextFreeTicketMicros,则计算这段时间内产生的令牌数,累加到 storedPermits 上,并更新下次可获取令牌时间 nextFreeTicketMicros 为当前时间。
  2. 计算 stableIntervalMicros 的值,1/permitsPerSecond。
  3. 调用 doSetRate(double, double) 方法计算 maxPermits 值(maxBurstSeconds*permitsPerSecond),并根据旧的 maxPermits 值对 storedPermits 进行调整。
@Override
final void doSetRate(double permitsPerSecond, long nowMicros) {
        resync(nowMicros);
        double stableIntervalMicros = SECONDS.toMicros(1L) / permitsPerSecond;
        this.stableIntervalMicros = stableIntervalMicros;
        doSetRate(permitsPerSecond, stableIntervalMicros);
}

/** Updates {@code storedPermits} and {@code nextFreeTicketMicros} based on the current time. */
void resync(long nowMicros) {
        // if nextFreeTicket is in the past, resync to now
        if (nowMicros > nextFreeTicketMicros) {
        double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
        storedPermits = min(maxPermits, storedPermits + newPermits);
        nextFreeTicketMicros = nowMicros;
        }
}

@Override
void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
        double oldMaxPermits = this.maxPermits;
        maxPermits = maxBurstSeconds * permitsPerSecond;
        if (oldMaxPermits == Double.POSITIVE_INFINITY) {
                // if we don't special-case this, we would get storedPermits == NaN, below
                storedPermits = maxPermits;
        } else {
                storedPermits =
                        (oldMaxPermits == 0.0)
                                ? 0.0 // initial state
                                : storedPermits * maxPermits / oldMaxPermits;
        }
}

调用 acquire(int) 方法获取指定数量的令牌时,

  1. 调用 reserve(int) 方法,该方法最终调用 reserveEarliestAvailable(int, long) 来更新下次可取令牌时间点与当前存储的令牌数,并返回本次可取令牌的时间点,根据该时间点计算需要等待的时间
  2. 阻塞等待1中返回的等待时间
  3. 返回等待的时间(秒)
/** 获取指定数量(permits)的令牌,阻塞直到获取到令牌,返回等待的时间*/
@CanIgnoreReturnValue
public double acquire(int permits) {
        long microsToWait = reserve(permits);
        stopwatch.sleepMicrosUninterruptibly(microsToWait);
        return 1.0 * microsToWait / SECONDS.toMicros(1L);
}

final long reserve(int permits) {
        checkPermits(permits);
        synchronized (mutex()) {
                return reserveAndGetWaitLength(permits, stopwatch.readMicros());
        }
}

/** 返回需要等待的时间*/
final long reserveAndGetWaitLength(int permits, long nowMicros) {
        long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
        return max(momentAvailable - nowMicros, 0);
}

/** 针对此次需要获取的令牌数更新下次可取令牌时间点与存储的令牌数,返回本次可取令牌的时间点*/
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
        resync(nowMicros); // 更新当前数据
        long returnValue = nextFreeTicketMicros;
        double storedPermitsToSpend = min(requiredPermits, this.storedPermits); // 本次可消费的令牌数
        double freshPermits = requiredPermits - storedPermitsToSpend; // 需要新增的令牌数
        long waitMicros =
                storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
                        + (long) (freshPermits * stableIntervalMicros); // 需要等待的时间

        this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros); // 更新下次可取令牌的时间点
        this.storedPermits -= storedPermitsToSpend; // 更新当前存储的令牌数
        return returnValue;
}

acquire(int) 方法是获取不到令牌时一直阻塞,直到获取到令牌,tryAcquire(int,long,TimeUnit) 方法则是在指定超时时间内尝试获取令牌,如果获取到或超时时间到则返回是否获取成功

  1. 先判断是否能在指定超时时间内获取到令牌,通过 nextFreeTicketMicros <= timeoutMicros + nowMicros 是否为true来判断,即可取令牌时间早于当前时间加超时时间则可取(预消费的特性),否则不可获取。
  2. 如果不可获取,立即返回false。
  3. 如果可获取,则调用 reserveAndGetWaitLength(permits, nowMicros) 来更新下次可取令牌时间点与当前存储的令牌数,返回等待时间(逻辑与前面相同),并阻塞等待相应的时间,返回true。
public boolean (int permits, long timeout, TimeUnit unit) {
        long timeoutMicros = max(unit.toMicros(timeout), 0);
        checkPermits(permits);
        long microsToWait;
        synchronized (mutex()) {
                long nowMicros = stopwatch.readMicros();
                if (!canAcquire(nowMicros, timeoutMicros)) { //判断是否能在超时时间内获取指定数量的令牌
                        return false;
                } else {
                        microsToWait = reserveAndGetWaitLength(permits, nowMicros);
                }
        }
        stopwatch.sleepMicrosUninterruptibly(microsToWait);
        return true;
}

private boolean canAcquire(long nowMicros, long timeoutMicros) {
        return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros; //只要可取时间小于当前时间+超时时间,则可获取(可预消费的特性!)
}

@Override
final long queryEarliestAvailable(long nowMicros) {
        return nextFreeTicketMicros;
}

Guava中的限流使用的是令牌桶算法,RateLimiter提供了两种限流实现:

  • 平滑突发限流(SmoothBursty)令牌的生成速度恒定。使用 RateLimiter.create(double permitsPerSecond) 创建的是 SmoothBursty 实例。
  • 平滑预热限流(SmoothWarmingUp) 令牌的生成速度持续提升,直到达到一个稳定的值。WarmingUp,顾名思义就是有一个热身的过程。使用 RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 时创建就是 SmoothWarmingUp 实例,其中 warmupPeriod 就是热身达到稳定速度的时间。继承图如下

1.4.2平滑突发限流(SmoothBursty)

a)什么是平滑突发限流

每秒以固定的速率输出令牌,以达到平滑输出的效果

      //每秒5个令牌
        RateLimiter rateLimiter = RateLimiter.create(5);
        while(true){
            System.out.println("time:" + rateLimiter.acquire() + "s");
        }

输出结果:

time:0.0s

time:0.196802s

time:0.186141s

time:0.199164s

time:0.199221s

time:0.199338s

time:0.199493s

平均每个0.2秒左右,很均匀

当产生令牌的速率大于取令牌的速率时,是不需要等待令牌时间的

        //每秒5个令牌
        RateLimiter rateLimiter = RateLimiter.create(5);
        while(true){
            System.out.println("time:" + rateLimiter.acquire() + "s");
            //线程休眠,给足够的时间产生令牌
            Thread.sleep(1000);
        }

输出结果:

time:0.0s

time:0.0s

time:0.0s

time:0.0s

time:0.0s

由于令牌可以积累,所以我一次可以取多个令牌,只要令牌充足,可以快速响应

        //每秒5个令牌
        RateLimiter rateLimiter = RateLimiter.create(5);
        while(true){
            //一次取出5个令牌也可以快速响应
            System.out.println("time:" + rateLimiter.acquire(5) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
        }

输出结果:

time:0.0s

time:0.990702s

time:0.190547s

time:0.195084s

time:0.199338s

time:0.999403s

1.4.3 平滑预热限流(SmoothWarmingUp)

a)什么是平滑预热限流

平滑预热限流带有预热期的平滑限流,它启动后会有一段预热期,逐步将令牌产生的频率提升到配置的速率

b)适用场景

这种方式适用于对于系统刚启动时能承受的QPS较小,需要预热一段时间后才能达到最佳状态。(系统启动后需要一段时间来进行预热的场景)

比如,我设置的是每秒5个令牌,预热期为5秒,那么它就不会是0.2左右产生一个令牌。在前5秒钟它不是一个均匀的速率,5秒后恢复均匀的速率

        //每秒5个令牌,预热期为5秒
        RateLimiter rateLimiter = RateLimiter.create(5,5, TimeUnit.SECONDS);
        while(true){
            //一次取出5个令牌也可以快速响应
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("time:" + rateLimiter.acquire(1) + "s");
            System.out.println("-----------");
        }

输出结果:

time:0.0s

time:0.57874s

time:0.539854s

time:0.520102s

time:0.485491s

-----------

time:0.455969s

time:0.423133s

time:0.391189s

time:0.359336s

time:0.327898s

-----------

time:0.295409s

time:0.263682s

time:0.231618s

time:0.202781s

time:0.198914s

-----------

time:0.198955s

time:0.199052s

time:0.199183s

time:0.199296s

time:0.199468s

-----------

time:0.199493s

time:0.199687s

time:0.198785s

time:0.198893s

time:0.199373s

-----------

从输出结果可以看出来,前面的速率不均匀,到后面就逐渐稳定在0.2秒左右了

1.4.4 二者区别

区别在于SmoothBursty加令牌的速度是恒定的,而SmoothWarmingUp会有个预热期,在预热期内加令牌的速度是慢慢增加的,直到达到固定速度为止。

代码操作

guava的RateLimiter客户端限流

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
spring:
  application:
    name: rate-limiter
server:
  port: 10086
package com.zhz.ratelimiter.controller;

import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeUnit;

/**
 * @author zhouhengzhe
 * @description:
 * @date 2022/1/6 上午2:17
 * @since v1
 */
@RestController
@Slf4j
public class RateLimiterController {

    //每秒2个
    RateLimiter limiter = RateLimiter.create(2.0);

    //非阻塞限流
    @GetMapping("/tryAcquire")
    public String tryAcquire(Integer count) {
        if (limiter.tryAcquire(count)) {
            log.info("success,rate is {}", limiter.getRate());
            return "success";
        } else {
            log.info("fail,rate is {}", limiter.getRate());
            return "fail";
        }
    }

    //限定时间的非阻塞限流
    @GetMapping("/tryAcquireWithTimeout")
    public String tryAcquireWithTimeout(Integer count, Integer timeout) {
        if (limiter.tryAcquire(count, timeout, TimeUnit.SECONDS)) {
            log.info("success,rate is {}", limiter.getRate());
            return "success";
        } else {
            log.info("fail,rate is {}", limiter.getRate());
            return "fail";
        }
    }

    //同步阻塞限流
    @GetMapping("/acquire")
    public String acquire(Integer count) {
        limiter.acquire(count);
        log.info("success,rate is {}", limiter.getRate());
        return "success";
    }
}
package com.zhz.ratelimiter;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

/**
 * @author mac
 */
@SpringBootApplication
public class RateLimiterApplication {

    public static void main(String[] args) {
        //第一种方式
//        SpringApplication.run(RateLimiterApplication.class, args);
        //第二种方式
        new SpringApplicationBuilder(RateLimiterApplication.class)
                .web(WebApplicationType.SERVLET)
                .run(args);
    }
}

1.5 rateLimiter需要注意的问题

a) RateLimiter 通过限制后面请求的等待时间,来支持一定程度的突发请求——预消费的特性。

b) RateLimiter 令牌桶的实现并不是起一个线程不断往桶里放令牌,而是以一种延迟计算的方式(参考resync函数),在每次获取令牌之前计算该段时间内可以产生多少令牌,将产生的令牌加入令牌桶中并更新数据来实现,比起一个线程来不断往桶里放令牌高效得多。

2. Sentinel单机限流

单机限流流程图

2.1 导包

<!-- sentinel 核心 -->
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-core</artifactId>
    <version>1.8.4</version>
</dependency>
<!-- sentinel客户端与dashboard通信 -->
<dependency>
    <groupId>com.alibaba.csp</groupId>
    /**sentinel-transport-simple-http:该模块会启动一个内嵌的 http-server,用于接收和处理 dashboard 发送来的数据请求;同时每隔 10s,主动向 dashboard-server 发送心跳,表明当前应用的健康程度。*/
    <artifactId>sentinel-transport-simple-http</artifactId>
    <version>1.8.4</version>
</dependency>

2.2sentinel限流实现

public class HelloSentinel {
    public static void main(String[] args) throws InterruptedException {
//        initFlowRules();
        for (int i = 0; i < 10000; i++) {
            Entry entry = null;
            try {
                // 根据资源名获取Entry,如果获取到,说明可正常执行;如果被流控,该行代码抛出异常
                entry = SphU.entry("resource1");
                // 被保护的业务逻辑
                System.out.println(new HelloSentinel().sayHello(String.valueOf(i)));
            } catch (BlockException ex) {
                // 资源访问阻止,被限流或被降级
                // 进行相应的处理操作
                System.out.println("block, " + i);
                Thread.sleep(500);
            } catch (Exception ex) {
                // 若需要配置降级规则,需要通过这种方式记录业务异常
                Tracer.traceEntry(ex, entry);
                System.out.println("ex, " + i);
            } finally {
                // 务必保证 exit,务必保证每个 entry 与 exit 配对
                if (entry != null) {
                    entry.exit();
                }
            }
        }
    }
    
    public String sayHello(String receiver) {
        return "hi, " + receiver;
    }

    /**
     * 添加流控规则(此代码就是 dashboard 上配置的规则加载到应用内存的底层实现)
     */
//    private static void initFlowRules() {
//        List<FlowRule> rules = new ArrayList<>();
//        FlowRule rule = new FlowRule();
//        rule.setResource("resource1");
//        rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
//        // Set limit QPS to 10.
//        rule.setCount(10);
//        rules.add(rule);
//        FlowRuleManager.loadRules(rules);
//    }
}

2.3 启动 sentinel-dashboard

从github下载或者使用源码编译出 sentinel-dashboard-1.8.4.jar,之后使用如下命令启动 java -Dserver.port=8888 -Dcsp.sentinel.dashboard.server=localhost:8888 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.4.jar

  • -Dcsp.sentinel.dashboard.server:会将 sentinel-dashboard 自身的信息注册到 dashboard 界面上
  • 浏览器访问:http://localhost:8888,用户名和密码都是 sentinel(具体看sentinel-dashboard 源码配置文件)

2.4 可视化

此时,如果启动应用程序 HelloSentinel 后,我们会在 dashboard 看到相应的应用。HelloSentinel 启动参数:

-Dcsp.sentinel.dashboard.server=127.0.0.1:8888 -Dproject.name=sentinel-demo

  • -Dcsp.sentinel.dashboard.server:指定 dashboard server 地址,应用会每隔 10s 向 dashboard 发送心跳,dashboard 上有机器的健康检查列表。
  • -Dproject.name:指定当前应用名

之后我们就可以在 dashboard 进行流控规则的配置了。

但是当我们关闭应用程序时,发现在 dashboard 配置的流控规则没了,因为此时配置的流控规则仅推送到了应用的内存中,需要进行持久化操作。
为了实现持久化,官方有 两种方式,此处使用最简单的本地文件存储。

2.5规则数据持久化到文件

public class FileDataSourceInit implements InitFunc {

    @Override
    public void init() throws Exception {
        String flowRuleDir = System.getProperty("user.home") + File.separator + "Desktop"  + File.separator + "dev" + File.separator + "sentinel" + File.separator + System.getProperty("project.name") + File.separator + "rules";
        String flowRuleFile = "flowRule.json";
        String flowRulePath = flowRuleDir + File.separator + flowRuleFile;

        // 如果文件不存在,创建文件
        File dir = new File(flowRuleDir);
        File file = new File(flowRulePath);
        if (!dir.exists()) {
            dir.mkdirs();
        }
        if (!file.exists()) {
            file.createNewFile();
        }

        // 注册读数据源:启动时读取文件中的流控规则,注册到FlowRuleManager中
        ReadableDataSource<String, List<FlowRule>> ds = new FileRefreshableDataSource<>(
            flowRulePath, source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {})
        );
        FlowRuleManager.register2Property(ds.getProperty());

        // 注册写数据源:dashboard修改规则配置后,通知应用,应用先更新到内存,然后写到文件中
        WritableDataSource<List<FlowRule>> wds = new FileWritableDataSource<>(flowRulePath, this::encodeJson);
        WritableDataSourceRegistry.registerFlowDataSource(wds);
    }

    private <T> String encodeJson(T t) {
        return JSON.toJSONString(t);
    }
}

配置到 spi 文件:META-INF/services/com.alibaba.csp.sentinel.init.InitFunc

io.study.sentineldemo.datasource.FileDataSourceInit

重启应用后,再在 dashboard 设置规则,该规则会推送给应用,应用首先将其放置到内存中,之后持久化到文件中。应用再次重启后,就会从文件进行规则数据读取了。

3. Tomcat 限流

Tomcat 8.5 版本的最大线程数在 conf/server.xml 配置中,如下所示:

<Connector port="8080" protocol="HTTP/1.1"
          connectionTimeout="20000"
          maxThreads="150"
          redirectPort="8443" />

其中 maxThreads 就是 Tomcat 的最大线程数,当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的。

注:maxThreads 的值可以适当的调大一些,此值默认为 150(Tomcat 版本 8.5.42),但这个值也不是越大越好,要看具体的硬件配置,需要注意的是每开启一个线程需要耗用 1MB 的 JVM 内存空间用于作为线程栈之用,并且线程越多 GC 的负担也越重。最后需要注意一下,操作系统对于进程中的线程数有一定的限制,Windows 每个进程中的线程数不允许超过 2000,Linux 每个进程中的线程数不允许超过 1000。

二、分布式限流

1、限流方案

1 . Nginx分布式限流

1.1 Nginx是什么

        Nginx是一款轻量级的Web 服务器,也可以用做反向代理、负载均衡、动静分离和 HTTP缓存。

        nginx属于7层代理,这里的层是OSI 7层网络模型,OSI 模型是从上往下的,越底层越接近硬件,越往上越接近软件,这七层模型分别是物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。

7层是指应用层,通常是http 。nginx如果要部署集群可以结合lvs四层代理做负载均衡

1.2 Nginx的优缺点

(1)跨平台

(2)配置异常简单,非常容易上手。

(3)非阻塞、高并发连接:数据复制时,磁盘I/O的第一阶段是非阻塞的。官方测试能够支撑5万并发连接,在实际生产环境中跑到2~3万并发连接数.(Nginx使用了最新的epoll模型)

(4)事件驱动:通信机制采用epoll模型,支持更大的并发连接。

(5)master/worker结构:一个master进程,生成一个或多个worker进程

(6)内存消耗小:处理大并发的请求内存消耗非常小。在3万并发连接下,开启的10个Nginx 进程才消耗150M内存(15M*10=150M)

(9)节省带宽:支持 GZIP 压缩,可以添加浏览器本地缓存的 Header 头。

(10)稳定性高:用于反向代理,宕机的概率微乎其微

1.3 Nginx限流怎么用

Nginx 提供两种限流方式,一是控制速率,二是控制并发连接数。

a) 控制速率

Ngixn层运行对请求的ip使用漏桶算法(leaky bucket algorithm)进行请求速率控制,速率限制配置有两个主要指令,limit_req_zone和 limit_req,比如配置如下:

http {
    limit_req_zone $binary_remote_addr zone=iplimit:10m rate=1r/s;
    limit_req_zone $server_name zone=iplimit:10m rate=1r/s;
    server {
        server_name  www.nginx-lyntest.com;
        listen       80;
        location ^~/my-api/ {
            proxy_pass   http://127.0.0.1:9999/;
            limit_req zone=iplimit burst=20 nodelay;
            limit_req_status 429; # 默认返回 http 503状态码
            limit_req_log_level warn; # 默认为 error级别
        }
    }
}
limit_req_zone指令:在http模块进行设置;limit_conn_zone key链接限制的可选项,可以是下面的 $server_name 或 $binary_remote_addr
$binaty_remote_addr NGINX 变量以保存客户端 IP 地址($remote_addr)的二进制;
$server_name Nginx的变量,按照Server的server_name进行限流;
zone=iplimit:10m 指定一个变量名(iplimit)以保存上面指定的客户端ip,当前配置大小为10M,1M可以存储大约 16,000 个 IP,这里的10M可以存储16w个IP;
rate=1r/s 漏桶请求速率,控制在每秒1个请求;
limit_req指令:在location模块指定上面http模块约定的配置
zone=iplimit 这里使用上面定义的zone区域以保存当前location的请求;
burst=20 设置一个大小20的槽的队列,当前超过漏桶速率数时将请求放入槽内,并且每 100 毫秒释放 1 个插槽;官方解释是以应对流量突刺的情况。
nodelay(或delay=8):当前超过令牌桶速率时,并且上面配置的burst槽都满的情况下,对请求处理。nodelay即不允许延迟马上进行拒绝比较适用于需要高性能的接口。可以设置delay=一个指定的数值
imit_req_status:可以在【http、server、location】中使用,当需要执行拒绝时的http状态码,默认为503,这里也可以指定为 429(Too Many Request)限流
limit_req_log_level:可以在【http、server、location】中使用,限流的日志,默认为error级别,这里可以指定为warm级别,官方的日志格式如
    2015/06/13 04:20:00 [error] 120315#0: *32086 limiting requests, 		excess: 1.000 by 
    zone "mylimit", client: 192.168.1.2, server: nginx.com, request: "GET / HTTP/1.0", 
    host: "nginx.com"

正常请求没有被限流的效果为:

默认返回状态状态码(limit_req_status默认返回Http 503 状态码),效果为:

可以将limit_req_status调整为 Http 429状态码,效果为:

b) 控制并发连接数

ngx_http_limit_conn_module 提供了限制连接数的能力。主要是利用limit_conn_zone和limit_conn两个指令。Nginx中的所谓连接数限制,其实是tcp连接,也就是请求方通过三次握手后成功建立的连接状态。

利用连接数限制 某一个用户的ip连接的数量来控制流量

注:并非所有连接都被计算在内 只有当服务器正在处理请求并且已经读取了整个请求头时,才会计算有效连接。

http {
    limit_conn_zone $binary_remote_addr zone=perip:10m;
    limit_conn_zone $server_name zone=perserver:10m;
    server {
        server_name  www.nginx-lyntest.com;
        listen       80;
        location /my-api/ {
            proxy_pass   http://127.0.0.1:9999/;
            # 每个server最多保持100个连接
            limit_conn perserver 100;
            # 每个ip最多保持1个连接
            limit_conn perip 1;
            limit_conn_log_level warm;
        }
    }
}
limit_conn_zone指令,同样在http模块中进行设置;limit_conn_zone key链接限制的可选项,可以是下面的 $server_name 或 $binary_remote_addr
$binaty_remote_addr NGINX 变量以保存客户端 IP 地址($remote_addr)的二进制;
$server_name Nginx的变量,按照Server的server_name进行限流;
zone= perip:10m 指定一个变量名(perip)以保存上面指定的客户端ip,当前配置大小为10M,1M可以存储大约 16,000 个 IP,这里的10M可以存储16w个IP;
zone=perserver:10m 指定一个变量名(perserver)以保存根据server模块名请求的统计进行限流;
limit_conn: 配置上面设置的配置,并且设置链接个数, 如上配置的是ip就限制ip的链接个数,设置的是server模块的名称就限制其链接的个数;
limit_conn_status: 限制链接的返回Http 状态码,可以在【http、server、location】中使用,默认返回值为 Http 503状态码,可以设置为 429 (Too Many Request)
limit_conn_log_level: 可以在【http、server、location】中使用,连接日志可选项有:info| notice| warn| error,默认级别为 error

代码

 /**
     * nginx专用
     * 1、修改host文件 (127.0.0.1   www.testnginx.com)
     * 2、修改nginx->讲上面的域名,添加到路由规则中
     *      配置文件地址:/usr/local/nginx/conf/nginx.conf
     * 3、添加配置项(具体可看resource文件地址)
     *
     **/
    @GetMapping("/nginx")
    public String nginx(){
        log.info("Nginx success");
        return "success";
    }
127.0.0.1   www.testnginx.com
server {
	server_name www.testnginx.com
	location /access-limit/ {
		proxy_pass http://127.0.0.1:10086/;#127.0.0.1可以换成具体ip
	}
}
# 根据IP地址限制速度
# 1) 第一个参数 $binary_remote_addr
#     binary_目的是缩写内存占用,remote_addr表示通过IP地址来限流
#  2) 第二个参数 zone=iplimit:20m
#     iplimit是一块内存区域(记录访问频率信息),20m是指这块内存区域的大小
#  3) 第三个参数 rate=1r/s
#     比如100r/m,标识访问的限流频率

limit_req_zone $binary_remote_addr zone=iplimit:20m rate=1r/s;
    server {
        server_name www.testnginx.com
        location /access-limit/ {
            proxy_pass http://127.0.0.1:10086/;

            # 基于IP地址的限制
            # 1) 第一个参数zone=iplimit => 引用limit_req_zone中的zone变量
            # 2) 第二个参数burst=2,设置一个大小为2的缓冲区域,当大量请求到来。
            #     请求数量超过限流频率时,将其放入缓冲区域
            # 3) 第三个参数nodelay=> 缓冲区满了以后,直接返回503异常
            limit_req zone=iplimit burst=2 nodelay;
        }
    }
# 根据服务器级别做限流
limit_req_zone $server_name zone=serverlimit:10m rate=100r/s;

    server {
        server_name www.testnginx.com
        location /access-limit/ {
            proxy_pass http://127.0.0.1:10086/;

            # 基于服务器级别的限制
            # 通常情况下,server级别的限流速率是最大的
            limit_req zone=serverlimit burst=100 nodelay;
        }
    }
# 基于连接数的配置
limit_conn_zone $binary_remote_addr zone=perip:20m;
limit_conn_zone $server_name zone=perserver:20m;


    server {
        server_name www.testnginx.com
        location /access-limit/ {
            proxy_pass http://127.0.0.1:10086/;

            # 每个server最多保持100个连接
            limit_conn perserver 100;
            # 每个IP地址最多保持1个连接
            limit_conn perip 5;

            # 异常情况,返回504(默认是503)
            limit_req_status 504;
            limit_conn_status 504;
        }
    }
server {
    server_name www.testnginx.com

    # 彩蛋
    location /download/ {
        limit_rate_after 100m;
        limit_rate 256k;
    }
}

2 . Redis+Lua限流

2.1 Redis/Lua是什么

Redis+Lua是一种分布式限流方案,分布式限流最关键的是要将限流服务做成原子化,而解决方案可以使使用redis+lua或者nginx+lua技术进行实现,通过这两种技术可以实现的高并发和高性能。

2.2 Redis/Lua的特点

redis凭借其特性成为了中间件的佼佼者,最新官方测试数据:

读的速度是110000次/s

写的速度是81000次/s。

lua:

减少网络开销:使用Lua脚本,无需向Redis 发送多次请求,执行一次即可,减少网络传输

原子操作:Redis 将整个Lua脚本作为一个命令执行,原子,无需担心并发

复用:Lua脚本一旦执行,会永久保存 Redis 中,,其他客户端可复用

功能:

默认根据全局接口的QPS作为流控指标

可在独立IP和全局接口自由切换

可自定义时间及规定时间内的QPS

可根据key值做后期的数据监控和统计

2.3 Redis/Lua限流怎么用

时间窗口算法借助 Redis 的有序集合可以实现

漏桶算法可以使用 Redis-Cell 来实现

@RedisLimit(name = "订单秒杀", prefix = "seckill", key = "distributed", count = 1, period = 1, limitType = LimitType.IP, msg = "当前排队人数较多,请稍后再试!")
@GetMapping("/limit/distributed/{id}")
public ResponseEntity<Object> limitDistributed(@PathVariable("id") String id) {

    return ResponseEntity.ok("成功购买:" + id + "个");
}
/**
 * 资源名称
 */
String name() default "";

/**
 * 前缀
 */
String prefix() default "";

/**
 * 资源key
 */
String key() default "";

/**
 * 最多访问次数
 */
int count();

/**
 * 时间,秒级
 */
int period();

/**
 * 类型
 */
LimitType limitType() default LimitType.CUSTOMER;

/**
 * 提示信息
 */
String msg() default "系统繁忙,请稍后再试";
以流量作为切点、滑动时间窗口作为核心算法。

lua脚本以保证其原子性操作

代码实现

Redis+Lua脚本实现分布式限流思路

使用Redia+Lua脚本的方式来对我们的分布式系统进行统一的全局限流,Redis+Lua实现的Lua脚本:

local key = KEYS[1]  --限流KEY(一秒一个)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
    return 0
else --请求数+1,并设置2秒过期
    redis.call("INCRBY", key, "1")
    redis.call("expire", key "2")
    return 1
end

我们可以按照如下的思路来理解上述Lua脚本代码。

(1)在Lua脚本中,有两个全局变量,用来接收Redis应用端传递的键和其他参数,分别为:KEYS、ARGV;

(2)在应用端传递KEYS时是一个数组列表,在Lua脚本中通过索引下标方式获取数组内的值。

(3)在应用端传递ARGV时参数比较灵活,可以是一个或多个独立的参数,但对应到Lua脚本中统一用ARGV这个数组接收,获取方式也是通过数组下标获取。

(4)以上操作是在一个Lua脚本中,又因为我当前使用的是Redis 5.0版本(Redis 6.0支持多线程),执行的请求是单线程的,因此,Redis+Lua的处理方式是线程安全的,并且具有原子性。

这里,需要注意一个知识点,那就是原子性操作:如果一个操作时不可分割的,是多线程安全的,我们就称为原子性操作。

接下来,可以使用如下Java代码来判断是否需要限流。

//List设置Lua的KEYS[1]
String key = "ip:" + System.currentTimeMillis() / 1000;
List<String> keyList = Lists.newArrayList(key);

//List设置Lua的ARGV[1]
List<String> argvList = Lists.newArrayList(String.valueOf(value));

//调用Lua脚本并执行
List result = stringRedisTemplate.execute(redisScript, keyList, argvList)

至此,简单的介绍了使用Redis+Lua脚本实现分布式限流的总体思路,并给出了Lua脚本的核心代码和Java程序调用Lua脚本的核心代码。接下来,我们就动手写一个使用Redis+Lua脚本实现的分布式限流案例。

Redis+Lua脚本实现分布式限流案例

通过自定义注解的形式来实现分布式、大流量场景下的限流,只不过这里使用了Redis+Lua脚本的方式实现了全局统一的限流模式。

创建注解

首先,我们在项目中,定义个名称为MyRedisLimiter的注解,具体代码如下所示。

package io.mykit.limiter.annotation;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
/**
 * @version 1.0.0
 * @description 自定义注解实现分布式限流
 */
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyRedisLimiter {
    @AliasFor("limit")
    double value() default Double.MAX_VALUE;
    double limit() default Double.MAX_VALUE;
}

在MyRedisLimiter注解内部,我们为value属性添加了别名limit,在我们真正使用@MyRedisLimiter注解时,即可以使用@MyRedisLimiter(10),也可以使用@MyRedisLimiter(value=10),还可以使用@MyRedisLimiter(limit=10)。

创建切面类

创建注解后,我们就来创建一个切面类MyRedisLimiterAspect,MyRedisLimiterAspect类的作用主要是解析@MyRedisLimiter注解,并且执行限流的规则。这样,就不需要我们在每个需要限流的方法中执行具体的限流逻辑了,只需要我们在需要限流的方法上添加@MyRedisLimiter注解即可,具体代码如下所示。

package io.mykit.limiter.aspect;
import com.google.common.collect.Lists;
import io.mykit.limiter.annotation.MyRedisLimiter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.List;

/**
 * @version 1.0.0
 * @description MyRedisLimiter注解的切面类
 */
@Aspect
@Component
public class MyRedisLimiterAspect {
    private final Logger logger = LoggerFactory.getLogger(MyRedisLimiter.class);
    @Autowired
    private HttpServletResponse response;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private DefaultRedisScript<List> redisScript;

    @PostConstruct
    public void init(){
        redisScript = new DefaultRedisScript<List>();
        redisScript.setResultType(List.class);
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(("limit.lua"))));
    }

    @Pointcut("execution(public * io.mykit.limiter.controller.*.*(..))")
    public void pointcut(){

    }

    @Around("pointcut()")
    public Object process(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
        //使用反射获取MyRedisLimiter注解
        MyRedisLimiter myRedisLimiter = signature.getMethod().getDeclaredAnnotation(MyRedisLimiter.class);
        if(myRedisLimiter == null){
            //正常执行方法
            return proceedingJoinPoint.proceed();
        }
        //获取注解上的参数,获取配置的速率
        double value = myRedisLimiter.value();
        //List设置Lua的KEYS[1]
        String key = "ip:" + System.currentTimeMillis() / 1000;
        List<String> keyList = Lists.newArrayList(key);

        //List设置Lua的ARGV[1]
        List<String> argvList = Lists.newArrayList(String.valueOf(value));

        //调用Lua脚本并执行
        List result = stringRedisTemplate.execute(redisScript, keyList, String.valueOf(value));
        logger.info("Lua脚本的执行结果:" + result);

        //Lua脚本返回0,表示超出流量大小,返回1表示没有超出流量大小。
        if("0".equals(result.get(0).toString())){
            fullBack();
            return null;
        }

        //获取到令牌,继续向下执行
        return proceedingJoinPoint.proceed();
    }

    private void fullBack() {
        response.setHeader("Content-Type" ,"text/html;charset=UTF8");
        PrintWriter writer = null;
        try{
            writer = response.getWriter();
            writer.println("回退失败,请稍后阅读。。。");
            writer.flush();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(writer != null){
                writer.close();
            }
        }
    }
}

上述代码会读取项目classpath目录下的limit.lua脚本文件来确定是否执行限流的操作,调用limit.lua文件执行的结果返回0则表示执行限流逻辑,否则不执行限流逻辑。既然,项目中需要使用Lua脚本,那么,接下来,我们就需要在项目中创建Lua脚本。

创建limit.lua脚本文件

在项目的classpath目录下创建limit.lua脚本文件,文件的内容如下所示。

local key = KEYS[1]  --限流KEY(一秒一个)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
    return 0
else --请求数+1,并设置2秒过期
    redis.call("INCRBY", key, "1")
    redis.call("expire", key "2")
    return 1
end

接口添加注解

注解类、解析注解的切面类、Lua脚本文件都已经准备好。那么,接下来,我们在PayController类中在sendMessage2()方法上添加@MyRedisLimiter注解,并且将limit属性设置为10,如下所示。

@MyRedisLimiter(limit = 10)
@RequestMapping("/boot/send/message2")
public String sendMessage2(){
    //记录返回接口
    String result = "";
    boolean flag = messageService.sendMessage("恭喜您成长值+1");
    if (flag){
        result = "短信发送成功!";
        return result;
    }
    result = "哎呀,服务器开小差了,请再试一下吧";
    return result;
}

此处,限制了sendMessage2()方法,每秒钟最多只能处理10个请求。

Tips:

  1. 如果使用RocketMQ的延迟Topic需要使用阿里云版本的RocketMQ,因为开源的RocketMQ不支持毫秒级别的延迟
  2. redis 4.0 以后开始支持扩展模块,redis-cell是一个用rust语言编写的基于令牌桶算法的的限流模块,提供原子性的限流功能,并允许突发流量.

4 . 阿里Sentinel限流(滑动窗口)

4.1 Sentinel 是什么

Sentinel是分布式系统的防御系统。以流量为切入点,通过动态设置的流量控制、服务熔断等手段达到保护系统的目的,通过服务降级增强服务被拒后用户的体验。

4.2 Sentinel 特点

4.3 Sentinel适用场景

首先一个服务有三个服务提供者,但这三台集群的硬件配置不一样,如图所示:

为了充分利用硬件的资源,诸如 Dubbo 都提供了基于权重的负载均衡机制,例如可以将8C16G的机器设置的权重是4C8G的两倍,这样充分利用硬件资源,假如现在需要引入 Sentinel 的限流机制,例如为一个 Dubbo 服务设置限流规则,这样由于三台集群分担的流量不均匀,会导致无法重复利用高配机器的资源。

假设经过压测,机器配置为C48G最高能承受的TPS为 1500,而机器配置为8C16G能承受的TPS为2800,那如果采取单机限流,其阔值只能设置为1500,因为如果超过1500,会将4C8G的机器压垮。

解决这种办法的方式就是针对整个集群进行限流,即为整个集群设置一个阔值,例如设置限流TPS为6000。

4.4 Sentinel分布式怎么用

集群流控中共有两种身份:

  • Token Client:集群流控客户端,用于向所属 Token Server 通信请求 token。集群限流服务端会返回给客户端结果,决定是否限流。
  • Token Server:即集群流控服务端,处理来自 Token Client 的请求,根据配置的集群规则判断是否应该发放 token(是否允许通过)。

Sentinel 1.4.0 开始引入了集群流控模块,主要包含以下几部分:

  • sentinel-cluster-common-default: 公共模块,包含公共接口和实体
  • sentinel-cluster-client-default: 默认集群流控 client 模块,使用 Netty 进行通信,提供接口方便序列化协议扩展
  • sentinel-cluster-server-default: 默认集群流控 server 模块,使用 Netty 进行通信,提供接口方便序列化协议扩展;同时提供扩展接口对接规则判断的具体实现(TokenService),默认实现是复用 sentinel-core 的相关逻辑

集群流控规则

private boolean clusterMode; // 标识是否为集群限流配置
private ClusterFlowConfig clusterConfig; // 集群限流相关配置项	

其中 用一个专门的 ClusterFlowConfig 代表集群限流相关配置项,以与现有规则配置项分开:

// 全局唯一的规则 ID,由集群限流管控端分配.
private Long flowId;

// 阈值模式,默认(0)为单机均摊,1 为全局阈值.
private int thresholdType = ClusterRuleConstant.FLOW_THRESHOLD_AVG_LOCAL;

private int strategy = ClusterRuleConstant.FLOW_CLUSTER_STRATEGY_NORMAL;

// 在 client 连接失败或通信失败时,是否退化到本地的限流模式
private boolean fallbackToLocalWhenFail = true;
  • flowId 代表全局唯一的规则 ID,Sentinel 集群限流服务端通过此 ID 来区分各个规则,因此务必保持全局唯一。一般 flowId 由统一的管控端进行分配,或写入至 DB 时生成。
  • thresholdType 代表集群限流阈值模式。其中单机均摊模式下配置的阈值等同于单机能够承受的限额,token server 会根据客户端对应的 namespace(默认为 project.name 定义的应用名)下的连接数来计算总的阈值(比如独立模式下有 3 个 client 连接到了 token server,然后配的单机均摊阈值为 10,则计算出的集群总量就为 30);而全局模式下配置的阈值等同于整个集群的总阈值

ParamFlowRule 热点参数限流相关的集群配置与 FlowRule 相似。

配置方式

在集群流控的场景下,我们推荐使用动态规则源来动态地管理规则。

对于客户端,我们可以按照原有的方式来向 FlowRuleManager 和 ParamFlowRuleManager 注册动态规则源,例如:

ReadableDataSource<String, List<FlowRule>> flowRuleDataSource = new NacosDataSource<>(remoteAddress, groupId, dataId, parser);
FlowRuleManager.register2Property(flowRuleDataSource.getProperty());

对于集群流控 token server,由于集群限流服务端有作用域(namespace)的概念,因此我们需要注册一个自动根据 namespace 生成动态规则源的 PropertySupplier:

// Supplier 类型:接受 namespace,返回生成的动态规则源,类型为 SentinelProperty<List<FlowRule>>
// ClusterFlowRuleManager 针对集群限流规则,ClusterParamFlowRuleManager 针对集群热点规则,配置方式类似
ClusterFlowRuleManager.setPropertySupplier(namespace -> {
    return new SomeDataSource(namespace).getProperty();
});

然后每当集群限流服务端 namespace set 产生变更时,Sentinel 会自动针对新加入的 namespace 生成动态规则源并进行自动监听,并删除旧的不需要的规则源。

集群限流客户端

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-cluster-client-default</artifactId>
    <version>1.8.4</version>
</dependency>

用户可以通过 API 将当前模式置为客户端模式:

http://<ip>:<port>/setClusterMode?mode=<xxx>

其中 mode 为 0 代表 client,1 代表 server。设置成功后,若已有客户端的配置,集群限流客户端将会开启并连接远程的 token server。我们可以在 sentinel-record.log 日志中查看连接的相关日志。

若集群限流客户端未进行配置,则用户需要对客户端进行基本的配置,比如指定集群限流 token server。我们提供了 API 进行配置:

http://<ip>:<port>/cluster/client/modifyConfig?data=<config>

其中 data 是 JSON 格式的 ClusterClientConfig,对应的配置项:

  • serverHost: token server host
  • serverPort: token server 端口
  • requestTimeout: 请求的超时时间(默认为 20 ms)

当然也可以通过动态配置源进行配置。我们可以通过 ClusterClientConfigManager 的 register2Property 方法注册动态配置源。配置源注册的相关逻辑可以置于 InitFunc 实现类中,并通过 SPI 注册,在 Sentinel 初始化时即可自动进行配置源加载监听。

若用户未引入集群限流 client 相关依赖,或者 client 未开启/连接失败/通信失败,则对于开启了集群模式的规则:

  • 集群热点限流默认直接通过
  • 普通集群限流会退化到 local 模式的限流,即在本地按照单机阈值执行限流检查

当 token client 与 server 之间的连接意外断开时,token client 会不断进行重试,每次重试的间隔时间以 n * 2000 ms 的形式递增。

集群限流服务端

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-cluster-server-default</artifactId>
    <version>1.8.4</version>
</dependency>

启动方式

Sentinel 集群限流服务端有两种启动方式:

  • 独立模式(Alone),即作为独立的 token server 进程启动,独立部署,隔离性好,但是需要额外的部署操作。独立模式适合作为 Global Rate Limiter 给集群提供流控服务。

  • 嵌入模式(Embedded),即作为内置的 token server 与服务在同一进程中启动。在此模式下,集群中各个实例都是对等的,token server 和 client 可以随时进行转变,因此无需单独部署,灵活性比较好。但是隔离性不佳,需要限制 token server 的总 QPS,防止影响应用本身。嵌入模式适合某个应用集群内部的流控。

官方提供了 API 用于在 embedded 模式下转换集群流控身份:

http://<ip>:<port>/setClusterMode?mode=<xxx>

其中 mode 为 0 代表 client,1 代表 server,-1 代表关闭。注意应用端需要引入集群限流客户端或服务端的相应依赖。

在独立模式下,我们可以直接创建对应的 ClusterTokenServer 实例并在 main 函数中通过 start 方法启动 Token Server。
规则配置在上面

属性配置

我们推荐给集群限流服务端注册动态配置源来动态地进行配置。配置类型有以下几种:

  • namespace set: 集群限流服务端服务的作用域(命名空间),可以设置为自己服务的应用名。集群限流 client 在连接到 token server 后会上报自己的命名空间(默认为 project.name 配置的应用名),token server 会根据上报的命名空间名称统计连接数。
  • transport config: 集群限流服务端通信相关配置,如 server port
  • flow config: 集群限流服务端限流相关配置,如滑动窗口统计时长、格子数目、最大允许总 QPS等

我们可以通过 ClusterServerConfigManager 的各个 registerXxxProperty 方法来注册相关的配置源。

从 1.4.1 版本开始,Sentinel 支持给 token server 配置最大允许的总 QPS(maxAllowedQps),来对 token server 的资源使用进行限制,防止在嵌入模式下影响应用本身。

TokenServer配置

示例;

sentinel-demo-cluster 提供了嵌入模式和独立模式的示例:

注意:若在本地启动多个 Demo 示例,需要加上 -Dcsp.sentinel.log.use.pid=true 参数,否则控制台显示监控会不准确。

集群限流控制台

使用集群限流功能需要对 Sentinel 控制台进行相关的改造,推送规则时直接推送至配置中心,接入端引入 push 模式的动态数据源。可以参考 Sentinel 控制台(集群流控管理文档)

同时云上版本 AHAS Sentinel 提供开箱即用的全自动托管集群流控能力,无需手动指定/分配 token server 以及管理连接状态,同时支持分钟小时级别流控、大流量低延时场景流控场景,同时支持 Istio/Envoy 场景的 Mesh 流控能力。

整体拓展架构



通用扩展接口

以下通用接口位于 sentinel-core 中:

  • TokenService: 集群限流功能接口,server / client 均可复用
  • ClusterTokenClient: 集群限流功能客户端
  • ClusterTokenServer: 集群限流服务端接口
  • EmbeddedClusterTokenServer: 集群限流服务端接口(embedded 模式)

以下通用接口位于 sentinel-cluster-common-default:

  • EntityWriter
  • EntityDecoder

Client 扩展接口

集群流控 Client 端通信相关扩展接口:

  • ClusterTransportClient:集群限流通信客户端
  • RequestEntityWriter
  • ResponseEntityDecoder

Server 扩展接口

集群流控 Server 端通信相关扩展接口:

  • ResponseEntityWriter
  • RequestEntityDecoder

集群流控 Server 端请求处理扩展接口:

  • RequestProcessor: 请求处理接口 (request -> response)

4.5 Sentinel的拓展

Sentinel的核心骨架是ProcessorSlotChain。其将不同的 Slot 按照顺序串在一起(责任链模式),从而将不同的功能组合在一起(限流、降级、系统保护)。系统会为每个资源创建一套SlotChain。

4.5.1 SPI机制

Sentinel槽链中各Slot的执行顺序是固定好的。但并不是绝对不能改变的。Sentinel将ProcessorSlot 作为 SPI 接口进行扩展,使得 SlotChain 具备了扩展能力。用户可以自定义Slot并编排Slot 间的顺序。

4.5.2 Slot简介

NodeSelectorSlot

负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降。

ClusterBuilderSlot

用于存储资源的统计信息以及调用者信息,例如该资源的 RT, QPS, thread count,Block count,Exception count 等等,这些信息将用作为多维度限流,降级的依据。简单来说,就是用于构建ClusterNode。

StatisticSlot

用于记录、统计不同纬度的 runtime 指标监控信息。

ParamFlowSlot

对应热点流控。

FlowSlot

用于根据预设的限流规则以及前面 slot 统计的状态,来进行流量控制。对应流控规则。

AuthoritySlot

根据配置的黑白名单和调用来源信息,来做黑白名单控制。对应授权规则。

DegradeSlot

通过统计信息以及预设的规则,来做熔断降级。对应降级规则。

SystemSlot

通过系统的状态,例如 load1 等,来控制总的入口流量。对应系统规则。

4.5.3 Context简介

Context是对资源操作的上下文,每个资源操作必须属于一个Context。如果代码中没有指定Context,则会创建一个name为sentinel_default_context的默认Context。一个Context生命周期中可以包含多个资源操作。Context生命周期中的最后一个资源在exit()时会清理该Conetxt,这也就意味着这个Context生命周期结束了。

4.5.4 Context代码举例

// 创建一个来自于appA访问的Context,
// entranceOne为Context的name
ContextUtil.enter("entranceOne", "appA");
// Entry就是一个资源操作对象
Entry resource1 = null;
Entry resource2 = null;
try {
// 获取资源resource1的entry
resource1 = SphU.entry("resource1");
// 代码能走到这里,说明当前对资源resource1的请求通过了流控
// 对资源resource1的相关业务处理。。。
// 获取资源resource2的entry
resource2 = SphU.entry("resource2");
// 代码能走到这里,说明当前对资源resource2的请求通过了流控
// 对资源resource2的相关业务处理。。。
} catch (BlockException e) {
// 代码能走到这里,说明请求被限流,
// 这里执行降级处理
} finally {
if (resource1 != null) {
resource1.exit();
}
if (resource2 != null) {
resource2.exit();
}
}
// 释放Context
ContextUtil.exit();
// --------------------------------------------------------
// 创建另一个来自于appA访问的Context,
// entranceTwo为Context的name
ContextUtil.enter("entranceTwo", "appA");
// Entry就是一个资源操作对象
Entry resource3 = null;
try {
// 获取资源resource2的entry
resource2 = SphU.entry("resource2");
// 代码能走到这里,说明当前对资源resource2的请求通过了流控
// 对资源resource2的相关业务处理。。。
// 获取资源resource3的entry
resource3 = SphU.entry("resource3");
// 代码能走到这里,说明当前对资源resource3的请求通过了流控
// 对资源resource3的相关业务处理。。。
} catch (BlockException e) {
// 代码能走到这里,说明请求被限流,
// 这里执行降级处理
} finally {
if (resource2 != null) {
resource2.exit();
}
if (resource3 != null) {
resource3.exit();
}
}
// 释放Context
ContextUtil.exit();

4.5.5 Node间的关系

  • Node:用于完成数据统计的接口
  • StatisticNode:统计节点,是Node接口的实现类,用于完成数据统计
  • EntranceNode:入口节点,一个Context会有一个入口节点,用于统计当前Context的总体流量数据
  • DefaultNode:默认节点,用于统计一个资源在当前Context中的流量数据
  • ClusterNode:集群节点,用于统计一个资源在所有Context中的总体流量数据

4.5.6 Sentinel核心源码

sentinel基于滑动窗口的改进

针对以上问题,系统采用了一种“折中”的改进措施:将整个时间轴拆分为若干“样本窗口”,样本窗口的长度是小于滑动时间窗口长度的。当等于滑动时间窗口长度时,就变为了“固定时间窗口算法”。 一般时间窗口长度会是样本窗口长度的整数倍。

那么是如何判断一个请求是否能够通过呢?当到达样本窗口终点时间时,每个样本窗口会统计一次本样本窗口中的流量数据并记录下来。当一个请求到达时,会统计出当前请求时间点所在样本窗口中的流量数据,然后再获取到当前请求时间点所在时间窗中其它样本窗口的统计数据,求和后,如果没有超出阈值,则通过,否则被限流。

数据统计源码流程

使用统计数据流程

4.5.7 Sentinel的模式

在原本的限流规则中加了一个clusterMode参数,如果是true的话,那么会走集群限流的模式,反之就是单机限流。如果是集群限流,判断身份是限流客户端还是限流服务端,客户端则和服务端建立通信,所有的限流都通过和服务端的交互来达到效果。对于Sentinel集群限流,包含两种模式,内嵌式和独立式。

a) 内嵌式

什么是内嵌式呢,简单来说,要限流那么必然要有个服务端去处理多个客户端的限流请求,对于内嵌式来说呢,就是整个微服务集群内部选择一台机器节点作为限流服务端(Sentinel把这个叫做token-server),其他的微服务机器节点作为限流的客户端(token-client),这样的做法有缺点也有优点。

优点:

  1. 这种方式部署不需要独立部署限流服务端,节省独立部署服务端产生的额外服务器开支,降低部署和维护复杂度。

缺点:

  1. 无自动故障转移机制。(无论是内嵌式还是独立式的部署方案,都无法做到自动的故障转移)

所有的server和client都需要事先知道IP的请求下做出配置,如果server挂了,需要手动的修改配置,否则集群限流会退化成单机限流。

比如你的交易服务有3台机器A\B\C,其中A被手动设置为server,B\C则是作为client,当A服务器宕机之后,需要手动修改B\C中一台作为server,否则整个集群的机器都将退化回单机限流的模式。

但是,如果client挂了,则是不会影响到整个集群限流的,比如B挂了,那么A和C将会继续组成集群限流。

如果B再次重启成功,那么又会重新加入到整个集群限流当中来,因为会有一个自动重连的机制,默认的时间是N*2秒,逐渐递增的一个时间。

这是想用Sentinel做集群限流并且使用内嵌式需要考虑的问题,要自己去实现自动故障转移的机制,当然,server节点选举也要自己实现了。

对于这个问题,官方提供了可以修改server/client的API接口,另外一个就是可以基于动态的数据源配置方式,

  1. 适用于单微服务集群内部限流

这个其实也是显而易见的道理,都内部选举一台作为server去限流了,如果还跨多个微服务的话,显然是不太合理的行为

  1. server节点的机器性能会受到一定程度的影响

这个肯定也比较好理解的,作为server去限流,那么其他的客户端肯定要和server去通信才能做到集群限流啊,对不对,所以一定程度上肯定会影响到server节点本身服务的性能,但是我觉得问题不大,就当server节点多了一个流量比较大的接口好了。

具体上会有多大的影响,我没有实际对这块做出实际的测试,如果真的流量非常大,需要实际测试一下这方面的问题。

我认为影响还是可控的,本身server和client基于netty通信,通信的内容其实也非常的小。

b) 独立式

单独部署一台机器作为限流服务端server,就不在本身微服务集群内部选一台作为server了。

优点就是解决了上面的缺点。

  1. 不会和内嵌式一样,影响到server节点的本身性能
  2. 可以适用于跨多个微服务之间的集群限流

缺点:

  1. 需要独立部署,会产生额外的资源(钱)和运维复杂度
  2. server默认是单机,需要自己实现高可用方案(个人看着这个缺点很致命,默认实现是单机,,自己实现高可用,,集群限流可以简单实现,里面真正复杂的东西官方没管

2 . 限流方案的对比

分布式需要看是基于哪种限流算法解决对应的问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

让火车在天上飞

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值