本文将介绍以下四种常见的限流算法,并使用 Node.js
+ redis
实现:
固定窗口限流
原理
固定窗口限流就是对一段固定时间窗口内的请求进行计数。
- 如果请求数超过了阈值,则拒绝该请求;
- 如果请求数没超过阈值,则接受该请求,且计数加1。
- 当时间窗口结束时,重置计数器为0。
代码实现
/**
* 固定窗口限流
* @param {string} key 缓存key
* @param {number} max 最大访问量
*/
export function fixedWindow(key = 'fixedWindow', max = 5) {
return async (ctx, next) => {
const cacheKey = `${key}:${ctx.path}`;
// 计数器
const counter = await redis.incr(cacheKey);
if (counter === 1) {
await redis.expire(cacheKey, 5 * 60);
}
// 如果超过最大访问量,则拒绝访问
if (counter > max) {
ctx.status = 429;
ctx.body = 'Too many request, try again later.';
} else {
await next();
}
}
}
测试一下
const Koa = require('koa');
const app = new Koa();
// 使用中间件
app.use(fixedWindow('fixedWindow', 5));
app.use((ctx) => ctx.body = 'Hello World');
app.listen(3000, () => {
console.log('server is running at http://localhost:3000')
});
ab -n 10 -c 10 -k -s 1200 "http://localhost:3000/"
缺点
如图,临界值会出现两倍流量配置的情况。
滑动窗口限流
滑动窗口限流解决固定窗口临界值的问题。它将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。
代码实现
/**
* 滑动窗口限流
* @param {string} key 缓存key
* @param {number} max 最大访问量
*/
export function slidingWindow(key = 'slidingWindow', max = 5) {
return async (ctx, next) => {
const path = ctx.path;
const cacheKey = `${key}:${path}`;
const smallWindow = 200; // 200ms
const now = Date.now();
const uuid = `${Math.random().toString().slice(3)}-${now}`;
const windowStart = calcWindowStart(5 * 60, smallWindow);
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local uuid = ARGV[2]
local windowStart = tonumber(ARGV[3])
local maxRequests = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)
redis.call('ZADD', key, now, uuid)
local requestCount = redis.call('ZCARD', key)
if requestCount <= maxRequests then
return "ALLOW"
else
return "DENY"
end
`;
const result = await redis.eval(
luaScript,
1,
cacheKey,
now.toString(),
uuid,
windowStart.toString(),
max
);
if (result !== 'ALLOW') {
ctx.status = 429;
ctx.body = 'Too many request, try again later.';
} else {
await next();
}
}
}
/**
* 根据 滑动窗口长度、最小窗口长度和当前时间,计算滑动窗口开始时间
* @param duration
* @param smallWindow
*/
export function calcWindowStart(
duration: number,
smallWindow: number
): number {
const now = Date.now();
const lastWindowLen = now % smallWindow;
const dur = duration * 1000;
return now - dur + (smallWindow - lastWindowLen);
}
测试一下
const Koa = require('koa');
const app = new Koa();
// 使用中间件
app.use(slidingWindow('slidingWindow', 5));
app.use((ctx) => ctx.body = 'Hello World');
app.listen(3000, () => {
console.log('server is running at http://localhost:3000')
});
ab -n 10 -c 10 -k -s 1200 "http://localhost:3000/"
缺点
滑动窗口算法虽然解决了固定窗口的临界问题,但是一旦到达限流后,请求都会直接被暴力被拒绝。酱紫我们会损失一部分请求,这其实对于产品来说,并不太友好。
漏桶算法
漏桶算法面对限流,就更加的柔性,不存在直接的粗暴拒绝。
它的原理很简单,可以认为就是注水漏水的过程。往漏桶中以任意速率流入水,以固定的速率流出水。当水超过桶的容量时,会被溢出,也就是被丢弃。因为桶容量是不变的,保证了整体的速率。
代码实现
原理:
- 请求进来时,先计算当前请求滴出时间并将请求放入队列。
- 根据滴出时间清除队列中已经滴出的请求。
- 如果队列长度超过了桶的容量,则拒绝请求。
计算公式:请求滴出时间 = MAX(上次请求滴出时间 + 滴水间隔时间 ,当前时间)
举例
当前时间:2024-04-26 13:00:00(1714107600000), 滴水间隔时间:1个/200ms;桶容量:5个;同时有5个请求进来
- 第1个请求滴出时间 = MAX(0 + 200, 1714107600000) = 1714107600200
- 第2个请求滴出时间 = MAX(1714107600200 + 200, 1714107600200) = 1714107600400
- 第3个请求滴出时间 = MAX(1714107600400 + 200, 1714107600400) = 1714107600600
- 第4个请求滴出时间 = MAX(1714107600600 + 200, 1714107600600) = 1714107600800
- 第5个请求滴出时间 = MAX(1714107600800 + 200, 1714107600800) = 1714107601000
/**
* 漏桶算法限流
* @param {string} key 缓存key
* @param {number} capacity 桶容量
* @param {number} outRate 出水速率, 每秒出水量
*/
export function leakyBucket(
key = 'leakyBucket',
capacity = 5,
outRate = 1
) {
return async (ctx, next) => {
const luaScript = `
local redisKey = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local outRate = tonumber(ARGV[3])
-- Step 1: 清除过期水滴,并获取当前水量
redis.call('ZREMRANGEBYSCORE', redisKey, '-inf', now);
local water = tonumber(redis.call('zcard', redisKey))
-- Step 2:如果水量小于容量,则继续加水
if water < capacity then
local interval = math.floor(1000 / outRate)
local max_score = redis.call('ZREVRANGE', redisKey, 0, 0)
local lastOperateTime = max_score[0] or 0
local currOperateTime = math.max(lastOperateTime + interval, now)
redis.call('ZADD', redisKey, currOperateTime, currOperateTime)
return currOperateTime
else
return -1
end
`;
const now = Date.now();
const currOperateTime = await redis.eval(
luaScript,
1,
key,
now.toString(),
capacity.toString(),
outRate.toString(),
);
if (currOperateTime && +currOperateTime < 0) {
ctx.status = 429;
ctx.body = 'Too many Requests. Try again later.';
} else {
await sleep(+currOperateTime - now);
await next();
}
}
}
async function sleep(ms) {
if (ms <= 0) return ;
return new Promise(resolve => setTimeout(resolve, ms));
}
测试一下
const Koa = require('koa');
const app = new Koa();
// 使用中间件
app.use(leakyBucket('fixedWindow', 5));
app.use((ctx) => ctx.body = 'Hello World');
app.listen(3000, () => {
console.log('server is running at http://localhost:3000')
});
缺点
在正常流量的时候,系统按照固定的速率处理请求,是我们想要的。但是面对突发流量的时候,漏桶算法还是循规蹈矩地处理请求,这就不是我们想看到的啦。流量变突发时,我们肯定希望系统尽量快点处理请求,提升用户体验嘛。
令牌桶算法
面对突发流量的时候,我们可以使用令牌桶算法限流。
令牌桶算法原理:
- 根据限流大小,定时往令牌桶里放令牌。
- 如果令牌数量满了,超过令牌桶容量的限制,那就丢弃。
- 系统在接受到一个用户请求时,都会先去令牌桶要一个令牌。如果拿到令牌,那么就处理这个请求的业务逻辑; 如果拿不到令牌,就直接拒绝这个请求。
代码实现
/**
* 固定窗口限流
* @param {string} key 缓存key
* @param {number} capacity 令牌桶容量
* @param {number} refillRate 令牌每秒添加速率,
*/
export function tokenBucket(
key: string = 'tokenBucket',
capacity: number = 5,
refillRate: number = 1
) {
return async (ctx, next) => {
const luaScript = `
local redisKey = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refillRate = tonumber(ARGV[3])
-- Get the current token count and last refill time
-- Step 1: 获取当前令牌数量、最后添加令牌时间
local lastRefillTime = tonumber(redis.call("HGET", redisKey, "lastRefillTime")) or now
local currentTokens = tonumber(redis.call("HGET", redisKey, "tokens")) or capacity
-- Calculate the elapsed time since the last refill
-- Step 2: 计算当前需要添加多少令牌
local elapsedSeconds = math.max(0, now - lastRefillTime)
local tokensToAdd = math.floor(elapsedSeconds * refillRate)
-- Update the token count and refill time
-- Step 3: 更新令牌数量和最后添加令牌时间
local newTokens = math.min(currentTokens + tokensToAdd, capacity)
redis.call("HSET", redisKey, "lastRefillTime", now)
redis.call("HSET", redisKey, "tokens", newTokens)
-- Check if there are enough tokens for this request
if newTokens >= 1 then
redis.call("HSET", redisKey, "tokens", newTokens - 1)
return "ALLOW"
else
return "DENY"
end
`;
const now = Math.floor(Date.now() / 1000);
const result = await redis.eval(
luaScript,
1,
key,
now.toString(),
capacity.toString(),
refillRate.toString(),
);
if (result !== 'ALLOW') {
ctx.status = 429;
ctx.body = 'Too many Requests. Try again later.';
} else {
await next();
}
}
}
测试一下
const Koa = require('koa');
const app = new Koa();
// 使用中间件
app.use(tokenBucket('tokenBucket', 5));
app.use((ctx) => ctx.body = 'Hello World');
app.listen(3000, () => {
console.log('server is running at http://localhost:3000')
});
缺点
如果令牌发放的策略正确,这个系统即不会被拖垮,也能提高机器的利用率。Google的Java开发工具包Guava中的RateLimiter类,就是基于令牌桶算法实现的。
拓展:Lua脚本是否可以用事务代替?
可以,但Redis事务存在一些缺陷
原子性的限制(如图)
不支持条件操作:事务不支持条件操作,例如基于当前值来更新某个键的值。
事务中的每条命令都会与redis服务器进行网络交互
总结
算法 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
固定窗口限流 | 简单 | 存在临界值问题 | -- |
滑动窗口限流 | 解决了固定窗口的临界值问题 | 达到限流后,对后续请求暴力拒绝 | -- |
漏桶算法 | 面对限流更加柔和,不存在暴力拒绝 | 面对突发流量,循规蹈矩 | -- |
令牌桶算法 | 解决了突发流量的问题 | 无明显缺点 | -- |
TIP
完整代码请点击这里。