GraphQL + Relay 中一次很隐蔽的 ID 冲突问题

7 阅读3分钟

在一个使用 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,在整个应用里只能有一份数据

于是发生了下面的事情:

  1. 请求部门 X

    • 返回 User A (role = admin)
  2. 请求部门 Y

    • 返回 User A (role = viewer)
  3. 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。


这个坑本身不复杂,

但如果你刚好踩到,真的会浪费不少时间。 希望这篇文章能帮到你