系统需要限制某个IP在1分钟内只能发5次短信验证码,如何用Redis实现这个“滑动窗口”限流?
使用 Redis 实现真正的“滑动窗口”限流,最经典且最合适的数据结构是 ZSET(有序集合)。
相比于简单的 INCR + EXPIRE(固定窗口,存在临界点突发流量问题),滑动窗口可以精确控制任意连续的 60 秒内请求次数不超过 5 次。
以下是完整的实现思路、Lua 脚本和代码示例。
一、 核心设计思路
利用 Redis 的 ZSET:
- Key:构造带有 IP 的标识,例如
sms:limit:192.168.1.100。 - Score:当前请求的时间戳(毫秒级)。
- Member:保证唯一性,通常用
时间戳 + UUID或随机字符串。
执行流程(每次请求到来时):
- 清理过期数据:移除 ZSET 中
Score小于当前时间戳 - 60000毫秒的记录。 - 统计当前数量:获取 ZSET 中剩余的元素个数。
- 判断是否限流:
- 如果个数
>= 5,则拒绝请求。 - 如果个数
< 5,则将当前请求的时间戳作为 Score 写入 ZSET,并放行。
- 如果个数
- 设置过期时间:为这个 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
五、 方案优缺点分析
优点:
- 绝对精确:完美解决了固定窗口(计数器法)在时间边界上的“突发流量”问题。
- 通用性强:不仅能防短信盗刷,还可以用于接口 API 限流。
缺点与注意事项:
- 内存占用:ZSET 需要记录每一次请求的时间戳和 UUID,相比于单纯的
INCR会消耗更多内存。但在“1分钟5次”这种小频次场景下,内存消耗完全可以忽略不计。 - NTP 时间同步问题:该算法依赖系统时间,如果应用服务器集群的时间不一致,会导致限流不准。解决方案是在 Lua 脚本中使用 Redis 的时间
redis.call('TIME')获取时间戳,而不是由客户端传入,这样能保证绝对的时间一致性。
右滑查看面试常问