高并发系统设计之限流

本文已收录至Github,推荐阅读 Java随想录

微信公众号:Java随想录

CSDN: 码农BookSea

这篇文章来讲讲限流,在高并发系统中限流是必不可少的,限流可以保证一部分的请求得到正常的响应,是一种自我保护的措施。限流可以保证使用有限的资源提供最大化的服务能力,按照预期流量提供服务,超过的部分将会拒绝服务、排队或等待、降级等处理。

首先,先来了解下几种限流算法。

限流算法

计数器算法

计数器算法是限流算法里最简单也是最容易实现的一种算法。

举个例子:我们规定接口A在1分钟内访问次数不能超过1000个。我们可以设置一个计数器,对固定时间窗口1分钟进行计数,每有一个请求,计数器就+1,如果请求数超过了阈值,则舍弃该请求,当时间窗口结束时,重置计数器为0。

高并发系统设计之限流_第1张图片

计数器算法虽然简单,但是有一个十分致命的问题,那就是临界问题。

假设有一个用户,他在0:59时,瞬间发送了1000个请求,并且1:01又发送了1000个请求,那么其实用户在 2秒里面,瞬间发送了2000个请求。用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。用户有可能利用这个漏洞卡Bug,瞬间压垮我们的应用。

缺点:没有办法防止时间范围临界点突发大流量,很可能在时间范围交界处被大量请求直接打到降级,影响后续服务

滑动窗口

滑动窗口算法解决了上诉计数器算法的缺点。计数器的时间窗口是固定的,而滑动窗口的时间窗口是动态的。

高并发系统设计之限流_第2张图片

整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器,比如当一个请求在0:35秒的时候到达,那么0:30~0:39对应的计数器就会加1。

那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的1000个请求会落在灰色的格子中,而1:01到达的请求会落在橘黄色的格子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是2000个,超过了限定的1000个,所以此时能够检测出来触发限流。

当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确

缺点:滑动窗口无法平滑控制请求流量,仅能控制时间段内请求总量,宏观来看,时间轴上的请求数量波形可能出现较大的波动

漏桶算法

说到漏桶算法的时候,我们脑中先构思出一幅图:一个水桶,桶底下有一个小孔,水以固定的频率流出,水龙头以任意速率流入水,当水超过桶则”溢出“

高并发系统设计之限流_第3张图片

漏桶算法的话保证了固定的流出速率,这是漏桶算法的优点,也可以说是缺点。始终恒定的处理速率有时候并不一定是好事情,对于突发的请求洪峰,在保证服务安全的前提下,应该尽最大努力去响应,这个时候漏桶算法显得有些呆滞,最终可能导致水位”溢出“,请求被丢弃。

缺点:无法应对突发流量,由于处理速度恒定,当大量请求到来时,用户等待时间长,用户体验差

令牌桶算法

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

令牌桶算法的原理是系统以恒定的速率产生令牌,然后把令牌放到令牌桶中,令牌桶有一个容量,当令牌桶满了的时候,再向其中放令牌,那么多余的令牌会被丢弃;当想要处理一个请求的时候,需要从令牌桶中取出一个令牌,如果此时令牌桶中没有令牌,那么则拒绝该请求。

高并发系统设计之限流_第4张图片

缺点:令牌桶的数量,生成的速度需要根据以往的系统性能以及用户习惯等经验的累积来判断,实际限流数难以预知

限流算法实现

Guava RateLimiter实现限流

引入依赖


    com.google.guava
    guava
    31.1-jre

下面是一个使用的简单例子:

import com.google.common.util.concurrent.RateLimiter;

public class RateLimiterTest {
    public static void main(String[] args) {
        RateLimiter rateLimiter = RateLimiter.create(1); //创建一个每秒产生一个令牌的令牌桶
        for (int i = 1; i <= 5; i++) {
            double waitTime = rateLimiter.acquire(i); //一次获取i个令牌
            System.out.println("acquire:" + i + " waitTime:" + waitTime);
        }

    }
}

结果:
    
acquire:1 waitTime:0.0
acquire:2 waitTime:0.995081
acquire:3 waitTime:1.998054
acquire:4 waitTime:2.999351
acquire:5 waitTime:3.999224

可以发现等待时间差不多间隔都是1秒。

img

RateLimiter是个抽象类,子类SmoothRateLimiter又做了层抽象,SmoothRateLimiter有两个子类SmoothBursty和SmoothWarmingUp。

  1. SmoothBursty: 令牌的生成速度恒定。使用 RateLimiter.create(double permitsPerSecond) 创建的是 SmoothBursty 实例。
  2. SmoothWarmingUp:令牌的生成速度持续提升,直到达到一个稳定的值。WarmingUp,顾名思义就是有一个热身的过程。使用 RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 时创建就是 SmoothWarmingUp 实例,其中 warmupPeriod 就是热身达到稳定速度的时间。

SmoothWarmingUp可以理解为是进阶版的SmoothBursty

令牌预分配

RateLimiter 使用令牌桶算法,会进行令牌的累积,令牌会被预先分配。

public class RateLimiterTest {
    public static void main(String[] args) {
        RateLimiter r = RateLimiter.create(5);
        while (true) {
            System.out.println("get 5 tokens: " + r.acquire(5) + "s");
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("end");
            /**
             * output:
             * get 5 tokens: 0.0s
             * get 1 tokens: 0.996766s 滞后效应,需要替前一个请求进行等待
             * get 1 tokens: 0.194007s
             * get 1 tokens: 0.196267s
             * end
             * get 5 tokens: 0.195756s
             * get 1 tokens: 0.995625s 滞后效应,需要替前一个请求进行等待
             * get 1 tokens: 0.194603s
             * get 1 tokens: 0.196866s
             */
        }
    }
}

RateLimiter 由于会累积令牌,所以可以应对突发流量。有一个请求会直接请求5个令牌,但是由于此时令牌桶中有累积的令牌,足以快速响应。 RateLimiter 在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。

预热限流

RateLimiter 的 SmoothWarmingUp 是带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。

public class RateLimiterTest {
    public static void main(String[] args) {
        RateLimiter r = RateLimiter.create(2, 3, TimeUnit.SECONDS);
        while (true) {
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("get 1 tokens: " + r.acquire(1) + "s");
            System.out.println("end");
            /**
             * output:
             * get 1 tokens: 0.0s
             * get 1 tokens: 1.329289s
             * get 1 tokens: 0.994375s
             * get 1 tokens: 0.662888s  上边三次获取的时间相加正好为3秒
             * end
             * get 1 tokens: 0.49764s  正常速率0.5秒一个令牌
             * get 1 tokens: 0.497828s
             * get 1 tokens: 0.49449s
             * get 1 tokens: 0.497522s
             */
        }
    }
}

创建一个平均分发令牌速率为2,预热期为3秒。令牌桶一开始并不会0.5秒发一个令牌,而是频率越来越高,在3秒钟之内达到原本设置的频率,以后就以固定的频率输出。

介绍几个重要的参数

abstract class SmoothRateLimiter extends RateLimiter {
    //当前存储令牌数
    double storedPermits;
    //最大存储令牌数
    double maxPermits;
    //添加令牌时间间隔
    double stableIntervalMicros;
    private long nextFreeTicketMicros;
}

通过Debug我们可以看到,SmoothBursty方法的最大令牌数被设置成了,maxBurstSeconds * permitsPerSecond,而maxBurstSeconds默认是1。

img

高并发系统设计之限流_第5张图片

而 SmoothWarmingUp最大令牌数的计算方法要复杂的多。

高并发系统设计之限流_第6张图片

Nginx 限流

对于Nginx接入层限流可以使用 Nginx自带的两个模块:连接数限流模块ngx_http _limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module

limit_conn 用来对某个key对应的总的网络连接数进行限流,可以按照如IP、域名维度进行限流。limit_req用来对某个key对应的请求的平均速率进行限流,有两种用法:平滑模式(delay)和允许突发模式(nodelay)。

limit_conn

limit_conn是对某个key对应的总的网络连接数进行限流。可以按照IP来限制IP维度的总连接数,或者按照服务域名来限制某个域名的总连接数。但是,记住不是每个请求连接都会被计数器统计,只有那些被Nginx处理的且已经读取了整个请求头的请求连接才会被计数器统计。

http {
    limit_conn_zone $binary_remote_addr zone=addr:10m;
    limit_conn_log_level error;
    limit_conn_status 503 ;

    server {
        location /limit {
            limit_conn addr l;
        }
    }
}
  • limit_conn:要配置存放key和计数器的共享内存区域和指定key的最大连接数。此处指定的最大连接数是1,表示Nginx最多同时并发处理1个连接,addr就是限流key,对应上文 zone=addr。
  • limit_conn_zone:用来配置限流key及存放key对应信息的共享内存区域大小。此处的key是$binary_remote_addr**,表示IP地址,也可以使用**$server_name作为key来限制域名级别的最大连接数。
  • limit_conn_status:配置被限流后返回的状态码,默认返回503。
  • limit_conn_log_level:配置记录被限流后的日志级别,默认error级别。

limit_req

limit_req 是漏桶算法实现,用于对指定key 对应的请求进行限流,比如,按照 IP维度限制请求速率。配置示例如下:

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
    limit_conn_log_level error;
    limit_conn_status 503;

    server {
        location /limit {
            limit_req zone=one burst=20 nodelay;
        }
    }
}

limit_req 和 limit_conn 的配置类似。

  • limit_req:配置限流区域,上面的参数会让nginx 每个IP一秒钟只处理一个请求。
  • burst: burst 参数定义了超出 zone 指定速率的情况下,客户端还能发起多少请求,超出速率的请求将会被放入队列,我们将队列大小设置为20。这意味着,如果从一个给定 IP 地址发送 21 个请求,Nginx 会立即将第一个请求发送到上游服务器群,然后将余下 20 个请求放在队列中。然后每1秒转发一个排队的请求,只有当传入请求使队列中排队的请求数超过 20 时,Nginx 才会向客户端返回 503。
  • nodelay:配置 burst 参数将会使通讯更流畅,但是可能会不太实用,因为该配置会使站点看起来很慢。在上面的示例中,队列中的第 20 个包需要等待 20 秒才能被转发,此时返回给客户端的响应可能不再有用。要解决这个情况,可以在 burst 参数后添加 nodelay 参数。使用 nodelay 参数,当一个请求到达“太早”时,只要在队列中能分配位置,Nginx 将立即转发这个请求。将队列中的该位置标为”taken”(占据),并且不会被释放以供另一个请求使用,直到一段时间后才会被释放。假设如前所述,队列中有 20 个空位,从给定的 IP 地址发出的 21 个请求同时到达。Ngin x会立即转发这个 21 个请求,并且标记队列中占据的 20 个位置,然后每 1秒释放一个位置。如果是25个请求同时到达,Nginx 将会立即转发其中的 21 个请求,标记队列中占据的 20 个位置,并且返回 503 状态码来拒绝剩下的 4 个请求。如果希望不限制两个请求间允许间隔的情况下实施“流量限制”,nodelay 参数是很实用的。
  • limit_req_zone:配置限流key、存放key对应信息的共享内存区域大小、固定请求速率。此处指定的key是“$binary_remote_addr”,表示IP地址。10m表示共享内存的大小,16000 个 IP 地址的状态信息,大约需要 1MB,所以示例中区域可以存储 160000 个 IP 地址
  • limit_conn_status:配置被限流后返回的状态码,默认返回503。
  • limit_conn_log_level:配置记录被限流后的日志级别,默认级别为error。

黑白名单限流

geo $limit {
    default         1;
    10.0.0.0/8      0;
    192.168.0.0/64  0;
}
map $limit $limit_key {
    0 "";
    1 $binary_remote_addr;
}
limit_req_zone $limit_key zone=req_zone:10m rate=5r/s;
server {
    location / {
        limit_req zone=req_zone burst=10 nodelay;
    }
}

geo 指令将给在白名单中的 IP 地址对应的 $limit 变量分配一个值 0,给其它不在白名单中的分配一个值 1。然后我们使用一个映射将这些值转为 key。

白名单内 IP 地址的$limit_key变量被赋值为空字符串,不在白名单内的被赋值为客户端的 IP 地址。当limit_req_zone后的第一个参数是空字符串时,不会应用“流量限制”,所以白名单内的 IP 地址不会被限制。其它所有 IP 地址都会被限制到每秒 5 个请求。

而要做出网站黑名单,就有可能要屏蔽一堆ip,但是如果将其放在nginx.conf文件夹下,既不美观,也不利于管理,因此需要单独写出一个conf文件,然后在nginx.conf中使用 include标签引用它。

如果我们不是要限流,而是要直接实现黑名单禁止访问网站的话。可以使用allowdeny标签。

server{
    listen: 80;
    server_name www.baidu.com;
    allow all; #允许访问所有的ip
    deny 172.0.0.1; #禁止 172.0.0.1 访问
}

可以配合shell脚本,然后把脚本加入crontab定时任务就可以实现动态添加黑名单。

#!/bin/bash
#取最近5w条数据
tail -n50000 /usr/local/nginx/logs/access.log \
#过滤需要的信息行ip等
|awk '{print $1,$12}' \
#过滤爬虫
|grep -i -v -E "google|yahoo|baidu|msnbot|FeedSky|sogou|360|bing|soso|403|admin" \
#统计
|awk '{print $1}'|sort|uniq -c|sort -rn \
#超过1000加入黑名单
|awk '{if($1>1000)print "deny "$2";"}' >> /usr/local/nginx/conf/blockip.conf
#重启nginx生效
/usr/local/nginx/sbin/nginx -s reload

本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。

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