延迟、吞吐、分位数与排队:性能指标怎样互相约束
一个 HTTP 请求可以同时有 20 ms 的业务处理时间、80 ms 的服务端等待时间和 110 ms 的客户端调用时间。这几个数字描述不同区间。压测报告里的“延迟”只有标明起点和终点,才能与线程池、数据库和网关指标放在一起分析。
同样,每秒收到 1,000 个请求、完成 900 个响应、成功创建 700 笔订单,是三种吞吐口径。服务拒绝请求很快时,响应吞吐甚至可能上升,而用户得到的有效结果正在减少。
一次请求有哪些时间与数量
从计划到达到完整响应
请求的时间可以按下面的结构拆开。连接复用会跳过部分建连步骤,代理和重试会增加新的区间。
计划到达 T0
├─ 发生器调度、可用执行槽等待
实际发起 T1
├─ 客户端连接等待、DNS、TCP、TLS(按实际路径发生)
├─ 发送请求、网络与代理转发
服务入口 T2
├─ 接收队列、执行器队列、应用准入等待
开始处理 T3
├─ CPU 计算、锁、连接获取、远程 IO、编码
完成处理 T4
├─ 响应写出、网络、客户端读取
完整响应 T5T5-T1 是从实际调用开始观察的总耗时;T5-T0 还包括发生器未能及时发起请求的延迟。只有记录了计划时刻,才能计算后一项。把两种时间都叫“端到端延迟”却不说明起点,会让发生器排队悄悄消失。
服务端可以测量 T4-T2,但这个入口可能位于网关、Servlet Filter 或业务方法。Filter 之前的容器排队不会自动出现在业务计时器里。T4-T3 也包含阻塞等待:方法运行了 100 ms,其中可能只有 3 ms 消耗 CPU,其余都在等待数据库连接。
同一进程内计算间隔适合使用单调时间源,例如 Java 的 System.nanoTime()。它的数值没有日历含义,也不能拿两台机器的 nanoTime 相减。跨进程使用各自测得的持续时间和调用关系;主机时钟偏差会影响分布式时间线的位置。
不同工具的字段定义要分别读取。k6 的 http_req_duration 是发送、等待首字节与接收响应之和,不包含初次 DNS 和连接建立时间;http_req_blocked、http_req_connecting、http_req_tls_handshaking 另行报告。k6 内置指标给出了这些口径。curl 的 time_total 则统计整个传输操作;两列名字相近的结果未必可以直接比较。
到达率、完成率和有效吞吐
| 数量 | 计数对象 | 典型用途 |
|---|---|---|
| 计划到达率 | 希望在窗口内发起的请求 | 描述外部需求 |
| 实际发送率 | 发生器真正发出的请求 | 判断负载是否施加成功 |
| 接收率 | 服务入口收到的请求 | 与客户端、网关核对丢失区间 |
| 完成率 | 在窗口内结束的请求,包含失败 | 判断排队和排空速度 |
| 有效吞吐 goodput | 达到指定业务结果的工作单元 | 判断服务是否完成有用工作 |
RPS 常指每秒请求,QPS 常指每秒查询;TPS 中的 transaction 可能是业务事务,也可能只是工具里定义的一组操作。使用这些缩写时,应写清计数点。例如下单接口返回 202 Accepted 后还需要异步审核,则“请求接纳率”和“审核完成率”要各自计量。
重试会增加尝试数,一笔订单可能贡献三个 HTTP 请求。批量接口则相反,一个 HTTP 请求可能处理一百条记录。比较优化前后的吞吐时,业务工作单元和成功语义必须保持一致。
在相同入口、相同时间窗口内,还可以检查数量关系:
窗口末在途数 = 窗口初在途数 + 新进入数 - 已离开数
已离开数 = 成功数 + 明确失败数 + 取消等其他终止数发生器还未发送的 dropped_iterations 不属于服务端接收数;业务拒绝属于服务端完成的失败结果;传输中断则可能只让客户端离开,服务端工作仍在执行。需要两边分别计数,不能用一个总错误率替代这些状态。
并发、线程、连接和利用率
并发数表示某个范围内同时存在的工作。100 个 HTTP 在途请求可能只使用少量 HTTP/2 连接,也可能等待 20 个数据库连接。线程池大小描述执行资源,连接池大小描述可借用连接,二者都不是请求并发的同义词。
资源利用率也有自己的分母。一个线程忙于等待 Socket 时,worker 利用率可以很高,CPU 利用率却很低;容器只有 0.5 个 CPU 配额时,宿主机器的总 CPU 百分比又可能显得很低。比较这些指标时,需要一并核对可用 worker 数、连接数和 CPU 配额,找出哪种资源已经限制了处理速度。
分布与排队怎样改变延迟
平均值和分位数回答不同问题
把十个耗时从小到大排列为:
10, 10, 11, 11, 12, 12, 13, 13, 110, 180 ms总耗时为 382 ms,平均 38.2 ms。按 nearest-rank 定义,p50 是第 ceil(10×0.5)=5 个样本,即 12 ms;p95 和 p99 都落在第十个样本,即 180 ms。十次请求不足以分辨百分之一的尾部,只能说明观察到了长请求。
不同工具可能使用插值或近似算法,得到略有差异的分位数。比较时固定算法、窗口和样本集。低流量接口的 p99 跳动很大,先看请求数和原始长请求,再决定是否扩大窗口;把窗口拉长虽然增加样本,也可能把短时故障稀释。
平均值适合资源总量和成本推算,分位数描述请求分布中的位置,最大值保留最极端观察。它们应一起使用。p99 以内仍可能包含失败请求,p99 以外也可能是成功但很慢的请求;状态分组是另一维度。
超时需要单独报告。客户端在 3 秒终止,只知道自己的等待持续了约 3 秒,无法据此推断服务端最终执行时间。若只保存成功请求,最慢的一批被排除后,图表可能在故障期间反而变快。把成功、拒绝、超时和其他失败分组,同时保留全部尝试数。
直方图如何跨实例聚合
直方图把样本计入区间,例如 ≤50ms、≤100ms、≤250ms、≤1s。Prometheus 经典 histogram 暴露的桶是累积计数,le="0.25" 包含所有不超过 250 ms 的样本。分位数由桶内插值估计,精度取决于桶分布;只有一个 +Inf 桶容纳所有超长请求时,尾部细节已经丢失。
跨实例应先按相同语义合并直方图,再计算分位数。下面是经典桶的示意查询,前提是各实例使用兼容桶、秒单位和同一个 /orders 路由标签:
histogram_quantile(
0.99,
sum by (le) (
rate(http_request_duration_seconds_bucket{route="/orders"}[5m])
)
)这里先对每条序列取 rate,以处理进程重启导致的 counter reset,再汇总。若还需按服务或路由分别观察,在 sum by 中保留对应标签。不能把 A 实例的 p99 和 B 实例的 p99 求平均:两个实例的请求量可能不同,分位数本身也没有可加性。
客户端预计算分位数的 summary、经典 histogram 与 native histogram 的存储和聚合方式不同,查询不能直接互换。选桶、插值和聚合规则见 Prometheus 直方图说明;Micrometer 的分位数与直方图配置解释了 Java 应用如何导出对应数据。
排队从哪里出现
单个处理槽每次占用 200 ms,持续工作时每秒约能处理 5 次;四个独立槽约为 20 次。外部每秒送来 40 次时,多出的请求只能等待、被拒绝或转移到别处。队列长度增长后,新请求即使自身处理仍只要 200 ms,也要先等待前面的任务释放槽位。
低于平均容量仍可能排队。请求会成批到达,服务时间也存在波动;少数长任务占住槽位时,后续短任务会受影响。在理想 M/M/1 模型中,泊松到达、独立指数服务时间、单服务台且 λ<μ 时,平均驻留时间为 1/(μ-λ)。它解释了接近饱和时等待急剧上升的形状,实际多池服务的 p99 不能直接从该式得出。模型假设与推导可查 MIT 排队课程资料。
串行调用可以按相邻且不重叠的时间段求和;并行调用看最晚完成的依赖。一个父 Span 已经包含子 Span,GC 暂停也可能同时落在父子区间内。把父耗时、所有子耗时和 GC 暂停再次相加,会重复计时。跨组件分析见数据库、缓存、MQ 与 GC 性能链,平均在途量的计算见 Little 定律与容量估算。
用 HTTP 服务观察开放与封闭负载
启动一个四槽位服务
下载完整实验包,解压后进入 latency-lab。包内包含 Java 服务、k6 脚本和 Compose 配置,README.md 保留连续运行入口。
实验使用 Linux、Docker Engine/Compose、Temurin 25.0.4+7 和 k6 2.2.0;Java 代码以 --release 17 编译,也可在 Temurin 17.0.20+8 运行。宿主操作者是有 Docker 权限的普通用户,Docker daemon 的权限独立;编译容器使用宿主 UID/GID,服务和发生器分别显式使用 10001:10001。Compose 只向宿主回环地址发布 18018 端口。
首次使用需要取得下面两个精确镜像。企业内网可从已审核私有仓库取得同版本镜像;离线机器用 docker save、docker load 搬运,并与可信清单核对镜像身份。镜像传递与导入命令见 Docker 官方镜像保存说明。不要为了拉取失败而关闭 TLS 校验。
docker pull eclipse-temurin:25.0.4_7-jdk
docker pull grafana/k6:2.2.0
docker run --rm --user 10001:10001 grafana/k6:2.2.0 version
mkdir -p classes
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/lab" -w /lab eclipse-temurin:25.0.4_7-jdk \
javac --release 17 -Xlint:all -Werror -d classes QueueServer.java
docker compose up -d queue编译无错误时会生成 classes/QueueServer.class。若报目录不可写,检查解压目录和 classes 的宿主权限,不要改用 root 掩盖问题。容器启动后检查:
docker compose logs queue
curl -q --noproxy '*' --fail-with-body \
http://127.0.0.1:18018/health
curl -q --noproxy '*' --fail-with-body -i \
'http://127.0.0.1:18018/work?ms=20'日志应显示 slots=4 acquireTimeoutMs=250,健康响应为 {"status":"UP"},工作响应为 {"ok":true,"delayMs":20}。启动尚未完成时,先看日志并重试健康请求,健康成功后再施加负载。客户端状态隔离使用 curl 的首选项 -q 和 --noproxy '*';相关参数见 curl 手册。
服务基于 JDK HttpServer,HTTP handler 共用四个业务许可。核心操作是:
long entered = System.nanoTime();
boolean acquired = slots.tryAcquire(250, TimeUnit.MILLISECONDS);
if (!acquired) {
// 返回 503,JSON reason 为 QUEUE_TIMEOUT
return;
}
try {
long started = System.nanoTime();
Thread.sleep(delay);
long finished = System.nanoTime();
// queue = started - entered;work = finished - started
// 响应 Server-Timing 和包含 delayMs 的 JSON
} finally {
slots.release();
}完整代码还负责参数检查、响应关闭、计数和停机。sleep 有意制造可控的资源持有时间,用于观察并发槽和排队;这个数值不代表数据库或业务计算的真实成本。Server-Timing 中的 queue 从 handler 进入才开始,不包括 Socket 接收和 HTTP 执行器之前的等待。
实验限制 k6 最多 64 个 VU,HTTP 执行器配置 96 个线程,用于减少这一层成为主要排队点的干扰。这个配置是有界本地实验的安排,不是生产服务器推荐值。Semaphore 的超时获取规则决定了等待失败的分支。
先让每秒 40 次请求完整完成
在同一解压目录运行:
docker compose run --rm -e MODE=open -e DELAY_MS=20 load这轮保持 5 秒,每秒计划启动 40 次迭代,每次只发一个 GET;预分配 64 个 VU,避免临时创建 VU 影响发起。脚本明确禁用代理环境并只允许实验目标。正常结果应包括:
dropped_iterations count=0
transport_failed count=0
invalid_response count=0
business_rejected count=0
goodput count>0
checks rate=100%一次 Linux amd64 运行得到 201 个完整成功响应、约 40 次/秒、客户端调用 p99 约 26 ms。5 秒窗口边沿可能多发或少发一次,机器调度也会改变耗时,不要要求逐个数字相同。判断重点是计划负载实际送达、载荷正确、失败分类没有遗漏。
k6 的 check() 记录检查结果;要让检查失败使进程返回非零,还需要配置 thresholds。实验为完整响应、正确 JSON、goodput 和各失败计数设置了阈值规则,而没有给不同机器规定相同的毫秒成绩。
服务变慢时,闭环发生器会自动减速
将每次持有时间改为 200 ms,固定四个 VU:
docker compose run --rm -e MODE=closed -e DELAY_MS=200 load每个 VU 收到响应后再发下一次请求。这是一种封闭模型,四个 VU 对应四个持续循环的用户。一次运行得到约 100 次成功、约 19.8 次/秒、p99 约 203 ms,服务端许可等待几乎为零。
这个结果适合描述“只有四个用户、每人必须等待上一步完成”的系统。若真实外部需求仍是每秒 40 次,发生器却只送出约 20 次,那么它已经把一半需求从实验中移除了。服务变慢时停止制造新到达,是协调遗漏的一种常见来源。k6 开放与封闭模型解释了这两种使用目的。
现在保持同样的 200 ms 工作时间,恢复固定到达率:
docker compose run --rm -e MODE=open -e DELAY_MS=200 \
-e ALLOW_REJECTS=1 load四个槽位约只能完成每秒 20 次工作,剩余请求开始等待;等不到许可的请求在约 250 ms 后得到 503 / QUEUE_TIMEOUT。一轮实测为:
| 观察项 | 一轮结果 | 含义 |
|---|---|---|
| HTTP 请求 | 201 | 实际已经发出的请求 |
| goodput | 106 | 完整200且业务体符合要求 |
| business_rejected | 95 | 完整503且reason为QUEUE_TIMEOUT |
| transport_failed / invalid_response | 0 / 0 | 无传输或响应契约错误 |
| dropped_iterations | 0 | VU足以启动计划迭代 |
| 成功响应的服务端排队平均值 | 约216 ms | 槽位等待成为主要耗时 |
总响应完成率会接近输入率,因为拒绝也产生响应;有效吞吐仍约为每秒 20 次。汇总行的速率可能按包含排空的整段执行时间计算,所以用“总数÷5秒”得到的计划窗口速率和汇总速率会有差异。需要精确比较时使用相同时间窗口的时序样本,而不是混用两个分母。
http_req_failed 会把 503 记为失败,即使这个拒绝正是实验预期。ALLOW_REJECTS=1 只放行这一种明确拒绝,并要求它确实出现;其他状态、坏 JSON、超时和传输失败仍使本轮失败。k6 的 error_code 中 1400—1599 还包含 HTTP 4xx/5xx,不能把所有非零 error_code 都归为断网;分类依据见 k6 错误码。
开放模型也可能发不出负载
保持每秒 40 次、工作 200 ms,却只给发生器一个 VU:
docker compose run --rm -e MODE=starved -e DELAY_MS=200 load一次运行只发出了 23 次成功请求,另有 177 次 dropped_iterations。服务延迟约 201 ms,看起来很稳定,因为大量计划工作根本没有到达它。这里的退出成功表示“发生器不足这一反例被观察到”,不表示服务达到 40 RPS。
constant-arrival-rate 按计划启动迭代,但没有空闲 VU 时无法启动;它不会自动为那些丢掉的迭代生成 HTTP 延迟样本。固定到达率执行器和dropped iterations说明了相关条件。正式容量测试发现该计数增长时,先检查发生器 CPU、VU、连接与系统限额,再决定增加发生器资源或降低计划速率。
从异常报表回到可检查的原因
请求变快了,但失败更多
先把成功、明确拒绝和传输失败拆开,再比较各组耗时与数量。排队 250 ms 后拒绝,可能比排队加执行的成功请求更快;把两组混在同一个平均值里,错误比例改变就会改变结果。
继续查看同一轮服务状态:
curl -q --noproxy '*' --fail-with-body \
http://127.0.0.1:18018/stats
docker stats --no-stream pf18-latency-queue-1负载结束并排空后,active 和 waiting 应回到 0。executed 是服务完成受控工作的累计数,goodput 是客户端完整读到正确结果的数;若 writeFailures 增长或客户端有超时,按各轮前后差值核对,而不是把进程累计值直接与单轮值相减。连续增长的 waiting 表示尚有积压,应先降载,保留运行数据,再检查哪些工作没有结束。
恢复验证重新运行 MODE=open / DELAY_MS=20,确认无拒绝、无 dropped、无传输错误。流量下降后继续观察 active 和 waiting,直到二者回到 0:CPU 已经降低时,旧请求仍可能没有排空。
服务端时间短,客户端总时间长
对一个请求观察 curl 的分阶段时间:
curl -q --noproxy '*' --fail-with-body -sS -o /dev/null \
-w 'dns=%{time_namelookup} connect=%{time_connect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
'http://127.0.0.1:18018/work?ms=20'本实验使用IP地址与明文HTTP,DNS和TLS成本很小或没有发生;真实域名和HTTPS要按实际路径解释。curl 这些时间多是从传输开始累计到某个时点,不能把整列直接相加。first_byte 明显增长,既可能来自服务器排队,也可能来自代理或网络;结合 Server-Timing 与服务端入口计时继续分段。
服务端内部计时正常而客户端慢时,检查计时器外的接收队列、代理、连接获取、重传和响应读取。请求端协议检查与浏览器 Network 面板使用可参考从请求到响应。
p99 对不上或突然跳变
先核对单位和样本:ms与s、成功组与全部组、请求级与业务级、单实例与集群、滚动窗口与整轮统计。再检查桶是否足够覆盖长尾、counter 是否重置、是否把多个实例 p99 平均、失败样本是否丢失。一个低请求量接口偶发一次长请求,p99跳变可能只是秩统计结果;把请求数、最大值和对应请求一起保留。
若同一到达率下成功延迟、waiting和资源等待同时增长,就继续定位资源池与IO。若只是候选版本的结果不同,固定数据、预热、JDK和资源限额后重新对照,具体方法见基线、压测与性能回归。
结束本机实验时执行:
docker compose down --remove-orphans它停止本实验容器并移除 Compose 网络;源码和编译结果保留。共享环境中的容器名称、网络和数据卷要逐项确认,不使用全局 prune 代替实验清理。
权威资料与规范地址
时间、执行与实验命令
- Java System.nanoTime:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/System.html#nanoTime()
- Java HttpServer:https://docs.oracle.com/en/java/javase/17/docs/api/jdk.httpserver/com/sun/net/httpserver/HttpServer.html
- Java Semaphore 超时获取:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/Semaphore.html#tryAcquire(long,java.util.concurrent.TimeUnit)
- curl 手册:https://curl.se/docs/manpage.html
- Docker image save:https://docs.docker.com/reference/cli/docker/image/save/
负载、统计与排队
- k6 内置指标:https://grafana.com/docs/k6/latest/using-k6/metrics/reference/
- k6 阈值:https://grafana.com/docs/k6/latest/using-k6/thresholds/
- k6 开放与封闭模型:https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/open-vs-closed/
- k6 固定到达率:https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/constant-arrival-rate/
- k6 丢弃迭代:https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/dropped-iterations/
- k6 错误码:https://grafana.com/docs/k6/latest/javascript-api/error-codes/
- Prometheus histogram 与 summary:https://prometheus.io/docs/practices/histograms/
- Micrometer histogram 与 quantile:https://docs.micrometer.io/micrometer/reference/concepts/histogram-quantiles.html
- MIT 排队课程资料:https://ocw.mit.edu/courses/15-763j-manufacturing-system-and-supply-chain-design-spring-2005/22f4805864deb06eee85acc1601463ba_queueing_note.pdf
