告别轮询:为什么我们在百万级IM项目中选用GraphQL订阅
前年我们接手了一个重构即时通讯模块的任务,当时面对的是一套跑在千万级用户体量下的老系统。最头疼的问题是消息推送,之前的团队为了解决“新消息提醒”这个问题,居然在客户端用了每秒一次的轮询。你可以想象一下,一百万个用户同时盯着手机,每隔一秒就向服务器发一个请求问“有消息吗?”,服务器哪怕只是回个空响应,那瞬间的QPS也直接飙到了100万。数据库那边的连接池经常被打满,接口耗时从正常的50ms抖动到800ms,用户那边就是消息延迟十几秒才收到,投诉电话快被打爆了。
那次线上事故排查了很久,最后发现是轮询请求把负载均衡的带宽给吃光了。我们当时就在想,这绝对不能这么干了。最开始有人提议用普通的WebSocket,毕竟大家都会写。但我们在设计新架构时,考虑到客户端其实只需要消息的id、content和sender这三个字段,如果服务端每次都把整个消息对象(包含很多内部字段、元数据)一股脑推过去,既浪费流量也不利于客户端解析。
这时候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-ws的connectionParams是在协议层的一个单独的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 建立阶段引入动态校验,而不是仅仅依赖连接时的静态参数。
- 服务端实现:在 Apollo Server 4 的
context 函数中,我不直接信任 connectionParams 里的 Token 过期时间,而是结合 Redis 维护一个活跃 Token 黑名单。
- 补偿机制:当检测到连接断开(可能是 Token 失效或网络问题),客户端在重连逻辑中必须尝试刷新 Token。
以下是服务端处理上下文认证的代码片段:
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 的源码里关于 ConnectionInit 和 Complete 这两个消息类型的处理逻辑看一遍,理解了连接怎么建立和销毁,你后面能省掉很多莫名其妙的 bug。技术选型没有银弹,能解决业务痛点且团队能维护的,才是最好的。