面对大模型 10 秒慢 I/O,为什么 Tomcat 会瘫痪而 WebFlux 能吞吐百万

📅 2026/6/26 8:55:48
面对大模型 10 秒慢 I/O,为什么 Tomcat 会瘫痪而 WebFlux 能吞吐百万
BIO 时代在 BIO 时代Tomcat 的核心哲学就是一请求一线程Thread-per-request。在 BIO 模式下因为没有了用来解耦连接与线程的中间网管maxConnections 的值在 BIO 里其实就是 maxThreads整个流程是这样硬核串联的第 ① 步内核三次握手客户端发起 HTTP 请求。操作系统内核Linux Kernel不管 Tomcat 忙不忙优先帮 Tomcat 完成 TCP 三次握手。握手成功的连接直接放入操作系统内核的 Accept Queue已完成连接队列。这个队列的最大容量就是acceptCount。第 ② 步Acceptor 线程“人肉”捞连接Tomcat 内部有一个 Acceptor 线程它的唯一工作就是死循环调用操作系统的 serverSocket.accept() 方法去把内核队列里的连接一条一条“捞”进 Tomcat 实例内部。第 ③ 步分发给 Worker 线程处理核心瓶颈点Acceptor 线程把连接捞出来之后它自己是不干业务活的。它会转头去 Tomcat 的 Worker 线程池线程池上限就是 maxThreads 申请一个空闲的工作线程。如果池子里有空闲工作线程Acceptor 把 Socket 连接直接移交给该工作线程由它去跑你的 Servlet/Controller 业务。Acceptor 腾出手来立刻去捞下一个。在 BIO 中maxThreads满了为什么会触发connect refused关键就在于当 maxThreads 爆满时会发生毁灭性的级联阻塞。Worker 线程池用光了假设 maxThreads 是 200此时 200 个线程全被耗时业务比如死等大模型返回占满了。Acceptor 线程被卡死Block第 201 个连接过来了Acceptor 顺畅地从内核捞出这个连接转头去线程池里要工作线程。结果线程池冷冷地回一句“没了全在忙你等着吧”由于是BIO同步阻塞Acceptor 线程无法把这个连接丢到任何地方去它自己必须死死死地抓着这个 Socket原地处于 Block阻塞状态等池子里释放出空闲工作线程所以BIO中maxConnections maxThreads内核队列acceptCount开始堆积既然唯一的“搬运工” Acceptor 线程现在被卡死在线程池门口了它就再也没有办法去执行 serverSocket.accept() 来捞连接了。彻底爆发 Connection Refused后续第 202 到 301 个请求继续涌入。由于没人从内核队列里往外捞了连接开始在操作系统的内核队列里堆积。当堆积满 acceptCount比如 100之后操作系统内核的凳子坐满了大门直接关闭后续请求瞬间爆发Connection refused。NIO 时代在 NIO非阻塞 I/O时代Tomcat 的核心底层由Acceptor接单员、Poller网络多路复用轮询器、TaskQueue内存任务队列 和 Worker 线程池共同支撑。这三个参数不再像 BIO 那样相互死锁、连环阻塞而是各司其职在不同的缓冲区协同作战。我们用最新的生产级默认配置来复盘一个请求从进入服务器到被处理的真实全流转运作过程。server:tomcat:max-threads:200# 最大工作线程数max-connections:10000# 最大物理连接数accept-count:100# 操作系统的内核排队队列长度第 ① 步内核三次握手对应acceptCount流量首先到达服务器的网卡。内核接管操作系统的内核OS Kernel优先响应与客户端完成标准的 TCP 三次握手。参数运作握手成功后这个合法的 TCP 连接会被丢进 Linux 内核维护的Accept Queue已完成连接队列。这个队列的上限就是acceptCount默认 100。NIO 的优势Tomcat 内部的Acceptor线程是非阻塞的它不干业务活只负责以微秒级的速度执行accept()疯狂从内核队列里往外捞连接。因此在正常状态下内核的这 100 个凳子几乎永远是空的。第 ② 步Acceptor 线程“人肉”捞连接Tomcat 大楼的门禁系统对应maxConnections连接被Acceptor线程捞出来后正式准备进入 Tomcat 应用层。门禁检查Tomcat 内部的计数器LimitLatch会拦截并检查“当前大楼里已经接管的连接总数有没有达到maxConnections默认 10000”参数运作如果没超Tomcat 抬杆放行将这个 Socket 移交给Poller多路复用轮询器挂载。在 NIO 下这些连接即使闲着Keep-Alive 状态也不需要消耗任何工作线程。它们仅仅作为一个内存对象和文件描述符FD挂在Poller上。如果超了10000个位置全满Tomcat 启动自我保护Acceptor线程直接原地挂起Block停止从内核捞人这时候后续新来的连接进不去大楼才开始在第① 步的acceptCount队列里堆积。第 ③ 步JVM 内存流水线对应maxThreads与隐藏的TaskQueue现在成功进入大楼并挂在Poller上的 10000 个长连接中突然有2000 个连接同时发送了真实的 HTTP 请求数据网络事件触发Poller线程利用系统的epoll机制瞬间捕捉到数据就绪事件。参数运作前 200 个请求被秒杀分发瞬间占满了工作线程池的maxThreads默认 200。这 200 个线程开始疯狂跑你的业务逻辑。关键突破点剩下的1800 个请求去哪了它们绝对不会被退回到内核的acceptCount队列Tomcat 会将这 1800 个请求封装成 Task直接塞进 Tomcat 内部用 Java 实现的TaskQueue任务队列里去排队。内部消化这个TaskQueue默认是无界的容量为Integer.MAX_VALUE。这 1800 个请求会平稳地待在 JVM 堆内存里等前面的 200 个工作线程谁忙完了谁就来TaskQueue里抓下一个任务继续执行。Tomcat: AsyncContext(Servlet 3.0 在传统 Tomcat 中一个请求进来Worker 线程 必须全程陪同。但开启 Servlet 3.0 异步特性后Tomcat 内部的流转通道会发生极其精妙的变化。1. 异步上下文的底层核心Socket 与请求的“寄存处”当一个请求触发了 Tomcat 的异步机制如调用了 request.startAsync()Tomcat 内部会生成一个核心对象AsyncContext异步上下文。这个 AsyncContext 内部死死抓着两个底层关键引用客户端的 Socket 连接句柄网络通道/文件描述符 FD当前请求的 Response 响应体对象2. 异步处理结果如何找回原有链接我们以“调用大模型异步思考 10 秒”为例拆解其底层的运作真相第①步前台寄存Worker 线程解绑释放请求到达 Tomcat由Worker-Thread-A接单并进入你的Controller。你的代码执行AsyncContext asyncContext request.startAsync();。此时发生解绑Tomcat 知道你要搞异步了于是把这个连接的Socket 句柄连同asyncContext像“寄存行李”一样塞进一个 Tomcat 全局的、由 NIO Poller 线程池共同维护的内部 Map/管理器中。Worker-Thread-A交代完一句“行李我存好了AI 好了叫我”之后瞬间被释放回 Tomcat 线程池。它立刻可以去处理别的用户的请求。第 ② 步: 业务池死等触发完成回调你的业务代码把asyncContext扔给了你自定义的业务线程池Executor由业务线程去发起调用大模型。10 秒钟后大模型思考结束数据返回到了你的业务线程。业务线程将数据写入asyncContext.getResponse().getWriter().write(data);。紧接着业务线程调用关键的asyncContext.complete();通知 Tomcat我干完活了可以把行李取出来还给用户了。第 ③ 步Poller 线程凭“小票”唤醒精准原路返回当asyncContext.complete()被触发时Tomcat 内部会产生一个“写就绪/异步完成”的内部事件。Tomcat 负责网络 I/O 轮询的Poller 线程我们前文提到过那个永不停歇的网管利用操作系统多路复用机制捕捉到了这个事件。Poller 顺着这个事件去全局的“寄存处”里根据这个 AsyncContext 内部死死绑定的、唯一的 Socket 文件描述符FD瞬间抓回了那个 10 秒前建立的物理 TCP 管道。Poller 线程从 Tomcat 工作线程池里临时抓取一个新的工作线程可能是 Worker-Thread-B把数据顺着这个 Socket 通道源源不断地吐回给前端浏览器。整个闭环完成既然 Tomcat 也能异步为什么我们还要用 Netty既然 Tomcat 从 Servlet 3.0 开始就能通过 AsyncContext 释放线程、实现异步转发那为什么像 Spring WebFlux 这样的高性能响应式框架、或者大厂网关依然坚决要用 Netty 替代 Tomcat 呢因为 Tomcat 的异步是“打补丁式的异步”它有两大无法逾越的底层架构痛点1. 阻塞依然存在于 Tomcat 内部的局部生命周期在 Tomcat 中虽然业务逻辑被你异步化了但请求的解析HTTP 协议的 Read/Parse和结果的输出Write/Flush默认依然是同步阻塞的。如果前端网卡很慢比如移动端网络卡顿Tomcat 的工作线程在把数据通过 complete() 吐给网卡时依然会被卡在网络写入Write上导致线程池耗尽。2. 内存对象的“大象包袱”Tomcat 是围绕着古老的HttpServletRequest 和 HttpServletResponse规范设计的。这两个对象极度臃肿里面包含了大量的 Session 管理、Cookie 缓存、复杂的 Context上下文等。在异步长等待期间比如等 AI 响应 10 秒这几万个臃肿的 Request 对象必须在 JVM 堆内存里持续挂着造成极其恐怖的内存开销极易引发频繁的 Full GC 甚至 OOM。Netty如何运作的在 Netty 时代不再有复杂的“门禁系统限制maxConnections”和“内部堆积队列TaskQueue”取而代之的是纯粹的事件循环EventLoop。第①步内核握手与接单BossGroup 运作客户端发起 TCP 连接请求。Linux 内核完成三次握手。Netty 的BossGroup通常只需要 1 个线程 死守端口。一觉察到内核有新连接Accept 事件它在微秒级内把这个连接捞出来为其包装成一个 Channel通道对象。瞬间移交Boss 线程不作任何停留立刻把这个Channel扔给后面的WorkerGroup。因为 Boss 线程永远处于饥饿状态、随时准备迎接下一个连接所以它几乎不存在连接来不及捞而在内核 acceptCount 里堆积的情况。第 ② 步: 网络事件的死守WorkerGroup / EventLoop 运作WorkerGroup内部有一组数量极少但极其高效的线程叫做EventLoop事件循环线程默认数量通常是CPU核心数 * 2。多路复用绑定Boss 扔过来的那个Channel会被死死绑定到某一个固定的EventLoop 线程上。实际上是都在EventLoop线程的任务队列TaskQueue–线程安全的光占座不干活如果这个客户端只是连着连续几小时不发任何数据Keep-Alive 长连接这个 EventLoop 线程压根不会理它也不占用任何 CPU 资源。一个EventLoop线程可以同时看管几万个这样安静的连接。第 ③ 步数据流过的瞬间Pipeline 管道流转当某个连接突然发送了真实的请求数据比如一段 HTTP 文本或一个 RPC 报文操作系统内核通知 Netty绑定该连接的那个EventLoop线程被瞬间唤醒。流水线处理这个线程负责把网卡里的二进制数据读出来然后顺着这个连接自带的Pipeline管道像剥洋葱一样把数据依次传给一堆Handler处理器——先解码、再解密、最后交给你写的业务 Handler。Q: 面对长耗时业务Netty 的非阻塞终极解法如果你的业务 Handler 拿到数据后需要去调用大模型 AI 接口需要死等 10 秒。Netty 是怎么做到不阻塞、不瘫痪的解法 A异步响应式编程如 WebFlux业务代码直接发起异步网络调用。在把请求发给大模型后该线程一刻也不逗留不等待返回立刻释放线程转身去处理第 10001 个连接的网络读写。10秒后大模型有响应了操作系统再次唤醒该线程把结果吐回前端。整个过程零线程阻塞。// 这里的 ctx (ChannelHandlerContext) 是当前连接的上下文它内部死死持有当前 channel 的引用webClient.post().body(...).retrieve().bodyToMono(String.class).subscribe(aiResult-{// 关键点这个 Lambda 表达式是一个闭包// 它把外层的 ctx 变量死死地“抓”在了自己的执行口袋里ctx.writeAndFlush(aiResult);});解法 B自定义业务线程池即 EventExecutorGroup如果你写的是传统的阻塞业务代码Netty 允许你在 Pipeline 层面配置“把这个长耗时 Handler 丢进专门的业务线程池去跑”。这样负责网络 IO 的 EventLoop 核心线程把接到的数据丢给业务线程后立刻抽身离开。业务线程自己去阻塞等 10 秒而 Netty 的网络引擎依然在一秒不停地疯狂运转。WebFlux 核心架构与全链路响应式图解WebFlux 彻底抛弃了 Servlet 规范它基于 Project Reactor 库构建将所有的请求和数据流都抽象成了Mono0~1个元素的流和Flux0~N个元素的流。WebFlux 到底做了什么①. 全链路非阻塞的“管道编排”当一个请求进来WebFlux不是立刻去执行业务而是先用 Java 代码拼装出一条“全自动流水线Pipeline”。GetMapping(/ai)publicMonoStringaskAI(RequestParamStringprompt){returnwebClient.post()// 1. 组装请求.bodyValue(prompt).retrieve().bodyToMono(String.class)// 2. 声明转换规则.map(result-AI 回复result);// 3. 声明加工逻辑}**关键内幕**当你作为开发者写下这段代码并 Return 的时候这个请求其实根本还没有发给大模型你只是把这个请求的“处理蓝图Mono”交给了 WebFlux。WebFlux 拿着这张蓝图去找 Netty 说“来把这个任务挂到你的 EventLoop 上去跑吧。”②. 引入了真正的“网络背压Backpressure机制”这是 Tomcat 无论如何也做不到的死穴。Tomcat 的做法上游发多少我就得接多少接不下去了就塞进无界队列 TaskQueue里等死直到OOM。WebFlux 的做法它完美实现了 Reactive Streams 规范。当下游的处理能力不足比如前端网络极卡吐不出数据时WebFlux 会通过流通道反向向 Netty 甚至向最外层网关发送一个信号Request N“我只能吃下 2 个包裹了你别发那么快”。Netty 收到信号后直接暂停读取操作系统的网卡数据。TCP 协议的滑动窗口随之变小逼迫客户端的发送端自动减速。整条链路实现了动态的自适应流量控制彻底免疫 OOM。③. 全面重写了 HTTP 协议对象告别大象包袱前文我们提到Tomcat 异步 Servlet 最大的痛点是 HttpServletRequest 太重了。WebFlux 彻底和 Servlet 划清界限。它自己打造了一套轻量级的ServerHttpRequest和ServerHttpResponse。在 WebFlux 里请求体Body不是一个巨大的 String 字符串或字节数组而是一个FluxDataBuffer零拷贝的二进制数据流。哪怕用户上传一个 10G 的大视频WebFlux 也不会在内存里开辟 10G 的空间而是像自来水管一样网卡进来一个 4K 的连接分片WebFlux 就立刻用响应式流传给下游组件流过即释放内存开销永远保持在极低的平稳水平。回到最初的场景AI 大模型等 10 秒WebFlux 是怎么闭环的当用户发起提问大模型需要思考 10 秒全链路到底发生了什么Mono 蓝图架设用户请求到达Netty WorkerLoop-1线程接到连接WebFlux 瞬间生成一个MonoString流水线蓝图丢给WebClient。线头放行零阻塞WebClient把提问发给 大模型之后WorkerLoop-1线程瞬间全身而退。此时没有 Tomcat 线程在等没有 Netty 线程在等没有任何 Java 线程在为这个请求付出 Thread.sleep 或 Block 的代价。内核死守这时候只有 Linux 内核的网卡文件描述符FD和少量的内存对象在静静挂着。数据回喷无缝召回10 秒后大模型的第一个 Token 碎片顺着网卡回来了。Linux 内核唤醒 Netty。Netty 顺着当初贴在数据屁股后面的Context 句柄瞬间定位到当初那个 Channel。WebFlux 激活流水线数据被注入当初架设好的 Mono 蓝图中自动触发.map(result - AI 回复 result)的加工逻辑。加工完成后Netty 的EventLoop在无锁状态下把这行热乎的文本顺着 TCP 管道直接喷回给前端。