告别轮询:为什么我们在百万级IM项目中选用GraphQL订阅

前年我们接手了一个重构即时通讯模块的任务,当时面对的是一套跑在千万级用户体量下的老系统。最头疼的问题是消息推送,之前的团队为了解决“新消息提醒”这个问题,居然在客户端用了每秒一次的轮询。你可以想象一下,一百万个用户同时盯着手机,每隔一秒就向服务器发一个请求问“有消息吗?”,服务器哪怕只是回个空响应,那瞬间的QPS也直接飙到了100万。数据库那边的连接池经常被打满,接口耗时从正常的50ms抖动到800ms,用户那边就是消息延迟十几秒才收到,投诉电话快被打爆了。

那次线上事故排查了很久,最后发现是轮询请求把负载均衡的带宽给吃光了。我们当时就在想,这绝对不能这么干了。最开始有人提议用普通的WebSocket,毕竟大家都会写。但我们在设计新架构时,考虑到客户端其实只需要消息的idcontentsender这三个字段,如果服务端每次都把整个消息对象(包含很多内部字段、元数据)一股脑推过去,既浪费流量也不利于客户端解析。

这时候GraphQL订阅的优势就体现出来了。它基于持久连接,但保持了对数据的声明式控制。我们选择了Apollo Server v4.10.0(2024年3月刚发布的那个版本)配合graphql-ws协议。为什么不用SSE(Server-Sent Events)?虽然SSE在单向推送上确实轻量,实现起来也简单,但我们这个IM场景里,客户端不仅要收消息,还得在连接建立时发送一些初始化参数(比如拉取离线消息的游标),而且是双向交互,用WebSocket更符合这种双向通信的需求。

我们当时做了一个对比测试,在模拟50万并发连接的情况下,SSE的连接建立开销确实比WebSocket小,但一旦涉及到客户端需要稍微复杂一点的交互(比如订阅时带参数),SSE就显得很僵硬了。而且GraphQL订阅天然支持多路复用,一个WebSocket连接上可以同时订阅“新消息”、“好友状态变更”和“群公告”。如果拆成三个SSE连接,那纯粹是浪费资源。

下面是我们当时定义的订阅类型,用的graphql-js v16.8.1,这个版本对内存回收做了优化,对我们这种长连接场景挺重要的:

type Message { id: ID! content: String! sender: User! createdAt: String! } type Subscription { newMessage(chatId: ID!): Message userStatusChanged(userId: ID!): User } type Query { # 这里省略其他查询 offlineMessages(lastId: ID): [Message] }

在客户端,我们不再疯狂轮询,而是建立了一个长连接。代码大概长这样(用的是graphql-ws的客户端库):

import { createClient } from 'graphql-ws'; const client = createClient({ url: 'ws://im-api.example.com/graphql', connectionParams: () => { // 这里处理认证,后面章节细说 return { token: localStorage.getItem('authToken') }; }, }); // 订阅特定聊天的消息 const subscription = client.iterate({ query: ` subscription OnNewMessage($chatId: ID!) { newMessage(chatId: $chatId) { id content sender { id nickname } } } `, variables: { chatId: '12345' }, }); (async () => { for await (const message of subscription) { // 这里处理收到的消息,更新UI console.log('收到新消息:', message); // 实际项目中我们会 dispatch 一个 Redux action 或者更新 React State } })();

上线后效果立竿见影。推送延迟从之前的几秒降到了平均120ms,服务器的QPS从100万降到了几乎可以忽略不计的维持连接数。那次我们盯着监控大屏,看着流量断崖式下跌,心里那个爽啊。这就是事件驱动的魅力,服务端有数据了才推,没数据就挂着,不像轮询那样瞎折腾。

协议迁移实战:从弃用的subscriptions-transport-ws平滑升级至graphql-ws v4

前阵子我们做技术债清理,发现之前有个内部项目还在用subscriptions-transport-ws这个库。这个库其实早就进入弃用状态了,维护者也不怎么管了,但我们线上跑得还挺稳,所以一直没动。直到有一次,我们在做安全扫描的时候发现,这个老库在处理WebSocket握手时的认证逻辑有点问题,而且它不支持最新的graphql-ws协议规范,导致我们在升级Apollo Server的时候遇到了兼容性问题。

当时我负责这块的迁移。为什么非要换到graphql-ws?其实核心原因不只是维护状态,而是graphql-ws的协议设计更清晰,它把连接初始化(ConnectionInit)和消息处理分得更开,这对我们这种需要动态鉴权的场景很关键。老库把很多逻辑杂糅在一起,排查问题很费劲。

迁移过程其实没想象中那么痛苦,但有几个细节必须注意。我们当时的策略是双协议同时支持,灰度切流。因为客户端不可能一次性全部升级,我们得让新旧版本APP都能连上来。

在Apollo Server v4.10.0里,配置方式变了挺多。以前v3的时候可能直接挂个中间件,现在v4更强调集成。我们当时的配置代码是这样的:

import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@apollo/server/express4'; import { createServer } from 'http'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; // 定义 Schema const typeDefs = ` type Query { ... } type Subscription { countdown(from: Int!): Int! } `; const resolvers = { Subscription: { countdown: { subscribe: async function* (_, { from }) { for (let i = from; i >= 0; i--) { await new Promise((resolve) => setTimeout(resolve, 1000)); yield { countdown: i }; } }, }, }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); const httpServer = createServer(app); const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql', }); // 这是关键:把 graphql-ws 的逻辑挂上去 const serverCleanup = useServer({ schema }, wsServer); const server = new ApolloServer({ schema, plugins: [ ApolloServerPluginDrainHttpServer({ httpServer }), { async serverWillStart() { return { async drainServer() { await serverCleanup.dispose(); }, }; }, }, ], }); await server.start(); app.use('/graphql', expressMiddleware(server));

这里有个坑,我当时排查了半天才发现。老库subscriptions-transport-ws在连接建立时,通常是通过URL参数或者connectionParams里的authToken来传令牌。但graphql-wsconnectionParams是在协议层的一个单独的JSON payload里。如果客户端不升级,它还是按老样子发,服务端就收不到Token,直接断开连接。

我是怎么解决的呢?我写了一个适配层。在useServer的配置里,我加了onConnect钩子,用来兼容老客户端的鉴权方式。

const getSession = async (connectionParams, websocket) => { // 尝试从新的 graphql-ws 协议格式获取 let token = connectionParams?.token; // 兼容老库:老库有时候会把 token 放在 url query 里 if (!token) { const urlParams = new URL(websocket.url, 'http://localhost').searchParams; token = urlParams.get('token'); } if (!token) { throw new Error('无权限连接'); } // 验证 JWT try { return jwt.verify(token, process.env.JWT_SECRET); } catch (e) { throw new Error('Token 无效或已过期'); } }; const wsServer = new WebSocketServer(...); useServer({ schema, // 这里处理握手逻辑 onConnect: async (ctx) => { const session = await getSession(ctx.connectionParams, ctx.extra.socket); // 把用户信息挂到上下文里,后续的 resolver 就能用了 ctx.extra.session = session; return true; }, // 处理上下文 context: async (ctx) => { return { session: ctx.extra.session }; } }, wsServer);

这样改完之后,我们部署上去,发现老版本APP的订阅依然正常,新版本APP也能走新的协议。那种感觉就像是在给飞行中的飞机换引擎,虽然惊险,但看到监控里连接成功率依然是99.9%的时候,还是挺有成就感的。

某电商订单中心实战:基于Kafka事件总线的微服务架构订阅实现

我们现在的电商系统,订单中心是个独立的微服务,库存、支付、物流都是分开的。前阵子大促,运营要求做一个“实时订单监控大屏”,还要在商家后台实时弹出“您有新的订单”提示。这需求一听就是典型的订阅场景,但难点在于,订单数据是在订单服务里的,而GraphQL网关是另一个服务。

如果让GraphQL服务直接去查订单库,那这个网关就太重了,而且耦合严重。我当时的方案是引入Kafka做事件总线。订单服务在创建或更新订单时,往Kafka的order-events主题扔一个消息,GraphQL订阅层去消费这个Topic,然后把数据推送给前端。

这里有个很实际的问题:GraphQL订阅是长连接,而Kafka消费者通常是短生命周期或者是批处理的。我们怎么把Kafka的流转换成GraphQL的推送?

我用了graphql-js里的PubSub模式,但自己实现了一个基于Kafka的适配器。在Apollo Server v4里,我们一般是这么玩的。

首先,我们定义了一个订单订阅的Schema:

type Order { id: ID! status: OrderStatus! totalAmount: Float! items: [OrderItem] } enum OrderStatus { CREATED PAID SHIPPED COMPLETED } type Subscription { orderStatusChanged(merchantId: ID!): Order }

然后,核心在于Resolver。这里不能直接查数据库,而是要监听Kafka。我写了一个KafkaPubSub类,它本质上是一个事件发射器,但在内部订阅了Kafka。

import { Kafka } from 'kafkajs'; import { EventEmitter } from 'events'; class KafkaPubSub { constructor(kafkaConfig, topic) { this.emitter = new EventEmitter(); this.kafka = new Kafka(kafkaConfig); this.consumer = this.kafka.consumer({ groupId: 'graphql-subscription-group' }); this.topic = topic; this.isConnected = false; } async connect() { if (this.isConnected) return; await this.consumer.connect(); await this.consumer.subscribe({ topic: this.topic, fromBeginning: false }); await this.consumer.run({ eachMessage: async ({ message }) => { const payload = JSON.parse(message.value.toString()); // 触发内部事件,eventName 可以基于 merchantId 或者 orderId // 这样每个商家只会收到自己的订单 const eventName = `order_${payload.merchantId}`; this.emitter.emit(eventName, payload); }, }); this.isConnected = true; } asyncIterableForQuery(eventName) { // 实现 AsyncIterator let resolve = null; const promise = new Promise((r) => (resolve = r)); const buffer = []; let done = false; const listener = (data) => { if (buffer.length === 0) { resolve(); } buffer.push({ value: data }); }; this.emitter.on(eventName, listener); return { next: () => { if (buffer.length > 0) { return Promise.resolve(buffer.shift()); } if (done) { return Promise.resolve({ done: true, value: undefined }); } return promise.then(() => { if (buffer.length > 0) { return Promise.resolve(buffer.shift()); } return { done: true, value: undefined }; }); }, return: () => { done = true; this.emitter.off(eventName, listener); return Promise.resolve({ done: true }); }, [Symbol.asyncIterator]() { return this; }, }; } }

在Resolver里,我们这样用:

const kafkaPubSub = new KafkaPubSub( { brokers: ['kafka1:9092'], clientId: 'graphql-sub' }, 'order-events' ); // 启动时连接 kafkaPubSub.connect().catch(console.error); const resolvers = { Subscription: { orderStatusChanged: { subscribe: (_root, { merchantId }) => { // 这里过滤,只订阅特定商家的事件 // 因为我们 Kafka 里推过来的可能是所有商家的消息 return { async *[Symbol.asyncIterator]() { const iterator = kafkaPubSub.asyncIterableForQuery(`order_${merchantId}`); for await (const event of iterator) { // 这里可以做更细粒度的过滤,比如虽然收到了事件,但确认是不是这个商家的 if (event.merchantId === merchantId) { yield { orderStatusChanged: event }; } } }, }; }, }, }, };

这里面有个细节。Kafka的消费是广播给消费者组的,如果我把merchantId作为分区键(Partition Key),那么同一个商家的订单都会进同一个分区。但GraphQL网关可能有多台实例,大家都在消费这个Topic。如果直接用emitter.emit,那可能实例A收到了消息,但连接的客户端在实例B上,这就推不过去了。

我们当时的解法是,在KafkaPubSub里不做过滤,直接把消息广播给所有GraphQL实例的内部Emitter,然后在Resolver的subscribe函数里做过滤。虽然多了一点点内存开销,但保证了只要客户端连在任意一个实例上,都能收到消息。后来为了优化,我们引入了Redis的Pub/Sub做了一层中转,让Kafka消费者只负责把消息丢给Redis,GraphQL实例监听Redis,这样架构更清晰,也解决了实例间的数据同步问题。

记得有一次大促凌晨,我们发现某个商家的订单推送延迟了将近5秒。查了半天,最后发现是Kafka消费组的autoCommit设置得太激进,导致重启Pod的时候重复消费,积压了一堆消息。后来我们把GraphQL订阅层的Kafka消费组隔离出来,单独设置了一个较低的max.poll.interval.ms,问题才解决。这种在微服务架构下搞实时推送,链路长,每个环节都得盯着。

4. 压测数据揭秘:单一WS连接多路复用的内存表现与10万级并发优化

在我负责的一个物流轨迹追踪项目中,我们需要实时向调度大屏推送上千辆卡车的经纬度变化。最初我担心每个订阅都建立一个独立的WebSocket连接会导致服务端文件描述符耗尽,但实测数据改变了我的看法。

我使用 graphql-ws (v5.14.0) 配合 Apollo Server 4.10.0 进行了一次针对性的压测。测试环境为 AWS c6g.2xlarge (8核16G)。核心结论是:GraphQL订阅的多路复用能力极强,单一WS连接处理多个订阅请求时,内存开销并非线性增长,而是呈现边际成本递减的趋势。

压测场景与数据

我模拟了如下场景:单连接订阅 1 个、10 个、50 个和 100 个车辆轨迹。服务端每 500ms 向所有订阅者广播一次位置更新。

| 单连接订阅数 | 总活跃订阅数 (1000连接) | Node.js 堆内存 (MB) | 平均消息延迟 (ms) |

| :--- | :--- | :--- | :--- |

| 1 | 1000 | 85 | 12 |

| 10 | 10000 | 210 | 15 |

| 50 | 50000 | 650 | 28 |

| 100 | 100000 | 1180 | 45 |

原因不在于连接数,而在于事件发射器(EventEmitter)的监听器堆积。在默认实现中,每个订阅都会在服务端注册一个独立的监听器。当订阅数达到 10 万级时,如果不在 Apollo Server 层面做事件过滤,内存会被这些回调函数占满。

优化方案:事件过滤与共享订阅

我的解决方案不是简单地增加机器,而是重构了订阅解析器中的发布/订阅逻辑。我放弃了为每个订阅单独创建监听器的做法,改为在上下文(Context)中维护一个共享的异步生成器(Async Generator),并在迭代时根据字段选择集(Field Selection)进行过滤。

以下是我在项目中使用的优化后的订阅解析器代码:

import { GraphQLResolveInfo } from 'graphql'; import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'; import { makeExecutableSchema } from '@graphql-tools/schema'; // 模拟一个全局的事件总线 const eventBus = new EventTarget(); // 优化后的订阅定义 const typeDefs = ` type Subscription { vehicleLocation(vehicleId: ID!): LocationUpdate } type LocationUpdate { vehicleId: ID! lat: Float! lng: Float! timestamp: Int! } `; const resolvers = { Subscription: { vehicleLocation: { // 使用 graphql-ws 推荐的 AsyncGenerator 模式 subscribe: async function* (_parent: any, args: { vehicleId: string }, _context: any, info: GraphQLResolveInfo) { const { vehicleId } = args; const eventName = `location_update_${vehicleId}`; // 使用 AbortController 处理取消订阅,防止内存泄漏 const abortController = new AbortController(); // 将外部事件转换为 AsyncGenerator 的 yield const eventPromise = new Promise<CustomEvent>((resolve) => { const handler = (event: Event) => { resolve(event as CustomEvent); // 重新注册下一次监听 eventBus.addEventListener(eventName, handler as EventListener, { signal: abortController.signal }); }; eventBus.addEventListener(eventName, handler as EventListener, { signal: abortController.signal }); }); try { while (!abortController.signal.aborted) { const event = await eventPromise; // 这里可以进行细粒度的字段过滤,减少序列化开销 yield { vehicleLocation: event.detail }; } } finally { // 清理逻辑 abortController.abort(); console.log(`Subscription for ${vehicleId} cleaned up.`); } }, }, }, };

为什么这么做? 原因在于 graphql-ws 协议本身支持在一个 WebSocket 连接上发起多个 Subscribe 消息(通过 id 区分)。如果不做优化,服务端会为每个 id 都跑一个完整的 Resolver 上下文,造成冗余。上述代码中,我利用 EventTarget 代替了传统的 PubSub 库(如 graphql-subscriptions),减少了中间层的封装开销。在 10 万并发的场景下,这种直接操作事件循环的方式比使用第三方 PubSub 库节省了约 300MB 的内存。

5. 避坑指南:JWT过期处理与WebSocket断连后的数据一致性补偿方案

在一个金融行情推送系统中,我遇到过一个棘手的问题:用户持有的 JWT Token 有效期只有 15 分钟,而 WebSocket 连接是持久化的。用户不可能因为 Token 过期就被强制断开连接,这会导致行情推送中断,引发客诉。

问题排查:握手认证的局限性

标准的 graphql-ws 协议在连接初始化(connection_init)时携带 Token。一旦连接建立,后续的 subscribe 消息是不携带认证信息的。

// 客户端连接逻辑 import { createClient } from 'graphql-ws'; const client = createClient({ url: 'ws://localhost:4000/graphql', connectionParams: () => { // 只在连接建立时执行一次 return { authorization: `Bearer ${localStorage.getItem('token')}`, }; }, });

问题在于:如果 Token 在第 16 分钟过期,服务端在此时无法拒绝新的订阅请求,因为旧的 context 中可能还缓存着旧的、已失效的 Payload。更严重的是,如果网络闪断导致 WebSocket 重连,客户端携带的可能是过期的 Token,导致重连失败。

解决方案:双 Token 机制与动态 Context 刷新

我的解决方案是在 WebSocket 的 context 建立阶段引入动态校验,而不是仅仅依赖连接时的静态参数。

以下是服务端处理上下文认证的代码片段:

import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@apollo/server/express4'; import { createServer } from 'http'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import jwt from 'jsonwebtoken'; interface MyContext { userId: string; tokenValid: boolean; } const server = new ApolloServer({ schema, }); // WebSocket 上下文处理 const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql', }); useServer( { schema, // 关键:每次操作前都校验 context,而不是只在连接时 context: async (ctx, msg, args) => { // 从连接参数中获取 token const token = (ctx.connectionParams as any)?.authorization?.split(' ')[1]; if (!token) throw new Error('Missing auth token'); try { // 验证 Token,这里不校验过期时间,而是校验签名和黑名单 const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }; // 检查 Redis 中的黑名单(如果 Token 被主动吊销) const isBlacklisted = await redisClient.get(`bl_${decoded.userId}_${token}`); if (isBlacklisted) { throw new Error('Token revoked'); } return { userId: decoded.userId, tokenValid: true }; } catch (e) { // 如果 Token 过期,尝试从上下文中获取 refreshToken 逻辑(此处简化) // 实际项目中,我通常会让客户端在 onClose 回调中刷新 token 并重连 throw new Error('Authentication failed'); } }, // 订阅的校验 onSubscribe: (ctx, msg) => { // 可以在这里根据 context 中的 userId 动态过滤订阅权限 const { userId } = ctx as unknown as MyContext; if (!userId) return [new Error('Unauthorized')]; }, }, wsServer );

数据一致性补偿

有一次线上故障,因为 Redis 集群抖动导致订阅事件丢失了 3 秒的数据。如果不处理,用户看到的股价就是滞后的。我的补偿方案是:在 WebSocket 重连成功后,客户端必须发送一个包含 lastReceivedTimestamp 的查询,服务端根据这个时间戳从数据库的变更日志(Change Log)中拉取遗漏的数据并补发。

// 客户端重连后的补偿逻辑 client.on('connected', () => { const lastTs = localStorage.getItem('last_msg_ts'); if (lastTs) { // 发起一次查询补偿 fetch('http://localhost:4000/graphql', { method: 'POST', body: JSON.stringify({ query: ` query Compensate($lastTs: Int!) { missedUpdates(since: $lastTs) { ... } } `, variables: { lastTs: parseInt(lastTs) } }) }).then(res => res.json()).then(data => { // 将补偿数据合并到本地状态 console.log('Compensated data:', data); }); } });

6. 面向未来:Apollo Server 4与AI流式输出(@stream)的边缘计算实践

随着 LLM 应用的爆发,我在最近的一个 AI 助手项目中,需要实现类似 ChatGPT 的“逐字输出”效果。传统的 HTTP 轮询太重,而标准的 GraphQL 订阅虽然能推送,但无法利用 HTTP/2 的多路复用优势。Apollo Server 4 对 @stream 指令的支持以及边缘计算(Edge Runtime)的兴起,提供了全新的解法。

技术选型:SSE 还是 WebSocket?

在 AI 流式输出场景下,数据是单向的(服务端 -> 客户端)。我评估后认为 WebSocket 显得过重,因为它需要双向握手和状态维护。我转向了 Server-Sent Events (SSE)

Apollo Server 4.x 的 @defer@stream 指令原生支持增量传输。结合 Cloudflare Workers 的边缘计算能力,我可以将 AI 推理的结果直接流式推送到用户浏览器,延迟从原来的 800ms(回源 + 全量返回)降低到了 120ms(首字到达时间)。

边缘流式推送实现

在 Cloudflare Workers 环境中,我无法使用 Node.js 的 EventEmitter,而是利用 Web 标准的 ReadableStream

以下是我在边缘节点实现 GraphQL 流式订阅(模拟 @stream 行为)的核心代码:

// worker.js (Cloudflare Workers 环境) import { ApolloServer } from '@apollo/server'; import { ApolloServerPluginInlineTraceDisabled } from '@apollo/server/plugin/disabled'; import { buildSubgraphSchema } from '@apollo/subgraph'; // 定义支持流式的 Schema const typeDefs = ` type Query { getAIResponse(prompt: String!): ResponseStream } type ResponseStream { text: String } directive @stream on FIELD `; const resolvers = { Query: { getAIResponse: async (_: any, { prompt }: { prompt: string }, context: any) => { // 模拟 AI 生成流 const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); const words = (await mockAIGeneration(prompt)).split(' '); for (const word of words) { // 模拟 AI 生成间隔 await new Promise(r => setTimeout(r, 50)); const data = JSON.stringify({ data: { getAIResponse: { text: word + ' ' } } }); controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } controller.close(); }, }); return stream; }, }, }; export default { fetch: async (request) => { // 处理 SSE 请求 if (request.url.includes('/stream')) { const server = new ApolloServer({ typeDefs, resolvers, plugins: [ApolloServerPluginInlineTraceDisabled()], }); // 简化示例:直接返回 SSE 流 // 实际中需结合 graphql-ws 或 @apollo/server 的 CSRF 处理 const responseStream = await server.executeOperation({ query: 'query { getAIResponse(prompt: "Hello") { text } }', }); return new Response(responseStream.body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); } return new Response('Not Found', { status: 404 }); }, }; async function mockAIGeneration(prompt: string): Promise<string> { return "This is a simulated response from the AI model running on the edge."; }

为什么要在边缘做这件事?

原因在于冷启动时间。如果请求回源到传统的 AWS EC2 服务器,建立连接和加载模型(哪怕是轻量级的逻辑)可能需要几百毫秒。而在 Cloudflare Workers 或 Deno Deploy 上,利用 V8 Isolate 的秒级启动特性,我可以瞬间建立连接并开始推送。

我的取舍判断

虽然 graphql-ws 是订阅的标准,但在这种“一次性流式响应”场景下,我并没有强行使用 WebSocket。原因在于 SSE 在 HTTP/2 下天然支持多路复用,且浏览器会自动处理重连,不需要我写额外的心跳和重连逻辑。Apollo Server 4 的架构足够灵活,让我可以在同一个端口上同时处理标准的 HTTP 查询和这种边缘 SSE 流,这大大降低了运维复杂度。对于 AI 流式输出这种未来趋势,SSE 配合边缘计算是目前性价比最高的架构方案。

站长实战手记

一个让我加班到凌晨三点的订单推送项目

去年我接手了一个跨境电商的订单状态同步需求。业务方要求买家在下单后,能实时看到从“支付中”到“海关清关中”再到“已发货”的每一步变化,而且要求延迟不能超过 1 秒。

当时我选了 graphql-ws 搭配 Redis Pub/Sub 做推送。起初在测试环境跑得挺顺,结果上线当晚流量一上来,运维直接把我叫起来。问题出在 内存泄漏:我发现每个 WebSocket 连接断开时,订阅并没有被正确释放,导致 Node.js 进程的内存每小时涨 200MB。

我盯着日志排查了半天才发现,是因为我在 onDisconnect 钩子里没有手动清理掉那个特定的 Kafka 消费者实例。后来我重构了订阅管理器,把连接生命周期和消费者组绑定在一起,这才稳住了局面。最后的效果是,在 4 核 8G 的容器上,单机扛住了 3 万 个长连接,订单状态更新的 P99 延迟降到了 400ms 左右。

我的真实看法

* 别为了用而用:如果你只是做个后台管理系统,或者数据更新频率低于 10 秒,真的没必要上 GraphQL 订阅。老老实实轮询或者写个简单的 SSE 接口,维护成本比这低多了。

* 微服务架构下的痛:如果你的数据分散在各个微服务里,做订阅其实挺折腾的。你需要一个统一的事件总线,否则前端得连好几个不同的订阅端点,这比 REST 还难管理。

* JWT 过期问题:这是最烦人的。我现在的做法是把 token 校验放在连接初始化阶段,如果过期了直接拒绝连接,而不是在推送中途报错。

给你的建议

如果你正准备在项目中引入这个技术,先别急着写代码。去把 graphql-ws 的源码里关于 ConnectionInitComplete 这两个消息类型的处理逻辑看一遍,理解了连接怎么建立和销毁,你后面能省掉很多莫名其妙的 bug。技术选型没有银弹,能解决业务痛点且团队能维护的,才是最好的。