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

Redis滑动窗口限流子窗口划分的最佳实践

2024-01-105.9k 阅读

1. Redis 限流与滑动窗口概述

在高并发的应用场景中,限流是保障系统稳定性的重要手段。Redis 作为一款高性能的内存数据库,因其丰富的数据结构和原子操作能力,成为实现限流的常用工具。滑动窗口限流是一种常见的限流策略,它能够在一定时间范围内限制请求的数量,相比于固定窗口限流,滑动窗口限流在时间维度上更加平滑,避免了固定窗口切换时可能出现的突发流量问题。

1.1 固定窗口限流的局限性

固定窗口限流是将时间划分为固定长度的窗口,在每个窗口内限制请求的数量。例如,设定一个 1 分钟的窗口,允许在这 1 分钟内最多处理 100 个请求。然而,这种方式存在一个明显的问题,当请求集中在窗口切换的边界时,可能会导致瞬间流量超过限制。假设在第一个窗口的最后一秒有 100 个请求,紧接着在第二个窗口的第一秒又有 100 个请求,虽然每个窗口内的请求数量都没有超过限制,但在这两秒内却有 200 个请求,可能会对系统造成压力。

1.2 滑动窗口限流的原理

滑动窗口限流通过将时间窗口划分成更小的子窗口,随着时间的推移,窗口像滑动一样移动。每个子窗口都记录请求的数量,通过累计子窗口内的请求数量来判断是否超过限流阈值。例如,将 1 分钟的时间窗口划分为 60 个 1 秒的子窗口,系统在任何时刻都能根据当前窗口内的子窗口请求总数来判断是否限流。这样即使在窗口切换时,由于子窗口的存在,也能更精确地控制流量,避免瞬间流量过大的问题。

2. 滑动窗口限流子窗口划分的重要性

子窗口的划分是滑动窗口限流实现的关键环节,它直接影响到限流的精度和系统的性能。

2.1 影响限流精度

子窗口的大小决定了限流的精度。如果子窗口过大,例如将 1 分钟的窗口划分为 2 个 30 秒的子窗口,虽然可以在一定程度上解决固定窗口的问题,但对于更精细的流量控制还是不够。在这种情况下,在 30 秒的子窗口内仍然可能出现较大的流量波动而无法及时被限流。相反,如果子窗口过小,例如将 1 分钟划分为 6000 个 1 毫秒的子窗口,虽然限流精度极高,但会增加系统的存储和计算开销。

2.2 影响系统性能

子窗口划分过细会导致 Redis 中存储的数据量大幅增加,每个子窗口都需要存储请求计数等信息。过多的数据存储不仅占用更多的内存,还会增加 Redis 的读写压力,降低系统的整体性能。此外,在计算当前窗口内的请求总数时,划分过细的子窗口需要处理更多的数据,也会增加计算开销。因此,需要在限流精度和系统性能之间找到一个平衡点,确定最佳的子窗口划分方案。

3. 确定子窗口划分的因素

在确定子窗口划分方案时,需要综合考虑多个因素。

3.1 业务场景的流量特点

不同的业务场景具有不同的流量特点。对于一些流量相对平稳的业务,如普通的网站浏览,子窗口可以适当划分得大一些,因为流量波动较小,不需要过于精细的控制。而对于一些流量波动剧烈的业务,如秒杀活动、直播互动等,就需要更细粒度的子窗口划分,以便及时捕捉流量变化并进行限流。例如,在秒杀活动开始的瞬间,流量会急剧上升,只有通过较小的子窗口才能精确地限制每秒的请求数量,保证系统的稳定性。

3.2 系统性能和资源限制

系统的性能和可用资源也是重要的考虑因素。如果服务器的内存有限,就不能划分过多的子窗口,以免 Redis 占用过多内存导致系统性能下降。同样,如果 CPU 资源紧张,也需要避免子窗口划分过细带来的大量计算开销。在实际应用中,需要通过性能测试来评估不同子窗口划分方案对系统性能的影响,根据系统的硬件资源情况来确定合适的子窗口大小。

3.3 限流规则的复杂度

限流规则的复杂度也会影响子窗口的划分。如果限流规则较为简单,只需要限制一定时间内的总请求数,那么子窗口的划分可以相对简单。但如果限流规则较为复杂,例如需要根据不同的用户类型、请求类型等进行差异化限流,就需要更灵活的子窗口划分。在这种情况下,可能需要根据不同的维度来划分多个子窗口集合,以满足复杂的限流需求。

4. 子窗口划分的常见方案

4.1 等长子窗口划分

等长子窗口划分是最常见的方案,即将整个时间窗口划分为若干个大小相等的子窗口。例如,将 1 分钟的时间窗口划分为 60 个 1 秒的子窗口,或者划分为 12 个 5 秒的子窗口。这种方案的优点是实现简单,易于理解和维护。在代码实现上,只需要根据子窗口的大小和当前时间计算出对应的子窗口编号,然后对该子窗口进行请求计数操作。

以下是使用 Python 和 Redis 实现等长子窗口限流的示例代码:

import redis
import time

# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db=0)

# 限流参数
window_size = 60  # 窗口大小,单位秒
sub_window_size = 1  # 子窗口大小,单位秒
limit = 100  # 限流阈值

def is_allowed():
    current_time = int(time.time())
    sub_window_index = current_time % window_size // sub_window_size
    key = f'rate_limit:{current_time // window_size}:{sub_window_index}'
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, sub_window_size)
    count = pipe.execute()[0]
    return count <= limit

在上述代码中,window_size 表示整个时间窗口的大小,sub_window_size 表示子窗口的大小,limit 是限流阈值。is_allowed 函数用于判断当前请求是否被允许。它首先根据当前时间计算出子窗口的索引,然后使用 Redis 的 incr 命令对该子窗口的请求计数加 1,并设置该计数的过期时间为子窗口的大小。最后判断计数是否超过限流阈值。

4.2 自适应子窗口划分

自适应子窗口划分是根据流量的变化动态调整子窗口的大小。当流量较低时,子窗口可以适当增大,以减少系统开销;当流量升高时,子窗口自动变小,提高限流精度。实现自适应子窗口划分需要实时监测流量,并根据流量变化调整子窗口的划分策略。

一种简单的实现思路是通过定期统计一段时间内的请求数量,根据请求数量的变化来调整子窗口大小。例如,如果在最近 10 秒内的请求数量超过了某个阈值,就将子窗口大小减半;如果请求数量低于另一个阈值,就将子窗口大小加倍。

以下是一个简化的自适应子窗口划分的代码示例:

import redis
import time

# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db=0)

# 初始限流参数
window_size = 60  # 窗口大小,单位秒
base_sub_window_size = 1  # 基础子窗口大小,单位秒
limit = 100  # 限流阈值
monitor_interval = 10  # 流量监测间隔,单位秒
high_threshold = 80  # 高流量阈值
low_threshold = 20  # 低流量阈值

def adjust_sub_window_size():
    current_time = int(time.time())
    monitor_key = f'monitor:{current_time // monitor_interval}'
    count = r.get(monitor_key)
    if count is None:
        return base_sub_window_size
    count = int(count)
    sub_window_size = base_sub_window_size
    if count > high_threshold:
        sub_window_size = sub_window_size // 2
    elif count < low_threshold:
        sub_window_size = sub_window_size * 2
    return sub_window_size

def is_allowed():
    sub_window_size = adjust_sub_window_size()
    current_time = int(time.time())
    sub_window_index = current_time % window_size // sub_window_size
    key = f'rate_limit:{current_time // window_size}:{sub_window_index}'
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, sub_window_size)
    count = pipe.execute()[0]
    return count <= limit

def monitor_traffic():
    while True:
        current_time = int(time.time())
        monitor_key = f'monitor:{current_time // monitor_interval}'
        pipe = r.pipeline()
        pipe.incr(monitor_key)
        pipe.expire(monitor_key, monitor_interval)
        pipe.execute()
        time.sleep(1)

在上述代码中,adjust_sub_window_size 函数根据流量监测结果动态调整子窗口大小。monitor_traffic 函数定期统计流量,is_allowed 函数在判断请求是否被允许时,先获取自适应调整后的子窗口大小,然后进行请求计数和限流判断。

4.3 分层子窗口划分

分层子窗口划分是将时间窗口划分为多个层次的子窗口。例如,第一层可以将 1 分钟划分为 6 个 10 秒的子窗口,第二层再将每个 10 秒的子窗口划分为 10 个 1 秒的子窗口。这种方案结合了不同粒度子窗口的优点,既能在较大时间尺度上进行宏观的流量控制,又能在较小时间尺度上进行精细的限流。

在实现时,可以使用 Redis 的哈希结构来存储不同层次子窗口的请求计数。例如,第一层子窗口的计数可以存储在一个哈希表的键值对中,每个键对应一个 10 秒的子窗口,值为该子窗口内的请求计数;第二层子窗口的计数可以作为哈希表中对应键的值的子哈希表,每个子哈希表的键对应 1 秒的子窗口,值为该 1 秒子窗口内的请求计数。

以下是一个简单的分层子窗口划分的代码示例:

import redis
import time

# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db=0)

# 限流参数
window_size = 60  # 窗口大小,单位秒
first_layer_sub_window_size = 10  # 第一层子窗口大小,单位秒
second_layer_sub_window_size = 1  # 第二层子窗口大小,单位秒
limit = 100  # 限流阈值

def is_allowed():
    current_time = int(time.time())
    first_layer_index = current_time % window_size // first_layer_sub_window_size
    second_layer_index = (current_time % first_layer_sub_window_size) // second_layer_sub_window_size
    first_layer_key = f'rate_limit:{current_time // window_size}:{first_layer_index}'
    second_layer_key = f'{first_layer_key}:{second_layer_index}'
    pipe = r.pipeline()
    pipe.hincrby(first_layer_key, second_layer_key, 1)
    pipe.expire(second_layer_key, second_layer_sub_window_size)
    count = pipe.execute()[0]
    total_count = sum(int(v) for v in r.hvals(first_layer_key))
    return total_count <= limit

在上述代码中,is_allowed 函数首先根据当前时间计算出第一层和第二层子窗口的索引,然后使用 Redis 的 hincrby 命令对第二层子窗口的请求计数加 1,并设置该计数的过期时间。最后通过累加第一层子窗口内所有第二层子窗口的计数来判断是否超过限流阈值。

5. 子窗口划分的性能优化

无论采用哪种子窗口划分方案,都可以通过一些性能优化措施来提升系统的整体性能。

5.1 批量操作 Redis

在对 Redis 进行读写操作时,尽量采用批量操作的方式。例如,在等长子窗口限流的代码示例中,使用 pipeline 来批量执行 increxpire 命令,这样可以减少 Redis 与客户端之间的网络通信开销,提高操作效率。在分层子窗口划分中,也可以通过批量操作哈希表的方式来提高性能。

5.2 合理设置过期时间

子窗口的过期时间设置要合理。过期时间过短可能导致请求计数过早丢失,影响限流的准确性;过期时间过长则会占用过多的 Redis 内存。一般来说,子窗口的过期时间应该等于子窗口的大小,这样可以保证在子窗口结束后,相关的请求计数信息能够及时被清理。

5.3 数据预加载与缓存

对于一些固定的限流配置信息,如窗口大小、子窗口大小、限流阈值等,可以在系统启动时预加载到内存中,避免每次请求都从 Redis 中读取这些信息,从而减少 Redis 的读取压力。同时,可以使用本地缓存来存储一些临时的计算结果,如当前窗口内的请求总数等,进一步提高系统的响应速度。

6. 实际应用案例分析

6.1 电商秒杀场景

在电商秒杀活动中,流量会在短时间内急剧上升,对系统的限流要求非常高。采用滑动窗口限流并合理划分子窗口可以有效保障系统的稳定性。例如,将 1 分钟的时间窗口划分为 60 个 1 秒的子窗口,能够精确控制每秒的请求数量。同时,为了应对秒杀开始瞬间的高流量,可以在活动开始前适当缩小子窗口大小,如将子窗口大小调整为 0.5 秒,提高限流精度。在实际实现中,可以结合 Redis 的原子操作和 Python 代码来实现高效的限流逻辑,确保只有符合限流规则的请求能够进入后续的业务处理流程。

6.2 API 接口限流场景

对于提供给第三方使用的 API 接口,为了保护系统资源,需要进行限流。假设一个 API 接口每分钟允许每个用户调用 100 次。可以采用等长子窗口划分,将 1 分钟划分为 60 个 1 秒的子窗口,每个子窗口允许调用的次数为 100 / 60(向上取整)。当用户发起请求时,通过 Redis 记录每个子窗口内该用户的调用次数,判断是否超过限流阈值。如果采用分层子窗口划分,可以在第一层将 1 分钟划分为 5 个 12 秒的子窗口,在第二层将每个 12 秒的子窗口划分为 12 个 1 秒的子窗口,这样既能在较大时间尺度上控制整体流量,又能在秒级粒度上进行精确限流。

7. 总结子窗口划分的要点

在 Redis 滑动窗口限流中,子窗口划分是核心环节。要根据业务场景的流量特点、系统性能和资源限制以及限流规则的复杂度来选择合适的子窗口划分方案。等长子窗口划分简单易懂,适用于流量相对平稳的场景;自适应子窗口划分能够根据流量动态调整,在流量波动较大的场景中表现出色;分层子窗口划分结合了不同粒度的优点,可用于复杂限流需求的场景。同时,通过批量操作 Redis、合理设置过期时间和数据预加载与缓存等性能优化措施,可以提升系统的整体性能,确保滑动窗口限流在实际应用中能够高效、稳定地运行,为系统的稳定性和可靠性提供有力保障。在实际应用中,需要不断测试和优化子窗口划分方案,以适应不同业务场景下的流量变化,实现最佳的限流效果。