MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis固定窗口限流在高并发场景的应用实践

2022-02-177.2k 阅读

1. 高并发场景与限流的必要性

在当今互联网蓬勃发展的时代,高并发场景无处不在。以电商平台为例,在促销活动如“双十一”期间,大量用户同时涌入平台进行商品抢购、下单等操作;社交媒体平台在热门事件发生时,瞬间会有海量的用户发布动态、点赞、评论。这些场景下,系统面临着巨大的流量压力。

如果不对流量进行控制,系统可能会因为资源耗尽而崩溃。比如,数据库可能因为过多的并发请求导致连接池耗尽,无法处理新的请求;服务器的 CPU 和内存可能会被占满,使得整个系统响应缓慢甚至无响应。限流作为一种有效的流量控制手段,能够确保系统在高并发场景下仍能稳定运行。

2. 限流算法概述

2.1 固定窗口限流算法原理

固定窗口限流算法是一种较为基础的限流算法。其核心思想是将时间划分为固定长度的窗口,在每个窗口内,对请求进行计数。当请求数达到设定的阈值时,后续的请求就会被限流。例如,设定一个 1 分钟的固定窗口,阈值为 100 次请求。在这 1 分钟内,如果请求数达到 100 次,那么从第 101 次请求开始就会被限流,直到下一个 1 分钟窗口开始。

2.2 漏桶限流算法原理

漏桶算法可以想象成一个底部有小孔的桶,请求就像水一样流入桶中,而桶会以固定的速率将水(请求)从底部小孔流出。无论请求的流入速度有多快,漏桶始终以恒定的速率处理请求。如果桶已满,新流入的水(请求)就会溢出,即被限流。它能够平滑地处理请求,避免突发流量对系统造成过大冲击。

2.3 令牌桶限流算法原理

令牌桶算法与漏桶算法相反,系统以固定的速率生成令牌放入桶中,每个请求在处理前需要先从桶中获取一个令牌。如果桶中有足够的令牌,请求就可以被处理;若桶中没有令牌,请求就会被限流。令牌桶算法能够在一定程度上允许突发流量,只要桶中有足够的令牌,就可以处理突发的大量请求。

3. Redis 基础介绍

3.1 Redis 数据结构

Redis 支持多种数据结构,如字符串(String)、哈希(Hash)、列表(List)、集合(Set)和有序集合(Sorted Set)。

  • 字符串(String):是 Redis 最基本的数据结构,它可以存储任何类型的数据,如数字、文本、二进制数据等。例如,可以使用 SET key value 命令来设置一个字符串类型的键值对,使用 GET key 命令来获取对应的值。
  • 哈希(Hash):用于存储对象,它将一个对象存储为一个字段和值的映射表。例如,对于一个用户对象,可以使用 HSET user:1 name "John" 来设置用户 1 的名字,使用 HGET user:1 name 来获取该用户的名字。
  • 列表(List):是一个有序的字符串元素集合,可以在列表的两端进行插入和删除操作。例如,可以使用 LPUSH list:1 element1 从列表左侧插入元素,使用 RPOP list:1 从列表右侧弹出元素。
  • 集合(Set):是一个无序的、不包含重复元素的字符串集合。可以使用 SADD set:1 member1 向集合中添加成员,使用 SMEMBERS set:1 查看集合中的所有成员。
  • 有序集合(Sorted Set):与集合类似,但每个成员都关联一个分数(score),通过分数来对成员进行排序。例如,使用 ZADD zset:1 10 member1 向有序集合中添加成员并设置分数,使用 ZRANGE zset:1 0 -1 WITHSCORES 可以查看有序集合中的所有成员及其分数。

3.2 Redis 原子操作

Redis 的很多操作都是原子性的,这意味着这些操作在执行过程中不会被其他操作打断。例如,INCR 命令用于将存储在指定键中的数字值递增 1。当多个客户端同时对同一个键执行 INCR 命令时,Redis 能够保证每个 INCR 操作的原子性,不会出现数据竞争导致的错误结果。这一特性对于实现限流等功能非常重要,因为在高并发场景下,我们需要确保对请求计数等操作的准确性。

4. Redis 实现固定窗口限流

4.1 基于 Redis 的固定窗口限流思路

利用 Redis 的原子操作和过期时间特性可以很方便地实现固定窗口限流。我们以一个固定窗口为 1 分钟,阈值为 100 次请求为例。首先,我们可以使用 Redis 的 INCR 命令对每个窗口内的请求进行计数。每次有请求到来时,我们先尝试对一个代表当前窗口的键进行 INCR 操作。如果这个键不存在,INCR 操作会先创建这个键并将其值设为 1;如果键已存在,则将其值加 1。然后,我们将这个键设置一个 1 分钟的过期时间,这样在下一个窗口开始时,这个键会自动删除,计数也会重新开始。当 INCR 操作后的计数值超过阈值 100 时,就对后续请求进行限流。

4.2 代码示例(Python 与 Redis - PyRedis 库)

import redis


def fixed_window_rate_limit(redis_client, key, limit, window_seconds):
    # 获取当前请求计数
    current_count = redis_client.incr(key)
    if current_count == 1:
        # 如果是第一个请求,设置键的过期时间
        redis_client.expire(key, window_seconds)
    if current_count > limit:
        return False
    return True


# 示例使用
if __name__ == "__main__":
    r = redis.Redis(host='localhost', port=6379, db=0)
    key = "rate_limit:example"
    limit = 100
    window_seconds = 60
    for i in range(150):
        if fixed_window_rate_limit(r, key, limit, window_seconds):
            print(f"请求 {i} 通过限流")
        else:
            print(f"请求 {i} 被限流")


在上述代码中,fixed_window_rate_limit 函数实现了固定窗口限流的逻辑。它接收 Redis 客户端对象、限流键、限流阈值和窗口时间作为参数。每次调用该函数时,先使用 incr 方法对键的值进行自增,并判断是否为第一个请求,如果是则设置键的过期时间。然后判断当前计数值是否超过限流阈值,以决定请求是否通过。

4.3 代码示例(Java 与 Redis - Jedis 库)

import redis.clients.jedis.Jedis;


public class FixedWindowRateLimit {
    public static boolean fixedWindowRateLimit(Jedis jedis, String key, int limit, int windowSeconds) {
        Long currentCount = jedis.incr(key);
        if (currentCount == 1) {
            jedis.expire(key, windowSeconds);
        }
        if (currentCount > limit) {
            return false;
        }
        return true;
    }


    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost", 6379);
        String key = "rate_limit:example";
        int limit = 100;
        int windowSeconds = 60;
        for (int i = 0; i < 150; i++) {
            if (fixedWindowRateLimit(jedis, key, limit, windowSeconds)) {
                System.out.println("请求 " + i + " 通过限流");
            } else {
                System.out.println("请求 " + i + " 被限流");
            }
        }
        jedis.close();
    }
}


在 Java 代码中,通过 Jedis 库与 Redis 进行交互。fixedWindowRateLimit 方法实现了固定窗口限流的核心逻辑,与 Python 代码类似,通过 incr 方法进行计数,通过 expire 方法设置键的过期时间,并根据计数值判断请求是否通过限流。

5. Redis 固定窗口限流在高并发场景中的优势

5.1 高性能与低延迟

Redis 基于内存存储,并且采用单线程模型,通过高效的事件驱动机制处理请求,这使得它在处理高并发请求时具有极低的延迟。在固定窗口限流场景中,每次请求到达时,对 Redis 进行 INCREXPIRE 等操作的时间开销非常小,几乎可以忽略不计。相比其他基于磁盘存储或者多线程竞争锁实现的限流方案,Redis 能够在高并发场景下快速响应请求,不会因为限流操作本身而成为系统的性能瓶颈。

5.2 分布式特性

在大型分布式系统中,高并发请求可能来自多个不同的服务器节点。Redis 天然支持分布式部署,可以方便地在多个节点之间共享限流数据。通过将限流键存储在 Redis 集群中,各个节点都可以通过访问 Redis 来进行请求计数和限流判断。这使得 Redis 固定窗口限流能够很好地适应分布式高并发场景,确保整个系统的限流策略一致且有效。

5.3 灵活性与可扩展性

Redis 提供了丰富的命令和数据结构,我们可以根据实际需求灵活调整固定窗口限流的实现。例如,如果需要对不同类型的请求进行分别限流,可以通过在限流键中添加请求类型标识来实现。同时,随着业务的发展和流量的增长,Redis 集群可以方便地进行扩展,通过增加节点来提高系统的处理能力,从而满足不断变化的高并发限流需求。

6. Redis 固定窗口限流的缺点与改进思路

6.1 缺点:临界问题

固定窗口限流算法存在一个明显的临界问题。例如,我们设置的固定窗口为 1 分钟,阈值为 100 次请求。假设在第 1 分钟的最后 1 秒内,系统收到了 100 次请求,然后在第 2 分钟的第 1 秒内,又收到了 100 次请求。从整体时间轴上看,在这 2 秒内系统收到了 200 次请求,而按照我们的限流设置,1 分钟内应该最多处理 100 次请求。这就导致在窗口切换的临界时刻,可能会出现瞬间流量超出预期的情况。

6.2 改进思路:滑动窗口限流

为了解决固定窗口限流的临界问题,可以引入滑动窗口限流算法。滑动窗口算法将固定窗口进行细分,例如将 1 分钟的窗口细分为 60 个 1 秒的小窗口。每个小窗口都有自己的请求计数,随着时间的推移,窗口会像幻灯片一样滑动。当请求到来时,不仅要考虑当前小窗口的计数,还要考虑滑动窗口内所有小窗口的总计数。这样可以更加平滑地处理流量,避免在窗口切换时出现的流量突增问题。

7. 滑动窗口限流在 Redis 中的实现

7.1 基于 Redis 的滑动窗口限流思路

在 Redis 中实现滑动窗口限流,可以使用有序集合(Sorted Set)数据结构。我们以每个小窗口为 1 秒为例,将每个小窗口的起始时间作为有序集合的分数(score),将每个小窗口内的请求计数作为有序集合的成员(member)。每次有请求到来时,先获取当前时间对应的小窗口,将该小窗口的计数加 1。然后,计算滑动窗口内所有小窗口的总计数,判断是否超过限流阈值。同时,需要定期清理过期的小窗口数据,以保证有序集合中只保留有效的窗口数据。

7.2 代码示例(Python 与 Redis - PyRedis 库)

import time


def sliding_window_rate_limit(redis_client, key, limit, window_seconds, sub_window_seconds):
    current_time = int(time.time())
    # 计算当前小窗口的起始时间
    current_sub_window_start = current_time - current_time % sub_window_seconds
    # 将当前小窗口的请求计数加1
    redis_client.zincrby(key, 1, current_sub_window_start)
    # 计算滑动窗口的起始时间
    window_start = current_time - window_seconds
    # 获取滑动窗口内的所有计数
    window_counts = redis_client.zrangebyscore(key, window_start, current_time)
    total_count = sum([float(count) for count in window_counts])
    if total_count > limit:
        return False
    # 清理过期的小窗口数据
    redis_client.zremrangebyscore(key, 0, window_start - sub_window_seconds)
    return True


# 示例使用
if __name__ == "__main__":
    r = redis.Redis(host='localhost', port=6379, db=0)
    key = "sliding_rate_limit:example"
    limit = 100
    window_seconds = 60
    sub_window_seconds = 1
    for i in range(150):
        if sliding_window_rate_limit(r, key, limit, window_seconds, sub_window_seconds):
            print(f"请求 {i} 通过限流")
        else:
            print(f"请求 {i} 被限流")


在上述 Python 代码中,sliding_window_rate_limit 函数实现了滑动窗口限流逻辑。通过 zincrby 方法对当前小窗口的计数进行自增,通过 zrangebyscore 方法获取滑动窗口内的所有计数并计算总和,通过 zremrangebyscore 方法清理过期的小窗口数据。

7.3 代码示例(Java 与 Redis - Jedis 库)

import redis.clients.jedis.Jedis;
import java.util.List;


public class SlidingWindowRateLimit {
    public static boolean slidingWindowRateLimit(Jedis jedis, String key, int limit, int windowSeconds, int subWindowSeconds) {
        long currentTime = System.currentTimeMillis() / 1000;
        long currentSubWindowStart = currentTime - currentTime % subWindowSeconds;
        jedis.zincrby(key, 1, String.valueOf(currentSubWindowStart));
        long windowStart = currentTime - windowSeconds;
        List<String> windowCounts = jedis.zrangeByScore(key, String.valueOf(windowStart), String.valueOf(currentTime));
        double totalCount = 0;
        for (String count : windowCounts) {
            totalCount += Double.parseDouble(count);
        }
        if (totalCount > limit) {
            return false;
        }
        jedis.zremrangeByScore(key, "0", String.valueOf(windowStart - subWindowSeconds));
        return true;
    }


    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost", 6379);
        String key = "sliding_rate_limit:example";
        int limit = 100;
        int windowSeconds = 60;
        int subWindowSeconds = 1;
        for (int i = 0; i < 150; i++) {
            if (slidingWindowRateLimit(jedis, key, limit, windowSeconds, subWindowSeconds)) {
                System.out.println("请求 " + i + " 通过限流");
            } else {
                System.out.println("请求 " + i + " 被限流");
            }
        }
        jedis.close();
    }
}


在 Java 代码中,通过 Jedis 库实现了滑动窗口限流。slidingWindowRateLimit 方法同样利用有序集合的相关操作来实现计数、求和以及清理过期数据的功能,以确保滑动窗口限流的正确执行。

8. 总结 Redis 固定窗口限流及滑动窗口改进

Redis 固定窗口限流在高并发场景中具有高性能、分布式、灵活可扩展等优势,能够有效地对流量进行控制,保障系统的稳定运行。然而,其存在的临界问题可能导致瞬间流量超出预期。通过引入滑动窗口限流算法对其进行改进,可以更加平滑地处理流量,避免窗口切换时的流量突增。在实际应用中,我们可以根据业务场景的特点和需求,选择合适的限流算法,并利用 Redis 的强大功能来实现高效可靠的限流机制,从而提升系统在高并发环境下的稳定性和可用性。无论是固定窗口限流还是滑动窗口限流,Redis 都为我们提供了便捷且高效的实现方式,帮助我们应对复杂多变的高并发挑战。同时,在使用过程中,我们还需要关注 Redis 的性能监控和优化,确保限流功能不会对 Redis 本身的性能产生过大影响,从而更好地服务于整个系统。