GraphQL Normalized Cache
Normalized Cache 把 GraphQL 查询结果按实体身份拆分存储,使同一实体在多个查询中共享状态,并解释 Mutation 响应、列表关系与实时事件如何影响 Apollo 缓存一致性。
[!info] related notes
- 所属 MOC: graphql-moc
- 前置概念: graphql-apollo-client、graphql-schema
- 并列概念: TanStack Query 服务端状态管理
- 易混淆概念: 数据库规范化
- 关系笔记: GraphQL Subscriptions
GraphQL Normalized Cache
一句话定义
Normalized Cache 不把每个 Query 的整棵 JSON 当成孤立副本,而是把可识别实体拆出来,以稳定 identity 保存,再让各 Query 引用这些实体。
为什么需要它
假设两个 Query 都返回同一个用户:
query {
user(id: "1") { id name }
}
以及:
query {
diagnosis(id: "d1") {
id
owner { id name }
}
}
如果按“完整响应”缓存,就可能存在两份:
Query A → { User 1 }
Query B → { Diagnosis d1 → User 1 }
当 User 1.name 更新时,两份副本可能失去一致性。
Normalized Cache 更像:
User:1
name = Sean
Diagnosis:d1
owner → User:1
两个 Query 都引用同一个实体。
Entity Identity
Apollo 常根据:
__typename + id
形成实体身份,例如:
User:1
Diagnosis:d1
所以稳定 id 非常重要。
没有可靠 identity 的对象更难被独立 normalize,可能只能嵌在父对象里处理。
Mutation 为什么有时会“自动更新”
假设缓存已有:
User:1 { name: "Sean" }
Mutation 返回:
{
"__typename": "User",
"id": "1",
"name": "Arlon"
}
客户端识别出这是同一个 User:1,就可以合并:
User:1 { name: "Arlon" }
引用该实体的视图也能一起更新。
核心来源是 Mutation Response,不是单纯 Mutation Input
不要把自动更新理解成:
updateUser(id=1, name=Arlon)
→ Apollo 看到参数
→ 自动相信缓存应该变成 Arlon
更准确的是:
Mutation input
→ Server 执行业务逻辑
→ Mutation response 返回最终实体
→ Apollo 根据 entity identity normalize / merge
因为服务器可能:
- 拒绝输入;
- 修正或规范化输入;
- 同时修改其他字段;
- 触发业务副作用。
客户端不能默认把输入当成 authoritative final state。
如果希望在服务端响应前先显示变化,应显式使用 optimistic update,再用真实响应校正。
为什么又不能什么都自动完成
实体字段更新容易合并,但列表关系更复杂。
例如创建新 Diagnosis:
Diagnosis:new 已进入 cache
不代表:
User:1.diagnoses 列表
自动知道应该插入它。
删除、排序、过滤列表也有同样问题,因为“实体存在”与“实体是否属于某个 query projection”不是同一事实。
所以仍可能需要:
cache.modify / update
writeFragment / writeQuery
refetchQueries
field policy
当前客户端写入与远端变化
当前客户端自己执行 Mutation
Client A
→ Mutation
→ Server
→ 返回 User:1 新状态
→ normalized merge
→ Client A UI 更新
这个场景通常不需要 Subscription 来告诉 Client A “你刚才自己改了”。
另一个客户端或后台任务修改
Client B / background job
→ Server state changed
Client A
→ 没有收到任何消息
→ 本地 User:1 仍可能是旧 projection
Normalized Cache 只负责收到数据以后怎么合并,并不负责凭空发现远端发生了变化。
因此还需要变化来源:
Subscription / SSE / WebSocket event
Polling
refetch on focus/reconnect
实时 payload 到达以后,才进入:
Event payload
→ identify User:1
→ merge / explicit cache update
→ UI re-render
所以:
Normalized Cache 解决“数据来了以后放到哪里、如何共享实体”;Subscription 等机制解决“远端变化怎样来到客户端”。
与 TanStack Query 的差异
TanStack Query 常以:
['user', 1]
['diagnosis', 'd1']
管理查询结果。
Normalized Cache 更强调:
Query result
↓ normalize
entities by identity
↓ references
many query projections
前者核心是 query identity,后者还增加 entity identity。
但不能反推出:
“TanStack Query 只能 invalidate/refetch,不能本地修改。”
TanStack Query 同样可以 setQueryData、optimistic update 或根据 mutation response patch query cache。差别主要在于缓存组织模型,不是“有没有修改缓存的 API”。
最重要的边界
客户端 cache 永远只是 server state 的 projection:
Server / DB = authority
Cache = projection
Normalization 解决重复实体的一致性问题,但不能证明数据仍然最新,也不能替代服务端事务和权限控制。
常见误解
- normalized cache = 数据库 normalization:不是。一个是客户端实体缓存,一个是关系数据库建模原则。
- Apollo 根据 Mutation 参数自动相信新状态:默认不是;核心通常是对服务端返回实体做 normalization/merge。
- Mutation 返回实体后所有列表都会正确:不一定,关系集合仍需 reconciliation。
- 有 cache 就不需要 refetch:不一定。无法可靠推导的新状态仍适合重新向服务端取真值。
- Normalized Cache 会自动知道别的用户更新了数据:不会,需要 Subscription、轮询或其他变化通知/重取机制。