2754 字
约 9 分钟
13
S04-X15 · SSE 与长连接推送:事件流、心跳与断线重连
页面要"实时收到服务端事件",多数人第一反应是 WebSocket。但上行只有心跳和偶尔一次点击时,SSE 成本低一个数量级:一个普通 HTTP 响应、几行文本协议、浏览器自带重连。代价同样明确——只有 GET、单向、对中间层缓冲极敏感。
1. 四种推送方式对比
| 方式 | 连接形态 | 服务端成本 | 延迟 | 上行 | 适用面 |
|---|---|---|---|---|---|
| 短轮询 | 每次一个短请求 | 请求数乘 QPS,无状态 | 取决于间隔 | 天然支持 | 低频、容忍秒级 |
| 长轮询 | 挂起到有数据或超时 | 每连接一个上下文加重连 | 低 | 天然支持 | 事件稀疏、兼容老旧代理 |
| SSE | 单个长 HTTP 响应 | 每连接一个异步上下文加缓冲 | 低 | 无,需另发请求 | 单向推送、要自动重连 |
| WebSocket | 升级后的双向帧 | 每连接一个会话对象 | 最低 | 双向、二进制 | 双向高频繁上行 |
2. 协议细节:四个字段加双换行
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
retry: 3000
id: 1
event: notice
data: {"orderId":"A1024","state":"paid"}
: keep-alive
data: 第一行
data: 第二行
| 字段 | 语法 | 语义 | 注意 |
|---|---|---|---|
data: |
data: 内容 |
事件载荷,多行用换行拼接 | 冒号后一个空格被去掉,其余保留 |
event: |
event: notice |
事件类型,用 addEventListener 收 |
不写即 message |
id: |
id: 1 |
序号,写进 Last-Event-ID |
含 NULL 时被忽略 |
retry: |
retry: 3000 |
重连间隔,毫秒 | 只在流内生效,非法值忽略 |
: 注释行 |
: ping |
心跳或占位 | 不触发 message,刷新空闲计时 |
三个硬约束:双换行才是事件边界,单换行只是续行;流是 UTF-8 文本,二进制得自己 base64;服务端不主动关,连接一直开着。
3. 浏览器端 EventSource 的行为
| 行为 | 说明 | 影响 |
|---|---|---|
| 自动重连 | 按 retry 或浏览器默认间隔重连 |
服务端要能容忍重复连接 |
Last-Event-ID |
重连带最后收到的 id |
断点续传的唯一钩子 |
| 只有 GET | 改不了 POST | 参数只能进 query 或 cookie |
| 无自定义头 | 设不了 Authorization |
Token 只能走 cookie 或 query |
| readyState | 0 连接中 / 1 已开 / 2 已关 | 手动 close 后不再重连 |
| 同源连接数 | HTTP/1.1 每域名约 6 条 | 多标签页互相挤占 |
| 错误事件 | 只给 onerror,不带状态码 |
401 与断网混在一起 |
const es = new EventSource('/api/events?topic=order', { withCredentials: true });
es.addEventListener('notice', e => render(JSON.parse(e.data)));
es.onerror = () => { if (es.readyState === EventSource.CLOSED) fallbackPoll(); };
window.addEventListener('beforeunload', () => es.close()); // 别忘了
onerror 之后浏览器自己重连,除非你 close()。别在前端重写重连,重点是服务端按 Last-Event-ID 补数据。
4. 服务端实现要点
@GetMapping(value = "/api/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter events(@RequestHeader(value = "Last-Event-ID", required = false) String lastId) {
SseEmitter em = new SseEmitter(0L); // 0:不超时,由网关侧控
em.onCompletion(() -> registry.remove(em));
em.onTimeout(() -> registry.remove(em));
em.onError(e -> registry.remove(em));
registry.add(em);
replayFrom(lastId).forEach(e -> send(em, e)); // 断点续传
return em;
}
| 要点 | 做法 | 不做会怎样 |
|---|---|---|
| 响应头 | text/event-stream、no-cache |
被缓存或当普通 JSON |
| 缓冲 | 关容器/网关缓冲,加 X-Accel-Buffering |
事件攒在缓冲,前端看不到 |
| 连接保持 | 异步 Servlet 或响应式栈,超时足够大 | 同步线程被长占 |
| 清理 | 完成、超时、错误三处都注销 | 连接泄漏,内存只涨不降 |
| 心跳 | 定时发注释事件 | 中间层按空闲超时掐断 |
# Spring Boot 3.x 属性名,按版本核对写法
server:
mvc:
async:
request-timeout: -1 # -1 表示不超时
tomcat:
connection-timeout: 60s # 这是读请求头的空闲,不是响应空闲
max-connections: 8192
5. nginx:必须关缓冲的那两行
location /api/events {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 3600s;
chunked_transfer_encoding off;
}
| 指令 | 作用 | 不配的后果 |
|---|---|---|
proxy_buffering off |
收到即转发 | 攒到缓冲满才下发,表现为"延迟几十秒来一批" |
proxy_cache off |
不缓存流 | 第二个用户拿到第一个用户的流 |
gzip off |
不压缩流 | gzip 自己也会攒数据 |
proxy_read_timeout |
读后端响应的空闲上限 | 默认 60s 断开,前端准点重连 |
proxy_http_version 1.1 |
上游长连接 | 复用逻辑可能提前关闭 |
只改超时不关缓冲,症状是"连上、心跳有、业务事件没有":心跳独立小包能即时穿过,业务事件容易被攒住。
6. 连接数管理
| 策略 | 连接数 | 代价 | 适用 |
|---|---|---|---|
| 每用户一条 | 用户数 | 要按 userId 建索引 | 通知类,推荐 |
| 每页面一条 | 用户数乘标签页 | 数量翻倍,清理更难 | 每页订阅不同主题 |
| 不设上限 | 无控制 | fd 与内存耗尽 | 不可取 |
一条连接的常驻成本是一个异步上下文加响应缓冲加若干集合条目,典型量级几十 KB(含容器对象)。一万条连接就是几百 MB 量级。ulimit -n 与容器 nofile 要一起看。
7. 心跳的必要性
心跳解决三件事:中间层空闲超时、NAT 与负载均衡掉表、让客户端区分"没事件"和"连接已死"。
| 决策 | 依据 |
|---|---|
| 间隔上限 | 小于链路中最小的空闲超时(读超时、LB idle、TCP keepalive) |
| 常见取值 | 15–30 秒量级;链路超时很短才需更密 |
| 形式 | 注释行,不触发前端事件,不占 id 序列 |
| 不要做的 | 用 data: {} 当心跳,污染序列还要过滤 |
scheduler.scheduleAtFixedRate(() -> registry.forEach(em -> {
try { em.send(SseEmitter.event().comment("hb")); }
catch (IOException e) { registry.remove(em); } // 顺手清理死连接
}), 0, 20, TimeUnit.SECONDS);
8. 断线重连:id 与幂等重放
服务端维护最近 N 条事件的环形缓冲;收到带 Last-Event-ID 的请求先补缺失区间,再切回实时流。
private final Deque<Event> ring = new ArrayDeque<>(1024); // 只留最近一段
void replay(String lastId, SseEmitter em) {
long from = lastId == null ? 0 : Long.parseLong(lastId) + 1;
ring.stream().filter(e -> e.id() >= from).forEach(e -> send(em, e));
}
id必须单调递增且不重复,用全局序列或时间戳加实例号;- 缓冲只覆盖最近一段,越界时发"请全量刷新"事件;
- 重放要幂等:前端按 id 去重,别假设只送一次。
9. 认证:没有自定义头的世界
| 方案 | 怎么做 | 代价 |
|---|---|---|
| cookie | withCredentials: true,跨域还要 Allow-Credentials |
SameSite 与 CSRF 面变大 |
| query token | /api/events?token=xxx |
token 进 access log、Referer、历史 |
| 一次性票据 | 先换短时 ticket 再进 query | 多一次往返,窗口小 |
| 放弃 EventSource | fetch 加 ReadableStream 解析 |
重连要自己实现 |
选择顺序:同域用 cookie,跨域用票据,非要自定义头就用 fetch 流式读。
10. 多实例下的广播
内存里的 emitter 集合在扩容后立刻失效:用户连在 A,事件产生在 B。
| 方案 | 说明 | 代价 |
|---|---|---|
| Redis Pub/Sub | 各实例订阅同一 channel 再推本机 | 无持久化,掉线期间消息丢失 |
| Redis Stream / Kafka | 有 offset,可按 id 补发 | 运维复杂,延迟略高 |
| 数据库轮询 | 定时查增量表 | 延迟与压力都不划算 |
@EventListener
public void onRemoteEvent(RemoteEvent e) { // 别的实例发布的事件
registry.forEach(em -> send(em, e)); // 只推本机连接
}
发布与本地推送分开写:发布方自己也订阅时,本机连接会收到两次。
11. 与 WebSocket 的选择判据
| 判据 | 选 SSE | 选 WebSocket |
|---|---|---|
| 上行频率 | 心跳加偶发操作 | 高频连续(协作编辑、下单) |
| 方向 | 服务端单向为主 | 双向对等 |
| 载荷 | 文本与 JSON | 需要二进制帧 |
| 基础设施 | 现有 HTTP 链路不用改 | 代理要支持 Upgrade |
| 重连 | 浏览器自带 | 自己写心跳与退避 |
| 老旧代理 | 更稳 | Upgrade 可能被拦 |
一句话:客户端不需要频繁说话,SSE 的运维成本更低;一旦要双向高频,WebSocket 才是对的。
12. 排错:七种现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 事件几十秒后成批到达 | 中间层缓冲 | 关 proxy_buffering,加 X-Accel-Buffering |
| 心跳有、业务事件没有 | 被 gzip 或缓存层攒住 | gzip off、proxy_cache off |
| 准点断开重连 | proxy_read_timeout 默认值或 LB idle |
调大超时并加心跳 |
| 内存只涨不降 | emitter 集合没有在完成/超时/错误时清理 | 三处都注册注销 |
| 重连风暴打满后端 | 客户端无抖动,全部同时重连 | 下发 retry 加客户端随机抖动 |
| 401 与断网分不清 | onerror 不带状态码 |
一次性票据加主动探测 |
| 传文件时页面卡死 | 用 SSE 送大二进制 | 改分片上传,事件只通知"文件就绪" |
13. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 只要"有新数据"提示 | 短轮询(30–60 秒) | 长连接成本高于一次小请求 |
| 海量客户端、极低频事件 | 长轮询或推送网关 | 常驻连接摊不平 |
| 推二进制或大文件 | 事件通知 + 预签名 URL | 事件流是文本协议 |
| 要求严格顺序与重放 | Kafka 加客户端位点 | 环形缓冲只是临时补丁 |
| 内网、客户端可控 | WebSocket | 少一层协议转换 |
14. 自测
- 事件流边界是单换行还是双换行?多行
data:到前端会变成什么? Last-Event-ID由谁发出,服务端拿它做什么?- 只调
proxy_read_timeout不关proxy_buffering,为什么仍看不到业务事件? - 心跳为什么优先用注释行而不是
data: {}? - 事件产生在 B 实例、用户连在 A 实例,缺的是哪一层?
- 出现什么信号时,该把 SSE 换成 WebSocket?
S04-X15 · SSE 与长连接推送:事件流、心跳与断线重连
https://www.clxhxhhr.top/posts/6006/ 评论
0 条
还没有评论,先写一条吧。