diff --git a/apps/backend/src/middleware/http-cache.test.ts b/apps/backend/src/middleware/http-cache.test.ts index 0477e622..888a3eaa 100644 --- a/apps/backend/src/middleware/http-cache.test.ts +++ b/apps/backend/src/middleware/http-cache.test.ts @@ -51,3 +51,26 @@ test('dev-mock/status 不能加 HTTP 缓存(它是可切换状态,缓存会 'dev-mock/status 缓存后,POST 切换开关再读会拿到旧值,界面看起来没生效', ) }) + +test('实时广播必须一次序列化、多路复用(不能在每个订阅者循环里各 stringify 一次)', async () => { + const src = await readFile( + new URL('../services/realtime/realtime-event-service.ts', import.meta.url), + 'utf8', + ) + assert.ok(src.includes('function buildEventFrame('), '缺少预构造 SSE 帧的函数') + const broadcastBody = src.slice( + src.indexOf('function broadcast('), + src.indexOf('/** 预先构造一整帧 SSE 报文'), + ) + assert.ok(broadcastBody.includes('const frame = buildEventFrame('), '广播必须先构造一次帧') + const broadcastCode = broadcastBody + .split('\n') + .filter((line) => !line.trim().startsWith('//')) + .join('\n') + assert.equal( + broadcastCode.includes('JSON.stringify'), + false, + '广播代码里不能出现 JSON.stringify:那意味着每个订阅者各序列化一次(生产实测 67 条/秒 × 客户端数)', + ) + assert.ok(broadcastBody.includes('writeEventFrame(subscriber, frame)'), '循环里只应写入同一帧') +}) diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index af678e54..4a36a7a1 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -471,24 +471,37 @@ function isEventVisibleToSubscriber(event: StoredRealtimeEvent, subscriber: Real } function broadcast(subscribers: Iterable, event: RealtimeEvent) { + // 同一份载荷要发给 N 个订阅者,序列化必须只做一次。 + // 生产实测单个后台客户端就能收到约 67 条/秒、每条约 2.7KB 的事件, + // 原实现在订阅者循环里各写一次 JSON.stringify + 模板串,客户端越多越吃亏。 + const frame = buildEventFrame('realtime', event) for (const subscriber of subscribers) { - writeEvent(subscriber, 'realtime', event) + writeEventFrame(subscriber, frame) } } +/** 预先构造一整帧 SSE 报文(含 `id:` 行),供广播复用。 */ +function buildEventFrame(name: string, payload: unknown): string { + const eventId = + name === 'realtime' && payload && typeof payload === 'object' && 'eventId' in payload + ? Number((payload as { eventId?: unknown }).eventId || 0) + : 0 + const idLine = eventId > 0 ? `id: ${eventId}\n` : '' + return `${idLine}event: ${name}\ndata: ${JSON.stringify(payload)}\n\n` +} + function writeEvent(subscriber: RealtimeSubscriber, name: string, payload: unknown) { + writeEventFrame(subscriber, buildEventFrame(name, payload)) +} + +function writeEventFrame(subscriber: RealtimeSubscriber, frame: string) { if (subscriber.response.writableEnded || subscriber.response.destroyed) { removeSubscriber(subscriber) return } try { - const eventId = - name === 'realtime' && payload && typeof payload === 'object' && 'eventId' in payload - ? Number((payload as { eventId?: unknown }).eventId || 0) - : 0 - const idLine = eventId > 0 ? `id: ${eventId}\n` : '' - subscriber.response.write(`${idLine}event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`) + subscriber.response.write(frame) } catch { removeSubscriber(subscriber) }