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. 自测

  1. 事件流边界是单换行还是双换行?多行 data: 到前端会变成什么?
  2. Last-Event-ID 由谁发出,服务端拿它做什么?
  3. 只调 proxy_read_timeout 不关 proxy_buffering,为什么仍看不到业务事件?
  4. 心跳为什么优先用注释行而不是 data: {}?
  5. 事件产生在 B 实例、用户连在 A 实例,缺的是哪一层?
  6. 出现什么信号时,该把 SSE 换成 WebSocket?
S04-X15 · SSE 与长连接推送:事件流、心跳与断线重连
https://www.clxhxhhr.top/posts/6006/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。