《Java 服务端通信工程》14.2 压测剖面与瓶颈定位

压测不是把 ab 的 -n 调大。本节区分恒定、阶梯、尖峰、浸泡四种剖面,给出吞吐、错误率与 p50/p95/p99 的口径,讲清协调缺失如何美化延迟,并用 ManagedChannel 写一个固定并发、按预期开始时间计时的 gRPC 压测客户端。

本节目标:让压测结果能回答「系统在哪个流量点开始变坏、坏在哪个资源、延迟数字是否可信」。
工具口径:本机 ab 可用;wrk 与 hey 未安装,不写成可用;gRPC 压测用自写客户端。

承接的问题

14.1 给了容量算式,但算式里的服务时间、并发上限、队列行为都需要测量。压测的任务不是「打出一个很高的 QPS」,而是校准模型:

  • 目标流量下,p99 是否在 deadline 内。
  • 第一个瓶颈出现在哪一层,和模型预测是否一致。
  • 限流、熔断、队列在压力下是否按设计动作。
  • 长时间运行是否出现泄漏或性能衰减。

不同问题需要不同剖面。用恒定负载测泄漏,跑 10 分钟看不出趋势;用尖峰测容量,得到的数字受冷启动和队列影响,不能代表稳态。

四种剖面

剖面形状建议时长回答的问题失败信号
恒定QPS 固定10–30 分钟稳态容量、延迟分位、错误率p99 持续上升、错误率抬头
阶梯每 2 分钟 +20%20–40 分钟拐点在哪、哪个资源先饱和吞吐不再增长而延迟陡增
尖峰5 秒内到 3–5 倍5–10 分钟队列、限流、自动扩容、冷启动超时、雪崩、扩容跟不上
浸泡70% 目标负载2–12 小时内存泄漏、连接泄漏、缓存退化RSS 单调上涨、fd 增长

恒定负载是基线,其他三种都建立在它之上。没有基线就直接做尖峰,看到的所有异常都无法归因。

把剖面写成数据,压测客户端只负责按时间表发请求,四种形状就能共用一套执行逻辑:

package com.plume.linkd.v3.load;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

public final class LoadProfile {

    public record Segment(Duration offset, Duration duration, double rps) {
    }

    public static List<Segment> constant(double rps, Duration total) {
        return List.of(new Segment(Duration.ZERO, total, rps));
    }

    public static List<Segment> step(double startRps, double increment,
                                     Duration stepDuration, int steps) {
        List<Segment> segments = new ArrayList<>();
        Duration offset = Duration.ZERO;
        double rps = startRps;
        for (int i = 0; i < steps; i++) {
            segments.add(new Segment(offset, stepDuration, rps));
            offset = offset.plus(stepDuration);
            rps += increment;
        }
        return List.copyOf(segments);
    }

    public static List<Segment> spike(double baseRps, double peakRps,
                                      Duration spikeDuration) {
        Duration baseWindow = Duration.ofMinutes(5);
        return List.of(
                new Segment(Duration.ZERO, baseWindow, baseRps),
                new Segment(baseWindow, spikeDuration, peakRps),
                new Segment(baseWindow.plus(spikeDuration), baseWindow, baseRps));
    }

    public static List<Segment> soak(double targetRps, Duration total) {
        return constant(targetRps, total);
    }

    public static void main(String[] args) {
        for (Segment segment : step(50, 50, Duration.ofMinutes(2), 5)) {
            System.out.printf("offset=%s rps=%.0f%n", segment.offset(), segment.rps());
        }
    }
}

Segment.offset 是相对于压测开始的偏移,不是绝对时间;客户端用 startNanos + offset 算出每个请求的预期开始时间,协调缺失修正和剖面切换就能共用同一套计时逻辑。step 的每一级持续 stepDuration,spike 前后各留 5 分钟基线,便于对比尖峰前后的恢复情况。

阶梯负载的关键是「每级稳定后再升」。每级至少持续 2 分钟,让连接池、JIT、缓存进入稳定状态;升得太快,测到的是上一级的余波。记录每一级的吞吐、p99 和关键资源利用率,拐点通常表现为「QPS 不涨、p99 翻倍」。

尖峰负载要区分「流量尖峰」和「重试尖峰」。前者是用户行为,后者是故障放大。压测时如果客户端带重试,3 倍流量可能变成 9 倍下游压力;要么关掉重试单独测,要么把重试预算写进报告。

浸泡负载最容易伤机器,也最容易被忽略。跑之前确认有监控、有自动停止条件、有磁盘空间;跑之后对比开始与结束的 RSS、线程数、fd 数、连接数。只看 QPS 和延迟,泄漏会被稳定的流量掩盖。

指标口径:先定义,再看数

同一份压测报告,不同口径能得出相反结论。至少要固定下面五项:

指标定义常见错误
吞吐完成的请求数 / 测量窗口秒数用发出的请求数,把超时算成完成
错误率失败请求 / 发出的请求把 429 和 503 混进 5xx,或把客户端取消算失败
p5050% 请求的延迟不超过该值只报平均值,掩盖长尾
p95 / p9995%/99% 请求的延迟不超过该值样本太少,p99 只是某一个请求
超时率达到 deadline 的请求 / 发出请求与错误率重复计算或漏算

延迟统计要区分「成功请求」和「全部请求」。只统计成功请求会美化结果:超时的请求被排除,p99 看起来很好。正确做法是把超时按 deadline 值计入,或者单独列一列「超时率」,并在报告里说明。

错误分类要按 gRPC 状态码,而不是 HTTP 状态码。DEADLINE_EXCEEDED、UNAVAILABLE、RESOURCE_EXHAUSTED、CANCELLED 的处置完全不同:前两个可能重试,第三个要退避,第四个通常不该重试。

样本量决定分位的可信度。100 个请求的 p99 只是最大值;要得到稳定的 p99,至少需要几千个请求。报告里写 p99 时,同时写样本数和测量窗口。

协调缺失:闭环比开环更「好看」

协调缺失(coordinated omission)是压测里最常见的方法论错误。

闭环客户端的工作方式是:发一个请求,等响应,再发下一个。如果服务端在 10:00:00 卡了 5 秒,这 5 秒内客户端一个请求都没发;恢复后它继续发。那 5 秒里「本应发出但没发出」的请求,完全没有进入延迟统计。结果是:服务端明明停摆了 5 秒,报告的 p99 却可能只有 200 ms。

开环客户端按预定时间发请求:第 i 个请求计划在 start + i / rps 发出,不管上一个是否完成。延迟从「计划发出时间」算起,而不是从「实际发出时间」算起。这样卡顿期间的排队会被计入延迟,p99 才会反映真实体验。

修正后的延迟:

latency_i = finish_i - intended_start_i

而不是:

latency_i = finish_i - actual_start_i

两者在系统健康时几乎一样,在系统卡顿时差出几个数量级。压测报告的延迟数字,必须注明用的是哪一种。

ab 是闭环工具:-c 个并发,每个连接发完一个再发下一个。它能测吞吐和基本分位,但无法避免协调缺失,也不支持 gRPC。用 ab 得到的 p99 适合做「相对比较」,不适合直接当成用户感受到的 p99。

ab 能测什么、不能测什么

ab 在本机可用,适合快速验证 HTTP 入口的吞吐和错误率:

ab -k -n 20000 -c 64 \
  -p /tmp/getbook.json \
  -T 'application/json' \
  http://127.0.0.1:9094/books/978-7-111-00001-1

参数含义:

  • -k:keep-alive,复用连接。不加的话每个请求一次 TCP 握手,测到的是握手开销。
  • -n:总请求数。样本要足够,p99 才有意义。
  • -c:并发数。ab 是单进程,-c 很大时客户端自身可能先成为瓶颈。
  • -p / -T:POST body 与 Content-Type。GET 请求不需要。

ab 的输出包含 Requests per second、Time per request、Failed requests 和分位表(50/66/75/80/90/95/98/99/100)。读法:

  • Time per request 有两行,一行是「每个请求的平均时间」,一行是「每个并发请求的平均时间」,别混。
  • Failed requests 要配合 Non-2xx responses 看;429 在 ab 里可能被算成失败,也可能是非 2xx,取决于版本。
  • 分位表是闭环口径,且 ab 默认 HTTP/1.0;对 HTTP/2 或 gRPC 没有参考价值。

ab 不能测:gRPC、流式响应、HTTP/2 多路复用、协调缺失修正、连接池行为。wrk 和 hey 在本机未安装,脚本里不要假设它们存在;需要更复杂的 HTTP 压测时,先用 JDK HttpClient 自写,和 gRPC 客户端共用同一套统计代码。

自写 gRPC 压测客户端

gRPC 压测需要三件事:固定并发、按目标速率发请求、从预期开始时间计算延迟。下面是一个可运行的骨架:

package com.plume.linkd.v3.load;

import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;

public final class GrpcLoadClient {

    public record Result(long samples, long errors, long timeouts,
                         double throughputRps, double p50Ms, double p95Ms, double p99Ms) {
    }

    private final ManagedChannel channel;
    private final LibraryGrpc.LibraryBlockingStub stub;
    private final List<Long> latenciesNanos = Collections.synchronizedList(new ArrayList<>());
    private final LongAdder errors = new LongAdder();
    private final LongAdder timeouts = new LongAdder();

    public GrpcLoadClient(String host, int port) {
        this.channel = ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()
                .build();
        this.stub = LibraryGrpc.newBlockingStub(channel);
    }

    public Result run(String isbn, int total, double targetRps, int concurrency)
            throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(concurrency);
        CountDownLatch done = new CountDownLatch(total);
        long startNanos = System.nanoTime();
        long intervalNanos = (long) (1_000_000_000L / targetRps);

        for (int i = 0; i < total; i++) {
            long intendedStart = startNanos + i * intervalNanos;
            pool.execute(() -> {
                sleepUntil(intendedStart);
                GetBookRequest request = GetBookRequest.newBuilder().setIsbn(isbn).build();
                try {
                    stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS).getBook(request);
                } catch (StatusRuntimeException e) {
                    switch (e.getStatus().getCode()) {
                        case DEADLINE_EXCEEDED -> timeouts.increment();
                        default -> errors.increment();
                    }
                } finally {
                    // 关键:用预期开始时间,而不是实际开始时间。
                    latenciesNanos.add(System.nanoTime() - intendedStart);
                    done.countDown();
                }
            });
        }

        done.await();
        pool.shutdown();
        long elapsedNanos = System.nanoTime() - startNanos;
        return summarize(total, elapsedNanos);
    }

    private Result summarize(int total, long elapsedNanos) {
        List<Long> snapshot;
        synchronized (latenciesNanos) {
            snapshot = new ArrayList<>(latenciesNanos);
        }
        Collections.sort(snapshot);
        double seconds = elapsedNanos / 1_000_000_000.0;
        return new Result(
                snapshot.size(),
                errors.sum(),
                timeouts.sum(),
                seconds == 0 ? 0 : total / seconds,
                percentileMs(snapshot, 0.50),
                percentileMs(snapshot, 0.95),
                percentileMs(snapshot, 0.99));
    }

    private static double percentileMs(List<Long> sorted, double p) {
        if (sorted.isEmpty()) {
            return 0.0;
        }
        int index = (int) Math.ceil(p * sorted.size()) - 1;
        index = Math.max(0, Math.min(sorted.size() - 1, index));
        return sorted.get(index) / 1_000_000.0;
    }

    private static void sleepUntil(long targetNanos) {
        long remaining = targetNanos - System.nanoTime();
        if (remaining <= 0) {
            return;
        }
        try {
            TimeUnit.NANOSECONDS.sleep(remaining);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public void close() throws InterruptedException {
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
    }
}

这段代码有几个刻意的选择:

  • intendedStart 在提交任务时就算好,延迟从它开始计,直接修正协调缺失。
  • withDeadlineAfter(800, MILLISECONDS) 和客户端总预算一致;超时单独计数,不混进普通错误。
  • ExecutorService 的线程数是并发上限,目标速率由 intervalNanos 控制;两者独立,才能做「低并发高 QPS」或「高并发低 QPS」的组合。
  • 所有延迟样本放在内存里排序。只适合几万到几十万样本;更大规模要用直方图或 HdrHistogram 一类结构,不能无限存 Long。

这个骨架没有做背压:如果服务端变慢,任务会在执行器队列里堆积,内存上涨。真实压测要换成有界队列 + 拒绝计数,或者用信号量限制在途请求数。压测工具本身把内存打爆,得到的数字没有意义。

在 linkd-v3 里落地

压测前先隔离流量,避免限流和真实租户互相干扰:

设置值原因
租户tenant-loadtest与演练租户分开,避免注入器命中
限流单独一组配额测容量时不应被 200 rps 的默认限流挡住
数据固定 ISBN 集合避免缓存命中率随数据变化
时长恒定 15 分钟覆盖 JIT 预热与连接池稳定
停止条件p99 > 2 s 或错误率 > 5%防止把下游打挂

压测顺序建议:

  1. 1 rps 跑 60 秒,确认功能与链路正确。
  2. 恒定 50 rps 跑 5 分钟,记录基线。
  3. 阶梯每 2 分钟 +50 rps,找到拐点。
  4. 在拐点的 70% 负载上浸泡 2 小时,看泄漏。
  5. 回到恒定 200 rps,验证目标流量下的 p99 与错误率。

每一步都记录「吞吐、p50/p95/p99、错误分类、关键资源」。资源至少包括:CPU、堆使用、线程数、fd 数、连接池借出数、队列深度。只记 QPS 和延迟,瓶颈定位只能靠猜。

常见错误与边界

错误一:只报平均延迟。 平均值会把 1% 的超时稀释掉。至少要报 p50、p95、p99 和超时率。

错误二:闭环客户端算 p99。 服务端停摆的时段没有请求进入统计,延迟被系统性低估。用预期开始时间修正,或明确标注这是闭环口径。

错误三:把超时排除在延迟之外。 只统计成功请求,等于把最差的用户体验删掉。超时要按 deadline 计入,或单列。

错误四:压测客户端先成为瓶颈。 ab -c 1000 在单进程上可能先耗尽客户端 CPU;gRPC 客户端如果所有请求共用一个 executor,也会在客户端排队。看服务端指标之前,先确认客户端利用率。

错误五:忽略预热。 JIT、连接池、DNS 缓存都需要时间。把前 30 秒算进结果,会让 p99 偏高;完全跳过预热,又可能错过冷启动问题。折中是单独报告冷启动段。

错误六:压测流量带重试。 重试会把下游压力放大 3 倍,测出来的「容量」其实是重试后的容量。测容量时关重试,测韧性时再打开。

边界:压测环境不等于生产。 数据量、网络拓扑、实例规格、依赖版本都可能不同。压测结论要写明环境,不能直接外推到生产。

边界:本机工具有限。 macOS 没有 ss;wrk、hey、grpcurl 未安装;ab 只适合 HTTP/1.x。需要 gRPC 或 HTTP/2 压测时,自写客户端比临时装工具更可控。

小结

  • 四种剖面各回答一个问题:恒定看稳态、阶梯找拐点、尖峰看队列与扩容、浸泡看泄漏。
  • 指标口径先定义:吞吐按完成数、错误按状态码分类、延迟报 p50/p95/p99 且包含超时。
  • 协调缺失会让闭环压测的 p99 严重偏乐观;用预期开始时间计算延迟。
  • ab 适合 HTTP/1.x 的快速验证,不能测 gRPC、流式与 HTTP/2;本机没有 wrk/hey。
  • 自写 gRPC 客户端要固定并发、按目标速率发请求、分别统计超时与错误,并限制在途数量。
  • 压测结论必须带环境、样本量、口径和资源指标,否则无法复现。

阅读导航:上一节 14.1 容量模型与 Little’s law | 下一节 14.3 成本、扩缩容与预算

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收