Rate Limit 限流中间件
要点
- 限流控制单位时间内同一来源的请求数量,防止滥用和 DDoS
- Hono 内置
rateLimit()中间件,支持内存和 Redis 两种存储后端 - 限流 key 可以按 IP、用户 ID、API Key 等维度生成
- 不同路由可以配置不同的限流策略(读接口和写接口阈值不同)
- 限流响应应该携带
Retry-After和X-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 的限流
限流可以放在多个层面:
- CDN 层(Cloudflare、AWS CloudFront):按 IP 限流,在边缘节点拦截,不会到达后端
- API 网关层(Kong、Nginx):按路由限流,支持更细粒度的配置
- 应用层(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 两种存储后端,配置项覆盖了常见需求。
这一节涉及到的几个层次:
- 基本配置:
duration和max控制时间窗口和请求上限 - 存储后端:内存(单实例)vs Redis(多实例)
- 自定义 key:按 IP、用户 ID、API Key 等维度计数
- 不同路由不同策略:读接口宽松,写接口(尤其登录)严格
- 响应头:
X-RateLimit-*和Retry-After让客户端感知限流状态 - 限流算法:固定窗口(默认)、滑动窗口、令牌桶、漏桶
- 边界场景:反向代理后的 IP 获取、CDN 层限流、与缓存的配合
限流配置需要根据实际流量和业务特性调整。过于激进的限流会影响正常用户,过于宽松则无法防护滥用。
下一篇看 Timeout 超时控制——请求超时、AbortController、与流式响应的配合。