缓存中间件
要点
- Hono 内置
cache()中间件在服务端缓存响应,减少重复计算 Cache-Control头控制浏览器和 CDN 的缓存行为- ETag 实现条件请求,响应未变化时返回 304,节省带宽
- 缓存策略需要与数据更新配合——数据变化时需要失效缓存
- 不同路由的缓存策略不同:静态资源长缓存,动态接口短缓存或不缓存
- 缓存中间件与 CDN 缓存配合使用,可以显著降低后端压力
1. 基本用法
cache() 中间件缓存响应,避免每次请求都重新计算:
// src/index.ts
import { Hono } from 'hono'
import { cache } from 'hono/cache'
const app = new Hono()
// 缓存 1 小时
app.get('/api/config', cache({
cacheName: 'api-config',
cacheControl: 'max-age=3600, public',
}), async (c) => {
// 第一次请求:执行查询,缓存响应
// 后续请求:直接返回缓存
const config = await db.config.findFirst()
return c.json(config)
})
export default app第一次请求时,中间件执行路由处理函数,缓存响应。后续请求直接返回缓存的响应,不执行路由处理函数。
cache() 中间件的默认存储是内存。在 Cloudflare Workers 上,可以使用 Cache API 作为存储后端。
2. Cache-Control 指令
Cache-Control 头告诉浏览器和 CDN 如何缓存响应。常见指令:
Cache-Control: max-age=3600, public- max-age=3600:缓存 3600 秒(1 小时)
- public:允许浏览器和中间代理(CDN)缓存
- private:只允许浏览器缓存,CDN 不缓存
- no-cache:缓存但每次使用前必须验证(配合 ETag)
- no-store:完全不缓存
- must-revalidate:缓存过期后必须重新验证
- s-maxage=3600:覆盖 max-age,只对共享缓存(CDN)生效
常见场景的配置:
// 静态资源:长缓存,文件名带 hash
app.get('/assets/*', cache({
cacheControl: 'max-age=31536000, immutable',
}))
// API 配置:中等缓存
app.get('/api/config', cache({
cacheControl: 'max-age=3600, public',
}))
// 用户数据:短缓存,private
app.get('/api/me', cache({
cacheControl: 'max-age=60, private',
}))
// 动态数据:不缓存
app.get('/api/realtime', (c) => {
c.header('Cache-Control', 'no-store')
return c.json({ time: Date.now() })
})3. ETag 与条件请求
ETag 是响应内容的指纹。浏览器首次请求时收到 ETag,后续请求带上 If-None-Match 头。服务端对比 ETag,如果内容没变,返回 304 Not Modified,不发送响应体:
// src/index.ts
import { Hono } from 'hono'
import { etag } from 'hono/etag'
const app = new Hono()
// etag() 中间件自动计算 ETag
app.use('/api/*', etag())
app.get('/api/users', async (c) => {
const users = await db.user.findMany()
return c.json(users)
})
export default app首次请求:
HTTP/1.1 200 OK
ETag: "abc123"
Content-Type: application/json
[{"id":1,"name":"Alice"}]后续请求:
GET /api/users
If-None-Match: "abc123"
HTTP/1.1 304 Not Modified
ETag: "abc123"304 响应没有 body,节省带宽。但服务端仍然需要执行查询来计算 ETag,所以 304 节省的是带宽而不是计算。
4. ETag 的实现原理
etag() 中间件默认基于响应体计算 hash:
// etag() 的简化实现
export const etag = () => async (c, next) => {
await next()
const body = await c.res.clone().text()
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(body))
const etag = '"' + Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('') + '"'
c.header('ETag', etag)
const ifNoneMatch = c.req.header('If-None-Match')
if (ifNoneMatch === etag) {
c.res = new Response(null, { status: 304, headers: c.res.headers })
}
}计算 ETag 需要读取整个响应体,对于大响应有性能开销。可以自定义 ETag 生成函数,基于数据的版本号而不是内容 hash:
app.use('/api/*', etag({
weak: true, // 弱 ETag,允许更高效的计算
retention: 100, // 内存缓存大小
}))弱 ETag(以 W/ 开头)表示语义等价而非字节等价。对于 JSON 响应,字段的顺序变化不影响语义,弱 ETag 更合适。
5. 缓存失效
缓存的核心难题是失效——数据变化时,如何确保客户端不会拿到旧数据?
5.1 短 TTL
最简单的策略:缓存时间短到可以接受数据延迟。
app.get('/api/users', cache({
cacheControl: 'max-age=10', // 缓存 10 秒
}), async (c) => {
return c.json(await db.user.findMany())
})数据更新后,最多 10 秒后客户端就能拿到新数据。适合对实时性要求不高的场景。
5.2 版本号或时间戳
在 URL 或查询参数里带上版本号:
// 客户端请求
fetch('/api/users?v=1234')app.get('/api/users', cache({
cacheControl: 'max-age=3600',
keyGenerator: (c) => c.req.url, // URL 包含版本号,作为缓存 key
}), async (c) => {
return c.json(await db.user.findMany())
})数据更新后,前端请求新的版本号,缓存自动失效。适合数据版本可控的场景。
5.3 主动清除缓存
服务端提供 API 清除缓存:
// 清除特定 URL 的缓存
app.post('/api/cache/invalidate', async (c) => {
const { url } = await c.req.json()
await cacheStore.delete(url)
return c.json({ ok: true })
})或者在数据更新时自动清除:
app.put('/api/users/:id', async (c) => {
const id = c.req.param('id')
const body = await c.req.json()
await db.user.update({ where: { id }, data: body })
// 清除相关缓存
await cacheStore.delete(`/api/users/${id}`)
await cacheStore.delete('/api/users')
return c.json({ ok: true })
})主动清除需要知道哪些缓存受影响,实现复杂度较高。
5.4 Cache-Tag(CDN 层面)
CDN 提供商(Cloudflare、Fastly)支持按 tag 清除缓存。给响应打上 tag,更新时按 tag 批量清除:
app.get('/api/users/:id', async (c) => {
const user = await db.user.findUnique({ where: { id: c.req.param('id') } })
c.header('Cache-Tag', `user-${user.id}, user-list`)
c.header('Cache-Control', 'max-age=3600, public')
return c.json(user)
})
// 更新用户时,清除 user-{id} 和 user-list 的缓存
app.put('/api/users/:id', async (c) => {
const id = c.req.param('id')
await db.user.update({ where: { id }, data: await c.req.json() })
// 调用 CDN API 清除 tag
await cdn.purgeByTags([`user-${id}`, 'user-list'])
return c.json({ ok: true })
})6. 不同路由不同策略
// src/index.ts
import { Hono } from 'hono'
import { cache } from 'hono/cache'
import { etag } from 'hono/etag'
const app = new Hono()
// 静态资源:长缓存 + immutable
app.get('/assets/*', cache({
cacheControl: 'max-age=31536000, immutable',
}))
// 配置接口:中等缓存
app.get('/api/config', cache({
cacheControl: 'max-age=3600, public',
}))
// 列表接口:短缓存 + ETag
app.get('/api/users',
cache({ cacheControl: 'max-age=10, public' }),
etag(),
async (c) => c.json(await db.user.findMany())
)
// 用户详情:短缓存 + ETag
app.get('/api/users/:id',
cache({ cacheControl: 'max-age=10, private' }),
etag(),
async (c) => {
const user = await db.user.findUnique({ where: { id: c.req.param('id') } })
if (!user) return c.json({ error: 'not found' }, 404)
return c.json(user)
}
)
// 实时数据:不缓存
app.get('/api/realtime', (c) => {
c.header('Cache-Control', 'no-store')
return c.json({ time: Date.now() })
})
export default app7. 缓存与认证
带认证的接口(需要登录)应该设置 Cache-Control: private,避免 CDN 缓存用户私有数据:
// ❌ 危险:public 缓存可能把用户 A 的数据返回给用户 B
app.get('/api/me', cache({
cacheControl: 'max-age=3600, public',
}), async (c) => {
const user = c.get('user')
return c.json(user)
})
// ✅ 安全:private 只允许浏览器缓存
app.get('/api/me', cache({
cacheControl: 'max-age=60, private',
}), async (c) => {
const user = c.get('user')
return c.json(user)
})或者完全不缓存认证接口:
app.get('/api/me', (c) => {
c.header('Cache-Control', 'no-store')
return c.json(c.get('user'))
})8. 缓存的边界场景
8.1 缓存穿透
大量请求同时查询一个不存在的数据(例如已删除的用户),每次都穿透缓存到数据库。
解决方案:缓存空结果,设置较短的 TTL。
app.get('/api/users/:id', cache({
cacheControl: 'max-age=10',
}), async (c) => {
const user = await db.user.findUnique({ where: { id: c.req.param('id') } })
if (!user) {
// 缓存 404 响应
c.status(404)
return c.json({ error: 'not found' })
}
return c.json(user)
})8.2 缓存雪崩
大量缓存同时过期,导致瞬时流量全部打到数据库。
解决方案:给 TTL 加随机偏移,避免同时过期。
const baseTTL = 3600
const randomOffset = Math.floor(Math.random() * 600) // 0-10 分钟随机
app.get('/api/config', cache({
cacheControl: `max-age=${baseTTL + randomOffset}`,
}), ...)8.3 缓存击穿
某个热点 key 过期,大量请求同时穿透到数据库。
解决方案:热点数据使用互斥锁,只允许一个请求回源,其他请求等待。
// 简化实现
const locks = new Map()
app.get('/api/hot-data', async (c) => {
const key = 'hot-data'
if (locks.has(key)) {
// 等待锁释放
await locks.get(key)
}
const cached = await cacheStore.get(key)
if (cached) return c.json(cached)
// 加锁
let resolve
locks.set(key, new Promise((r) => { resolve = r }))
try {
const data = await db.hotData.findFirst()
await cacheStore.set(key, data)
return c.json(data)
} finally {
locks.get(key).then(resolve)
locks.delete(key)
}
})总结
缓存中间件是降低后端压力、提升响应速度的重要手段。Hono 内置的 cache() 中间件处理服务端缓存,配合 Cache-Control 和 etag() 中间件可以覆盖客户端和 CDN 缓存。
这一节涉及到的几个层次:
- 基本缓存:
cache()缓存响应,Cache-Control控制缓存策略 - ETag 与条件请求:响应未变化时返回 304,节省带宽
- 缓存失效:短 TTL、版本号、主动清除、CDN Cache-Tag
- 路由差异化:静态资源长缓存,动态接口短缓存或不缓存
- 缓存与认证:
private避免用户私有数据被 CDN 缓存 - 边界场景:缓存穿透、雪崩、击穿的应对策略
缓存配置需要根据业务特性和数据更新频率调整。过于激进的缓存会导致数据延迟,过于保守则无法发挥缓存的效果。
下一篇看压缩中间件——compress() 中间件的 gzip/deflate、压缩阈值、与缓存的权衡。