跳到主要内容

Express 实现 SSE 流式响应:从协议格式到反向代理踩坑,面试怎么答?

面试速答(30 秒版)

  • SSE(Server-Sent Events)是 基于 HTTP 长连接 的「服务端 → 客户端」单向推送:响应头是 text/event-stream,连接建立后服务端不断 res.write() 事件片段。
  • 适合:进度条/任务状态/轻量通知/LLM 流式输出;不适合:双向高频交互(更偏 WebSocket)。
  • 浏览器端用 EventSource:天然 自动重连,可带 Last-Event-ID 做断线续传;服务端用 id:/retry: 控制重放与重连间隔。
  • Express 关键点:正确头部、立即 flush、事件格式、心跳、防缓冲、断开清理req.on("close"))。

一、先把 SSE 讲清楚:它到底是什么

SSE 是 W3C 定义的一种浏览器能力:用一个普通的 HTTP 请求建立长连接,服务端持续推送「事件」。

它的本质不是“特殊的协议”,而是:

  • 仍然是 HTTP(通常是 GET
  • 响应类型固定:Content-Type: text/event-stream
  • 响应体按约定格式分片输出:data:/event:/id:/retry: + 空行分隔

1) 事件格式(最容易被追问)

一条 SSE 事件由多行 field: value 组成,以 空行 结尾表示“这一条事件结束,浏览器可以派发了”:

id: 42
event: progress
data: {"pct":10}

关键规则(面试可直接背):

  • data: 可以出现多次,多行会被按 \n 拼接成最终 payload(你一般直接发 JSON 字符串即可)。
  • 必须以 空行 结束,否则客户端收不到完整事件。
  • id: 会成为浏览器的 Last-Event-ID,断线重连可用它做“从哪里继续”。
  • retry: 是建议的重连间隔(毫秒)。

二、端到端流程(画图更稳)


三、Express 怎么写:最小可用 + 可扩展

下面给一个“可直接跑”的最小版本(包含:头部、flush、心跳、断线清理、事件格式化)。

1) 服务端(Express)

const express = require("express");

const app = express();

function writeSseEvent(res, { id, event, data, retry }) {
if (retry != null) res.write(`retry: ${retry}\n`);
if (id != null) res.write(`id: ${id}\n`);
if (event) res.write(`event: ${event}\n`);

const payload = typeof data === "string" ? data : JSON.stringify(data);
// data 允许多行,这里简单按换行拆分,避免客户端拼接时丢格式
for (const line of payload.split("\n")) {
res.write(`data: ${line}\n`);
}
res.write("\n"); // 空行:结束一条事件
}

app.get("/api/sse", (req, res) => {
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
// 反向代理(尤其 Nginx)可能会缓冲,常见做法是显式关闭缓冲
res.setHeader("X-Accel-Buffering", "no");

// 让响应头尽快发出去,避免客户端一直 pending
if (typeof res.flushHeaders === "function") res.flushHeaders();

// 建连后先发一条注释行(可选),有些链路上能更快触发 flush
res.write(": connected\n\n");

let nextId = 1;
writeSseEvent(res, {
id: nextId++,
event: "ready",
data: { ok: true, ts: Date.now() },
retry: 2000,
});

// 心跳:避免中间层空闲超时(30s 是常见经验值)
const heartbeat = setInterval(() => {
res.write(": ping\n\n");
}, 30_000);

// 模拟流式进度
const timer = setInterval(() => {
writeSseEvent(res, {
id: nextId++,
event: "progress",
data: { pct: Math.min(100, (nextId - 2) * 10) },
});
if (nextId > 12) {
clearInterval(timer);
writeSseEvent(res, { id: nextId++, event: "done", data: { ok: true } });
res.end();
}
}, 500);

req.on("close", () => {
clearInterval(heartbeat);
clearInterval(timer);
});
});

app.listen(3000, () => {
console.log("SSE server listening on http://localhost:3000");
});

你需要记住的 4 个关键点:

  1. Content-Type 必须是 text/event-stream(否则浏览器不会按 SSE 解析)。
  2. 每条事件最后必须 \n\n(空行分隔)。
  3. 要能处理断开:req.on("close") 里清理定时器/订阅。
  4. 要考虑代理缓冲/超时:心跳 + 关闭缓冲。

2) 浏览器端(EventSource)

const es = new EventSource("/api/sse");

es.addEventListener("ready", (e) => {
console.log("ready:", JSON.parse(e.data));
});

es.addEventListener("progress", (e) => {
console.log("progress:", JSON.parse(e.data));
});

es.addEventListener("done", (e) => {
console.log("done:", JSON.parse(e.data));
es.close();
});

es.onerror = (err) => {
// 断线会触发,浏览器会自动重连(除非 close)
console.log("sse error:", err);
};

四、SSE vs WebSocket vs Long Polling:怎么回答最加分

方案连接形态通信方向典型优势典型坑
SSEHTTP 长连接服务端 → 客户端简单、兼容性好、自动重连、天然按事件消费只支持单向;中间层缓冲/超时要处理
WebSocket升级协议双向双向实时、适合高频交互需要更复杂的网关/负载均衡支持、鉴权与回放更复杂
Long Polling多次 HTTP近似单向最通用(不依赖长连接)额外 RTT、服务端开销大、体验差

面试总结句:

  • “只要是服务端推送为主、事件频率不高、还希望走现有 HTTP 基建,我优先 SSE;需要双向/高频/低延迟才上 WebSocket。”

五、常见追问与标准答法(高频)

1) 断线重连与续传怎么做?

  • 浏览器会自动重连,并把最后收到的 id: 放进请求头 Last-Event-ID(或在某些场景带到 query/自定义机制)。
  • 服务端要支持“从某个 id 继续”:
    • 事件要有 单调递增、可恢复id
    • 服务端存储事件(例如 Redis Stream / DB / 日志队列)或能从业务状态重算

2) 多实例(多进程/多机器)怎么广播?

  • 单机:用 Set 存所有连接的 res,遍历写即可(注意断开清理)。
  • 多实例:不要直接跨进程存 res,改成用 消息总线(Redis Pub/Sub、Kafka 等)把事件广播到每个实例,各实例再写到本机连接。

3) 为什么我写了 res.write(),浏览器却很久才收到?

典型原因是 缓冲

  • Nginx/网关缓冲(需要 proxy_buffering off;,或配 X-Accel-Buffering: no
  • 压缩中间件缓冲(如 compression),SSE 通常应对该路由禁用压缩
  • 平台层(某些 CDN)会聚合 chunk,需按平台文档设置“禁用缓冲/禁用缓存”

4) SSE 的“可靠性”怎么保证?

SSE 的默认语义更接近“尽力而为”,要达到可追溯需要你补齐:

  • 事件 id + 客户端断线重连带 Last-Event-ID
  • 服务端具备按 id 重放能力(存储/可重算)
  • 业务幂等:客户端可能会重复收到(“至少一次”),要用 id 去重

六、易错点 / 坑(背下来能救命)

  • 忘记空行:只写 data: 没写 \n\n,客户端永远不触发回调。
  • 代理超时:没心跳,30~60 秒空闲就被网关断开。
  • 代理缓冲:没关缓冲或被压缩中间件吞 chunk,表现为“卡很久突然喷一堆”。
  • 事件 id 不可恢复:随便用自增变量,服务重启就续不下去;想做续传必须让 id 可持久化或可重算。
  • 大对象频繁推送:SSE 仍然占用连接与内存,推送频率太高要评估 WebSocket/批量策略。

速记要点(可背诵)

  • 头:text/event-stream + no-cache + keep-alive + 关闭缓冲
  • 体:id/event/data + 空行结束一条事件
  • 断:req.on("close") 清理
  • 续:id + Last-Event-ID + 可重放/可重算