《Java 网络编程基础》15.2 压测工具与指标口径

ab 能跑出数字,但吞吐、p50/p95/p99、错误数和连接数必须一起读,单看 QPS 会得出相反结论。本节确认本机 ab 可用、wrk 不在,用三次 keep-alive 与一次非 keep-alive 实测 /healthz,给出 LoadTestPlan 的 Little 定律并发估算、分位算法与验收判定。

本节目标:用 ab 和 JDK 自带代码量出可复现的吞吐、分位、错误数与连接数,并用 Little 定律解释这三个数为什么必须一起看。
适用版本:JDK 25(Temurin 25.0.5+8);macOS 26.3 自带的 ab 2.3。

承接的问题

15.1 能回答「这条连接停在哪」,但回答不了「这个服务能扛多少」。linkd v1 评审时最容易出现的两种数字是:

  • 用 ab -n 100 -c 1 跑出几千 QPS,就认为服务能扛几千并发。并发一加,连接排队、accept 变慢,数字立刻掉下来。
  • 报告写「平均延迟 5ms」,但用户投诉的是偶尔转圈两秒。平均值把尾部抹平了,p99 才是用户真正感知的部分。

压测的目标不是得到一个漂亮数字,而是得到一组能复现、能解释、能比较的数字。可复现要求给命令、机器口径和时长;能解释要求吞吐、延迟分位、错误数、连接数一起看;能比较要求同一个工具、同一组参数、同一个服务版本。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。服务是 15.1 的替身(JDK HttpServer,16 个平台线程,/healthz 返回 ok),端口 9091。它不是 linkd 本体,绝对数字只用于演示口径,不能当成 linkd 的性能承诺。

先检查工具:ab 在,wrk 不在

command -v ab wrk hey
/usr/sbin/ab -V

本机输出:

/usr/sbin/ab
This is ApacheBench, Version 2.3 <$Revision: 1913912 $>

wrk、hey 都没有安装,所以本节不写它们的实测。ab 的优点是 macOS 自带、输出包含分位表;缺点是默认 HTTP/1.0、不支持脚本化场景。压测 linkd 的 /healthz 足够,压测带业务参数的接口就不够了。

如果目标机器连 ab 都没有,用本节「核心机制四」的 JDK 最小压测器;它只依赖 java.net.Socket,能给出吞吐、错误数和分位,但没有 ab 的连接耗时拆分。

核心机制一:一次 ab 运行给了四组数

先跑一次 keep-alive:

/usr/sbin/ab -k -n 3000 -c 50 http://127.0.0.1:9091/healthz

本机输出(节选,单次运行,未做统计):

Concurrency Level:      50
Time taken for tests:   0.339 seconds
Complete requests:      3000
Failed requests:        0
Keep-Alive requests:    3000
Requests per second:    8861.74 [#/sec] (mean)
Time per request:       5.642 [ms] (mean)
Time per request:       0.113 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   2.2      0      22
Processing:     2    5   2.1      5      26
Total:          2    5   3.8      5      33

Percentage of the requests served within a certain time (ms)
  50%      5
  95%      8
  99%     32
 100%     33 (longest request)

这份报告要分四组读,缺一组就会误判:

组字段本机值说明
吞吐Requests per second8861.74单位是「完成的请求/秒」,不是「连接/秒」
延迟Time per request 两行5.642 / 0.113 ms第一行是单请求视角,第二行是并发摊薄后的服务端视角
错误Failed requests0非 2xx 另有一行 Non-2xx responses,不要只看失败数
连接Keep-Alive requests、Connect3000、均值 03000 个请求复用连接,建连时间接近 0

两个 Time per request 差 50 倍,正好等于并发数。把第二行当成用户延迟,会得出「服务只要 0.1ms」的错误结论;把第一行当成服务端处理能力,又会把排队时间算进业务。报告里必须写清楚用的是哪一个。

Connect 均值 0、最大值 22ms,说明连接被复用;Processing 的均值 5ms、最大值 26ms,才是服务端处理加排队的范围。Total 的 p99 是 32ms,比均值 5.642ms 高 5.7 倍,这个差距就是下一节要解释的尾部。如果 Processing 随并发线性上涨而 Connect 仍接近 0,先查 EventLoop 是否在 Handler.onReadable 里做了阻塞调用。

核心机制二:p99 是排出来的,不是算平均

同一组参数连跑三次,本机输出(三次运行,未做统计):

运行吞吐(req/s)平均延迟(ms)p50p95p99
18861.745.6425832
29398.445.3204725
39702.835.1535712

平均延迟只差 9%,p99 却从 12ms 跳到 32ms。这不是「服务不稳定」的道德判断,而是尾部的样本少、受调度和 GC 影响大。用一次运行的 p99 做硬门槛,结论会随运行次数翻转;要么多跑几次取中位数,要么把样本量做大。

分位数用最近秩(nearest-rank),不插值:

public static long percentile(long[] sortedNanos, double p) {
    if (sortedNanos.length == 0) {
        throw new IllegalArgumentException("empty samples");
    }
    int rank = (int) Math.ceil(p / 100.0 * sortedNanos.length);
    int index = Math.max(0, Math.min(sortedNanos.length - 1, rank - 1));
    return sortedNanos[index];
}

rank 是「第几个样本」,从 1 开始;数组下标要减 1。p99 在 100 个样本里是第 99 个,也就是第二慢的请求;在 3000 个样本里是第 2970 个,也就是第 30 慢的请求。样本越少,p99 越接近最大值,波动越大。想让 p99 有统计意义,至少 1000 个样本,最好 10000 个。

不要对 p99 再求平均:三次运行的 p99 平均是 23ms,但没有任何一次运行真的出现过 23ms 的 p99;报区间或中位数。

核心机制三:Little 定律把三个数绑在一起

Little 定律是 L = λ × W:系统中的平均请求数等于到达率乘以平均停留时间。压测里 L 是并发连接数,λ 是吞吐,W 是平均延迟。三者不是独立的,给定两个,第三个就定了。

用本机数据验证:并发 50,平均延迟 5.642ms,预测吞吐 50 / 0.005642 = 8862.1,ab 报 8861.74,差 0.004%。这不是巧合,是定律。

反过来估算并发。目标 8000 req/s,预期平均延迟 5.6ms,所需并发就是 ceil(8000 × 5.6 / 1000),即 45 条;LoadTestPlan.plan() 只是把这个乘法封成了方法。实际用 50 条跑到 8861,说明估算方向正确,还留了约 10% 余量。

两个边界必须写清楚:

  • W 是平均延迟,不是 p99。 用 p99 代入会得到 8000 × 0.032 = 256 条,这是「所有请求都按最慢 1% 走」的上界,不是所需并发。容量规划用均值,SLO 用分位,两者不能混。
  • 闭模型和开模型不同。 ab 是闭模型:固定 50 个客户端,上一个请求完成才发下一个,服务变慢时到达率自动下降。真实用户是开模型:不管服务多慢,请求都会按外部速率到达。开模型下排队会发散,ab 的 p99 会低估真实尾部,这就是 coordinated omission。要测开模型,得用固定速率发请求的工具,或者自己按时间表补发。

核心机制四:没有 ab 时的 JDK 最小压测器

只用 JDK 也能测。下面的类固定并发、总共发 total 个请求,每个请求新建连接、发 HTTP/1.0、读到状态行,记录纳秒耗时:

package com.plume.linkd.app;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
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.AtomicInteger;

public final class JdkLoadGenerator {

    public static void main(String[] args) throws Exception {
        String host = args[0];
        int port = Integer.parseInt(args[1]);
        int concurrency = Integer.parseInt(args[2]);
        int total = Integer.parseInt(args[3]);

        AtomicInteger next = new AtomicInteger();
        AtomicInteger errors = new AtomicInteger();
        long[] samples = new long[total];
        CountDownLatch done = new CountDownLatch(total);
        ExecutorService pool = Executors.newFixedThreadPool(concurrency);
        long start = System.nanoTime();
        for (int i = 0; i < concurrency; i++) {
            pool.execute(() -> {
                while (true) {
                    int index = next.getAndIncrement();
                    if (index >= total) return;
                    long begin = System.nanoTime();
                    try {
                        request(host, port);
                        samples[index] = System.nanoTime() - begin;
                    } catch (IOException e) {
                        errors.incrementAndGet();
                    }
                    done.countDown();
                }
            });
        }
        done.await(60, TimeUnit.SECONDS);
        pool.shutdownNow();
        long elapsed = System.nanoTime() - start;

        long[] ok = Arrays.stream(samples).filter(v -> v > 0).sorted().toArray();
        System.out.printf("concurrency=%d total=%d errors=%d rps=%.1f%n",
                concurrency, total, errors.get(), total / (elapsed / 1e9));
        System.out.printf("p50=%.2fms p95=%.2fms p99=%.2fms%n",
                LoadTestPlan.percentile(ok, 50) / 1e6,
                LoadTestPlan.percentile(ok, 95) / 1e6,
                LoadTestPlan.percentile(ok, 99) / 1e6);
    }

    private static void request(String host, int port) throws IOException {
        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress(host, port), 2000);
            socket.setSoTimeout(3000);
            String request = "GET /healthz HTTP/1.0\r\nHost: " + host + "\r\n\r\n";
            socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
            BufferedReader reader = new BufferedReader(new InputStreamReader(
                    socket.getInputStream(), StandardCharsets.ISO_8859_1));
            String statusLine = reader.readLine();
            if (statusLine == null || !statusLine.startsWith("HTTP/1.1 200"))
                throw new IOException("bad status: " + statusLine);
        }
    }
}

本机输出(20 并发、500 个请求,单次运行,未做统计):

concurrency=20 total=500 errors=0 rps=1439.9
p50=11.94ms p95=14.10ms p99=50.00ms

同样是非 keep-alive,ab -n 2000 -c 50 在本机是 1964.34 req/s、p50 25ms、p99 33ms。两个数不相等,因为客户端实现不同:这个生成器每个请求都新建连接、用 HTTP/1.0、没有预热;ab 也有建连,但客户端更紧凑,且 -c 50 的排队行为不同。不同工具的数字不能直接比,只能用同一个工具比较服务版本的前后差异。

这个生成器还有一个盲点:done.await 只等 60 秒,超时请求不会减少 total,吞吐仍按「总数 / 耗时」算,看起来正常。生产压测必须单独统计超时与未完成数。

在 linkd 里落地:LoadTestPlan

把口径固化成类:给目标吞吐和预期平均延迟,算所需并发;解析 ab 报告;用 p99 预算做判定。

package com.plume.linkd.app;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public final class LoadTestPlan {

    public record Plan(double targetRps, double meanLatencyMillis,
                       double p99BudgetMillis, int concurrency) { }

    public record AbResult(double rps, long failed, double p50, double p95, double p99) { }

    public record Verdict(boolean pass, List<String> reasons) { }
    public static void main(String[] args) throws IOException {
        Plan plan = plan(Double.parseDouble(args[1]), Double.parseDouble(args[2]),
                Double.parseDouble(args[3]));
        AbResult result = parseAb(Path.of(args[0]));
        System.out.printf("plan targetRps=%.0f mean=%.1fms p99Budget=%.1fms concurrency=%d%n",
                plan.targetRps(), plan.meanLatencyMillis(), plan.p99BudgetMillis(),
                plan.concurrency());
        System.out.printf("actual rps=%.2f failed=%d p50=%.2fms p95=%.2fms p99=%.2fms%n",
                result.rps(), result.failed(), result.p50(), result.p95(), result.p99());
        Verdict verdict = verify(plan, result);
        System.out.println("verdict=" + (verdict.pass() ? "PASS" : "FAIL")
                + " " + verdict.reasons());
    }
    public static Plan plan(double targetRps, double meanLatencyMillis, double p99BudgetMillis) {
        double inFlight = targetRps * meanLatencyMillis / 1000.0;
        return new Plan(targetRps, meanLatencyMillis, p99BudgetMillis,
                Math.max(1, (int) Math.ceil(inFlight)));
    }

    public static AbResult parseAb(Path file) throws IOException {
        double rps = 0;
        double p50 = 0;
        double p95 = 0;
        double p99 = 0;
        long failed = -1;
        for (String raw : Files.readAllLines(file)) {
            String line = raw.trim();
            if (line.startsWith("Requests per second:")) rps = firstNumber(line);
            else if (line.startsWith("Failed requests:")) failed = (long) firstNumber(line);
            else if (line.startsWith("50%")) p50 = firstNumber(line);
            else if (line.startsWith("95%")) p95 = firstNumber(line);
            else if (line.startsWith("99%")) p99 = firstNumber(line);
        }
        if (failed < 0) throw new IllegalArgumentException("not an ab report: " + file);
        return new AbResult(rps, failed, p50, p95, p99);
    }

    public static long percentile(long[] sortedNanos, double p) {
        if (sortedNanos.length == 0) throw new IllegalArgumentException("empty samples");
        int rank = (int) Math.ceil(p / 100.0 * sortedNanos.length);
        return sortedNanos[Math.max(0, Math.min(sortedNanos.length - 1, rank - 1))];
    }

    public static Verdict verify(Plan plan, AbResult result) {
        List<String> reasons = new ArrayList<>();
        if (result.failed() != 0) reasons.add("failed=" + result.failed());
        if (result.rps() < plan.targetRps())
            reasons.add(String.format("rps %.2f < %.0f", result.rps(), plan.targetRps()));
        if (result.p99() > plan.p99BudgetMillis())
            reasons.add(String.format("p99 %.2fms > %.1fms",
                    result.p99(), plan.p99BudgetMillis()));
        return new Verdict(reasons.isEmpty(), reasons);
    }

    private static double firstNumber(String line) {
        for (String token : line.split("\\s+")) {
            try {
                return Double.parseDouble(token);
            } catch (NumberFormatException ignored) {
                // token 不是数字,继续找下一个
            }
        }
        throw new IllegalArgumentException("no number in: " + line);
    }
}

验收目标是 8000 req/s、平均 5.6ms、p99 不超过 50ms,对第一次运行做判定:

java -cp out com.plume.linkd.app.LoadTestPlan logs/ab-keep-1.txt 8000 5.6 50

本机输出:

plan targetRps=8000 mean=5.6ms p99Budget=50.0ms concurrency=45
actual rps=8861.74 failed=0 p50=5.00ms p95=8.00ms p99=32.00ms
verdict=PASS []

把 p99 预算收紧到 20ms,同一次运行输出 verdict=FAIL [p99 32.00ms > 20.0ms]。

同一次运行、同一个吞吐,只改预算就翻转结论。这说明判定必须写成「目标 + 分位 + 预算」三元组,不能只说「性能达标」。plan() 给的是起点,verify() 给的是门槛,两者都用同一份 ab 输出,避免「计划用一套数、验收用另一套数」。

三次运行的完整对照:

运行rpsp99对 8000/50ms 判定对 8000/20ms 判定
18861.7432PASSFAIL
29398.4425PASSFAIL
39702.8312PASSPASS

三次都过 50ms,只有一次过 20ms。CI 里如果只取一次运行,20ms 门槛会随机红绿;正确做法是三次全跑、看最差一次,或者把样本量加到 30000 再看 p99。

常见错误与边界

错误一:不加 -k 就下结论。 ab 默认 HTTP/1.0、每个请求新建连接。本机 2000 个非 keep-alive 请求只有 1964 req/s,而 3000 个 keep-alive 请求有 8861 req/s,差 4.5 倍。测的是连接建立还是服务处理,要写清楚。

错误二:把第二个 Time per request 当用户延迟。 0.113ms 是「并发摊薄」后的数字,用户感知接近 5.642ms。报告里两个都写,并注明视角。

错误三:-n 太小。 100 个请求的 p99 是第二慢的请求,一次 GC 就能把它拉到几百毫秒。至少 1000,涉及 p99 建议 10000;-n 太小还会让 Time taken 短到受启动开销影响。

边界:闭模型低估尾部。 ab 的 50 个客户端在上一个请求完成后才发下一个,服务卡住时新请求不会继续到达,p99 里看不到「本应排队」的那些请求。对外承诺 SLO 时,要用开模型工具或按固定速率补发。

边界:并发超过 backlog 会变成连接错误。 -c 远大于 ServerSocket backlog 时,失败会以 Connection reset 或超时出现,看起来像服务崩溃,实际是 accept 队列溢出。先把 -c 控制在压测目标内,再单独测 backlog 上限。

小结

  • 本机 ab 可用、wrk/hey 不在;压测数字必须带命令、机器口径和时长。
  • 一次 ab 报告要同时读吞吐、两个 Time per request、Failed requests 与 Keep-Alive requests/Connect。
  • p99 用最近秩从排序样本里取,样本少于 1000 时波动很大;三次运行 p99 从 12ms 到 32ms。
  • Little 定律 L = λW:本机 50 并发、5.642ms 平均延迟预测 8862 req/s,与 ab 的 8861.74 一致。
  • LoadTestPlan 用平均延迟算并发、用 p99 预算做判定;不同工具的数字不能直接比,回环数字只能当回归基线。

到这里,连接诊断和容量口径都有了。最后一节把它们收成 linkd v1 的验收清单、故障注入项和复盘模板,逐条对应前 14 章。

阅读导航:上一节:15.1 tcpdump、netstat 与连接诊断 · 下一节:15.3 linkd v1 验收与故障复盘 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发