高并发接口限流

文章目录

    • 简介
    • 使用限流工具
    • 实现限流常见的算法4种
      • 1、计数器限流算法
      • 2、滑动窗口限流算法
      • 3、漏桶限流算法
      • 4.令牌桶限流算法
    • 接口限流方案
    • 限流算法对比、网关限流实践总结
    • Redis实现限流的几种方式
      • 基于Redis的setNX的操作(固定时间算法)
      • 基于Redis的数据结构zset(滑动窗口)
      • 基于Redis的令牌桶算法

简介

所谓限流,就是指限制流量请求的频次。它主要是在高并发情况下,用于保护系统的一种策略,主要是避免在流量高峰导致系统崩溃,造成系统不可用的问题。
在设计接口限流方案时,可以考虑以下几种常见的限流策略:

  1. 固定窗口计数器:该策略将时间划分为固定大小的窗口,在每个窗口内限制请求的数量。例如,每秒钟最多允许处理10个请求。如果超过了限制数量,则拒绝后续请求。这种方法简单直观,但可能会因为窗口切换瞬间的流量峰值而导致不均匀的限流效果。
  2. 滑动窗口计数器:与固定窗口计数器类似,滑动窗口计数器也将时间划分为窗口,但窗口之间存在重叠。例如,每秒钟最多允许处理10个请求,但可以在前一秒的计数器中减去已过期的请求数量。这样可以更平滑地限制请求,减少流量峰值对服务的影响。
  3. 令牌桶算法:令牌桶算法通过维护一个固定容量的令牌桶,以固定速率向其中添加令牌。每次请求需要消耗一个令牌,只有当令牌桶中有足够的令牌时才能处理请求,否则请求被拒绝。这种算法能够平滑地限制请求,但可能会因为突发请求导致桶中令牌不足。
  4. 漏桶算法:漏桶算法将请求处理看作是一个水桶中的水流出的过程。固定的请求速率相当于固定的水流速度,而请求则相当于水滴。如果水桶已满,则多余的请求(水滴)会被丢弃,从而实现限流效果。这种算法能够稳定地限制请求速率,但可能会因为突发请求导致响应时间增加。
  5. 基于权重的限流:基于权重的限流方法根据不同的请求类型或用户进行区分,并为不同类型或用户设置不同的请求限制。例如,对于高优先级的请求可以设置较高的限制,而对于低优先级的请求可以设置较低的限制。这种方法可以根据具体场景进行细粒度的限流控制。
    选择合适的限流方案需要根据具体业务需求和性能要求来确定,通常需要综合考虑系统的稳定性、性能开销和用户体验等因素。

使用限流工具

使用一些现成的限流工具,如Spring Cloud Gateway、Sentinel等,来轻松实现限流功能。

实现限流常见的算法4种

所谓限流,就是指限制流量请求的频次。它主要是在高并发情况下,用于保护系统的一种策略,主要是避免在流量高峰导致系统崩溃,造成系统不可用的问题。
实现限流常见的算法4种,分别是计数器限流算法、滑动窗口限流算法、漏桶限流算法、令牌桶限流算法。

1、计数器限流算法

一般用在单一维度的访问频率限制上,比如短信验证码每隔 60s只能发送一次,或者接口调用
次数等。它的实现方法很简单,就是每调用一次就加 1,处理结束以后减1。
统计一段时间内允许通过的请求数。比如 qps为100,即1s内允许通过的请求数100,每来一个请求计数器加1,超过100的请求拒绝、时间过1s后计数器清0,重新计数。这样限流比较暴力,如果前10ms 来了100个请求,那剩下的990ms只能眼睁睁看着请求被过滤掉,并不能平滑处理这些请求,容易出现常说的“突刺现象”。
高并发接口限流_第1张图片

2、滑动窗口限流算法

本质上也是一种计数器,只是通过以时间为维度的可滑动窗口设计,来减少了临界值带来的并
发超过阈值的问题。每次进行数据统计的时候,只需要统计这个窗口内每个时间刻度的访问量就可
以了。Spring Cloud 中的熔断框架 Hystrix,以及 Spring Cloud Alibaba 中的Sentinel 都采用滑动
窗口来做数据统计。
高并发接口限流_第2张图片

3、漏桶限流算法

它是一种恒定速率的限流算法,不管请求量是多少,服务端的处理效率是恒定的。基于 MQ 来实现
的生产者消费者模型,其实算是一种漏桶限流算法
高并发接口限流_第3张图片

4.令牌桶限流算法

相对漏桶算法来说,它可以处理突发流量的问题。它的核心思想是,令牌桶以恒定速率去生成令牌保存到令牌桶里面,桶的大小是固定的,令牌桶满了以后就不再生成令牌。每个客户端请求进来的时候,必须要从令牌桶获得一个令牌才能访问,否则排队等待。在流量低峰的时候,令牌桶会出现堆积,因此当出现瞬时高峰的时候,有足够多的令牌可以获取,因此令牌桶能够允许瞬时流量的处理。网关层面的限流、或者接口调用的限流,都可以使用令牌桶算法,像 Google 的Guava,和Redisson 的限流,都用到了令牌桶算法。我认为,限流的本质是实现系统保护,最终选择什么样的算法,一方面取决于统计的精准度,另一方面考虑限流维度和场景的需求。
假设有个桶,并且会以一定速率往桶中投放令牌,每次请求来时都要去桶中拿令牌,如果拿到则放行,拿不到则进行等待直至拿到令牌为止,比如以每秒100的速度往桶中投放令牌,令牌桶初始化一秒过后桶内有100个令牌,如果大量请求来时会立即消耗完100个令牌,其余请求进行等待,最终以匀速方式放行这些请求。此算法的好处在于既能应对短暂瞬时流量,又可以平滑处理请求。

高并发接口限流_第4张图片

接口限流方案

限制总并发数(比如数据库连接池、线程池)
限制瞬时并发数(如 nginx 的 limit_conn模块,⽤来限制 瞬时并发连接数)
限制时间窗口内的平均速率(如 Guava 的 RateLimiter、nginx 的 limit_req 模块,限制每秒的平均速率)
限制远程接口调用速率
限制MQ 的消费速率
可以根据网络络连接数、⽹络流量、CPU或内存负载等来限流

限流策略:
Nginx接入层限流
按照⼀定的规则如帐号、IP、系统调用逻辑等在Nginx层面做限流
业务应用系统限流
通过业务代码控制流量这个流量可以被称为信号量,可以理解成是⼀种锁,它可以限制⼀项资源最多能同时被多少进程访问。
2、lua脚本:

local key = KEYS[1] --限流KEY(⼀秒⼀个)
    local limit = tonumber(ARGV[1]) --限流⼤⼩
    local current = tonumber(redis.call('get', key) or "0")
    if current + 1 > limit then --如果超出限流⼤⼩
return 0
    else --请求数+1,并设置2秒过期
redis.call("INCRBY", key,"1")
redis.call("expire", key,"2")
end
 return 1

减少⽹络开销: 不使⽤ Lua 的代码需要向 Redis 发送多次请求, ⽽脚本只需⼀次即可, 减少⽹络传输;
原⼦操作: Redis 将整个脚本作为⼀个原⼦执⾏, ⽆需担⼼并发, 也就⽆需事务;
复⽤: 脚本会永久保存 Redis 中, 其他客户端可继续使⽤.

2、ip限流lua脚本:

local key = "rate.limit:" .. KEYS[1]
local limit = tonumber(ARGV[1])
local expire_time = ARGV[2]

 local is_exists = redis.call("EXISTS", key)
 if is_exists == 1 then
if redis.call("INCR", key) > limit then
return 0
else
return 1
end
 else
redis.call("SET", key, 1)
redis.call("EXPIRE", key, expire_time)
return 1
end

import org.apache.commons.io.FileUtils;

import redis.clients.jedis.Jedis;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class RedisLimitRateWithLUA {
public static void main(String[] args) {
 final CountDownLatch latch = new CountDownLatch(1);
for (int i = 0; i < 7; i++) {
new Thread(new Runnable() {
public void run() {
try {
 latch.await();
 System.out.println("请求是否被执⾏:"+accquire());
} catch (Exception e) {
 e.printStackTrace();
}
}
}).start();
}
 latch.countDown();
}
public static boolean accquire() throws IOException, URISyntaxException {
 Jedis jedis = new Jedis("127.0.0.1");
 File luaFile = new File(RedisLimitRateWithLUA.class.getResource("/").toURI().getPath() + "limit.lua"
 String luaScript = FileUtils.readFileToString(luaFile);
 String key = "ip:" + System.currentTimeMillis()/1000; // 当前秒
 String limit = "5"; // 最⼤限制
 List<String> keys = new ArrayList<String>();
 keys.add(key);
 List<String> args = new ArrayList<String>();
 args.add(limit);
 Long result = (Long)(jedis.eval(luaScript, keys, args)); // 执⾏lua脚本,传⼊参数
return result == 1;
}

限流算法对比、网关限流实践总结

Guava RateLimiter实现平滑限流
Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流

2.1 spirng cloud gateway网关限流原理解析
** 核心限流类:org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter

2.2 spring-cloud-zuul网关限流zuul-ratelimit原理分析
zuul-ratelimt 支持memory、redis限流,通过计数器算法实现限流,即在窗口时间内消耗指定数量的令牌后限流,窗口时间刷新后重新指定消耗令牌数量为0。

2.3 Guava RateLimiter实现平滑限流
Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现,代码实现时序图如下:

高并发接口限流_第5张图片

Redis实现限流的几种方式

互联网应用往往是高并发的场景,互联网的特性就是瞬时、激增,比如鹿晗官宣了,此时,如果没有流量管控,很容易导致系统雪崩。
而限流是用来保证系统稳定性的常用手段,当系统遭遇瞬时流量激增,很可能会因系统资源耗尽导致宕机,限流可以把一超出系统承受能力外的流量直接拒绝掉,保证大部分流量可以正常访问,从而保证系统只接收承受范围以内的请求。
我们常用的限流算法有:漏桶算法、令牌桶算法。

基于Redis的setNX的操作(固定时间算法)

我们在使用Redis的分布式锁的时候,大家都知道是依靠了setNX的指令,在CAS(Compare and swap)的操作的时候,同时给指定的key设置了过期实践(expire),我们在限流的主要目的就是为了在单位时间内,有且仅有N数量的请求能够访问我的代码程序。所以依靠setnx可以很轻松的做到这方面的功能。
比如我们需要在10秒内限定20个请求,那么我们在setnx的时候可以设置过期时间10,当请求的setnx数量达到20时候即达到了限流效果。代码比较简单就不做展示了。
当然这种做法的弊端是很多的,比如当统计1-10秒的时候,无法统计2-11秒之内,如果需要统计N秒内的M个请求,那么我们的Redis中需要保持N个key等等问题。

// 限流的个数
private int maxCount = 10;
// 指定的时间内
private long interval = 60;
// 原子类计数器
private AtomicInteger atomicInteger = new AtomicInteger(0);
// 起始时间
private long startTime = System.currentTimeMillis();

public boolean limit(int maxCount, int interval) {
    atomicInteger.addAndGet(1);
    if (atomicInteger.get() == 1) {
        startTime = System.currentTimeMillis();
        atomicInteger.addAndGet(1);
        return true;
    }
    // 超过了间隔时间,直接重新开始计数
    if (System.currentTimeMillis() - startTime > interval * 1000) {
        startTime = System.currentTimeMillis();
        atomicInteger.set(1);
        return true;
    }
    // 还在间隔时间内,check有没有超过限流的个数
    if (atomicInteger.get() > maxCount) {
        return false;
    }
    return true;
}

基于Redis的数据结构zset(滑动窗口)

上面的计数器算法存在的弊端,使用滑动窗口可以很容易的实现。上面提到的1-10怎么变成2-11,其实可以理解为窗口大小不变,变化的是窗口的启始结束位置,而我们如果用Redis的list数据结构可以轻而易举的实现该功能。
我们可以将请求打造成一个zset数组,当每一次请求进来的时候,value保持唯一,可以用UUID生成,而score可以用当前时间戳表示,因为score我们可以用来计算当前时间戳之内有多少的请求数量。而zset数据结构也提供了range方法让我们可以很轻易的获取到2个时间戳内有多少请求
代码实现也比较简单,

public Response limitFlow(){
    Long currentTime = new Date().getTime();
    System.out.println(currentTime);
    if(redisTemplate.hasKey("limit")) {
        Integer count = redisTemplate.opsForZSet().rangeByScore("limit", currentTime -  intervalTime, currentTime).size();        // intervalTime是限流的时间 
        System.out.println(count);
        if (count != null && count > 5) {
            return Response.ok("每分钟最多只能访问5次");
        }
    }
    redisTemplate.opsForZSet().add("limit",UUID.randomUUID().toString(),currentTime);
    return Response.ok("访问成功");
}

基于Redis的令牌桶算法

提到限流就不得不提到令牌桶算法了。令牌桶算法提及到输入速率和输出速率,当输出速率大于输入速率,那么就是超出流量限制了。
也就是说我们每访问一次请求的时候,可以从Redis中获取一个令牌,如果拿到令牌了,那就说明没超出限制,而如果拿不到,则结果相反。可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病。依靠上述的思想,我们可以结合Redis的List数据结构很轻易的做到这样的代码,只是简单实现。另外,关注互联网架构师,在后台回复:2T,可以获取我整理的 Redis 系列面试题和答案,非常齐全。 依靠List的leftPop来获取令牌

public Response limitFlow(Long id){
    Object result = redisTemplate.opsForList().leftPop("limit_list");
    if(result == null){
        return Response.ok("当前令牌桶中无令牌");
    }
    return Response.ok("访问成功"); }
再依靠Java的定时任务,定时往List中rightPush令牌,当然令牌也需要唯一性,所以我这里还是用UUID进行了生成.一旦需要提高速率,则按需提高放入桶中的令牌的速率即可。
@Scheduled(fixedDelay = 100,initialDelay = 0)
public void setIntervalTimeTask(){
    redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString());
}

令牌桶算法提及到输入速率和输出速率,当输出速率大于输入速率,那么就是超出流量限制了。
也就是说我们每访问一次请求的时候,可以从Redis中获取一个令牌,如果拿到令牌了,那就说明没超出限制,而如果拿不到,则结果相反。
依靠上述的思想,我们可以结合Redis的List数据结构很轻易的做到这样的代码,只是简单实现依靠List的leftPop方法来获取令牌。

static void LimitRequest()
        {
            RedisClient client = new RedisClient("[email protected]:6379");
            string key = "limitRate";
          var result=  client.PopItemFromList(key);
            if(result==null)
            {
                Console.WriteLine("系统繁忙,请稍后再试");
            }
            else
            {
                Console.WriteLine("访问成功");
            }

        }

再依靠定时任务,定时往令牌桶List中加入新的令牌(使用List的rightPush方法),当然令牌也需要唯一性,这里还是用UUID生成令牌: 10S的速率往令牌桶中添加UUID,保证唯一性
/// 比如我们速率限制是1分钟100个,那么就处理为1分钟内,桶中就只有100个令牌。

/// 
static void AddTokenToBucket()
{
    string key = "limitRate";
    RedisClient client = new RedisClient("[email protected]:6379");

    var count = client.GetListCount(key);
    for(var i=0;i<100-count;i++) //需要判断原来是否还有剩余,有则相应扣减,确保桶中只有100个令牌
    {
        client.AddItemToList(key, Guid.NewGuid().ToString());
    }
}

你可能感兴趣的:(并发,Java性能优化,java)