SSE(Server-Sent Events)是一种基于HTTP的单向推送技术,客户端通过EventSource建立长连接后,服务端可以持续向浏览器发送文本事件。相比WebSocket,SSE更轻量,原生支持自动重连和事件ID,在通知流、日志推送、大模型流式输出等场景中非常实用。但长连接一旦断开,如果服务端没有记录客户端消费到哪一条,重连后要么重新发送全部历史数据,要么直接从最新事件开始,导致中间的消息丢失。这个问题的标准解法就是利用SSE规范中的id字段和Last-Event-ID请求头,实现断点续传。

下面深入拆解整个机制,从连接中断的原因到服务端和客户端的完整配合方案,最后给出生产环境中的优化建议。
SSE连接为什么容易中断
SSE依赖的是一条长时间的HTTP响应流,但在真实的网络环境中,这条连接可能被多个中间环节主动切断。比如移动设备切换基站或Wi-Fi时,底层TCP连接会重建;Nginx、Apache等反向代理默认对上游响应的读取超时通常是60秒,如果服务端在这段时间内没有发送任何数据,代理就会断开连接;一些运营商网关或企业防火墙也会对空闲长连接进行回收。
连接断开后,浏览器端的EventSource对象会按照规范自动发起重连,默认间隔约为3秒。问题在于,自动重连只是重新建立了HTTP连接,服务端如果没有额外处理,并不知道客户端最后一次收到的事件是哪一条。于是常见做法是重新发送全部事件,或者只发最新事件,前者造成大量重复流量,后者导致断线期间的消息永久丢失。
要解决这个矛盾,就需要在事件流中给每条事件打上唯一标识,并让客户端在重连时把这个标识回传给服务端,服务端据此定位断点。SSE规范已经内置了这套机制,核心就是事件id字段和Last-Event-ID请求头。
理解Last-Event-ID的协议语义
SSE的响应格式由多行文本组成,每条事件可以包含id、event、data等字段,以空行分隔。例如服务端向客户端发送这样一条事件:
id: 100 data: 用户订单已支付
客户端收到后,会把lastEventId更新为100。当连接意外断开,浏览器自动重连时,会在新的HTTP请求头中自动携带Last-Event-ID: 100。服务端只需要读取这个请求头,就知道客户端已经处理到id为100的事件,接下来应该从101开始发送。需要注意的是,这个请求头是由浏览器EventSource实现自动添加的,开发者不需要手动设置。
如果事件流中没有提供id字段,客户端就没有办法维护lastEventId,重连请求也不会携带Last-Event-ID头,断点续传自然无从谈起。因此第一条规则就是:只要希望支持断点续传,服务端必须在每条事件中明确给出id。id可以是数字,也可以是字符串,但要保证在业务范围内单调递增或可以排序,否则无法正确定位游标。
此外,SSE还支持retry字段,服务端可以通过它指定客户端重连间隔,例如retry: 5000表示断线后5秒重试。这个机制也能配合断点续传使用,降低服务端压力。
服务端实现断点续传
理解了协议语义后,用Node.js实现一个支持断点续传的SSE服务并不复杂。这里以内存数组作为消息存储,实际生产环境可以替换为Redis列表或数据库表。服务端需要完成三件事:接收连接时读取Last-Event-ID头;根据该ID找到后续事件并发送;在新事件产生时实时推送给所有在线客户端。
以下是一个完整的Node.js原生实现,不依赖Express,方便看清每一步逻辑:
const http = require('http');
const messages = [];
let nextId = 1;
const clients = new Set();
function dispatch(event) {
for (const client of clients) {
client.res.write(`id: ${event.id}\n`);
client.res.write(`data: ${event.data}\n\n`);
}
}
setInterval(() => {
const event = { id: nextId, data: `消息内容 ${nextId}` };
messages.push(event);
if (messages.length > 200) {
messages.shift();
}
dispatch(event);
nextId += 1;
}, 5000);
http.createServer((req, res) => {
if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const lastEventId = req.headers['last-event-id'];
let startIndex = 0;
if (lastEventId) {
const lastId = parseInt(lastEventId, 10);
const index = messages.findIndex(msg => msg.id === lastId);
startIndex = index === -1 ? messages.length : index + 1;
}
for (let i = startIndex; i < messages.length; i++) {
res.write(`id: ${messages[i].id}\n`);
res.write(`data: ${messages[i].data}\n\n`);
}
const clientInfo = { res };
clients.add(clientInfo);
req.on('close', () => {
clients.delete(clientInfo);
});
} else {
res.writeHead(404);
res.end();
}
}).listen(8080, () => {
console.log('SSE服务运行在8080端口');
});
这段代码中,messages数组模拟了最近的事件历史,客户端重连时先根据Last-Event-ID头找到上次消费的位置,再从下一条开始补发。如果消息已经不在历史数组中,就会从当前最新位置继续,这在生产环境中可能需要结合持久化存储来解决。另外,每个客户端连接被放入clients集合,当有新的模拟事件产生时,会逐个推送。
需要注意的是,服务端在发送响应后不要调用res.end(),否则连接会立即关闭,客户端会不断重连。实际项目中还要处理客户端断开的清理,避免集合无限增长。这个示例用req.on('close')来移除断开连接,简单有效。
客户端重连与事件ID管理
浏览器端的EventSource会自动处理重连,开发者只需要监听事件即可。在一个SSE连接中,onmessage会在每次收到事件时触发,事件对象event.lastEventId就是这条事件的id,也是下次自动重连时浏览器会回传的值。
const source = new EventSource('/events');
source.onmessage = (event) => {
console.log('事件ID:', event.lastEventId);
console.log('数据:', event.data);
};
source.onopen = () => {
console.log('连接已建立');
};
source.onerror = () => {
console.log('连接中断,浏览器会自动重连并携带Last-Event-ID');
};
这里没有手动处理重连逻辑,因为EventSource规范要求浏览器在连接失败后自动重试。服务端返回retry字段可以调整重试间隔,客户端无需额外代码。但有一点必须注意:onerror触发时不要立即调用source.close(),除非业务上确实要停止接收。如果只是网络抖动,浏览器会自动恢复连接。
如果开发者使用fetch或其他HTTP客户端手动实现SSE,就需要自己维护lastEventId并在重连时手动设置Last-Event-ID请求头。这种方式更灵活,比如可以控制重连策略、增加认证信息,但工作量也更大。对大多数场景来说,原生EventSource已经足够可靠。
生产环境中的常见坑与优化
断点续传虽然机制简单,但在生产环境落地时还有几个容易踩的坑。首先是反向代理的超时配置。Nginx默认proxy_read_timeout为60秒,如果SSE服务端长时间没有事件产生,连接会被Nginx断开。解决办法是在Nginx的location块中关闭缓冲并延长读取超时,同时服务端周期性发送心跳注释行。
location /events {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Connection '';
proxy_http_version 1.1;
}
这里的proxy_set_header Connection ''用于防止Nginx把长连接头部处理成关闭连接。SSE服务端还可以每隔15到30秒发送一行冒号开头的注释,例如: ping,这不会产生事件,但能保持TCP链路活跃,防止中间设备因为空闲而断开。
另一个问题是事件存储的清理策略。服务端不能无限保存历史事件,否则内存会持续增长。通常可以设置一个最大长度或过期时间,比如只保留最近1000条或最近1小时的事件。对于需要强一致性的场景,可以把事件ID和消息体写入Redis列表,客户端重连时从列表的某个位置开始读取,并用游标标记每个客户端的消费进度。这样即使服务端重启,断点信息也不会丢失。
还要注意事件ID的生成方式。自增数字简单直观,但如果服务端有多个实例负载均衡,就需要保证全局唯一且有序,可以使用数据库序列或带时间戳的雪花算法。否则两个实例生成的ID可能冲突,导致客户端定位游标错误。
最后,断点续传也会带来重复消费的可能。例如客户端在收到事件后、调用业务处理前连接断开,重连后服务端会重新发送这条事件。业务侧需要做好幂等处理,用事件ID作为去重键,避免重复下单或重复通知。
SSELast-Event-ID断点续传修改时间:2026-09-24 16:07:15