手把手教你在 Node.js 中使用 Redis 做请求限流
请求限流是一个用于控制网络请求与传输量的技术,在健全的 Node 应用中,我们通常会开启接口速率限制,来限定用户在一个周期时间内最大的请求数量,以此保护服务器免遭恶意请求与流量攻击。
在大规模的分布式应用中,我们通常可以通过网关层进行限流配置,但本文主要将的是如何在 Node 应用层去做限流,理解了其中的原理,就可以灵活地去应用各种限流的方法。
初始化工作
为了进行限流相关功能的探索,我们需要启动一个 Web 服务,在这里我带大家来使用优雅的 Koa 框架进行一些初始化的准备工作。
首先需要准备好 nvm,切换到 Node 14 的版本,因为接下来我会使用 ESM 的相关语法:
$ nvm use 14 $ mkdir limitexample && cd limitexample && npm init -y $ vim package.json # add "type": "module" $ npm i koa ioredis koa-ratelimit
依赖安装完成后,接着来创建一个入口文件 index.js
:
import Koa from "koa"; const app = new Koa(); // Put rate limit middleware app.use(async (ctx) => (ctx.body = "Hello World")); app.listen(3000);
通过 nodemon index
启动 Server 服务,由于文章接下来会使用到 Redis ,如果你还没有在本地安装 Redis 服务,可以使用 Docker 快速进行创建:
docker run --name redis-example -p 6379:6379 -e ALLOW_EMPTY_PASSWORD=yes -d redis
若要访问命令行客户段,则可通过 docker exec -it redis-example redis-cli
进行访问。
所有准备工作已做完,现在来开启 Coding & Test 旅程吧!
限
若要访问命令行客户段,则可通过 docker exec -it redis-example redis-cli
进行访问。
所有准备工作已做完,现在来开启 Coding & Test 旅程吧!
限流策略
固定窗口算法
固定窗口算法是一种计数器算法,固定窗口指的是一段固定的时间范围,窗口的大小即为允许通过的最大请求数,简而言之,固定窗口算法通过设定周期时间内最大允许的请求数目,来达到限流的效果。
它的核心逻辑为:在一个周期开始的时候,计数器清零,每个请求都将被计数,若计数达到上限,则不会再响应多余的请求,知道进入下一个周期。
我们来模拟一个使用固定窗口算法的网络请求图:
- 在 09:00 ~ 09:01 这段时间内,所产生的请求数是 75,并没有达到固定窗口的请求上限,然后进入到下一个周期 09:01 ~ 09:02 后,计数会重新清零。
- 在 09:02 ~ 09:03 期间,由于请求数很早就到达上限,在这段时间内超过上限的请求将不会相应并返回 429(Too Many Requests)。
我们使用 用户ip:limitrate
作为 Redis 的 Key,Key 的过期时间作为滑动窗口的大小,当请求进来时,进行以下两步判断:
- 若 Key 不存在,则设置当前 Key 的值为 1
- 若 Key 存在,则 Key 的值加 1
代码实现如下:
// index.js import { fixedWindow } from "./middleware/ratelimit.js"; app.use(fixedWindow); // middleware/ratelimit import Redis from 'ioredis' const FIX_WINDOW_SIZE = 60; // second const FIX_WINDOW_MAX_REQUEST = 100; const redisClient = new Redis(6379); export const fixedWindow = async (ctx, next) => { const redisKey = `${ctx.ip}:ratelimit`; const curCount = await redisClient.get(redisKey); if (!curCount) { await redisClient.setex(redisKey, FIX_WINDOW_SIZE, 1); next(); return; } if (Number(curCount) < FIX_WINDOW_MAX_REQUEST) { await redisClient.incr(redisKey); next(); } else { ctx.status = 429; ctx.body = "you have too many requests"; } };
固定窗口算法是简单且易于实现的,但也有一个很明显的缺点,来看看下面的示意图:
在快要接近 09:03 的时候,请求数达到了 80 左右,到底 09:03 以后就被清零又重新计数,在这段相隔不到 1 分钟的时间内,实际请求数已经超过了设置的最大上限,因此这个算法的短板是很明显的,我们在下一个算法中看看如何进行改进。
滑动窗口算法
滑动窗口算法也是一种计数器算法,可将它看作固定窗口看法的改良版本,由上面的描述大家可以得知固定窗口算法在一个周期范围内只有一个窗口,在这个窗口内的请求会进行计数等操作,而滑动窗口则是在一个周期范围内同时具有多个窗口进行计数,我们可以将一个周期时间拆分为多个窗口,拆分的越细,滑动窗口算法越平滑。
为了统计当前滑动窗口的请求总数,就需要查询出所有时间范围在当前滑动窗口周期之内的窗口,然后将每个窗口的请求数进行累加,进行判断是否出发限流的条件,这就是滑动窗口算法,以下为示例图:
首先我们来定义滑动窗的模型所依赖的变量,通过模型我们可以计算出请求速率是否超过限制,大致需要定义以下信息:
DURATION
,一段周期时间范围,单位秒MAX_REQ_IN_DURATION
,周期范围内所允许的最大请求数SPLIT_DURATION
,所拆分的最小窗口的时间周期,单位秒
为了限制用户在每 60 秒的时间内最多请求 100 次,我们可以定义以下的值:
const DURATION = 60 const MAX_REQ_IN_DURATION = 100 const SPLIT_DURATION = 1
除此之外,还需要一个数据结构用于储存用户的请求信息,在 Redis 中,使用 Hash 结构来计数是较为合适的,整个结构如下:
其中关于 Redis 结构,我们需要使用 ioredis 调用以下 Redis 命令完成相关操作:
- 在每次更新键值的时候,通过
EXPIRE
去更新 Redis Key 的过期时间 - 在每次更新键值的时候,通过
HDEL
删除在滑动窗口之前的 Hash Key -
在每次更新键值的时候,我们需要通过
HGETALL
获取到所有 Key,然后进行进行判断:- 存在 Key 在最小拆分窗口的周期时间内,
HINCRBY
在原有 Key 的基础上去增加 1 - 不存在 Key 在最小拆分窗口的周期时间时,将当前时间的时间戳作为
HINCRBY
的 Key
- 存在 Key 在最小拆分窗口的周期时间内,
逻辑讲的差不多了,接下来看代码吧:
// index.js import { slidingWindow } from "./middleware/slidingWindow.js"; app.use(slidingWindow); // middleware/slidingWindow import Redis from "ioredis"; const DURATION = 60; const MAX_REQ_IN_DURATION = 100; const SPLIT_DURATION = 0.0001; const redisClient = new Redis(6379); export const slidingWindow = async (ctx, next) => { const redisKey = `ratelimit:${ctx.ip}`; const durationEnd = Date.now(); const durationStart = durationEnd - DURATION * 1000; const splitStart = durationEnd - SPLIT_DURATION * 1000; const userRequestMap = await redisClient.hgetall(redisKey); if (Object.keys(userRequestMap).length === 0) { await redisClient .multi() .hset(redisKey, durationEnd, 1) .expire(redisKey, DURATION) .exec(); next(); return; } let requestCount = 0; let splitTimestamp = null; for (let [timestamp, count] of Object.entries(userRequestMap)) { if (Number(timestamp) < durationStart) { await redisClient.hdel(redisKey, timestamp); } else { requestCount += Number(count); if (Number(timestamp) > splitStart) { splitTimestamp = timestamp; } } } if (requestCount < MAX_REQ_IN_DURATION) { await redisClient.hincrby(redisKey, splitTimestamp ? splitTimestamp : durationEnd, 1); await redisClient.expire(redisKey, DURATION); next(); } else { ctx.status = 429; ctx.body = "you have too many requests"; } };
滑动日志算法
滑动日志算法与滑动窗口算法很接近,唯一不同的是,滑动日志算法会记录下用户的每一个请求的时间戳,这种算法的计算方式更为准确,算法也更加简单,但是相比滑动窗口算法,会更加消耗内存。
转自:https://vv13.cn/Algorithm/20200902_%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%9C%A8Node.js%E4%B8%AD%E4%BD%BF%E7%94%A8Redis%E5%81%9A%E8%AF%B7%E6%B1%82%E9%99%90%E6%B5%81/
参考:
1:https://www.infoq.cn/article/qg2tx8fyw5vt-f3hh673
2:https://www.infoq.cn/article/iPxNuQWU3lGwXc8J7tZW?utm_source=related_read&utm_medium=article