
大家好。我最近在做内部老系统改造碰到一个特别典型的问题业务链路还是传统的Servlet容器那套Filter、拦截器、war包部署一样没落下但性能瓶颈已经很明显——大量远程API调用把容器线程池占满线程都在那儿睡大觉一台4核8G的机器并发一上来就报警。第一反应自然是上Spring WebFlux Netty可惜内网部署规范卡得死必须Tomcat跑war。后来我花了不少时间把Servlet 3.1和Reactor的融合机制研究了一遍才找到一条相对平滑的出路不用换容器、不用推翻现有MVC结构只需要让请求在进入业务逻辑后切换成响应式模型用Mono/Flux来管理异步任务线程占用能立刻降下来。这篇文章就把这套机制从规范原理到落地细节完整拆开讲适合已经熟悉Servlet基础、想给老项目引入响应式能力但又不方便整体迁移到WebFlux的团队参考。先说个总纲。Servlet 3.1和Reactor之间的关系并不是谁替代谁更像两套并行模型通过一个适配层对齐。Servlet容器负责HTTP生命周期Reactor负责业务异步编排。难点在于中间那座“桥”怎么搭一边是容器线程模型一边是事件循环和背压两者对“异步执行”和“线程归属”的理解完全不同。我会先分别把两边的关键机制讲透再给三种能直接落地的融合方案最后把部署和启动阶段容易踩的坑一起列出来。1. 先搞清楚Servlet 3.1到底给了什么很多开发提到Servlet 3.1第一反应是“不就是注解配置替代web.xml吗”这个印象完全跑偏了。3.1真正有价值的东西是异步处理和非阻塞IO这两块它们是整个融合机制的地基。1.1 异步处理让容器先放手在Servlet 3.0之前一个HTTP请求从进入到返回始终占着一个容器线程。Tomcat默认200个线程就意味着同一时间最多只能同时有200个请求在处理中。如果某个请求在等待下游服务、等待数据库、等待锁这个线程就活活被挂起。很多团队靠“加机器、加线程”硬扛代价很大。Servlet 3.0开始引入的AsyncContext解决了一部分问题。核心思路是请求进来后业务代码可以调用request.startAsync()把请求标记为异步模式随后容器就释放当前线程这个HTTP连接并不会关闭只是不再占着线程。等异步业务真正有结果了再拿到AsyncContext往ServletResponse里写数据最后调用complete()通知容器整个请求结束。这个过程可以类比成餐厅点餐传统模型是服务员端着菜单站在后厨等菜直到菜好了再端出来期间这名服务员服务不了任何其他客人异步模型是服务员记下需求后先离开后厨做好菜叫号再由出餐口把菜送到桌上。服务员的利用率一下就上来了。但要跑通异步有一个必须记住的配置相关的Servlet和Filter都要开启asyncSupported否则容器会直接抛IllegalStateException。注解方式是WebServlet(asyncSupported true)、WebFilter(asyncSupported true)Spring Boot内嵌Tomcat环境下DispatcherServlet默认已经是异步支持状态所以很多人没意识到这条限制。真正自己写Servlet接异步时该配的不配启动时不报错请求一到就异常属于特别隐蔽的坑。用AsyncContext还有几个操作细节必须遵守startAsync()必须在响应提交之前调用一旦已经写了部分响应体容器不允许再进入异步模式。拿到的AsyncContext内部持有request和response的引用但跨线程使用时要注意HTTP连接是共享资源多个线程不能同时往里写数据。异步请求必须有超时兜底用asyncContext.setTimeout()设置否则异常情况下请求会挂着直到容器连接超时体验非常差。complete()只能调用一次重复调用会产生多余的生命周期回调甚至引发IllegalStateException。1.2 非阻塞IO异步的另一个轮子Servlet 3.1在3.0基础上补齐了非阻塞IO。原来的异步模式只是“线程不占着了”但读取请求体、写响应体的时候getInputStream().read()和getOutputStream().write()依然是阻塞的。如果请求体很大或者响应体是持续流式的依然有线程卡在IO上。3.1增加的ReadListener和WriteListener让Servlet也拥有了类似Netty的IO事件回调机制ServletRequest.startAsync()之后InputStream.setReadListener()可以注册数据到达回调容器在数据可读时调用onDataAvailable()全部读完时调用onAllDataRead()。ServletOutputStream.setWriteListener()注册onWritePossible()回调表示底层缓冲区有空闲、可以继续写入数据了。这里你会发现这套“有事件就回调、不让线程干等”的思想和Reactor模型几乎是一个模子刻出来的。所以Servlet 3.1与Reactor能融合不是大家硬凑而是规范本身就把异步事件化铺好了路。但我要说句实话纯业务项目里极少有人直接手写ReadListener和WriteListener因为状态机管理非常容易出错。它们更像是给框架层提供的原料Spring MVC底层正是靠类似机制做异步返回值的适配。你可以不直接用但理解它才能理解后面框架封装出来的行为为什么是那样。2. Reactor的异步模型说白了是什么再来看另一头。Reactor不是某个具体框架而是一套基于Reactive Streams规范实现的响应式编程模型Spring WebFlux、WebClient底层都跑在它上面。想理解它和Servlet的融合点不需要学一堆概念抓住三条主线就够了发布订阅、背压、调度器。2.1 发布订阅和背压到底在说什么Reactor里最常见的两个类型是Mono和Flux。Mono表示0到1个元素的异步序列Flux表示0到N个元素的异步序列。它们不是“装数据的容器”数据并存在容器里而是生产者和消费者之间的一条管道定义。关键点是订阅驱动。当你写出Mono.just(hello)什么都没有发生只有.subscribe()之后数据才会真正流动起来。这叫做冷流。订阅动作会沿着链路往回触发从最上游开始产出数据逐步推给下游。背压是这套模型最值钱的设计。下游消费者可以告诉上游“我一次只能处理10条”于是上游就会按照这个速率生产不会不管不顾地往内存里灌数据。打个比方Reactor像是一条自动传送带看管传送带的人会询问下一道工序每小时能消化多少零件按这个速度来投料防止中间堆货。而Schedulers.boundedElastic()、Schedulers.parallel()这些调度器负责决定你的每个操作符到底跑在哪个线程上。比如做数据库同步查询就把任务丢到boundedElastic()上用专门的弹性线程池去执行如果是CPU密集型计算用parallel()更合适。线程调度被抽象成操作符这让异步代码的可读性比手写线程池高很多。2.2 为什么不能拿Reactor的线程直接写HttpServletResponse很多人第一次尝试融合时会直接写类似代码在Flux的subscribe回调里调用response.getOutputStream().write()。初看没问题其实埋着几个雷。第一是线程边界问题。Flux的回调可能跑在Reactor的调度线程上这个线程不归Servlet容器管理。它去操作ServletOutputStream时就得小心生命周期问题。一旦请求超时或客户端断开容器会回调AsyncListener这时候正在写的回调线程可能还没结束两边一竞争轻则日志刷屏重则数据错乱。第二是阻塞调用问题。ServletOutputStream.write()本身是阻塞的如果你在响应式链路里同步写前面努力换来的非阻塞又白搭了。写完之后调用flush()更是个潜在阻塞点。虽然Servlet 3.1给了新接口但原生的flush语义仍然需要底层socket配合在高吞吐场景下不能假设它永远和事件循环兼容。第三是背压失效问题。HttpServletResponse不参与Reactive Streams的背压协商。Flux往里推数据时并不知道客户端消费速度。如果Flux生成一条数据就写一条而客户端读得慢Tomcat的响应缓冲区写满之后write会阻塞Reactor侧是感知不到这个压力的。这就是为什么框架层要做适配而不是让业务代码直接操作response。所以Servlet和Reactor之间需要一座桥。桥的职责是从服务器拿到AsyncContext作为承载把Flux转换为事件源在数据就绪时由容器合适的机会写入响应同时管理取消和超时。下面三种方案正好覆盖了从框架级到手写级的完整套路。3. 三种真正能落地的融合方案3.1 方案一Spring MVC Reactive返回值生产环境首选如果你的项目已经用了Spring MVC最省事的融合办法是直接在Controller方法里返回Mono、Flux。Spring MVC从4.2开始就支持响应式返回值底层就是借助Servlet 3.1异步上下文适配的这属于典型的框架对开发人员透明化封装。先看一个最简示例RestController public class ReactiveController { GetMapping(/mono-hello) public MonoString hello() { return Mono.just(hello reactor) .subscribeOn(Schedulers.boundedElastic()); } GetMapping(/flux-list) public FluxString list() { return Flux.just(item-1, item-2, item-3) .delayElements(Duration.ofMillis(200)); } }请求到达时Spring MVC检测到返回值类型是Mono或Flux会把当前请求切换到异步模式也就是内部调用了request.startAsync()然后订阅这个响应式流。当流里有数据产生时Spring的适配器负责把数据写入响应并调用complete()结束请求。这里有两个实测细节值得注意对于MonoSpring会等它产出唯一结果然后写一个普通JSON响应。对于Flux如果不指定媒体类型Spring并不做SSE流式输出而是等所有元素收集完成后把整个Flux变成JSON数组一次性返回。这在有些场景下可能不是你要的效果。如果你希望Flux边生产边推送必须显式声明produces MediaType.TEXT_EVENT_STREAM_VALUEGetMapping(path /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream() { return Flux.interval(Duration.ofMillis(500)) .map(i - ServerSentEvent.builder(tick- i).build()); }这个方案最大的好处是业务代码里感知不到Servlet异步的存在从Controller到Service全都是响应式写法线程模型由框架隔离团队上手成本低。我们线上有个订单状态查询接口原来在Servlet线程池里做两次远程调用现在改成Mono.zip组合两个WebClient请求线程占用直接降了一大截。这应该说是“Servlet 3.1Reactor融合”最省心的落地形态。3.2 方案二原生Servlet 3.1 AsyncContext Flux理解机制的必经之路框架封装太舒服容易让人变成黑盒使用者。如果你想彻底搞懂这个融合机制我建议亲手写一个原生Servlet版本。虽然不建议生产这么写但实践一遍对很多框架内部行为的理解能达到新高度。下面是我跑通过的一个最小DemoWebServlet(urlPatterns /async-flux, asyncSupported true) public class AsyncFluxServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { AsyncContext asyncContext req.startAsync(); asyncContext.setTimeout(30_000L); FluxLong flux Flux.interval(Duration.ofMillis(100)) .take(5); Disposable disposable flux.subscribe( item - writeData(asyncContext, item), error - finishWithError(asyncContext, error), asyncContext::complete ); asyncContext.addListener(new AsyncListener() { Override public void onComplete(AsyncEvent event) { disposable.dispose(); } Override public void onTimeout(AsyncEvent event) { disposable.dispose(); asyncContext.complete(); } Override public void onError(AsyncEvent event) { disposable.dispose(); } Override public void onStartAsync(AsyncEvent event) {} }); } private void writeData(AsyncContext asyncContext, Object data) { try { ServletOutputStream out asyncContext.getResponse().getOutputStream(); synchronized (out) { out.write((data \n).getBytes(StandardCharsets.UTF_8)); out.flush(); } } catch (IOException e) { asyncContext.complete(); } } private void finishWithError(AsyncContext asyncContext, Throwable error) { asyncContext.complete(); } }这段代码虽然简单但体现了桥接的核心要素startAsync()释放容器线程让请求生命周期跟容器线程解耦。subscribe()开始生产数据回调发生在Reactor调度线程上不再占用容器请求线程。每次数据到达时通过AsyncContext重新获取ServletResponse的OutputStream写入。take(5)限制了数据条数防止无限流导致请求永远不会complete()。AsyncListener在超时或结束时取消订阅防止订阅者回调在请求结束后继续执行。我实际操作时发现最棘手的是写响应时的竞争问题。Flux的数据产生频率如果很高writeData会被多个Reactor线程并发调用但ServletOutputStream整体上不是线程安全的。上面用synchronized锁住输出流是应急做法生产环境需要用串行化调度比如把写操作切到单线程调度器上或者用SampleSubscriber做请求量的手工控制。这恰恰解释了为什么手写方案容易出问题也更能理解框架层替我们解决了多少脏活。3.3 方案三SSE流式输出 Flux接住大模型API的流式响应最近大家接触最多的响应式场景其实是大模型API的流式调用。大模型接口普遍支持streamtrue服务端会把生成的token一点点推回来而不是等全部生成完一起返回。你用WebClient去请求这种接口时拿到的就是一个FluxString。如果这个调用发生在传统Servlet容器项目里高层怎么把它推给浏览器答案还是SSE配上Reactor。先看Controller这一端RestController public class ChatController { private final WebClient webClient WebClient.builder() .baseUrl(http://llm-gateway) .build(); GetMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString chat(RequestParam String prompt) { return webClient.post() .uri(/v1/chat/completions) .bodyValue(Map.of( prompt, prompt, stream, true )) .retrieve() .bodyToFlux(String.class) .map(data - ServerSentEvent.builder(data).build()); } }浏览器端用原生EventSource就能接const eventSource new EventSource(/chat?prompthello); eventSource.onmessage (event) { console.log(AI token:, event.data); };这背后的数据链路就是大模型服务端返回的字节流被WebClientReactor Netty包装成FluxStringController把这个流原样返回给Spring MVCSpring MVC再借助Servlet 3.1异步上下文逐步把事件写回Tomcat响应缓冲浏览器逐条展示。从头到尾没有一条业务代码是阻塞等待的这就是融合机制在当前AI应用场景里最有价值的表现。实际操作中有三个地方我栽过跟头如果大模型接口是内部部署的方言协议返回的不一定是标准SSE格式要先统一解析成事件结构再流转不要指望bodyToFlux(String.class)通吃所有格式。WebClient实例要复用不要在每次请求里创建否则线程和连接资源会被反复创建拖垮这一点跟传统HttpClient何其相似。客户端断连时ServerSentEvent流不会自动感知取消要依赖WebClient内部机制和超时配置否则大模型侧还会继续生成内容白白浪费上游资源。4. 从war包到启动类这套机制在部署上容易踩的雷融合机制本身讲完了但光有代码还跑不起来。部署阶段的问题往往比编码阶段更让人头大。热搜词里几条典型的报错和概念误区我集中放到这一节说明。4.1 Maven项目的Servlet容器选型和异步开关怎么配先确认你的容器版本和Servlet规范对应关系。Servlet 3.1对应Tomcat 8.5及以上版本Tomcat 9对应Servlet 4.0从Tomcat 10开始Java EE名称空间变成了Jakarta EE包名从javax.servlet迁移到了jakarta.servlet。如果你用Spring Boot 2.x内嵌Tomcat是9.x配合javax开头的写法没问题Spring Boot 3.x则必须使用jakarta开头的API。很多老项目升级后一启动就报类找不到根因往往就是新容器配了旧依赖。配置异步支持要看你的注册方式。纯注解方式WebServlet(name demo, urlPatterns /demo, asyncSupported true)如果是Spring Boot里用ServletRegistrationBean注册需要显式设置Bean public ServletRegistrationBeanDemoServlet demoServletRegistration() { ServletRegistrationBeanDemoServlet registration new ServletRegistrationBean(new DemoServlet(), /demo); registration.setAsyncSupported(true); return registration; }Filter也是一样的逻辑两边都开启异步请求才能顺利通过整个Filter链进入异步状态。如果Filter漏配asyncSupported请求走到Filter时容器会认为当前不在异步支持上下文里轻则日志告警重则直接中断链路。4.2 DispatcherServlet与两个ApplicationContext的关系所有Spring MVC的请求都经过DispatcherServlet而DispatcherServlet启动时要初始化自己的WebApplicationContext。很多人不清楚“root WebApplicationContext”和“servlet WebApplicationContext”的区别这两个概念在融合响应式能力后尤其容易踩坑。root WebApplicationContext通常由ContextLoaderListener创建负责Service、Repository等业务层Bean。DispatcherServlet又会创建一个自己的子容器负责Controller、HandlerMapping、HandlerAdapter等Web组件。子容器能访问父容器的Bean反过来不行。如果你在root里扫描了Controller或者把EnableWebMvc配置放在了root容器就会出现子容器找不到控制器、或者Bean被重复实例化的诡异问题。在响应式场景里还有一个额外的坑WebClient、ReactiveTypeHandler这些Web层组件应该放在servlet子容器里不要塞到root容器。一旦放错层次你注入WebClient时可能拿到的是父容器里的实例某些配置就没有生效排查起来非常折腾。记一个原则Web相关的东西交给DispatcherServlet容器业务相关的东西交给root容器。4.3 一次真实的NoClassDefFoundError排查SpringBootServletInitializer找不到热搜词里有一条经典报错错误: 找不到或无法加载主类 org.jeecg.JeecgSystemApplication 原因: java.lang.NoClassDefFoundError: org/springframework/boot/web/servlet/support/SpringBootServletInitializer我第一次看到这个错时也觉得莫名其妙明明主类就在那里怎么会找不到后来分析下来问题往往并不在主类本身而在主类依赖的一个类加载失败。SpringBootServletInitializer是Spring Boot提供的用于war包部署的入口类报错说明这个类不在classpath里或者classpath加载顺序不对。排查步骤可以参考下面这套我每次遇到类似问题都会按这个顺序来先确认打包方式。如果pom.xml里写的packagingjar/packaging而你又想部署到外部Tomcat那Spring Boot可执行jar的BOOT-INF/lib目录在外部容器场景下不会被识别为classpath启动时自然找不到依赖类。解决方案是把packaging改成war。检查启动类是否继承了SpringBootServletInitializer并重写了configure方法SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } }用mvn dependency:tree看依赖里有没有重复引入Servlet API比如同时存在javax.servlet-api和jakarta.servlet-api或者同时存在Tomcat embed和外部Tomcat提供版本极易触发NoClassDefFoundError。检查IDE运行方式。如果直接在IDEA里用main方法启动要确认Run Configuration把项目根目录当成classpath根而不是把BOOT-INF/classes当成根否则同样会出现类加载错乱。总之看到NoClassDefFoundError别急着怀疑主类本身先查它的依赖链条大多数问题都出在依赖版本不一致和打包方式不对上。5. 还好这些坑我替你先踩了5.1 编码现场的5条实操心得第一不要在异步回调里直接持有HttpServletRequest或HttpServletResponse的旧引用。容器在超时或客户端断开时会关闭相关资源你持有旧引用再往里写数据大概率会碰到IOException。规范做法是从AsyncContext中重新获取response。第二给异步请求设置合理的超时时间。我见过不少项目没写asyncContext.setTimeout()下游服务一卡整个请求无声无息地挂着直到Tomcat全局连接超时才释放。你宁可主动设一个30秒、60秒的阈值让错误尽早暴露也不要让它拖着线程池。第三注意线程池隔离。如果用的是Spring MVC方案响应式流的调度线程默认走Reactor的boundedElastic或Netty事件循环这些线程池不要跟Servlet线程池混用。多个接口共用一个无界队列一旦任务积压内存和线程都会出问题。合理做法是按业务域拆分调度器。第四重视背压配置。WebClient返回的Flux默认可能按Long.MAX_VALUE请求上游数据这在服务端代理场景下没有太大问题但如果下游数据量极大建议用limitRate()或onBackpressureBuffer()显式控制消费速率防止中间环节缓冲过大。第五日志链路要自己处理。从Servlet线程切到Reactor调度线程后原来的MDC上下文不会自动带过去。我们线上排查慢接口时发现很多日志没法串联就是这个问题。解决方案是用装饰器模式封装Mono/Flux在订阅前抓取MDC内容在回调执行时恢复简单可靠。5.2 常见问题速查表现象根因解法调用startAsync()抛IllegalStateExceptionServlet或Filter未开启asyncSupported检查注解或ServletRegistrationBean配置异步请求一直不返回线程池被占满AsyncContext未complete()或超时未设置在业务完成、异常、超时三条路径都调用complete()Controller返回Flux浏览器只看到JSON数组未启用SSE媒体类型指定produces TEXT_EVENT_STREAM_VALUE使用Flux.interval()后服务内存持续增长无限流未取消订阅未释放合理使用take()、在finally或AsyncListener里取消订阅请求在异步回调里写流报IllegalStateException在响应已提交或连接已关闭后继续写从AsyncContext重新获取response捕获IOException后正常结束启动报NoClassDefFoundError: SpringBootServletInitializer打包方式或依赖版本不对改为war打包继承初始化器检查依赖树异步切换后日志无法串联ThreadLocal未跨线程传递用装饰器模式在订阅前后恢复MDC大模型流式输出总是先全部缓存完才返回使用了非SSE的普通Flux返回改为SSE流式响应浏览器用EventSource接收我个人在实际操作中的体会是能靠框架解决的问题不要过度手写底层。方案一的Spring MVC适配机制已经把所有生命周期细节封装好了生产环境直接用是性价比最高的选择。但如果你只停留在会用框架这一层遇到奇怪问题时会非常被动。所以我强烈建议拿方案二写一个最小Demo亲眼看一看AsyncContext和Reactor是怎么在代码层面互相作用的理解以后再回到框架封装思路会通透很多。这个改造方向后续还能再扩展比如把线程模型的选择做成接口级别可配置、用Micrometer把异步请求的排队时长和线程占用指标暴露到监控系统、或者把大模型流式调用包装成通用组件给多个业务方复用。每一种扩展本质上都还是在吃透Servlet 3.1和Reactor融合机制的红利。关键是要清楚容器不变响应式能力照样能长出来关键是找对那座桥。