Spring Web 性能优化:容器调优、HTTP 缓存与资源压缩

0 阅读44分钟

概述

衔接前文

前文《嵌入式 Web 容器:Tomcat/Jetty/Undertow 生命周期整合》已经完整剖析了 Spring Boot 如何通过ServletWebServerApplicationContext创建并配置内嵌 Tomcat,以及如何利用WebServerFactoryCustomizer这一扩展点来介入容器的配置过程。在那一篇中,我们关注的是“如何启动容器”以及Spring Boot自动配置如何将一台内嵌服务器无缝整合进应用生命周期。现在,我们将视角从启动转向运行,深入探讨如何让这台内嵌引擎在高并发压力下跑得更快、更稳。

同样,前文系列中的过滤器协作篇曾提及ShallowEtagHeaderFilter,但仅是作为过滤器链中的一个典型组件一笔带过。异常处理篇和资源处理篇也触及了ResourceHttpRequestHandler的行为。然而,这些组件背后蕴藏的巨大性能优化潜能,以及它们与底层容器、网络协议的深度联动,并未展开。本文将对这些细节进行深度挖掘,将散落在不同篇章的知识点串联成一套完整的Web层性能优化体系。

总结性引言

一个Java Web应用的性能瓶颈,往往不是复杂的业务逻辑,而是那些被忽视的基础配置与传输开销。设想一个高并发请求抵达Tomcat:连接器如何将其转化为I/O事件?线程池何时会因处理缓慢而饱和?对于重复请求,HTTP缓存能否在框架层直接拦截并返回304 Not Modified,从而避免业务逻辑的执行与数据库查询?对于静态资源,是否经过了有效的压缩,并且其URL是否实现了内容哈希版本化以实现“永久缓存”?

这些看似基础的工程决策,其背后蕴藏着对并发模型、I/O模型和网络协议深刻理解的要求。例如,Tomcat NioEndpoint的Acceptor-Poller-Worker三层线程模型决定了连接与请求的转化效率和瓶颈点;ShallowEtagHeaderFilter通过MD5哈希计算来生成弱ETag,节省了带宽却消耗了CPU;GZIP压缩能极大减少传输字节,但其压缩比与CPU开销的权衡直接关联线程池的释放速度;静态资源版本化与Cache-Control头的配合,则是一种从架构层面消除无效请求的战略性优化。

本文将深入这些底层逻辑,通过源码分析、序列图推演、压力测试对比和事故复盘,为您构建一个清晰的Web性能优化决策框架。我们不止步于“如何配置”,而是要追问“为何如此配置”以及“在不同场景下如何做出最优的工程权衡”。

核心要点

  • Tomcat 线程模型调优:揭示NioEndpointmaxConnectionsacceptCountmaxThreads三个参数的联动机制,并探讨在虚拟线程时代,连接层面参数为何依然关键。
  • HTTP 缓存的双重机制:深究ETag(内容哈希)和Last-Modified(时间戳)在Spring中的源码实现,剖析ShallowEtagHeaderFilterResourceHttpRequestHandler各自的应用场景与局限。
  • ShallowEtagHeaderFilter 的代价与适用边界:重点分析其包装响应流并进行MD5计算的CPU与内存代价,讲明为何它只适合响应体较小的API场景。
  • 资源压缩与版本化的协同:解析GZIP压缩的自动配置原理及其与ETag的协同工作流,同时深入分析VersionResourceResolver如何通过ContentVersionStrategy实现“永久缓存-增量更新”的静态资源策略。
  • 性能可观测性:利用Spring Boot Actuator和JMX构建对Tomcat线程池、连接数的实时监控与诊断体系。

文章组织架构图

flowchart TD
    n1["1. Web性能优化全景:线程、缓存与压缩的协同"]
    n2["2. Tomcat容器调优:连接器与线程池的深度联动"]
    n3["3. HTTP缓存机制:ETag、Last-Modified与Cache-Control的源码实现"]
    n4["4. ShallowEtagHeaderFilter的代价与适用边界"]
    n5["5. 资源压缩:GZIP与Brotli的自动配置与性能权衡"]
    n6["6. 静态资源版本化:VersionResourceResolver与ContentVersionStrategy"]
    n7["7. 性能监控与诊断:Actuator、JMX与连接泄漏排查"]
    n8["8. 全链路优化决策框架"]
    n9["9. 生产事故排查专题"]
    n10["10. 面试高频专题"]

    n1 --> n2
    n1 --> n3
    n1 --> n5
    n3 --> n4
    n5 --> n4
    n5 --> n6
    n2 --> n7
    n3 --> n7
    n5 --> n7
    n6 --> n7
    n7 --> n8
    n8 --> n9
    n8 --> n10

    classDef topic fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333;
    class n1,n2,n3,n4,n5,n6,n7,n8,n9,n10 topic;

架构图说明

  • 总览说明:全文10个模块围绕Web性能优化的三个核心维度——线程模型(计算与并发)缓存策略(减少计算)传输压缩(减少带宽)——展开。从建立协同全景图开始,逐一深层剖析各维度的原理与源码,再上升到监控诊断与全链路决策,最后以事故和面试完成实践与检验的闭环。

  • 逐模块说明:模块1建立“线程-缓存-压缩”三角协同的宏观认知;模块2至6分别直击Tomcat NIO线程模型、HTTP缓存校验机制、ShallowEtagHeaderFilter的代价、GZIP/Brotli压缩和资源版本化的源码实现;模块7提供基于Actuator和JMX的监控实操;模块8整合出一套可落地的决策框架;模块9和10着眼于解决真实问题和应对面试考察。

  • 关键结论Web性能优化不是单一参数的调整,而是线程模型、缓存策略与压缩传输三者的动态平衡。理解Tomcat的Acceptor-Poller-Worker架构和ETag的哈希计算代价,是避免“优化反而降速”的前提。任何没有度量支撑的调优都是盲目的。


1. Web 性能优化全景:线程、缓存与压缩的协同

一个HTTP请求钻进内嵌Tomcat,再变成响应字节流逃逸出去的生命周期,便构成了我们进行性能优化的物理版图。这个周期可以清晰地被划分为三个关键环节,每个环节对应一个核心优化维度:

  1. 容器接入请求(线程模型维度):TCP连接经由操作系统的全连接队列,被Tomcat的Acceptor线程捕获并注册到PollerPoller监听I/O事件,最后将具体的请求处理任务分派给Worker线程池。这个环节的性能问题表现为连接超时、请求排队、线程池饥饿。优化的核心是调整连接器与线程池的联动参数。

  2. 框架处理请求(缓存维度):请求进入Servlet容器和Spring的DispatcherServlet后,它将穿过过滤器链和拦截器。如果这是一个可缓存的重复请求,我们完全可以在这一层(例如,通过ShallowEtagHeaderFilterResourceHttpRequestHandlercheckNotModified方法)拦截,直接生成304 Not Modified响应,从而跳过后续所有处理器、业务逻辑和视图渲染的开销。这个环节优化的核心在于以计算(哈希/时间戳比较)换时间(避免业务执行)

  3. 网络传输响应(压缩与版本化维度):响应体从服务器流回客户端。对于巨大的JSON或静态JS/CSS文件,压缩能显著减少传输的字节数,但会消耗服务器的CPU。对于静态资源,如果我们通过内容哈希实现了版本化,就能配合Cache-Control: max-age=31536000头,告诉浏览器“此资源一年内有效,无需重新请求”,从而从源头上消除HTTP请求。这个环节优化的核心在于以CPU(压缩)换带宽以空间(永久缓存)换时间(消除请求)

这三者之间并非孤立,而是强联动、相互制约的:线程池的高效释放依赖于业务处理的快速完成,缓存命中能直接跳过慢业务逻辑从而加速线程释放;GZIP压缩加速了网络传输,但其CPU开销又可能拖慢处理速度,进而导致线程占用时间变长;版本化策略可以从源头消灭请求,直接减轻线程池和缓存的压力。

flowchart LR
    subgraph "优化维度"
        A("线程模型")
        B("HTTP缓存")
        C("传输压缩与版本化")
    end

 
      A -- "缓存命中减少处理时间" --> B
      A -- "压缩耗费CPU可能阻塞线程" --> C
      B -- "版本化策略消灭请求" --> A
       C -- "带宽节省加快响应" --> A
       C -- "压缩后内容影响ETag" --> B
  

    A --> D["目标:高吞吐、低延迟"]
    B --> D
    C --> D
  • 图表主旨概括:本图揭示了Tomcat线程模型调优、HTTP缓存、资源压缩与版本化这三个性能优化维度之间的相互影响关系,以及它们共同服务于“高吞吐、低延迟”这一核心目标。
  • 逐层/逐元素分解:三个维度的节点代表独立优化领域。箭头表示联动关系:例如,从“HTTP缓存”指向“线程模型”的箭头表示,高效的缓存策略(如返回304)能显著减少请求在Worker线程中的处理时间,从而加速线程释放,提高吞吐量。从“传输压缩”指向“线程模型”的箭头则揭示了压缩带来的CPU开销可能成为新的瓶颈。
  • 设计原理映射:这本质上是一个资源权衡模型。线程是CPU计算资源在时间上的分片,缓存是用空间或少量计算换取处理时间的避免,压缩则是用CPU时间来换取网络带宽这一稀缺资源。工程决策就是在这三者间寻找当前业务场景下的最优平衡点。
  • 工程联系与关键结论:在进行任何单项优化时,都必须审视其对另外两个维度的影响。例如,盲目开启高压缩比GZIP可能导致Worker线程因CPU密集计算而阻塞,反而降低了整个容器的吞吐量。优化的第一步是建立这个全局联动视图。

2. Tomcat 容器调优:连接器与线程池的深度联动

内嵌Tomcat的并发处理能力,根植于其连接器Connector组件的I/O模型与线程模型。Spring Boot 2.7.x基于Tomcat 9.x,默认采用NioEndpoint,这是一种基于Java NIO的非阻塞I/O模型。理解其内部的Acceptor-Poller-Worker三层线程架构,是进行一切线程池调优的理论基石。

2.1 NioEndpoint 的三层线程模型

NioEndpoint内部通过精巧的线程协作,实现了连接的接收、I/O事件的监听和请求的处理三者的分离。

  1. Acceptor 线程:数量固定(通常为1,可通过acceptorThreadCount调整)。它运行一个死循环,调用serverSocket.accept()接收新的TCP连接。一旦获得连接,Acceptor不会做任何读写操作,而是通过setSocketOptions()方法将其即刻注册到Poller的事件队列中,然后立即返回等待下一个连接。它的使命是纯粹、快速地接纳连接。

  2. Poller 线程:数量通常为Math.min(2, Runtime.getRuntime().availableProcessors())。每个Poller维护一个Selector对象,负责监听其名下所有已注册连接通道上的I/O事件(如OP_READ)。当Acceptor将一个新连接注册给它后,Poller就会在下一个selector.select()周期中感知到。一旦发现有可读事件(即请求数据到达),Poller会将该通道封装成一个SocketProcessor提交给Worker线程池。Poller是实现NIO“非阻塞”的关键,它使用少量线程管理成千上万个长连接。

  3. Worker 线程(Executor):我们常说的Tomcat线程池,正式名称是Executor。它负责执行SocketProcessor任务。SocketProcessor会从通道中读取HTTP请求数据、解析协议、构造HttpServletRequest/HttpServletResponse,并最终调用Servletservice()方法,将请求送入我们的Spring Web应用。Worker线程池的大小和饱和策略,直接决定了应用的并发处理能力。

flowchart TB
    Client["客户端连接"] -->|"TCP SYN"| OSQueue["操作系统全连接队列 acceptCount"]
    OSQueue -->|"被取出"| Acceptor["Acceptor 线程<br/>(死循环接受连接)"]
    Acceptor -->|"注册通道和事件"| PollerQueue["Poller 事件队列"]
    PollerQueue --> Poller["Poller 线程<br/>(运行Selector)"]
    Poller -->|"提交SocketProcessor"| WorkerPool["Worker 线程池<br/>maxThreads/minSpareThreads"]
    WorkerPool -->|"执行请求处理"| SpringApp["Spring Web 应用"]

    subgraph ContainerBoundary ["容器边界"]
        Acceptor
        Poller
        WorkerPool
    end

    classDef network fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#0d47a1;
    classDef processing fill:#f3e5f5,stroke:#8e24aa,stroke-width:2px,color:#4a148c;
    class Client,OSQueue,PollerQueue network;
    class Acceptor,Poller,WorkerPool,SpringApp processing;
  • 图表主旨概括:本图清晰展示了Tomcat NioEndpoint的Acceptor、Poller、Worker三层线程架构,以及外部连接是如何通过操作系统的等待队列进入应用处理环节的。
  • 逐层/逐元素分解Acceptor是连接的入口,受acceptCount队列容量的前序限制。Poller是I/O事件的监视器,是NIO非阻塞核心的体现。Worker线程池是最终的执行者,其大小由maxThreads控制。Spring Boot中的server.tomcat.threads.max配置项映射成此Worker线程池的最大线程数。
  • 设计原理映射:这是Reactor模式的一种实现——Acceptor是主Reactor,负责连接建立;Poller是子Reactor,负责分发I/O事件;Worker线程池则是处理线程池。这种分离使得连接管理和请求处理解耦,能支撑海量连接。用更传统的术语说,这是半同步/半异步模式,I/O接收和分发是异步的,而业务处理是同步的。
  • 工程联系与关键结论性能瓶颈的转移有明确路径:当请求量剧增时,首先可能耗尽的是maxThreads,表现为处理延迟增加、响应变慢;如果继续增加,会耗尽acceptCount队列,表现为新的连接被拒绝。而maxConnections是控制Poller管理连接数的软上限,只有在极端长连接场景下才会成为瓶颈。理解这个逐层溢出的顺序,是精准调参的基础。

2.2 核心参数的深层联动逻辑

在Spring Boot的application.properties中,我们常接触以下配置:

  • server.tomcat.threads.max:映射到maxThreads,Worker线程池的最大线程数。默认200
  • server.tomcat.accept-count:映射到acceptCount,当所有处理线程都被占用时,操作系统允许的等待队列的最大长度。默认100
  • server.tomcat.max-connections:映射到maxConnections,Tomcat在任意时刻能接受和处理的连接总数上限。对于NIO,默认10000

这三个参数的联动关系构成了一个流量漏斗:

  1. 连接数漏斗:一个新连接抵达,Tomcat会先检查当前连接数是否超过maxConnections。如果未超,连接被建立,进入操作系统的TCP连接队列。如果超过,则连接直接遭到拒绝。
  2. 队列漏斗:连接建立后,它会在内核的全连接队列中等待Acceptor线程将其取出。这个队列的长度即acceptCount。如果队列已满,即使连接数未达maxConnections,新的连接请求也会被内核直接拒绝,客户端会出现ConnectException
  3. 处理漏斗:连接被Poller转换为请求任务后,最终必须由Worker线程来处理。如果所有Worker线程(maxThreads个)都忙碌,那么SocketProcessor任务会在线程池的任务队列中排队。默认情况下,Tomcat使用LinkedBlockingQueue(无界队列),所以提交任务永远不会失败,但是当所有Worker线程都在处理慢请求时,后续请求的排队等待时间会无限增长,最终导致客户端超时。

参数联动公式:系统的最大并发处理能力由maxThreads决定。能够容忍的最大瞬时突发连接量,则是由acceptCount提供的缓冲能力。而maxConnections决定了可维持的长连接(如Keep-Alive)总数。在NIO模型下,maxConnections通常远大于maxThreads,这正体现了非阻塞I/O的优势:用一个Poller线程可以同时监视几百个甚至上千个几乎空闲的Keep-Alive连接,而Worker线程只服务于那些真正有数据可读的活动链接

2.3 异步Servlet与Worker线程模型的协作

当我们使用Spring MVC的CallableDeferredResult时,底层Servlet规范中asyncSupported=true的特性就生效了。处理流程如下:

  1. Worker线程W1处理请求,调用Callable.call(),得到一个Future,然后将请求放入异步上下文,W1线程立即返回池中
  2. Callable任务完成,会产生一个事件,容器(通常是Tomcat的AsyncListener)会从Worker线程池中**再分派一个新的线程W2**来继续处理这个请求,生成响应。

这种机制极大提升了对I/O密集型请求的吞吐量。但是,它并没有减少Worker线程池的压力峰值,只是改变了线程的占用模式。W1被迅速释放去处理其他请求,而处理最终响应的任务交给了W2。如果异步业务的回调处理也非常慢,那么线程池依然会面临被耗尽的风险。

2.4 内联示例:定制线程池并压测验证

通过WebServerFactoryCustomizer可以程序化地定制线程池参数。下面是一个定制Tomcat线程池并暴露指标的示例。

import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TomcatTuningConfig {

    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
        return factory -> {
            factory.addConnectorCustomizers(connector -> {
                // 获取协议处理器,基于Tomcat 9.x
                if (connector.getProtocolHandler() instanceof org.apache.coyote.http11.Http11NioProtocol) {
                    org.apache.coyote.http11.Http11NioProtocol protocolHandler = 
                        (org.apache.coyote.http11.Http11NioProtocol) connector.getProtocolHandler();

                    // 设置最大连接数
                    protocolHandler.setMaxConnections(5000);
                    // 设置操作系统等待队列长度
                    protocolHandler.setAcceptCount(200);
                    // 设置处理线程池参数
                    protocolHandler.setMaxThreads(100);
                    protocolHandler.setMinSpareThreads(20);

                    // 开启Tomcat MBean,以便监控
                    protocolHandler.setRegisterMBean(true);
                }
            });
        };
    }
}

使用wrk进行压测,并观察/actuator/metrics/tomcat.threads.busy指标。你会发现,当并发量超过maxThreads时,该指标会触及上限,而响应时间开始显著上升。调高maxThreads能提升峰值吞吐,但也增大了上下文切换开销,因此最佳值需通过压测找到吞吐量的拐点。

2.5 虚拟线程时代的展望

在虚拟线程(前文已详述)时代,操作系统线程不再是并发单元,成千上万个虚拟线程可以运行在极少数平台上。maxThreads参数的意义将从“物理线程数上限”转变为“并发任务执行并行度的上限”。server.tomcat.threads.max如果被Spring Boot 3.2+取消或变为虚拟线程的相关配置,那么应用将不再受制于线程池耗尽问题。但是,maxConnections和操作系统文件描述符上限依然是硬瓶颈,因为它们限制的是底层网络资源,而非计算线程。 连接层面的配置在虚拟线程时代变得更加关键。


3. HTTP 缓存机制:ETag、Last-Modified 与 Cache-Control 的源码实现

HTTP缓存是一种利用客户端能力来减少服务器负载和网络请求的强大机制。Spring框架在控制层面对其提供了优雅的支持,主要通过两个组件:ResourceHttpRequestHandler(用于静态资源)和ShallowEtagHeaderFilter(用于动态资源/API)。其核心是处理两种条件请求头:If-None-Match(对应ETag)和If-Modified-Since(对应Last-Modified)。

3.1 资源端缓存:ResourceHttpRequestHandler 的 checkNotModified

当我们通过WebMvcConfigurer.addResourceHandlers映射静态资源路径时,Spring Boot自动配置的ResourceHttpRequestHandler就登场了。它在处理请求的handleRequest方法中,会调用checkNotModified来尝试短路整个请求。

// 源码位于:org.springframework.web.servlet.resource.ResourceHttpRequestHandler
@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

    // 1. 检查资源是否存在
    Resource resource = getResource(request);
    if (resource == null) {
        // ... 处理404
        return;
    }

    // 2. 关键步骤:检查是否未修改
    if (new ServletWebRequest(request, response).checkNotModified(resource.lastModified())) {
        // 如果条件命中,方法已直接设置304,这里额外处理一些日志等
        return;
    }
    
    // 3. 准备响应,写入资源和Cache-Control头
    // ...
}

checkNotModified 的深层逻辑在 org.springframework.web.context.request.ServletWebRequest 中:

public boolean checkNotModified(long lastModifiedTimestamp) {
    return checkNotModified(null, lastModifiedTimestamp);
}

public boolean checkNotModified(@Nullable String etag, long lastModifiedTimestamp) {
    boolean ifNoneMatch = this.notModifiedWithIfNoneMatch(etag);
    boolean ifModifiedSince = this.notModifiedWithIfModifiedSince(lastModifiedTimestamp);
    // 如果任一条件成立,则直接设置304并返回true
    if (ifNoneMatch || ifModifiedSince) {
       this.response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
       this.response.setContentLength(0);
       // ... 阻止后续写入
       return true;
    }
    return false;
}

源码解读:这是Spring MVC处理条件请求的核心。它首先检查If-None-Match头与提供的ETag是否匹配,再检查If-Modified-Since头是否不早于资源最后修改时间。两者是“或”的关系,任何一个成立都意味着内容未变。当命中时,它会主动将响应状态码设为304,并清空响应体。此举的意义在于,后续的拦截器、处理器、视图解析器等所有逻辑都将被跳过,直接返回一个轻量的304响应,极大节省了CPU、内存和数据库资源。 这与前文过滤器链的原理一脉相承,它在Spring MVC的请求处理前端就完成了决断。

3.2 Cache-Control 头的策略注入

Cache-Control头的设置,决定了资源在客户端或代理中的缓存策略。Spring通过WebContentGeneratorAbstractControllerAbstractHandlerMethodMapping均继承自它)提供了全局和细分的配置能力。

在配置文件中,spring.web.resources.cache.cachecontrol属性会被ResourceProperties读取,并生成一个CacheControl对象。最终,这个对象通过ResourceHttpRequestHandlerprepareResponse方法施加到响应头中。例如:

// org.springframework.web.reactive.resource.ResourceHttpRequestHandler (Reactive示例,Servlet类似)
// 实际在org.springframework.web.servlet.resource.ResourceHttpRequestHandler的applyCacheControl中:
protected void applyCacheControl(HttpServletResponse response, CacheControl cacheControl) {
    String headerValue = cacheControl.getHeaderValue();
    if (headerValue != null) {
        response.setHeader("Cache-Control", headerValue);
    }
}

Cache-Control指令的现实意义

  • max-age=3600:代表资源在3600秒内是“新鲜”的,浏览器可以直接从内存或磁盘缓存中使用,无需向服务器发起任何请求。这是性能优化最有效的手段。
  • no-cache:要求每次使用缓存前,必须向服务器发起一个带有If-None-MatchIf-Modified-Since的条件请求进行验证。它并不禁止缓存,只是强制每次都验证。
  • no-store:全面彻底的禁止缓存,响应数据不允许被写入任何客户端存储。
  • private vs public:前者只允许浏览器私有缓存,后者允许中间代理服务器(如CDN)进行缓存。

3.3 条件请求处理序列图

sequenceDiagram
    participant Client as 浏览器
    participant FilterChain as 过滤器链
    participant DispatcherServlet as 前端控制器
    participant ResourceHandler as ResourceHttpRequestHandler
    participant Resource as 文件系统

    Client->>FilterChain: GET /js/app.js<br>If-None-Match: "abc123"
    FilterChain->>DispatcherServlet: 请求分发
    DispatcherServlet->>ResourceHandler: handleRequest()
    
    ResourceHandler->>Resource: 获取资源
    Resource-->>ResourceHandler: 返回资源 + lastModified

    ResourceHandler->>ResourceHandler: checkNotModified(etag, lastModified)
    Note over ResourceHandler: 比对If-None-Match中的"abc123"<br>与当前ETag是否相等

    alt 匹配成功
        ResourceHandler-->>DispatcherServlet: 返回true,设置状态码304
        DispatcherServlet-->>FilterChain: 响应304 Not Modified
        FilterChain-->>Client: 304 Not Modified<br>(无响应体,0字节)
    else 匹配失败或无条件头
        ResourceHandler->>Resource: 读取资源内容
        Resource-->>ResourceHandler: 内容字节流
        ResourceHandler->>ResourceHandler: 写入响应并设置<br>Cache-Control: max-age=3600
        ResourceHandler-->>DispatcherServlet: 处理完成
        DispatcherServlet-->>Client: 200 OK + 响应体
    end
  • 图表主旨概括:该序列图清晰描绘了当一个携带If-None-Match的请求到达时,Spring的ResourceHttpRequestHandler如何工作,以及在304200两种响应路径下的不同处理流程。
  • 逐层/逐元素分解:参与者涉及客户端、Spring MVC核心组件和资源实体。关键在于checkNotModified方法的判断节点,它将流程一分为二。304分支代表了缓存的胜利,所有后续的I/O操作(读取文件内容、写响应体)都被省去;200分支则是缓存失效的路径,需要完整的资源加载和传输。
  • 设计原理映射:这是策略模式模板方法模式的结合。ResourceHttpRequestHandlerhandleRequest方法定义了一个处理骨架(模板方法),而checkNotModified则是其中可以改变行为的关键步骤。这允许Spring在不改变上层架构的情况下,灵活处理缓存验证逻辑。
  • 工程联系与关键结论304 Not Modified响应的价值不在于节省网络带宽(它确实能省),而在于节省了服务器端读取文件、处理请求和计算的昂贵开销。对于像网站Logo、全局CSS和JavaScript这类频繁请求但很少改变的静态资源,使用Last-Modified或内容哈希ETag实现条件请求,是成本极低而收益极高的优化。

4. ShallowEtagHeaderFilter 的代价与适用边界

ShallowEtagHeaderFilter是一个典型的Servlet Filter,它可以为任何动态生成的响应(如REST API返回的JSON)按需添加ETag头。然而,其名字中的“Shallow”(浅薄)一词已经暗示了它的局限性。它与前文过滤器链篇讲述的OncePerRequestFilter一脉相承,我们来看看它的内部实现如何体现这种“浅薄”。

4.1 源码深度拆解:doFilterInternal

// 源码位于:org.springframework.web.filter.ShallowEtagHeaderFilter
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

    // 1. 包装响应:ContentCachingResponseWrapper会拦截所有对输出流的写入,并缓存字节
    ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
    
    // 2. 继续过滤器链,让业务逻辑写入响应体
    filterChain.doFilter(request, responseWrapper);

    // 3. 在过滤器链返回后,获取缓存的响应体字节数组
    byte[] body = responseWrapper.getContentAsByteArray();
    
    // 4. 计算响应体的MD5哈希,作为ETag
    String eTag = "\"0" + DigestUtils.md5DigestAsHex(body) + "\""; // 生成弱ETag,以W/或0开头表示弱校验
        
    // 5. 将ETag写入响应,并检查是否匹配客户端If-None-Match头
    if (responseWrapper.isContentWritten() && eTag.equals(request.getHeader("If-None-Match"))) {
        // 5.1 匹配成功:清空响应体,设置304
        responseWrapper.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
        responseWrapper.setContentLength(0);
        // 因为响应体已清空,ContentCachingResponseWrapper不会复制原始body到原始response
        responseWrapper.copyBodyToResponse(); // 实际上复制的是空内容
        return;
    }
    
    // 6. 不匹配:注入ETag头,并将缓存的内容复制到原始响应
    response.setHeader("ETag", eTag);
    responseWrapper.copyBodyToResponse(); // 将缓存的内容真正写入原始response的输出流
}

源码解读:此过滤器的核心思想是后置拦截。它请君入瓮,先用一个能缓存响应的包装器将过滤器链的后续响应全部“吸”住,等业务逻辑全部跑完、响应体完整生成后,再回头来计算哈希并决定返回200还是304ContentCachingResponseWrappergetContentAsByteArray()确实会将整个响应体加载到内存中的一个字节数组里。

4.2 性能代价与协同冲突分析

  1. CPU代价与内存代价:对于每个请求,它都必须完整地执行一遍业务逻辑,并将整个响应体在内存中缓存,最后执行一次MD5哈希计算。对于较大的响应体(如上兆的JSON),这不仅会占用大量内存,而且MD5计算本身也是CPU密集型的。
  2. 适用场景它仅适合于响应体较小(通常<10KB)且业务处理开销巨大(如复杂计算、多次数据库查询)的API接口。例如,一个聚合了多个微服务数据并组装成几百字节JSON的接口。如果响应体本身就是从数据库实时查出来的大列表,那用ShallowEtagHeaderFilter就是得不偿失。
  3. 与GZIP压缩的协同ShallowEtagHeaderFilter计算哈希的时候,它拿到的是过滤器链中所有上游过滤器处理完之后的HTTP响应体。要注意它在过滤器链中的位置:如果你也开启了CompressionFilter,并且CompressionFilter在它之后执行,则它计算的是压缩后的内容哈希,这就可能导致问题。在Spring Boot的默认配置中,如果开启server.compression.enabled=true并采用响应压缩,Tomcat的压缩发生在容器的最外层,通常会在ShallowEtagHeaderFilter 之后,所以它计算的是未压缩的原始响应体内容哈希。这个策略是正确的,因为客户端收到的ETag应该是未压缩内容的标识。但是,如果你自己添加了一个外层的压缩过滤器并在ShallowEtagHeaderFilter之前执行,则会导致它每次都计算压缩内容的哈希,ETag会万劫不复,永远匹配不上。
sequenceDiagram
    participant Client
    participant ShallowEtag as ShallowEtagHeaderFilter
    participant BusinessLogic as 业务逻辑
    participant TomcatCompression as Tomcat外层GZIP

    Client->>ShallowEtag: 请求
    ShallowEtag->>BusinessLogic: 通过(包装响应)
    BusinessLogic-->>ShallowEtag: 返回大JSON(例如100KB)
    ShallowEtag->>ShallowEtag: 缓存响应体,计算MD5哈希 -> "0abc"
    alt 客户端请求头 If-None-Match: "0abc"
        ShallowEtag-->>TomcatCompression: 空响应体 + 304
        TomcatCompression-->>Client: 304 Not Modified(无体)
    else 不匹配
        ShallowEtag-->>TomcatCompression: 原始响应体 + ETag:"0abc"
        TomcatCompression->>TomcatCompression: 对原始体进行GZIP压缩
        TomcatCompression-->>Client: 200 OK + 压缩后响应体 + ETag:"0abc"
    end
  • 图表主旨概括:该序列图展示了ShallowEtagHeaderFilter在处理一个请求时,如何通过ContentCachingResponseWrapper缓存响应并计算哈希,以及它与底层容器的GZIP压缩是如何协作的。
  • 逐层/逐元素分解:关键节点是ContentCachingResponseWrapper的拦截和缓存行为。当304条件满足时,它不必将响应体发送到下层;当不满足时,未压缩的原始响应体带着ETag继续向下,让容器的压缩负责对其进行编码。ETag是未压缩内容的摘要。
  • 设计原理映射:这是典型的装饰器模式ContentCachingResponseWrapper就是被装饰过的HttpServletResponse,为其增加了缓存和事后分析的能力。过滤器本身在过滤器链中扮演了责任链模式的一环,并对链的执行结果施加了后置处理。
  • 工程联系与关键结论ShallowEtagHeaderFilter是一个“以空间和计算换带宽”的典型案例。务必牢记它必须包裹所有会修改响应体的过滤器(如Spring Security的SecurityHeadersFilter),并且只适用于小响应体。在微服务API网关层,通过Last-Modified或更轻量的版本号实现缓存校验往往比在应用层做全面MD5更经济。

5. 资源压缩:GZIP 与 Brotli 的自动配置与性能权衡

HTTP压缩是节省Web流量最主要的手段。Spring Boot 2.7.x基于内嵌Tomcat提供了对GZIP压缩的无缝支持。其原理是,通过server.compression.enabled=true属性触发自动配置,该配置会为Tomcat的Connector设置一个CompressionConfig对象,或者在某些部署下添加一个CompressionFilter

5.1 GZIP 压缩的自动配置入口

当我们设置server.compression.enabled=true时,Spring Boot的ServerProperties中的Compression内部类会绑定属性。这个配置最终被TomcatServletWebServerFactory消费,它通过定制Connector来启用压缩。

// org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory
private void customizeCompression(Connector connector) {
    // 绑定properties中的compression属性
    Compression compression = this.serverProperties.getCompression();
    if (compression != null && compression.getEnabled()) {
        // 获取协议处理器
        AbstractHttp11Protocol<?> protocolHandler = (AbstractHttp11Protocol<?>) connector.getProtocolHandler();
        // 为HTTP/1.1协议设置压缩配置
        protocolHandler.setCompression("on"); // 开启压缩
        protocolHandler.setCompressionMinSize(compression.getMinResponseSize()); // 最小响应体大小阈值
        // 设置可压缩的MIME类型
        protocolHandler.setCompressibleMimeTypes(StringUtils.arrayToCommaDelimitedString(compression.getMimeTypes()));
        // 设置压缩级别
        if (compression.getLevel() != null) {
            protocolHandler.setCompressibleMimeType("gzip");
            // 注意:在Tomcat 9中压缩级别配置通常直接设置gzip的级别?这里通过自定义configure来实现
            // 实际上Spring Boot可能通过protocolHandler.setGzipLevel(compression.getLevel())或类似方法
        }
    }
}

(注:实际代码路径涉及customizeConnector等,此处为逻辑简化。)

Tomcat在接收到一个响应时,会依据配置做如下判断:

  1. 响应头Content-Type是否在compressibleMimeTypes列表中。
  2. 响应体长度是否大于等于minResponseSize
  3. 请求头Accept-Encoding是否包含gzip。 如果条件全部满足,Tomcat的输出流会被封装,写入的数据经过GZIPOutputStream压缩后发送。这种机制对应用开发者完全透明。

5.2 Brotli 压缩的现状与权衡

Brotli是一种较新的压缩算法,比GZIP压缩率更高,尤其在文本类文件上。但是在内嵌Tomcat 9.x中,原生并不支持Brotli。虽然可以通过添加com.nixxcode.jvmbrotli等库并编写自定义Valve或过滤器来实现,但这并非标准配置。通常在工程实践中,更推荐在反向代理层(如Nginx、Cloudflare等CDN)上开启Brotli,这样可以将压缩的CPU开销从应用服务器完全剥离出去,让应用服务器专注于业务逻辑。

5.3 GZIP 压缩与 ETag 协同工作流程图

flowchart TD
    Start["请求进入"] --> CheckAcceptEncoding{"请求头<br>Accept-Encoding: gzip?"}
    CheckAcceptEncoding -- "否" --> ProcessNormal["正常处理请求"]
    ProcessNormal --> ResponseNormal["返回未压缩响应"]

    CheckAcceptEncoding -- "是" --> ProcessWithGzipAware["正常处理请求,应用可能生成ETag"]
    ProcessWithGzipAware --> GetResponse["获得原始响应体和可能的ETag"]
    GetResponse --> CheckCompress{"内容类型和大小<br>满足压缩条件?"}
    
    CheckCompress -- "否" --> SendUncompressed["发送未压缩响应及原始ETag"]
    CheckCompress -- "是" --> CompressBody["在容器层对响应体GZIP压缩"]
    CompressBody --> AdjustHeaders["修改响应头:<br>Content-Encoding: gzip<br>Content-Length: 压缩后大小<br>ETag: 保留未压缩内容的ETag"]
    AdjustHeaders --> SendCompressed["发送压缩响应"]

    SendCompressed --> Client["客户端解压并缓存"]
    SendUncompressed --> Client
    Client --> NextReq["下次请求带If-None-Match"]
    NextReq --> Server["服务器比较If-None-Match与原始ETag,不考虑压缩"]

    classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333;
    classDef process fill:#f8f9fa,stroke:#333,stroke-width:1px,color:#333;
    class Start,ProcessNormal,ResponseNormal,ProcessWithGzipAware,GetResponse,SendUncompressed,CompressBody,AdjustHeaders,SendCompressed,Client,NextReq,Server process;
    class CheckAcceptEncoding,CheckCompress decision;
  • 图表主旨概括:此图说明了在Tomcat容器层面实现GZIP压缩与在应用层(如ShallowEtagHeaderFilter)生成ETag是如何并行不悖的。关键点是ETag是基于未压缩内容生成的,这保证了客户端无论是否支持压缩,其缓存的ETag都能正确工作。
  • 逐层/逐元素分解:流程被分为两个关键决策点:客户端是否声称支持GZIP,以及服务器是否决定对本次响应进行压缩。压缩操作发生在容器的最外层,像一层透明的包装。它只是改变了传输的字节,而不改变资源本身的逻辑标识(ETag)。
  • 设计原理映射:这是分层架构思想的体现。传输层的压缩(HTTP内容编码)与表现层的缓存标识(ETag)被清晰分离。这种设计允许它们独立演化。例如,未来从GZIP切换到Brotli,ShallowEtagHeaderFilter完全无需改动。
  • 工程联系与关键结论正确配置下,GZIP和ETag的协同是完美的。但一个容易出错的场景是:自定义过滤器修改了原始响应体(如添加页脚),然后又有一个后置过滤器计算了错误的ETag,导致压缩后的内容与ETag不匹配。务必保持过滤器链的顺序清晰:先内容修改,再ETag计算,最终交给容器压缩。

6. 静态资源版本化:VersionResourceResolver 与 ContentVersionStrategy

对静态资源(CSS, JS, 图片)实施版本化,是Web性能优化中的“核武器”。其策略是利用Cache-Control: max-age=31536000, immutable让浏览器永久缓存资源,然后通过嵌入在URL中的内容哈希(例如app-8d9e0a7b.js)来实现无缝的增量更新。Spring MVC通过VersionResourceResolver优雅地支持了这一模式。

6.1 VersionResourceResolver 与策略模式

VersionResourceResolver是一个ResourceResolver,它将请求URL中包含的版本字符串解析出来,并映射回真实的资源文件。它主要支持两种版本策略:

  • ContentVersionStrategy:使用资源内容计算一个哈希值(如MD5)作为版本号。这是业界最佳实践,因为只要文件内容不变,URL就永远不变,缓存永远有效。
  • FixedVersionStrategy:使用一个固定的字符串(如应用构建版本号)作为版本号。这适用于一些在构建时就确定的、希望集体失效的场景。
// org.springframework.web.servlet.resource.VersionResourceResolver
public class VersionResourceResolver extends AbstractResourceResolver {
    // ...
    @Override
    protected Resource resolveResourceInternal(HttpServletRequest request, String requestPath,
            List<? extends Resource> locations, ResourceResolverChain chain) {
        
        // 1. 检查请求路径中是否含已知的版本号字符串
        VersionPathStrategy pathStrategy = this.getStrategyForPath(requestPath);
        if (pathStrategy != null) {
            // 2. 提取版本号
            String actualVersion = pathStrategy.extractVersion(requestPath);
            // 3. 移除版本号,得到真实资源路径
            String resourcePath = pathStrategy.removeVersion(requestPath, actualVersion);
            // 4. 用真实路径解析资源
            Resource resolved = chain.resolveResource(request, resourcePath, locations);
            if (resolved == null) {
                return null;
            }
            // 5. 验证:用策略再次从解析到的资源中计算出版本号,确保匹配
            String expectedVersion = pathStrategy.getResourceVersion(resolved);
            if (expectedVersion.equals(actualVersion)) {
                return resolved; // 匹配,返回资源
            }
        }
        // 版本不匹配或无版本,交给后续解析器
        return chain.resolveResource(request, requestPath, locations);
    }
}

ContentVersionStrategygetResourceVersion会读取资源文件的内容,计算并返回其MD5哈希。这样,当我们发布新版本的前端代码时,JS文件内容变化导致MD5变化,生成的HTML中引用的URL随之变化,浏览器就会主动请求新资源,而旧资源的URL由于没变,依然从缓存读取。

6.2 ResourceUrlEncodingFilter 的作用

ResourceUrlEncodingFilter是一个过滤器,它会拦截HTTP响应,在生成的HTML中,将符合版本规则的静态资源URL自动转换为包含版本号的URL。这通常是结合模板引擎(如Thymeleaf)一起使用的。Thymeleaf的th:srcth:href在遇到@{}语法时,会触发ResourceUrlProvidergetForLookupPath方法,后者内部会通过VersionResourceResolver链解析出带版本号的公开URL路径。这样,开发者只需写<script th:src="@{/js/app.js}"></script>,最终渲染出的HTML就可能是<script src="/js/app-8d9e0a7b.js"></script>

6.3 VersionResourceResolver 解析序列图

sequenceDiagram
    participant Browser as 浏览器
    participant ResourceHandler as ResourceHttpRequestHandler
    participant VersionResolver as VersionResourceResolver
    participant ContentStrategy as ContentVersionStrategy
    participant FileSystem as 文件系统

    Browser->>ResourceHandler: GET /js/app-8d9e0a7b.js
    ResourceHandler->>VersionResolver: resolveResource("/js/app-8d9e0a7b.js")
    VersionResolver->>VersionResolver: 匹配到ContentVersionStrategy
    VersionResolver->>ContentStrategy: extractVersion("/js/app-8d9e0a7b.js") -> "8d9e0a7b"
    VersionResolver->>ContentStrategy: removeVersion(path, "8d9e0a7b") -> "/js/app.js"
    VersionResolver->>FileSystem: 请求解析资源 "/js/app.js"
    FileSystem-->>VersionResolver: 返回资源 Resource
    VersionResolver->>ContentStrategy: getResourceVersion(resource) -> 计算期望哈希
    ContentStrategy-->>VersionResolver: 期望哈希 = "8d9e0a7b"
    VersionResolver->>VersionResolver: 期望哈希 == 实际版本号? 匹配成功
    VersionResolver-->>ResourceHandler: 返回资源
    ResourceHandler-->>Browser: 200 OK + 资源文件 (附带Cache-Control: max-age=31536000)
  • 图表主旨概括:该序列图揭示了VersionResourceResolver如何从带版本号的URL中剥离版本信息,定位到真实文件,并逆向验证版本的有效性,以确保客户端请求的版本正是服务器上该内容文件的真实版本。
  • 逐层/逐元素分解:解析器是整个流程的导演。它依赖两个核心策略方法:extractVersion(拆分URL)和getResourceVersion(计算真实文件哈希)。最后的哈希比对环节期望哈希 == 实际版本号是安全性的保证,能防止客户端用恶意构造的URL探测文件。
  • 设计原理映射:这里完美体现了策略模式VersionResourceResolver是上下文,而ContentVersionStrategyFixedVersionStrategy是可替换的策略族。同时,解析器链(ResourceResolverChain)又是责任链模式VersionResourceResolver是其中一环,如果它能解析则返回,否则交给下一个解析器。
  • 工程联系与关键结论静态资源版本化配合Cache-Control: immutable是前端性能优化的终极手段,它从根本上消灭了重复获取静态资源的HTTP请求。务必确保一切生产环境下的构建流程(Webpack等)都输出内容哈希文件,并与Spring的VersionResourceResolver配置保持一致。一个常见错误是CDN上的旧资源被提前失效,但应用服务器来不及更新HTML中引用的新哈希,导致页面404。版本化的基础设施必须前后端联动。

7. 性能监控与诊断:Actuator、JMX 与连接泄漏排查

没有度量的优化如同盲人摸象。Spring Boot Actuator 和 JMX 为我们提供了洞悉 Tomcat 内部状态的眼睛。

7.1 通过 Actuator 端点暴露指标

引入spring-boot-starter-actuator后,我们可以通过/actuator/metrics端点访问大量预置的Tomcat指标。关键是以下三个:

  • tomcat.threads.config.maxmaxThreads配置值。
  • tomcat.threads.busy:当前正在处理请求的线程数。这是判断线程池是否饱和的第一指标。如果它持续等于config.max,说明线程池已耗尽。
  • tomcat.threads.current:当前线程池中的线程总数(包括空闲和忙碌的)。

此外,tomcat.connections.currenttomcat.connections.max可以监控连接数的使用情况。

7.2 开启 JMX MBean 注册

通过设置server.tomcat.mbeanregistry.enabled=true,Tomcat的核心组件会将自身注册为JMX MBean。我们可以使用JDK自带的JConsole连接上去,在MBean选项卡下查看Catalina命名空间下的ThreadPoolConnector的属性和操作。例如,ConnectormaxConnectionsthreadCount等属性均可实时查看,甚至可以通过JMX动态调整部分参数。

7.3 诊断连接泄漏与线程池饥饿

线程池饥饿的典型现象是:

  1. /actuator/metrics/tomcat.threads.busy 持续接近或等于 max
  2. 应用响应变慢,大量请求超时。
  3. 但服务器CPU和内存使用率并不高。

此时,通常是有Worker线程阻塞在了外部依赖(如数据库连接池耗尽、死锁的远程调用)上。我们可以使用jstack -l <pid>来地毯式搜索。重点关注http-nio-8080-exec-命名开头的线程,它们就是Worker线程。如果大部分此类线程的栈顶都停留在如com.zaxxer.hikari.pool.HikariPoolgetConnection()方法上,那根因就是数据库连接池不足,而非Tomcat本身。Tomcat线程池饥饿往往是下层资源耗尽的一个表征,调大maxThreads治标不治本,甚至会加剧下游的压力。

连接泄漏则表现为:

  1. tomcat.connections.current 随时间缓慢或急剧上升,最终达到maxConnections
  2. 此时新的连接会被拒绝。

排查可以使用lsof -p <pid> | grep TCP | wc -l查看进程打开的TCP连接总数。结合netstat -anp | grep <port>查看哪些连接处于ESTABLISHEDCLOSE_WAIT状态。大量的CLOSE_WAIT通常表明应用代码没有正确关闭HttpClient获得的连接流,是典型的连接泄漏。在Tomcat端,如果没有配置合理的keepAliveTimeout,客户端长连接会长时间占着连接资源。


8. 全链路优化决策框架

综合以上所有维度的分析,我们可以形成一个面向不同应用场景的优化决策路径图。

flowchart TD
    Start["开始性能优化"] --> A{"你的应用类型是?"}

    A -- "静态资源密集型" --> B1["1. 启用资源版本化<br>2. 设置Cache-Control: max-age=31536000<br>3. 开启GZIP压缩"]
    B1 --> B2{"有CDN吗?"}
    B2 -- "有" --> B3["推送所有版本化资源到CDN<br>配置CDN的HTTP/2和Brotli压缩"]
    B2 -- "无" --> B4["使用ResourceHttpRequestHandler<br>直接提供缓存头"]
    B3 --> Monitor["接入监控"]
    B4 --> Monitor

    A -- "API密集型" --> C1["1. 对关键只读接口评估<br>启用ShallowEtagHeaderFilter<br>2. 开启GZIP压缩<br>3. 线程池调优"]
    C1 --> C2{"接口响应体大小?"}
    C2 -- "小于10KB且计算开销大" --> C3["使用ShallowEtagHeaderFilter"]
    C2 -- "较大" --> C4["禁用ETag过滤器<br>依赖业务层Redis缓存等"]
    C3 --> C5{"API消耗I/O还是CPU?"}
    C4 --> C5
    C5 -- "I/O阻塞" --> C6["适当增加maxThreads<br>或使用DeferredResult提升吞吐"]
    C5 -- "CPU密集" --> C7["控制maxThreads略高于核心数<br>避免过多线程切换"]
    C6 --> Monitor
    C7 --> Monitor

    A -- "实时高并发" --> D1["1. 优先线程池调优<br>2. 禁用不必要的压缩和ETag<br>3. 考虑虚拟线程"]
    D1 --> D2["精确压测确定maxThreads<br>避免系统过载"]
    D2 --> Monitor

    Monitor["Actuator/JMX监控"] --> Decision{"指标是否正常?"}
    Decision -- "线程池忙/连接高" --> Revisit["回溯业务流程<br>解决下游瓶颈/连接泄漏"]
    Decision -- "正常" --> EndNode["持续观察"]

    classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333;
    classDef process fill:#f8f9fa,stroke:#333,stroke-width:1px,color:#333;
    class Start,B1,B3,B4,C1,C3,C4,C6,C7,D1,D2,Monitor,Revisit,EndNode process;
    class A,B2,C2,C5,Decision decision;
  • 图表主旨概括:这是一个基于应用类型的全链路优化决策树,引导开发者根据应用是静态资源密集型、API密集型还是实时高并发型,选择不同的优化优先级和策略组合,并强调以监控来闭环。
  • 逐层/逐元素分解:决策树从应用类型这个大前提开始分叉。在静态资源路径,决策迅速导向版本化和CDN;在API路径,则进入更细粒度的判断,权衡ETag过滤器的代价与收益;对于实时系统,则回归到线程模型的极致调优,并指向虚拟线程的未来。所有分支最终都汇入监控和闭环反馈。
  • 设计原理映射:这是一个工程化的决策树模型。它将抽象的“性能优化”分解为具体的、可执行的分支判断,将之前的所有技术细节串联成一个决策框架。它强调场景驱动(Context-Driven),而不是一套参数打天下。
  • 工程联系与关键结论“先度量,再优化”是唯一正确的黄金法则。任何调优都始于监控数据,终于压测验证。上图提供的框架能帮助你快速定位到最适合当前场景的优化起点,避免在无关紧要的参数上耗费时间。

9. 生产事故排查专题

事故 1:Tomcat 线程池耗尽导致雪崩

  • 现象:某电商应用在一次热点促销活动高峰期间,所有页面响应极度缓慢,大量请求返回HTTP 502 Bad Gateway。应用服务器CPU使用率仅20%,但Actuator监控显示tomcat.threads.busy持续维持在200(即maxThreads的上限)。

  • 排查思路

    1. 通过jstack dump线程快照,发现几乎所有http-nio-8080-exec-线程都阻塞在org.apache.http.impl.conn.PoolingHttpClientConnectionManager.requestConnection()方法上。
    2. 这表明这些Tomcat Worker线程正在等待从HTTP连接池获取一个连接到下游的推荐服务。
    3. 检查下游推荐服务的连接池配置,发现其MaxPerRoute仅为10,且响应时间因负载而变慢。
  • 根因:下游服务的HTTP连接池配置过低,导致大流量下连接获取成为瓶颈。Tomcat的200个工作线程全部被阻塞在获取连接上,无法处理新的请求,造成了线程池饥饿,并引发了前端网关的超时和雪崩。

  • 解决方案

    1. 立即处理:紧急调低下游连接获取的超时时间,并增加下游HTTP连接池的MaxPerRouteMaxTotal,使之能容纳并联过载。
    2. 架构改进:引入断路器(Hystrix/Sentinel),当调用下游服务的响应时间过长或线程排队过多时,快速失败并执行降级逻辑,而非无限期阻塞Tomcat线程。这才是根本解决之道。
  • 最佳实践永远不要用无界阻塞来等待外部资源。 任何I/O调用都应有超时和降级预案。Tomcat的maxThreads是防御最终手段,其值应设置为略高于应用被设计处理的并发事务量,而非无限制增大。

事故 2:ETag 值不一致导致缓存永远失效

  • 现象:为了给一个高频访问的用户信息接口“加速”,开发团队启用了ShallowEtagHeaderFilter。然而,在生产环境监控发现,该接口每次请求都返回200,从未见过304,导致优化无效。

  • 排查思路

    1. 使用curl -v连续两次请求同一接口,发现服务器每次返回的ETag值都不同,但响应体内容通过diff检查却是完全一致的。
    2. 仔细审查该接口的过滤器链,发现一个自定义的SecurityAuditFilter,它在doFilter之后向响应的header中添加了一个X-Trace-Id。还有Spring Security的CsrfFilter在某些情况下会修改响应的Cookie
    3. ShallowEtagHeaderFilter恰好包裹了所有过滤器,包括这些修改响应头的过滤器。因此,每次响应被ContentCachingResponseWrapper捕获的最终字节流,由于X-Trace-Id每次都不同,导致MD5哈希千变万化。
  • 根因ShallowEtagHeaderFilter位于过滤器链的最后,但它包裹的是所有过滤器的执行体。任何在其内部链中修改响应体或响应头的过滤器都会破坏响应体的字节级一致性。

  • 解决方案

    1. 调整过滤器顺序。确保所有会修改响应体的操作(如添加X-Trace-Id)都发生在ShallowEtagHeaderFilter之前。这可以通过Spring Security的addFilterBeforeFilterRegistrationBeansetOrder来精确控制。
    2. 如果X-Trace-Id必须添加,但又不希望它影响缓存,则应在ShallowEtagHeaderFilter之后,再添加一个独占的过滤器来放置此类每次请求都变动的头。但这意味着它在304响应时不会被执行,可能导致这个头缺失。一个更好的设计是,决定哪些接口需要缓存,并在外部网关层添加这种TraceId。
  • 最佳实践对于会修改响应的过滤器,务必清晰地理解它们在过滤器链中的顺序,并评估其对ShallowEtagHeaderFilter的影响。对于不能保持响应体完全一致的API,禁用ETag,改用Last-Modified配合应用层缓存是更稳健的选择。


10. 面试高频专题

  1. Tomcat 的 maxConnectionsacceptCountmaxThreads 有什么区别?如何根据场景调优?

    • 回答maxConnections是Tomcat能同时保持的TCP连接数上限,受操作系统文件描述符限制;acceptCount是当Worker线程全忙时,操作系统全连接队列的长度;maxThreads是Worker线程池最大线程数。调优时,对于I/O密集型应用,可适当增大maxThreads以提升并发处理能力,但要监控上下文切换;对于连接密集型(如长轮询),需重点调大maxConnections并确保Worker线程能快速处理释放。acceptCount作为瞬时突发缓冲,过大易造成请求排队超时,过小则直接拒绝连接。
    • 追问
      • 如果maxConnections设置过大,会出现什么问题? (可能耗光操作系统内存和文件描述符,导致OOM或无法再建立任何连接。)
      • 在NIO模型下,maxConnectionsmaxThreads的关系是怎样的? (NIO用一个Poller管理成千上万连接,maxConnections可远大于maxThreads。只要大部分连接是空闲的,只有有数据可读的连接才占用Worker线程。)
      • 压测时发现TPS不再上升,且tomcat.threads.busy未满,可能瓶颈在哪? (可能在下游资源如数据库连接池、Redis连接数,或CPU/网络带宽打满。)
    • 加分回答:可以提及Reactor模型,说明Acceptor和Poller数量的意义。在Spring Boot 3.2+虚拟线程时代,maxThreads的概念会演进,但连接限制依然重要。
  2. ETag 和 Last-Modified 的异同?Spring 中是如何实现的?

    • 回答:ETag是内容哈希,精确到字节,适合内容有微小变动就需感知的场景;Last-Modified是时间戳,精度到秒,对一秒内多次修改可能不敏感。在Spring中,ResourceHttpRequestHandler通过checkNotModified方法同时支持两者,它会先比较ETag再比较时间戳,任一命中即返回304。ShallowEtagHeaderFilter则为动态响应计算MD5 ETag。
    • 追问
      • 如果两者同时存在,谁优先级更高? (If-None-Match(ETag)优先级高于If-Modified-Since,符合HTTP规范。)
      • 弱ETag (W/) 和强ETag的区别? (强ETag要求字节级完全相等,弱ETag标识内容语义相同,允许微小差异。Spring的ShallowEtagHeaderFilter生成的是弱ETag。)
      • Last-Modified只能在资源是文件时使用吗? (不,任何能提供时间戳的场景都可以,如缓存中的对象时间。)
    • 加分回答:可以深入谈checkNotModifiedServletWebRequest中的源码实现,以及其短路机制对应用性能的巨大优化价值。
  3. ShallowEtagHeaderFilter 的原理是什么?它有什么局限?

    • 回答:它通过ContentCachingResponseWrapper缓存整个响应体字节,在过滤器链返回后计算MD5生成ETag。如果与客户端If-None-Match匹配,则清空响应体并返回304。局限在于必须缓存整个响应体,对大响应体有OOM风险;计算MD5有CPU开销;必须贴在过滤器链最里层,否则响应被其他过滤器修改后ETag会失效。
    • 追问
      • 为什么叫“Shallow”? (因为它只比较响应体,忽略任何可能因请求而异的响应头,它是浅层次的ETag校验。)
      • 它对流式或文件下载响应有用吗? (绝不能使用,会导致OOM。)
      • 如何解决它带来的内存压力? (可以通过配置其只应用于特定URL模式,并确保响应体小。更大的场景应使用CDN或反向代理的缓存。)
    • 加分回答:可以延伸到HTTP缓存的最佳实践,即尽量在资源端(如CDN)解决,避免在应用层做全量哈希。
  4. GZIP 压缩在 Spring Boot 中是如何启用的?它与 ETag 能共存吗?

    • 回答:通过server.compression.enabled=true等相关配置启用。Spring Boot会为内嵌Tomcat的Connector设置压缩参数。它与ShallowEtagHeaderFilter能很好共存,因为Tomcat的压缩发生在最外层,而ShallowEtagHeaderFilter计算的是压缩前的原始内容哈希,这一点符合HTTP规范。
    • 追问
      • 如果压缩和ETag不能共存,会出现什么现象? (如果先压缩后计算ETag,则每次响应可能因压缩参数微小变化而不同,导致ETag频繁失效。)
      • 压缩级别如何影响性能? (级别越高,压缩率越高,但CPU消耗越大,可能导致吞吐量下降。需要压测权衡。)
      • 除了GZIP,还了解哪些压缩方式? (Brotli,压缩率更高,但原生支持有限,多用于CDN/Nginx层。)
    • 加分回答:可以提及Content-Encoding: gzip是一种传输编码,而ETag是对内容的标识,两者在设计上就是正交的。理解这点是配置它们协同工作的基础。
  5. 什么是静态资源版本化?它的核心价值是什么?

    • 回答:是将资源的版本(如内容哈希)嵌入其URL中的策略。核心价值是能对静态资源使用极长的Cache-Control头(如一年),实现浏览器永久缓存。当文件更新时,内容哈希变化导致URL变化,浏览器会主动请求新文件。这从源头消灭了“对比是否更新”的请求开销。
    • 追问
      • ContentVersionStrategyFixedVersionStrategy有何区别? (前者基于文件内容,内容不变则URL不变;后者基于构建版本号,所有资源版本一起变化。)
      • ResourceUrlEncodingFilter在其中起什么作用? (它负责在服务端渲染HTML时,自动将资源路径转换为带版本号的路径。)
      • 如果使用Webpack打包,还需要Spring的版本化吗? (如果是前后端分离,通常由Webpack管。但如果是Spring MVC模板渲染的场景,两者配合最佳。)
    • 加分回答:可以介绍immutable指令与max-age配合,明确指出资源内容永不改变,避免浏览器因用户刷新而发起不必要的条件验证请求。

(后续面试题6-12将遵循相同格式展开,如:监控线程池、排查连接泄漏、Cache-Control指令、VersionResourceResolver策略差异、304生成环节、虚拟线程时代maxThreads意义、系统设计题等,此处因篇幅限制,提供设计题概要。)

  1. (系统设计题)设计一个面向全球用户的静态资源分发方案,要求利用 Spring 的资源版本化、HTTP 缓存头,并结合 CDN 实现就近访问与缓存策略。请说明如何控制缓存失效、如何处理版本回滚时的旧资源清理,并给出核心的 Spring 配置和 CDN 策略配置思路。
    • 回答:在Spring端,配置VersionResourceResolver使用ContentVersionStrategy,并配合ResourceUrlEncodingFilter让HTML模板自动生成带哈希的资源URL。对所有版本化资源设置Cache-Control: max-age=31536000, immutable。CDN配置为拉取源站这些URL并遵循源站提供的Cache-Control头进行缓存。
    • 追问
      • 上线一个新版本时,如何确保CDN和用户浏览器缓存都立即更新? (因为新一代JS文件的哈希变了,HTML中引用的是新URL,CDN会回源站抓取新文件,浏览器也会请求新URL。旧文件的URL还在,旧版本用户不受影响。)
      • 如果需要紧急回滚一个JS版本? (只需将Deployment中的HTML模板回退到引用旧哈希URL的版本即可。CDN上旧哈希的资源依然存在且被缓存,新上线后会立即使用那些旧文件。)
      • 如何处理那些长期无人访问、占据CDN存储的旧版本资源? (可以在CDN上配置缓存过期后的清理策略,或通过CDN的剔除API进行清理,但通常不是首要问题,存储成本低。关键要防止URL冲突。)
    • 加分回答:可以谈及Cache-Control: stale-while-revalidate等高级指令来优化CDN回源时的用户体验,以及使用Subresource Integrity (SRI) 来增强安全性。

Web 性能优化参数速查表

优化维度关键参数/注解默认值 (Spring Boot 2.7.x)调优建议监控指标 (Actuator)
Tomcat 线程池server.tomcat.threads.max200默认值适用于大多数I/O密集场景。上限受制于OS和硬件,压测找吞吐量拐点。tomcat.threads.busy, tomcat.threads.config.max
Tomcat 连接server.tomcat.max-connections10000 (NIO)通常无需调整。如涉及长轮询,需调大,同时关注OS的ulimit -ntomcat.connections.current, tomcat.connections.max
Tomcat 队列server.tomcat.accept-count100瞬时突发缓冲。设太大会导致严重排队,延迟恶化;太小则直接拒绝请求。观察请求超时率,结合线程池指标。
HTTP 缓存(资源)spring.web.resources.cache.cachecontrol无(默认动态生成)max-age=31536000 用于版本化资源;no-cache 用于频繁更新的静态资源。分析Apache/ Nginx访问日志中的304比例。
ETag (过滤器)@Bean ShallowEtagHeaderFilter未启用仅用于响应体小(<10KB)且计算昂贵的JSON API。需精细配置过滤器顺序。观察CPU使用率和304比例。
GZIP 压缩server.compression.enabledfalse通常建议开启,但对CPU敏感应用需权衡。min-response-size 设为2KB以上。网络I/O流量减少百分比,CPU使用率。
资源版本化VersionResourceResolver + ResourceUrlEncodingFilter未自动配置生产环境必须配置,配合Webpack等。使用ContentVersionStrategy通过浏览器DevTools确认资源是否命中磁盘缓存,无网络请求。
连接泄漏keepAliveTimeout, maxKeepAliveRequeststimeout: 默认因连接器而异根据调查设置合理超时,防止空闲长连接耗尽连接数。分析netstatCLOSE_WAIT状态连接数。

延伸阅读

  • Apache Tomcat 9 官方文档:NioEndpoint 与 Connector 配置章节
  • Spring Boot 官方文档:“Embedded Web Servers” 及 “Static Content”
  • Spring Framework 官方文档:“Web MVC” 中的资源处理章节
  • 《HTTP/2 in Action》中关于 HTTP 缓存与压缩的章节
  • 《High Performance Browser Networking》 (Ilya Grigorik) 中对TCP和HTTP协议调优的讨论