缓存中间件

要点

  • 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 app

7. 缓存与认证

带认证的接口(需要登录)应该设置 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-Controletag() 中间件可以覆盖客户端和 CDN 缓存。

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

  1. 基本缓存cache() 缓存响应,Cache-Control 控制缓存策略
  2. ETag 与条件请求:响应未变化时返回 304,节省带宽
  3. 缓存失效:短 TTL、版本号、主动清除、CDN Cache-Tag
  4. 路由差异化:静态资源长缓存,动态接口短缓存或不缓存
  5. 缓存与认证private 避免用户私有数据被 CDN 缓存
  6. 边界场景:缓存穿透、雪崩、击穿的应对策略

缓存配置需要根据业务特性和数据更新频率调整。过于激进的缓存会导致数据延迟,过于保守则无法发挥缓存的效果。

下一篇看压缩中间件——compress() 中间件的 gzip/deflate、压缩阈值、与缓存的权衡。