本文已收录至 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 限流器」。
引入依赖
<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 秒。
值得一提的是,Guava 的 RateLimiter 基于「平滑突发预热”(SmoothBursty)」和「平滑滚动窗口”(SmoothWarmingUp)」两种限流算法实现。
这两种算法都是基于「令牌桶 (Token Bucket) 算法」的改良。
平滑突发预热算法容许在开始时有肯定的突发性,而平滑滚动窗口算法则会在预约的工夫内逐步减少容许的申请数量。
类继承关系图:
RateLimiter 是个抽象类,子类 SmoothRateLimiter 又做了层形象,SmoothRateLimiter 有两个子类 SmoothBursty
和SmoothWarmingUp
。
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 是带有预热期的平滑限流,它启动后会有一段预热期,逐渐将散发频率晋升到配置的速率。
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 参数如下:
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 解决的且曾经读取了整个申请头的申请连贯才会被计数器统计。
示例:
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 维度限度申请速率。
配置示例如下:
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 能够进行如下配置:
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 地址的申请限度。以下是每段代码的具体解释:
geo $limit {...}
:此局部定义了一个变量$limit
,依据客户端的 IP 地址赋值。default 1;
示意默认值为 1,10.0.0.0/8
和192.168.0.0/64
的 IP 地址段的值为 0。map $limit $limit_key {...}
:此局部依据$limit
变量的值定义另一个变量$limit_key
。如果$limit
为 0,则$limit_key
为空字符串;如果$limit
为 1,则$limit_key
的值等于客户端的二进制 IP 地址。limit_req_zone $limit_key zone=req_zone:10m rate=5r/s;
:此局部定义了名为req_zone
的共享内存区域,大小为 10MB,用于存储每个$limit_key
(即特定 IP 地址)的以后状态。rate=5r/s
设置了申请的速率限度,即每秒最多只能承受 5 个申请。- 在
server
局部,在/
地位应用了limit_req
指令来利用定义的限度。zone=req_zone
指定了要应用的限度区域。burst=10
容许霎时并发申请超过限度,将多出的申请放在队列中期待解决,队列长度为 10。nodelay
示意不进行提早解决,即达到 rate 后立刻回绝超出的申请。
总结起来,这个配置文件实现了除了从 10.0.0.0/8
和 192.168.0.0/64
这两个 IP 地址段收回的申请之外,其余所有 IP 地址每秒只能发送 5 个申请,并且容许突发申请数量达到 10 个。
IP 地址成千上万,而要做出网站黑名单,就有可能要屏蔽一堆 IP,然而如果将其放在 nginx.conf 文件夹下,既不美观,也不利于治理。
因而能够独自写出一个 conf 文件,而后在 nginx.conf 中应用 include
标签援用它。
如果咱们不是要限流,而是要间接实现黑名单禁止拜访网站的话。能够应用「allow」和「deny」标签。
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 拜访。上面是具体解释:
tail -n50000 /usr/local/nginx/logs/access.log
:从拜访日志的开端开始获取最新的 50000 条记录。awk '{print $1,$12}'
:应用 awk 命令从每行中提取第一个和第十二个空格分隔的字段,这些通常是客户端 IP 和用户代理字符串。grep -i -v -E "google|yahoo|baidu|msnbot|FeedSky|sogou|360|bing|soso|403|admin"
:应用 grep 命令排除蕴含列出的字符串的行,这些通常是爬虫的名称或不心愿阻止的拜访源。awk '{print $1}'|sort|uniq -c|sort -rn
:再次应用 awk 打印第一个字段(IP),而后排序并统计每个 IP 呈现的次数,最初依照数量降序排序。awk '{if($1>1000)print"deny "$2";"}' >> /usr/local/nginx/conf/blockip.conf
:如果某个 IP 的拜访次数超过 1000 次,那么将其增加到 Nginx 的配置文件 blockip.conf 中,应用deny
指令阻止该 IP 进一步拜访。/usr/local/nginx/sbin/nginx -s reload
:从新加载 Nginx 的配置以利用更新的黑名单。
留神:频繁地重启 Nginx 可能会导致局部申请失败,因而在生产环境中要审慎应用此脚本。
Semaphore
下面两种计划都要借助框架或者中间件,Java 本人的 Semaphore 就能够实现限流,不过性能上远不如下面两个弱小。
Semaphore 是一个计数信号量,用于治理对无限资源的拜访。它是一种同步工具,能够在并发编程中很好地强制限度对特定资源的拜访。
当咱们议论「无限资源」,能够指代的是多种类型的资源,例如数据库连贯或者网络连接等。
Semaphore 通过外部计数器来跟踪资源的应用:初始化时设定一个最大值,每次资源被申请时减一,每次资源被开释时加一。当计数器为 0 时,任何进一步的申请都会被阻塞,直到有其余线程开释一个资源。
以下是一个 Semaphore 的示例:
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。
这段代码输入如下:
Semaphore acquired
Semaphore acquired
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
总结
在这篇文章中,咱们探讨了高并发零碎限流的各种算法和实现。现行的许多办法都能够无效地解决这个问题,但它们并非万能的。依据业务需要,环境和其余因素的不同,不同的限流策略也会有所不同。
总之,尽管面对高并发零碎限流的问题可能会让人感觉有些头疼,但只有咱们深刻了解业务需要,精确抉择适当的工具和策略,就肯定能够战败它。记住,最好的解决方案总是那些可能随着工夫的推移继续改良和优化的计划。
心愿你喜爱浏览这篇文章,并从中找到一些对你有用的信息。
感激浏览,如果本篇文章有任何谬误和倡议,欢送给我留言斧正。
老铁们,关注我的微信公众号「Java 随想录」,专一分享 Java 技术干货,文章继续更新,能够关注公众号第一工夫浏览。
一起交流学习,期待与你共同进步!