Files
NextEdu/docs/superpowers/specs/2026-07-05-cache-strategy-design.md

29 KiB
Raw Permalink Blame History

缓存策略落地专项重构设计文档

字段
文档版本 v1
创建日期 2026-07-05
作者 Trae 协作生成
状态 待用户审查
范围 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范)
标杆模块 classes

1. 背景与现状

1.1 项目缓存现状

现状 问题
服务端数据缓存 仅用 react.cache(请求级 memoization30 个 data-access 文件)+ revalidatePath(路径级失效) 未用 unstable_cache / revalidateTag / Redis 数据缓存;多实例下无跨实例共享
客户端缓存 TanStack Query V5 基础设施已建(createQueryClient 默认 staleTime 30s/retry 1 useActionQuery / useActionMutation 用 useEffect + useState 自实现,绕过 QueryClient30+ 组件直接 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 目标

  1. 建立 shared/lib/cache/ 数据缓存基础设施,支持 memory / redis 双 driver 切换
  2. 建立 shared/lib/query-keys.ts 客户端 queryKey 工厂,集中化命名
  3. 建立 shared/lib/cache/invalidation-map.ts 集中式失效映射表
  4. 重构 useActionQuery / useActionMutation 接入 QueryClient + 自动 invalidate
  5. 完成 classes 标杆模块全场景迁移6 data-access + 5 actions
  6. 引入 ESLint 规则强制使用 invalidateFor,禁止直接调用 revalidatePath / revalidateTag
  7. 同步架构文档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                     # 公共 APIcacheFn / 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             # 重构:内部改走 useQueryQueryClient
└─ 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}
  • valueJSON.stringify
  • tag 反查索引:next-edu:cache:tag:{tag} → Redis Set 存放 keys
  • TTLredis EX 原子设置
  • invalidateTagsSMEMBERSDEL 批量删除

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.tsredis-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 × NNext.js fetch 缓存 + unstable_cache 失效)
 * 3. revalidatePath × NRSC 静态缓存失效)
 *
 * @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. 后续工作(下期预告)

  1. 按模块推进其他 29 个模块的 data-access / actions 迁移
  2. 按页面推进客户端组件迁移到 useQuery
  3. 在 100+ 组件中替换 toast.success/errornotify.success/errorV5 状态管理专项已建)
  4. 评估缓存命中率监控Prometheus + Grafana
  5. 评估引入 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.tsrate-limit + cache 共用
@upstash/ratelimit 动态 import redis-limiter.ts 内部 不复用cache 不需要 ratelimit
upstash-modules.d.ts rate-limit 目录内 提升至 shared/lib/upstash-modules.d.tsrate-limit 与 cache 均从提升后的位置 import旧路径保留 re-export shim 避免破坏现有 import
fail-open 策略 catch → 兜底返回 allow catch → 直查 DB透传 producer

附录 BqueryKey 命名约定

格式:[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"] }) 失效所有详情