速率限制器是一种工具,用于监控客户端 IP 可以发送到 API 端点的每单位时间的请求数。如果请求数量超过某个阈值,速率限制器将在一段时间内阻止客户端 IP 发送进一步的请求。
关键概念
- 限制:客户端IP每单位时间可以向API端点发送的最大请求数。
- 窗口:速率限制器用于跟踪客户端 IP 发送的请求数量的时间单位。它可以是从几秒到几天的任何时间。
- 标识符:速率限制器用来识别客户端 IP 的请求的唯一属性。
设计速率限制器
- 速率限制的目标是什么。
- 决定速率限制的类型,例如固定窗口、滑动窗口等。
- 存储类型:内存或持久存储。
- 请求标识符:IP 地址、用户 ID API 密钥等。
- 计数和记录
- 代码检查请求是否在限制内。
- 将响应发送给客户端。
默默地放弃请求
这比向客户端返回 429 错误更好。这是一个欺骗攻击者的伎俩,让他们认为请求已被接受,即使速率限制器实际上已经丢弃了请求。
速率限制策略
1、固定窗口计数
- 机制:时间范围(窗口)是固定的,比如一个小时或一天。计数器在每个窗口结束时重置。
- 使用案例:随着时间的推移平均速率限制就足够的简单场景。
- 优点:易于实施和理解。
- 缺点:可能允许窗口边界出现流量突发。
如何使用
- 获取请求者的 IP。
- 如果 IP 不存在,则在计数器中设置一个新值。
- 如果 IP 存在。检查时间差。
- 如果 IP 计数器的当前时间与开始时间之差大于速率限制窗口 -> 我们重置计数器。
- 或者 如果计数器未达到请求限制,则递增计数器。
- 否则 告知客户端 429,请求过多。
类比:试想一家电影院每场演出都售票。他们规定:每小时只能售出 100 张票。这是为了管理人群,确保每个人都能获得舒适的体验。每个小时都是一个时间 "窗口"。每小时开始时(比如下午 2 点),门票数量会重置,不管前一小时卖出了多少。如果在下午 2:45 票数达到 100 张,则在下午 3 点前不再售票,此时下一个窗口开始。export const rateLimitMiddleware = ( req: express.Request, res: express.Response, next: express.NextFunction ) => { const ip = req.ip if (!ip) { res.status(500).send('No IP address found on request') return }
const currentTime = Date.now()
if (!counters.has(ip)) { counters.set(ip, { count: 1, startTime: currentTime }) next() return }
const windowCounter = counters.get(ip)
if (windowCounter) { const difference = currentTime - windowCounter.startTime const isGreaterThanWindow = difference > rateLimitWindowInMs
if (isGreaterThanWindow) { // Reset the counter for the new window windowCounter.count = 1 windowCounter.startTime = currentTime next() } else if (windowCounter.count < requestLimitPerWindow) { // Increment the counter and allow the request windowCounter.count++ next() } else { // Rate limit exceeded res.status(429).send('Too Many Requests') } } }
|
2、滑动窗口日志
- 机制:在日志中记录每个请求的时间戳。窗口随着每个请求滑动,检查前一个窗口中的请求数量是否超出限制。
- 使用案例:当需要更平滑的速率限制而不允许固定间隔的突发时。
- 优点:比固定窗口更公平、更一致。
- 缺点:由于需要存储单独的时间戳,因此更加占用内存。
如何使用
- 对于每个 IP,我们都会记录请求的时间戳。
- 检查日志是否存在。如果不存在,则设置初始值。
- 如果存在,则检查日志中的所有时间戳。
- slidingWindowInMs -> 允许请求的时间窗口。
- requestThreshold -> 窗口内接受请求的最大数量。
- 我们会过滤掉窗口外的所有时间戳。
- 检查长度是否小于阈值。
- 如果没有,则告诉用户 429,请求太多。
类比:想象一下,在一场音乐会上,保安会记录每位来宾的入场时间。为了安全起见,演出场所每小时只允许 500 人入场。在整个活动期间,保安不断检查记录,以确保在任何一个滚动小时内进场人数不超过 500 人。如果最后一小时进入的人数过多,新的来宾必须等待,直到人数降到 500 人以下。export const rateLimitMiddleware = ( req: express.Request, res: express.Response, next: express.NextFunction ) => { const ip = req.ip if (!ip) { res.status(500).send('No IP address found on request') return }
if (!requestLogs.has(ip)) { requestLogs.set(ip, { timestamps: [Date.now()] }) next() return }
const currentTime = Date.now() const log = requestLogs.get(ip)
if (log) { log.timestamps = log.timestamps.filter((timestamp) => { const difference = currentTime - timestamp const isWithinWindow = difference <= slidingWindowInMs
return isWithinWindow })
if (log.timestamps.length < requestThreshold) { // Allow request log.timestamps.push(currentTime)
next() } else { // Rate limit exceeded res.status(429).send('Too Many Requests') } } }
|
3、滑动窗口计数器
- 机制:与固定窗口类似,但更平滑。窗户会根据每个请求滑动,结合了固定窗户的简单性和滑动原木的一些优点。
- 用例:当您需要公平性和内存效率之间的平衡时。
- 优点:比固定窗口更一致,比滑动日志更少的内存使用。
- 缺点:实现起来比固定窗口更复杂。
如何使用这与滑动窗口日志有点不同。它的内存占用较少,因为我们只跟踪一个计数器和最后一次请求的时间戳。它是滑动窗口日志和固定窗口计数器的混合体。
- 对于每个 IP,我们都会跟踪计数器和上次请求的时间戳。
- 如果日志已经存在,我们会做一些检查。
- 如果上次请求的时间与当前时间之差大于 slidingWindowInMs,那么我们就要重置计数器。这意味着从上次请求到现在已经过去了 60 秒。在 slidingWindowInMs 范围内,我们允许请求阈值的最大值。
- 如果情况并非如此:我们要计算出从计数器中递减的数量。
- 检查更新后的计数器是否大于请求阈值。
- 如果是 -> 429 响应。
- 如果不是,我们继续!
类比:一家咖啡店每小时最多提供 100 杯咖啡。他们不跟踪每份订单的时间,而是对上一小时的订单进行统计。在每次新的点单后,他们都会调整这个统计表,只考虑从当前时间算起的过去一个小时。如果统计结果达到 100,他们就会暂停新的订单,直到窗口向前移动时数量下降。
export const rateLimitMiddleware = ( req: express.Request, res: express.Response, next: express.NextFunction ) => { const ip = req.ip if (!ip) { res.status(500).send('No IP address found on request') return }
if (!requestLogs.has(ip)) { requestLogs.set(ip, { counter: 1, lastRequestTimestamp: Date.now() }) next() return }
const currentTime = Date.now() const log = requestLogs.get(ip)
if (log) { const timeElapsed = currentTime - log.lastRequestTimestamp const shouldResetCounter = timeElapsed > slidingWindowInMs
if (shouldResetCounter) { log.counter = 1 } else { // Calculate the decrement amount based on the time elapsed // Example: Sliding window is 60 seconds, in milliseconds that is 60000 // reqThreshold is 10 // This equals to 60000 / 10 = 6000 // Let's say timeElapsed is 30000, that means 30 seconds have passed // 30000 / 6000 = 5 // So we decrement the counter by 5 // This effectively calculates the number of requests that are outside of the sliding window // Those requests are no longer counted towards the rate limit (expired) const decrementAmount = Math.floor( timeElapsed / (slidingWindowInMs / requestThreshold) )
// `Math.max` is used to ensure the counter never goes below 0 log.counter = Math.max(0, log.counter - decrementAmount) }
log.lastRequestTimestamp = currentTime
const shouldBlockRequest = log.counter >= requestThreshold if (shouldBlockRequest) { res.status(429).send('Too many requests') return }
log.counter++ next() } }
|
4、令牌桶
- 机制:桶以稳定的速度充满代币。每个请求都会花费一个令牌。如果存储桶为空,则请求将被拒绝或排队。
- 使用案例:对于可接受偶尔突发的 API 或服务很有用。
- 优点:允许超过限制的短时间突发流量。
- 缺点:实施起来稍微复杂。在短时间内可能不公平。
如何运作
- 每个用户都有一个水桶。
- 当他们提出请求时,我们会递减他们的一些令牌。
- 每次他们提出请求时,我们都会尝试重新填充令牌。
- 不过,重新装满的逻辑与他们上次重新装满水桶的时间有关。
- 举个例子,如果用户发送垃圾邮件请求,那么在某个时间点上,timeSinceLastRefillInSeconds 就算不是 0,也会小于 1。
- 这将导致无法添加新的令牌。
类比:令牌桶比较难理解。不过,我们可以用一个比喻来说明。想象一下,你有一个水桶,水龙头正以恒定的速度往里注水。每次需要用水时,你都会拿一个杯子从桶里舀出一些水。水桶代表你的令牌桶,而水就是令牌。
水桶里有多少水,你就只能舀多少水。如果水桶空了,您必须等到水桶再次装满水时才能舀更多的水。水桶装满水的速度就是令牌加入水桶的速度。
// Class export class TokenBucket { capacity: number tokens: number refillRatePerSeconds: number lastRefill: number
constructor(capacity: number, refillRate: number) { this.capacity = capacity this.tokens = capacity this.refillRatePerSeconds = refillRate this.lastRefill = Date.now() }
refill() { const now = Date.now() const timeSinceLastRefillInSeconds = (now - this.lastRefill) / SECONDS_CONVERSION
// Add new tokens to the bucket since the last refill // Example: 10 tokens per second, 5 seconds since last refill = 50 new tokens // But don't exceed the capacity of the bucket // This way, if the bucket is not used for a long time, it will not be overflowing with tokens const newTokens = timeSinceLastRefillInSeconds * this.refillRatePerSeconds this.tokens = Math.min(this.capacity, this.tokens + newTokens) this.lastRefill = now }
allowRequest(): boolean { this.refill() if (this.tokens >= 1) { this.tokens -= 1 return true } return false } }
// Usage const buckets = new Map<string, TokenBucket>()
export const rateLimitMiddleware = ( req: express.Request, res: express.Response, next: express.NextFunction ) => { const ip = req.ip
if (!ip) { res.status(500).send('No IP address found on request') return }
const hasIpNoBucket = !buckets.has(ip) if (hasIpNoBucket) { buckets.set(ip, new TokenBucket(10, 1)) // Example: 10 tokens, refill 1 token/sec }
const bucket = buckets.get(ip) if (bucket && bucket.allowRequest()) { next() } else { res.status(429).send('Too Many Requests') } }
|
队列速率限制
- 机制:不是拒绝多余的请求,而是在可能的情况下对它们进行排队和处理。
- 用例:当丢失请求是不可接受的并且延迟是可以容忍的时。
- 优点:没有请求会被彻底拒绝。
- 缺点:如果队列太长,可能会导致高延迟和资源消耗。