跳到主要内容

滑动窗口计数器

2025年01月06日
柏拉文
越努力,越幸运

一、认识


利用 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();
}
})();