关于后端:高并发系统设计之限流

36次阅读

共计 7334 个字符,预计需要花费 19 分钟才能阅读完成。

本文已收录至 Github,举荐浏览 👉 Java 随想录

微信公众号:Java 随想录

CSDN:码农 BookSea

这篇文章来讲讲限流,在高并发零碎中限流是必不可少的,限流能够保障一部分的申请失去失常的响应,是一种自我爱护的措施。限流能够保障应用无限的资源提供最大化的服务能力,依照预期流量提供服务,超过的局部将会拒绝服务、排队或期待、降级等解决。

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

限流算法

计数器算法

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

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

计数器算法尽管简略,然而有一个非常致命的问题,那就是临界问题。

假如有一个用户,他在 0:59 时,霎时发送了 1000 个申请,并且 1:01 又发送了 1000 个申请,那么其实用户在 2 秒外面,霎时发送了 2000 个申请。用户通过在工夫窗口的重置节点处突发申请,能够霎时超过咱们的速率限度。用户有可能利用这个破绽卡 Bug,霎时压垮咱们的利用。

毛病:没有方法避免工夫范畴临界点突发大流量,很可能在工夫范畴交界处被大量申请间接打到降级,影响后续服务

滑动窗口

滑动窗口算法解决了上诉计数器算法的毛病。计数器的工夫窗口是固定的,而滑动窗口的工夫窗口是动静的。

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

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

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

毛病:滑动窗口无奈平滑管制申请流量,仅能管制时间段内申请总量,宏观来看,时间轴上的申请数量波形可能呈现较大的稳定

漏桶算法

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

漏桶算法的话保障了固定的流出速率,这是漏桶算法的长处,也能够说是毛病。始终恒定的解决速率有时候并不一定是好事件,对于突发的申请洪峰,在保障服务平安的前提下,应该尽最大致力去响应,这个时候漏桶算法显得有些僵滞,最终可能导致水位”溢出“,申请被抛弃。

毛病:无奈应答突发流量,因为处理速度恒定,当大量申请到来时,用户等待时间长,用户体验差

令牌桶算法

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

令牌桶算法的原理是零碎以恒定的速率产生令牌,而后把令牌放到令牌桶中,令牌桶有一个容量,当令牌桶满了的时候,再向其中放令牌,那么多余的令牌会被抛弃;当想要解决一个申请的时候,须要从令牌桶中取出一个令牌,如果此时令牌桶中没有令牌,那么则回绝该申请。

毛病:令牌桶的数量,生成的速度须要依据以往的零碎性能以及用户习惯等教训的累积来判断,理论限流数难以预知

限流算法实现

Guava RateLimiter 实现限流

引入依赖

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>

上面是一个应用的简略例子:

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

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。

而 SmoothWarmingUp 最大令牌数的计算方法要简单的多。

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

本篇文章就到这里,感激浏览,如果本篇博客有任何谬误和倡议,欢送给我留言斧正。文章继续更新,能够关注公众号第一工夫浏览。

正文完
 0