面试场景题系列--(3)如何避免超预期的高并发压力压垮系统?限流算法--xunznux

如何避免超预期的高并发压力压垮系统?

在互联网高可用架构设计中,限流是一种经典的高可用架构模式。限流的主要目标是确保系统在面对突发流量或者恶意攻击时,能够继续提供服务,而不会因为超出负载能力而崩溃。在现代互联网应用中,系统可能会面临以下几种情况:

  • 突发流量:某些事件(如促销活动、新闻发布、重大事件)会导致大量用户突然访问系统。这种突发的高并发访问会给系统带来巨大的负载压力,可能超过系统的处理能力,导致系统响应变慢甚至崩溃。
  • 恶意攻击:黑客可能会使用DoS(拒绝服务)或DDoS(分布式拒绝服务)攻击,通过发送大量的请求使系统瘫痪。这种攻击会耗尽系统资源,使正常用户无法访问。
    限流的基本思想是控制系统的并发访问量,通过拒绝部分请求来保护系统不被过载。这样虽然有部分用户访问失败,但是整个系统依然是可用的,依然能对外提供服务,而不是因为负载压力太大而崩溃,导致所有用户都不能访问。具体来说,限流可以通过以下方式实现:
  • 请求排队:将请求放入队列中,超过队列长度的请求直接拒绝或返回错误。这种方式可以限制系统同时处理的请求数。
  • 请求速率限制:控制单位时间内的请求数量。例如,每秒最多处理100个请求,超过的请求直接拒绝。
  • 优先级限制:根据请求的优先级进行限流,高优先级的请求优先处理,低优先级的请求可能被拒绝或延迟处理。

1. 需求分析

设计一个限流器组件,其主要应用场景是部署在微服务网关或其他 HTTP 服务器入口处,以过滤器的方式对请求进行过滤。对于超过限流规则的请求,该组件将返回“服务不可用”的 HTTP 响应。
限流规则可以通过配置文件获取,支持本地配置和远程配置两种方式,远程配置优先于本地配置。具体限流方式包括:

  • 全局限流:针对所有请求进行限流,确保整个系统处理的请求总数符合限流配置。
  • 账号限流:针对单个账号进行限流,限制单个账号发送的请求数量。
  • 设备限流:针对单个客户端设备进行限流,限制单个设备发送的请求数量。
  • 资源限流:针对某个资源(即某个 URL)进行限流,确保访问该资源的请求总数符合限流配置。
  • 时间窗口限流:在特定时间窗口内限制请求数量。比如,每分钟最多允许100次请求。
  • 漏桶算法:模拟漏桶流出水的过程,平滑突发流量。适用于流量比较稳定的场景。
  • 令牌桶算法:控制请求的通过率,适用于突发流量较多的场景。在设定的速率下生成令牌,请求只有拿到令牌才可以通过。
  • 基于优先级的限流:根据请求的优先级进行限流,高优先级请求更容易通过限流器,低优先级请求则容易被限制。
  • 基于地理位置的限流:针对不同的地理位置进行限流,防止某一地区的突发流量影响整体服务。
  • 基于用户行为的限流:结合用户的历史行为数据,根据用户的行为模式进行限流。
  • 动态限流:根据系统的实时负载情况动态调整限流策略,确保系统在高负载时仍然能够提供服务。
  • 分布式限流:在分布式系统中,通过分布式限流算法(如 Redis 的计数器)对多个节点进行限流,确保限流策略在分布式环境下的一致性。

需要遵守的一些开发原则:

  • 设计应遵循开闭原则,支持灵活的限流规则功能扩展。将限流规则和策略配置从代码中分离出来,通过配置文件或配置中心进行管理,确保配置的灵活性和动态更新能力。这样,在未来无需修改现有代码且兼容现有配置文件的情况下,即可支持新的配置规则。每个类和模块应有且只有一个职责。
  • 限流器的各个功能模块(如规则解析、限流策略应用、监控和日志记录等)应单独实现,便于理解、测试和维护。各模块内部应高内聚,对外尽量低耦合。限流器组件的各个子模块(如规则解析模块、限流策略模块等)应保持内部功能紧密相关,对外暴露尽量少的接口,降低模块之间的依赖。
  • 系统应具有良好的容错能力。在限流规则解析或应用过程中出现异常时,限流器应能优雅地降级或恢复,确保系统的高可用性。
  • 设计限流器时应考虑性能问题,确保在高并发场景下的低延迟和高吞吐量。使用高效的数据结构和算法,减少限流检查带来的额外开销。
  • 设计限流器时应考虑到安全性,防止恶意用户绕过限流策略。确保限流规则和策略配置的安全传输和存储,避免被篡改或泄露。

2. 概要设计

设计目标是一个限流器组件,而不是一个独立的系统,不可以独立部署进行限流,而是部署在系统网关(或者其他 HTTP 服务器上),作为网关的一个组件进行限流。

  • 当用户发出请求时,这些请求首先经过负载均衡服务器。负载均衡服务器的作用是分配请求到多个后端服务器,以确保系统负载均衡、提高可用性和响应速度。网关服务器本质上是一个 HTTP 服务器,作为客户端和微服务之间的入口。它可以处理认证、授权、限流等功能。限流器作为网关中的一个过滤器组件,和网关中的签名校验过滤器、用户权限过滤器等配置在同一个过滤器责任链(Chain of Responsibility)上,限流器配置在过滤器责任链的前端。过滤器责任链是多个过滤器按顺序执行的机制,确保每个过滤器都能按指定顺序处理请求。由于限流器位于责任链的前端,当请求超过限流时,限流器可以立即拒绝请求,避免其他过滤器执行不必要的处理。
  • 当请求进入限流器,限流器根据配置的限流策略判断请求是否超过限流阈值。如果超过阈值,限流器会直接返回 HTTP 503(Too Many Requests)响应,告知客户端请求被拒绝。如果请求未超过限流阈值,限流器会将请求传递给下一个过滤器,继续执行其他必要的处理(如签名校验、用户权限验证等其他网关过滤器),最终调用相应的微服务完成请求处理。限流策略可以通过本地配置文件设置,也可以从远程配置中心服务器加载。远程配置中心优先于本地配置,确保限流策略可以动态调整和统一管理。
  • 通过这样的设计,限流器可以有效保护系统免受突发流量或恶意请求的影响,提高系统的稳定性和可用性。同时,通过责任链机制,限流器确保只有在请求合法且未超限的情况下才会执行后续处理,提高了系统处理效率。

2.1 限流模式设计

请求是否超过限流,主要就是判断单位时间请求数量是否超过配置的请求限流数量。单位时间请求数量,可以本地记录,也可以远程记录。方便起见,本地记录称作本地限流, 远程记录称作远程限流(也叫分布式限流)。

  • 本地限流意味着,每个网关服务器需要根据本地记录的单位时间请求数量进行限流。假设限流配置为每秒限流 50 请求,如果该网关服务器本地记录的当前一秒内接受请求数 量达到 50,那么这一秒内的后续请求都返回 503 响应。如果整个系统部署了 100 台网关服务器,每个网关配置本地限流为每秒 50,那么,整个系统每秒最多可以处理 5000 个请求。
    • 优点:
      • 简单易实现:本地限流不需要依赖外部系统,直接在网关服务器上实现即可。
      • 性能较高:由于不需要远程通信,本地限流的性能较高。
    • 缺点:
      • 不均衡:不同网关服务器之间的请求量可能不均衡,有的网关服务器负载较轻,而有的负载较重。
      • 难以精确控制总流量:总流量是所有网关服务器限流配置的总和,难以精确控制。
  • 远程限流意味着,所有网关共享同一个限流数量,每个网关服务器收到请求后,从远程服务器中获取单位时间内已处理请求数,如果超过限流,就返回 503 响应。也就是说,可能某个网关服务器一段时间内根本就没有请求到达,但是远程的已处理请求数已经达到了限流上限,那么这台网关服务器也必须拒绝请求。我们使用 Redis 作为记录单位时间请求数量的远程服务器。
    • 优点:
      • 均衡控制:所有网关服务器共享一个限流数,可以更均衡地控制请求流量。
      • 精确控制总流量:可以精确控制整个系统的总流量。
    • 缺点:
      • 性能影响:每次请求都需要访问远程服务器,可能会增加请求的延迟。
      • 复杂性增加:需要确保远程限流服务器的高可用性和性能,增加了系统的复杂性。

2.2 高可用设计

为了保证配置中心服务器和 Redis 服务器宕机时,限流器组件的高可用性,限流器应具有自动降级功能。具体来说,限流器需要实现以下降级机制:

  1. 配置中心不可用时的降级: 当配置中心服务器不可用时,限流器应自动切换到使用本地配置。这意味着限流器在初始化时应从本地配置文件中加载限流规则,并在配置中心恢复可用时重新加载远程配置。这样即使配置中心宕机,限流器依然能够按照本地配置的限流规则进行工作。
  2. Redis 服务器不可用时的降级: 当 Redis 服务器不可用时,限流器应自动降级为本地限流。这要求限流器在无法连接到 Redis 时,能够切换到本地记录单位时间请求数量的方式进行限流。这样即使 Redis 宕机,限流器依然能够在每个网关服务器上按照本地限流规则进行限流,从而避免整个系统因为分布式限流服务器的宕机而失效。

3. 限流算法设计

限流器运行期需要通过配置文件获取对哪些 URL 路径进行限流;本地限流还是分布式限流;对用户限流还是对设备限流,还是对所有请求限流;限流的阈值是多少;阈值的时间单位是什么;具体使用哪种限流算法。因此,需要先看下配置文件的设计:

# 全局限流配置
limiterConfig:
  # 需要进行限流的URL路径
  url: /

  # 限流规则集合
  rules:
    # 规则1:针对设备限流
    - actor: device          # 限流主体,可以是 device, user, all
      windowSize: second     # 时间单位,可以是 second, minute, hour 等
      maxRequests: 10        # 限流阈值,每单位时间允许的请求数量
      method: TB             # 使用的限流算法,可以是 Counter, SW (Sliding Window), LB (Leaky Bucket), TB (Token Bucket)
      scope: global          # 限流作用范围,可以是 global, local

    # 规则2:针对所有请求限流
    - actor: all
      windowSize: second
      maxRequests: 50
      method: W
      scope: local

  1. 全局限流配置(limiterConfig):
    • url: 需要进行限流的URL路径。
  2. 限流规则集合(rules):
    • actor: 限流主体,可以是 device(设备),user(用户),all(所有请求)。
    • windowSize: 时间单位,可以是 second(秒),minute(分钟),hour(小时)等。
    • maxRequests: 限流阈值,每单位时间允许的请求数量。
    • method: 使用的限流算法,可以是 Counter(计数器),SW(滑动窗口),LB(漏桶),TB(令牌桶)。
    • scope: 限流作用范围,可以是 global(全局),local(本地)。
      通过这种方式,可以方便地配置限流器的行为,并且可以轻松扩展以支持更多规则和选项。

常用的限流算法有以下几种:

  1. 计数器法(Counter)
  2. 滑动窗口法(Sliding Window)
  3. 漏桶算法(Leaky Bucket)
  4. 令牌桶算法(Token Bucket)

3.1 计数器法(Counter)

原理

将配置文件中的时间单位 unit 作为一个时间窗口,每个窗口仅允许限制流量内的请求通过。即在指定的时间窗口内记录请求的数量,超过上限则拒绝请求。最简单的限流算法。
计数器算法思路
计数器限流方式比较粗暴,一次访问就增加一次计数,在系统内设置每 N 秒的访问量,超过访问量的访问直接丢弃,从而实现限流访问。具体大概是以下步骤:

  • 将时间划分为固定的窗口大小,例如 1 s;
  • 在窗口时间段内,每来一个请求,对计数器加 1;
  • 当计数器达到设定限制后,该窗口时间内的后续请求都将被丢弃;
  • 该窗口时间结束后,计数器清零,重新开始计数。
实现方式

实现方式和扩展方式很多,这里以 Redis 举例简单的实现,计数器主要思路就是在单位时间内,有且仅有 N 数量的请求能够访问我的代码程序。所以可以利用 Redis 的 setnx来实现这方面的功能。
比如现在需要在 10 秒内限定 20 个请求,那么可以在 setnx 的时候设置过期时间 10,当请求的 setnx 数量达到 20 的时候即达到了限流效果。

优点

实现简单,开销小。

缺点

对突发流量不友好,容易在时间窗口边界出现“流量突刺”现象。
这种算法的弊端是,在开始的时间,访问量被使用完后,1 s 内会有很长时间的真空期是处于接口不可用的状态的,同时也有可能在一秒内出现两倍的访问量。
T窗口的前1/2时间 无流量进入,后1/2时间通过5个请求;
T+1窗口的前 1/2时间 通过5个请求,后1/2时间因达到限制丢弃请求。
因此在 T的后1/2和(T+1)的前1/2时间组成的完整窗口内,通过了10个请求。

示例代码
import java.util.concurrent.atomic.AtomicInteger;

public class CounterRateLimiter {
    private final int maxRequests;
    private final long windowSize;
    private AtomicInteger counter;
    private long windowStart;

    public CounterRateLimiter(int maxRequests, long windowSize) {
        this.maxRequests = maxRequests;
        this.windowSize = windowSize;
        this.counter = new AtomicInteger(0);
        this.windowStart = System.currentTimeMillis();
    }

    public synchronized boolean isAllowed() {
        long now = System.currentTimeMillis();
        // 判断是否是新的时间窗口
        if (now - windowStart > windowSize) {
            windowStart = now;
            counter.set(0);
        }
        // 原子增加请求计数并判断是否超过阈值
        if (counter.incrementAndGet() <= maxRequests) {
            return true;
        }
        return false;
    }
}

3.2 滑动窗口法(Sliding Window)

原理

改进固定窗口缺陷的方法。将限流的时间窗口分成更小的时间片,每个时间片记录请求数,通过多个时间片的请求数总和进行限流。

滑动窗口计数法的思路
  • 将时间划分为细粒度的区间,每个区间维持一个计数器,每进入一个请求则将计数器加一;
  • 多个区间组成一个时间窗口,每流逝一个区间时间后,则抛弃最老的一个区间,纳入新区间。如图中示例的窗口 T1 变为窗口 T2;
  • 若当前窗口的区间计数器总和超过设定的限制数量,则本窗口内的后续请求都被丢弃。
实现方式

利用 Redis 的 list 数据结构可以轻而易举地实现该功能。我们可以将请求打造成一个 zset 数组,当每一次请求进来的时候,key 保持唯一,value 可以用 UUID 生成,而 score 可以用当前时间戳表示,因为 score 我们可以用来计算当前时间戳之内有多少的请求数量。而 zset 数据结构也提供了 range 方法让我们可以很轻易地获取到两个时间戳内有多少请求。
通过上述代码可以做到滑动窗口的效果,并且能保证每 N 秒内至多 M 个请求,实现方式相对来说也是比较简单的,但是所带来的缺点就是 zset 的数据结构会越来越大。

优点
  • 减少“流量突刺”现象,更平滑。
缺点
  • 实现复杂,存储开销大。
示例代码
import java.util.LinkedList;

public class SlidingWindowRateLimiter {
    private final int maxRequests;
    private final long windowSize;
    private final LinkedList<Long> timestamps;

    public SlidingWindowRateLimiter(int maxRequests, long windowSize) {
        this.maxRequests = maxRequests;
        this.windowSize = windowSize;
        this.timestamps = new LinkedList<>();
    }

    public synchronized boolean isAllowed() {
        long now = System.currentTimeMillis();
        // 保证列表所保存的所有请求中,第一个请求和当前请求的时间间隔在窗口之内
        while (!timestamps.isEmpty() && now - timestamps.getFirst() > windowSize) {
            timestamps.removeFirst();
        }
        // 如果这个时间间隔(窗口内)的请求数量已经不超过阈值则处理请求
        if (timestamps.size() < maxRequests) {
            timestamps.addLast(now);
            return true;
        }
        return false;
    }
}

3.3 漏斗算法(Leaky Bucket)

原理

以固定速率处理请求,如果桶满则拒绝请求。

设计思路:

在计数器算法中我们看到,当使用了所有的访问量后,接口会完全处于不可用状态,有些系统不能接受这样的处理方式,对此可以使用漏斗算法进行限流,漏斗算法的原理就像名字,访问量从漏斗的大口进入,从漏斗的小口进入系统。这样不管是多大的访问量进入漏斗,最后进入系统的访问量都是固定的。漏斗的好处就是,大批量访问进入时,漏斗有容量,不超过容量(容量的设计=固定处理的访问量 * 可接受等待时长)的数据都可以排队等待处理,超过的才会丢弃。

实现方式:

实现方式可以使用队列,队列设置容量,访问可以大批量塞入队列,满队列后丢弃后续访问量。队列的出口以固定速率拿去访问量处理。
构建一个特定长度的队列 queue 作为漏桶,开始的时候,队列为空,用户请求到达后从队列尾部写入队列,而应用程序从队列头部以特定速率读取请求。当读取速度低于写入速度的时候,一段时间后,队列会被写满,这时候写入队列操作失败。写入失败的请求直接构造 503 响应返回。
这种方案由于出口速率是固定的,所以并没有办法应对短时间的突发流量。

优点
  • 平滑输出流量,适合流量整形。
缺点
  • 实现相对复杂,无法处理突发流量。
示例代码
public class LeakyBucketRateLimiter {
    private final int capacity;
    private final long leakRate;
    private long lastLeakTimestamp;
    private int water;

    public LeakyBucketRateLimiter(int capacity, long leakRate) {
        this.capacity = capacity;
        this.leakRate = leakRate;
        this.lastLeakTimestamp = System.currentTimeMillis();
        this.water = 0;
    }

    public synchronized boolean isAllowed() {
        long now = System.currentTimeMillis();
        int leakedWater = (int)((now - lastLeakTimestamp) / leakRate);
        if (leakedWater > 0) {
            water = Math.max(0, water - leakedWater);
            lastLeakTimestamp = now;
        }
        if (water < capacity) {
            water++;
            return true;
        }
        return false;
    }
}

3.4 令牌桶算法(Token Bucket)

原理

以固定速率生成令牌,请求到来时消耗令牌,没有令牌则拒绝请求。

设计思路

令牌桶算法是漏斗算法的改进版,为了处理短时间的突发流量而做了优化,令牌桶算法主要由三部分组成:令牌流、数据流、令牌桶。
名词释义:
令牌流:流通令牌的管道,用于生成的令牌的流通,放入令牌桶中。
数据流:进入系统的数据流量。
令牌桶:保存令牌的区域,可以理解为一个缓冲区,令牌保存在这里用于使用。
令牌流会按照一定的速率生成令牌放入令牌桶,访问要进入系统时,需要从令牌桶中获取令牌,有令牌的可以进入,没有的被抛弃,由于令牌桶的令牌是源源不断生成的,当访问量小时,可以留存令牌达到令牌桶的上限,这样当短时间的突发访问量时,积累的令牌数可以处理这个问题。当访问量持续大量流入时,由于生成令牌的速率是固定的,最后也就变成了类似漏斗算法的固定流量处理。

实现方式

实现方式和漏斗也比较类似,可以使用一个队列保存令牌,一个定时任务用等速率生成令牌放入队列,访问量进入系统时,从队列获取令牌再进入系统。
google 开源的 guava 包中的 RateLimiter 类实现了令牌桶算法,不同其实现方式是单机的,集群可以按照上面的实现方式,队列使用中间件 MQ 实现,配合负载均衡算法,考虑集群各个服务器的承压情况做对应服务器的队列是较好的做法。

优点
  • 能处理突发流量,适合流量控制。
缺点
  • 实现复杂,需要精确的计时器。
示例代码
public class TokenBucketRateLimiter {
    private final int capacity;
    private final long refillRate;
    private long lastRefillTimestamp;
    private int tokens;

    public TokenBucketRateLimiter(int capacity, long refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.lastRefillTimestamp = System.currentTimeMillis();
        this.tokens = capacity;
    }

    public synchronized boolean isAllowed() {
        long now = System.currentTimeMillis();
        int newTokens = (int)((now - lastRefillTimestamp) / refillRate);
        if (newTokens > 0) {
            tokens = Math.min(capacity, tokens + newTokens);
            lastRefillTimestamp = now;
        }
        if (tokens > 0) {
            tokens--;
            return true;
        }
        return false;
    }
}

3.5 分布式令牌桶算法(限流进阶)

单点应用下,对应用进行限流,既能满足本服务的需求,又可以很好地保护好下游资源。在选型上,可以采用上面提及的 Google Guava 的 RateLimiter。
而在多机部署的场景下,对单点的限流,并不能达到我们想要的最好效果,需要引入分布式限流。分布式限流的算法,依然可以采用令牌桶算法,只不过将令牌桶的发放、存储改为全局的模式。

在真实应用场景,可以采用 redis + lua 的方式,通过把逻辑放在 redis 端,来减少调用次数。
lua 的逻辑如下:
redis 中存储剩余令牌的数量 cur_token,和上次获取令牌的时间 last_time;
在每次申请令牌时,可以根据(当前时间 cur_time - last_time) 的时间差乘以令牌发放速率,算出当前可用令牌数;
如果有剩余令牌,则准许请求通过,否则不通过。
在多机部署场景下,为了实现分布式限流,通常会使用 Redis 作为共享存储,通过 Lua 脚本来保证操作的原子性和效率。采用 Redis + Lua 的方式可以确保令牌桶的发放和消费在分布式环境中的一致性。

实现思路
  1. 初始化令牌桶:
    • 每个 URL 对应一个 Redis key,用来存储该 URL 的令牌数。
    • 定期向 Redis 中添加令牌,以保证每个时间单位内都有新的令牌发放。
  2. 获取令牌:
    • 使用 Lua 脚本在 Redis 中原子操作获取令牌,判断当前令牌数是否满足请求。
    • 如果满足请求,则扣减令牌数,并允许请求通过。
    • 如果不满足请求,则拒绝请求。
  3. 配置文件:
    • 使用 YAML 配置文件来定义需要限流的 URL 及其对应的限流规则。
Lua 脚本

首先编写 Lua 脚本,用于在 Redis 中原子操作获取令牌:

local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 获取当前令牌桶的状态
local tokens = redis.call('get', key)
if tokens == false then
    tokens = capacity
else
    tokens = tonumber(tokens)
end

-- 计算新令牌数:计算在这段时间内生成的新令牌数+剩余令牌数 与 阈值的最小值
local last_tokens = math.min(tokens + ((now - redis.call('time')[1]) * rate), capacity)

if last_tokens < requested then
    -- 令牌不足,拒绝请求
    return -1
else
    -- 令牌足够,允许请求并扣减令牌数
    redis.call('set', key, last_tokens - requested)
    return last_tokens - requested
end
  • KEYS[1]:Redis key,表示需要限流的 URL。
  • ARGV[1]:令牌生成速率(每秒生成的令牌数)。
  • ARGV[2]:令牌桶容量。
  • ARGV[3]:当前时间戳(秒)。
  • ARGV[4]:请求需要的令牌数。
Java 代码

然后在 Java 代码中调用 Lua 脚本:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class RedisRateLimiter {
    private final JedisPool jedisPool;
    private final String luaScript;
    private final int rate;
    private final int capacity;

    public RedisRateLimiter(JedisPool jedisPool, int rate, int capacity) {
        this.jedisPool = jedisPool;
        this.rate = rate;
        this.capacity = capacity;

        // Lua script to be executed
        this.luaScript = "local key = KEYS[1]\n" +
                         "local rate = tonumber(ARGV[1])\n" +
                         "local capacity = tonumber(ARGV[2])\n" +
                         "local now = tonumber(ARGV[3])\n" +
                         "local requested = tonumber(ARGV[4])\n" +
                         "local tokens = redis.call('get', key)\n" +
                         "if tokens == false then\n" +
                         "    tokens = capacity\n" +
                         "else\n" +
                         "    tokens = tonumber(tokens)\n" +
                         "end\n" +
                         "local last_tokens = math.min(tokens + ((now - redis.call('time')[1]) * rate), capacity)\n" +
                         "if last_tokens < requested then\n" +
                         "    return -1\n" +
                         "else\n" +
                         "    redis.call('set', key, last_tokens - requested)\n" +
                         "    return last_tokens - requested\n" +
                         "end";
    }

    public boolean acquire(String key, int permits) {
        try (Jedis jedis = jedisPool.getResource()) {
            long now = System.currentTimeMillis() / 1000;
            Object result = jedis.eval(luaScript, 1, key, String.valueOf(rate), String.valueOf(capacity), String.valueOf(now), String.valueOf(permits));
            return (Long) result != -1;
        }
    }
}
  • 使用 Jedis 库与 Redis 交互,执行 Lua 脚本。
  • 根据请求的 URL 和令牌数调用 acquire 方法,判断是否允许请求通过。
思路解释

为了实现分布式限流,我们需要在多机部署的场景下,通过共享存储(如 Redis)来协调各个节点的限流操作。具体的实现思路如下:

1. 初始化令牌桶

每个需要限流的 URL 都对应一个 Redis key,这个 key 存储了当前可用的令牌数。我们需要定期向这个 Redis key 中添加令牌,以确保每个时间单位内都有新的令牌发放。令牌桶的容量和生成速率可以根据具体的限流配置进行设置。

2. 获取令牌

当有请求到达时,系统需要判断当前的令牌数是否足够。这个操作需要在 Redis 中以原子操作的方式进行,以保证数据的准确性和一致性。为了实现这一点,我们使用 Lua 脚本在 Redis 中执行限流操作。

在分布式限流场景下,当有请求到达时,需要确保多个节点对令牌桶的操作是安全且一致的。因为多个节点会同时访问和修改共享的 Redis 数据,所以使用原子操作至关重要。具体原因如下:

  1. 数据一致性和准确性
    多个节点并发访问同一个 Redis key 时,如果没有原子操作,可能会出现以下问题:
    Race Conditions(竞争条件):多个节点同时读取当前令牌数,并且都判断当前有足够的令牌数,于是都进行扣减操作。这会导致实际的令牌数比预期的少。
    数据不一致:不同节点可能会在不同时间读取和修改令牌数,导致令牌数的不一致性,破坏限流机制。
  2. 原子操作的必要性
    为了避免以上问题,需要确保对令牌桶的读取和更新是一个不可分割的原子操作。在 Redis 中,普通的命令是分步执行的,这意味着在多步操作之间可能会被其他操作中断。为了解决这个问题,Redis 提供了 Lua 脚本功能。
  3. Lua 脚本在 Redis 中的原子性
    原子性:在 Redis 中执行 Lua 脚本是一个原子操作。即在脚本执行期间,Redis 保证不会有其他操作插入。这样可以确保读取、计算和更新令牌数是一个不可分割的过程,避免了并发访问带来的数据不一致问题。
    效率:Lua 脚本在 Redis 服务器端执行,避免了多次网络往返的开销,提高了操作效率。
3. 配置文件

为了灵活地管理限流规则,我们使用 YAML 配置文件来定义需要限流的 URL 及其对应的限流策略。配置文件包括以下信息:

  • URL 路径
  • 限流规则(如对设备、用户还是全局进行限流)
  • 时间单位(如秒、分钟)
  • 限流的阈值(即单位时间内允许的最大请求数)
  • 使用的限流算法(如令牌桶算法)
具体步骤
Lua 脚本

Lua 脚本用于在 Redis 中原子性地获取令牌。具体步骤如下:

  1. 获取当前 Redis key 对应的令牌数。如果没有,则初始化为令牌桶的容量。
  2. 计算新令牌数:根据当前时间和令牌生成速率,计算当前时间段内应有的令牌数,但不超过令牌桶的容量。
  3. 判断令牌数是否满足请求:如果满足请求,则扣减令牌数并允许请求通过;否则,返回 -1,表示拒绝请求。
Java 代码

Java 代码使用 Jedis 库与 Redis 交互,具体步骤如下:

  1. 从配置文件中读取限流规则。
  2. 当有请求到达时,调用 acquire 方法,执行 Lua 脚本。
  3. Lua 脚本判断当前令牌数是否满足请求,如果满足,则允许请求通过;否则,返回 503 响应。
配置文件

使用 YAML 文件来配置限流规则,配置文件包括:

  • URL 路径
  • 限流规则(如设备限流、全局限流)
  • 时间单位(如秒)
  • 限流阈值(单位时间内允许的最大请求数)
  • 限流算法(如令牌桶算法)
关键点
  1. 分布式限流:
    • 通过 Redis 作为共享存储,实现各个节点之间的限流协调。
    • 使用 Lua 脚本在 Redis 中进行原子性操作,保证数据一致性。
  2. 灵活的配置管理:
    • 使用 YAML 文件定义限流规则,可以灵活地配置不同的 URL 及其对应的限流策略。
    • 支持本地限流和分布式限流,能够根据具体需求进行调整。
  3. 高可用性:
    • 在配置中心服务器或 Redis 服务器宕机时,限流器能够自动降级,保证系统的高可用性。
      通过这种设计思路,我们可以在多机部署的场景下,实现高效的分布式限流,保证系统在高并发访问下的稳定性和高可用性。

3.6 总结

每种限流算法都有其优缺点,具体选择取决于系统的具体需求和特点。

  • 计数器法:实现简单,但容易在时间窗口边界出现突刺流量。
  • 滑动窗口法:平滑限流,但实现复杂,开销大。
  • 漏桶算法:平滑流量,适合流量整形,但无法处理突发流量。
  • 令牌桶算法:能处理突发流量,适合流量控制,但实现复杂。
  • 分布式令牌桶算法:适合分布式限流场景,但实现复杂。
  • 6
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Go语言基于Redis实现的分布式限流是一种常见的解决方案,可以有效地控制系统的并发访问流量,防止系统被过多的请求压垮。 首先,分布式限流需要使用Redis的计数器功能。通过对每个请求进行计数,并设置一个时间窗口,可以统计在该窗口内的请求次数。当请求次数过某个阈值时,可以拒绝该请求或者进行降级处理。 其次,为了保证分布式限流的准确性和高效性,需要使用Redis的原子操作,例如INCR、EXPIRE等。INCR命令可以原子地将计数器的值加1,并返回加1后的结果,而EXPIRE命令可以设置计数器的过期时间。通过这些原子操作,可以在多个节点之间共享计数状态,并且保证计数器的同步和高效性。 此外,为了保证系统的稳定性和可靠性,需要考虑设置适当的限流阈值和时间窗口大小。根据系统的负载情况和性能需求,可以调整这些参数,实现对系统流量的合理控制。 在实际应用中,可以使用Go语言的Redis客户端连接Redis服务器,并通过相关命令操作计数器。同时,还可以结合其他的组件和技术,如分布式锁、消息队列等,增强系统的稳定性和可扩展性。 总之,Go语言基于Redis实现的分布式限流是一种可行且有效的解决方案,可以帮助我们应对大流量的并发请求,保证系统的稳定运行。通过合理设定限流参数和灵活运用Redis的功能,我们可以实现流量控制、降级和保护系统免受恶意请求的攻击。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值