- Published on
Relay 归一化机制导致的隐蔽问题
- Authors

- Name
- hpoenixf
在一个使用 GraphQL + Relay 的项目中,我们遇到了一个非常隐蔽的问题: 页面没有报错,接口返回也看起来正常,但 UI 上的数据却偶尔不对。 后来发现,问题根本不在接口,而是在 Relay 的 store 机制。
业务背景
先说下业务场景。 系统里有「部门」的概念,一个用户可以同时属于多个部门。 在不同部门中,我们用同一个字段表示用户权限,比如 role。 但这个字段的值是和部门强相关的。
User A
- 在部门 X:role = admin
- 在部门 Y:role = viewer
从业务角度看,这非常合理。
初始的 Schema 设计
一开始,我们的 GraphQL Schema 是这样设计的:
type Department {
id: ID!
name: String!
users: [User!]!
}
type User {
id: ID!
name: String!
role: String!
}
查询部门列表时,直接把用户一起带出来:
query {
departments {
id
name
users {
id
name
role
}
}
}
这个设计在非 Relay 项目里其实完全没问题, 但在 Relay 下,隐患已经埋好了。
问题现象
问题并不是一开始就出现的。 随着需求增加:
- 页面开始同时展示多个部门
- 同一个用户在不同部门下都会出现
这时开始出现一些很诡异的情况:
- A 部门里明明是 admin
- 切到 B 部门后再回来
- A 部门里的角色变成了 viewer
- 刷新页面,有时又恢复正常。
排查过程
最开始大家都在怀疑:
- 后端是不是缓存有问题
- 接口是不是返回顺序不稳定
- 前端是不是 setState 写错了
后来直接打 log 看 Relay store,才发现真相。
问题根因:Relay 的归一化机制
Relay 会基于 id 对数据做归一化存储。 简单说就是:
同一个 id,在整个应用里只能有一份数据
于是发生了下面的事情:
请求部门 X
- 返回 User A (role = admin)
请求部门 Y
- 返回 User A (role = viewer)
Relay 认为这是同一个 User
- 后一次直接覆盖前一次
从 Relay 的角度看,它没做错。 错的是我们告诉它:这两个对象是同一个 Entity。
一个看似合理但无效的尝试
当时前端的第一反应是:
那我不请求 id 行不行?
于是尝试把查询改成:
users {
name
role
}
但很快发现问题依旧。 原因是: 只要 Schema 里这个类型有 id,Relay 还是会自动把 id 加进请求里。 也就是说,你根本绕不开它。
回头看:其实是建模问题
后来我们意识到一个关键点: "用户在某个部门下的身份 + 权限" 并不是一个纯粹的 User。 它是一个强上下文相关的数据结构。 但我们却把它建模成了一个全局 Entity。
最终方案:引入上下文对象
最终的解决方案其实很简单,但需要转一下思路。
不再直接返回 User
我们新增了一个类型:
type DepartmentUser {
name: String!
role: String!
}
并调整 Department:
type Department {
id: ID!
name: String!
users: [DepartmentUser!]!
}
关键点只有一个:
DepartmentUser 没有 id
为什么这个方案能解决问题
因为在 Relay 里:
- 没有 id → 不会被归一化
- 不会进全局 store
- 每次查询的数据都是独立的
即使是同一个用户:
- 在不同部门下
- 返回的数据也不会互相覆盖
一点额外收益
这个改动还有一个副作用(是好的那种):
- Schema 语义更清晰了
- 后端也更容易理解
- 不再纠结「User 的 role 到底是谁的」
最后的经验总结
这次问题之后,我们在团队里约定了一条规则:
只给真正的全局实体加 id
如果一个对象:
- 强依赖上下文
- 离开父节点就没有意义
那它大概率不应该是 Relay Entity。
这个坑本身不复杂, 但如果你刚好踩到,真的会浪费不少时间。 希望这篇文章能帮到你。