29 KiB
29 KiB
缓存策略落地专项重构设计文档
| 字段 | 值 |
|---|---|
| 文档版本 | v1 |
| 创建日期 | 2026-07-05 |
| 作者 | Trae 协作生成 |
| 状态 | 待用户审查 |
| 范围 | 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范) |
| 标杆模块 | classes |
1. 背景与现状
1.1 项目缓存现状
| 层 | 现状 | 问题 |
|---|---|---|
| 服务端数据缓存 | 仅用 react.cache(请求级 memoization,30 个 data-access 文件)+ revalidatePath(路径级失效) |
未用 unstable_cache / revalidateTag / Redis 数据缓存;多实例下无跨实例共享 |
| 客户端缓存 | TanStack Query V5 基础设施已建(createQueryClient 默认 staleTime 30s/retry 1) |
useActionQuery / useActionMutation 用 useEffect + useState 自实现,绕过 QueryClient;30+ 组件直接 useQuery,缺乏统一 queryKey 约定 |
| 失效编排 | revalidatePath 散落在 30+ actions 文件 | 易漏失效;无集中审计点 |
| 单测可 mock 性 | data-access 函数直接 export 缓存包装版本 | 难以绕过缓存层 mock(架构文档 P2 待办) |
1.2 已有可复用资产
| 资产 | 文件 | 复用方式 |
|---|---|---|
| Redis driver 切换模式 | shared/lib/rate-limit/{index.ts,redis-limiter.ts,memory-limiter.ts,types.ts} |
缓存层同构复用 CACHE_DRIVER=memory|redis + 动态 import + webpackIgnore |
| Upstash Redis 类型声明 | shared/lib/rate-limit/upstash-modules.d.ts |
缓存层直接复用,避免重复声明 |
| TanStack Query 工厂 | shared/lib/query-client.ts |
不动,新增 queryKey 工厂与之配合 |
useActionQuery / useActionMutation Hook |
shared/hooks/use-action-{query,mutation}.ts |
重构内部实现,对外 API 调整(破坏性变更,需迁移消费方) |
| env 校验 | src/env.mjs |
新增 CACHE_DRIVER 字段 |
1.3 部署形态约束
- 多实例部署(PM2 cluster / Kubernetes 多 Pod)
- 必须支持跨实例共享缓存 → 引入 Redis 后端
- 复用
UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN环境变量
2. 目标与非目标
2.1 目标
- 建立
shared/lib/cache/数据缓存基础设施,支持memory/redis双 driver 切换 - 建立
shared/lib/query-keys.ts客户端 queryKey 工厂,集中化命名 - 建立
shared/lib/cache/invalidation-map.ts集中式失效映射表 - 重构
useActionQuery/useActionMutation接入 QueryClient + 自动 invalidate - 完成 classes 标杆模块全场景迁移(6 data-access + 5 actions)
- 引入 ESLint 规则强制使用
invalidateFor,禁止直接调用revalidatePath/revalidateTag - 同步架构文档(004 / 005 / known-issues)
2.2 非目标
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
- 其他客户端组件迁移到 useQuery(下期按页面推进)
- L1+L2 两级缓存(YAGNI,先用单层 Redis 验证)
- 缓存预热 / SWR 主动刷新(YAGNI)
- 缓存命中率监控埋点(YAGNI)
- 现有
react.cache全量替换(保留作为请求级 memoization 内层)
3. 架构设计
3.1 模块边界与文件布局
src/shared/lib/
├─ cache/ # 新增:数据缓存基础设施
│ ├─ index.ts # 公共 API:cacheFn / invalidateFor / getCacheStore
│ ├─ types.ts # CacheFnOptions / CacheStore / InvalidationRule 接口
│ ├─ memory-store.ts # 内存 LRU 实现(默认,单实例降级用)
│ ├─ redis-store.ts # Redis 实现(多实例,复用 @upstash/redis)
│ ├─ store-factory.ts # 按 CACHE_DRIVER 动态加载(webpackIgnore)
│ ├─ cache-fn.ts # cacheFn(fn, { tags, ttl, keyParts }) 包装器
│ ├─ invalidation-map.ts # mutation → { tags, queryKeys, paths } 集中映射表
│ ├─ client-invalidation-map.ts # 仅 queryKeys 子集,供客户端 bundle 使用
│ ├─ invalidate.ts # invalidateFor(actionId, params) 编排函数
│ └─ upstash-modules.d.ts # 复用 rate-limit 已有的 @upstash/redis 类型声明(提升至 shared/lib/,旧路径保留 re-export shim)
├─ query-keys.ts # 新增:queryKey 工厂(按 模块/资源/操作 分层)
├─ redis-client.ts # 新增:共享 Redis 单例(rate-limit + cache 共用)
└─ rate-limit/ # 既有:速率限制(仅 redis-limiter.ts 改为引用 redis-client.ts)
src/shared/hooks/
├─ use-action-query.ts # 重构:内部改走 useQuery(QueryClient)
└─ use-action-mutation.ts # 重构:内部改走 useMutation + 自动 invalidate
3.2 依赖方向(严格遵循三层架构)
app/(dashboard)/.../page.tsx
↓ 调用
modules/[module]/actions.ts → 调用 invalidateFor("classes.update", { id }) 编排失效
↓ 调用
modules/[module]/data-access.ts → 用 cacheFn(fn, { tags, ttl, keyParts }) 包装查询
↓ 调用
shared/lib/cache/ # 缓存基础设施
↓ 内部
shared/lib/redis-client.ts # 复用 rate-limit 的 @upstash/redis 客户端
3.3 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
CACHE_DRIVER |
memory |
memory|redis,对标 RATE_LIMIT_DRIVER |
UPSTASH_REDIS_REST_URL |
- | 复用 rate-limit 已有变量 |
UPSTASH_REDIS_REST_TOKEN |
- | 复用 rate-limit 已有变量 |
3.4 故障降级策略
- Redis 不可用:catch 错误 →
console.error+ 透传到原函数(直查 DB)→ 不阻断主流程 - 与 rate-limit 模块的 "fail-open 限流降级" 策略一致
4. 服务端缓存层详细设计
4.1 cacheFn API
// shared/lib/cache/types.ts
export interface CacheFnOptions {
/** 缓存标签,用于按 tag 失效(必填,至少 1 个) */
tags: readonly string[]
/** TTL 秒数;不传则永久缓存(仅靠 tag 失效) */
ttl?: number
/** 自定义 key 段,默认根据 fn.name + 参数 JSON 自动生成 */
keyParts?: readonly unknown[]
}
// shared/lib/cache/cache-fn.ts
export function cacheFn<T extends (...args: any[]) => Promise<unknown>>(
fn: T,
options: CacheFnOptions
): T
4.2 调用示例(classes data-access)
// 修改前
export const getClassById = cache(async (id: string) => {
return db.query.classes.findFirst({ where: eq(classes.id, id) })
})
// 修改后
export const getClassByIdRaw = async (id: string) => {
return db.query.classes.findFirst({ where: eq(classes.id, id) })
}
export const getClassById = cacheFn(getClassByIdRaw, {
tags: ["classes:detail", "classes:detail:{id}"],
ttl: 300,
keyParts: ["classes", "by-id"],
})
4.3 双层缓存包装
import { cache as reactCache } from "react"
export const cacheFn = <T>(fn: T, options: CacheFnOptions): T => {
// 外层:react.cache 提供请求级 memoization(同一 RSC 请求内去重)
// 内层:cacheStore.getOrSet 提供跨请求/跨实例数据缓存
return reactCache(((...args: any[]) => {
return cacheStore.getOrSet(buildKey(fn, args, options), () => fn(...args), options)
}) as T)
}
双层职责:
react.cache:请求内去重(同请求多次调用只查 1 次 DB)—— 现有行为cacheStore:跨请求/跨实例数据缓存 —— 新增能力
4.4 CacheStore 接口
// shared/lib/cache/types.ts
export interface CacheStore {
getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number }
): Promise<T>
invalidateTags(tags: readonly string[]): Promise<void>
}
4.5 两种实现
memory-store.ts(默认):
Map<string, { value, expireAt, tags }>+ LRU 淘汰(maxEntries=500)- 适用于单实例 dev/test
redis-store.ts(多实例):
- 复用
shared/lib/redis-client.ts单例 - key 格式:
next-edu:cache:{keyParts hash} - value:
JSON.stringify - tag 反查索引:
next-edu:cache:tag:{tag}→ Redis Set 存放 keys - TTL:
redis EX原子设置 invalidateTags:SMEMBERS→DEL批量删除
4.6 redis-client.ts 共享单例
// shared/lib/redis-client.ts
// rate-limit 与 cache 共用的 Redis 客户端单例
// 复用 env.UPSTASH_REDIS_REST_URL / TOKEN
// 动态 import + webpackIgnore(与 redis-limiter.ts 同模式)
// 故障降级:返回 null,调用方走 fail-open
改造 redis-limiter.ts:将 getRedisClient() 抽出至 redis-client.ts,redis-limiter.ts 改为 import { getRedisClient } from "@/shared/lib/redis-client"。rate-limit/index.ts 单例选择逻辑不变。
4.7 store-factory.ts driver 切换
let singleton: CacheStore | null = null
export async function getCacheStore(): Promise<CacheStore> {
if (singleton) return singleton
if (env.CACHE_DRIVER === "redis") {
const { RedisCacheStore } = await import("./redis-store")
singleton = new RedisCacheStore()
} else {
singleton = new MemoryCacheStore()
}
return singleton
}
4.8 Tag 命名规范
| 格式 | 示例 | 失效场景 |
|---|---|---|
{module} |
classes, grades, users |
模块任意写操作全模块失效 |
{module}:{resource} |
classes:list, classes:detail, classes:stats |
资源级失效 |
{module}:{resource}:{id} |
classes:detail:cls_abc123 |
单条记录失效 |
classes 模块约定的 tags:
classes—— 任意写操作全模块失效classes:detail+classes:detail:{id}—— 单条详情失效classes:students:{classId}—— 学生列表失效classes:schedule:{classId}—— 课表失效classes:stats—— 聚合统计失效classes:invitations:{classId}—— 邀请码失效
5. 失效编排与客户端缓存
5.1 集中式失效映射表
// shared/lib/cache/invalidation-map.ts
export interface InvalidationRule {
readonly tags: readonly string[]
readonly queryKeys: readonly (readonly (string | number)[])[]
readonly paths: readonly string[]
}
export const INVALIDATION_MAP = {
// ===== classes 模块 =====
"classes.create": {
tags: ["classes", "classes:list"],
queryKeys: [["classes", "list"]],
paths: ["/teacher/classes/my", "/admin/classes"],
},
"classes.update": {
tags: ["classes", "classes:detail", "classes:detail:{id}"],
queryKeys: [["classes", "detail"], ["classes", "list"]],
// paths 不含动态段:revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由)
paths: ["/teacher/classes/my", "/admin/classes"],
},
"classes.delete": {
tags: ["classes", "classes:list", "classes:detail:{id}"],
queryKeys: [["classes", "list"], ["classes", "detail"]],
paths: ["/teacher/classes/my", "/admin/classes"],
},
"classes.students.update": {
tags: ["classes:students:{classId}"],
queryKeys: [["classes", "students"]],
paths: ["/teacher/classes/my"],
},
"classes.schedule.update": {
tags: ["classes:schedule:{classId}"],
queryKeys: [["classes", "schedule"]],
paths: ["/teacher/classes/schedule"],
},
"classes.grade.update": {
tags: ["classes"],
queryKeys: [["classes", "list"]],
paths: [],
},
"classes.invitation.create": {
tags: ["classes:invitations:{classId}"],
queryKeys: [["classes", "invitations"]],
paths: ["/teacher/classes/my/{classId}"],
},
"classes.invitation.revoke": {
tags: ["classes:invitations:{classId}"],
queryKeys: [["classes", "invitations"]],
paths: ["/teacher/classes/my/{classId}"],
},
} as const satisfies Record<string, InvalidationRule>
5.2 invalidateFor 编排函数
// shared/lib/cache/invalidate.ts
/**
* 由 Server Action 在写操作成功后调用,集中编排缓存失效。
*
* 三件事:
* 1. cacheStore.invalidateTags(服务端数据缓存失效)
* 2. revalidateTag × N(Next.js fetch 缓存 + unstable_cache 失效)
* 3. revalidatePath × N(RSC 静态缓存失效)
*
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
*/
export async function invalidateFor(
actionId: string,
params: Record<string, string> = {}
): Promise<void> {
const rule = INVALIDATION_MAP[actionId]
if (!rule) {
throw new Error(`[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`)
}
// 1. 服务端数据缓存失效(Redis / 内存)
const resolvedTags = rule.tags.map((t) => fillTemplate(t, params))
const store = await getCacheStore()
await store.invalidateTags(resolvedTags)
// 2. Next.js fetch 缓存 + unstable_cache 失效
resolvedTags.forEach((tag) => revalidateTag(tag))
// 3. 路径级 RSC 缓存失效
rule.paths.forEach((p) => revalidatePath(fillTemplate(p, params)))
}
5.3 actions 层调用范式(classes 标杆)
// modules/classes/actions-teacher.ts(重构后)
"use server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { invalidateFor } from "@/shared/lib/cache"
import { updateClassInScope } from "../data-access-teacher"
export async function updateClassAction(
input: UpdateClassInput
): Promise<ActionState<{ classId: string }>> {
await requirePermission(Permission.CLASS_MANAGE)
const result = await updateClassInScope(input)
// 一行代替原来 3-5 行 revalidatePath 散乱调用
await invalidateFor("classes.update", { id: result.classId })
return { success: true, data: { classId: result.classId } }
}
5.4 客户端 queryKey 工厂
// shared/lib/query-keys.ts
export const queryKeys = {
classes: {
all: ["classes"] as const,
lists: () => [...queryKeys.classes.all, "list"] as const,
list: (filters: ClassFilters) => [...queryKeys.classes.lists(), filters] as const,
details: () => [...queryKeys.classes.all, "detail"] as const,
detail: (id: string) => [...queryKeys.classes.details(), id] as const,
students: (classId: string) => [...queryKeys.classes.all, "students", classId] as const,
schedule: (classId: string) => [...queryKeys.classes.all, "schedule", classId] as const,
stats: (classId: string) => [...queryKeys.classes.all, "stats", classId] as const,
invitations: (classId: string) => [...queryKeys.classes.all, "invitations", classId] as const,
},
// 后续模块按此模式扩展...
} as const
export type QueryKeys = typeof queryKeys
5.5 useActionQuery 重构
// shared/hooks/use-action-query.ts(重构后)
"use client"
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import type { ActionState } from "@/shared/types/action-state"
export interface UseActionQueryOptions<T> extends Omit<
UseQueryOptions<ActionState<T>>,
"queryKey" | "queryFn"
> {
/** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */
queryKey: readonly (string | number | object)[]
/** 是否启用,默认 true */
enabled?: boolean
}
export function useActionQuery<T>(
action: () => Promise<ActionState<T>>,
options: UseActionQueryOptions<T>
): { data: T | undefined; loading: boolean; error: Error | null; refetch: () => void } {
const query = useQuery({
queryKey: options.queryKey,
queryFn: action,
enabled: options.enabled ?? true,
...options,
})
// 解包 ActionState:失败时抛错,成功时返回 data
const data = query.data?.success ? query.data.data : undefined
const error = query.error ?? (query.data && !query.data.success
? new Error(query.data.message ?? "Action failed")
: null)
return {
data,
loading: query.isLoading,
error,
refetch: () => { void query.refetch() },
}
}
破坏性变更:useActionQuery 必须传 queryKey(之前不需要)。强制集中化 queryKey 是本次重构的核心目标。
5.6 useActionMutation 重构
// shared/hooks/use-action-mutation.ts(重构后)
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { ActionState } from "@/shared/types/action-state"
import { notify } from "@/shared/lib/notify"
import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map"
export interface UseActionMutationOptions<T> {
/** mutationFn */
mutationFn: () => Promise<ActionState<T>>
/** 关联的 INVALIDATION_MAP actionId,成功后自动失效相关 queryKey */
actionId?: string
/** 模板参数,如 { id: "cls_123" } */
params?: Record<string, string>
/** 显式额外失效的 queryKey(不通过 actionId 时使用) */
invalidateQueryKeys?: readonly (readonly (string | number | object)[])[]
successMessage?: string | false
errorMessage?: string | false
onSuccess?: (data: T | undefined) => void
onError?: (error: unknown) => void
}
export function useActionMutation<T = unknown>(options: UseActionMutationOptions<T>) {
const queryClient = useQueryClient()
const mutation = useMutation<ActionState<T>, Error, void>({
mutationFn: options.mutationFn,
onError: (error) => {
if (options.errorMessage !== false) {
notify.error(options.errorMessage ?? error.message)
}
options.onError?.(error)
},
onSuccess: async (result) => {
if (result.success) {
if (options.successMessage !== false) {
notify.success(options.successMessage ?? result.message ?? "Operation succeeded")
}
// 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP 的客户端 queryKey 列表
const queryKeysToInvalidate = resolveClientInvalidations(options.actionId, options.params)
await Promise.all(
queryKeysToInvalidate.map((qk) => queryClient.invalidateQueries({ queryKey: qk }))
)
options.onSuccess?.(result.data)
} else {
if (options.errorMessage !== false) {
notify.error(options.errorMessage ?? result.message ?? "Operation failed")
}
options.onError?.(new Error(result.message ?? "Action returned failure"))
}
},
})
return { isWorking: mutation.isPending, mutate: mutation.mutateAsync }
}
5.7 客户端可见的失效映射子集
// shared/lib/cache/client-invalidation-map.ts
/**
* 仅客户端 queryKey 失效映射(INVALIDATION_MAP 的子集)。
* 从 invalidation-map.ts 提取 queryKeys 字段,避免客户端 bundle 拉入 revalidateTag 等 server-only。
*/
export const CLIENT_INVALIDATION_MAP = {
"classes.create": { queryKeys: [["classes", "list"]] },
"classes.update": { queryKeys: [["classes", "detail"], ["classes", "list"]] },
"classes.delete": { queryKeys: [["classes", "list"], ["classes", "detail"]] },
"classes.students.update": { queryKeys: [["classes", "students"]] },
"classes.schedule.update": { queryKeys: [["classes", "schedule"]] },
"classes.grade.update": { queryKeys: [["classes", "list"]] },
"classes.invitation.create": { queryKeys: [["classes", "invitations"]] },
"classes.invitation.revoke": { queryKeys: [["classes", "invitations"]] },
} as const
5.8 ESLint 强制规则
// eslint.config.js(节选)
{
files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.name='revalidatePath']",
message: "使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节",
},
{
selector: "CallExpression[callee.name='revalidateTag']",
message: "使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节",
},
],
},
// 豁免:shared/lib/cache/ 内部允许调用
overrides: [{
files: ["src/shared/lib/cache/**/*.ts"],
rules: { "no-restricted-syntax": "off" },
}],
}
6. classes 标杆迁移
6.1 data-access 迁移清单
| 文件 | 当前 react.cache 用法 | 迁移后 cacheFn 用法 |
|---|---|---|
data-access-teacher.ts |
cache(getClassesByTeacherId) 等 |
cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 }) |
data-access-admin.ts |
cache(getAllClassesForAdmin) |
cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 }) |
data-access-students.ts |
cache(getStudentsByClass) |
cacheFn(fn, { tags: ["classes:students:{classId}"], ttl: 60 }) |
data-access-stats.ts |
cache(getClassStats) |
cacheFn(fn, { tags: ["classes:stats"], ttl: 60 }) |
data-access-schedule.ts |
cache(getScheduleByClass) |
cacheFn(fn, { tags: ["classes:schedule:{classId}"], ttl: 600 }) |
data-access.ts |
共享类型与行映射 | 不动(无 cache 调用) |
6.2 TTL 策略
| 资源 | TTL | 理由 |
|---|---|---|
列表/详情(classes, classes:detail) |
300s | 容忍 5 分钟滞后,写后 tag 立即失效 |
学生名单(classes:students:{classId}) |
60s | 转班/调班需较快感知 |
课表(classes:schedule:{classId}) |
600s | 课表变更频率极低 |
统计(classes:stats) |
60s | 聚合数据短 TTL 兜底 |
邀请码(classes:invitations:{classId}) |
300s | 与列表一致 |
6.3 actions 迁移清单
| 文件 | 当前 revalidatePath | 迁移后 |
|---|---|---|
actions-teacher.ts |
revalidatePath("/teacher/classes/my") 等 |
invalidateFor("classes.update", { id }) |
actions-admin.ts |
多处 revalidatePath | invalidateFor("classes.create/update/delete") |
actions-grade.ts |
revalidatePath("/admin/grades") |
invalidateFor("classes.grade.update") |
actions-invitations.ts |
多处 | invalidateFor("classes.invitation.create/revoke") |
actions-schedule.ts |
多处 | invalidateFor("classes.schedule.update") |
6.4 双导出模式(修复 P2 单测可 mock 性)
// data-access-teacher.ts
export const getClassByIdRaw = async (id: string) => {
return db.query.classes.findFirst({ where: eq(classes.id, id) })
}
export const getClassById = cacheFn(getClassByIdRaw, {
tags: ["classes:detail", "classes:detail:{id}"],
ttl: 300,
keyParts: ["classes", "by-id"],
})
测试时 import getClassByIdRaw 直接 mock,绕过 cacheStore。
6.5 客户端组件迁移示范
// app/(dashboard)/teacher/classes/my/[id]/page.tsx(重构后)
"use client"
import { useQuery } from "@tanstack/react-query"
import { queryKeys } from "@/shared/lib/query-keys"
import { getClassDetailAction } from "@/modules/classes/actions-teacher"
export default function ClassDetailPage({ params }: { params: { id: string } }) {
// 之前:手动 useState + useEffect + Action().then()
// 现在:声明式 useQuery,自动跨页共享缓存
const { data: classDetail, isLoading } = useQuery({
queryKey: queryKeys.classes.detail(params.id),
queryFn: () => getClassDetailAction({ classId: params.id }),
})
// ...
}
互操作验证:当 useActionMutation({ actionId: "classes.update" }) 成功后,queryClient.invalidateQueries({ queryKey: ["classes", "detail"] }) 自动触发 refetch,无需在 onSuccess 中手写 router.refresh()。
7. 测试策略
| 类型 | 文件 | 验证目标 |
|---|---|---|
| 单元 | cache/memory-store.test.ts |
LRU 淘汰、TTL 过期、tag 索引维护 |
| 单元 | cache/redis-store.test.ts |
mock Redis 客户端,验证 key 格式、SMEMBERS+DEL 批量删除 |
| 单元 | cache/cache-fn.test.ts |
react.cache + cacheStore 双层包装;keyParts 生成;tags 传递 |
| 单元 | cache/invalidation-map.test.ts |
模板填充 {id} 占位符;未知 actionId 抛错 |
| 集成 | cache/invalidate.test.ts |
invalidateFor 三步编排顺序(store.invalidateTags → revalidateTag → revalidatePath) |
| 集成 | hooks/use-action-query.test.tsx |
QueryClient 集成;queryKey 失效后自动 refetch |
| 集成 | hooks/use-action-mutation.test.tsx |
actionId 关联自动 invalidate;显式 invalidateQueryKeys 回退 |
| 标杆 | modules/classes/**/*.test.ts |
现有测试不破(仅替换 cache 包装) |
8. 架构文档同步
修改完成后同步以下文档(项目规则强制):
| 文档 | 修改内容 |
|---|---|
docs/architecture/004_architecture_impact_map.md |
新增"缓存基础设施"章节(位于 rate-limit 章节后);更新 classes 模块章节(标注 cacheFn 包装) |
docs/architecture/005_architecture_data.json |
shared.lib.cache.* 节点;classes 模块的 cacheUsage 字段;INVALIDATION_MAP 节点 |
docs/troubleshooting/known-issues.md |
新增"缓存策略规则"章节(cacheFn 用法、invalidateFor 强制、queryKey 工厂强制) |
9. 验收标准
| 标准 | 验证方法 |
|---|---|
npm run lint 零错误 |
含新增 ESLint 规则 |
npx tsc --noEmit 零错误 |
含 cacheFn 类型推导 |
| classes 模块 6 个 data-access 全部 cacheFn 包装 | grep 验证 |
| classes 模块 5 个 actions 全部用 invalidateFor | ESLint 验证零 revalidatePath 直接调用 |
| classes 模块新增 2+ 单测通过 | npm test cache memory-store |
| Redis 模式下多实例缓存共享验证 | 手动:CACHE_DRIVER=redis 启动两实例,A 实例写后 B 实例读取验证 |
| 架构文档同步 | git diff 验证 004/005/known-issues 三个文件已更新 |
| rate-limit 模块迁移至 redis-client.ts 共享 | 现有 rate-limit 单测不破 |
10. 风险与缓解
| 风险 | 缓解 |
|---|---|
useActionQuery 破坏性变更影响 11 个文件 |
本期仅迁移 classes 标杆页面;其他文件保持 useEffect 模式待下期;TypeScript 编译会立即提示漏传 queryKey |
useActionMutation 破坏性变更影响 50+ 文件 |
提供 invalidateQueryKeys 显式回退入口,不强制传 actionId;旧调用方先迁移 onSuccess 的 toast 逻辑,actionId 后续补 |
| Redis 故障导致缓存穿透 | fail-open 策略:catch 错误 → 直查 DB;与 rate-limit 一致 |
| 多实例下 tag 失效延迟 | Redis pub/sub 暂不引入;依赖 tag 索引在 Redis 中立即可见(同实例内 revalidateTag 立即生效;跨实例依赖下次请求读取 Redis) |
| INVALIDATION_MAP 漏登记导致失效不全 | ESLint 规则 + 代码审查双重保障;新增写操作 PR 必须更新 INVALIDATION_MAP |
cacheFn 包装函数 this 绑定丢失 |
仅包装纯函数(data-access 不使用 this);签名约束 extends (...args) => Promise<unknown> |
11. 不在本期范围
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
- 其他客户端组件迁移到 useQuery(下期按页面推进)
- L1+L2 两级缓存(YAGNI,先用单层 Redis 验证)
- 缓存预热 / SWR 主动刷新(YAGNI)
- 缓存命中率监控埋点(YAGNI)
- Redis pub/sub 跨实例失效广播(YAGNI,单实例内 tag 失效已足够)
- 现有
react.cache全量替换(保留作为请求级 memoization 内层)
12. 后续工作(下期预告)
- 按模块推进其他 29 个模块的 data-access / actions 迁移
- 按页面推进客户端组件迁移到 useQuery
- 在 100+ 组件中替换
toast.success/error为notify.success/error(V5 状态管理专项已建) - 评估缓存命中率监控(Prometheus + Grafana)
- 评估引入 Redis pub/sub 实现跨实例实时失效广播(如跨实例失效延迟不可接受)
附录 A:与 rate-limit 模块的复用关系
| 资产 | rate-limit 现状 | cache 复用方式 |
|---|---|---|
RATE_LIMIT_DRIVER env |
已实现 | 新增 CACHE_DRIVER env,独立控制 |
@upstash/redis 动态 import |
redis-limiter.ts 内部 getRedisClient() |
抽出至 redis-client.ts,rate-limit + cache 共用 |
@upstash/ratelimit 动态 import |
redis-limiter.ts 内部 |
不复用,cache 不需要 ratelimit |
upstash-modules.d.ts |
rate-limit 目录内 | 提升至 shared/lib/upstash-modules.d.ts,rate-limit 与 cache 均从提升后的位置 import;旧路径保留 re-export shim 避免破坏现有 import |
| fail-open 策略 | catch → 兜底返回 allow | catch → 直查 DB(透传 producer) |
附录 B:queryKey 命名约定
格式:[module, resource, ...args]
queryKeys.classes.all // ["classes"]
queryKeys.classes.lists() // ["classes", "list"]
queryKeys.classes.list(filters) // ["classes", "list", filters]
queryKeys.classes.detail(id) // ["classes", "detail", id]
queryKeys.classes.students(classId) // ["classes", "students", classId]
queryKeys.classes.schedule(classId) // ["classes", "schedule", classId]
queryKeys.classes.stats(classId) // ["classes", "stats", classId]
queryKeys.classes.invitations(classId) // ["classes", "invitations", classId]
约定:
- 第一段:模块名(与
src/modules/[module]/一致) - 第二段:资源名(复数:list / detail / students / schedule / stats)
- 后续段:唯一标识(id / classId)或过滤条件对象
- 失效时使用前缀匹配:
invalidateQueries({ queryKey: ["classes", "detail"] })失效所有详情