弹性工程中4种不同的速率限制策略


速率限制器是一种工具,用于监控客户端 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')
}
}

队列速率限制

  • 机制:不是拒绝多余的请求,而是在可能的情况下对它们进行排队和处理。
  • 用例:当丢失请求是不可接受的并且延迟是可以容忍的时。
  • 优点:没有请求会被彻底拒绝。
  • 缺点:如果队列太长,可能会导致高延迟和资源消耗。