10,000 个用户每秒轮询一次价格,一个月是 259 亿次请求。换成一条长连接,这 10,000 个用户变成 10,000 条 TCP 连接加每秒一万帧。省下来的账很好算,剩下的问题是另一种分叉:用 SSE 还是 WebSocket,以及它们各自从哪里开始难受。

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

先把被替换掉的那一头算清楚。这 259 亿次请求按 AWS API Gateway 的公开分档(us-east-1,截至 2026-09-22)算,REST API 每月 5.7 万美元上下:前 3.33 亿次每百万 3.50 美元,200 亿次以上那一段降到每百万 1.51 美元。这笔账里没有「数据变没变」这一项:价格十分钟不动,那十分钟的六百万次请求照样计费。

这个乘法在服务端对应到对象的生成与销毁。这些对象随栈而异,本文压测用的是 Spring Boot + Tomcat,一次 HTTP 请求在它上面要配一整套请求对象:Http11Processorcoyote.Request / coyote.Response、它们背后的 header 与 chunk 缓冲。轮询每秒把这一整套生成再丢掉一万次,开销落在分配率上。长连接省掉的正是这笔分配率,代价是那套对象改成跟着连接常驻。

客户端这一头也有上限。按每域名 6 条 HTTP/1.1 连接、单次请求占用 100 到 200 ms(参考资料那篇轮询成本分析给的区间,取 150 ms)算,一个浏览器实例到约 40 req/s 就见底,这是 Little’s Law 的直接应用,跟 OceanBase 与 TiDB 那轮容量压测里算吞吐是同一个式子。规范这一头没有规定条数(RFC 9112 §9.4),6 是实现层面的长期惯例。

更新周期 30 s 以上、并发用户不到一千,继续轮询没问题,参考资料那篇轮询成本分析给的就是这条经验线(原文按低频更新取 30 s 以上,跟着一个 1,000 用户的算例)。

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

两边都是「一条连着的 TCP,服务端想发就发」,区别集中在三处,而且这三处互相独立,各自单独影响选型:帧里装什么、允许往哪个方向走、掉线之后谁负责接上。

载荷:文本字段流,对带 opcode 的帧#

SSE 的载荷是 UTF-8 文本,规范写明事件流一律按 UTF-8 解码。内容按 字段名: 值 排,一个空行结束一条,字段只有四个:dataeventidretry,冒号开头的行是注释:

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 的重连由规范定义、浏览器实现:连接掉线时先在 EventSource 上派一个 error 事件,等一个 reconnection time 那么长,然后把上次的 last event ID 塞进 Last-Event-ID 请求头重新 fetch(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 一次响应头。不 flush 的话容器把它扣到第一次写入为止,一条还没等到 tick 的连接在外人看来像没建立,压测端会按 tick 速率而不是 ramp 速率报建连数。

WebSocket 这一头没有这套语义,重连、退避、状态重建都要自己写,参考资料那篇轮询文给的客户端样例就是一段自己实现的指数退避加随机抖动(Math.random() 加在延时上,上限 30 s)。另有两条硬性规范:帧必须掩码(RFC 6455 §5.3),收到 Ping 必须尽快回 Pong(§5.5.2),不回 pong 会在 run 中途被服务端关掉。

keepalive 频率由中间设备决定,两条通道都要自己盯:本文两条路径都是 15 s 主动发一次东西,而参考资料那篇迁移记录在灰度第三周记的坑是企业代理会在 60 秒后静默掐断 SSE。企业防火墙还经常挡掉 WebSocket 的 Upgrade 头,兜底顺序因此是 WebSocket 不通就退到 SSE。

三处合起来,判定其实很窄。内容单向流到浏览器、载荷是文本,SSE 够用;两端持续交替收发(协同编辑、下单、多人光标),或者载荷本来就是二进制,需要 WebSocket。同一份产品里两种通道并存很常见:公开行情走 SSE、鉴权后的交易走 WebSocket。方向定完,剩下的才是量级。

同一套推送功能放进一个进程,唯一的变量是 wire framing#

下面的数来自 sse-demo(commit 850a668)里的 91 次 run。/sse/streamSseEmitter)和 /ws/streamWebSocketHandler)注册在同一个进程里,一条 tick 上的代码全部共用;分两个进程对比会各自带来自己的堆、GC 和容器配置,那样任何差异都可能来自那些东西。

共用的东西 具体规则
帧字节 Frame 每 tick 每 room 只序列化一次,两边拿同一个对象
连接规则 每连接一个 ArrayBlockingQueue(深 256)、每连接一个虚拟线程的阻塞写循环、队列满即关连接、15 s 无帧发一次心跳
注册表与扇出 Registry + TickPublisher,一条平台线程负责 compose 并 enqueue
资源限额 --cpus 4 --memory 8g,两边同一组 JVM 参数
flowchart TD T["TickPublisher
一条平台线程, 1 Hz"] F["Frame
每 tick 每 room 组一次字节"] R["Registry
room 内全部 session"] Q1["Session A 队列
有界 256"] Q2["Session B 队列"] W1["虚拟线程写循环 A"] W2["虚拟线程写循环 B"] S1["SseSink
text/event-stream"] S2["WsSink
WebSocket frame"] T --> F --> R R --> Q1 --> W1 R --> Q2 --> W2 W1 -->|"这个 session 是 SSE"| S1 W1 -->|"这个 session 是 WS"| S2 W2 -->|"两种 transport 都走同样逻辑"| S1

留在两条 transport 身上的只剩把帧交给内核那一步,加上容器替它维护的那套对象。慢消费者的处理也共用:队列满就关连接,而不是把写变慢,否则一个卡住的消费者会悄悄撑大堆,报告出来的连接数里就掺进了服务端没有在服务的部分。

每档「支撑得住」要同时满足四条:建连 ≥ 99%、hold 内几乎不掉线、投递率 ≥ 0.99、p99 < 1000 ms。第三条是唯一能区分「持有连接」和「服务连接」的,一台机器可以挂着五万条连接只给一半推帧,只数连接数的压测会把这叫成功。

这些数字成立的前提#

  • 机器:单机 OrbStack 容器,--cpus 4 --memory 8g,真 Linux 内核加真 cgroup 限额,不是裸金属,也没有跨主机网络。仓库按参考项目的标准给过一句定性:「A benchmark is unmeasured until it runs on a real node, not a laptop.」方向性结论可以引用,具体倍数引用时要带上容器和规模。
  • 负载:单向推送,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,那是「无此读数」的占位)。
  • 跨语言不对等:Go 和 Rust 是最小实现,没有鉴权、tracing、优雅停机,而且只注册了 /sse/streamMODE=ws 打过去是 404。WebSocket 的跨语言对比因此做不了。
  • 哪些数不是直接测到的:只有堆墙 51,767 和 59,264 两个,它们按上面那个两段式外推出来,各有一次实测复核,偏差 +1.1% 和 +1.2%,这段区间里那条线是直的。除此之外文中的数字都是实测值。
  • 哪些数带条件:Tomcat 那一格的 111.8 / 78 KB 是 socket 缓冲还在堆内时测的,当前默认已翻成堆外,要复现得显式带 DIRECT_BUFFER=false
  • 方差ceiling.sh 每档只跑一次。贴着判据跑的时候方差很大,旧二进制在 4 万档同一配置两次跑出 p99 655 ms 与 2097 ms,差 3 倍。距判据有 2 到 3 倍余量的档可以信,贴着的档必须按区间读。
  • 没测的:TLS、网关与 LB、重连风暴、Go 与 Rust 的生产特性。最后那一章讲多实例,全部来自公开资料,本文没有实测过。

每连接成本:差值的符号跟着容器变#

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 贵 1.50 倍

Jetty 与 Netty 两行在当前镜像上复核通过,每连接存活堆的变化在 5.3% 以内。

Tomcat 那一格贵在哪,堆直方图差分(GC.class_histogram 强制 full GC)能逐项列出来:SSE 侧每连接挂着 41 个 MessageBytes、45 个 ByteChunk、42 个 CharChunk 加整套 coyote.Request / Http11Processor,合计 111.9 KB;WebSocket 侧只有三个 Ws* 对象,合计 56.1 KB。这两个合计是直方图逐项相加得到的,上面那张表的 111.8 KB 和 78 KB 是存活堆斜率,同一格两个口径会给出不同的数,别拿来互相验算。那一整套就是轮询每秒重建一万遍的那套 HTTP 请求对象,换成长连接之后改为常驻,因为 SSE 的请求永远不会完成。分配率也跟着高:同一负载的 60 s JFR 显示 SSE 的分配采样是 WebSocket 的 1.8 倍,前三名全落在每次 send() 的字符串与 media type 解析上。

换 RSS 口径、把规模推到 10 万,Netty 上的差值变成另一个数:

Netty @ 100,000 连接 SSE WebSocket
RSS 3976 MB 2433 MB −39%
存活堆 1956.5 MB 1287.2 MB −34%
扇出耗时(最坏) 381.4 ms 199.2 ms −48%
p99 524 ms 459 ms −12%
openFds 100,022 100,022 相同

扇出耗时指一个 tick 内把帧排进全部 session 队列的总耗时,取整个 run 的最坏值,tick 周期是 1 s。openFds 两边相同是关键,它证明 WebSocket 那一档确实承载了 10 万条连接,这个 −39% 排除了「负载没打上去」的解释。

于是「SSE 比 WebSocket 贵多少」有四个答案:三个容器各一个,同一个 Netty 推到 10 万再换 RSS 口径又是一个,而且最后这个的符号是反的。它带着容器、规模和内存口径三个自变量,当成协议的固定属性去引用就会失真。有一条常被引用的分界线是「并发过了某个数量级就该换 WebSocket」,它在容量这一头落不了地:本文测到的最高一档是 10 万,两种 transport 在 Netty 上都还没到自己的临界,换成 Tomcat 则先在 8192 这个默认计数器上遇到上限。

换协议之前还有更便宜的一档:把 SSE 那两个 NIO socket 缓冲挪到堆外,每连接省 14.18 KB 堆,上限从 50,000 抬到 58,000,5 万档 p99 从 786 ms 降到 328 ms。这一档只动配置和 pom 里那个 servlet 容器 starter,换 transport 却要动客户端契约。

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

五种运行时的 SSE @ 20,000,RSS 口径:

运行时 空载 RSS 满载 RSS 每连接 RSS p99 扇出耗时
Java · Tomcat(全调优) 294 MB 3187 MB 148.1 KB 164 ms 81 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

Netty 36.0、Go 37.1、Rust 34.7,相差 7% 以内。有 GC 的 JVM、有 GC 的 Go、无 GC 的 Rust 落在同一格,说明这 35 KB 主要是每连接的 I/O 缓冲,与运行时的语言无关。数量级差距只在空载基线那一栏,而它是固定成本:Netty 空载 263 MB 摊到 1,000 条连接是每条 269 KB,摊到 10 万条只剩 2.7 KB。JVM 这一侧的 4 倍差距(148 到 36 KB)来自选了 Tomcat,换容器就能拿回。

Jetty 还把两条内存口径劈开了:每连接存活堆 18.3 KB 是全场最低,每连接 RSS 64.1 KB 是全场最高,同一个运行时两个数差 3.5 倍。cgroup 按 RSS 决定杀哪个进程,所以按内存选型要盯 RSS,只看存活堆会把最费内存的运行时选成最省的。

单机容量:四道限制按连接数排开#

上一章比较每连接占多少内存,这一章改问一台机器能挂多少条。拦在前面的一共四道限制,按触发时的连接数从小到大是 accept 计数器、堆、扇出的 tick 预算、CPU,中间还夹着一种不报错的失败。

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

Tomcat maxConnections 默认 8192,低于「单机 1 万」这个常见目标,而它的失败是安静的:要 12,000 条只拿到 8,192 条,failed=0rejected=0,日志里什么都没有。它是 NIO connector 上的一个计数器,与 executor 无关,虚拟线程帮不上忙。这点有内核级证据:5,000 条连接时进程只有 40 个 OS 线程,http-nio-8080-exec-N 这些线程名一个都不存在,虚拟线程确实生效了,上限仍然是那个计数器。

还有三个更低的坎会伪装成服务端到顶,查上限之前先排掉:accept-count 默认 100,每秒 2,000 条的 ramp 会出现拒绝;文件描述符默认 1024,报 Too many open files;压测端每个源地址约 28 K 临时端口。

堆墙能算出来#

accept 计数器调过之后,当前镜像上 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%。五档的每连接堆笔直(112.3 到 113.4 KB),这条外推有依据。50,000 档重复三次都 PASS,堆占 heapMax 的 95.2%。这个上限要写成「5 万档过得去、5.2 万是硬墙」,写成点值就会误导。

扇出周期是阈值效应#

10 万档满载 RSS 是 3976 MB(SSE)和 2433 MB(WebSocket),占 8 GB 的一半和三成,先挡路的可以不是内存。扇出耗时走的是另一条线:

运行时 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,所以本文给不出它们各自在哪一档越线,也不做外推:两次实际观测到越线,都是跳变而不是渐进退化,线性外推在这种阈值效应上不成立。旧二进制的 Tomcat SSE 在 3 万档 p99 262 ms,4 万档的一次 run 里是 6291 ms(同一档另有两次读数相差 3 倍,见前面方差那条),扇出耗时正是在这一步越过 1 s tick;把堆放宽到 80% 之后同样的跳变在 5.5 万档(282 ms)到 6 万档(1583 ms)之间再现。

判断该不该上分片扇出,看扇出耗时距 tick 周期多远,别看连接数。同一个配置切四条扇出线程,SSE @ 40,000 的扇出耗时从 469 ms 降到 117 ms,正好等于分片数,p99 从 655 ms 降到 459 ms;而 WS @ 60,000 本来只有 126 ms,分片后 148 ms,p99 两次都是 393 ms。这个阴性对照说明分片只在扇出确实是瓶颈时兑现。

僵尸带:连接全活,帧没送出#

Tomcat 的堆放宽到 80% 之后,OOM 墙推到约 59,900,底下那道扇出墙立刻露出来。60,000 档建连 60,000 条全部存活、零掉线、没有 OOM,而投递率只有 0.269,p99 83,886 ms,扇出耗时 1583 ms。进程正常、六万条健康连接,四分之三的帧没送出去。

OOM 是响亮的:ExitOnOutOfMemoryError 让进程退出,编排层重启,日志留一行说明。僵尸是安静的,按进程存活或连接数做的健康检查完全看不见它。70% 那个配置失败得更诚实,它在变成僵尸之前就把自己杀了。要拿堆上限从 70% 提到 80% 多出来的那 16% 容量(51,767 到 59,948),前提是把探针换成看投递率或 fanOutMaxMillis

第四道是 CPU,所以上限要写成区间#

ceiling.sh 那条阶梯跑了 18 档,17 档 PASS、1 档 FAIL。Rust、Go、Netty 的 SSE 各四档,4 万到 10 万全部通过判据,最紧的是 Netty 的 p99 524 ms,距 1 s 还有一倍;WebSocket 那半边只有 Netty 能测,四档同样全 PASS。前三道限制一条都没触发,所以这几条只能写成「≥10 万,未触及」。

唯一的 FAIL 是 Jetty 在 10 万档:p99 1049 ms,超判据 4.9%,而扇出耗时 203 ms、内存 6.7 GB / 8 GB 都还有余量,投递率 0.998、掉线 0。它是 18 档里唯一把 4 个 vCPU 用满的一档,先到上限的是 CPU。它 80,000 档的 p99 是 655 ms,只剩 1.5 倍空间,所以这条上限写成 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 里 enqueue。

发布之前先查一遍在线状态,没人在线就丢掉这个事件,能消掉绝大部分无谓的事件处理。它和本文的队列规则是同一条原则的两种写法:别给不在场的读者做扇出。

还有一条常见做法是服务端主动在 10 到 15 分钟后断开每条 SSE 连接,好让滚动发布时 LB 有机会把连接摊平,附带强制重新鉴权。代价可以换算(算术,非实测):10 万条连接按这个周期全量回收,稳态是 110 到 170 建连/s,而本文压测端按 2,000 建连/s ramp 打、accept-count 调到 4096 才干净,周期性回收带来的建连压力因此很小。滚动发布那一侧的具体配置(preStopterminationGracePeriod 与 Spring Boot 的 graceful shutdown)写在另一篇里。

难办的是另一头:一个节点掉线时,它那几万条连接会在同一个退避窗口里一起回来,这个尖峰比稳态回收高两个数量级。本文没测过重连风暴,能说的只有两条:accept-count 按尖峰定而不是按稳态定;浏览器的退避规范不做要求,抖动得自己用 retry 控。指望自动扩缩去接这种瞬时尖峰通常来不及,Queue-it 那期播客的结论就是 autoscaling 是必要条件、不是充分条件,能预测的尖峰要靠 pre-scaling 提前铺。

小结#

按这个顺序选:

  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. 上限先查计数器和 tick 预算。 虚拟线程改不动 maxConnections;堆墙能按每连接斜率算出来;分片扇出的开关看扇出耗时距 tick 周期多远。
  6. 探针要能识别静默失效。 四条判据里只有投递率能区分「持有连接」和「服务连接」,正文里扇出耗时是另一个露出僵尸态的读数;进程存活和连接数在僵尸态下全是绿的。

引用上面任何一个数之前先看一眼「这些数字成立的前提」那一节:容器不是真机、负载是单向 1 Hz、跨语言对比不对等、四条曲线的真临界点都还悬着。

相关文章#

参考资料#

自己压测的那批数:

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

规范原文:

多实例架构与轮询成本的公开记录: