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 个关键点:
Content-Type必须是text/event-stream(否则浏览器不会按 SSE 解析)。- 每条事件最后必须
\n\n(空行分隔)。 - 要能处理断开:
req.on("close")里清理定时器/订阅。 - 要考虑代理缓冲/超时:心跳 + 关闭缓冲。
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:怎么回答最加分
| 方案 | 连接形态 | 通信方向 | 典型优势 | 典型坑 |
|---|---|---|---|---|
| SSE | HTTP 长连接 | 服务端 → 客户端 | 简单、兼容性好、自动重连、天然按事件消费 | 只支持单向;中间层缓冲/超时要处理 |
| 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+ 可重放/可重算