【翻译-Node】NodeJS Rate Limiting 入门

1,100 阅读9分钟

参考资料:

1.【文章来源】Getting Started with Node.js Rate Limiting

  1. 【文章来源】rate-limiting-node-js
  1. 5种限流算法,7种限流方式

介绍

Rate Limiting的设计可以保护后端API免受恶意攻击,它本意是由我们限制用户向API发出的请求数量。

而API 提供商广泛使用Rate-Limiting来限制未订阅用户在给定时间内可以发出的请求数量。比如,newsapi.org将拥有开发者帐户的用户限制为每天只能发出 100 个请求。

Rate Limiting是用于控制服务器中的传出和传入请求的功能。我们可能会将没有高级帐户的用户提出的请求数限制为 100 到一个小时。当用户在窗口持续时间内发出的请求数超过提供的限制时,将返回一条错误消息,通知用户超出了允许的限制。

学习基础

  1. HTTP 请求/响应的理解
  2. 对Node有一定的了解
  3. 本地安装了Node和Redis

项目设置

  1. 创建一个limiter为项目命名的文件夹。
  2. 在该目录中,执行以下命令来初始化 Node.js 项目: npm init -y
  3. 安装express: npm install --save express
  4. 在项目目录中,创建名为 的入口点文件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}`);
});
  1. 在项目目录中执行以下命令,确保我们的应用程序运行无错误:node index.js
  2. 对外提供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);
    }
};

在上面的代码中,我们在我们的应用程序中添加了Redismoment。我们Redis用作内存数据库来跟踪用户活动,同时moment帮助我们操作 Javascript 日期。

常见Rate-limiting算法

此处举例常见的5种

1. 固定窗口计数器

这可能是实现Rate-limiting的最明显的方法。在这种方法中,跟踪用户在每个窗口中发出的请求数。

在这种情况下,窗口是指所考虑的时间空间。也就是说,如果我希望我的 API 每分钟允许 10 个请求,我们有一个 60 秒的窗口。因此,从00:00:00开始后,一个窗口将会是从00:00:0000:01:00

因此,对于用户在一分钟内发出的第一个请求,使用优化的key-value存储(如 HashMap 或 Redis),我们可以根据计数存储用户的 ID,因为这是第一个请求,所以统计为1。请参阅以下格式:

fixed window

在同一窗口内的后续请求中,我们检查用户是否未超过限制(即计数不大于 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. 令牌桶

在令牌桶算法中,我们只保留一个计数器来指示用户留下了多少令牌,以及显示上次更新时间的时间戳。这个概念起源于分组交换计算机网络和电信网络,其中有一个固定容量的桶来保存以固定速率(窗口间隔)添加的令牌。

在测试数据包的一致性时,检查桶以查看它是否包含足够数量的所需令牌。如果是,则删除适当数量的令牌,然后数据包通过传输;否则,处理方式不同。

在我们的例子中,当收到第一个请求时,我们记录时间戳,然后为用户创建一个新的令牌桶:

tokenBucket

在随后的请求中,我们测试自上次创建时间戳以来窗口是否已经过去。如果没有,我们检查存储桶是否仍然包含该特定窗口的令牌。如果是,我们将减少令牌1并继续处理请求;否则,请求将被丢弃并触发错误。

在窗口自上一个时间戳以来已经过去的情况下,我们将时间戳更新为当前请求的时间戳,并将令牌数量重置为允许的限制。

优势:

  • 这是一种准确的方法,因为窗口在用户之间不是固定的,因此是根据用户的活动确定的。
  • 内存消耗最少,因为每个用户只有一个条目,用于随着时间的推移管理他们的活动(时间戳和可用令牌)。

5. 漏桶

漏桶算法利用队列以先进先出 (FIFO) 方式接受和处理请求。该限制是对队列大小实施的。例如,如果限制是每分钟 10 个请求,那么队列每次只能容纳 10 个请求。

随着请求排队,它们以相对恒定的速率进行处理。这意味着即使服务器受到突发流量的影响,传出响应仍然以相同的速率发送出去。

一旦队列被填满,服务器将丢弃更多传入请求,直到释放更多空间。

优势:

  • 这种技术可以平滑流量,从而防止服务器过载。

劣势:

  • 由于请求受到限制,流量整形可能会导致用户感觉整体速度变慢,从而影响应用程序的用户体验。