Skip to content

缓存与性能

Redis 缓存

配置 Redis

ts
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      redis: {
        driver: 'redis',
        host: process.env.REDIS_HOST || '127.0.0.1',
        port: Number(process.env.REDIS_PORT) || 6379,
        password: process.env.REDIS_PASSWORD,
        db: 0,
      },
    },
  },
})

Nitro Storage vs 直接用 ioredis?

Nitro Storage(useStorage('redis'))是 Nitro 提供的统一存储接口,好处是开发环境可以自动切换到内存存储。但 useStorage 不支持 Redis 的高级功能(如 INCREXPIRE、Lua 脚本)。如果需要这些功能,直接用 ioredis

缓存工具函数

ts
// server/utils/cache.ts
export const getCached = async <T>(key: string): Promise<T | null> => {
  const storage = useStorage('redis')
  const value = await storage.getItem<string>(key)
  return value ? JSON.parse(value) : null
}

export const setCache = async (key: string, value: any, ttlSeconds = 3600) => {
  const storage = useStorage('redis')
  await storage.setItem(key, JSON.stringify(value), { ttl: ttlSeconds })
}

export const deleteCache = async (key: string) => {
  const storage = useStorage('redis')
  await storage.removeItem(key)
}

export const getCachedOrFetch = async <T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttlSeconds = 3600
): Promise<T> => {
  const cached = await getCached<T>(key)
  if (cached) return cached

  const data = await fetchFn()
  await setCache(key, data, ttlSeconds)
  return data
}

使用缓存

ts
// server/api/plans.get.ts
export default defineEventHandler(async () => {
  return await getCachedOrFetch('plans:list', async () => {
    return await db.select().from(plans).where(eq(plans.status, 1)).orderBy(plans.sortOrder)
  }, 300) // 缓存 5 分钟
})

缓存失效策略

缓存不是"设了就忘",必须有失效机制,否则用户看到的是过期数据:

策略适用场景实现
TTL 过期数据变化不频繁setCache(key, data, 300) — 5 分钟后自动过期
主动删除数据变更后立即生效修改数据时 deleteCache('plans:list')
标签批量删除一类数据全失效deleteCache('plans:*') — 需要自己实现通配符删除

推荐做法:TTL 保底 + 关键操作后主动删除

ts
// 管理后台修改套餐后,主动删除缓存
// server/api/admin/plans/[id].put.ts
await db.update(plans).set(data).where(eq(plans.id, id))
await deleteCache('plans:list')  // 修改后立即失效

getCachedOrFetch 的陷阱

如果 fetchFn 抛出异常,异常不会被缓存,下次请求会重新尝试。这是正确的行为——不要缓存错误。

缓存穿透

如果 fetchFn 返回 nullgetCachedOrFetch 不会缓存它(因为 cachednull 时会重新查询)。如果查询结果确实为空且想缓存,可以缓存一个空数组 [] 而非 null

API 缓存(routeRules)

ts
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 套餐列表:缓存 5 分钟
    '/api/plans': { swr: 300 },

    // 静态资源:长缓存
    '/_nuxt/**': {
      headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
    },
  },
})

SWR(Stale-While-Revalidate)原理

text
┌──── 第一次请求 ──────────────────┐
│  请求 → 服务器查询 → 响应        │
│         + 缓存结果(TTL=300s)    │
└──────────────────────────────────┘

┌──── TTL 内再次请求 ──────────────┐
│  请求 → 直接返回缓存(快)        │
│         不触发服务器查询           │
└──────────────────────────────────┘

┌──── TTL 过期后请求 ──────────────┐
│  请求 → 先返回过期缓存(快)       │
│         同时后台重新查询           │
│         更新缓存                  │
│         下次请求得到新数据         │
└──────────────────────────────────┘

SWR 的优势:用户永远不会等待——要么返回缓存(快),要么返回过期缓存同时后台更新。比传统缓存的"过期后必须等新数据"体验更好。

SWR vs Redis 缓存的区别

  • SWR:Nitro 层面缓存整个 HTTP 响应,包括 headers。适合不依赖用户身份的公共数据
  • Redis 缓存:应用层面缓存查询结果,可以精确控制。适合需要根据用户身份返回不同数据的场景

不要对需要认证的 API 使用 SWR

/api/user/profile 加 SWR 会导致 A 用户看到 B 用户的数据。

routeRules 缓存适用场景

路由缓存方式原因
/api/plansswr: 300公共数据,所有人相同,5 分钟更新一次即可
/api/user/profileRedis 缓存每个用户不同,不能共享
/api/admin/statsRedis 缓存管理员专用,数据更新后需要主动失效
/_nuxt/**HTTP 长缓存静态资源含内容哈希,可安全缓存 1 年

数据库查询优化

1. 只查询需要的字段

ts
// ✅ 只查 3 个字段
const users = await db.select({
  id: users.id,
  nickname: users.nickname,
  avatar: users.avatar,
}).from(users)

// ❌ 查所有字段(包括 passwordHash)
const users = await db.select().from(users)

2. 使用索引

在 schema 中添加索引,加速常用查询:

ts
export const users = pgTable('users', {
  // ...字段定义
}, (table) => ({
  phoneIdx: index('idx_users_phone').on(table.phone),     // 手机号搜索
  roleIdx: index('idx_users_role').on(table.role),         // 角色筛选
  createdIdx: index('idx_users_created').on(table.createdAt), // 时间排序
}))

什么时候需要索引?

频繁用于 WHEREORDER BYJOIN 的列需要索引。id 主键自动有索引。索引会增加写入开销,不要给不常用的列加索引。

3. 分页查询

ts
const [list, [{ count }]] = await Promise.all([
  db.select().from(users).limit(pageSize).offset(offset),
  db.select({ count: sql`count(*)` }).from(users),
])

大数据量分页的 offset 问题

OFFSET 100000 需要 PostgreSQL 先扫描前 10 万行再丢弃。更好的方案是游标分页(参见用户管理模块章节)。

4. 批量操作

ts
// ✅ 一次插入多条
await db.insert(users).values(userList)

// ❌ 循环单条插入
for (const user of userList) {
  await db.insert(users).values(user)  // N 次数据库往返
}

前端性能优化

useFetch vs useLazyFetch

ts
// useFetch:阻塞渲染,等待数据返回
const { data: user } = await useFetch('/api/user/profile')
// 页面在数据返回前不会渲染

// useLazyFetch:不阻塞渲染,数据加载完后再更新
const { data: stats } = useLazyFetch('/api/admin/stats')
// 页面立即渲染,stats 为 null,加载完成后更新
场景选择原因
用户个人信息useFetch必须有用户数据才能渲染页面
仪表盘统计useLazyFetch统计数据不是首屏关键内容
列表数据useFetch列表是页面主体,没有数据显示空页面无意义
侧边栏推荐useLazyFetch非关键内容,可以延迟加载

SSR 场景注意

useFetch 在 SSR 时会等待数据返回,增加首屏时间(TTFB)。如果数据对 SEO 无意义,考虑 useLazyFetch 或关闭 SSR。

懒加载组件

ts
// Lazy 前缀自动懒加载
const LazyChart = resolveComponent('LazyChartComponent')

// 或在模板中
// <LazyChartComponent v-if="showChart" />

什么时候用懒加载?

体积大或不是首屏需要的组件——如图表、富文本编辑器、弹窗内容。

服务端性能

1. 并行查询

ts
const [users, orders, stats] = await Promise.all([
  db.select().from(users).limit(10),
  db.select().from(orders).limit(10),
  db.select({ count: sql`count(*)` }).from(users),
])

为什么并行?

三个查询互相不依赖,串行执行需要 3 次数据库往返时间,并行只需 1 次。

2. 连接池

postgres.js 默认使用连接池,默认大小 10。生产环境建议调大:

ts
const client = postgres(config.dbUrl, {
  max: 20,              // 最大连接数
  idle_timeout: 20,     // 空闲连接 20 秒后释放
  connect_timeout: 10,  // 连接超时 10 秒
})

连接数怎么设置?

经验公式:max = CPU 核心数 * 2 + 磁盘数。4 核服务器可以设 20。同时要确保 PostgreSQL 的 max_connections 大于所有应用实例的连接池大小之和。

3. 后台任务

ts
export default defineEventHandler((event) => {
  event.waitUntil(logActivity())  // 不阻塞响应
  return { status: 'ok' }
})

event.waitUntil() 让任务在响应发送后继续执行,不增加响应时间。适合日志记录、缓存更新、通知发送等非关键操作。

常见错误

waitUntil 用于关键业务逻辑(如扣款),可能导致响应成功但后台任务失败,数据不一致。关键逻辑必须在响应前完成。

基于 Nuxt 4 官方文档整理编写