基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

系统需要限制某个IP在1分钟内只能发5次短信验证码,如何用Redis实现这个“滑动窗口”限流?

知识点图片

使用 Redis 实现真正的“滑动窗口”限流,最经典且最合适的数据结构是 ZSET(有序集合)

相比于简单的 INCR + EXPIRE(固定窗口,存在临界点突发流量问题),滑动窗口可以精确控制任意连续的 60 秒内请求次数不超过 5 次。

以下是完整的实现思路、Lua 脚本和代码示例。


一、 核心设计思路

利用 Redis 的 ZSET

  • Key:构造带有 IP 的标识,例如 sms:limit:192.168.1.100
  • Score:当前请求的时间戳(毫秒级)。
  • Member:保证唯一性,通常用 时间戳 + UUID随机字符串

执行流程(每次请求到来时):

  1. 清理过期数据:移除 ZSET 中 Score 小于 当前时间戳 - 60000毫秒 的记录。
  2. 统计当前数量:获取 ZSET 中剩余的元素个数。
  3. 判断是否限流
    • 如果个数 >= 5,则拒绝请求。
    • 如果个数 < 5,则将当前请求的时间戳作为 Score 写入 ZSET,并放行。
  4. 设置过期时间:为这个 Key 设置 60 秒的过期时间(防止冷数据占用内存)。

二、 为什么必须用 Lua 脚本?

在并发场景下,上述的 4 个步骤必须是原子操作。如果通过代码多次调用 Redis,可能会发生竞态条件(Race Condition),导致超发。Redis 执行 Lua 脚本是单线程原子的,完美解决并发问题。

Lua 脚本代码

plaintext
-- KEYS[1]: 限流的Key (如 sms:limit:ip)
-- ARGV[1]: 当前时间戳 (毫秒)
-- ARGV[2]: 窗口起始时间戳 (当前时间 - 60000)
-- ARGV[3]: 限制次数 (5)
-- ARGV[4]: 唯一标识 (UUID等,ZSET的member必须唯一)

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

-- 1. 移除窗口外的数据 (小于 window_start 的都被删除)
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)

-- 2. 获取当前窗口内的请求数量
local current_requests = redis.call('ZCARD', key)

-- 3. 判断是否超过限制
if current_requests >= limit then
    return 0 -- 触发限流,拒绝
else
    -- 4. 记录本次请求
    redis.call('ZADD', key, now, member)
    -- 5. 重置 Key 的过期时间为 60 秒,防止占用内存
    redis.call('EXPIRE', key, 60)
    return 1 -- 允许通过
end

三、 完整代码实现(Java / Spring Boot 示例)

使用 StringRedisTemplate 调用上述 Lua 脚本:

java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.UUID;

@Service
public class SmsRateLimitService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    // Lua脚本,建议放在 resources 目录下读取,这里为了直观写在代码里
    private static final String LUA_SCRIPT = 
            "redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[2]) " +
            "local count = redis.call('ZCARD', KEYS[1]) " +
            "if count >= tonumber(ARGV[3]) then " +
            "return 0 " +
            "else " +
            "redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) " +
            "redis.call('EXPIRE', KEYS[1], 60) " +
            "return 1 " +
            "end";

    public boolean canSendSms(String ip) {
        String key = "sms:limit:" + ip;
        long now = System.currentTimeMillis();
        long windowStart = now - 60000; // 1分钟前
        int limit = 5; // 限制次数
        String member = now + "-" + UUID.randomUUID().toString(); // 保证 member 唯一

        DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
        
        Long result = redisTemplate.execute(
                script,
                Collections.singletonList(key), // KEYS[1]
                String.valueOf(now),            // ARGV[1]
                String.valueOf(windowStart),    // ARGV[2]
                String.valueOf(limit),          // ARGV[3]
                member                          // ARGV[4]
        );

        return result != null && result == 1L; // 1表示通过,0表示限流
    }
}

四、 Python (redis-py) 实现示例

如果你使用的是 Python,代码如下:

python
import time
import uuid
import redis

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

lua_script = """
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[2])
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[3]) then
    return 0
else
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
    redis.call('EXPIRE', KEYS[1], 60)
    return 1
end
"""
# 注册脚本
sliding_window_script = r.register_script(lua_script)

def can_send_sms(ip):
    key = f"sms:limit:{ip}"
    now_ms = int(time.time() * 1000)
    window_start = now_ms - 60000
    limit = 5
    member = f"{now_ms}-{uuid.uuid4()}"
    
    # 执行脚本
    result = sliding_window_script(
        keys=[key],
        args=[now_ms, window_start, limit, member]
    )
    return result == 1

五、 方案优缺点分析

优点:

  1. 绝对精确:完美解决了固定窗口(计数器法)在时间边界上的“突发流量”问题。
  2. 通用性强:不仅能防短信盗刷,还可以用于接口 API 限流。

缺点与注意事项:

  1. 内存占用:ZSET 需要记录每一次请求的时间戳和 UUID,相比于单纯的 INCR 会消耗更多内存。但在“1分钟5次”这种小频次场景下,内存消耗完全可以忽略不计。
  2. NTP 时间同步问题:该算法依赖系统时间,如果应用服务器集群的时间不一致,会导致限流不准。解决方案是在 Lua 脚本中使用 Redis 的时间 redis.call('TIME') 获取时间戳,而不是由客户端传入,这样能保证绝对的时间一致性。
00:00
00:00