本文已收录至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限流器」。
引入依赖
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 > /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技术干货,文章持续更新,可以关注公众号第一时间阅读。
一起交流学习,期待与你共同进步!