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

Redis固定窗口限流并发处理的原子性保障

2023-05-252.6k 阅读

Redis固定窗口限流的基本概念

在高并发的系统环境中,为了保护后端服务不被过多的请求压垮,限流是一种常用的手段。Redis固定窗口限流是其中一种较为简单直观的限流方式。

所谓固定窗口限流,就是在一个固定的时间窗口内,对请求的数量进行限制。例如,在1分钟的时间窗口内,只允许100个请求通过。这个时间窗口就像是一个固定大小的容器,请求如同水流进入容器,当容器装满(达到限制数量)后,后续请求就会被限流。

在Redis中实现固定窗口限流,通常利用其原子操作和数据结构来完成。常见的做法是使用Redis的计数器来记录每个时间窗口内的请求数量。例如,以时间窗口的起始时间戳作为键,每次请求到达时,使用INCR命令对对应键的值进行原子性递增。如果递增后的值超过了限制数量,则认为该请求被限流。

并发处理中原子性的重要性

在高并发场景下,多个请求可能同时尝试访问和修改Redis中的限流计数器。如果这些操作不是原子性的,就可能会出现数据不一致的问题,导致限流失效。

假设我们设置了一个1分钟内允许100个请求的限流规则。如果两个请求同时检查当前请求数是否达到100,并且都发现未达到,然后同时进行递增操作。那么实际的请求数就可能超过100,从而使得限流失去意义。

原子性操作确保了在并发环境下,对数据的读取、修改和写入是一个不可分割的整体,不会被其他并发操作干扰。在Redis中,许多命令本身就是原子性的,这为实现可靠的限流提供了基础。

Redis原子操作实现固定窗口限流

使用INCR命令

Redis的INCR命令可以对存储在指定键中的整数值进行原子性递增。我们可以利用这个命令来实现固定窗口限流。以下是一个简单的Python示例代码,使用redis - py库:

import redis
import time


def fixed_window_rate_limit(redis_client, key, limit, window):
    current_time = int(time.time())
    window_start = current_time - current_time % window
    key_with_window = f"{key}:{window_start}"
    count = redis_client.incr(key_with_window)
    if count == 1:
        # 设置键的过期时间,确保窗口过期后计数器被自动删除
        redis_client.expire(key_with_window, window)
    if count > limit:
        return False
    return True


# 示例使用
redis_client = redis.StrictRedis(host='localhost', port=6379, db = 0)
key = 'example_limit'
limit = 100
window = 60
for _ in range(150):
    if fixed_window_rate_limit(redis_client, key, limit, window):
        print('请求通过')
    else:
        print('请求被限流')

在上述代码中,我们首先计算出当前时间窗口的起始时间window_start,并以此作为键的一部分。每次请求到达时,调用incr方法对对应键的值进行递增。如果递增后的值超过了限制limit,则返回False表示请求被限流;否则返回True表示请求通过。

使用Lua脚本提升原子性

虽然INCR命令本身是原子性的,但在实际应用中,我们可能还需要一些额外的逻辑,例如初始化计数器、设置过期时间等。将这些操作组合在一起时,为了确保整体的原子性,可以使用Lua脚本。

Lua脚本在Redis中是原子执行的,所有在脚本中执行的命令都不会被其他客户端的命令打断。以下是使用Lua脚本实现固定窗口限流的示例:

-- lua脚本实现固定窗口限流
-- KEYS[1] 限流的键
-- ARGV[1] 限制数量
-- ARGV[2] 窗口时间(秒)
local current_time = tonumber(redis.call('TIME')[1])
local window_start = current_time - current_time % tonumber(ARGV[2])
local key_with_window = KEYS[1] .. ':' .. window_start
local count = redis.call('INCR', key_with_window)
if count == 1 then
    redis.call('EXPIRE', key_with_window, ARGV[2])
end
if count > tonumber(ARGV[1]) then
    return 0
else
    return 1
end

在Python中调用这个Lua脚本的代码如下:

import redis


def fixed_window_rate_limit_with_lua(redis_client, key, limit, window):
    lua_script = """
    local current_time = tonumber(redis.call('TIME')[1])
    local window_start = current_time - current_time % tonumber(ARGV[2])
    local key_with_window = KEYS[1] .. ':' .. window_start
    local count = redis.call('INCR', key_with_window)
    if count == 1 then
        redis.call('EXPIRE', key_with_window, ARGV[2])
    end
    if count > tonumber(ARGV[1]) then
        return 0
    else
        return 1
    end
    """
    result = redis_client.eval(lua_script, 1, key, limit, window)
    return result == 1


# 示例使用
redis_client = redis.StrictRedis(host='localhost', port=6379, db = 0)
key = 'example_limit'
limit = 100
window = 60
for _ in range(150):
    if fixed_window_rate_limit_with_lua(redis_client, key, limit, window):
        print('请求通过')
    else:
        print('请求被限流')

在这个示例中,Lua脚本首先计算出当前时间窗口的起始时间,然后对计数器进行递增操作。如果计数器是第一次被创建(值为1),则设置其过期时间。最后判断计数器的值是否超过限制,并返回相应的结果。通过这种方式,我们确保了从计算窗口起始时间、递增计数器、设置过期时间到判断是否限流这一系列操作的原子性。

固定窗口限流的不足与改进思路

虽然固定窗口限流实现简单且能在一定程度上保护后端服务,但它存在一个明显的问题,即“临界问题”。

假设我们设置了1分钟内允许100个请求的限流规则。在时间窗口的边界处,例如在0:59到1:00这一瞬间,如果同时有大量请求到达,前一分钟的最后几个请求和后一分钟的最初几个请求可能会同时通过,导致瞬间请求数超过限制的两倍。

为了解决这个问题,可以考虑使用滑动窗口限流算法。滑动窗口限流将时间窗口划分为多个小的子窗口,随着时间的推移,窗口像幻灯片一样滑动。这样可以更平滑地限制请求流量,避免临界问题。在Redis中实现滑动窗口限流,可以利用有序集合(Sorted Set)等数据结构来记录每个请求的时间戳,并根据时间戳进行滑动窗口的计算和限流判断。

总结Redis固定窗口限流原子性保障要点

  1. 利用Redis原子命令:如INCR命令,确保对计数器的递增操作是原子性的,避免并发环境下数据不一致。
  2. 结合Lua脚本:当需要多个相关操作组合时,使用Lua脚本保证这些操作作为一个整体原子执行,涵盖初始化计数器、设置过期时间等逻辑。
  3. 注意限流算法的局限性:虽然固定窗口限流实现简单,但要清楚其临界问题,根据实际场景考虑是否需要更复杂的限流算法如滑动窗口限流来替代或补充。

通过合理利用Redis的原子操作和数据结构,我们可以有效地实现固定窗口限流,并在高并发环境下保障限流逻辑的正确性和稳定性。在实际应用中,需要根据系统的具体需求和流量特点,灵活选择和优化限流方案。