虚拟线程下的 Spring MVC SSE:Tomcat、Jetty、WebFlux 的单机上限
目录
同一份 Spring MVC 代码:SseEmitter 推送,每条连接一个容量 256 的 ArrayBlockingQueue,加一个跑在虚拟线程上的阻塞写循环。在 4 vCPU / 8 GB 的容器里挂 2 万条 SSE 连接,跑在 Tomcat 上每条连接常驻 112.3 KB 存活堆(socket 缓冲还在堆内那一档),换成 Jetty 是 18.3 KB。两边的平台线程数却差不多,Tomcat 24 个,Jetty 19 个(各次 run 的 server-final.json 里的 platformThreads),虚拟线程在两边都生效了,内存的差距出在容器本身。
数据来自 sse-demo(commit 850a668,Java 25、Spring Boot 4.1.1,对应 Tomcat 11.0.24、Jetty 12.1.12),负载是服务端单向推送,1 Hz,每帧 265 字节。一档连接数算通过,要同时满足建连成功率 ≥ 99%、保持期间基本不掉线、投递率 ≥ 0.99、p99 < 1000 ms;p99 读数是直方图桶的上界,接近判据的档位要按区间理解。测试跑在单机 OrbStack 容器里,没有用物理机,其余前提见《SSE 还是 WebSocket》的「这些数字成立的前提」。那篇比较两种 transport,这里只比较同一份 SSE 代码放在三种容器里的差别。
| 容器 | 编程模型 | 每连接存活堆(2 万) | 每连接 RSS(2 万) | 单机上限 | 先到的限制 |
|---|---|---|---|---|---|
| Tomcat,socket 缓冲在堆内 | Spring MVC + 虚拟线程 | 112.3 KB | 未测 | 5 万三次通过,52,345 处 OOM | 堆 |
| Tomcat,socket 缓冲移到堆外 | Spring MVC + 虚拟线程 | 约 98 KB(推算) | 未测 | 5.8 万三次通过,余量 1.09 倍 | 堆 |
| Jetty | Spring MVC + 虚拟线程 | 18.3 KB | 64.1 KB | 8 万到 10 万之间 | CPU |
| WebFlux(Netty) | Reactive,虚拟线程关闭 | 19.9 KB | 36.0 KB | ≥ 10 万 | 测到 10 万仍未触及 |
每连接内存按满载减空载、再除以连接数来算,不把空载基线摊进去。两行 Tomcat 都把 maxConnections 调到了 200,000:它的默认值是 8192,超出的连接建立不起来,也不报错(见上一篇的连接数上限一节),不调这一项的 Tomcat 到不了表里的上限。Tomcat 的每连接 RSS 在 2 万这一档没有对应的 run,表里标未测;到 5 万,缓冲在堆内时 RSS 只用到 cgroup 的 74.5%,先到的仍然是堆。要按 RSS 估 Tomcat,参照上一篇的换容器一节:全调优(同样把缓冲移到堆外)在 2 万档是每连接 148.1 KB。仓库当前的默认值已经是缓冲在堆外,要复现 112.3 KB 那一行得显式带上 DIRECT_BUFFER=false。「约 98 KB」是 112.3 KB 减去实测省下的 14.18 KB,2 万没有单独跑。
Jetty 这一行不用改 Java 代码,只换 pom 里的 starter;WebFlux 这一行要把推送链路改成 reactive 的写法。
Tomcat 上一条 SSE 连接常驻 112 KB 堆#
SSE 在 Servlet 里是一个一直没结束的请求。Tomcat 为它保留的东西分两部分:请求处理对象和两个 socket 缓冲。
SSE 请求不结束,Coyote 的请求对象一直留在堆里#
对空载和满载各做一次堆直方图(GC.class_histogram,会先强制 full GC)再相减,Tomcat 上每条 SSE 连接常驻一个 Http11Processor、一个 Http11InputBuffer、coyote.Request 和 coyote.Response 各一个,以及 41 个 MessageBytes、45 个 ByteChunk、42 个 CharChunk、11 个 MimeHeaderField,合计 111.9 KB,和上表的 112.3 KB 基本吻合。最大的一项是 4 个约 8 KB 的 char 数组,共 32.9 KB,下一节的两个 socket 缓冲参数都调不到它们。
同一个 Tomcat 上的 WebSocket 连接,握手完成后这些对象都释放了,每条只剩 WsSession、WsFrameServer、WsRemoteEndpointImplServer 三个对象。这套请求对象是 Tomcat 自己的实现,Servlet 规范并不要求,同一份 Spring MVC 代码跑在 Jetty 上用的是另一套实现。SSE 和 WebSocket 两边的直方图对比在上一篇里。
读写 socket 缓冲各 8 KB,默认分配在堆上#
Tomcat 的 NIO connector 给每条连接一个读 ByteBuffer 和一个写 ByteBuffer,socket.appReadBufSize 和 socket.appWriteBufSize 默认都是 8192 字节;socket.directBuffer 默认是 false,两个缓冲用 ByteBuffer.allocate() 分配在堆上(Tomcat 11 HTTP Connector 文档)。客户端连上以后一个字节都不上行,这 16 KB 也一直占着。
把 socket.directBuffer 设成 true,两个缓冲改用 ByteBuffer.allocateDirect()。58,000 条连接时 jvm.buffer.memory.used{id=direct} 读到 906.2 MB,正好每连接 16.0 KB;堆上每连接省下 14.18 KB,比 16 KB 略少。字节数没有变,只是从堆的账上挪到了直接内存的账上。这在 Tomcat 上有用,是因为它先到的墙是堆。
Tomcat 的上限是堆:5 万能过,52,345 处 OOM#
压测镜像的 Dockerfile 设了 -XX:MaxRAMPercentage=70,8 GB 容器里堆上限是 5736 MB。本节到下一个对照表之前,读数都是 socket 缓冲留在堆内那一档(DIRECT_BUFFER=false,也就是仓库默认值翻到堆外之前)。按每连接 112 KB、空载 74 MB 算,堆能装下 (5736 − 74) × 1024 ÷ 112 ≈ 51,767 条连接。请求 6 万条时,进程在建好 52,345 条之后报 java.lang.OutOfMemoryError: Java heap space 退出,和预测差 1.1%。5 万这一档重复跑了三次都通过,但堆用到了上限的 95.2%,p99 786 ms,离判据只剩 1.27 倍,单线程扇出最坏一次是 560 ms。所以这个上限写成「5 万能过,5.2 万到顶」更准确。
堆快满时,GC 会先抢走扇出线程的 CPU。把堆放宽到 80% 之后,6 万档的连接全部建立、没有 OOM,但扇出耗时 1583 ms,超过了 1 s 的推送周期,投递率只有 0.269,详见上一篇的僵尸状态一节。在 70% 下,进程走到这一步之前就已经 OOM 退出了。
把 socket 缓冲移到堆外之后,同一个镜像在 5 万档背靠背重跑了一组对比(和上面那三次不是同一次 run):
| 5 万条连接 | 缓冲在堆内 | 缓冲在堆外 |
|---|---|---|
| 存活堆(占堆上限) | 5468.7 MB(95.3%) | 4776.2 MB(83.3%) |
| RSS | 6100 MB | 6705 MB |
| p99 | 786 ms | 328 ms |
| 扇出最坏耗时 | 620.7 ms | 100.5 ms |
堆占用降下来以后,GC 的压力跟着减轻,p99 和扇出耗时都明显下降。上限从 5 万提到 5.8 万,三次都通过,但最坏一次 p99 是 917.5 ms,余量只剩 1.09 倍;按每连接省 14.18 KB 推算,堆墙在 59,300 条左右。这时 RSS 已经是 7029 MB,占 cgroup 的 85.8%,8 GB 里留给 Tomcat 调整的余量基本用完了。
换成 Jetty:Java 代码不变,存活堆 18.3 KB#
sse-demo 的 server-jetty 模块没有复制代码,pom 里的 sourceDirectory 直接指向 Tomcat 模块的 src/main/java,SseEmitter、队列和虚拟线程写循环全部相同。差别只在依赖:spring-boot-starter-web 和 spring-boot-starter-websocket 里各排除一次 spring-boot-starter-tomcat,再加上 spring-boot-starter-jetty。Tomcat 专用的 TomcatTuning 在编译时被排除,所以 Jetty 跑的是它自己的默认值。
存活堆 18.3 KB,RSS 64.1 KB#
Jetty 的两种内存口径差得最远。每连接存活堆 18.3 KB,是三个 Java 容器里最低的。算比值要用仓库当前默认值下的 Tomcat,也就是缓冲移到堆外那一行(约 98 KB),18.3 KB 是它的 18.7%,约 5.4 倍。表里 112.3 KB 那一行是 DIRECT_BUFFER=false 时测的,和 Jetty 的默认值不同口径,不拿来算这个比值。每连接 RSS 64.1 KB,比 Netty 的 36.0 KB 高 78%。RSS 比存活堆多出的约 46 KB,既包括堆外内存,也包括堆里已经提交、但没有被存活对象占用的空间。
Jetty 12.1 的 HttpConfiguration 默认读写都用 direct ByteBuffer(_useInputDirectByteBuffers 和 _useOutputDirectByteBuffers 的初值都是 true,见 jetty-12.1.12 的源码),这和存活堆低、RSS 高的结果方向一致。但 SSE 请求挂起时 Jetty 在堆里留下了哪些对象、堆外是哪几块缓冲,这次没有对 Jetty 做堆直方图,还说不出来。
两种口径差这么多,按内存选型就要看 RSS:cgroup 用来判断 OOM 的就是进程实际占用的物理内存,只看存活堆会把 Jetty 当成最省内存的那个。Jetty 的每连接 RSS 在 2 万、4 万、8 万三档分别是 64.2、64.4、63.9 KB,基本不随连接数变化,总 RSS 按连接数线性涨,做容量规划可以直接用这个斜率。
8 万能过,10 万压线,先到的是 CPU#
Jetty 的 p99 在 2 万、3 万、4 万、6 万、8 万档依次是 164、197、262、459、655 ms,一路平滑上升,这几档的单次扇出最坏耗时都不超过 133 ms。到 10 万档,服务端 CPU 打到 400%,4 个 vCPU 全满,是这轮 18 档上限测试(rust / go / netty 各四档,Jetty 六档)里唯一打满的一档;p99 读数 1049 ms,是 917 到 1049 ms 这个桶的上界,只比判据高 4.9%,投递率 0.998,内存 6.7 GB,扇出 203 ms 也还有余量。所以 Jetty 的上限写成 8 万到 10 万之间,8 万档的 p99 只剩 1.5 倍余量。
作为对照,Tomcat 在 4 万档的 p99 也是 262 ms,和 Jetty 一样。两者在这个规模上的延迟没有差别,差别在 Tomcat 到 5.2 万就没有堆了。
WebFlux:没有每连接线程,RSS 最低#
WebFlux 模块是单独一份代码。controller 返回 Flux<String>,注册表里每条连接对应一个 Sinks.many().unicast().onBackpressureBuffer(...),缓冲深度和 Servlet 侧的队列一样是 256。application.yml 显式关掉了 spring.threads.virtual.enabled,所有连接都由 Reactor Netty 的 event loop 写出,2 万条连接时进程有 18 个平台线程,加到 10 万条仍然是 18 个。
2 万连接时每连接存活堆 19.9 KB,和 Jetty 在同一量级;RSS 36.0 KB,是三个 Java 容器里最低的,和同一轮压测里 Go net/http 的 37.1 KB、Rust axum 的 34.7 KB 相差不到 7%(跨语言的对比见上一篇)。加到 10 万条连接时,p99 524 ms,离判据还有约一倍;RSS 3976 MB,不到 8 GB 的一半;单次扇出最坏 381.4 ms。这个规模下几个限制都没有触发,所以 WebFlux 的上限只能写成「≥ 10 万」。
代价在代码上:SseEmitter 换成 Flux,队列加虚拟线程换成 Sinks.Many,Servlet 侧的推送代码不能直接复用。
按改动成本排的三种做法#
三种做法按改动从小到大排列,每一种都是前一种的余量用完之后的下一步。
-
继续用 Tomcat:先把
server.tomcat.max-connections调到目标连接数以上,再把 socket 缓冲移到堆外,上限能到 5.8 万,但余量很薄。sse-demo 是在TomcatConnectorCustomizer里设的,下面是TomcatTuning的节选,删掉了注释和日志行:@Bean public TomcatConnectorCustomizer socketBufferCustomizer( @Value("${demo.tomcat.app-read-buf-size:8192}") int readBufSize, @Value("${demo.tomcat.app-write-buf-size:8192}") int writeBufSize, @Value("${demo.tomcat.direct-buffer:true}") boolean directBuffer) { return (Connector connector) -> { connector.setProperty("socket.appReadBufSize", Integer.toString(readBufSize)); connector.setProperty("socket.appWriteBufSize", Integer.toString(writeBufSize)); connector.setProperty("socket.directBuffer", Boolean.toString(directBuffer)); }; }调完这两项,再往上就不是继续拧 Tomcat 的旋钮能解决的了。
-
换 Jetty:Java 代码不动,pom 里换 starter。下面是
server-jetty/pom.xml的节选,只留换 starter 那两段,删掉了注释和 actuator 依赖;用到了spring-boot-starter-websocket的项目,那里照这一段再排除一次 Tomcat:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jetty</artifactId> </dependency>项目里依赖 Tomcat API 的定制代码(比如上面的
TomcatConnectorCustomizer)要删掉或改写,sse-demo 的做法是在编译时把TomcatTuning排除掉。 -
改成 WebFlux:要重写推送链路,换来三者里最低的 RSS 和最宽的延迟余量。单机目标在 10 万连接以上,或者每台机器的内存预算按 RSS 卡得很紧时,这个改写成本才划算。
小结#
2 万条 SSE 连接时,开了虚拟线程的 Tomcat 和 Jetty 各有 24、19 个平台线程,WebFlux 本来就关掉虚拟线程、也没有每连接线程,同样是 18 个。线程已经不是每条连接的主要成本。Tomcat 的成本在 SSE 请求挂起期间一直留在堆里的 Coyote 请求对象和两个 socket 缓冲:缓冲留在堆内时每条 112.3 KB,移到堆外还剩约 98 KB,先到的是堆墙;同一份代码换到 Jetty 是 18.3 KB,比 98 KB 那一档低约 5.4 倍,先到的变成 CPU;WebFlux 到 10 万连接仍未触及任何限制。
遗留的问题是 Jetty 省在哪里。Tomcat 那边有堆直方图,能逐项对上 112 KB 的账;Jetty 这边只有存活堆和 RSS 两个总数,要对 Jetty 也做一次同样的直方图差分才能回答。
相关文章#
- SSE 还是 WebSocket:每连接内存、单机容量上限与多实例路由:同一组压测数据,侧重 SSE 与 WebSocket 的比较和单机先后遇到的四个限制
- API Gateway 架构:Spring Cloud Gateway 从 WebFlux 迁到 MVC 加虚拟线程的取舍
参考资料#
- meirongdev/sse-demo(commit
850a668):本文数据出自docs/RESULTS.md的 §1、§2、§3、§4、§8、§9、§11b、§11d、§11e;platformThreads出自results/<run>/server-final.json - Apache Tomcat 11 Configuration Reference: The HTTP Connector:
socket.appReadBufSize、socket.appWriteBufSize、socket.directBuffer、maxConnections的默认值 - Jetty 12.1.12 的
HttpConfiguration.java:读写缓冲默认使用 directByteBuffer