缓存与性能
Redis 缓存
配置 Redis
// 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 的高级功能(如 INCR、EXPIRE、Lua 脚本)。如果需要这些功能,直接用 ioredis。
缓存工具函数
// 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
}使用缓存
// 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 保底 + 关键操作后主动删除
// 管理后台修改套餐后,主动删除缓存
// 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 返回 null,getCachedOrFetch 不会缓存它(因为 cached 为 null 时会重新查询)。如果查询结果确实为空且想缓存,可以缓存一个空数组 [] 而非 null。
API 缓存(routeRules)
// 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)原理
┌──── 第一次请求 ──────────────────┐
│ 请求 → 服务器查询 → 响应 │
│ + 缓存结果(TTL=300s) │
└──────────────────────────────────┘
┌──── TTL 内再次请求 ──────────────┐
│ 请求 → 直接返回缓存(快) │
│ 不触发服务器查询 │
└──────────────────────────────────┘
┌──── TTL 过期后请求 ──────────────┐
│ 请求 → 先返回过期缓存(快) │
│ 同时后台重新查询 │
│ 更新缓存 │
│ 下次请求得到新数据 │
└──────────────────────────────────┘SWR 的优势:用户永远不会等待——要么返回缓存(快),要么返回过期缓存同时后台更新。比传统缓存的"过期后必须等新数据"体验更好。
SWR vs Redis 缓存的区别
- SWR:Nitro 层面缓存整个 HTTP 响应,包括 headers。适合不依赖用户身份的公共数据
- Redis 缓存:应用层面缓存查询结果,可以精确控制。适合需要根据用户身份返回不同数据的场景
不要对需要认证的 API 使用 SWR
/api/user/profile 加 SWR 会导致 A 用户看到 B 用户的数据。
routeRules 缓存适用场景
| 路由 | 缓存方式 | 原因 |
|---|---|---|
/api/plans | swr: 300 | 公共数据,所有人相同,5 分钟更新一次即可 |
/api/user/profile | Redis 缓存 | 每个用户不同,不能共享 |
/api/admin/stats | Redis 缓存 | 管理员专用,数据更新后需要主动失效 |
/_nuxt/** | HTTP 长缓存 | 静态资源含内容哈希,可安全缓存 1 年 |
数据库查询优化
1. 只查询需要的字段
// ✅ 只查 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 中添加索引,加速常用查询:
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), // 时间排序
}))什么时候需要索引?
频繁用于 WHERE、ORDER BY、JOIN 的列需要索引。id 主键自动有索引。索引会增加写入开销,不要给不常用的列加索引。
3. 分页查询
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. 批量操作
// ✅ 一次插入多条
await db.insert(users).values(userList)
// ❌ 循环单条插入
for (const user of userList) {
await db.insert(users).values(user) // N 次数据库往返
}前端性能优化
useFetch vs useLazyFetch
// 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。
懒加载组件
// Lazy 前缀自动懒加载
const LazyChart = resolveComponent('LazyChartComponent')
// 或在模板中
// <LazyChartComponent v-if="showChart" />什么时候用懒加载?
体积大或不是首屏需要的组件——如图表、富文本编辑器、弹窗内容。
服务端性能
1. 并行查询
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。生产环境建议调大:
const client = postgres(config.dbUrl, {
max: 20, // 最大连接数
idle_timeout: 20, // 空闲连接 20 秒后释放
connect_timeout: 10, // 连接超时 10 秒
})连接数怎么设置?
经验公式:max = CPU 核心数 * 2 + 磁盘数。4 核服务器可以设 20。同时要确保 PostgreSQL 的 max_connections 大于所有应用实例的连接池大小之和。
3. 后台任务
export default defineEventHandler((event) => {
event.waitUntil(logActivity()) // 不阻塞响应
return { status: 'ok' }
})event.waitUntil() 让任务在响应发送后继续执行,不增加响应时间。适合日志记录、缓存更新、通知发送等非关键操作。
常见错误
把 waitUntil 用于关键业务逻辑(如扣款),可能导致响应成功但后台任务失败,数据不一致。关键逻辑必须在响应前完成。