" />
文章

限流的几种方式

限流的几种方式

  1. 计数器算法

:将时间周期划分为固定大小的窗口(如每分钟、每小时),并在每个窗口内统计请求的数量。当窗口内的请求数达到预设的阈值时,后续请求将被限制。时间窗口结束后,计数器清零。

  • 优点:实现简单,易于理解。
  • 缺点:在窗口切换时刻可能会有突刺流量问题,即在窗口结束时会有短暂的大量请求被允许通过。
  1. 滑动窗口算法

:改进了计算器算法(固定窗口算法)的突刺问题,将时间窗口划分为多个小的时间段(桶),每个小时间段有自己的计数器。随着时间流逝,窗口像滑块一样平移,过期的小时间段的计数会被丢弃,新时间段加入计数。所有小时间段的计数之和不能超过设定的阈值。

  • 优点:更平滑地处理流量,避免了突刺问题。
  • 缺点:实现相对复杂,需要维护多个计数器。
  1. 漏桶算法

:想象一个固定容量的桶,水(请求)以恒定速率流入桶中,同时桶底部有小孔让水以恒定速率流出。当桶满时,新来的水(请求)会被丢弃。此算法主要用来平滑网络流量,防止瞬时流量过大。

  • 优点:可以平滑突发流量,保证下游系统的稳定。
  • 缺点:无法处理突发流量高峰,多余的请求会被直接丢弃。
  1. 令牌桶算法

:与漏桶相反,有一个固定速率填充令牌的桶,令牌代表请求许可。当请求到达时,需要从桶中取出一个令牌,如果桶中有令牌则允许请求通过,否则拒绝。桶的容量是有限的,多余的令牌会被丢弃。

  • 优点:既能平滑流量,又能处理一定程度的突发流量(因为令牌可以累积)。
  • 缺点:需要精确控制令牌生成速度,实现较漏桶复杂。

单机版本限流

可以采用Guava的RateLimiter的方式

将 RateLimiter 和 Guava Cache 整合到一个 Spring Boot 项目中,并通过 @Autowired 注入这两个组件以便在控制器中使用。以下是整合的代码示例:

1. 添加 Guava 依赖

确保在 pom.xml 文件中已添加 Guava 依赖:

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

2. 配置 RateLimiter 和 Guava Cache

将 RateLimiter 和 LoadingCache 配置在同一个配置类中,并将它们定义为 Spring Bean。

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class GuavaConfig {

    @Bean
    public RateLimiter rateLimiter() {
        // 配置 RateLimiter,每秒允许 1 个请求
        return RateLimiter.create(1.0);
    }

    @Bean
    public LoadingCache<String, String> cache() {
        // 配置 Guava Cache,最大容量为 100,过期时间为 10 分钟
        return CacheBuilder.newBuilder()
                .maximumSize(100)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String key) {
                        // 定义缓存未命中时的加载行为
                        return "Default_Value_For_" + key;
                    }
                });
    }
}

3. 使用 RateLimiter 和 Guava Cache

在控制器中通过 @Autowired 注解将 RateLimiter 和 LoadingCache 注入,并在业务方法中使用它们。

import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class ApiController {

    private final RateLimiter rateLimiter;
    private final LoadingCache<String, String> cache;

    @Autowired
    public ApiController(RateLimiter rateLimiter, LoadingCache<String, String> cache) {
        this.rateLimiter = rateLimiter;
        this.cache = cache;
    }

    @GetMapping("/limited")
    public String rateLimitedEndpoint() {
        // 使用 RateLimiter 限流
        if (rateLimiter.tryAcquire()) {
            return "Request successful!";
        } else {
            return "Rate limit exceeded. Please try again later.";
        }
    }

    @GetMapping("/cache/get")
    public String getFromCache(@RequestParam String key) {
        // 从缓存中获取值
        return cache.getUnchecked(key);
    }

    @GetMapping("/cache/put")
    public String putToCache(@RequestParam String key, @RequestParam String value) {
        // 将数据放入缓存
        cache.put(key, value);
        return "Added to cache: " + key + " -> " + value;
    }
}

原理

Guava的RateLimiter基于令牌桶算法(Token Bucket Algorithm)实现限流。其核心原理是通过在固定时间间隔内生成“令牌”,当请求到来时会消耗这些令牌,从而控制请求的速率。以下是RateLimiter底层的实现原理:

  1. 令牌桶模型:RateLimiter会以一个恒定的速率生成令牌,令牌放入桶中,当桶满了则不再生成新令牌。请求到来时需要从桶中获取令牌,只有当有可用令牌时请求才会通过,若没有可用令牌则需要等待或被拒绝。

  2. 稳定速率限制:RateLimiter使用一种平滑的速率限制机制,每秒生成固定数量的令牌。这可以确保在给定的时间段内不会超过指定的速率。

  3. 预热模式:RateLimiter支持平滑的预热模式(warmup模式),可以在服务启动时逐渐提升速率,防止突发请求对系统造成压力。预热模式下会逐步增加令牌生成速率,直至达到目标速率。

  4. 基于时间的令牌生成:RateLimiter记录上次令牌生成的时间戳,通过计算当前时间与上次生成时间的差来决定是否生成新令牌。由于每次调用都会更新时间戳,因此RateLimiter可以在多线程环境下并发执行。

这种实现方式的优点是令牌生成速率平稳,且具有较高的灵活性,可以根据不同的限流需求调整速率或配置预热模式。

使用redisson 进行RateLimiter限流

原理:

local rate = redis.call("hget", KEYS[1], "rate")  # 100 
local interval = redis.call("hget", KEYS[1], "interval")  # 3600000
local type = redis.call("hget", KEYS[1], "type")  # 0
assert(rate ~= false and interval ~= false and type ~= false, "RateLimiter is not initialized")
local valueName = KEYS[2]      # {xindoo.limiter}:value 用来存储剩余许可数量
local permitsName = KEYS[4]    # {xindoo.limiter}:permits 记录了所有许可发出的时间戳  
# 如果是单实例模式,name信息后面就需要拼接上clientId来区分出来了
if type == "1" then
    valueName = KEYS[3]        # {xindoo.limiter}:value:b474c7d5-862c-4be2-9656-f4011c269d54
    permitsName = KEYS[5]      # {xindoo.limiter}:permits:b474c7d5-862c-4be2-9656-f4011c269d54
end
# 对参数校验 
assert(tonumber(rate) >= tonumber(ARGV[1]), "Requested permits amount could not exceed defined rate")
# 获取当前还有多少许可 
local currentValue = redis.call("get", valueName)   
local res
# 如果有记录当前还剩余多少许可 
if currentValue ~= false then
    # 回收已过期的许可数量
    local expiredValues = redis.call("zrangebyscore", permitsName, 0, tonumber(ARGV[2]) - interval)
    local released = 0
    for i, v in ipairs(expiredValues) do
        local random, permits = struct.unpack("Bc0I", v)
        released = released + permits
    end
    # 清理已过期的许可记录
    if released > 0 then
        redis.call("zremrangebyscore", permitsName, 0, tonumber(ARGV[2]) - interval)
        if tonumber(currentValue) + released > tonumber(rate) then
            currentValue = tonumber(rate) - redis.call("zcard", permitsName)
        else
            currentValue = tonumber(currentValue) + released
        end
        redis.call("set", valueName, currentValue)
    end
    # ARGV  permit  timestamp  random, random是一个随机的8字节
    # 如果剩余许可不够,需要在res中返回下个许可需要等待多长时间 
    if tonumber(currentValue) < tonumber(ARGV[1]) then
        local firstValue = redis.call("zrange", permitsName, 0, 0, "withscores")
        res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]))
    else
        redis.call("zadd", permitsName, ARGV[2], struct.pack("Bc0I", string.len(ARGV[3]), ARGV[3], ARGV[1]))
        # 减小可用许可量 
        redis.call("decrby", valueName, ARGV[1])
        res = nil
    end
else # 反之,记录到还有多少许可,说明是初次使用或者之前已记录的信息已经过期了,就将配置rate写进去,并减少许可数 
    redis.call("set", valueName, rate)
    redis.call("zadd", permitsName, ARGV[2], struct.pack("Bc0I", string.len(ARGV[3]), ARGV[3], ARGV[1]))
    redis.call("decrby", valueName, ARGV[1])
    res = nil
end
local ttl = redis.call("pttl", KEYS[1])
# 重置
if ttl > 0 then
    redis.call("pexpire", valueName, ttl)
    redis.call("pexpire", permitsName, ttl)
end
return res

我将向你展示如何在 Spring Boot 项目中集成 Redisson 的分布式限流功能,并提供原理说明,以帮助你更好地理解如何运作。下面的代码将展示如何使用 Redisson 的 RateLimiter 来实现分布式限流。

示例代码:Spring Boot 集成 Redisson RateLimiter

以下是一个完整的 Spring Boot 项目代码示例,用于演示如何使用 Redisson 实现分布式限流:

  1. 引入依赖
    首先需要在 pom.xml 中引入 Redisson 的依赖:
    xml <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.16.3</version> <!-- 选择适合的版本 --> </dependency>

  2. 配置 RedissonClient
    在 application.yml 中配置 Redis 的连接信息:
    yaml spring: redis: host: localhost port: 6379

  3. 创建 Redisson 配置类
    在 Spring Boot 中,通过配置类创建 RedissonClient,并将其注入到 Spring 容器中:
    ```java
    import org.redisson.Redisson;
    import org.redisson.api.RedissonClient;
    import org.redisson.config.Config;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

   @Bean
   public RedissonClient redissonClient() {
       Config config = new Config();
       config.useSingleServer().setAddress("redis://127.0.0.1:6379");
       return Redisson.create(config);
   }

}
```

  1. 创建限流服务
    使用 RRateLimiter 来定义一个限流逻辑:
    ```java
    import org.redisson.api.RRateLimiter;
    import org.redisson.api.RateIntervalUnit;
    import org.redisson.api.RateType;
    import org.redisson.api.RedissonClient;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;

@Service
public class RateLimiterService {

   private final RRateLimiter rateLimiter;

   @Autowired
   public RateLimiterService(RedissonClient redissonClient) {
       // 获取或创建一个限流器
       rateLimiter = redissonClient.getRateLimiter("myRateLimiter");
       // 初始化限流器:每秒最多产生 5 个令牌
       rateLimiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
   }

   public boolean tryAcquire() {
       // 尝试获取一个令牌
       return rateLimiter.tryAcquire();
   }

}
```

  1. 创建 REST 控制器
    创建一个控制器,使用 RateLimiterService 来限制 API 请求的速率:
    ```java
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class RateLimiterController {

   private final RateLimiterService rateLimiterService;

   @Autowired
   public RateLimiterController(RateLimiterService rateLimiterService) {
       this.rateLimiterService = rateLimiterService;
   }

   @GetMapping("/limited")
   public String limitedEndpoint() {
       if (rateLimiterService.tryAcquire()) {
           return "请求成功";
       } else {
           return "请求被限流,请稍后再试";
       }
   }

}
```

原理说明

  1. Redis 和分布式锁机制
    - Redisson 通过使用 Redis 来共享状态。限流器的令牌桶计数信息存储在 Redis 中,因此即使有多个应用实例,这些实例都能访问到相同的令牌桶信息。
    - RRateLimiter 通过 Redis 的原子性操作来保证每次请求时,令牌的获取和计数递减操作都是安全的。这种分布式共享机制确保不同节点都遵循相同的限流策略。

  2. 令牌桶算法
    - Redisson 的 RRateLimiter 使用了类似令牌桶(Token Bucket)的算法进行限流。令牌桶算法的工作原理如下:

    • 每秒生成一定数量的令牌,代表允许通过的请求数。
    • 每次请求前,必须从令牌桶中获取令牌。如果令牌足够,则允许请求;如果没有足够的令牌,说明请求速率过高,需要被限流。
    • 当没有令牌时,请求将会被限制,从而保护服务不被突发流量压垮。
  3. 高可用与一致性
    - 通过 Redis 作为中心存储,RateLimiter 能够为分布式系统中的所有实例提供全局一致的限流行为。
    - Redis 的单线程架构保证了对令牌计数器的原子性操作,这使得无论有多少个应用实例在并发访问,都不会出现竞态条件,限流行为是一致的。

  4. RateLimiter 参数解析
    - RateType.OVERALL:全局限流模式,所有客户端共享同一个令牌桶。
    - trySetRate(rateType, rate, interval, unit):

    • rateType:限流类型。
    • rate:在时间窗口内可以生成的最大令牌数量。
    • interval 和 unit:定义时间窗口的长度。
  5. Spring Boot 集成优势
    - Spring Boot 提供了便捷的依赖管理和配置机制,通过依赖注入(DI)可以轻松地将限流逻辑集成到项目的各个部分。
    - 使用 @Service 和 @RestController 的 Spring 注解,可以将限流服务无缝集成到业务逻辑中,实现对某些关键接口的保护。

RRateLimiter使用时注意事项

了解了RRateLimiter的底层原理,再结合Redis自身的特性,我想到了RRateLimiter使用的几个局限点(问题点)。

RRateLimiter是非公平限流器

这个是我查阅资料得知,并且在自己代码实践的过程中也得到了验证,具体表现就是如果多个实例(机器)取竞争这些许可,很可能某些实例会获取到大部分,而另外一些实例可怜巴巴仅获取到少量的许可,也就是说容易出现旱的旱死 涝的涝死的情况。在使用过程中,你就必须考虑你能否接受这种情况,如果不能接受就得考虑用某些方式尽可能让其变公平。

Rate不要设置太大

从RRateLimiter的实现原理你也看出了,它采用的是滑动窗口的模式来限流的,而且记录了所有的许可授权信息,所以如果你设置的Rate值过大,在Redis中存储的信息(permitsName对应的zset)也就越多,每次执行那段lua脚本的性能也就越差,这对Redis实例也是一种压力。个人建议如果你是想设置较大的限流阈值,倾向于小Rate+小时间窗口的方式,而且这种设置方式请求也会更均匀一些。

限流的上限取决于Redis单实例的性能

从原理上看,RRateLimiter在Redis上所存储的信息都必须在一个Redis实例上,所以它的限流QPS的上限就是Redis单实例的上限,比如你Redis实例就是1w QPS,你想用RRateLimiter实现一个2w QPS的限流器,必然实现不了。 那有没有突破Redis单实例性能上限的方式?单限流器肯定是实现不了的,我们可以拆分多个限流器,比如我搞10个限流器,名词用不一样的,然后每台机器随机使用一个限流器限流,实际的流量不就被分散到不同的限流器上了吗,总的限流上线不也就上来了。

License:  CC BY 4.0