同一份 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 侧的推送代码不能直接复用。

按改动成本排的三种做法#

三种做法按改动从小到大排列,每一种都是前一种的余量用完之后的下一步。

  1. 继续用 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 的旋钮能解决的了。

  2. 换 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 排除掉。

  3. 改成 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 也做一次同样的直方图差分才能回答。

相关文章#

参考资料#