参考资料:
1.【文章来源】Getting Started with Node.js Rate Limiting
- 【作者】[Odhiambo Paul]
- 【文章来源】rate-limiting-node-js
介绍
Rate Limiting的设计可以保护后端API免受恶意攻击,它本意是由我们限制用户向API发出的请求数量。
而API 提供商广泛使用Rate-Limiting来限制未订阅用户在给定时间内可以发出的请求数量。比如,newsapi.org将拥有开发者帐户的用户限制为每天只能发出 100 个请求。
Rate Limiting是用于控制服务器中的传出和传入请求的功能。我们可能会将没有高级帐户的用户提出的请求数限制为 100 到一个小时。当用户在窗口持续时间内发出的请求数超过提供的限制时,将返回一条错误消息,通知用户超出了允许的限制。
学习基础
- HTTP 请求/响应的理解
- 对Node有一定的了解
- 本地安装了Node和Redis
项目设置
- 创建一个
limiter为项目命名的文件夹。 - 在该目录中,执行以下命令来初始化 Node.js 项目:
npm init -y - 安装
express:npm install --save express - 在项目目录中,创建名为 的入口点文件
index.js,向其中添加以下代码片段:
const express = require("express");
const app = express();
const port = 3000;
//returns the string Hello World when / is visited
app.get("/", (req, res) => {
res.send("Hello World!");
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
- 在项目目录中执行以下命令,确保我们的应用程序运行无错误:
node index.js - 对外提供API。当我们向端点发送
GET请求时,它会返回信息。而此时,我们引入Rate-limiting,将 API 访问限制为在指定持续时间内给定数量。express中我们将以中间件来实现。
在项目根目录下新建一个文件,命名为routes.js,添加如下代码:
const { json } = require("express");
const express = require("express");
const router = express.Router();
const posts = [
{
id: 1,
author: "Lilian",
title: "Stock market",
body: "Post 1",
},
{
id: 2,
author: "Tom",
title: "Covid 19",
body: "Post 2",
},
{
id: 3,
author: "Vincent",
title: "Django APIs",
body: "Post 3",
},
{
id: 4,
author: "Cindy",
title: "Node.js Streams",
body: "Post 4",
},
];
router.get("/", function (req, res, next) {
res.json(posts);
});
module.exports = router;
在上面的代码中,我们创建了一个posts包含所有帖子的数组。然后,路由器posts以 JSON 数组的形式返回一个数组。
我们最终导出了路由器,从而可以在我们的index.js文件中导入和使用它。
第三方库实现Rate-limiting
此处推荐: express-rate-limiter
- 安装此包:
npm install express-rate-limit --save
在index.js文件中,添加以下代码:
const express = require("express");
const indexRoute = require("./router");
const rateLimit = require("express-rate-limit");
const app = express();
const port = 3000;
app.use(
rateLimit({
windowMs: 12 * 60 * 60 * 1000, // 12 hour duration in milliseconds
max: 5,
message: "You exceeded 100 requests in 12 hour limit!",
headers: true,
})
);
app.use("/posts", indexRoute);
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
windowMs是窗口的大小,也就是限制的窗口持续时间(以毫秒为单位)max是用户在给定窗口持续时间内可以发出的最大请求量message是用户在超出限制时收到的响应消息headers指示是否添加标头以显示请求总数,再次尝试发出请求之前的等待持续时间
到此处,我们已经简单实现了API的Rate-Limiting
使用Redis实现自定义的Rate-Limiting
利用Redis去实现一个自定义的功能, 以根据用户在窗口持续时间内发出的请求数存储每个用户的 IP 地址。
此处我们需要两个包来实现我们的自定义Rate-Limiting: npm i -S redis moment
在项目根目录中,创建一个名为customLimitter.js. 向其中添加以下代码:
import moment from 'moment';
import redis from 'redis';
const redis_client = redis.createClient();
const WINDOW_DURATION_IN_HOURS = 24;
const MAX_WINDOW_REQUEST_COUNT = 100;
const WINDOW_LOG_DURATION_IN_HOURS = 1;
export const customLimiter = (req, res, next) => {
try {
//Checks if the Redis client is present
if (!redis_client) {
console.log('Redis client does not exist!');
process.exit(1);
}
//Gets the records of the current user base on the IP address, returns a null if the is no user found
redis_client.get(req.ip, function(error, record) {
if (error) throw error;
const currentTime = moment();
//When there is no user record then a new record is created for the user and stored in the Redis storage
if (record == null) {
let newRecord = [];
let requestLog = {
requestTimeStamp: currentTime.unix(),
requestCount: 1
};
newRecord.push(requestLog);
redis_client.set(req.ip, JSON.stringify(newRecord));
next();
}
//When the record is found then its value is parsed and the number of requests the user has made within the last window is calculated
let data = JSON.parse(record);
let windowBeginTimestamp = moment()
.subtract(WINDOW_DURATION_IN_HOURS, 'hours')
.unix();
let requestsinWindow = data.filter(entry => {
return entry.requestTimeStamp > windowBeginTimestamp;
});
console.log('requestsinWindow', requestsinWindow);
let totalWindowRequestsCount = requestsinWindow.reduce((accumulator, entry) => {
return accumulator + entry.requestCount;
}, 0);
//if maximum number of requests is exceeded then an error is returned
if (totalWindowRequestsCount >= MAX_WINDOW_REQUEST_COUNT) {
res
.status(429)
.jsend.error(
`You have exceeded the ${MAX_WINDOW_REQUEST_COUNT} requests in ${WINDOW_DURATION_IN_HOURS} hrs limit!`
);
} else {
//When the number of requests made are less than the maximum the a new entry is logged
let lastRequestLog = data[data.length - 1];
let potentialCurrentWindowIntervalStartTimeStamp = currentTime
.subtract(WINDOW_LOG_DURATION_IN_HOURS, 'hours')
.unix();
//When the interval has not passed from the last request, then the counter increments
if (lastRequestLog.requestTimeStamp > potentialCurrentWindowIntervalStartTimeStamp) {
lastRequestLog.requestCount++;
data[data.length - 1] = lastRequestLog;
} else {
//When the interval has passed, a new entry for current user and timestamp is logged
data.push({
requestTimeStamp: currentTime.unix(),
requestCount: 1
});
}
redis_client.set(req.ip, JSON.stringify(data));
next();
}
});
} catch (error) {
next(error);
}
};
在上面的代码中,我们在我们的应用程序中添加了Redis和moment。我们Redis用作内存数据库来跟踪用户活动,同时moment帮助我们操作 Javascript 日期。
常见Rate-limiting算法
此处举例常见的5种
1. 固定窗口计数器
这可能是实现Rate-limiting的最明显的方法。在这种方法中,跟踪用户在每个窗口中发出的请求数。
在这种情况下,窗口是指所考虑的时间空间。也就是说,如果我希望我的 API 每分钟允许 10 个请求,我们有一个 60 秒的窗口。因此,从00:00:00开始后,一个窗口将会是从00:00:00到00:01:00
因此,对于用户在一分钟内发出的第一个请求,使用优化的key-value存储(如 HashMap 或 Redis),我们可以根据计数存储用户的 ID,因为这是第一个请求,所以统计为1。请参阅以下格式:
在同一窗口内的后续请求中,我们检查用户是否未超过限制(即计数不大于 10)。如果用户没有,我们将计数加一;否则,请求将被丢弃并触发错误。
在窗口结束时,我们重置每个用户的记录以计数0并为当前窗口重复该过程。
优势:
- 容易实现
劣势:
- 这种方法并不完全准确,因为对所有用户强加一个通用的窗口开始时间是不公平的。实际上,在这种情况下,用户的窗口应该从他们第一次请求的时间到 60 秒后开始计数。
- 当接近窗口结束时出现流量突发时,例如,在第 55 秒时,服务器最终会完成比每分钟计划更多的工作。例如,我们可能在 55 到 60 秒之间有 10 个来自用户的请求,在 0 到 5 秒之间的下一个窗口中可能有来自同一用户的另外 10 个请求。因此,服务器最终在 10 秒内为该用户处理了 20 个请求。
- 在特别大的窗口周期中——例如,每小时 50 个请求(3,600 秒)——如果用户在前 10 分钟(600 秒)内达到限制,最终可能会等待很长时间。这意味着用户发出 50 个请求需要 10 分钟,但发出 51 个请求需要一个小时。这可能会导致在打开新窗口后立即对 API 进行标记。
2. 滑动日志
滑动日志算法跟踪用户提出的每个请求的时间戳。可以使用 HashMap 或 Redis 记录此处的请求。在这两种情况下,可以根据时间对请求进行排序以改进操作。
记录请求的过程如下所示:
- 检索最后一个窗口(60 秒)中记录的所有请求,并检查请求数是否超过允许的限制
- 如果请求数小于限制,记录请求并处理它
- 如果请求数等于限制,则丢弃该请求
优势:
- 这种方法更准确,因为它根据用户的活动计算每个用户的最后一个窗口,并且不会为所有用户强加一个固定的窗口。
- 由于没有固定的窗口,因此它不受窗口末尾的请求激增的影响。
劣势:
- 它的内存效率不高,因为我们最终会为每个请求存储一个新条目。
- 计算也非常昂贵,因为每个请求都会触发对先前保存的请求的计算,以检索最后一分钟的日志,然后获取计数。
3. 滑动窗口计数器
这种方法试图优化固定窗口计数器和滑动日志技术的一些劣势。在这种技术中,用户的请求按时间戳分组,而不是记录每个请求,我们为每个组保留一个计数器。
它跟踪每个用户的请求计数,同时按固定时间窗口(通常是限制窗口大小的一小部分)对它们进行分组。这是它的工作原理。
当接收到用户的请求时,我们检查用户的记录是否已经存在,以及是否已经存在该时间戳的条目。如果这两种情况都成立,我们只需增加时间戳上的计数器。
在确定用户是否超出限制时,我们检索在最后一个窗口中创建的所有组,然后对它们的计数器求和。如果总和等于限制,则用户已达到限制,传入请求将被丢弃。否则,将插入或更新时间戳并处理请求。
此外,可以将时间戳组设置为在窗口时间用完后过期,以控制内存消耗的速率。
优势:
- 这种方法节省了更多内存,因为我们不是为每个请求创建一个新条目,而是按时间戳对请求进行分组并增加计数器。
4. 令牌桶
在令牌桶算法中,我们只保留一个计数器来指示用户留下了多少令牌,以及显示上次更新时间的时间戳。这个概念起源于分组交换计算机网络和电信网络,其中有一个固定容量的桶来保存以固定速率(窗口间隔)添加的令牌。
在测试数据包的一致性时,检查桶以查看它是否包含足够数量的所需令牌。如果是,则删除适当数量的令牌,然后数据包通过传输;否则,处理方式不同。
在我们的例子中,当收到第一个请求时,我们记录时间戳,然后为用户创建一个新的令牌桶:
在随后的请求中,我们测试自上次创建时间戳以来窗口是否已经过去。如果没有,我们检查存储桶是否仍然包含该特定窗口的令牌。如果是,我们将减少令牌1并继续处理请求;否则,请求将被丢弃并触发错误。
在窗口自上一个时间戳以来已经过去的情况下,我们将时间戳更新为当前请求的时间戳,并将令牌数量重置为允许的限制。
优势:
- 这是一种准确的方法,因为窗口在用户之间不是固定的,因此是根据用户的活动确定的。
- 内存消耗最少,因为每个用户只有一个条目,用于随着时间的推移管理他们的活动(时间戳和可用令牌)。
5. 漏桶
漏桶算法利用队列以先进先出 (FIFO) 方式接受和处理请求。该限制是对队列大小实施的。例如,如果限制是每分钟 10 个请求,那么队列每次只能容纳 10 个请求。
随着请求排队,它们以相对恒定的速率进行处理。这意味着即使服务器受到突发流量的影响,传出响应仍然以相同的速率发送出去。
一旦队列被填满,服务器将丢弃更多传入请求,直到释放更多空间。
优势:
- 这种技术可以平滑流量,从而防止服务器过载。
劣势:
- 由于请求受到限制,流量整形可能会导致用户感觉整体速度变慢,从而影响应用程序的用户体验。