高并发系统设计之限流

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

微信公众号:Java随想录

原创不易,注重版权。转载请注明原作者和原文链接

目录

当我们谈论Web应用或者服务,一个重要的话题就不能避免:「限流」。这是一种保护系统和维持服务稳定性的重要手段。

尤其当我们使用Java来进行应用开发时, 这个话题就显得尤为重要。限流可以保证一部分的请求得到正常的响应,是一种自我保护的措施。

限流可以保证使用有限的资源提供最大化的服务能力,按照预期流量提供服务,超过的部分将会拒绝服务、排队或等待、降级等处理。

在这篇博客中,我们将探讨限流技术。希望这篇博客能够给予正在处理或者即将面临流量管理问题的读者们一些启示和帮助。

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

限流算法

计数器算法

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

这种算法的大概思想如下:

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

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

假设有一个用户,他在1~1:58前都没有请求,在1:59秒时瞬间发送了1000个请求,并且1:01又发送了1000个请求,那么其实用户在 2秒里面,瞬间发送了2000个请求,但是因为请求在两次时间窗口的重置节点,计数器会判定没有超过阈值。

用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。用户有可能利用这个漏洞卡Bug,瞬间压垮我们的应用。

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

滑动窗口

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

图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分。比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。

这里的10秒称之为「步长 」,1分钟称之为「窗口大小」。

那么滑动窗口怎么解决刚才的临界问题的呢?

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

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

但是计算器算法和滑动窗口算法都有一个固有的问题:「它不能平滑地控制请求流量」。

以上面的例子说明,0:59和1:01秒都有1000个请求,而其他时间段没有请求,从宏观角度看,整个系统的请求处理速率并不平稳,而是有着明显的波动

这样的波动可能导致系统的负载瞬间上升,对资源造成压力,同时也可能影响到系统的稳定性。所以,虽然滑动窗口算法能够控制某个时间段内的请求总量,但它无法确保请求流量的平稳,可能会导致宏观视角下的请求数量波动较大。

漏桶算法

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

脑海中有这么一幅画面的时候,再举个例子:

假设我们有一个漏桶,其容量为100字节,以每秒10字节的速率流出。现在,我们有3个数据包,分别为20字节,50字节和120字节。

  • 第一个数据包(20字节)进入漏桶,由于漏桶未满,数据包被成功接收,并在两秒内发送完成(因为发送速度为10字节/秒)。
  • 接着第二个数据包(50字节)进入漏桶,同样,漏桶还未满,所以数据包被接收,并在五秒内发送完成。
  • 最后,第三个数据包(120字节)试图进入漏桶。但此时漏桶的剩余容量不足以接收这个数据包,因此超出漏桶容量的数据(20字节)会被丢弃,只有100字节的数据能够被接收。然后,这100字节的数据在10秒内被发送完成。

漏桶算法保证了固定的流出速率,这是漏桶算法的优点,也可以说是缺点。

缺点在于漏桶算法对「突发流量反应不良」。

当大量数据突然到来时,漏桶算法处理能力有限。一旦输入速率超过了漏桶的容量,所有溢出的数据都会被丢弃。

例如,如果我们在短时间内发送大量数据,由于漏桶的固定出口速率,可能会导致大量数据丢失,用户等待时间长,用户体验差。

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

令牌桶算法

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

令牌桶算法的原理是系统「以恒定的速率产生令牌」,然后把令牌放到令牌桶中,令牌桶有一个容量,当令牌桶满了的时候,再向其中放令牌,那么多余的令牌会被丢弃。

当想要处理一个请求的时候,需要从令牌桶中取出一个令牌,如果此时令牌桶中没有令牌,那么必须等待新的令牌被添加到桶中才能继续请求。

令牌桶算法可以通过限制可供立即使用的令牌数量来控制数据的请求速率,允许突发流量在一定程度上得到满足。

缺点:令牌桶的数量,生成的速度需要根据以往的系统性能以及用户习惯等经验的累积来判断,由于令牌桶算法允许突发数据传输,如果没有其他机制来控制,可能会在网络中引发严重的拥塞,实际限流数难以预知。

限流实现

我们已经探讨了限流算法的理论部分,下面来介绍具体在我们的开发中该如何去实现限流。

Guava RateLimiter实现限流

Guava工具包自带了「RateLimiter限流器」。

引入依赖

xml 复制代码
<dependency>
	<groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>

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

java 复制代码
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秒。

值得一提的是,Guava的RateLimiter基于「平滑突发预热"(SmoothBursty) 」和「平滑滚动窗口"(SmoothWarmingUp)」两种限流算法实现。

这两种算法都是基于「令牌桶(Token Bucket)算法」的改进。

平滑突发预热算法允许在开始时有一定的突发性,而平滑滚动窗口算法则会在预定的时间内逐渐增加允许的请求数量。

类继承关系图:

RateLimiter是个抽象类,子类SmoothRateLimiter又做了层抽象,SmoothRateLimiter有两个子类SmoothBurstySmoothWarmingUp

  • SmoothBursty 是一种基本的实现,当令牌桶中足够的令牌时,允许突发的请求。如果令牌被完全消耗掉,则每个新的请求都需要等待新的令牌生成。这种方式适合应对突然的大流量,但在令牌消耗殆尽后,响应时间可能会增加。使用 RateLimiter.create(double permitsPerSecond) 创建。

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

  • SmoothWarmingUp 是另一种实现,它包含了一个预热期,在此期间令牌会以较慢的速度生成。预热之后,令牌生成将达到正常速度。这种方式可以防止系统在初始阶段就被大流量冲垮,允许系统有一定的缓冲期来适应高流量。使用 RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 时创建,warmupPeriod 就是热身达到稳定速度的时间。

SmoothWarmingUp最大令牌数的计算方法则要复杂的多:

他们的主要区别在于如何处理初始的高流量请求。SmoothBursty 适用于能够承受初次大流量冲击的系统,而 SmoothWarmingUp 更适合需要一段时间来调整负载的系统。

预热限流

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

java 复制代码
import com.google.common.util.concurrent.RateLimiter;
import java.time.LocalDateTime;

public class SmoothWarmingUpExample {

    public static void main(String[] args) {
        double permitsPerSecond = 5.0; // 设置每秒允许的操作数为5
        int warmupPeriodInSeconds = 3; // 预热时间为3秒

        RateLimiter rateLimiter = RateLimiter.create(permitsPerSecond, warmupPeriodInSeconds, TimeUnit.SECONDS);

        for (int i = 1; i <= 20; i++) { 
            double waitTime = rateLimiter.acquire(); // acquire()方法将返回所需的等待时间
            System.out.println("Accessing resource at: " + LocalDateTime.now() + ", Wait time: " + waitTime + " seconds");
        }
    }
}

在上述代码中,我们设置了每秒允许的操作数为5,并且预热时间为3秒。你可以通过改变不同的参数来观察SmoothWarmingUp的效果。

运行此代码后,你应该能看到类似于以下的输出:

Accessing resource at: 2023-09-05T09:51:13.108, Wait time: 0.0 seconds
Accessing resource at: 2023-09-05T09:51:13.672, Wait time: 0.557151 seconds
Accessing resource at: 2023-09-05T09:51:14.188, Wait time: 0.512443 seconds
Accessing resource at: 2023-09-05T09:51:14.659, Wait time: 0.46468 seconds
Accessing resource at: 2023-09-05T09:51:15.071, Wait time: 0.407339 seconds
Accessing resource at: 2023-09-05T09:51:15.427, Wait time: 0.355441 seconds
Accessing resource at: 2023-09-05T09:51:15.738, Wait time: 0.306069 seconds
Accessing resource at: 2023-09-05T09:51:15.991, Wait time: 0.247954 seconds
Accessing resource at: 2023-09-05T09:51:16.198, Wait time: 0.201317 seconds
Accessing resource at: 2023-09-05T09:51:16.396, Wait time: 0.194641 seconds
Accessing resource at: 2023-09-05T09:51:16.596, Wait time: 0.196288 seconds
Accessing resource at: 2023-09-05T09:51:16.798, Wait time: 0.196751 seconds
Accessing resource at: 2023-09-05T09:51:16.994, Wait time: 0.194123 seconds
Accessing resource at: 2023-09-05T09:51:17.198, Wait time: 0.19905 seconds
Accessing resource at: 2023-09-05T09:51:17.395, Wait time: 0.194192 seconds
Accessing resource at: 2023-09-05T09:51:17.598, Wait time: 0.197366 seconds
Accessing resource at: 2023-09-05T09:51:17.797, Wait time: 0.194953 seconds
Accessing resource at: 2023-09-05T09:51:17.996, Wait time: 0.195428 seconds
Accessing resource at: 2023-09-05T09:51:18.198, Wait time: 0.196305 seconds
Accessing resource at: 2023-09-05T09:51:18.398, Wait time: 0.19455 seconds

从输出中可以看到,等待时间逐渐减少,最后趋于稳定。

SmoothWarmingUp 参数如下:

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

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处理的且已经读取了整个请求头的请求连接才会被计数器统计。

示例:

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来限制域名级别的最大连接数。文件中的 10m 是指 10 兆字节(megabytes)。在 limit_conn_zone 指令中,它指的是用于存储状态信息的共享内存区域的大小。
  • limit_conn_status:配置被限流后返回的状态码,默认返回503。
  • limit_conn_log_level:配置记录被限流后的日志级别,默认error级别。

limit_req

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

配置示例如下:

nginx 复制代码
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参数定义了请求的最大队列长度。当超过 rate(速率)参数设定的请求数量时,额外的请求会被放入队列等待处理。

    举例来说,如果 rate=1r/s(每秒一个请求),burst=20 的配置意味着:在正常情况下,Nginx 会限制每秒只能有一个请求进入。然而,如果突然出现瞬间高并发(例如一秒内突然来了30个请求),那么多出的29个请求不会立刻被丢弃或者返回错误,而是会暂存到一个队列中。由于队列长度为 burst 参数设定的20,所以前20个额外的请求会被放入队列,排队等待处理;超出队列长度的后续请求(这里的第30个请求)将会被拒绝,并返回503状态码。

  • nodelay :配置 burst 参数将会使通讯更流畅,但是可能会不太实用,因为该配置会使站点看起来很慢。nodelay 参数的作用是改变处理超出请求数的方式。如果没有 nodelay,Nginx 会尝试平滑处理这些额外的请求,将它们分散到接下来几秒内进行处理。而有了 nodelay 后,Nginx 不再把这些请求推迟到后面的时间,而是立刻处理所有符合 burst 额度内的请求,超出部分则直接拒绝。

    例如,假设我们设置了 rate=1r/s, burst=5, nodelay。如果在一秒内收到了7个请求,由于我们设定了 nodelay,所以 Nginx 会立刻处理其中6个请求(1个基础请求加上5个 burst),剩下的1个请求(第7个)将被立刻拒绝,因为它超出了允许的 burst 额度。

  • 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。

黑白名单限流

某些情况下,我们可能只希望对黑名单中的IP地址进行限流。

这时,Nginx可以进行如下配置:

nginx 复制代码
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;
    }
}

这个配置主要实现了对特定 IP 地址的请求限制。以下是每段代码的详细解释:

  1. geo $limit {...}:此部分定义了一个变量 $limit,根据客户端的 IP 地址赋值。default 1; 表示默认值为 1,10.0.0.0/8192.168.0.0/64 的 IP 地址段的值为 0。
  2. map $limit $limit_key {...}:此部分根据 $limit 变量的值定义另一个变量 $limit_key。如果 $limit 为 0,则 $limit_key 为空字符串;如果 $limit 为 1,则 $limit_key 的值等于客户端的二进制 IP 地址。
  3. limit_req_zone $limit_key zone=req_zone:10m rate=5r/s;:此部分定义了名为 req_zone 的共享内存区域,大小为 10MB,用于存储每个 $limit_key(即特定 IP 地址)的当前状态。rate=5r/s 设置了请求的速率限制,即每秒最多只能接受 5 个请求。
  4. server 部分,在 / 位置使用了 limit_req 指令来应用定义的限制。zone=req_zone 指定了要使用的限制区域。burst=10 允许瞬间并发请求超过限制,将多出的请求放在队列中等待处理,队列长度为 10。nodelay 表示不进行延迟处理,即达到 rate 后立即拒绝超出的请求。

总结起来,这个配置文件实现了除了从 10.0.0.0/8192.168.0.0/64 这两个 IP 地址段发出的请求之外,其他所有 IP 地址每秒只能发送 5 个请求,并且允许突发请求数量达到 10 个。

IP地址不计其数,而要做出网站黑名单,就有可能要屏蔽一堆IP,但是如果将其放在nginx.conf文件夹下,既不美观,也不利于管理。

因此可以单独写出一个conf文件,然后在nginx.conf中使用include标签引用它。

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

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

这个名单我们肯定是需要动态变更的,不然每次人为去维护太麻烦,可以配合shell脚本,然后把脚本加入crontab定时任务就可以实现动态添加黑名单。

参考示例如下:

#取最近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

上面是一个bash脚本,用于分析Nginx的访问日志并阻止特定IP访问。下面是详细解释:

  1. tail -n50000 /usr/local/nginx/logs/access.log:从访问日志的末尾开始获取最新的50000条记录。
  2. awk '{print $1,$12}':使用awk命令从每行中提取第一个和第十二个空格分隔的字段,这些通常是客户端IP和用户代理字符串。
  3. grep -i -v -E "google|yahoo|baidu|msnbot|FeedSky|sogou|360|bing|soso|403|admin":使用grep命令排除包含列出的字符串的行,这些通常是爬虫的名称或不希望阻止的访问源。
  4. awk '{print $1}'|sort|uniq -c|sort -rn:再次使用awk打印第一个字段(IP),然后排序并统计每个IP出现的次数,最后按照数量降序排序。
  5. awk '{if($1>1000)print "deny "$2";"}' >> /usr/local/nginx/conf/blockip.conf:如果某个IP的访问次数超过1000次,那么将其添加到Nginx的配置文件blockip.conf中,使用deny指令阻止该IP进一步访问。
  6. /usr/local/nginx/sbin/nginx -s reload:重新加载Nginx的配置以应用更新的黑名单。

注意:频繁地重启Nginx可能会导致部分请求失败,因此在生产环境中要谨慎使用此脚本。

Semaphore

上面两种方案都要借助框架或者中间件,Java自己的Semaphore就可以实现限流,不过功能上远不如上面两个强大。

Semaphore是一个计数信号量,用于管理对有限资源的访问。它是一种同步工具,可以在并发编程中很好地强制限制对特定资源的访问。

当我们谈论「有限资源」,可以指代的是多种类型的资源,例如数据库连接或者网络连接等。

Semaphore通过内部计数器来跟踪资源的使用:初始化时设定一个最大值,每次资源被请求时减一,每次资源被释放时加一。当计数器为0时,任何进一步的请求都会被阻塞,直到有其他线程释放一个资源。

以下是一个 Semaphore 的示例:

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class SemaphoreExample {

    public static void main(String[] args) {
        // 创建包含5个线程的线程池,这样我们有了5个可用的线程
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // 创建一个Semaphore实例,只允许2个线程同时执行
        Semaphore semaphore = new Semaphore(2);

        Runnable longRunningTask = () -> {
            boolean permit = false;
            try {
                permit = semaphore.tryAcquire();
                if (permit) {
                    System.out.println("Semaphore acquired");
                    Thread.sleep(2000);
                } else {
                    System.out.println("Could not acquire semaphore");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (permit) {
                    semaphore.release();
                }
            }
        };

        for (int i = 0; i < 5; i++) {
            executor.submit(longRunningTask);
        }

        //关闭executor
        executor.shutdown();
    }
}

上述代码创建了一个线程池和一个 Semaphore 实例。每次只有两个线程被允许执行(由 Semaphore 实例控制)。当所有任务提交给线程池后,每个任务都尝试获取 Semaphore,如果成功,则任务开始执行,否则等待直到其他任务释放 Semaphore。在这个例子中,虽然有5个线程在线程池中,但通过 Semaphore 我们限制了同时运行的线程数为2。

这段代码输出如下:

java 复制代码
Semaphore acquired
Semaphore acquired
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore

总结

在这篇文章中,我们探讨了高并发系统限流的各种算法和实现。现行的许多方法都可以有效地解决这个问题,但它们并非万能的。根据业务需求,环境和其他因素的不同,不同的限流策略也会有所不同。

总之,虽然面对高并发系统限流的问题可能会让人觉得有些头疼,但只要我们深入理解业务需求,准确选择适当的工具和策略,就一定可以战胜它。记住,最好的解决方案总是那些能够随着时间的推移持续改进和优化的方案

希望你喜欢阅读这篇文章,并从中找到一些对你有用的信息。


感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。

老铁们,关注我的微信公众号「Java 随想录」,专注分享Java技术干货,文章持续更新,可以关注公众号第一时间阅读。

一起交流学习,期待与你共同进步!