Java web应用性能分析之【高并发之限流】

Java web应用性能分析之【高并发之缓存-多级缓存】-CSDN博客

高并发应对策略:缓存、限流、降级

限流的扯淡概叙

        在前面的文章中我们已经概叙了保护软件系统的“三高”特性:高可用,高性能,高并发;以及应对高并发的三大利器:缓存、限流、降级;在前文中详细描述了“多级缓存”,现在我们聊聊“多级限流”。

        “限流”和房地产的限购有异曲同工之妙,都是对“稀缺资源”的一种限制,设置各种门槛,不让你拥有它;目的是保护“稀缺资源”,不让“超售”导致系统故障。想象一下限购,目的是防止炒房,遵循中央的“房住不炒”指示,从而保护我国被房地产撑大的经济体系。毕竟有“隔壁小日子过得不错的日本选手”的前车之鉴,自九十年代房地产泡沫破裂导致经济下行,至今30多年还未恢复。我国的房地产泡沫破裂势必会影响我中华民族伟大复兴的历史使命,至少在完成历史使命前它不能破。

        限流的手段:限制消费者总量、限制单个消费者购买量。

  • 限制消费者总量:类似外地户口社保满五年、本地户口、楼盘摇号等,与之 对应的是我们的池化,线程池(web服务器的请求处理线程池)、连接池(数据库、Redis等的连接池)等。
  • 限制单个消费者购买量:类似单身限购一套、家庭限购两套等,与之对应的是单位时间内的请求数。

备注: (扯蛋一下啊,房子不稀缺:国家统计局原副局长贺铿表示,中国的房地产供应过剩,14亿人可能住不完。有数据显示,中国的住房空置率达到了15%,有些省份甚至高达25%或30%。并且,中国有大量的闲置房产,超过了1.2亿套,这些房产足以满足4亿人口的居住需求。加之现在人口越来越少,那么房子就更不是稀缺了。)

        回归正题,限流是指在各种应用场景中,通过技术和策略手段对数据流量、请求频率或资源消耗进行有计划的限制,以避免系统负载过高、性能下降甚至崩溃的情况发生。限流的目标在于维护系统的稳定性和可用性,并确保服务质量。

        限流在本质上,是设置一个资源数量上限,超出这个上限的请求,将被缓冲,或者直接失败,是一种过载保护。对于高并发场景下的限流来说,它有特殊的含义:它主要是用来保护底层资源的。如果你想要调用某些服务,你需要首先获取调用它的许可。限流一般由服务提供方来提供,对调用方能够做事的能力进行限制。

微服务请求响应示意图

        截止到2024年,从当前技术在来说,Spring Cloud+Nginx系统架构毫无疑问是当今“三高”Web应用的通用技术之一。Spring Cloud微服务框架和高性能的Nginx反向代理Web服务的优秀组合,基本满足互联网应用的可扩展、高可用、高性能的架构需求。

限流的好处

  1. 保护系统稳定性:过多的并发请求可能导致服务器内存耗尽、CPU 使用率饱和,从而引发系统响应慢、无法正常服务的问题。
  2. 防止资源滥用:确保有限的服务资源被合理公平地分配给所有用户,防止个别用户或恶意程序过度消耗资源。
  3. 优化用户体验:对于网站和应用程序而言,如果任由高并发导致响应速度变慢,会影响所有用户的正常使用体验。
  4. 保障安全:在网络层面,限流有助于防范 DoS/DDoS 攻击,降低系统遭受恶意攻击的风险。
  5. 运维成本控制:合理的限流措施可以帮助企业减少不必要的硬件投入,节省运营成本。

高并发场景下如何实现系统限流?

        限流要结合容量和压测来进行,当外部请求接近或者达到系统最大阈值时,触发限流,采取其他手段进行降级,保证系统不被压垮,常见降级策略包括延迟处理,拒绝服务,随机拒绝等。

        在Spring Cloud+Nginx系统架构下,限流方式:Nginx入口限流、网关限流、单机限流。

        Nginx入口限流:通常在网关层的上游,在用户请求到达业务服务前进行处理,在入口的Nginx做限流,然后再将请求转发给网关,网关再调用其他的微服务,从而实现整个流程的请求调用,Nginx入口 限流也是分布式系统中常用的限流手段。

        网关限流:在Spring Cloud+Nginx系统架构下,通过网关组件来限流,在Spring Cloud Gateway上结合Sentinel或者Hystrix 对整个业务平台进行限流(当然后面涉及的“降级”也是可以通过这两个组件来完成)。

        单机限流:使用JUC的Semaphore、Guava 框架、Redis+lua等,再结合spring注解,对单个接口进行单机限流。在微服务架构下,各接口对应的服务至少是启动两个,且运行在多台服务器上,而我们的单机限流一般都是基于jvm层面的限流,只能在单台服务器上限流,有一定的局限性,一般是用于多线程级别的限流。

        一般情况下我们是做两级限流:Nginx入口限流和网关限流。

Nginx入口限流

Nginx 提供了两种限流手段:

  • 通过控制速率来实现限流:使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制。
  • 通过控制并发连接数来实现限流:利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数。

参考:Java web应用性能分析服务端慢之Nginx慢-CSDN博客

Nginx通过控制速率来实现限流
使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制,示例配置如下:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
  location / {
    limit_req zone=mylimit;
  }
}

以上配置表示,限制每个 IP 访问的速度为 2r/s,因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第 2 个请求。


上面的速率控制虽然很精准但是应用于真实环境未免太苛刻了,真实情况下我们应该控制一个 IP 单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用 burst 关键字开启此设置,示例配置如下:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
  location / {
    limit_req zone=mylimit burst=4;
  }
}

burst=4 表示每个 IP 最多允许4个突发请求

使用单 IP 在 10ms 内发并发送了 6 个请求的执行结果如下:

        从以上结果可以看出他的执行符合我们的预期,只有 1 个执行成功了,其他的 5 个被拒绝了(第 2 个在 501ms 才会被正常执行)。

如果单个 IP 在 10ms 内发送 6 次请求的结果如下:

        从以上结果可以看出,限流升级后,有 1 个请求被立即处理了,4 个请求被放到 burst 队列里排队执行了,另外 1 个请求被拒绝了。

Nginx通过控制并发连接数来实现限流
利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数,示例配置如下:
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
  ...
  limit_conn perip 10;
  limit_conn perserver 100;
}
其中 limit_conn perip 10 表示限制单个 IP 同时最多能持有 10 个连接;limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。

网关限流

        在 Spring Cloud Gateway 网关层限流,可以用Sentinel 限流框架来实现。

详细参考:只需三步实现Gateway结合Sentinel实现无侵入网关限流,注意避坑!_sentinel gateway-CSDN博客

1.引入依赖
<dependency>
  <groupId>com.alibaba.cloud</groupId>
  <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
  <groupId>com.alibaba.cloud</groupId>
  <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>

2.配置限流相关的规则
spring:
  application:
    name: gate-way-blog
  cloud:
    sentinel:
      transport:
        dashboard: localhost:18080
      scg: # 配置限流之后,响应内容
        fallback:
          # 两种模式,一种是 response 返回文字提示信息,
          # 另一种是 redirect 重定向跳转,不过配置 redirect 也要配置对应的跳转的 uri
          mode: response
          # 响应的状态
          response-status: 200
          # 响应体
          response-body: '{"code": -10,"message": "被熔断或限流!"}'

3.最后在 Sentinel 控制台配置网关的限流设置即可,当然也可以使用 Nacos 作为数据源,两者选择配置其中一个即可。

单机限流

        JVM 层面多线程级别的限流可以使用 JUC下的 Semaphore,示例如下:

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SemaphoreExample {

    private final Semaphore semaphore = new Semaphore(5); // 只允许5个线程同时访问

    public void accessResource() {
        try {
            semaphore.acquire(); // 获取许可,如果当前许可数不足,则会阻塞
            System.out.println(Thread.currentThread().getName() + "获得了许可,正在访问资源...");
            // 模拟访问资源的时间消耗
            Thread.sleep(2000);
            System.out.println(Thread.currentThread().getName() + "访问资源结束,释放许可...");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            e.printStackTrace();
        } finally {
            semaphore.release(); // 访问结束后释放许可
        }
    }

    public static void main(String[] args) {
        SemaphoreExample example = new SemaphoreExample();

        for (int i = 0; i < 10; i++) {
            new Thread(() -> example.accessResource()).start();
        }
    }
}

        想要实现更平滑的单机限流,可以考虑 Google 提供的 Guava 框架,示例如下:

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>28.2-jre</version>
</dependency>

import com.google.common.util.concurrent.RateLimiter;
import java.time.Instant;

/**
 * Guava 实现限流
 */
public class RateLimiterExample {
    public static void main(String[] args) {
        // 每秒产生 10 个令牌(每 100 ms 产生一个)
        RateLimiter rt = RateLimiter.create(10);
        for (int i = 0; i < 11; i++) {
            new Thread(() -> {
                // 获取 1 个令牌,获取到令牌就执行,否则就阻塞等待
                rt.acquire();
                System.out.println("正常执行方法,ts:" + Instant.now());
            }).start();
        }
    }
}

问题思考:

1.Semaphore 限流和 Guava 限流有什么区别?

2.Sentinel 和 Nginx 限流有什么不足?应该如何避免?

限流算法

  • 计数器法:将时间划分固定窗口大小,如1s设定100请求,该窗口时间之后的请求进行丢弃处理。
  • 滑动窗口计数:将时间拆分为毫秒,窗口计数器总和超过设定数量,后续请求都丢弃。
  • 漏桶算法:以固定的速率流出,对请求突发不太友好,会被舍弃掉。
  • 令牌桶算法:请求突发量取决于桶里令牌数量,保护本系统,对下游系统不太友好。
  • 黑白名单限流:区别于上面四种限流,这种限流一般是针对恶意攻击,或者服务只对固定客户开放。
计数器算法

        计数器法:将时间划分固定窗口大小,如1s设定100请求,该窗口时间之后的请求进行丢弃处理。计数器算法是限流算法里最简单也是最容易实现的一种算法。

        有时我们还会使用计数器来进行限流,主要用来限制一定时间内的总并发数,比如数据库连接池、线程池、秒杀的并发数;计数器限流只要一定时间内的总请求数超过设定的阀值则进行限流,是一种简单粗暴的总数量限流,而不是平均速率限流。

        这个方法有一个致命问题:临界问题——当遇到恶意请求,在0:59时,瞬间请求100次,并且在1:00请求100次,那么这个用户在1秒内请求了200次,用户可以在重置节点突发请求,而瞬间超过我们设置的速率限制,用户可能通过算法漏洞击垮我们的应用。

滑动窗口算法

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

        图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分。比如图中,我们就将滑动窗口划成了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秒内被发送完成。

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

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

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

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

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

令牌桶算法

        从上图中可以看出,令牌算法有点复杂,桶里存放着令牌token。桶一开始是空的,token以固定的速率r往桶里面填充,直到达到桶的容量,多余的token会被丢弃。每当一个请求过来时,就会尝试着移除一个token,如果没有token,请求无法通过。

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

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

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

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

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

黑白名单限流

        某些情况下(这种限流一般是针对恶意攻击,或者服务只对固定客户开放),我们可能只希望对黑名单中的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访问。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

-无-为-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值