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。这个查询是按时间自然过期的,不是因为突变而失效的。
查看突变代码:
// 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,迟早会使错误的作用域失效,或者漏掉你本想使之失效的作用域。
具体失败链条:
- 管理员 MRR 查询使用的是
['admin', 'mrr', 'global']。 - Credit 突变使用
['subscriptions']来做失效。 - 这两个 key 没有共同前缀。突变没有使管理员查询失效。
- 查询在整个
staleTime窗口内保持过期:4 小时。
次要失败:没有任何 E2E 测试能够检测到突变后的脏数据场景。
修复
两个变更,在 2026-03-14 同时上线:
1. 类型化 query-key factory
Query key 现在通过类型化 factory 创建。Factory 强制执行命名约定并提供类型安全的 key 生成。每个查询和每次失效都通过 factory 进行。
// 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 测试
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 天。
关于本文
- 本文由 Huifer 撰写,TanStack Ship 的独立开发者兼维护者。2026 年 3 月上线 credit-system 功能时引入了这个 Bug。由于症状(过期 MRR)看起来像数据库缓存问题,花了 6 小时的日志才找到。实际修复是 4 行;现在防止它的测试是 40 行。
- 2026-03-14 测试,12% 管理员用户受影响;修复后 90 天以上零脏数据事件。
- 依据:TanStack Query 官方文档,query 失效需要精确的 key 匹配才能正常工作。TanStack Query key 指南,类型化 query-key factory 是防止范围碰撞的推荐模式。React Testing Library 最佳实践,E2E 测试对检测数据新鲜度问题至关重要。
- 已验证来源: TanStack Query 文档 · TanStack Query 失效指南 · TanStack Query Key Factory · TanStack Start Server Functions · TanStack Ship GitHub · React Testing Library · TypeScript Query Patterns
- 最后更新: 2026-08-05 · 更新日志
开始使用 TanStack Ship — 类型化 query-key factory 和脏数据 E2E 测试随管理后台模块一起提供。克隆免费入门版 →