10,000 个用户每秒轮询一次价格,一个月是 259 亿次请求。换成长连接后,就是 10,000 条 TCP 连接,服务端每秒推一万帧。省下的请求费用容易算,要定的是另外两件事:选 SSE 还是 WebSocket,以及一台机器上各自先受哪个限制。

轮询的成本是用户数乘更新频率#

先算轮询的成本。这 259 亿次请求按 AWS API Gateway 的公开价格(us-east-1)算,REST API 每月大约 5.7 万美元:前 3.33 亿次每百万 3.50 美元,超过 200 亿次的部分降到每百万 1.51 美元。这个费用和数据变没变无关:价格十分钟不动,这十分钟里的六百万次请求照样计费。

服务端每处理一次请求,都要新建一批对象。具体是哪些对象取决于技术栈,本文压测用的是 Spring Boot + Tomcat,每个 HTTP 请求要创建 Http11Processor、coyote.Request / coyote.Response,以及它们背后的 header 和 chunk 缓冲。轮询每秒要把这些对象创建再回收一万次,开销体现在分配率上。长连接省掉了这部分分配,代价是这些对象随连接一直留在内存里。

客户端也有上限。浏览器对每个域名最多开 6 条 HTTP/1.1 连接,单次请求按 100 到 200 ms 算(DEV Community 上那篇轮询成本分析 Why 1-second polling doesn’t scale 给的区间,取 150 ms;下文简称轮询那篇),按 Little’s Law,一个浏览器实例最多约 40 req/s(OceanBase 与 TiDB 那轮容量压测里算吞吐用的也是这个公式)。6 条不是规范规定的,RFC 9112 §9.4 没有限定条数,这是浏览器实现上的长期惯例。

如果更新周期在 30 s 以上、并发用户不到一千,继续轮询就可以。这是轮询那篇给的经验值(原文说低频更新取 30 s 以上,并附了一个 1,000 用户的算例)。

SSE 和 WebSocket 的协议差别:载荷、方向、重连#

两者都是一条保持打开的 TCP 连接,服务端可以随时推送。区别在三处,彼此独立:载荷格式、通信方向、断线后由谁负责重连。

结论比较简单:数据只从服务端流向浏览器、载荷是文本,用 SSE 就够;两端要频繁双向收发(协同编辑、下单、多人光标),或者载荷本身是二进制,用 WebSocket。同一个产品里两种都用也很常见,比如公开行情走 SSE、鉴权后的交易走 WebSocket。下面三节分别说明。

载荷:文本还是二进制#

SSE 的载荷是 UTF-8 文本,规范写明事件流一律按 UTF-8 解码。每行是 字段名: 值,空行表示一条消息结束。字段只有四个:data、event、id、retry,以冒号开头的行是注释:

event: newMessage
data: {"user":"Alice","text":"Hello!"}
id: 1692632147971-0

: 这行是注释,服务端定期发送,用来保持空闲连接

所以传二进制要付出代价:字节要先编码成文本才能放进 data,base64 用 4 个字符表示 3 个字节,体积增加三分之一,两端各多一次编解码。WebSocket 的帧带类型,opcode 区分文本和二进制(RFC 6455 §5.6),二进制可以直接传。

方向:单向还是双向#

SSE 连接只能由服务端往客户端发。客户端要发数据,就另发一个普通 HTTP 请求,整个系统是一条长连接加若干短请求。这些短请求通常直接用现有的 REST 接口和鉴权中间件。WebSocket 在一条连接上双向通信,省掉了额外的请求往返,但订阅关系、心跳、重连都要应用自己维护。

重连:SSE 由浏览器负责,WebSocket 要自己写#

SSE 的重连由规范定义、浏览器实现:连接断开时,EventSource 先触发 error 事件,等待 reconnection time,然后在 Last-Event-ID 请求头里带上最后收到的 event ID 重新请求(HTML 标准 §9.2.3、§9.2.4),等待时间由 retry 字段设置。有两点要注意:

  • 规范对退避的写法是「Optionally, wait some more」:允许浏览器加退避,但不强制,也没提抖动。想让重连分散开,要自己设 retry,或者换一个能控制重试逻辑的客户端库(原生 EventSource 也不能带 Authorization 头)。
  • 规范里还有一个 fail the connection 分支:进入后 readyState 变为 CLOSED,浏览器不再重连。状态码或 Content-Type 不对就会进入这个分支,所以服务端要显式返回 200 和 text/event-stream,并立即 flush 一次响应头,否则容器会等到第一次写数据时才发送响应头。

WebSocket 没有这些机制,重连、退避、状态恢复都要自己写。轮询那篇给的客户端示例就是自己实现的指数退避加随机抖动(延时逐次翻倍、上限 30 s,再加一段 Math.random() 抖动)。规范还有两条强制要求:客户端发出的帧必须掩码(RFC 6455 §5.3),收到 Ping 必须尽快回 Pong(§5.5.2),压测时不回 Pong 的连接会在 run 中途被服务端关闭。

两种连接都要自己发 keepalive,间隔取决于中间的代理和防火墙:本文两条路径都是 15 s 没有数据就发一次心跳。轮询那篇的灰度计划里,第三周遇到的问题是企业代理会在 60 秒后静默断开 SSE。Artera 的迁移记录 Our Journey to a Scalable SSE Architecture 提到,早期试验中企业防火墙经常拦掉 WebSocket 依赖的 Upgrade 头;轮询那篇给的降级顺序也是 WebSocket 连不上就退到 SSE。

压测方法:两种 transport 共用同一条推送链路#

下面的数据来自 sse-demo(commit 850a668)的 91 次 run。/sse/stream(SseEmitter)和 /ws/stream(WebSocketHandler)注册在同一个进程里,推送链路上的代码完全共用。如果分成两个进程,堆、GC 和容器配置都不同,测出的差异就分不清来自哪里。

共用的部分 具体规则
帧字节 Frame 每个 tick 每个 room 只序列化一次,两边用同一个对象
连接规则 每条连接一个 ArrayBlockingQueue(容量 256)、一个虚拟线程上的阻塞写循环;队列满就关闭连接;15 s 没有帧就发一次心跳
注册表与扇出 Registry + TickPublisher,由一条平台线程负责组帧和入队
资源限额 --cpus 4 --memory 8g,两边用同一组 JVM 参数
flowchart TD subgraph shared["两种 transport 共用:组帧、注册表、队列、写循环"] T["TickPublisher
一条平台线程, 1 Hz"] F["Frame
每 tick 每 room 组一次字节"] R["Registry
room 内全部 session"] Q1["Session A 的队列
有界 256"] Q2["Session B 的队列
有界 256"] W1["虚拟线程写循环 A"] W2["虚拟线程写循环 B"] end S1["SseSink
text/event-stream"] S2["WsSink
WebSocket frame"] T --> F --> R R --> Q1 --> W1 R --> Q2 --> W2 W1 -->|"Session A 是 SSE"| S1 W2 -->|"Session B 是 WebSocket"| S2

两者不同的只有最后把帧写给内核这一步,以及容器为每条连接维护的对象。慢消费者的处理也一样:队列满了直接关闭连接,不等它慢慢写。否则一个卡住的客户端会让堆慢慢变大,统计的连接数里也会混进实际没在收数据的连接。

每一档连接数算通过,要同时满足四条:建连成功率 ≥ 99%、保持期间基本不掉线、投递率 ≥ 0.99、p99 < 1000 ms。只有投递率能区分「连接还在」和「数据送到了」:一台机器可以保持五万条连接却只给一半推送数据,只统计连接数的压测会把这算作通过。

这些数字成立的前提#

  • 机器:单机 OrbStack 容器,--cpus 4 --memory 8g,真实的 Linux 内核和 cgroup 限额,但不是物理机,也没有跨主机网络。结论的方向可以引用,具体倍数引用时要注明容器和规模。
  • 负载:单向推送,1 Hz,每帧 265 字节,从服务端到客户端。双向通信的吞吐、每帧掩码的开销都不在这些数据里,比较承载能力时只能用同负载、同容器下的数字。
  • 每连接内存的算法:内存按 空载基线 + 每连接斜率 × 连接数 两部分算。每连接的部分用满载减空载,再除以连接数,不直接用满载除以连接数。各技术栈的空载内存相差 267 倍(Rust 1.1 MB 到 Tomcat 294 MB),把这部分固定开销摊进每连接,结果就不准了。
  • 两种内存口径:存活堆(heapLive)和 RSS 的结果不一样,同一个运行时能差 3.5 倍。跨运行时比较只能用 RSS,因为 Go 的 HeapAlloc 和 JVM 的存活集含义不同,Rust 没有托管堆(ALL-RUNS.csv 里 rust 各档的 heapLiveMb 是 −1,表示没有这个数据)。
  • p99 是直方图桶的上界:压测端用每倍频程 4 个子桶的直方图统计延迟,报出的 p99 是所在桶的上界,所以读数都是 164、328、655、1049 ms 这类桶边界,最大误差 25%。相邻两个桶的差异不说明问题,差几个桶才算有差别。
  • 跨语言不对等:Go 和 Rust 是最小实现,没有鉴权、tracing、优雅停机,而且只实现了 /sse/stream,用 MODE=ws 请求会返回 404,所以没法做 WebSocket 的跨语言对比。
  • 波动:ceiling.sh 每档只跑一次,接近判据阈值时波动很大,同一档同一配置两次 run 的 p99 能差 3 倍,例子见扇出耗时那一节。离阈值有 2 到 3 倍余量的档位可信,接近阈值的档位要按区间理解。
  • 旧二进制:三个 Java 镜像在 2026-09-18 重建过一次,重建前的 run 用的是另一个二进制,源码没有保留,下文叫它旧二进制。当时 Tomcat 的 socket 缓冲还在堆内,仓库现在默认放到堆外,要复现旧数据得显式带上 DIRECT_BUFFER=false。和当前镜像对照过的几个数:Tomcat SSE 每连接 111.8 KB 对 112.3 KB,差不到 1%;Jetty、Netty 的 SSE 每连接最多变了 5.3%,Netty 的 WebSocket 从 10.4 KB 涨到 13.3 KB。
  • 没测的:TLS、网关与 LB、重连风暴、Go 与 Rust 的生产特性。最后一章的多实例内容全部来自公开资料,本文没有实测。

每连接内存:SSE 和 WebSocket 哪边多,取决于容器#

20,000 条 SSE 连接、每个客户端一条,heapLive(存活堆)口径:

容器 SSE 每连接存活堆 WebSocket 每连接存活堆 SSE 相对 WS
Tomcat(各自默认缓冲,缓冲在堆内时的数据) 111.8 KB 78 KB +43%
Jetty(Spring MVC 不变) 18.3 KB 19.9 KB −7.8%
WebFlux / Netty 19.9 KB 13.3 KB +50%

Tomcat 那一行是旧二进制上测的,和当前镜像差不到 1%,对照见前提那一节;Jetty 和 Netty 两行是当前镜像的数据。注意此处的 111.8 KB 仅统计堆内 Java 对象,不包含堆外 socket 缓冲及 JVM 本地内存,不能与后文 RSS 口径的数据直接比大小。

SSE 每条连接比 WebSocket 多占多少内存,没有固定的答案。这张表里三个容器三个结果,再加上 Netty 在 10 万连接下的 RSS,一共四个:Tomcat 多 43%,Jetty 少 7.8%,Netty 在 2 万连接时存活堆多 50%,在 10 万连接时 RSS 多 63%。差值取决于容器、连接规模和内存口径,不能当成协议本身的固定属性来引用。下面三节分别看 Tomcat 上 SSE 为什么占得多、Netty 在 10 万连接下的 RSS,以及换容器和换语言各差多少。

Tomcat 的 SSE 连接常驻一整套请求对象#

用堆直方图做差分(GC.class_histogram,会强制 full GC),可以逐项看到 Tomcat 上多出来的对象。SSE 每条连接持有 41 个 MessageBytes、45 个 ByteChunk、42 个 CharChunk,加上完整的 coyote.Request / Http11Processor,合计 111.9 KB,和表里的存活堆斜率只差 0.1 KB。其中最大的是 4 个约 8 KB 的 char 数组,共 32.9 KB,Tomcat 的两个缓冲参数都调不到它。WebSocket 每条连接只有三个 Ws* 对象,合计 56.1 KB。这个数比表里的 78 KB 少,是因为做直方图的 bench/profile.sh 把 WS 缓冲设成了 1 KB,表里用的是默认的 8 KB(RESULTS.md §2 两种都有)。

这些正是轮询时每秒要创建一万次的 HTTP 请求对象。SSE 的请求一直不结束,所以它们随连接一直留在堆上。SSE 的分配率也更高:同一负载下 60 s 的 JFR 记录里,SSE 的分配采样是 WebSocket 的 1.8 倍,排前三的都是每次 send() 时的字符串处理和 media type 解析。

在换协议之前,有一个改动更小的办法:把 SSE 的两个 NIO socket 缓冲放到堆外。每条连接少占 14.18 KB 堆,连接上限从 50,000 提高到 58,000,5 万连接时的 p99 从 786 ms 降到 328 ms。这相差好几个桶,而且 786 ms 在同配置的四次 run 里都一样,不是偶然波动。p99 下降是因为堆的压力小了:堆占用从上限的 95% 降到 83%,GC 负担也随之减轻。这个改动只涉及配置和 pom 里的 servlet 容器 starter,换 transport 却要改客户端接口。

后文表格里出现的「Tomcat 全调优」,具体包含四项调整:调大连接数计数器(server.tomcat.max-connections=200000,默认 8192 会静默丢弃连接)、放大握手队列(server.tomcat.accept-count=4096,默认 100 会拒绝并发建连)、启用虚拟线程(spring.threads.virtual.enabled=true,避免数万个平台线程栈占满物理内存),以及把 socket 缓冲移到堆外(DIRECT_BUFFER=true,降低堆占用)。

Netty 在 10 万连接下,SSE 的 RSS 多 63%#

在 Netty 上把连接数加到 10 万,并改用 RSS 统计,差值又不一样了:

Netty @ 100,000 连接 SSE WebSocket SSE 相对 WS
RSS 3976 MB 2433 MB +63%
存活堆 1956.5 MB 1287.2 MB +52%
扇出耗时(最大值) 381.4 ms 199.2 ms +91%
p99 524 ms 459 ms 高一个桶
openFds 100,022 100,022 相同

扇出耗时是一个 tick 内把帧放进所有 session 队列的总耗时,取整个 run 的最大值,tick 周期是 1 s。两边的 openFds 相同,说明 WebSocket 确实承载了 10 万条连接,这 63% 的差距不是因为负载没加上去。

常有人说「并发到某个量级就该换 WebSocket」,本文的容量数据支持不了这个说法:测到的最高一档是 10 万连接,两种 transport 在 Netty 上都还没到上限;如果用 Tomcat,先遇到的是默认 8192 的连接数上限。

换容器差 4 倍,换语言只差 7%#

五种运行时的 SSE @ 20,000,RSS(物理内存)口径:

运行时 空载 RSS 满载 RSS 每连接 RSS p99 扇出耗时
Java · Tomcat(全调优) 294 MB 3187 MB 148.1 KB 164 ms 70 ms
Java · Jetty 272 MB 1523 MB 64.1 KB 164 ms 62 ms
Java · Netty(WebFlux) 263 MB 966 MB 36.0 KB 131 ms 103 ms
Go · net/http 6.6 MB 732 MB 37.1 KB 115 ms 123 ms
Rust · axum / tokio 1.1 MB 680 MB 34.7 KB 115 ms 47 ms

注意这里的 148.1 KB 是 cgroup 统计的物理内存(RSS)口径。Tomcat 即使全调优后去除了平台线程栈、把 socket 缓冲挪到堆外省了堆,但堆外缓冲和 JVM 本地内存依然算进 RSS,分摊到每连接仍有 148.1 KB。它与前文存活堆口径的 111.8 KB 是两套不同的测量口径,不能直接比大小。

Netty 36.0 KB、Go 37.1 KB、Rust 34.7 KB,相差不到 7%。有 GC 的 JVM 和 Go、没有 GC 的 Rust 结果接近,说明这 35 KB 左右主要是每条连接的 I/O 缓冲,和语言关系不大。数量级的差别只出现在空载基线上,而空载是固定开销:Netty 空载 263 MB,分摊到 1,000 条连接是每条 269 KB,分摊到 10 万条只剩 2.7 KB。JVM 内部 4 倍的差距(148 KB 对 36 KB)来自 Tomcat,换容器就能省下来。

Jetty 的两种内存口径差得最远:每连接存活堆 18.3 KB,是三个 Java 容器里最低的;每连接 RSS 64.1 KB,在五种运行时里仅次于 Tomcat。两个数差 3.5 倍。cgroup 据以判断 OOM 的就是进程实际占用的物理内存,也就是 RSS,所以按内存选型要看 RSS,只看存活堆会把 RSS 偏高的 Jetty 当成最省内存的。

单机容量:先后遇到的四个限制#

上一章看每条连接占多少内存,这一章看一台机器能承载多少条连接。按触发时的连接数从少到多,依次是四个限制:Tomcat 的连接数计数器、堆、扇出耗时、CPU,另外还有一种不报错的失败。

连接数上限是一个计数器,与线程模型无关#

Tomcat 的 maxConnections 默认 8192,比常见的「单机 1 万连接」目标低,而且超出时不报错:请求 12,000 条连接,只建立了 8,192 条,failed=0、rejected=0,日志里也没有记录。它是 NIO connector 上的一个计数器,和 executor 无关,开了虚拟线程也没用。操作系统层面也能看到这一点:5,000 条连接时进程只有 40 个 OS 线程,也没有任何 http-nio-8080-exec-N 线程,说明虚拟线程确实生效了,但上限仍然是这个计数器。

还有三个更低的限制,看起来像服务端到了上限,查上限之前要先排除:accept-count 默认 100,每秒 2,000 条的建连速度会出现拒绝;文件描述符默认 1024,会报 Too many open files;压测端每个源地址只有约 28 K 个临时端口。

堆的上限可以算出来#

调大 maxConnections 之后,当前镜像上 Tomcat SSE 遇到的下一个限制是堆。MaxRAMPercentage=70 时堆上限是 5736 MB,按实测每连接 112 KB(缓冲在堆内)和 74 MB 空载基线计算:

(5736 − 74) × 1024 / 112 = 51,767 连接

60,000 档在 OOM 之前建立了 52,345 条连接,比预测多 1.1%;把 MaxRAMPercentage 提到 80% 再算,预测 59,264、实测 59,948,多 1.2%。五个档位的每连接堆都在 110.4 到 113.4 KB 之间,基本不变,所以这样外推有依据;文中只有 51,767 和 59,264 是算出来的,其他都是实测值。50,000 档重复三次都通过,堆占 heapMax 的 95.2%。所以这个上限应该写成「5 万能过,5.2 万到顶」,写成单个数字容易误导。

扇出耗时:平稳增长和突然跳高#

10 万连接时,Netty 的满载 RSS 是 3976 MB(SSE)和 2433 MB(WebSocket),分别占 8 GB 的一半和三成,所以先到上限的不一定是内存。扇出耗时的增长有两种情况。内存还充足时,它随连接数平稳增长,没有突变,Netty、Go、Rust 都是这样:

运行时 40,000 连接 100,000 连接
Rust 36.5 ms 66.2 ms
Go 165.6 ms 416.8 ms
Netty 141.6 ms 381.4 ms

三者都没有达到 1 s,所以本文不知道它们在多少连接时会超过 tick 周期,也不做外推。

另一种是突然跳高。实际观测到的两次超时都在 Tomcat 上,原因相同:GC 占满 CPU,单线程的扇出拿不到 CPU。当前镜像把堆放宽到 80% 之后,5.5 万连接时扇出 282 ms,6 万连接时 1583 ms,此时存活堆 6522 MB,是 6556 MB 上限的 99.5%。堆接近上限后,GC 占用了扇出线程需要的 CPU。这种情况没法用线性外推来预测。

另一次是在旧二进制上,只用来说明跳高的现象,不用来确定上限(当前镜像同配置 4 万连接时 p99 262 ms、扇出 63 ms)。当时 Tomcat SSE 在 3 万连接时 p99 是 262 ms;4 万连接、单线程扇出跑了三次:调过缓冲的两次 p99 分别是 655 ms 和 2097 ms,相差 3 倍,就是前提里说的波动;没调缓冲的那次 p99 是 6291 ms,扇出耗时 2575 ms,超过了 1 s 的 tick 周期,服务端 CPU 的中位数一直在 400%,4 个 vCPU 全满。

要不要把扇出分片,看扇出耗时离 tick 周期还有多远,不看连接数。旧二进制上,Tomcat 同一配置改成四个扇出线程,SSE 4 万连接的扇出耗时从 469 ms 降到 117 ms,正好是四分之一,p99 从 655 ms 降到 459 ms,低了两个桶。那组 run 的堆只用了上限的六成多,扇出慢是因为扇出线程本身,与 GC 无关。作为对照,WS 6 万连接的扇出本来只有 126 ms,分片后是 148 ms,p99 两次都是 393 ms。可见只有扇出本身是瓶颈时,分片才有效果。

僵尸状态:连接都在,数据没送出#

Tomcat 的堆放宽到 80% 后,OOM 出现的位置推到约 59,900 条连接,这时另一个问题暴露出来了。6 万档的 60,000 条连接全部建立、没有掉线、也没有 OOM,但投递率只有 0.269,p99 83,886 ms,扇出耗时 1583 ms。原因和上一节一样:堆接近上限但没触发 OOM,GC 占满 CPU,扇出线程得不到执行。进程正常,六万条连接也正常,但四分之三的帧没有送出去。

OOM 至少容易发现:ExitOnOutOfMemoryError 让进程退出,编排层重启它,日志里有记录。僵尸状态不报错,按进程存活或连接数做的健康检查发现不了。70% 的配置反而更容易排查,因为进程在进入僵尸状态之前就因 OOM 退出了。如果要用堆上限从 70% 提到 80% 多出来的 16% 容量(51,767 到 59,948),健康检查就要改成看投递率或 fanOutMaxMillis。

第四个限制是 CPU,上限只能给区间#

ceiling.sh 的 SSE 一共跑了 18 档:Rust、Go、Netty 各四档(4 万到 10 万),Jetty 六档(2 万到 10 万)。17 档通过、1 档未通过。十二档里 4 万到 10 万全部通过,余量最小的是 Netty,p99 524 ms,离 1 s 还有一倍。WebSocket 只有 Netty 能测,另外四档也全部通过,不在上面这 18 档里。前三个限制都没有触发,所以这几个运行时的上限只能写成「≥10 万,未触及」。

唯一没通过的是 Jetty 的 10 万档:p99 报的是 1049 ms,即 917 到 1049 ms 这个桶的上界,真实值可能高于也可能低于 1 s。harness 按桶上界判定,所以算未通过,实际是刚好压线。扇出耗时 203 ms、内存 6.7 GB / 8 GB 都还有余量,投递率 0.998,没有掉线。它是 18 档里唯一把 4 个 vCPU 用满的,先到上限的是 CPU。它在 8 万档的 p99 是 655 ms,只剩 1.5 倍余量,所以 Jetty 的上限写成 8 万到 10 万之间。

多实例部署后,最先出问题的是路由#

这一章的内容来自公开资料与架构推演,本文没有实测。加第二台机器后,最先出问题的是路由:SSE 连接对象保存在某个节点的本地内存里。用户连在 Node A,业务请求却被路由到 Node B,B 在自己的注册表里找不到这个用户,消息就被悄悄丢掉了。常见的解决办法有三种:

方案 做法 代价
Sticky sessions LB 按 IP hash 或 cookie 把同一客户端一直送到同一个节点 不用改代码,马上能用;但负载可能不均,节点挂掉时它上面的连接全部断开
消息总线 Redis Pub/Sub、NATS 或 Kafka,所有节点订阅同一个通道;持有连接的节点转发,其他节点丢弃 多一个组件,多一跳延迟
分成两层 API/计算层只发布,连接/Gateway 层只订阅并投递 计算层和连接层各自扩缩,故障互不影响

第三种做法的好处可以用前面的数据说明:每条连接那 35 KB 左右主要是 I/O 缓冲,和业务代码关系不大,所以连接层可以不带业务依赖,按连接数选省内存的容器,单独扩缩。代价是这一层的瓶颈变成了扇出,每个 Gateway 要接收全部事件,再放进自己负责的 session 队列。

发布前先查一下用户是否在线,不在线就丢掉这个事件,能省掉大量不必要的事件处理。这和本文队列满就关闭连接的规则是一个思路:不给收不到消息的连接做扇出。

Artera 的做法是服务端在 10 到 15 分钟后主动断开每条 SSE 连接,让 LB 在滚动发布时有机会重新均衡连接,同时也强制客户端重新鉴权。这样做的代价可以估算(只是计算,没有实测):10 万条连接按这个周期轮换,平均每秒 110 到 170 次建连;本文压测时以每秒 2,000 次建连的速度加压,accept-count 调到 4096 后就没有拒绝了,所以周期性断开带来的建连压力很小。滚动发布的具体配置(preStop、terminationGracePeriod 与 Spring Boot 的 graceful shutdown)见另一篇。

比较麻烦的是节点故障:一个节点挂掉时,它上面的几万条连接会在同一个退避时间窗口内一起重连,这个峰值比平时的轮换高两个数量级。本文没有测过重连风暴,只能给两点建议:accept-count 要按峰值设置,不能按平均值;规范不要求浏览器做退避,抖动要自己用 retry 控制。靠自动扩缩容应对这种突发峰值通常来不及,Queue-it 那期播客的结论是 autoscaling 必要但不够,能预见的峰值要提前扩容(pre-scaling)。

完整的跨节点路由方案、基于 Redis Sharded Pub/Sub 与 NATS 的三级路由设计、以及浏览器携带 Last-Event-ID 重连落到新节点时的断线补发机制,已在下一篇《SSE 多实例部署的跨节点路由与断线补发》中展开。

小结#

按这个顺序选:

  1. 先算轮询的成本。 成本 = 用户数 × 更新频率,和数据是否变化无关。更新周期在 30 s 以上、并发用户不到一千,可以继续轮询。
  2. 再看通信方向和载荷类型。 单向文本流用 SSE,二进制或频繁双向通信用 WebSocket。SSE 的重连由浏览器实现,但 EventSource 不能带自定义请求头;WebSocket 的重连、退避、掩码和 Pong 都要自己处理。
  3. 然后设计多实例架构。 连接对象保存在单个节点的内存里,多实例部署后最先出问题的是路由。
  4. 最后看单机容量,优先改容器和配置。 先换容器(SSE @ 20,000 的每连接 RSS,Tomcat 148.1 KB,Netty 36.0 KB),再把 socket 缓冲放到堆外(上限从 50,000 提高到 58,000),这两步只改配置和 starter。换语言只差 7%,而且是连接数足够多时的 7%;换协议的差值取决于容器、规模和内存口径,见每连接内存那一章。
  5. 查上限时先看连接数计数器和扇出耗时。 虚拟线程不影响 maxConnections;堆的上限可以按每连接内存算出来;扇出耗时接近 tick 周期时,先看是不是 GC 占了 CPU,是的话先降低堆压力,不是再根据扇出耗时离 tick 周期多近决定要不要分片。
  6. 健康检查要能发现不报错的失效。 四条判据里只有投递率能区分「连接还在」和「数据送到了」,扇出耗时也能反映僵尸状态;进程存活和连接数在僵尸状态下都显示正常。

引用上面的数字前,先看「这些数字成立的前提」一节:测试环境是容器,没有用物理机;负载是单向 1 Hz;p99 是桶上界;跨语言对比不对等;Rust、Go、Netty 的 SSE 和 Netty 的 WebSocket 都还没测到上限。

相关文章#

参考资料#

压测数据:

  • meirongdev/sse-demo(commit 850a668,2026-09-20)
    • docs/RESULTS.md:本文全部实测数字与失效说明
    • docs/METHODOLOGY.md:三种内存口径、直方图桶设计、11 个 harness 缺陷
    • results/ALL-RUNS.csv:上面所有 run 的逐条汇总

规范原文:

多实例架构与轮询成本的公开资料: