bigywater.tech
网络协议 · 88

Server-Sent Events:基于 HTTP 的单向推送

SSE 是 HTTP 里最简单的“服务器推送”:一个不结束的响应,一行行文本。它没有 WebSocket 的双向和二进制,但自带断线重连。这篇用本机服务器逐个验证它的格式、重连规则,以及和 WebSocket、反复请求相比每条消息的开销。

Last-Event-ID
断线重连时客户端带上的请求头;三轮收到的 id 是 [1,2,3] [4,5,6] [7,8,9],无重复无缺失
34 / 22 / 85 字节
推送 20 字节消息时 SSE / WebSocket / 反复请求 每条的服务器→客户端开销
504 ms
客户端按 retry: 500 重连时,断线到下一次请求到达服务器的间隔

流式响应

实测 · 本机执行输出
  响应头:HTTP/1.1 200 OK | Content-Type: text/event-stream | Cache-Control: no-cache | Transfer-Encoding: chunked
  事件流文本(117 字节):b'retry: 500\n\nid: 1\nevent: tick\ndata: message 1\n\nid: 2\nevent: tick\ndata: message 2\n\nid: 3\nevent: tick\ndata: message 3\n\n'
  每个数据块到达的时刻(从发出请求起,ms):0, 205, 410, 616
  连接在发完 3 个事件后被服务器掐断(没有结尾的 0 长度块):正常结束 = False

一个不设 Content-Length 的 chunked 响应,Content-Type: text/event-stream,服务器想发就发。

文本格式

实测 · 本机执行输出
  服务器发出的原始文本:
    ': 这是注释,用于保活'
    ''
    'data: 第一行'
    'data: 第二行'
    ''
    'event: update'
    'id: 7'
    'data: {"x":1}'
    ''
    'data:no-space'
    ''
    'unknown: field'
    'data: after-unknown'
    ''
    'data: 没有结尾空行的事件'
  派发的事件:类型 'message',数据 '第一行\n第二行',id None
  派发的事件:类型 'update',数据 '{"x":1}',id '7'
  派发的事件:类型 'message',数据 'no-space',id None
  派发的事件:类型 'message',数据 'after-unknown',id None
  注释行(: 开头)被忽略;两个 data 行变成一个事件、值用换行连接;"data:no-space" 冒号后没有空格也行;未知字段 unknown 被忽略;最后一个事件没有结尾空行,不会被派发(解析器只在遇到空行时派发)

字段名: 值,空行结束一个事件;多行 data 用换行连接;注释行用于保活;没有结尾空行的事件不会派发。

重连

实测 · 本机执行输出
  三轮收到的事件 id:[['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']](没有重复、没有缺失:每次从 Last-Event-ID 的下一个继续)
  服务器记录到的三次请求带的 Last-Event-ID:[None, '3', '6']
  断线(我们读到连接被掐断)到下一次请求到达服务器的间隔:[504, 502] ms(客户端按服务器给的 retry: 500 睡了 500 ms,再加上建立连接的时间)

服务器用 retry 指定重连等待时间,客户端用 Last-Event-ID 请求头告知断点,实现不丢不重。

开销

实测 · 本机执行输出
  SSE:服务器发出 34110 字节(响应头 105 字节,之后每条 34.0 字节:"data: " 6 + 20 字节载荷 + "\n\n" 2 = 28 字节事件文本,再加分块框架 "1c\r\n" 和 "\r\n" 共 6 字节 = 34 字节)
  WebSocket:每条帧头 2 字节 + 20 字节载荷 = 22 字节(服务器→客户端不掩码,<126 字节的载荷),1000 条 22000 字节(不含一次性的升级握手)
  反复请求(同一条 keep-alive 连接上 1000 次 GET):服务器→客户端 85000 字节(每次响应头约 65 字节 + 20 字节载荷);另外客户端→服务器每次还要发 51 字节的请求
  按单条消息:SSE 34 字节、WebSocket 22 字节、反复请求 85 字节;反复请求还多一个往返的延迟

每条 20 字节消息:WebSocket 22 字节,SSE 34 字节,反复请求 85 字节(还多一个往返)。

分步演示

SSE
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。全部在本机,服务器是我写的线程 + 套接字小程序;重连逻辑是我自己写的客户端实现的,不是浏览器的 EventSource;第四节的 SSE 与反复请求是实测字节数,WebSocket 是按帧格式算出来的(2 字节帧头 + 载荷)
    • 没有验证:浏览器 EventSource 的真实行为;HTTP/1.1 下浏览器对同一主机的 6 个连接上限对 SSE 的影响;HTTP/2 上 SSE 的多路复用;代理或负载均衡对长连接的缓冲和空闲超时;事件流的字符编码(只用 UTF-8);服务器发送 retry 之外的退避策略
    京ICP备2026067502号-1京公网安备11010502063487号