平均响应时间正常,并不代表用户没有变慢。本文从 p95、p99 长尾延迟入手,结合 Spring Boot 指标、慢请求日志、线程池与数据库连接池,建立一条适合生产环境的 Java 服务故障定位路径。

问题背景:平均值为什么会掩盖故障

生产环境里有一种很容易被忽略的故障:接口平均耗时只有几十毫秒,错误率也没有明显变化,但一部分用户已经频繁遇到超时,或者页面偶尔卡住几秒。

这类问题通常不是“所有请求都变慢”,而是少量请求变得非常慢。假设 1000 个请求中有 990 个在 50 毫秒内完成,另外 10 个请求耗时 5 秒,平均耗时约为 99.5 毫秒。这个数字看起来并不严重,但那 1% 的用户已经明显感知到了问题。

因此,生产环境不能只看平均耗时。至少要同时关注:

  • p50:中位数,代表典型请求的体验;
  • p95:95% 请求低于该耗时,用来观察较普遍的慢请求;
  • p99:99% 请求低于该耗时,更容易发现长尾;
  • 最大耗时:适合辅助排查,但容易受到极端单次请求影响。

长尾延迟本身不是根因,它只是一个结果。根因可能来自数据库慢查询、连接池等待、锁竞争、下游调用超时、线程池排队、Full GC,甚至某一类特殊参数触发了低效代码路径。排查的关键,是把“接口变慢”拆成可观测的等待阶段。

先建立一套不会误导自己的指标

以 Spring Boot 应用为例,可以使用 Micrometer 记录请求耗时。下面的过滤器展示了一个接近完整的实现:它会记录请求总耗时,并对超过阈值的请求输出慢请求日志。

package example.web;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Component
public class RequestMetricsFilter extends OncePerRequestFilter {

    private static final long SLOW_REQUEST_MILLIS = 1000L;

    private final Timer requestTimer;
    private final Counter errorCounter;

    public RequestMetricsFilter(MeterRegistry registry) {
        this.requestTimer = Timer.builder("app.http.server.requests")
                .description("HTTP request latency")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registry);

        this.errorCounter = Counter.builder("app.http.server.errors")
                .description("HTTP responses with status >= 500")
                .register(registry);
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        long startNanos = System.nanoTime();
        try {
            filterChain.doFilter(request, response);
        } finally {
            long elapsedNanos = System.nanoTime() - startNanos;
            requestTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);

            if (response.getStatus() >= 500) {
                errorCounter.increment();
            }

            long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(elapsedNanos);
            if (elapsedMillis >= SLOW_REQUEST_MILLIS) {
                System.err.printf(
                        "slow request method=%s uri=%s status=%d costMs=%d%n",
                        request.getMethod(),
                        request.getRequestURI(),
                        response.getStatus(),
                        elapsedMillis
                );
            }
        }
    }
}

项目需要引入与自身 Spring Boot 版本匹配的 Actuator 和 Micrometer 依赖。Timer 负责记录耗时,百分位数的计算和展示由 Micrometer 的实现及监控系统共同完成。示例中的慢请求日志只是基础方案,正式环境应使用项目统一的日志框架,而不是直接使用 System.err

这里有一个重要细节:不要直接把完整 URL、用户输入参数或订单号作为指标标签。比如把 /order/100001/order/100002 分别作为 uri 标签,会造成高基数,最终让监控系统本身变慢甚至失控。应尽量使用路由模板,例如 /order/{id},或者使用 Spring Boot Actuator 默认提供的规范化 URI 标签。

慢请求日志应该记录什么

一条慢请求日志的目标不是“证明它很慢”,而是帮助我们缩小范围。至少应包含:

  • 请求方法、规范化后的接口路径;
  • HTTP 状态码;
  • 总耗时和各阶段耗时;
  • 请求是否发生异常;
  • 业务上可以安全记录的关键参数摘要;
  • trace 标识或请求标识;
  • 应用实例、机房、版本号。

不建议记录完整请求体、密码、身份证号、支付信息等敏感内容。也不要把所有请求都打印成 INFO 日志,否则正常流量一上来,日志量会掩盖真正的异常。更合理的方式是:所有请求进入指标系统,只有超过阈值的请求进入慢日志;异常请求无论是否超过阈值,都记录必要上下文。

如果想进一步拆分耗时,可以在业务服务中显式记录阶段:

long start = System.nanoTime();
Order order = orderRepository.findById(orderId)
        .orElseThrow(() -> new IllegalArgumentException("order not found"));
long afterDb = System.nanoTime();

InventoryResult result = inventoryClient.query(order.getSku());
long afterRemote = System.nanoTime();

orderAssembler.fill(order, result);
long afterAssemble = System.nanoTime();

log.info("order detail cost, orderId={}, dbMs={}, remoteMs={}, assembleMs={}, totalMs={}",
        orderId,
        millis(start, afterDb),
        millis(afterDb, afterRemote),
        millis(afterRemote, afterAssemble),
        millis(start, afterAssemble));

实际项目中,更建议将这些阶段分别记录为 Timer,而不是长期依赖字符串日志做统计。日志适合查看单个请求,指标适合观察一段时间内的趋势。

一条可执行的排查路径

第一步:确认是全局问题还是单接口问题

先按接口、状态码、实例和版本号查看 p95、p99 的变化。如果所有接口同时变慢,优先检查节点资源、网络、数据库和公共依赖;如果只有一个接口异常,再进入该接口的业务链路。

同时对比请求量。如果流量增长伴随 p99 上升,可能是容量或排队问题;如果流量没有变化却突然变慢,更像是慢查询、锁等待、下游异常或代码发布引入的问题。

第二步:观察应用实例的资源和排队

不要只看 CPU 百分比。还要观察:

  • CPU 是否被少数线程持续占用;
  • 堆内存、Old Gen 和 GC 暂停时间是否同步变化;
  • Tomcat、Jetty 或 Undertow 工作线程是否接近上限;
  • 数据库连接池 active、idle、pending 是否异常;
  • 下游 HTTP 客户端连接池是否有等待;
  • 文件描述符、网络连接数是否接近限制。

如果 CPU 不高但请求变慢,等待型问题的可能性反而更高。线程可能阻塞在数据库连接、锁、网络响应或队列上。

第三步:在问题发生时抓线程状态

生产环境排查应尽量使用低侵入方式,并且先确认目标 Java 进程。常见命令如下:

jps -lv
jcmd <pid> Thread.print -l > /tmp/thread-1.txt
sleep 10
jcmd <pid> Thread.print -l > /tmp/thread-2.txt

连续抓取两次比只看一次更有价值。对比线程状态时重点关注:

  • 大量线程是否处于 WAITINGTIMED_WAITING
  • 是否集中等待同一个锁;
  • 是否大量线程卡在 JDBC 获取连接;
  • 是否大量线程停在 HTTP 客户端读取响应;
  • 是否存在少数 RUNNABLE 线程持续占用 CPU。

线程 dump 不能直接告诉你“数据库慢了”或“某个服务故障了”,但它能告诉你请求时间消耗在计算、锁等待还是外部等待上。随后要结合数据库连接池指标、慢查询日志和下游监控继续验证,而不是只凭线程栈下结论。

第四步:核对发布、配置和依赖变化

长尾问题经常与一次看似无关的变更有关,例如:

  • 新增了一个未建立索引的查询条件;
  • 将下游超时时间从 500 毫秒改成了 5 秒;
  • 连接池大小没有随流量调整;
  • 某个日志级别被临时改成 DEBUG;
  • 新版本引入了同步远程调用;
  • 某个缓存失效后,大量请求同时回源。

排查时应把 p99 上升时间点与发布记录、配置变更、数据库执行计划和依赖方告警放在同一条时间线上。单独查看应用日志,往往只能看到结果,无法看到因果关系。

常见误区

只设置平均耗时告警

平均值对少量慢请求不敏感。告警应至少包含 p95 或 p99,并配合请求量阈值,避免低流量时几个异常样本造成误报。

慢请求阈值全服务统一

查询接口、写入接口和文件导出接口的合理耗时不同。阈值可以先统一建立基线,再按接口类型调整。阈值不是越低越好,过多慢日志会增加磁盘和采集压力。

一发现慢就盲目扩大线程池

如果线程都在等待数据库连接,扩大 Web 线程池只会让更多请求进入等待;如果下游已经过载,增加并发还可能造成级联故障。先确认瓶颈所在,再决定限流、降级、调连接池还是优化查询。

只看应用节点,不看依赖方

一次接口请求可能经过缓存、数据库、消息系统和多个 HTTP 服务。应用自身 CPU 正常,不代表依赖正常。监控面板应能按请求入口和下游调用阶段关联查看。

实践建议

  1. 为核心接口建立 p50、p95、p99 和请求量基线,不要等故障发生后才开始采集。
  2. 慢请求日志与错误日志分开设计,控制采样和敏感信息,保证故障时日志仍然可写。
  3. 指标标签使用低基数维度,如路由、状态码、实例和版本,避免把业务 ID 放进去。
  4. 数据库连接池、HTTP 连接池、线程池都要监控“使用量”和“等待量”,只看池大小没有意义。
  5. 为发布、配置、扩缩容和依赖方异常保留可查询的时间线。
  6. 预先演练线程 dump、日志检索和回滚流程,避免故障时临时摸索命令。
  7. 对关键接口设置超时、隔离和降级边界,让单个慢依赖不会无限占用整个应用的处理能力。

总结

接口平均耗时正常,并不等于服务健康。长尾延迟通常藏在少量请求、某个实例或某个等待阶段里。有效的排查方法不是盯着一张 CPU 面板,而是先用 p95、p99 发现问题,再通过慢请求日志、线程状态、连接池指标和依赖方监控逐层缩小范围。

真正成熟的生产监控,不只是故障发生时提供一堆数据,而是让人能够回答三个问题:哪些请求变慢了,时间具体耗在哪里,最近发生了什么变化。只要这条证据链完整,很多看似偶发的“接口卡顿”,就能从猜测变成可以验证和修复的问题。

最后修改:2026 年 09 月 05 日
如果觉得我的文章对你有用,请随意赞赏