一、迁移背景与动机
以前咱们用Spring Boot开发Web应用,默认的容器就是Tomcat,稳如老狗。但后来发现随着流量变大,Tomcat在高并发下CPU和内存开销有点高,尤其是连接数一上来,线程模型吃不住。Undertow是JBoss搞的,基于NIO的非阻塞模型,号称比Tomcat更轻量,启动更快,内存占用也更低。于是很多团队决定在生产环境从Tomcat迁到Undertow,想着能省点机器钱。结果呢?迁移完一上线,各种奇葩问题冒出来了——有的请求超时,有的文件上传失败,有的WebSocket断连。这些坑点藏在细节里,不踩一遍真不知道。今天咱们就聊聊那些容易被忽略的坑,以及怎么排查,全是实战经验。
二、迁移实操(快速上手)
在Spring Boot项目里换容器很简单,改个依赖就行。咱们的技术栈是 Java + Spring Boot 2.6.x + Undertow 2.2,示例里统一用这个。
<!-- pom.xml:去掉Tomcat,引入Undertow -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- 排除内置的Tomcat -->
<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-undertow</artifactId>
</dependency>
改完依赖,理论上应用就能跑。但千万别天真,这只是开始。
三、隐藏的坑点与实战排查
3.1 文件上传大小的限制差异
Tomcat默认上传单个文件大小是1MB,Undertow默认也是1MB,看起来一样?但坑在如果上传多个文件,总大小限制的行为不一样。Tomcat是 max-request-size 和 max-file-size 分开算,Undertow把 max-request-size 当做整个请求体的上限,一旦超过直接拒掉,连个明确的错误码都不给。有个项目上传批量图片,客户端发了个10张图片的multipart请求,总大小4MB,每张400KB,Tomcat下没问题,换到Undertow后客户端收到413状态码,但日志里啥也没打印,排查半天。
# application.yml:调整Undertow的上传限制
spring:
servlet:
multipart:
max-file-size: 5MB # 每个文件最大5MB
max-request-size: 20MB # 整个请求体最大20MB
# 注意:Undertow本身也有独立配置,但Spring Boot已经帮你映射了
# 如果还不够,需要额外配置Undertow的Buffers
undertow:
buffer-size: 1024
direct-buffers: true
排查思路:出现413时,先在Spring Boot的 MultipartAutoConfiguration 里断点调试,发现异常被Servlet容器直接拦截了,根本没有进入Spring的MultipartResolver。然后抓包看响应头,发现是Undertow的 io.undertow.server.handlers.form.FormDataParser 抛的。解决方法:调大 max-request-size,同时注意 max-file-size 要匹配业务需要。
3.2 异步请求处理的区别
Tomcat用Servlet 3.0异步支持,Undertow也支持,但底层的线程模型不同。Tomcat的异步请求会占用容器线程直到超时,而Undertow的 AsyncContext 背后是用Xnio的 IoThread 来调度,如果代码里没处理好,可能导致请求挂起不返回。有个支付回调接口,用了 DeferredResult,Tomcat下正常,迁移后大量连接被挂起,最后触发超时。
// Java技术栈:Spring Boot + Undertow,正确的DeferredResult使用方式
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
@RestController
public class AsyncController {
@GetMapping("/async-demo")
public DeferredResult<String> handleAsync() {
// 设置超时时间10秒
DeferredResult<String> result = new DeferredResult<>(10000L);
// 注意:不能在主线程里直接搞阻塞操作
// 应该把任务提交到业务线程池,而不是用容器线程
taskExecutor.submit(() -> {
try {
// 模拟耗时操作
Thread.sleep(2000);
result.setResult("OK");
} catch (Exception e) {
result.setErrorResult(e); // 异常一定要处理
}
});
// 设置超时回调,避免挂起
result.onTimeout(() -> {
result.setResult("timeout");
});
return result;
}
// 需要自定义线程池
private final ThreadPoolTaskExecutor taskExecutor;
public AsyncController(ThreadPoolTaskExecutor taskExecutor) {
this.taskExecutor = taskExecutor;
}
}
排查思路:用 jstack 抓线程栈,发现大量 UNDERTOW_IO 线程在等待 CountDownLatch 或其他锁。原因是异步任务没有及时完成,而Undertow的IO线程有限,被占满后新请求无法处理。解决方案:确保异步任务使用独立的业务线程池,而不是复用容器IO线程。同时给 DeferredResult 设置合理的超时时间,并实现 onTimeout 回调。
3.3 WebSocket支持的问题
如果项目用了WebSocket,迁移后可能直接连不上。Tomcat的WebSocket实现和Undertow不兼容,Spring Boot的 spring-boot-starter-websocket 依赖里默认用Tomcat的 TomcatWebSocketContainer,换成Undertow后需要改成 UndertowWebSocketContainer。但更隐蔽的问题在于:Undertow对WebSocket的路径匹配规则和Tomcat不同,尤其是带通配符的路径。另外,Undertow默认的心跳间隔是30秒,Tomcat是60秒,如果客户端没按新的间隔发ping,连接会被自动断开。
<!-- pom.xml:确保WebSocket依赖版本一致,且不排除Undertow -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 注意:不用额外引入 undertow-websockets,Spring Boot自动装配 -->
// Java技术栈:WebSocket配置类,显式指定使用Undertow的WebSocket容器
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
import org.springframework.web.socket.server.standard.UndertowWebSocketContainer;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
// 默认情况下,Spring Boot会根据依赖自动选择容器
// 但为了显式控制,可以注入UndertowWebSocketContainer
return new ServerEndpointExporter();
}
@Bean
public UndertowWebSocketContainer undertowWebSocketContainer() {
UndertowWebSocketContainer container = new UndertowWebSocketContainer();
container.setSendTimeout(10000); // 发送超时10秒
container.setSessionTimeout(60000); // 会话超时60秒
// Undertow的心跳间隔默认30秒,这里可以调整
// 但UndertowWebSocketContainer没有直接设置心跳的方法,需要底层配置
return container;
}
}
排查思路:客户端WebSocket连接建立后很快断开,服务端日志出现 WebSocket session closed 且没有错误。用Wireshark抓包看到服务端发了Close帧,状态码是1006(异常关闭)。查看Undertow源码发现 WebSocketChannel 的 idleTimeout 默认30秒,如果客户端没发心跳就会被踢。解决方法:在Undertow的 Builder 里配置 setIoThreads 和 setWorkerThreads,同时设置 setSocketOptions 中的 READ_TIMEOUT 和 WRITE_TIMEOUT。
3.4 优雅停机与连接池释放
Tomcat有 tomcat.mbeans 注册,可以用JMX管理;Undertow没有MBean,但Spring Boot的优雅停机是通过 SmartLifecycle 实现的。坑在于:Undertow默认不会等待正在处理的请求完成就直接关闭,导致部分请求被截断。另外,如果用了连接池(比如Druid或HikariCP),Undertow关闭时连接池可能没来得及释放,造成后续重启失败。
# application.yml:开启优雅停机
server:
shutdown: graceful # 启用优雅停机
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 每个阶段最多等30秒
但仅仅这样还不够,Undertow的线程池需要配合。需要自定义 UndertowServletWebServerFactory,设置 setGracefulShutdownTimeout。
// Java技术栈:自定义Undertow工厂,设置优雅停机超时
import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.stereotype.Component;
@Component
public class UndertowGracefulShutdownCustomizer
implements WebServerFactoryCustomizer<UndertowServletWebServerFactory> {
@Override
public void customize(UndertowServletWebServerFactory factory) {
// 设置等待请求完成的超时时间(毫秒)
factory.setGracefulShutdownTimeout(30000);
// 注意:这个超时时间要和spring.lifecycle.timeout-per-shutdown-phase一致
}
}
排查思路:停服时用 curl 发请求,发现返回503或者连接被RST。分析Undertow的关闭流程:首先拒绝新连接,然后等待已接收的请求处理完,如果等待超时,剩余请求会被丢弃。如果出现503,说明graceful-shutdown没生效或者超时太短。检查日志,看看是否有Shutdown相关的WARN日志。
3.5 自定义错误页面与异常处理
Tomcat对404、500等错误有默认的显示页面,Undertow也有,但路径不同。如果项目里自定义了 ErrorPageRegistrar,在Tomcat下能正常工作,换成Undertow后可能不生效。原因是Undertow的 ErrorPageHandler 处理顺序和Tomcat不一样,它会先检查是否是 io.undertow.server.handlers.error.SimpleErrorPageHandler 处理的静态错误,然后才轮到Spring的 BasicErrorController。
// Java技术栈:自定义ErrorPageRegistrar,确保Undertow也生效
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.ErrorPageRegistrar;
import org.springframework.boot.web.server.ErrorPageRegistry;
import org.springframework.stereotype.Component;
@Component
public class MyErrorPageRegistrar implements ErrorPageRegistrar {
@Override
public void registerErrorPages(ErrorPageRegistry registry) {
// 把404错误转到/error/404路径
registry.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND, "/error/404"));
// 把500错误转到/error/500
registry.addErrorPages(new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error/500"));
}
}
但光注册还不够,Undertow需要 CustomErrorPageFactory 的支持。实际上Spring Boot自动配置已经处理了,但如果你直接返回错误页面内容,可能发现页面空白。排查方式:在发请求时看响应头,如果是 Content-Type: text/plain 或者 application/json 说明被Undertow的默认错误处理器拦截了。解决方案:在 application.yml 里设置 server.error.include-stacktrace: always,或者用 @ControllerAdvice 彻底接管异常处理。
3.6 静态资源访问的路径变化
Tomcat默认把 src/main/resources/static 映射到 /,Undertow也一样,但如果用了Servlet的 addResourceHandler 自定义路径,可能会冲突。有个项目把静态文件放在 src/main/resources/static/upload 下,并用 ResourceHandlerRegistry 设置路径 /files/**,Tomcat下正常,Undertow下访问 http://localhost:8080/files/logo.png 返回404。原因是Undertow对路径的匹配更严格,末尾的斜杠和通配符处理不同。
// Java技术栈:配置静态资源映射
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 映射外部路径,注意Undertow下需要显式指定路径分隔符
registry.addResourceHandler("/files/**")
.addResourceLocations("file:./upload/")
.setCachePeriod(3600);
}
}
排查思路:用 curl -I 看返回的 Location 是否有重定向,或者直接看服务日志是否有404。检查Undertow的 StaticResourceHandler 是否成功注册。可以用Spring Boot的 actuator/mappings 端点查看所有请求映射,确认 /files/** 是否被正确处理。
3.7 日志与监控的差异
Tomcat的访问日志通过 tomcat-access 组件输出,Undertow也有访问日志,但默认是不开启的。如果生产环境需要分析访问记录,忘了开启会导致日志缺失。另外,Undertow的Metrics(比如连接数、线程数)需要通过 undertow.metrics 开启,和Tomcat的 tomcat.metrics 不同。
# application.yml:开启Undertow的访问日志和指标
server:
undertow:
access-log:
enabled: true
dir: ./logs/access
pattern: common
metrics:
enabled: true # 暴露Undertow的Metrics给Actuator
排查思路:用 curl /actuator/metrics 查看是否包含 undertow.connections.total 等指标。如果没有,检查 management.endpoints.web.exposure.include 是否包含 metrics。
四、排查思路总结
迁移到Undertow后,出现问题不要慌。咱们的排查套路是:
- 对比配置:把原来Tomcat的配置(application.yml、ServerProperties)逐项和Undertow的默认值对比,尤其注意
max-http-header-size、max-connections、buffer-size。 - 抓包分析:用Wireshark或tcpdump看HTTP/WebSocket的详细交互,能发现很多协议层面的差异。
- 开启调试日志:在
application.yml里设置logging.level.io.undertow=DEBUG,把Undertow的内部日志打出来,它能显示每个请求的处理流程。 - 单元测试:针对文件上传、异步请求、WebSocket写集成测试,在本地用Undertow容器跑一跑,别等上线才暴露。
五、应用场景与技术优缺点
应用场景:
- 需要极致高并发的API网关或微服务,Undertow的非阻塞模型能大幅降低线程开销。
- 对内存敏感的容器化部署(比如Kubernetes Pod限制256MB),Undertow比Tomcat省几十MB。
- 需要原生支持异步处理、HTTP/2的场景。
技术优缺点: | 方面 | Tomcat | Undertow | |------|--------|----------| | 线程模型 | 传统BIO + NIO(默认NIO) | 纯NIO,基于Xnio | | 启动速度 | 较慢(加载注解多) | 较快(轻量) | | 内存占用 | 较高(每个请求一个线程栈) | 较低(少量IO线程+工作线程池) | | 兼容性 | 好,几乎所有Servlet规范都支持 | 部分高级特性(如JSP)不支持 | | 社区资源 | 丰富 | 较少,但Spring Boot加持后好很多 | | 配置复杂度 | 中等 | 中等,但坑点多 |
注意事项:
- Undertow默认不开启访问日志,要手动配置。
- 异步请求必须使用独立的业务线程池,不能依赖Undertow的IO线程。
- WebSocket的心跳间隔可能比想象中短,需要和前端对齐。
- 文件上传的
max-request-size是整体限制,和Tomcat行为不同。 - 优雅停机需要同时设置
server.shutdown=graceful和UndertowServletWebServerFactory的超时。
六、总结
从Tomcat迁移到Undertow不是简单的改个依赖,很多底层行为差异会悄悄出现。最好的做法是:先在测试环境做灰度切换,用生产流量的1%测试;同时监控API的错误率、响应时间和线程数。如果出现异常,对照上面提到的坑点逐个排查。另外,建议保持Spring Boot版本和Undertow版本的兼容,不要随便升级。记住,容器只是工具,稳定才是王道。
Comments