犀牛鸟 2026 研究笔记 estelledc.github.io

案例 #1761:创建共享空间后列表不刷新 — Pinia TTL 缓存失效

PR #1761 | 作者 hiasky (Anky) | 2026-06-22 合入 | 1 文件 +3/-0

结论先行

用户创建共享空间后,列表页面不显示新空间,必须手动刷新。根因是 Pinia store 的 fetchOrganizations() 内置了 60 秒 TTL 缓存:创建成功后的回调虽然调了 fetchOrganizations(),但因为距上次加载不到 60 秒,请求被缓存守卫直接跳过,前端拿到的还是旧数据。修复方式极简——在 create() 成功后将缓存时间戳 organizationsLoadedAt 重置为 0,让下一次 fetchOrganizations() 必定穿透缓存。3 行代码,0 副作用,直接合入。

这个模式对 #1248 有直接参考价值:用户点赞/点踩后,如果前端也依赖类似的 TTL 缓存来管理消息或反馈状态,就会遇到同样的「操作成功但 UI 不更新」问题。


1. 问题现象(What)

复现路径:

  1. 进入共享空间列表页(此时 fetchOrganizations() 正常加载列表)
  2. 点击「创建」,填写空间名称并提交
  3. 后端返回创建成功,前端弹出成功提示
  4. 列表页面没有出现新创建的空间
  5. 手动按 F5 刷新页面后,新空间才出现

用户感知:「我明明创建成功了,但列表里看不到。」这是典型的前端状态同步 bug——后端状态已变更,前端缓存还在返回旧数据。

修复前 / 修复后

观测 修复前 修复后
创建 API 201 成功 201 成功
列表 UI 仍显示旧列表(TTL 拦截 fetch) 立即出现新空间
用户操作 必须 F5 刷新 无需手动刷新

定位步骤

rg "organizationsLoadedAt|fetchOrganizations" frontend/src/stores/organization.ts
rg "fetchOrganizations|create" frontend/src/views/organization/

阅读顺序organization.tscreate()fetchOrganizations() 的 TTL 守卫 → OrganizationList.vue 的 mount 调用链。


2. 根因分析(Why)

类比

想象你去图书馆查书目。管理员每分钟更新一次书目显示屏。你在 10 秒前刚看过屏幕(「没有《新书》」),然后你向管理员捐了一本《新书》。你转头再看显示屏——还是没有。因为显示屏的刷新周期是 60 秒,你的捐书操作虽然已经入库,但显示屏还在展示 50 秒前的快照。解决方法很简单:捐完书后告诉管理员「请立即刷新显示屏」。

技术机制

WeKnora 前端的 organization.ts store 中,fetchOrganizations() 不是每次调用都发请求——它有一个 TTL(Time-To-Live)缓存守卫:

// 简化后的逻辑(organization.ts)
const organizationsLoadedAt = ref(0)
const TTL = 60_000 // 60 秒

async function fetchOrganizations() {
  // 缓存守卫:如果距上次加载不到 60 秒,直接返回
  if (Date.now() - organizationsLoadedAt.value < TTL) {
    return  // ← 创建成功后的调用在这里被拦截
  }
  const data = await api.getOrganizations()
  organizations.value = data
  organizationsLoadedAt.value = Date.now()
}

创建空间的调用链如下:

sequenceDiagram
    participant User as 用户
    participant UI as 列表页 (OrganizationList.vue)
    participant Store as Pinia Store (organization.ts)
    participant API as 后端 API

    User->>UI: 打开列表页
    UI->>Store: fetchOrganizations()
    Store->>API: GET /organizations
    API-->>Store: 返回列表 [A, B]
    Store->>Store: organizationsLoadedAt = Date.now()
    Store-->>UI: 渲染 [A, B]

    Note over User,API: 用户在 10 秒内创建新空间

    User->>UI: 点击创建,提交「空间 C」
    UI->>Store: create({ name: "空间 C" })
    Store->>API: POST /organizations
    API-->>Store: 201 Created ✓
    Store->>Store: handleSettingsSaved()
    Store->>Store: fetchOrganizations()
    Store->>Store: Date.now() - loadedAt < 60s → 跳过!
    Store-->>UI: 列表仍为 [A, B] ← 用户看不到 C

关键文件:

文件 角色
frontend/src/stores/organization.ts Pinia store,管理组织列表的获取、创建、缓存
frontend/src/views/organization/OrganizationList.vue 列表视图,onMounted 调用 fetchOrganizations()

问题本质是 TTL 缓存与写操作的一致性冲突。TTL 缓存适用于「数据不常变」的场景——比如组织列表在大部分时间是稳定的。但当当前用户自己执行了写操作,这个缓存假设就失效了,必须主动使其失效。


3. 解决方案(How)

3 行代码的精确修复

organization.tscreate() 方法中,API 调用成功后、fetchOrganizations() 调用前,将 organizationsLoadedAt 重置为 0

// organization.ts — create() 方法(修复后)
async function create(params: CreateOrgParams) {
  const result = await api.createOrganization(params)

  // ↓ 新增 3 行:使缓存失效
  organizationsLoadedAt.value = 0

  await fetchOrganizations()  // 现在一定会穿透缓存
  return result
}

修复后的时序:

sequenceDiagram
    participant User as 用户
    participant UI as 列表页
    participant Store as Pinia Store
    participant API as 后端 API

    User->>UI: 点击创建,提交「空间 C」
    UI->>Store: create({ name: "空间 C" })
    Store->>API: POST /organizations
    API-->>Store: 201 Created ✓
    Store->>Store: organizationsLoadedAt = 0(缓存失效)
    Store->>Store: fetchOrganizations()
    Store->>Store: Date.now() - 0 > 60s → 穿透!
    Store->>API: GET /organizations
    API-->>Store: 返回列表 [A, B, C]
    Store-->>UI: 渲染 [A, B, C] ✓

为什么是重置 TTL 而不是其他方案

其他候选方案的对比:

方案 做法 问题
移除 TTL 缓存 删掉 organizationsLoadedAt 逻辑 破坏性改动。TTL 缓存存在是有理由的——避免组件反复 mount 时重复请求。移除它会引入性能回退
fetchOrganizations()force 参数 fetchOrganizations(force = true) 跳过守卫 可行,但改动更大——需要修改函数签名,所有调用方都要审查是否需要传 force。而且 create() 方法内部已经知道”我刚创建了新数据”,在这里重置时间戳是最局部化的修复
乐观更新 create() 成功后手动把新空间 push 到列表 更复杂。需要构造一个与 API 返回格式完全一致的临时对象,还要处理排序、分页等边界情况。收益不明显——组织列表不是高频操作
重置 TTL 时间戳(本 PR) organizationsLoadedAt = 0 最小改动,不改函数签名,不影响其他调用方,精确解决问题

重置时间戳之所以优雅,是因为它复用了已有的缓存基础设施。不需要引入新概念、新参数,只是在恰当的时机戳破缓存。


4. Review 与合入(Maintainer 视角)

这个 PR 无 review 评论,直接合入。可以从几个角度理解为什么:

改动极小且局部化:+3/-0,只在 create() 方法中加了一行赋值。不改函数签名、不改数据结构、不涉及其他文件。Maintainer 审查这种改动只需要确认两件事——赋值位置对不对、值对不对。

根因和修复一一对应:PR 描述清晰地说明了「缓存拦截」的根因,修复逻辑是对该根因的直接回应。没有绕路,没有附带重构。

风险可控:将 organizationsLoadedAt 设为 0 的最坏情况是多发一次 GET 请求——这对用户体验和服务器负载都没有实质影响。

作者已验证:PR 提供了明确的测试步骤(创建 → 验证列表立即出现),表明作者已经手动验证过修复效果。

对于新贡献者来说,这种 PR 的特征值得记住:改动越小越聚焦,合入阻力越低。


5. 可复用模式(Pattern)

模式名称:写操作后 TTL 缓存失效(Write-Through Cache Invalidation)

适用场景:前端 store 对某个列表/资源使用了 TTL 缓存(通过时间戳或标志位控制),用户执行了创建/更新/删除操作后,需要立即刷新该资源。

实现步骤

  1. 找到缓存守卫变量(如 xxxLoadedAtisCacheValid
  2. 在写操作的 await api.xxxAction() 成功之后
  3. 在重新获取数据的 fetchXxx() 调用之前
  4. 将缓存守卫变量重置为初始值(时间戳设为 0,布尔值设为 false

伪代码模板

async function mutateResource(params) {
  await api.mutate(params)           // 写操作
  resourceLoadedAt.value = 0         // 使缓存失效
  await fetchResource()              // 重新获取(现在一定穿透)
}

决策点:如果写操作是高频的(比如用户快速连续点赞),应考虑乐观更新而非每次重新请求。如果写操作是低频的(比如创建组织),TTL 重置是最简方案。


6. 常见误区

误区一:「我调了 fetch 就一定会发请求」

错误认知:在 create() 成功后调用了 fetchOrganizations(),数据就一定会更新。

正确理解:如果 fetch 函数内部有缓存守卫(TTL、标志位、防抖),调用 ≠ 执行。必须检查 fetch 内部是否有 early return 的分支。在 code review 中看到「写操作后调了 fetch」不等于「数据一定刷新了」。

误区二:「缓存失效应该在 fetch 内部处理」

错误认知:应该给 fetchOrganizations() 加一个 force 参数或特殊标记来跳过缓存。

正确理解:两种方式都能解决问题,但职责归属不同。fetch 函数的职责是「高效地获取数据」,缓存守卫是它的内部策略。而「我刚修改了数据,缓存已过时」是调用方(create())才知道的上下文信息。让调用方在调用前重置缓存,比让 fetch 函数暴露内部细节更符合单一职责原则。

误区三:「用 watchcomputed 就不会有这个问题」

错误认知:如果用 Vue 的响应式系统自动追踪变更,就不需要手动管理缓存。

正确理解:watchcomputed 追踪的是已在 store 中的响应式数据变更。问题在于数据还没进入 store——create() 成功后新数据在后端,store 里的列表还是旧的,fetchOrganizations() 本应去拿新数据但被缓存拦住了。响应式系统不能解决「数据还没到达」的问题。

误区四:「TTL 缓存只影响性能,不会造成功能 bug」

错误认知:60 秒 TTL 只是少发几次 GET,最坏情况是多等一会儿数据才更新。

正确理解:TTL 假设 列表在窗口期内不变;用户自己的 写操作 打破这一假设。写成功后必须 主动失效 缓存(本 PR:organizationsLoadedAt = 0),否则 UI 与后端长期不一致。


7. 思考点

问题 1:假设你要为 #1248 实现点赞功能——用户在对话气泡上点赞后,赞的状态需要立即反映在 UI 上。如果消息列表的 store 也使用了类似的 TTL 缓存,你会选择「重置 TTL + 重新请求」还是「乐观更新」?为什么?提示:考虑操作频率(用户可能快速连续点赞多条消息)和数据量(重新请求整个消息列表 vs 只更新一条消息的状态)。

问题 2:这个 PR 只处理了 create() 的缓存失效。如果 organization.ts 还有 update()delete() 方法,它们是否也需要同样的缓存失效逻辑?如果需要,你会在每个方法里重复写 organizationsLoadedAt = 0,还是抽象出一个 invalidateCache() 辅助函数?各自的 trade-off 是什么?

问题 3:如果 TTL 从 60 秒改成 0 秒(等于没有缓存),这个 bug 就不会出现。那为什么 WeKnora 要加 TTL 缓存?在什么场景下 TTL 缓存是有价值的?


8. 延伸阅读