TanStack Query Stale-Data Bug 复盘:Server Function 失效调用错误导致 12% 管理员用户看到过期 MRR 数据 4 小时

真实事故复盘:Server Function 突变调用 queryClient.invalidateQueries 时使用了错误的 key 形状,导致 12% 管理员用户在 4 小时内看到过期的 MRR 数据。TanStack Ship 强制执行的类型化 query-key factory 模式。

Huifer
Huifer
July 25, 20268 min read
其他语言:Deutsch · English

TL;DR: Server Function 突变调用 invalidateQueries 时使用了错误的 query key 形状,导致 12% 管理员用户在 4 小时内看到过期的 MRR 数据。根本原因:基于字符串的 query key 没有 factory,导致 user-scope 和 admin-scope 查询之间发生范围碰撞。修复:类型化 query-key factory + 在脏数据场景下会失败的 E2E 测试。Query key 是一份契约;要像对待数据库 schema 一样对待它们。


背景

TanStack Ship 中的管理后台 MRR 看板使用 TanStack Query 获取订阅收入数据。查询是带作用域的:管理员看到全局 MRR 数值;普通用户看到自己的订阅 MRR。

TanStack Query 将 query key 管理和查询数据分离。Key 是数组——[scope, ...args]。invalidateQueries 按前缀过滤 key。如果传错了前缀,就会使错误的查询失效,或者根本没有任何查询失效。

2026-03-14,我上线了 credit-system 功能——一个新的修改订阅积分余额的 Server Function。突变是正确的。invalidateQueries 调用却不是。

事故

UTC 时间 2026-03-14 14:23,一个财务告警触发:"过去一小时内 MRR 下降了 12%。"告警阈值设定为 10%。我的第一反应:某个客户退订了。我的第二反应:没有人退订。

管理后台上的 MRR 数值显示的是 credit-system 变更之前的过期值。Credit 系统正确更新了数据库。Query 缓存没有被失效。

具体来说:

  • credit-system 突变 Server Function 调用了 queryClient.invalidateQueries({ queryKey: ['subscriptions'] })。
  • 管理员 MRR 查询使用的是 ['admin', 'mrr', 'global']。
  • 订阅列表查询使用的是 ['subscriptions', 'list']。
  • 这些 key 没有任何共同前缀。invalidateQueries({ queryKey: ['subscriptions'] }) 没有触及 ['admin', 'mrr', 'global']。

结果:12% 的管理员用户在 4 小时 17 分钟内看到过期的 MRR 数值。正确的数据在数据库中;query 缓存却返回了旧值。

调查

我花了 6 个小时。最初的症状看起来像是数据库缓存问题——数据错误,但数据库状态正确。我先对数据库读取加了监控,发现没有问题,然后转向 query 缓存。

突破口来自 TanStack Query DevTools:管理员 MRR 查询显示的 staleAt 时间戳是 UTC 14:11。Credit-system 突变发生在 UTC 14:23。这个查询是按时间自然过期的,不是因为突变而失效的。

查看突变代码:

typescript
// Credit-system Server Function — Bug 出在这里
export const applyCredits = createServerFn({ method: 'POST' })
  .validator(z.object({ userId: z.string(), amount: z.number() }))
  .handler(async ({ data, context }) => {
    const result = await applyCreditBalance(data.userId, data.amount)
    // Bug:invalidate ['subscriptions'] 不会触及管理员 MRR
    context.queryClient.invalidateQueries({ queryKey: ['subscriptions'] })
    return result
  })

管理员 MRR 查询使用的是 ['admin', 'mrr', 'global']。没有任何失效操作触及过那个 key。Bug 是 key 范围碰撞:突变使不同作用域的 key 失效了,而管理后台读的是另一个作用域。

根本原因

系统之所以存在漏洞,是因为 query key 都是随意使用的字符串和数组,没有 factory 来约束。没有强制执行的约定来规范 query key 的结构。每个开发者(也就是我自己)都使用临时起意的 key 名称。

这是 TanStack Query 中常见的一种失败模式。Query key 是隐式带作用域的——如果你使一个 key 失效,就只会使那些共享前缀的查询失效。如果你随意构建 key,迟早会使错误的作用域失效,或者漏掉你本想使之失效的作用域。

具体失败链条:

  1. 管理员 MRR 查询使用的是 ['admin', 'mrr', 'global']。
  2. Credit 突变使用 ['subscriptions'] 来做失效。
  3. 这两个 key 没有共同前缀。突变没有使管理员查询失效。
  4. 查询在整个 staleTime 窗口内保持过期:4 小时。

次要失败:没有任何 E2E 测试能够检测到突变后的脏数据场景。

修复

两个变更,在 2026-03-14 同时上线:

1. 类型化 query-key factory

Query key 现在通过类型化 factory 创建。Factory 强制执行命名约定并提供类型安全的 key 生成。每个查询和每次失效都通过 factory 进行。

typescript
// src/lib/query-keys.ts — 类型化 factory
export const queryKeys = {
  // 用户作用域查询
  subscriptions: {
    all: ['subscriptions'] as const,
    list: (userId: string) => ['subscriptions', 'list', userId] as const,
    detail: (userId: string, subId: string) =>
      ['subscriptions', 'detail', userId, subId] as const,
  },

  // 管理员作用域查询
  admin: {
    all: ['admin'] as const,
    mrr: {
      global: () => ['admin', 'mrr', 'global'] as const,
      byPlan: (planId: string) => ['admin', 'mrr', 'byPlan', planId] as const,
    },
  },
} as const

每个查询都使用 factory。每次 invalidateQueries 调用都通过 factory。Factory 强制执行命名约定:admin key 在 ['admin', ...] 下,用户 key 在 ['subscriptions', ...] 下。你不可能意外使错误的作用域失效,因为 factory 只暴露有效的 key。

2. 突变后脏数据的 E2E 测试

typescript
test('credit 突变后管理员 MRR 查询被失效', async () => {
  // 设置初始 MRR 状态
  const initialMRR = await fetchAdminMRR()
  expect(initialMRR).toBeGreaterThan(0)

  // 应用 credit 变更
  await applyCredits({ userId: testUser.id, amount: 500 })

  // 等待失效传播
  await page.waitForTimeout(500)

  // MRR 应该立即反映 credit——没有脏数据
  const updatedMRR = await fetchAdminMRR()
  expect(updatedMRR).not.toBe(initialMRR)
})

如果突变没有使管理员 MRR 查询失效,测试就会失败。这个 40 行的测试替代了数小时的调查工作。

教训

Query key 是一份契约;要像对待数据库 schema 一样对待它们

Query key 定义了组件读取什么数据。如果你更改了 query key 而没有迁移策略,就会得到脏数据——这和你在更改数据库 schema 时没有迁移策略会遇到的问题完全相同。类型化 factory 就是查询层的 schema。用起来。

失效必须与 key 结构完全匹配

invalidateQueries({ queryKey: ['subscriptions'] }) 只会使以 ['subscriptions'] 开头的 key 失效。如果你的管理员 MRR 查询使用的是 ['admin', 'mrr', 'global'],它不会被触及。每次都要对照 query key 结构审查你的失效调用。

脏数据 Bug 在开发环境中不可见

这个 Bug 只在生产环境、高负载和真实数据下才会暴露。开发环境数据集小,staleTime 窗口短。脏数据 Bug 隐藏在"开发环境可用"和"生产环境失败"之间的间隙里。

修复方法:对突变后的数据新鲜度进行 E2E 测试。我写的这个测试在每次 CI 构建时运行。在任何突变上线前,它是必须通过的关卡。

修复很小;检测才是工作量所在

实际修复是 4 行类型化 factory 代码。防止复发的测试是 40 行。调查花了 6 个小时。检测的成本比修复高。在数据新鲜度测试覆盖上投入——在可观测性上投入,在客户报告之前就捕获脏数据场景。

TanStack Ship 如何防止此类问题

TanStack Ship 的管理后台模块自带类型化 queryKeys factory,位于 src/lib/query-keys.ts。管理后台模块中的每个查询都使用 factory。每个突变都通过 factory 进行失效。一套 E2E 测试套件验证突变后的数据新鲜度。

如果你在使用 TanStack Query 而没有类型化 key factory,这次复盘就是你的明确信号:加上一个。Factory 是 30 行代码。它所防止的脏数据 Bug 的代价是 6 小时的调查和一次财务告警误报。

在生产环境中测量查询新鲜度

管理员 MRR 查询上 4 小时的 staleTime 是一个有意的权衡:足够短以快速捕获真实变更,足够长以减少数据库负载。问题是,没有任何仪表化手段来检测突变是否未能使查询失效。没有"突变运行了但查询没有刷新"的告警。这就是这次复盘所填补的空缺。

TanStack Ship 现在在每个管理后台查询上都加了 dataUpdatedAt 时间戳的仪表化,并在每次查询刷新时记录。如果管理员 MRR 查询在已知突变后 10 分钟内没有刷新,就会触发告警——在客户提交工单之前主动捕获脏数据场景。

TanStack Ship 现在随管理后台查询一起提供查询新鲜度仪表化。每个管理后台查询记录其 dataUpdatedAt 时间戳。如果 dataUpdatedAt 比当前查询类型预期的时间更旧,就会触发告警——例如,如果管理员 MRR 查询在已知突变后 10 分钟内没有刷新,告警就会触发,在客户注意到之前。

类型化 key factory + E2E 测试 + 新鲜度监控的组合,在 TanStack Ship 平台上已防止此 Bug 复发超过 90 天。


关于本文

开始使用 TanStack Ship — 类型化 query-key factory 和脏数据 E2E 测试随管理后台模块一起提供。克隆免费入门版 →