一、迁移背景与动机

以前咱们用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-sizemax-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源码发现 WebSocketChannelidleTimeout 默认30秒,如果客户端没发心跳就会被踢。解决方法:在Undertow的 Builder 里配置 setIoThreadssetWorkerThreads,同时设置 setSocketOptions 中的 READ_TIMEOUTWRITE_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后,出现问题不要慌。咱们的排查套路是:

  1. 对比配置:把原来Tomcat的配置(application.yml、ServerProperties)逐项和Undertow的默认值对比,尤其注意 max-http-header-sizemax-connectionsbuffer-size
  2. 抓包分析:用Wireshark或tcpdump看HTTP/WebSocket的详细交互,能发现很多协议层面的差异。
  3. 开启调试日志:在 application.yml 里设置 logging.level.io.undertow=DEBUG,把Undertow的内部日志打出来,它能显示每个请求的处理流程。
  4. 单元测试:针对文件上传、异步请求、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=gracefulUndertowServletWebServerFactory 的超时。

六、总结

从Tomcat迁移到Undertow不是简单的改个依赖,很多底层行为差异会悄悄出现。最好的做法是:先在测试环境做灰度切换,用生产流量的1%测试;同时监控API的错误率、响应时间和线程数。如果出现异常,对照上面提到的坑点逐个排查。另外,建议保持Spring Boot版本和Undertow版本的兼容,不要随便升级。记住,容器只是工具,稳定才是王道。