滑动窗口计数器
一、认识
利用 Redis
有序集合(ZSET
)存储请求的时间戳,并动态调整统计范围,使得时间窗口可以 滑动。具体步骤为: 为每个用户或操作通过 ZADD
创建一个有序集合键,值为请求的时间戳。当用户发起请求时, 添加当前时间戳到有序集合,通过 ZREMRANGEBYSCORE
删除集合中超出时间窗口范围的旧时间戳, 通过 ZCARD
获取集合中当前时间窗口内的元素数量, 并设置键的过期时间, 避免无用数据长期占用存储。
滑动窗口 通过更精细化的时间统计解决固定窗口的临界问题。它利用 Redis
有序集合(ZSET
)存储请求的时间戳,并动态调整统计范围,使得时间窗口可以 滑动。具体步骤为: 为每个用户或操作通过 ZADD
创建一个有序集合键,值为请求的时间戳。当用户发起请求时, 添加当前时间戳到有序集合,通过 ZREMRANGEBYSCORE
删除集合中超出时间窗口范围的旧时间戳, 通过 ZCARD
获取集合中当前时间窗口内的元素数量, 如果数量超过限制,拒绝请求, 并设置键的过期时间, 避免无用数据长期占用存储。滑动窗口 可以实现精细化统计,能够更精确地限制请求频率,避免流量突增,解决了固定窗口的临界问题,限流更加平滑,时间粒度更加精细。并具有高灵活性,可适用于更高要求的限流场景,例如对实时性要求较高的 API
。而且可以动态调整,可以灵活调整时间窗口和请求限制。
二、Node.js
const redis = require('redis');
// 创建 Redis 客户端
const redisClient = redis.createClient();
(async () => {
await redisClient.connect(); // 连接 Redis
console.log("Connected to Redis!");
})();
/**
* 滑动窗口计数器
* @param {string} key - Redis 键名,用于存储请求记录
* @param {number} windowSizeInSeconds - 滑动窗口大小(秒)
* @returns {Promise<number>} - 当前时间窗口内的请求数量
*/
async function slidingWindowCounter(key, windowSizeInSeconds) {
const currentTime = Date.now(); // 当前时间戳(毫秒)
const windowStart = currentTime - windowSizeInSeconds * 1000; // 窗口开始时间
// 添加当前请求时间戳到有序集合
await redisClient.zAdd(key, { score: currentTime, value: currentTime.toString() });
// 删除窗口外的请求记录
await redisClient.zRemRangeByScore(key, 0, windowStart);
// 获取窗口内的请求数量
const requestCount = await redisClient.zCard(key);
// 设置键的过期时间,避免无用数据长期占用存储
await redisClient.expire(key, windowSizeInSeconds + 1);
return requestCount;
}
/**
* 模拟请求并统计滑动窗口内的请求数量
*/
async function simulateRequests() {
const key = "api:sliding_window"; // Redis 键名
const windowSize = 10; // 窗口大小(秒)
// 模拟请求并统计当前窗口内的请求数量
const requestCount = await slidingWindowCounter(key, windowSize);
console.log(`Requests in the last ${windowSize} seconds: ${requestCount}`);
}
// 模拟请求间隔 500ms
setInterval(simulateRequests, 500);
三、Lua 脚本
四、Pipeline
如果并发量高,可以通过 Redis Pipeline
批量执行命令,减少网络开销。
五、Lua Node.js
Lua
脚本可以将多个 Redis
操作封装为一个原子性事务,减少网络延迟和数据竞争。减少网络往返,适合复杂业务逻辑。所有操作在 Redis
内部一次执行,具有原子性。如果业务逻辑复杂,涉及多次读写并需要保证原子性,比如滑动窗口计数器、分布式锁等,推荐使用 Lua
脚本。
const { createClient } = require('redis');
const redisClient = createClient();
redisClient.connect();
// Lua 脚本:滑动窗口计数器
const slidingWindowLuaScript = `
local key = KEYS[1]
local currentTime = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
-- 删除窗口外的请求记录
redis.call("ZREMRANGEBYSCORE", key, 0, currentTime - windowSize)
-- 添加当前请求时间
redis.call("ZADD", key, currentTime, currentTime)
-- 获取当前窗口内请求数量
local count = redis.call("ZCARD", key)
-- 设置键的过期时间
redis.call("EXPIRE", key, windowSize + 1)
return count
`;
/**
* 滑动窗口计数器(基于 Lua 脚本)
* @param {string} key - Redis 键名
* @param {number} windowSizeInSeconds - 滑动窗口大小(秒)
* @returns {Promise<number>} - 当前窗口内的请求数量
*/
async function slidingWindowWithLua(key, windowSizeInSeconds) {
const currentTime = Date.now();
return redisClient.eval(
slidingWindowLuaScript,
1, // 键数量
key, // 键
currentTime, // 当前时间戳
windowSizeInSeconds * 1000 // 窗口大小(毫秒)
);
}
/**
* 模拟滑动窗口计数器
*/
(async () => {
try {
const key = 'api:sliding_window_lua';
const windowSize = 10; // 窗口大小(秒)
// 模拟 5 个并发请求
const promises = Array.from({ length: 5 }, async (_, i) => {
const requestCount = await slidingWindowWithLua(key, windowSize);
console.log(`Request ${i + 1}: Requests in the last ${windowSize} seconds: ${requestCount}`);
});
await Promise.all(promises);
} catch (error) {
console.error("Error in Lua-based sliding window counter:", error);
} finally {
redisClient.quit();
}
})();
六、Pipeline Node.js
如果并发量高,可以通过 Redis Pipeline
批量执行命令,减少网络开销。操作之间没有原子性,适合需要简单批量操作的场景。减少 RTT
,但不适合复杂逻辑。Pipeline
是一种批量发送 Redis
命令的方式,可以减少网络往返次数,但操作之间的顺序和原子性由客户端控制。如果仅需要批量操作(如删除键值、批量设置值等)且对原子性要求较低,可以选择 Pipeline
,提高性能的同时降低开发复杂度。
/**
* 滑动窗口计数器(基于 Pipeline)
* @param {string} key - Redis 键名
* @param {number} windowSizeInSeconds - 滑动窗口大小(秒)
* @returns {Promise<number>} - 当前窗口内的请求数量
*/
async function slidingWindowWithPipeline(key, windowSizeInSeconds) {
const currentTime = Date.now(); // 当前时间戳(毫秒)
const windowStart = currentTime - windowSizeInSeconds * 1000; // 窗口开始时间
const pipeline = redisClient.multi();
// 删除窗口外的请求记录
pipeline.zRemRangeByScore(key, 0, windowStart);
// 添加当前请求时间戳到有序集合
pipeline.zAdd(key, { score: currentTime, value: currentTime.toString() });
// 获取窗口内的请求数量
pipeline.zCard(key);
// 设置键的过期时间
pipeline.expire(key, windowSizeInSeconds + 1);
const results = await pipeline.exec();
// 获取 ZCARD 的结果(第三个命令的结果)
return results[2];
}
/**
* 模拟滑动窗口计数器
*/
(async () => {
try {
const key = 'api:sliding_window_pipeline';
const windowSize = 10; // 窗口大小(秒)
// 模拟 5 个并发请求
const promises = Array.from({ length: 5 }, async (_, i) => {
const requestCount = await slidingWindowWithPipeline(key, windowSize);
console.log(`Request ${i + 1}: Requests in the last ${windowSize} seconds: ${requestCount}`);
});
await Promise.all(promises);
} catch (error) {
console.error("Error in Pipeline-based sliding window counter:", error);
} finally {
redisClient.quit();
}
})();