5月28日 09:26

GraphQL 查询、变更和订阅有什么区别

一句话回答

GraphQL 的三种操作类型各有分工:Query 读数据、Mutation 写数据、Subscription 监听数据变化实时推送。它们在执行语义、传输协议、缓存策略上都有本质区别,面试时把核心差异说清楚就能拿分。

核心区别一览

维度QueryMutationSubscription
用途读取数据修改数据实时监听数据变化
REST 类比GETPOST / PUT / DELETEWebSocket
执行方式并行串行持久连接,服务端推送
网络协议HTTPHTTPWebSocket(主流)
缓存可缓存需要失效缓存不可缓存
幂等性幂等非幂等非幂等
Schema 要求必须定义可选可选

面试时先把这个表格甩出来,再逐个展开细节。

Query:只读,并行执行

Query 是 GraphQL 中最基础的操作,用于从服务端获取数据。关键点在于:Query 中的多个字段是并行执行的,这是 GraphQL 规范的明确要求。

graphql
query GetUserAndPosts($userId: ID!) { user(id: $userId) { name email } posts(userId: $userId) { title createdAt } }

上面这个查询中,userposts 两个解析器会同时执行,不会等一个完成再执行另一个。这对性能有利,但也意味着 Query 中不应该有副作用——如果两个字段都修改了数据,并行执行可能导致竞态条件。

需要注意的坑:并行执行虽然快,但也会放大 N+1 问题。假设 posts 字段里还嵌套了 author,那每个 post 都会触发一次 author 查询。10 个 post 就是 10 次额外查询。解决方案是用 DataLoader 做批量加载,把 10 次查询合并成 1 次 WHERE id IN (...)

javascript
const 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 中的多个字段是串行执行的,一个接一个,保证数据一致性。

graphql
mutation 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 层用数据库事务包裹。
javascript
const 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 保持长连接,当数据变化时主动推送给客户端。

graphql
subscription OnMessage($roomId: ID!) { messageAdded(roomId: $roomId) { id content sender { name } createdAt } }

和轮询 Query 的本质区别:轮询是客户端定时发 Query,浪费带宽且有延迟;Subscription 是服务端主动推送,数据变化时客户端立刻收到,延迟在毫秒级。

传输协议:Subscription 通常走 WebSocket(graphql-ws 协议是当前主流),但也有走 Server-Sent Events(SSE)的实现。WebSocket 支持双向通信,SSE 只支持服务端到客户端的单向推送。

连接管理的坑

  1. 断线重连:移动端网络不稳定,WebSocket 断开是常态。必须实现自动重连 + 重订阅逻辑。apollo-client 的 retryLink 可以处理重连,但重连后需要重新发送订阅请求。
javascript
const wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/graphql', retryAttempts: 10, shouldRetry: () => true, on: { connected: () => console.log('Reconnected, subscriptions will be re-established'), } }));
  1. 心跳机制:graphql-ws 协议通过 Ping/Pong 消息维持连接活跃。如果服务端一段时间没收到 Pong,会主动断开连接。

  2. 连接数限制:每个 Subscription 占用一个 WebSocket 连接(或一个连接上的一个订阅槽位)。大规模应用需要用 Redis Pub/Sub 做消息分发,让多个服务端实例共享订阅事件。

  3. 过滤条件:不加过滤的 Subscription 会推送给所有订阅者。实际应用中应该在 resolver 层做 withFilter,只推送符合条件的消息。

javascript
const 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 连接时,连接断开会影响所有订阅。

标签:GraphQL