面对大模型 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 爆满时,会发生毁灭性的级联阻塞。

  1. Worker 线程池用光了:假设 maxThreads 是 200,此时 200 个线程全被耗时业务(比如死等大模型返回)占满了。

  2. Acceptor 线程被卡死(Block):第 201 个连接过来了,Acceptor 顺畅地从内核捞出这个连接,转头去线程池里要工作线程。结果线程池冷冷地回一句:“没了,全在忙,你等着吧!”
    由于是 BIO(同步阻塞),Acceptor 线程无法把这个连接丢到任何地方去,它自己必须死死死地抓着这个 Socket,原地处于 Block(阻塞)状态,等池子里释放出空闲工作线程!

所以BIO中: maxConnections = maxThreads

  1. 内核队列(acceptCount)开始堆积:既然唯一的“搬运工” Acceptor 线程现在被卡死在线程池门口了,它就再也没有办法去执行 serverSocket.accept() 来捞连接了。

  2. 彻底爆发 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 内部死死抓着两个底层关键引用:

  1. 客户端的 Socket 连接句柄(网络通道/文件描述符 FD)
  2. 当前请求的 Response 响应体对象

2. 异步处理结果如何找回原有链接?

我们以“调用大模型异步思考 10 秒”为例,拆解其底层的运作真相:

第①步:前台寄存,Worker 线程解绑释放
  1. 请求到达 Tomcat,由 Worker-Thread-A接单并进入你的 Controller
  2. 你的代码执行 AsyncContext asyncContext = request.startAsync();。
  3. 此时发生解绑:Tomcat 知道你要搞异步了,于是把这个连接的 Socket 句柄 连同 asyncContext 像“寄存行李”一样,塞进一个 Tomcat 全局的、由 NIO Poller 线程池共同维护的内部 Map/管理器中
  4. Worker-Thread-A 交代完一句“行李我存好了,AI 好了叫我”之后,瞬间被释放回 Tomcat 线程池。它立刻可以去处理别的用户的请求。
第 ② 步: 业务池死等,触发完成回调
  1. 你的业务代码把 asyncContext 扔给了你自定义的业务线程池(Executor),由业务线程去发起调用大模型。
  2. 10 秒钟后,大模型思考结束,数据返回到了你的业务线程。
  3. 业务线程将数据写入 asyncContext.getResponse().getWriter().write(data);
  4. 紧接着,业务线程调用关键的 asyncContext.complete();(通知 Tomcat:我干完活了,可以把行李取出来还给用户了)。
第 ③ 步:Poller 线程凭“小票”唤醒,精准原路返回
  1. asyncContext.complete() 被触发时,Tomcat 内部会产生一个 “写就绪/异步完成”的内部事件。
  2. Tomcat 负责网络 I/O 轮询的 Poller 线程(我们前文提到过那个永不停歇的网管)利用操作系统多路复用机制捕捉到了这个事件。
  3. Poller 顺着这个事件,去全局的“寄存处”里,根据这个 AsyncContext 内部死死绑定的、唯一的 Socket 文件描述符(FD),瞬间抓回了那个 10 秒前建立的物理 TCP 管道。
  4. 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 内核完成三次握手。

  1. Netty 的 BossGroup通常只需要 1 个线程) 死守端口。一觉察到内核有新连接(Accept 事件),它在微秒级内把这个连接捞出来,为其包装成一个 Channel(通道)对象
  2. 瞬间移交:Boss 线程不作任何停留,立刻把这个Channel扔给后面的 WorkerGroup。因为 Boss 线程永远处于饥饿状态、随时准备迎接下一个连接,所以它几乎不存在连接来不及捞而在内核 acceptCount 里堆积的情况。

第 ② 步: 网络事件的死守(WorkerGroup / EventLoop 运作)

WorkerGroup 内部有一组数量极少但极其高效的线程,叫做 EventLoop(事件循环线程),默认数量通常是 CPU核心数 * 2

  • 多路复用绑定:Boss 扔过来的那个 Channel,会被死死绑定到某一个固定的 EventLoop 线程上。
    • 实际上是都在EventLoop线程的任务队列(TaskQueue)–线程安全的
  • 光占座不干活:如果这个客户端只是连着,连续几小时不发任何数据(Keep-Alive 长连接),这个 EventLoop 线程压根不会理它,也不占用任何 CPU 资源。一个 EventLoop 线程可以同时看管几万个这样安静的连接。

第 ③ 步:数据流过的瞬间(Pipeline 管道流转)

当某个连接突然发送了真实的请求数据(比如一段 HTTP 文本或一个 RPC 报文):

  1. 操作系统内核通知 Netty,绑定该连接的那个 EventLoop 线程被瞬间唤醒。
  2. 流水线处理:这个线程负责把网卡里的二进制数据读出来,然后顺着这个连接自带的 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 库构建,将所有的请求和数据流都抽象成了 Mono(0~1个元素的流)Flux(0~N个元素的流)

WebFlux 到底做了什么?

①. 全链路非阻塞的“管道编排”

当一个请求进来,WebFlux 不是立刻去执行业务,而是先用 Java 代码拼装出一条“全自动流水线(Pipeline)”。

@GetMapping("/ai")
public Mono<String> askAI(@RequestParam String prompt) {
    return webClient.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 划清界限。它自己打造了一套轻量级的 ServerHttpRequestServerHttpResponse

  1. 在 WebFlux 里,请求体(Body)不是一个巨大的 String 字符串或字节数组,而是一个 Flux<DataBuffer>(零拷贝的二进制数据流)。

  2. 哪怕用户上传一个 10G 的大视频,WebFlux 也不会在内存里开辟 10G 的空间,而是像自来水管一样,网卡进来一个 4K 的连接分片,WebFlux 就立刻用响应式流传给下游组件,流过即释放,内存开销永远保持在极低的平稳水平


回到最初的场景:AI 大模型等 10 秒,WebFlux 是怎么闭环的?

当用户发起提问,大模型需要思考 10 秒,全链路到底发生了什么:

  1. Mono 蓝图架设:用户请求到达,Netty WorkerLoop-1 线程接到连接,WebFlux 瞬间生成一个Mono<String>流水线蓝图,丢给 WebClient
  2. 线头放行(零阻塞)WebClient 把提问发给 大模型之后,WorkerLoop-1 线程瞬间全身而退。此时,没有 Tomcat 线程在等,没有 Netty 线程在等,没有任何 Java 线程在为这个请求付出 Thread.sleep 或 Block 的代价。
  3. 内核死守:这时候,只有 Linux 内核的网卡文件描述符(FD)和少量的内存对象在静静挂着。
  4. 数据回喷(无缝召回)
  • 10 秒后,大模型的第一个 Token 碎片顺着网卡回来了。
  • Linux 内核唤醒 Netty。Netty 顺着当初贴在数据屁股后面的 Context 句柄,瞬间定位到当初那个 Channel
  • WebFlux 激活流水线:数据被注入当初架设好的 Mono 蓝图中,自动触发.map(result -> "AI 回复:" + result)的加工逻辑。
  • 加工完成后,Netty 的EventLoop在无锁状态下,把这行热乎的文本顺着 TCP 管道直接喷回给前端。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值