平均数会撒谎, 分位数才是延迟的通用语言 — Little 定并发, 曲棍球杆定容量, wrk/ab 的数字先过三道口径再信
把系统想成一条高速公路收费站: QPS 是每秒涌来的车流, RT 是过站耗时, 而 P99 回答的是"最堵的那 1% 车要等多久" — 平均过站时间会被堵死的车硬拉高, 所以谈性能只认分位数。车流到 7 成时一切安好, 再往上排队长度非线性爆炸 (曲棍球杆), 这不是玄学, 是排队论 W = S/(1−ρ)。wrk/ab 就是那个发车的调度员: Little's Law 告诉你该发多少车 (c = QPS × RT), 但发车之前先确认调度员自己没累趴 — 压测机端口/fd/CPU 先死, 是压测界的第一玩笑话, 也是第一常见事故。
$ wrk -t8 -c400 -d30s --latency http://api.local/list | tail -1 Requests/sec: 41015.32 # 关键: 这是 wrk 视角的 HTTP 请求吞吐 # nginx QPS ≈ app QPS, DB QPS 可能是它的 2.7 倍 — 各层各算
order_tps = 500 gateway_qps = order_tps * 7 # → 3500: 一个事务 7 个请求 db_qps = order_tps * 22 # → 11000: 一个事务 22 条 SQL # 关键: 三层各说各话, 混用 = 容量评审翻车
Requests/sec: 41015.32 # 口径一: 请求数每秒 Transfer/sec: 15.18MB # 口径二: 字节每秒 ≈ 121 Mbps # 关键: 380B 响应没事; 换 10KB 响应 = 3.2 Gbps → 千兆网卡先死
rt = net + queue + svc # 0.4 + 40 + 5 (ms) # 关键: svc 只占 11% — 先查"在等谁", 再查"在算什么"
lat = [10]*9800 + [2000]*200 # ms, 2% 长尾 print(sum(lat)/len(lat)) # → 49.8 平均"健康" print(sorted(lat)[9900-1]) # → 2000 P99 才是真相
import math lat = sorted([8, 12, 9, 200, 11, 1500, 10, 9]) # ms rank = math.ceil(0.99 * len(lat)) # → 8 (1-based) print(lat[rank-1]) # → 1500 # 关键: 样本小时 P99 ≈ max — 样本必须够多, 否则噪声当分位
c = 5000 * 0.080 # → 400 在途 = 压测 -c 与连接池下限 # 关键: RT 翻倍 → 同样 c 下 QPS 减半, 这就是雪崩的数学
$ ss -ant state established | tail -n +2 | wc -l 412 # → keep-alive 下 ≈ wrk -c 400 的在途请求 # 关键: 看在途 (QPS×RT), 别数线程
for rho in (0.5, 0.8, 0.9, 0.95): print(rho, 1/(1-rho)) # → 2.0 / 5.0 / 10.0 / 20.0 倍 # 关键: 最后 10% 的利用率, 买走 10 倍的延迟
qps, rt, think = 200, 0.1, 5 vu = qps * (rt + think) # → 1020 个虚拟用户 # 关键: 不加 think time, 1020 VU 会打出 10200 QPS
$ wrk -t8 -c400 -d5m URL # 预热, 结果丢弃 $ wrk -t8 -c400 -d10m --latency URL > run1.txt # 关键: 预热与正式必须同参数, 换参数 = 重新冷启动
$ wrk -t8 -c400 -d10m --latency --timeout 2s \ -s order_bench.lua http://api.local/order # -t8: 8 线程×epoll; -c400: 400 条长连接 # 超过核数的 -t 只会让 wrk 自己打自己
Requests per second: 2403.85 [#/sec] (mean) Time per request: 83.200 [ms] (mean) # = c/RPS×1000 Time per request: 0.416 [ms] (mean, across all concurrent requests) # 关键: 83.2 = 200/2403.85×1000 — 是"一批"不是"一个"
# saturation 的一手来源: $ curl -s :8080/metrics | grep -E 'inflight|pool_wait' http_inflight_requests 1840 # 在途逼近容量 → 饱和预警 # 关键: 四件套齐了, 才叫可观测
首页压得动, 下单压不了 — POST body、随机 SKU、多用户 token 都要脚本化。wrk 内嵌 Lua: request() 每请求回调, done() 自定义报表。
-- order_bench.lua: 70% 查询 + 30% 下单, 请求级随机 SKU/用户 wrk.headers["Content-Type"] = "application/json" function request() if math.random(10) <= 3 then -- 30% 下单 wrk.method = "POST" wrk.body = string.format('{"sku_id":%d,"count":1,"uid":%d}', 1000 + math.random(50), math.random(1, 100000)) else wrk.method, wrk.body = "GET", nil -- 其余走查询 end return wrk.format() end function done(summary, latency) print(string.format("req=%d err_status=%d timeout=%d", summary.requests, summary.errors.status, summary.errors.timeout)) for _, p in ipairs({50, 90, 99, 99.9}) do print(string.format("p%g = %.1f ms", p, latency:percentile(p))) end end # $ wrk -t8 -c400 -d10m --latency --timeout 2s -s order_bench.lua http://api.local/order
errors.status 里是非 2xx/3xx 计数 — 吞吐判读前先看它是不是 0。
ab 输出是面试与评审高频题, 每一行都要读得出主语。
$ ab -n 50000 -c 200 -k -H "Accept-Encoding: gzip" http://api.local/list Concurrency Level: 200 Time taken for tests: 12.345 seconds Complete requests: 50000 # 全部完成 Failed requests: 0 # 非 0 先停下查, 别看后面了 Requests per second: 4050.26 [#/sec] (mean) # ← 吞吐 Time per request: 49.380 [ms] (mean) # ← RT×c, 不是 RT! Time per request: 0.247 [ms] (mean, across all concurrent requests) 90% 38 # ← 分位段, ab 自带 99% 102 # P99 = 102ms 100% 245 (longest request) # 判定: 4050 QPS @ P99 102ms, SLO 200ms → 达标; 余量 = 1 - 3000/4050 ≈ 26%
49.380 = 200/4050.26×1000 — 一批 200 个的墙钟时间; 平均 RT 认准 across 行。
容量评审不拍脑袋: 业务目标先翻译成机器语言, 再反推每一层的水位。
# 大促评审: 目标 5000 单/秒, 一单 7 个请求, 全链 P99 80ms qps_gateway = 5000 * 7 # → 35000 QPS, 这才是要压的数 concurrency = qps_gateway * 0.080 # → 2800: 压测并发下限 (Little) # 单台 wrk 打不动 2800 连接 → 4 台压测机 × -c 700 分片发压 # DB 侧: SQL 平均 15ms → 在途 SQL = 35000 × 0.015 db_inflight = 35000 * 0.015 # → 525 条在途 SQL 的连接需求 pool_per_node, db_nodes = 20, 30 # HikariCP: core×2+spindles≈20 → 600 ≥ 525 ✓ # 关键: 连接池不是越大越好 — 越大锁竞争越凶, 够在途数即可
这套推导让评审从"感觉要扩容"变成 35000/2800/525 三个数字的对话。
默认桶 (.005~10s) 在 SLO 附近没有刻度, P99 算出来永远是插值幻觉。
# 关键 1: le 桶边界必须细过 SLO — 目标 P99 800ms, # 默认桶 0.75~1s 之间只有一个刻度, 800ms 根本判不准 buckets: [.01, .025, .05, .1, .2, .3, .5, .7, .8, 1, 1.5, 2, 3] # 关键 2: 分位数必须"先聚合桶再算", 不能对各实例的 p99 求平均 # histogram_quantile(0.99, # sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) # 关键 3: series 数 = label 组合 × 桶数 — user_id 这类高基数 # 绝不能进 histogram, 否则 TSDB 先被压测打挂
桶从 14 个收到 13 个且贴住 SLO: 告警判定准了, series 还少了 8%。
压不上去先做炮台体检: CPU、fd、TIME_WAIT 三件套, 十有八九是它。
$ wrk -t16 -c2000 -d30s http://api.local/list # QPS 死活上不了 8000, P99 却只有 0.4ms — 系统毫发无伤? $ top -p $(pgrep wrk) # wrk 单线程 100%: -t16 在 8 核机上自己卷自己 $ ulimit -n # → 1024, -c 2000 直接 Too many open files $ ss -s | grep -i timewait # TIME_WAIT: 28106, 短连接把本地端口打光 # 处方四连: # 1) -t 改 8 (≤ 核数); 不够就加机器分片, 不加线程 # 2) ulimit -n 65535 (systemd 服务写 LimitNOFILE=) # 3) sysctl -w net.ipv4.tcp_tw_reuse=1 # 4) sysctl -w net.ipv4.ip_local_port_range="10240 65535"
改完同样 -c2000: QPS 8000 → 41000, "系统瓶颈"原来在压测机上。
容量不是压一次得出的, 是"抬一档、看两头 (QPS 与 P99)"找拐点。
#!/bin/bash # 每 90s 抬一档并发, 冷却 30s 防上一档队列残余污染下一档 for c in 100 200 400 800 1600; do wrk -t8 -c$c -d90s --latency http://api.local/list \ | tee "bench_c${c}.log" | grep -E "Requests/sec| 99%" sleep 30 done # 实测读数: # c=400 QPS 6100 P99 45ms ← 还在线性区 # c=800 QPS 6250 P99 1900ms ← QPS 只 +2%, P99 ×42: 过 knee 了 # 结论: knee≈6200, 上线容量 = 6200 × 0.7 ≈ 4300, 写进容量台账
从此扩容申请都引用这份曲线, 不再"感觉不够了"。
JMeter 适合录制回放, Locust 适合 Python 团队, k6 的 thresholds 天然配 CI: 不达标直接红。
import http from 'k6/http'; import { check } from 'k6'; export const options = { scenarios: { steady: { executor: 'constant-arrival-rate', // open-loop 恒速防 omission rate: 2000, timeUnit: '1s', preAllocatedVUs: 500, maxVUs: 2000, duration: '5m' }, }, thresholds: { // 不达标 → 退出码非 0 http_req_duration: ['p(95)<300', 'p(99)<800'], http_req_failed: ['rate<0.001'], }, }; export default function () { const r = http.get(`${__ENV.BASE}/list`); check(r, { 'status is 200': (r) => r.status === 200 }); }
合入主干每夜跑 5 分钟, 两起 N+1 与一起缓存未预热在发布前被拦。
单次压测噪声 ±15%: 拿最好一次汇报是自欺, 拿最差一次是冤枉系统。
from statistics import median # 4 轮同参数: 第 0 轮当预热丢弃, 只统计后 3 轮 runs = [parse(f"bench_c400_r{i}.log") for i in range(4)] samples = runs[1:] qps_list = [r["qps"] for r in samples] # [6120, 6180, 6250] p99_list = [r["p99"] for r in samples] # [44, 46, 47] report = { "qps": median(qps_list), # 6180 — 中位数抗离群 "p99": median(p99_list), # 46ms "spread": (max(qps_list) - min(qps_list)) / median(qps_list), # 2.1% } assert report["spread"] < 0.05, "run 间方差 >5% 必须重测: 环境不干净" # 报告必附环境快照: commit / 配置 diff / 机器规格 / 数据量级
执行这套纪律后, 两次"性能抖动"复盘都发现是压测机邻居在抢网卡。
索引高度、buffer pool 命中率、执行计划全随数据量变 — 造数必须与生产同量级。
-- 影子表: 结构与索引与生产一致, 不动生产数据 CREATE TABLE orders_bench LIKE orders; INSERT INTO orders_bench (user_id, sku_id, amount, created_at) SELECT user_id, sku_id, amount, created_at FROM orders WHERE created_at >= NOW() - INTERVAL 90 DAY LIMIT 120000000; -- 1.2 亿行, 与生产同量级 ANALYZE TABLE orders_bench; -- 统计信息对齐, 执行计划才一致 -- 压前自检: 两边 EXPLAIN 必须走同一个索引再开压 EXPLAIN SELECT * FROM orders_bench WHERE user_id = 9527 ORDER BY created_at DESC LIMIT 20;
重压后 P99 3ms → 480ms: 这才是上线后会发生的数字。
生产环境全链路压测的前提是压测流量"可识别、可隔离、可清理"。
// 入口中间件: 给压测流量染色, DAO 层按标记路由影子表 func LoadTestMark(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Load-Test") == "1" { ctx := context.WithValue(r.Context(), shadowKey{}, true) r = r.WithContext(ctx) } next.ServeHTTP(w, r) }) } // DAO 层: 染色流量读写 orders_shadow, 与真实数据物理隔离 func table(ctx context.Context) string { if isShadow(ctx) { return "orders_shadow" // 压测脏数据进不了生产表 } return "orders" } // 配套: 染色标全链透传 (MQ/RPC 都要带上), 第三方依赖按标记走挡板
大促前在生产打 2 倍峰值流量, 真实订单零污染, 缓存/连接池全程热态。
# 错: 平均 RT 50ms → 达标 ✓ # 对: P99 = 1800ms > SLO 200ms → 不达标 (1% ≈ 每分钟 600 人)
# 错: avg(histogram_quantile(0.99, ... by (pod))) → 455ms 幻觉 # 对: histogram_quantile(0.99, sum by (le) (rate(bucket[5m])))
# 错: "网关 3500 QPS → 撑 3500 单/秒" # 对: 3500 QPS ÷ 7 请求/单 = 500 TPS
# 错: 只汇报 Requests/sec 41015 # 对: 41015 × 10KB × 8 ≈ 3.2 Gbps → 先升万兆网卡再谈压测
# 错: Requests/sec 50000 → "吞吐翻倍!" # 对: err_status=30000 → 真实成功吞吐 20000, 立即回滚
# 错: Time per request: 83.2ms → "平均 83ms?" # 对: 0.416ms (across all concurrent) = 1000/RPS 才是平均 RT
# 错: "系统 QPS 80000" # 对: "下单链路: nginx 8000 / app 8000 / DB 22000"
# 错: wrk -t64 -c4000 (8 核压测机) # 对: -t8, 4 台 × -c1000 分片
# 错: ab 2400 vs wrk 41000 → "wrk 快 17 倍" # 对: ab -k 重测 39000 → 差距其实是连接模型, 不是服务
# 错: connect timeout → 判"服务挂了" # 对: ss -s 看 TIME_WAIT 28106 → tw_reuse=1 + 长连接
# 错: wrk: connect() failed: Too many open files # 对: ulimit -n 65535 再开压 (systemd: LimitNOFILE=)
# 错: wrk -d60s 跑一次就进 PPT # 对: 预热 5m 丢弃 → 正式 -d10m 连测 3 轮
# 错: ab -n 100000 http://svc/ # 对: 70% list + 25% detail + 5% order (k6 scenarios 加权)
# 错: 测试库 1000 行压出 P99 3ms # 对: 1.2 亿行 + ANALYZE 后重压 → 480ms 才是真话
# 错: capacity = knee 峰值 6200 # 对: capacity = 6200 × 0.7 ≈ 4300, 留排队余量
# 错: 1 轮 QPS 6800 → 汇报 "6800" # 对: 4 轮弃 1 → 6180 ± 2.1%, 附环境快照
# 错: 1 台压测机非要打出 10 万 QPS # 对: 4 台 × 2.5 万, 网关访问日志对账请求数
# 错: wrk 和服务跑同一台 8 核 # 对: 压测机独立部署, 核数/网卡对等
# 错: 默认桶 …0.75, 1 → SLO 800ms 落在 250ms 大缝里 # 对: 自定义桶加 .7 .8 .9 → SLO 附近分辨率 100ms
# 错: "1000 并发 = 线程池 max 1000" # 对: c = 5000×0.08 = 400, worker 池 256 足够