Rate Limit 限流中间件

要点

  • 限流控制单位时间内同一来源的请求数量,防止滥用和 DDoS
  • Hono 内置 rateLimit() 中间件,支持内存和 Redis 两种存储后端
  • 限流 key 可以按 IP、用户 ID、API Key 等维度生成
  • 不同路由可以配置不同的限流策略(读接口和写接口阈值不同)
  • 限流响应应该携带 Retry-AfterX-RateLimit-* 头,让客户端知道何时可以重试
  • 分布式部署时需要共享存储(Redis),内存存储只在单实例时可用

1. 基本用法

rateLimit() 中间件限制单位时间内的请求数量:

// src/index.ts
import { Hono } from 'hono'
import { rateLimit } from 'hono/rate-limit'
 
const app = new Hono()
 
app.use('*', rateLimit({
  duration: 60 * 1000,  // 时间窗口:1 分钟
  max: 100,             // 窗口内最多 100 次请求
}))
 
app.get('/', (c) => c.text('Hello'))
 
export default app

超过限制后,中间件返回 429 Too Many Requests。

默认存储后端是内存。单实例服务够用,多实例部署时需要换成 Redis。

2. 存储后端

2.1 内存存储(默认)

import { rateLimit, memoryStore } from 'hono/rate-limit'
 
app.use('*', rateLimit({
  store: memoryStore,
  duration: 60 * 1000,
  max: 100,
}))

内存存储把计数器存在进程内存里。优点是零依赖、性能好;缺点是多实例部署时每个实例各自计数,限流效果被稀释。

2.2 Redis 存储

import { rateLimit, redisStore } from 'hono/rate-limit'
import { Redis } from 'ioredis'
 
const redis = new Redis(process.env.REDIS_URL)
 
app.use('*', rateLimit({
  store: redisStore(redis),
  duration: 60 * 1000,
  max: 100,
}))

Redis 存储让所有实例共享计数器,限流效果不会因为水平扩展而失效。

3. 自定义 key 生成

默认 rateLimit() 按客户端 IP 计数。如果需要按用户 ID 或 API Key 限流,可以自定义 keyGenerator

// 按 IP 限流(默认)
app.use('*', rateLimit({
  duration: 60 * 1000,
  max: 100,
}))
 
// 按用户 ID 限流
app.use('/api/*', rateLimit({
  duration: 60 * 1000,
  max: 1000,
  keyGenerator: (c) => {
    const user = c.get('user') as { id: string } | undefined
    return user?.id ?? c.req.header('x-forwarded-for') ?? 'anonymous'
  },
}))
 
// 按 API Key 限流
app.use('/api/*', rateLimit({
  duration: 60 * 1000,
  max: 500,
  keyGenerator: (c) => {
    return c.req.header('X-API-Key') ?? c.req.header('x-forwarded-for') ?? 'anonymous'
  },
}))

keyGenerator 接收 context,返回一个字符串作为限流的 key。不同 key 各自独立计数。

4. 不同路由不同策略

读接口和写接口的限流阈值通常不同。写接口(POST、PUT、DELETE)消耗更多资源,阈值应该更低:

// src/index.ts
import { Hono } from 'hono'
import { rateLimit } from 'hono/rate-limit'
 
const app = new Hono()
 
// 全局:按 IP 限制,每分钟 100 次
app.use('*', rateLimit({
  duration: 60 * 1000,
  max: 100,
}))
 
// 读接口:更宽松
app.use('/api/*', rateLimit({
  duration: 60 * 1000,
  max: 500,
  keyGenerator: (c) => c.req.header('x-forwarded-for') ?? 'anonymous',
}))
 
// 写接口:更严格
app.post('/api/login', rateLimit({
  duration: 60 * 1000,
  max: 5,  // 登录接口限制更严,防止暴力破解
  keyGenerator: (c) => c.req.header('x-forwarded-for') ?? 'anonymous',
}))
 
app.post('/api/posts', rateLimit({
  duration: 60 * 1000,
  max: 20,
  keyGenerator: (c) => c.req.header('x-forwarded-for') ?? 'anonymous',
}))
 
export default app

注意:Hono 的路由匹配规则是,多个中间件叠加时都会执行。如果全局和路径级都挂了 rateLimit(),请求会被计数两次。要避免这种情况,全局中间件只挂一次,路径级中间件用 keyGenerator 区分。

5. 限流响应头

rateLimit() 中间件会在响应里添加限流相关的头:

X-RateLimit-Limit: 100              ← 窗口内允许的请求总数
X-RateLimit-Remaining: 95           ← 窗口内剩余的请求数
X-RateLimit-Reset: 1687234567890    ← 窗口重置的时间戳(毫秒)

超过限制时返回 429:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1687234567890
Content-Type: application/json
 
{"message":"Rate limit exceeded"}

Retry-After 告诉客户端多少秒后可以重试。前端可以根据这个头决定等待时间:

async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const res = await fetch(url, options)
    if (res.status !== 429) return res
 
    const retryAfter = res.headers.get('Retry-After')
    const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * (i + 1)
    await new Promise((resolve) => setTimeout(resolve, waitTime))
  }
  throw new Error('Max retries exceeded')
}

6. 限流算法

rateLimit() 默认使用固定窗口算法。还有几种常见的限流算法:

6.1 固定窗口

把时间切成固定窗口(例如每分钟),每个窗口内最多 N 次请求。窗口边界时可能出现突发流量(前一分钟的最后几秒 + 后一分钟的前几秒)。

6.2 滑动窗口

在固定窗口的基础上,按请求时间戳计算最近 N 秒内的请求数。比固定窗口更平滑,但实现稍复杂。

6.3 令牌桶

以固定速率生成令牌,请求需要消耗令牌才能通过。桶有容量上限,允许突发流量。

6.4 漏桶

请求以固定速率被处理,超出的请求排队或丢弃。适合平滑流量。

Hono 的 rateLimit() 默认是固定窗口。如果需要其他算法,可以自己实现或用第三方库。

7. 自定义限流响应

默认 429 响应是纯文本。如果需要 JSON 格式,可以用 handler 选项:

app.use('*', rateLimit({
  duration: 60 * 1000,
  max: 100,
  handler: (c) => {
    return c.json({
      error: 'Rate limit exceeded',
      retryAfter: 30,
    }, 429)
  },
}))

8. 限流的边界场景

8.1 反向代理后的 IP 获取

如果服务部署在 Nginx、Cloudflare 等反向代理之后,c.req.header('x-forwarded-for') 才能拿到真实 IP。直接使用 c.req.header('x-forwarded-for') 需要注意:

  • 客户端可以伪造 X-Forwarded-For
  • 反向代理链可能有多个 IP,格式是 client, proxy1, proxy2
  • 应该只取第一个 IP(最左边的)
keyGenerator: (c) => {
  const forwarded = c.req.header('x-forwarded-for')
  const ip = forwarded?.split(',')[0].trim() ?? 'unknown'
  return ip
}

8.2 CDN 和 WAF 的限流

限流可以放在多个层面:

  1. CDN 层(Cloudflare、AWS CloudFront):按 IP 限流,在边缘节点拦截,不会到达后端
  2. API 网关层(Kong、Nginx):按路由限流,支持更细粒度的配置
  3. 应用层(Hono rateLimit):按用户、API Key 等业务维度限流

多层限流可以互补:CDN 挡住大流量攻击,应用层挡住精细化的滥用。

8.3 限流与缓存的配合

缓存可以减少后端压力,但不能替代限流。缓存命中时,后端不处理请求,限流计数器仍然会增加。如果需要「只计数缓存未命中」的逻辑,需要自定义实现。

9. 测试限流

// tests/rate-limit.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { rateLimit } from 'hono/rate-limit'
 
describe('rate limit', () => {
  it('超过限制返回 429', async () => {
    const app = new Hono()
    app.use('*', rateLimit({
      duration: 60 * 1000,
      max: 3,
    }))
    app.get('/', (c) => c.text('ok'))
 
    // 前 3 次请求正常
    for (let i = 0; i < 3; i++) {
      const res = await app.request('/')
      expect(res.status).toBe(200)
    }
 
    // 第 4 次被限流
    const res = await app.request('/')
    expect(res.status).toBe(429)
  })
})

测试时注意:内存存储的计数器在进程生命周期内存在。不同测试用例之间可能会互相影响。可以用 vi.useFakeTimers() 或每次测试创建新的 app 实例。

总结

限流中间件是保护服务稳定性的防线。Hono 内置的 rateLimit() 中间件支持内存和 Redis 两种存储后端,配置项覆盖了常见需求。

这一节涉及到的几个层次:

  1. 基本配置durationmax 控制时间窗口和请求上限
  2. 存储后端:内存(单实例)vs Redis(多实例)
  3. 自定义 key:按 IP、用户 ID、API Key 等维度计数
  4. 不同路由不同策略:读接口宽松,写接口(尤其登录)严格
  5. 响应头X-RateLimit-*Retry-After 让客户端感知限流状态
  6. 限流算法:固定窗口(默认)、滑动窗口、令牌桶、漏桶
  7. 边界场景:反向代理后的 IP 获取、CDN 层限流、与缓存的配合

限流配置需要根据实际流量和业务特性调整。过于激进的限流会影响正常用户,过于宽松则无法防护滥用。

下一篇看 Timeout 超时控制——请求超时、AbortController、与流式响应的配合。