Servlet 3.1与Reactor融合:传统Java Web项目引入响应式编程的实践指南
2026/9/14 23:59:16 网站建设 项目流程

大家好。我最近在做内部老系统改造,碰到一个特别典型的问题:业务链路还是传统的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增加的ReadListenerWriteListener让Servlet也拥有了类似Netty的IO事件回调机制:

  • ServletRequest.startAsync()之后,InputStream.setReadListener()可以注册数据到达回调;容器在数据可读时调用onDataAvailable(),全部读完时调用onAllDataRead()
  • ServletOutputStream.setWriteListener()注册onWritePossible()回调,表示底层缓冲区有空闲、可以继续写入数据了。

这里你会发现,这套“有事件就回调、不让线程干等”的思想,和Reactor模型几乎是一个模子刻出来的。所以Servlet 3.1与Reactor能融合,不是大家硬凑,而是规范本身就把异步事件化铺好了路。

但我要说句实话:纯业务项目里极少有人直接手写ReadListenerWriteListener,因为状态机管理非常容易出错。它们更像是给框架层提供的原料,Spring MVC底层正是靠类似机制做异步返回值的适配。你可以不直接用,但理解它,才能理解后面框架封装出来的行为为什么是那样。

2. Reactor的异步模型说白了是什么

再来看另一头。Reactor不是某个具体框架,而是一套基于Reactive Streams规范实现的响应式编程模型,Spring WebFlux、WebClient底层都跑在它上面。想理解它和Servlet的融合点,不需要学一堆概念,抓住三条主线就够了:发布订阅、背压、调度器。

2.1 发布订阅和背压到底在说什么

Reactor里最常见的两个类型是MonoFluxMono表示0到1个元素的异步序列,Flux表示0到N个元素的异步序列。它们不是“装数据的容器”,数据并存在容器里,而是生产者和消费者之间的一条管道定义。

关键点是订阅驱动。当你写出Mono.just("hello"),什么都没有发生;只有.subscribe()之后,数据才会真正流动起来。这叫做冷流。订阅动作会沿着链路往回触发,从最上游开始产出数据,逐步推给下游。

背压是这套模型最值钱的设计。下游消费者可以告诉上游“我一次只能处理10条”,于是上游就会按照这个速率生产,不会不管不顾地往内存里灌数据。打个比方,Reactor像是一条自动传送带,看管传送带的人会询问下一道工序每小时能消化多少零件,按这个速度来投料,防止中间堆货。

Schedulers.boundedElastic()Schedulers.parallel()这些调度器,负责决定你的每个操作符到底跑在哪个线程上。比如做数据库同步查询,就把任务丢到boundedElastic()上,用专门的弹性线程池去执行;如果是CPU密集型计算,用parallel()更合适。线程调度被抽象成操作符,这让异步代码的可读性比手写线程池高很多。

2.2 为什么不能拿Reactor的线程直接写HttpServletResponse

很多人第一次尝试融合时会直接写类似代码:在Fluxsubscribe回调里调用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方法里返回MonoFlux。Spring MVC从4.2开始就支持响应式返回值,底层就是借助Servlet 3.1异步上下文适配的,这属于典型的框架对开发人员透明化封装。

先看一个最简示例:

@RestController public class ReactiveController { @GetMapping("/mono-hello") public Mono<String> hello() { return Mono.just("hello reactor") .subscribeOn(Schedulers.boundedElastic()); } @GetMapping("/flux-list") public Flux<String> list() { return Flux.just("item-1", "item-2", "item-3") .delayElements(Duration.ofMillis(200)); } }

请求到达时,Spring MVC检测到返回值类型是MonoFlux,会把当前请求切换到异步模式,也就是内部调用了request.startAsync(),然后订阅这个响应式流。当流里有数据产生时,Spring的适配器负责把数据写入响应并调用complete()结束请求。

这里有两个实测细节值得注意:

  • 对于Mono,Spring会等它产出唯一结果,然后写一个普通JSON响应。
  • 对于Flux,如果不指定媒体类型,Spring并不做SSE流式输出,而是等所有元素收集完成后,把整个Flux变成JSON数组一次性返回。这在有些场景下可能不是你要的效果。

如果你希望Flux边生产边推送,必须显式声明produces = MediaType.TEXT_EVENT_STREAM_VALUE

@GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream() { return Flux.interval(Duration.ofMillis(500)) .map(i -> ServerSentEvent.builder("tick-" + i).build()); }

这个方案最大的好处是:业务代码里感知不到Servlet异步的存在,从Controller到Service全都是响应式写法,线程模型由框架隔离,团队上手成本低。我们线上有个订单状态查询接口,原来在Servlet线程池里做两次远程调用,现在改成Mono.zip组合两个WebClient请求,线程占用直接降了一大截。这应该说是“Servlet 3.1+Reactor融合”最省心的落地形态。

3.2 方案二:原生Servlet 3.1 + AsyncContext + Flux,理解机制的必经之路

框架封装太舒服,容易让人变成黑盒使用者。如果你想彻底搞懂这个融合机制,我建议亲手写一个原生Servlet版本。虽然不建议生产这么写,但实践一遍,对很多框架内部行为的理解能达到新高度。

下面是我跑通过的一个最小Demo:

@WebServlet(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); Flux<Long> 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重新获取ServletResponseOutputStream写入。
  • take(5)限制了数据条数,防止无限流导致请求永远不会complete()
  • AsyncListener在超时或结束时取消订阅,防止订阅者回调在请求结束后继续执行。

我实际操作时发现最棘手的是写响应时的竞争问题。Flux的数据产生频率如果很高,writeData会被多个Reactor线程并发调用,但ServletOutputStream整体上不是线程安全的。上面用synchronized锁住输出流是应急做法,生产环境需要用串行化调度,比如把写操作切到单线程调度器上,或者用SampleSubscriber做请求量的手工控制。这恰恰解释了为什么手写方案容易出问题,也更能理解框架层替我们解决了多少脏活。

3.3 方案三:SSE流式输出 + Flux,接住大模型API的流式响应

最近大家接触最多的响应式场景,其实是大模型API的流式调用。大模型接口普遍支持stream=true,服务端会把生成的token一点点推回来,而不是等全部生成完一起返回。你用WebClient去请求这种接口时,拿到的就是一个Flux<String>。如果这个调用发生在传统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 Flux<ServerSentEvent<String>> 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?prompt=hello"); eventSource.onmessage = (event) => { console.log("AI token:", event.data); };

这背后的数据链路就是:大模型服务端返回的字节流,被WebClient(Reactor Netty)包装成Flux<String>,Controller把这个流原样返回给Spring MVC,Spring 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 ServletRegistrationBean<DemoServlet> demoServletRegistration() { ServletRegistrationBean<DemoServlet> 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加载顺序不对。

排查步骤可以参考下面这套,我每次遇到类似问题都会按这个顺序来:

  1. 先确认打包方式。如果pom.xml里写的<packaging>jar</packaging>,而你又想部署到外部Tomcat,那Spring Boot可执行jar的BOOT-INF/lib目录在外部容器场景下不会被识别为classpath,启动时自然找不到依赖类。解决方案是把packaging改成war
  2. 检查启动类是否继承了SpringBootServletInitializer并重写了configure方法:
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } }
  1. mvn dependency:tree看依赖里有没有重复引入Servlet API,比如同时存在javax.servlet-apijakarta.servlet-api,或者同时存在Tomcat embed和外部Tomcat提供版本,极易触发NoClassDefFoundError
  2. 检查IDE运行方式。如果直接在IDEA里用main方法启动,要确认Run Configuration把项目根目录当成classpath根,而不是把BOOT-INF/classes当成根,否则同样会出现类加载错乱。

总之,看到NoClassDefFoundError别急着怀疑主类本身,先查它的依赖链条,大多数问题都出在依赖版本不一致和打包方式不对上。

5. 还好这些坑我替你先踩了

5.1 编码现场的5条实操心得

第一,不要在异步回调里直接持有HttpServletRequestHttpServletResponse的旧引用。容器在超时或客户端断开时会关闭相关资源,你持有旧引用再往里写数据,大概率会碰到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配置
异步请求一直不返回,线程池被占满AsyncContextcomplete()或超时未设置在业务完成、异常、超时三条路径都调用complete()
Controller返回Flux,浏览器只看到JSON数组未启用SSE媒体类型指定produces = TEXT_EVENT_STREAM_VALUE
使用Flux.interval()后服务内存持续增长无限流未取消,订阅未释放合理使用take()、在finallyAsyncListener里取消订阅
请求在异步回调里写流报IllegalStateException在响应已提交或连接已关闭后继续写AsyncContext重新获取response,捕获IOException后正常结束
启动报NoClassDefFoundError: SpringBootServletInitializer打包方式或依赖版本不对改为war打包,继承初始化器,检查依赖树
异步切换后日志无法串联ThreadLocal未跨线程传递用装饰器模式在订阅前后恢复MDC
大模型流式输出总是先全部缓存完才返回使用了非SSE的普通Flux返回改为SSE流式响应,浏览器用EventSource接收

我个人在实际操作中的体会是:能靠框架解决的问题,不要过度手写底层。方案一的Spring MVC适配机制已经把所有生命周期细节封装好了,生产环境直接用是性价比最高的选择。但如果你只停留在会用框架这一层,遇到奇怪问题时会非常被动。所以我强烈建议拿方案二写一个最小Demo,亲眼看一看AsyncContextReactor是怎么在代码层面互相作用的,理解以后再回到框架封装,思路会通透很多。

这个改造方向后续还能再扩展,比如把线程模型的选择做成接口级别可配置、用Micrometer把异步请求的排队时长和线程占用指标暴露到监控系统、或者把大模型流式调用包装成通用组件给多个业务方复用。每一种扩展,本质上都还是在吃透Servlet 3.1和Reactor融合机制的红利。关键是要清楚:容器不变,响应式能力照样能长出来,关键是找对那座桥。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询