高并发架构实战:基于 Redis + Lua 脚本实现生产级滑动窗口限流器

举报
yd_243452805 发表于 2026/10/25 15:58:09 2026/10/25
【摘要】 在微服务与开放 API 架构中,突发高并发流量极易引发级联故障击穿底层数据库。传统的固定窗口计数器在临界点存在“双倍突发流量”的致命缺陷。本文深入剖析滑动时间窗口(Sliding Window Log)算法原理,并结合 Redis Sorted Set 与原子性 Lua 脚本,实现一个轻量、精准的分布式高并发限流方案。

一、 为什么传统的固定窗口限流会失效?

最朴素的限流方案是固定窗口计数器(如利用 Redis 的 INCR 和 EXPIRE,限制 1 分钟最多 100 次请求)。但在生产环境中,它存在严重的临界双倍突发问题:

  • 场景复现:限制为 1 分钟 100 次请求。客户端在 00:59 发送了 100 个请求,窗口重置后,紧接着在 01:01 又发送了 100 个请求。

  • 后果:在 00:59 到 01:01 这短短 2 秒的滑动区间内,系统实际放行了 200 个请求,瞬间打垮下游服务。

限流算法 优点 缺点 适用场景
固定窗口 实现极简,内存消耗极低 窗口边界存在流量突变穿透漏洞 对限流精度要求不高、流量平稳的场景
令牌桶(Token Bucket) 允许瞬时突发流量,平滑塑形 分布式多节点协同维护成本较高 基础设施层网关(如 Ingress、Kong、Nginx)
滑动日志(Sliding Window Log) 绝对精准,彻底消灭临界突发效应 需保存窗口内请求记录,内存占用略高于计数器 核心业务接口防刷、短信/支付防爆破

二、 滑动窗口核心原理与 Redis ZSET 建模

滑动时间窗口的核心在于:只统计当前时刻向前推移一个完整周期内的真实请求数。

利用 Redis 的有序集合(Sorted Set / ZSET),我们可以优雅地完成这一流程:

  • Key:限流的业务维度(如 limit:sms:user_1001 或 limit:order_create)。

  • Score:请求到达服务器时的毫秒级时间戳(current_time_ms)。

  • Member:唯一请求标识(通常使用 毫秒时间戳 + 随机序列 或 UUID)。

每次请求到来时,执行以下 4 步原子操作:

  1. 淘汰过期记录:清除窗口期之前的所有冷数据(Score < now - window_size)。

  2. 统计窗口请求数:获取当前集合中的元素总个数。

  3. 阈值判定:若总数已达上限,拒绝请求;若未超限,将本次请求写入集合。

  4. 刷新兜底过期时间:为该 Key 续期,防止僵尸 Key 永久占用 Redis 内存。

三、 生产级 Lua 脚本实现

在高并发场景下,如果使用客户端分步调用 Redis 命令,各命令之间存在网络 RTT(往返耗时),多线程并发时极易产生脏读,导致限流失效。必须将逻辑封装为 Lua 脚本,由 Redis 单线程引擎原子执行。

1. 原子 Lua 脚本代码

Lua
-- 参数说明:
-- KEYS[1]: 限流的 Key
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 窗口大小(毫秒,如 60000 代表 1 分钟)
-- ARGV[3]: 窗口内最大允许请求数(如 100)
-- ARGV[4]: 唯一请求标识(防 member 冲突)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_permits = tonumber(ARGV[3])
local member = ARGV[4]

-- 1. 计算过期边界,移除窗口外的历史请求
local clear_before = now - window
redis.call('ZREMRANGEBYSCORE', key, 0, clear_before)

-- 2. 统计当前窗口内的有效请求总数
local current_requests = redis.call('ZCARD', key)

-- 3. 判断是否超出阈值
if current_requests < max_permits then
    -- 未超限:将当前请求加入集合,返回 1 放行
    redis.call('ZADD', key, now, member)
    -- 设置 Key 的 TTL(窗口时间再加 1 秒冗余),避免内存泄露
    redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)
    return 1
else
    -- 已超限:直接返回 0 拒绝
    return 0
end

2. 客户端调用示例(以 Python 为例)

Python
import time
import uuid
import redis

# 连接云上分布式 Redis 实例
r = redis.Redis(host='127.0.0.1', port=6379, db=0, decode_responses=True)

# 注册并缓存 Lua 脚本 SHA 校验码,减少网络带宽消耗
rate_limit_lua = """...上面编写的 Lua 脚本内容..."""
limit_script = r.register_script(rate_limit_lua)

def is_allowed(user_id: str, max_requests: int = 5, window_seconds: int = 10) -> bool:
    """
    判断该用户在过去的 window_seconds 秒内是否未超过 max_requests 次
    """
    key = f"rate_limit:{user_id}"
    now_ms = int(time.time() * 1000)
    window_ms = window_seconds * 1000
    unique_member = f"{now_ms}_{uuid.uuid4().hex[:6]}"

    # 原子执行 Lua 脚本
    result = limit_script(keys=[key], args=[now_ms, window_ms, max_requests, unique_member])
    return result == 1

# 压测模拟验证
user = "developer_test_01"
for i in range(7):
    allowed = is_allowed(user, max_requests=5, window_seconds=10)
    print(f"请求 {i+1}: {'【放行】' if allowed else '【拦截 429 Too Many Requests】'}")
    time.sleep(0.5)
控制台输出:

Plaintext
请求 1: 【放行】
请求 2: 【放行】
请求 3: 【放行】
请求 4: 【放行】
请求 5: 【放行】
请求 6: 【拦截 429 Too Many Requests】
请求 7: 【拦截 429 Too Many Requests】

四、 生产落地优化与避坑建议

  1. 分布式时钟一致性:Lua 脚本中的 now 参数建议由统一网关层传入,或在 Lua 脚本内部直接调用 redis.call('TIME') 获取 Redis 服务器的时间戳,避免应用实例之间因本地 NTP 时钟偏移导致计算边界失准。

  2. 极端大流量下的内存防膨胀:ZSET 滑动日志会记录每次请求的 member。如果单 Key 在窗口内需要支持上万甚至数十万级别的吞吐,ZSET 会占用较多内存。这种超大规模场景下,建议降级为滑动窗口计数器(Sliding Window Counter),将 1 分钟切分为 6 个 10 秒的小桶分别计数,兼顾极低内存与平滑限流。

  3. 降级与容灾设计:当云 Redis 出现网络抖动或超时告警时,限流中间件应具备本地“Fail-Open(默认放行)”或“降级为本地简易限流”的熔断策略,避免限流组件自身故障拖垮核心交易链路。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。