Subscription — CodeRoadMapSubscription
实时订阅与 defer/stream。
Subscription
这一课你将能做到
- 为本站「判题结果实时通知」设计并实现一条 Subscription
- 说清楚 graphql-ws 与 SSE 两条传输路线的选型依据
- 用 @defer / @stream 做增量交付,并理解它和 Subscription 的本质区别
为什么要学
判题是典型的异步场景:用户提交代码,后台队列判题,3 秒后出结果。轮询方案每 2 秒打一次接口,一万个在线用户就是每秒 5000 次无效请求;WebSocket 推送方案是服务端在结果就绪的那一刻主动告诉那一个人。Subscription 就是把「推送」纳入 GraphQL 类型系统的官方方式。
人话:Query 是「你去问」,Subscription 是「有消息我喊你」。
先建立图像
订阅的生命周期:
客户端 服务端
│ subscription { submissionStatusChanged(id:"s1") { status } }
│ ────────────────────────────────────────────────────────────> 建立 WebSocket 连接,注册监听
│ │
│ (判题机消费队列,状态变更,发布事件) │
│ <──────────────────────────────────────────────────────────── 推送 { status: "RUNNING" }
│ <──────────────────────────────────────────────────────────── 推送 { status: "ACCEPTED" }
│ complete / 客户端断开 │ 关闭流
三种「实时」手段对比:
| 方案 | 底层 | 适用 |
|---|
| Subscription over WebSocket | graphql-ws 协议 | 高频双向、事件流 |
| Subscription over SSE | HTTP 长连接 | 服务端单向推、要穿代理 |
| @defer / @stream | HTTP 分块传输 | 一次性查询的「先给骨架后补慢字段」 |
跟着做
步骤 1:定义订阅类型
type Subscription {
"指定提交记录的状态变化(仅提交者本人可订阅)"
submissionStatusChanged(submissionId: ID!): Submission!
"我所在路线的公告"
trackAnnouncement(trackSlug: String!): Announcement!
}
type Announcement { id: ID!, content: String!, publishedAt: DateTime! }
惯例:Subscription 字段的 resolver 返回一个事件流(Publisher/AsyncIterator),每次事件产出后,再按客户端声明的字段集走正常字段解析。
步骤 2:服务端发布事件(以 graphql-yoga 为例)
// 用进程内 PubSub 演示;生产换 Redis Pub/Sub 或 MQ
const pubsub = createPubSub()
const resolvers = {
Mutation: {
async judgeCallback(_, { submissionId, status }, { db }) {
const sub = await db.submission.update({ where: { id: submissionId }, data: { status } })
pubsub.publish('submission:changed', submissionId, sub) // 事件触发
return { submission: sub }
}
},
Subscription: {
submissionStatusChanged: {
subscribe: (_, { submissionId }) =>
pubsub.subscribe('submission:changed', submissionId), // 返回 AsyncIterable
resolve: (payload) => payload // 事件载荷 → 字段解析入口
}
}
}
步骤 3:客户端订阅(graphql-ws)
import { createClient } from 'graphql-ws'
const client = createClient({ url: 'ws://localhost:8080/graphql' })
const unsubscribe = client.subscribe(
{
query: `subscription OnJudge($id: ID!) {
submissionStatusChanged(submissionId: $id) {
id status judgeOutput { stdout }
}
}`,
variables: { id: 's1' }
},
{
next: ({ data }) => renderStatus(data.submissionStatusChanged),
error: (err) => showRetry(err),
complete: () => console.log('流被服务端关闭')
}
)
步骤 4:graphql-ws 协议帧
连接建立后的消息都是 JSON 帧,懂这几类就够排查问题:
→ { "type": "connection_init" } 客户端握手(可带鉴权 payload)
← { "type": "connection_ack" } 服务端确认
→ { "id":"1", "type":"subscribe", "payload":{ query...} } 发起订阅
← { "id":"1", "type":"next", "payload":{ data... } } 每次事件
← { "id":"1", "type":"complete" } 该订阅结束
注意:废弃的老协议叫 subscriptions-transport-ws(消息类型是 start/data/stop),新项目不要再用。
步骤 5:@defer / @stream——不是订阅的「增量查询」
query LessonPage($slug: String!) {
lesson(slug: $slug) {
title
content
...CommentsFragment @defer # 慢的部分推迟
}
}
fragment CommentsFragment on Lesson {
comments(first: 20) { id author { name } content }
}
响应是多部分的(multipart/mixed):先收到主体数据,comments 位置是占位;第二个分块补齐。@stream 同理,用于列表的边下边渲染。两者的前提:一次请求;Subscription 是长连接多次事件——这是本质区别。
讲清楚要点
- Subscription 的根字段只能有一个:一次订阅对应一个事件流,这是规范约束。
- 订阅是「注册 + 重放查询」:每个事件到达后,服务端按你声明的字段集重新执行一次字段解析——所以订阅字段下的嵌套查询天然是实时一致的。
- Pub/Sub 与传输解耦:进程内 PubSub 只能单机玩;多实例部署必须换 Redis/NATS/Kafka,事件按「用户/租户」维度路由。
- 鉴权发生在握手:graphql-ws 的
connection_init payload 里带 token,握手失败直接拒连;别在每个事件里重新鉴权,但要在事件发布侧做「这条事件该不该发给这个连接」的过滤。
- @defer/@stream 是查询优化,不是实时:它们把一次查询拆成多个分块,连接照样关闭;需要服务端主动推送时仍要 Subscription。
- 背压与断线:客户端处理不过来要丢弃或采样;断线后重连 + 从最后事件 ID 续传(需要服务端事件日志)是生产级方案。
你可能会懵的地方
- Subscription 能代替轮询所有场景吗?
不能。低频、容忍延迟的场景(每分钟的统计数字)轮询更简单可靠;WebSocket 有连接成本,杀鸡不用牛刀。
- 为什么很多网关/CDN 对 WebSocket 不友好?
长连接占用 LB 资源、不走路径缓存。SSE 是纯 HTTP 响应流,穿透性更好——所以不少团队最终选 SSE 承载订阅。
- 事件丢了怎么办?
协议本身不保证送达。可靠方案:事件带单调序号,客户端重连时带上
lastEventId,服务端从日志补发——和 Kafka offset 一个思想。
- 订阅里能写副作用吗?
subscribe 函数不该有副作用,它只是注册监听。触发事件的永远是 Mutation 或外部系统。
- Live Query 是什么?
社区实验方案(非规范):「查询结果变化时自动推新结果」。概念美好但「什么算变化」的追踪成本极高,生产少见,知道概念即可。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 用老协议 subscriptions-transport-ws | 已废弃、无人维护 | graphql-ws 或 SSE |
| 单机 PubSub 直接上多实例 | 事件只到达本机连接,大量丢推送 | 换 Redis Pub/Sub 等外部总线 |
| 在 subscribe 里做鉴权后的每次事件都放行 | 越权看到别人的判题结果 | 发布侧按订阅者过滤 payload |
| 把慢字段全塞首次响应 | 首屏白等 3 秒 | @defer 推迟非关键字段 |
| 断线后直接重订阅 | 断线期间事件永久丢失 | 事件序号 + lastEventId 续传 |
| 用 Subscription 做聊天室高频消息 + 每个事件深嵌套查询 | 每事件触发一次 N+1 风暴 | payload 预取充分 + DataLoader |
卡点急救
- 连接立刻被关闭,收到 4403:graphql-ws 用关闭码表意,4403 = Forbidden,握手鉴权没过;4401 = 未认证。查
connection_init 的 payload。
- 本地能连、上测试环境连不上:反向代理没开 Upgrade 头转发,Nginx 加
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";,或改 SSE。
- 事件触发了但客户端没收到:多实例部署下事件发布在 A 机、连接在 B 机——把进程内 PubSub 换成外部消息总线。
面试常问
- Subscription 的实现原理?
客户端通过 WebSocket(graphql-ws 协议)或 SSE 注册一个事件流订阅;服务端数据源(Mutation 或外部事件)发布事件到 Pub/Sub 总线,匹配到订阅者后按订阅声明的字段集执行一次字段解析并推送。
- @defer 和 Subscription 的区别?
@defer/@stream 是一次性请求的分块增量交付(慢字段后补),连接随响应结束;Subscription 是长连接上的多次事件推送,服务端主动驱动。前者优化首屏,后者解决实时。
- 生产环境做订阅要考虑什么?
外部消息总线(多实例)、握手鉴权与事件级过滤、断线续传(事件序号)、连接数与背压治理、以及「是否真的需要实时」的成本评估。
自测三问
- Subscription 的根字段为什么只能有一个?
- 多实例部署时,进程内 PubSub 会出什么问题?
- 判题页「骨架先出、判题详情后补」该用 Subscription 还是 @defer?
小结一句话
Subscription 把「服务端主动推送」收编进类型系统:graphql-ws/SSE 负责传输、Pub/Sub 负责事件路由、每次事件重放字段解析保证数据新鲜;一次性慢查询则用 @defer/@stream 分块交付。
延伸