GraphQL 查询、变更和订阅有什么区别
一句话回答
GraphQL 的三种操作类型各有分工:Query 读数据、Mutation 写数据、Subscription 监听数据变化实时推送。它们在执行语义、传输协议、缓存策略上都有本质区别,面试时把核心差异说清楚就能拿分。
核心区别一览
| 维度 | Query | Mutation | Subscription |
|---|---|---|---|
| 用途 | 读取数据 | 修改数据 | 实时监听数据变化 |
| REST 类比 | GET | POST / PUT / DELETE | WebSocket |
| 执行方式 | 并行 | 串行 | 持久连接,服务端推送 |
| 网络协议 | HTTP | HTTP | WebSocket(主流) |
| 缓存 | 可缓存 | 需要失效缓存 | 不可缓存 |
| 幂等性 | 幂等 | 非幂等 | 非幂等 |
| Schema 要求 | 必须定义 | 可选 | 可选 |
面试时先把这个表格甩出来,再逐个展开细节。
Query:只读,并行执行
Query 是 GraphQL 中最基础的操作,用于从服务端获取数据。关键点在于:Query 中的多个字段是并行执行的,这是 GraphQL 规范的明确要求。
graphqlquery GetUserAndPosts($userId: ID!) { user(id: $userId) { name email } posts(userId: $userId) { title createdAt } }
上面这个查询中,user 和 posts 两个解析器会同时执行,不会等一个完成再执行另一个。这对性能有利,但也意味着 Query 中不应该有副作用——如果两个字段都修改了数据,并行执行可能导致竞态条件。
需要注意的坑:并行执行虽然快,但也会放大 N+1 问题。假设 posts 字段里还嵌套了 author,那每个 post 都会触发一次 author 查询。10 个 post 就是 10 次额外查询。解决方案是用 DataLoader 做批量加载,把 10 次查询合并成 1 次 WHERE id IN (...)。
javascriptconst authorLoader = new DataLoader(async (ids) => { const authors = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]); return ids.map(id => authors.find(a => a.id === id)); });
Mutation:写入,串行执行
Mutation 用于创建、更新、删除数据。和 Query 最大的区别是:Mutation 中的多个字段是串行执行的,一个接一个,保证数据一致性。
graphqlmutation CreateAndUpdatePost { createPost(input: { title: "Hello", content: "World" }) { id title } updatePost(id: 1, input: { title: "Updated" }) { id title } }
这里 createPost 会先执行完毕,updatePost 才会开始。这个设计是有意为之的:如果两个 Mutation 都要修改同一条数据,串行执行可以避免并发冲突。
实际开发中的经验:
- Mutation 的参数建议用 Input Type 封装,不要一个个散着传。好处是以后加字段只改 Input Type,不用改每个 Mutation 的签名。
- Mutation 应该返回修改后的完整对象,而不仅仅是
success: true。这样客户端可以直接更新本地缓存,不用再发一次 Query。 - 复杂的 Mutation 考虑加事务。比如"创建订单并扣减库存",两个操作必须原子性成功或失败,在 resolver 层用数据库事务包裹。
javascriptconst resolvers = { Mutation: { createOrder: async (_, { input }, { db }) => { const tx = await db.beginTransaction(); try { const order = await tx.query('INSERT INTO orders ...'); await tx.query('UPDATE inventory SET stock = stock - ? ...', [input.quantity]); await tx.commit(); return order; } catch (e) { await tx.rollback(); throw e; } } } };
Subscription:实时推送,持久连接
Subscription 是 GraphQL 中实现实时数据的方式。客户端发起订阅后,服务端通过 WebSocket 保持长连接,当数据变化时主动推送给客户端。
graphqlsubscription OnMessage($roomId: ID!) { messageAdded(roomId: $roomId) { id content sender { name } createdAt } }
和轮询 Query 的本质区别:轮询是客户端定时发 Query,浪费带宽且有延迟;Subscription 是服务端主动推送,数据变化时客户端立刻收到,延迟在毫秒级。
传输协议:Subscription 通常走 WebSocket(graphql-ws 协议是当前主流),但也有走 Server-Sent Events(SSE)的实现。WebSocket 支持双向通信,SSE 只支持服务端到客户端的单向推送。
连接管理的坑:
- 断线重连:移动端网络不稳定,WebSocket 断开是常态。必须实现自动重连 + 重订阅逻辑。apollo-client 的
retryLink可以处理重连,但重连后需要重新发送订阅请求。
javascriptconst wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/graphql', retryAttempts: 10, shouldRetry: () => true, on: { connected: () => console.log('Reconnected, subscriptions will be re-established'), } }));
-
心跳机制:graphql-ws 协议通过
Ping/Pong消息维持连接活跃。如果服务端一段时间没收到 Pong,会主动断开连接。 -
连接数限制:每个 Subscription 占用一个 WebSocket 连接(或一个连接上的一个订阅槽位)。大规模应用需要用 Redis Pub/Sub 做消息分发,让多个服务端实例共享订阅事件。
-
过滤条件:不加过滤的 Subscription 会推送给所有订阅者。实际应用中应该在 resolver 层做
withFilter,只推送符合条件的消息。
javascriptconst resolvers = { Subscription: { messageAdded: { subscribe: withFilter( () => pubsub.asyncIterator(['MESSAGE_ADDED']), (payload, variables) => payload.roomId === variables.roomId ) } } };
Query 并行 vs Mutation 串行:面试常追问
面试官可能会问:"为什么 Query 是并行的,Mutation 是串行的?"
答案是 GraphQL 规范的刻意设计:
- Query 是只读操作,多个字段之间没有依赖关系,并行执行可以显著减少响应时间。一个 Query 里有 5 个字段,并行执行只需要最慢那个的时间,串行执行则是 5 个时间的总和。
- Mutation 是写操作,字段之间可能有依赖(比如先创建再更新),也可能操作同一条数据。串行执行保证操作顺序和一致性。
如果你在 Query 里写了有副作用的操作,GraphQL 不会阻止你,但并行执行可能导致不可预期的结果。这也是为什么约定俗成:读操作放 Query,写操作放 Mutation。
面试追问速答
Q: Subscription 能用 HTTP 实现吗? 技术上可以,用 SSE 或者长轮询模拟,但会失去双向通信能力。WebSocket 是主流方案。
Q: Mutation 执行失败会怎样? 单个字段抛错不影响其他字段,GraphQL 会部分成功部分返回 error。如果需要原子性,在 resolver 里用事务。
Q: 怎么限制 Query 的深度和复杂度?
用 graphql-depth-limit 限制嵌套深度,用 graphql-cost-analysis 计算查询复杂度并设上限,防止恶意查询拖垮服务。
Q: 多个 Subscription 之间会互相影响吗? 不会。每个 Subscription 是独立的观察者,互不干扰。但共享同一个 WebSocket 连接时,连接断开会影响所有订阅。