一、Undertow是个啥?先认识一下

对于不少Java开发者来说,Undertow这个名字可能有点陌生。它的本意是“地下暗流”,听起来有点酷。如果你用过Spring Boot,应该记得默认容器可以是Tomcat,也可以换成Undertow。它和Tomcat、Jetty一样,都是帮助咱们Java程序对外提供HTTP服务的“小旅馆”。但Undertow的重量更轻、内存占用更小,而且设计思路有点像搭乐高积木,你可以按需组合各种处理器,非常适合放在微服务和容器化场景里使用。

要说应用场景,它可不少。比如公司做微服务架构,每个服务都是一个独立的小模块,Undertow能很方便地嵌进项目里,不用再像老项目那样单独维护一个笨重的外部中间件。再比如做轻量级API网关或者内部业务系统,Undertow也能扛住很大的并发流量。还有一个特别舒服的场景是开发调测阶段,因为它的启动速度很快,改完代码后重启几乎不折磨人,这对于急性子的开发小伙伴来说相当友好。

当然,选择它也要付出一些代价。如果你习惯了Tomcat那种“什么都能现成配好”的风格,刚上手Undertow时可能会觉得它太素,很多细节需要自己在代码里抠。不过这也正是它的优势,因为只有把所有细节拿捏在自己手里,等出了问题,你才清楚地知道该去哪儿调整。

二、开发环境里的Undertow调试配置

2.1 先把依赖引进来

咱们从头折腾一遍。本文出现的所有代码、配置和命令,全部属于Java技术栈体系,Maven是Java的构建工具,Logback是Java日志库,JVM参数也是Java进程的运行参数,所以不会出现其他语言。先把Undertow的核心依赖放进Maven的pom.xml里。

<!-- 技术栈:Java -->
<dependency>
    <groupId>io.undertow</groupId>
    <artifactId>undertow-core</artifactId>
    <version>2.3.14.Final</version>
</dependency>

<!-- 为了用上更多高级Handler,建议把undertow-server也带上 -->
<dependency>
    <groupId>io.undertow</groupId>
    <artifactId>undertow-server</artifactId>
    <version>2.3.14.Final</version>
</dependency>

2.2 启动一个最简单的Undertow

我们先不搞花活儿,就用最直接的方式起一个服务器。下面这段代码创建了一个监听8080端口的Undertow,任意路径请求都会得到一句“Hello Undertow”。代码里的注释把每步要干什么都写清楚了。

// 技术栈:Java
import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.util.Headers;

public class DebugUndertow {
    public static void main(String[] args) {
        // 写一个处理器:给浏览器返回一行文字
        HttpHandler handler = new HttpHandler() {
            @Override
            public void handleRequest(HttpServerExchange exchange) throws Exception {
                // 设置响应头,告诉浏览器这是纯文本且用UTF-8编码
                exchange.getResponseHeaders().put(Headers.CONTENT_TYPE,
                        "text/plain; charset=utf-8");
                // 把内容发回去
                exchange.getResponseSender().send("Hello Undertow");
            }
        };

        // 构建服务器,监听本机8080端口
        Undertow server = Undertow.builder()
                .addHttpListener(8080, "localhost")
                .setHandler(handler)
                .build();
        // 启动!
        server.start();
        System.out.println("服务器已启动,访问 http://localhost:8080 试试看。");
    }
}

2.3 把日志调成“话痨”模式

开发调试时最怕遇到问题但看不见内部发生了什么。这时候可以打开更详细的日志,让Undertow把家底都吐出来。在Java技术栈里,我们通常用SLF4J来接日志。先在项目里的resources目录放一个logback.xml,把日志级别调到DEBUG。

<!-- 技术栈:Java -->
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 重点:把Undertow的包级别设置为DEBUG,方便开发时观察细节 -->
    <logger name="io.undertow" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>

    <root level="INFO">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

启动之后,控制台会刷出很多请求、连接、线程分配方面的信息,非常直观。不过要牢记,生产环境可别开这么高的日志级别,否则日志文件会蹭蹭往上涨,最后把磁盘撑爆。

2.4 给调试加一点“特效”:模拟慢请求

有时候我们想验证接口在慢请求下表现怎么样,那就故意让某个处理器睡一会儿,同时给服务器设置读写超时,观察超时时的表现。下面这段示例把业务处理时间变成了2秒,超时时间设成5秒,所以你还能看到请求完整跑完。如果把超时改成1秒,就能立刻看到超时异常。

// 技术栈:Java
import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import org.xnio.Options;
import java.util.concurrent.TimeUnit;

public class SlowDebug {
    public static void main(String[] args) {
        // 模拟一个处理很慢的接口
        HttpHandler slowHandler = new HttpHandler() {
            @Override
            public void handleRequest(HttpServerExchange exchange) throws Exception {
                // 睡2秒,假装在查一个慢数据库
                TimeUnit.MILLISECONDS.sleep(2000);
                exchange.getResponseSender().send("终于处理完了");
            }
        };

        // 用一个外层处理器来记录每次请求的耗时
        HttpHandler debugHandler = new HttpHandler() {
            @Override
            public void handleRequest(HttpServerExchange exchange) throws Exception {
                long start = System.currentTimeMillis();
                exchange.addExchangeCompleteListener((exchange1, nextListener) -> {
                    long cost = System.currentTimeMillis() - start;
                    System.out.println("请求路径:" + exchange1.getRequestPath() + ",耗时:" + cost + "ms");
                    nextListener.proceed();
                });
                // 真正的业务处理器在这里执行
                slowHandler.handleRequest(exchange);
            }
        };

        Undertow server = Undertow.builder()
                .addHttpListener(8080, "localhost")
                // 读超时5秒,写超时5秒
                .setSocketOption(Options.READ_TIMEOUT, 5000)
                .setSocketOption(Options.WRITE_TIMEOUT, 5000)
                .setHandler(debugHandler)
                .build();
        server.start();
    }
}

2.5 开发时的小技巧:自动重启

很多人喜欢用Spring Boot,其实里面有个DevTools能和热启动配合。如果是纯Undertow项目,也可以借助Maven的 exec 插件来启动,每次改完代码,IDE编译后插件就会重启进程。这虽然不是严格意义的热部署,但已经能让开发过程顺畅不少。上下班的时候,省下的等待时间加起来也够多喝两杯咖啡了。

三、生产环境下的参数调优方案

3.1 线程数:不多不少,正好才最好

Undertow有两个很关键的线程池:IO线程和Worker线程。IO线程只管网络连接和读写,数量不用太多,一般跟CPU核数挂钩。Worker线程负责跑具体业务代码,可以多一些。生产环境里,先让默认值跑一段时间,然后用压测工具观察CPU和响应时间,再微调是更稳妥的做法。

下面这段代码演示了如何显式设置这两个参数,思路非常直白。

// 技术栈:Java
Undertow server = Undertow.builder()
        .addHttpListener(8080, "0.0.0.0")
        // IO线程太多会造成频繁切换,太少又没法充分利用网卡吞吐
        .setIoThreads(Math.max(2, Runtime.getRuntime().availableProcessors() / 2))
        // Worker线程负责业务逻辑,可以根据服务时长适当放大
        .setWorkerThreads(Math.max(8, Runtime.getRuntime().availableProcessors() * 16))
        .setHandler(handler)
        .build();

这里提醒一句,如果你的业务里有不少阻塞操作,比如同步调数据库、连Redis、甚至调第三方HTTP接口,那Worker线程数要充足。否则一个接口卡住,后面所有请求都跟着排长队。

3.2 缓冲区、连接数和超时

生产环境并发高,缓冲区太小容易造成频繁创建临时对象,太大又浪费内存。下面这组配置是比较常使用的生产起步值,咱们得结合压测结果再来细调。

// 技术栈:Java
Undertow server = Undertow.builder()
        .addHttpListener(8080, "0.0.0.0")
        // 缓冲区设成16KB,能覆盖大部分协议头信息,减少内存碎片
        .setBufferSize(16 * 1024)
        // 开启堆外缓冲区,减轻GC压力
        .setDirectBuffers(true)
        // 服务端套接字backlog设为8192,避免瞬间大量连接被内核拒绝
        .setSocketOption(Options.BACKLOG, 8192)
        .setSocketOption(Options.KEEP_ALIVE, true)
        .setHandler(handler)
        .build();

同时要关注系统的文件句柄数,Linux下默认1024可能不够用,生产环境需要适当调大。文件句柄好比餐厅里的餐具,客人太多盘子不够,那就只能干等着。

3.3 打开访问日志,别让问题来去无踪

生产环境出了问题,没日志就像大海捞针。Undertow自带访问日志能力,能记录每个请求的状态、耗时和来源。我们可以用RequestDumpingHandler包裹业务Handler,也可以把它接到统一日志采集系统里。

生产环境一般不会把请求日志打到控制台,而是写到独立文件,按天滚动保存。下面是用Logback输出请求日志的配置片段。

<!-- 技术栈:Java -->
<appender name="REQUEST_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/undertow-request.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
        <fileNamePattern>logs/undertow-request.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
        <maxHistory>30</maxHistory>
    </rollingPolicy>
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n</pattern>
    </encoder>
</appender>

这样日志会按天压缩,超过30天自动清理,运维方面也省心不少。

3.4 JVM内存和GC调优

生产上Undertow自身的参数有时并不是瓶颈,JVM堆内存设置反而更关键。假如机器是8G内存,我们通常把初始堆内存和最大堆内存都调到2G,并且换上G1垃圾收集器,同时保留“内存不够就导出堆转储文件”的能力。下面是Java技术栈里常见的启动命令写法。

# 技术栈:Java
java -Xms2g -Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -jar my-undertow-app.jar

开堆转储选项是为了一旦出现内存溢出,就能留下现场数据,事后排查起来会轻松很多。很多团队喜欢把初始堆内存和最大堆内存设成一样,这样避免运行期频繁扩容和收缩堆,换来更稳定的响应速度。

3.5 生产环境架构层面的调优

如果单机再怎么调都不够用,就得看全局了。比较常见的做法是把Undertow服务放在Nginx后面,由Nginx去处理静态文件、做负载均衡,Undertow只负责动态请求。这样压力分散开,每一层都能专注做自己擅长的事。多实例部署时,还可以配上注册中心做服务发现,让流量均匀分散到不同节点,这也是生产环境里最管用的调优思路之一。

四、Undertow的优缺点和注意事项

4.1 优点有哪些

Undertow最大的优点就是省:内存省、CPU省、启动时间省。和Tomcat相比,它面对高并发时表现不差,某些场景下响应时间还更短。因为设计上借鉴了非阻塞思路,所以单个请求占用的资源控制得很好,特别适合小容器里塞多个服务实例的场景。

扩展性也很不错。假如你想在请求进来时先做权限校验,再走业务处理器,可以直接用Handler链一层层包上去。代码读起来清晰,不像某些框架里一堆拦截器横纵交错,排查问题还得翻好几层配置文件。

4.2 缺点和注意事项

它太简洁,另一层意思就是很多功能要自己动手。比如想看完整的请求与响应日志,Tomcat可能直接把功能甩给你,而Undertow你得自己包装Handler或者接一套日志系统。

更要小心的是,IO线程里千万不能做耗时操作。如果你在某个Handler里写了一段睡眠,或者同步访问数据库,会让IO线程被堵住,整个服务器性能直接垮掉。写代码时要分清哪些工作在IO线程,哪些在Worker线程,重活尽量让Worker线程去干。

此外,千万不要轻信网上那些“万能参数”。很多人线上有点卡,就盲目把线程数加到好几千,结果上下文切换把CPU耗尽,反而更慢。调优这件事一定要拿压测数据说话,先测得准,再调得稳,切记不能拍脑袋。

五、文章总结

写到这里,咱们把Undertow从开发到生产的主要脉络梳理了一遍。开发环境里,我们用详细日志、慢请求模拟和超时观察,让很多隐蔽问题提前浮出水面。生产环境里,我们需要冷静地调整线程数、缓冲区、访问日志以及JVM参数,让服务器在有限的资源下跑出更好的稳定性。

Undertow不是那种“锦衣玉食”的框架,它需要你像一个熟悉自己厨具的大厨,知道什么时候火要猛,什么时候火要小。刚开始用时可能不习惯,可一旦理解了线程模型和Handler链,很多性能问题你都能提前化解。希望这篇文章能用最直白的方式让你少走弯路,把Undertow这个“地下暗流”用得顺手,也让它成为你项目里的得力帮手。