极限发压工具设计(多长连接 + 发压任务)
发压机瓶颈顺序 · 连接模型 · open/closed model · coordinated omission · 单机理论极限 · 分布式发压
一句话结论
极限发压 = 先榨干发压机自身:FD → 临时端口 → 内存 → conntrack → 单核 syscall,逐堵墙翻越;再用 open model + HdrHistogram 保证数字不撒谎;最后横向扩成分布式集群。被测系统的上限,只有在发压机不是瓶颈时才有意义。
场景问题
打个比方:你想测一辆赛车的极速,却用一辆限速 60 的代步车去追它——追不上不代表赛车跑不快,只代表你的车先到极限了。发压机就是那辆追车:连接数、FD、临时端口、CPU、网卡队列,每一项都是它的"限速器"。极限发压,就是把这些限速器一个个拆掉,直到追车本身不再是瓶颈,才能真正量出被测系统的上限。
类比失效边界:赛车追车是一次性冲刺,压测是持续稳态——发压机不仅要跑得快,还要在高负载下稳定维持连接、精确控速、正确计时。一次性冲刺可以忽略 TIME_WAIT 堆积和 GC 停顿,持续压测不行。
实时对战后台(gRPC 长连接、帧同步、状态同步)与互联网无状态服务的压测侧重点根本不同:
| 维度 | 互联网无状态(HTTP/1) | 游戏长连接(gRPC/TCP) |
|---|---|---|
| 连接生命周期 | 短连接/连接池,建连开销可摊薄 | 长连接,连接数即并发上限之一 |
| 发压机瓶颈先撞 | 临时端口(每请求建连) | FD 上限、内存/每连接开销 |
| 并发扩展方式 | 加连接数 | HTTP/2 多路复用:连接内加 stream |
| 延迟测量陷阱 | closed model 掩盖过载 | 同左,且 stream 排队更隐蔽 |
与 接入网关(长连接接入层) 形成攻防对照:接入层讲如何承载长连接,本文讲如何压满它。极限低延迟的测量方法见 全栈极限低延迟。
实现方案
① 发压机内部数据流
② 发压机瓶颈拆解:逐堵墙翻越
瓶颈出现顺序
单机长连接数从 1 万推到 100 万,会按以下顺序撞墙:
第一堵墙:文件描述符(FD)上限
每条 TCP 连接消耗一个 FD。默认 ulimit -n 通常为 1024(进程级)或 65535(系统级),远不够。
# 查当前限制
ulimit -n
cat /proc/sys/fs/nr_open # 单进程硬上限
cat /proc/sys/fs/file-max # 系统全局上限
# 突破:/etc/security/limits.conf 或 systemd LimitNOFILE
ulimit -n 1048576
sysctl -w fs.nr_open=2097152
sysctl -w fs.file-max=2097152
第二堵墙:临时端口耗尽(四元组空间)
每条 TCP 连接由 (src_ip, src_port, dst_ip, dst_port) 四元组唯一标识。对同一目的 (ip, port),可用临时端口约 2.8 万~6.4 万(net.ipv4.ip_local_port_range)。
# 查/扩临时端口范围
cat /proc/sys/net/ipv4/ip_local_port_range # 默认 32768 60999 ≈ 2.8万
sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 扩到 ~6.4万
# 突破上限:多目的 IP(被测侧多 VIP)或多源 IP(发压机多网卡/IP alias)
# 每增加一个目的端口或目的 IP,四元组空间翻倍
第三堵墙:内存 / 每连接开销
每条 TCP 连接在内核侧占用 sk_buff 发送/接收缓冲(默认各 ~87KB,可调小);用户态每个 goroutine/协程栈初始 ~2–8KB,百万连接需 GB 级内存。
sysctl -w net.ipv4.tcp_rmem="4096 4096 16384" # 压缩接收缓冲
sysctl -w net.ipv4.tcp_wmem="4096 4096 16384" # 压缩发送缓冲
第四堵墙:conntrack 表满
Linux 默认开启连接跟踪(NAT/iptables 依赖),每条连接占一个 conntrack entry,默认上限约 65536。
sysctl -w net.netfilter.nf_conntrack_max=2097152
# 或:发压机不做 NAT,直接关 conntrack(需确认无 iptables 规则依赖)
第五堵墙:TIME_WAIT 堆积
短连接场景(每请求建连)主动关闭方进入 TIME_WAIT,默认等待 2×MSL(约 60s),期间四元组不可复用。长连接压测不常见,但连接频繁重建时会触发。
sysctl -w net.ipv4.tcp_tw_reuse=1 # 允许复用 TIME_WAIT 连接(客户端侧)
sysctl -w net.ipv4.tcp_fin_timeout=15 # 缩短 FIN_WAIT2 超时
第六堵墙:单核 syscall 开销
连接数和 FD 都够了,但单核每秒能处理的 send/recv syscall 有上限(约数十万次/核)。高并发下 syscall 本身成为 CPU 瓶颈。突破手段见「极限手段阶梯」章节。
瓶颈-信号-手段速查表
关于数字
下表数字为业界数量级,强依赖内核版本、硬件与负载,非本环境实测,仅供方向参考。
| 瓶颈 | 可观测信号 | 突破手段 |
|---|---|---|
| FD 上限 | Too many open files 报错;/proc/sys/fs/file-nr 接近上限 | 调 ulimit -n、fs.nr_open、fs.file-max |
| 临时端口耗尽 | connect: Cannot assign requested address;ss -s 显示大量 TIME_WAIT | 扩 ip_local_port_range;多目的 IP/端口;多源 IP |
| 内存/每连接 | OOM;free -h 可用内存耗尽;swap 飙升 | 缩 tcp_rmem/wmem;减协程栈;用 epoll 而非 goroutine-per-conn |
| conntrack 满 | nf_conntrack: table full, dropping packet 内核日志 | 调 nf_conntrack_max;关 conntrack |
| TIME_WAIT 堆积 | ss -s TIME_WAIT 数量持续增长 | tcp_tw_reuse=1;长连接复用 |
| 单核 syscall | 发压机某核 CPU 100%;perf top 显示 __sys_sendmsg 占比高 | 绑核+多进程;批量 syscall;io_uring |
| 网卡队列 | ethtool -S 显示 rx_missed_errors;单队列 CPU 100% | RSS 多队列;irqbalance 或手动中断亲和 |
瓶颈在发压机还是被测系统?
压测 QPS 上不去时,先做对照实验确认瓶颈方,避免把发压机上限误当被测系统上限。由弱到强五条:
- 回环基线:发压机压自身(
localhost上的 no-op 服务),排除网络因素,测发压机纯发送能力上限。要求这个上限至少是目标压力的 3~5 倍——只有留出余量,被测系统的数字才有资格谈。 - 观测发压机资源:每一个核的 CPU 是否都低于 60~70%(只看平均值会被单核打满掩盖)?网卡 TX 是否饱和(
sar -n DEV 1)?FD/端口/conntrack 是否有报错(dmesg)? - 观测被测系统资源:CPU、内存、网卡 RX 是否有余量?若被测系统资源空闲而 QPS 上不去,瓶颈在发压侧。
- 换 no-op 服务端对照:把被测服务换成同协议的空实现(收包即丢弃或 echo)。QPS 大幅上升 = 原瓶颈在被测侧;几乎不变 = 瓶颈在发压机,之前的数字全部作废。
- 多发压机线性度对照(最硬的证据):加一台发压机,总 QPS 接近线性翻倍 = 单机发压机是瓶颈;停滞在原水平 = 被测系统饱和。这条不依赖对任何资源指标的解读,最难反驳。
schedule lag:发压机的自证指标
上面五条都是外部对照,还需要一条发压机自证指标。发压机内部同时记录计划发送时刻与实际发送时刻,二者之差即 schedule lag:
lag 稳定在低位(如 < 1ms) = 发压机跟得上,本轮数据可用
lag 单调上升 = 发压机自身饱和,本轮数据无效,直接废弃
关键是把它做成硬门禁:超阈值就自动把该轮结果标记 invalid,而不是事后靠人判断。没有这个门禁,一份发压机饱和产出的报告和一份正常报告长得一模一样——这是压测流程里最容易缺失的一环。
③ 连接模型与发压引擎
长连接池 vs 每请求建连
| 维度 | 每请求建连(短连接) | 长连接池(固定 N 条) |
|---|---|---|
| 临时端口消耗 | 每请求消耗一个,高 QPS 下极易耗尽 | 固定消耗 N 个,与 QPS 无关 |
| 建连开销 | TCP 三次握手 + TLS 握手每次都付 | 一次性摊薄,复用连接 |
| 适用场景 | HTTP/1 短请求、测建连能力 | gRPC/WebSocket 长连接压测 |
| 发压机瓶颈先撞 | 临时端口 → TIME_WAIT | FD 上限 → 内存/每连接开销 |
ghz 的 connections × concurrency 分层模型
ghz 是 gRPC 压测事实标准,其核心参数:
ghz --connections=100 --concurrency=1000 \
--total=100000 --rps=5000 \
--proto ./service.proto --call pkg.Service/Method \
0.0.0.0:50051
--connections N:建立 N 条 TCP 长连接(消耗 N 个临时端口)。--concurrency M:总并发 worker 数,每个 worker 持有一条连接引用,轮询分配到 N 条连接上。- 实际并发 stream 数 = M,分布在 N 条连接上,每条连接承载约 M/N 个并发 stream。
gRPC/HTTP2 多路复用与临时端口约束
HTTP/2 在单条 TCP 连接上支持多个并发 stream(SETTINGS_MAX_CONCURRENT_STREAMS,服务端默认通常 100~1000)。这意味着:
- 临时端口不是并发瓶颈:100 条连接 × 每连接 100 stream = 10000 并发,只消耗 100 个临时端口。
- 连接数受服务端 accept 能力约束:连接数过多会打满服务端 accept backlog;连接数过少则每连接 stream 数超过服务端限制,请求被拒。
- goroutine-per-stream:ghz 内部每个 worker 是一个 goroutine,持有 gRPC ClientConn(连接池),发请求时从池中取连接发 stream。
发压机 被测 gRPC 服务
goroutine-1 ─┐
goroutine-2 ─┤─ conn-1 (stream 1,2,3...) ──► listener
goroutine-3 ─┤
goroutine-4 ─┤─ conn-2 (stream 1,2,3...) ──► listener
... ─┘
配比建议(数量级,非实测):
| 场景 | connections | concurrency/conn |
|---|---|---|
| 测服务端吞吐上限 | 10~50(够用) | 50~200(压满 stream) |
| 测建连能力 | 逐步增加 | 1(每连接单 stream) |
| 测尾延迟 | 少量(5~10) | 低并发(避免排队掩盖) |
gRPC 长连接维护:subchannel / keepalive / GOAWAY / 重连
前一节的 --connections=100 只是压测入参的表象。一次 grpc.Dial(...) / 一个 ghz --connections=N,运行时展开成的东西远不止 N 条 TCP——真正决定"400 万 PCU 长连接怎么活下来"的,是 ClientConn 底下 subchannel/keepalive/GOAWAY 这套生命周期。承载侧(接入网关)与发压侧的对称视角在这里合流:发压端必须模拟真实客户端的长连接生命周期,被测端的行为才是真的。
四层结构:ClientConn ≠ TCP ≠ Stream
ClientConn (grpc.Dial 一次)
├── Resolver(DNS / xDS / 自定义)→ 返回 M 个后端地址
├── LB Policy (pick_first / round_robin / grpclb)
└── Subchannel × M ← 每个后端地址一个 subchannel
└── Transport (1 条 TCP + 1 条 HTTP/2)
└── Stream × N ← HTTP/2 多路复用
- pick_first:resolver 返回 M 个地址,M 个 subchannel 只有 1 个进入 READY、其它待命;对外看是 1 条 TCP。
- round_robin:M 个 subchannel 全部进入 READY,请求轮询分发到各 subchannel;对外看是 M 条 TCP,M 通常等于 DNS 返回的地址数。
- 服务端滚动或 headless Service 扩缩容时,resolver 会推
NewAddressList,subchannel 集合随之增删——所以"临时端口的实际消耗"是 subchannel 数、不是 ClientConn 数。
一句话记忆
一条 ClientConn 打开的 TCP 条数 = 后端地址数 × LB 策略选中数;发压时 --connections=100 若指向的是一个 headless Service 有 20 个 Pod,用 round_robin 就是 100 × 20 = 2000 条 TCP。这也是 §② 里"端口撞墙"的实际来源。
Keepalive PING:双向探活
TCP 层的 SO_KEEPALIVE 默认几十分钟才发一次,游戏后台等不起。gRPC 走的是 HTTP/2 层的 PING 帧:
| 侧 | 参数 | 语义 | 触发 GOAWAY 的边界 |
|---|---|---|---|
| Client | keepalive.ClientParameters.Time | 空闲 T 秒后发一次 PING | 服务端若认为 T 太小会回 GOAWAY(ENHANCE_YOUR_CALM) 断连 |
| Client | keepalive.ClientParameters.Timeout | 发出 PING 后 X 秒未收 ACK 则断连 | — |
| Client | keepalive.ClientParameters.PermitWithoutStream | 无活跃 stream 时是否也发 PING | 默认 false(老 grpc-go),压测/长驻场景通常要开 |
| Server | keepalive.ServerParameters.Time / Timeout | 服务端主动探活客户端 | 客户端不响应即断 |
| Server | keepalive.EnforcementPolicy.MinTime | 允许客户端 PING 的最小间隔 | 客户端 PING 比这更频繁就发 ENHANCE_YOUR_CALM |
发压侧最常见的坑:客户端 Time 设得比服务端 MinTime 小(比如 client 5s、server 强制 ≥10s),第一批连接建好 5s 后被服务端集体断掉,QPS 曲线掉一半——并不是被测系统抖,是发压侧参数没对齐。
GOAWAY 三段式:滚动升级下的优雅下线
服务端准备重启(发布/滚动/MaxConnectionAge 到点)时,gRPC 走三段式让在线连接优雅迁移:
① server 发第一次 GOAWAY(last_stream_id = 2^31-1, "graceful")
↓ 客户端收到后:老 stream 继续跑、新 stream 走这条连接的会被拒
↓ 但新 RPC 会由 LB 自动选到下一个 subchannel
② server 等 MaxConnectionAgeGrace(如 10s)
③ server 发第二次 GOAWAY(last_stream_id = 实际的最后一个) + FIN
↓ 客户端触发 subchannel 重连(走 backoff+jitter)
关键参数(keepalive.ServerParameters):
MaxConnectionAge:一条连接的最大存活时长(例 30min);到点服务端主动 GOAWAY,把长连接周期性地强制刷新,让流量在滚动升级 / 后端扩缩容时不会全部黏在老 Pod 上。MaxConnectionAgeGrace:GOAWAY 到强制 FIN 之间的宽限期(例 10s),期间老 stream 跑完自然收尾。- 默认这两个都是 infinity——生产要显式设,且加 jitter(例
MaxConnectionAge = 30min ± 5min),否则同一批建的连接会在同一秒集体过期,触发重连风暴。
面试问 "服务端滚动升级、400w PCU 长连接怎么办" 的标准答法:GOAWAY 三段式 + MaxConnectionAge 带 jitter + 客户端 backoff+jitter + LB 探活驱逐坏 subchannel。发压端也必须模拟这个流程——ghz 不管这些,自研 harness 至少要能被动接受 GOAWAY 并重连。
重连 backoff:防重连风暴
subchannel 从 READY → TRANSIENT_FAILURE 时(TCP RST / GOAWAY / keepalive timeout),gRPC 走指数退避重连:
默认(grpc-go connectivity/backoff):
BaseDelay = 1.0s
Multiplier = 1.6
Jitter = 0.2 ← 关键:±20% 随机抖动
MaxDelay = 120s
第 1 次重试:~1s ± 0.2s
第 2 次重试:~1.6s ± 0.32s
第 3 次重试:~2.56s ± 0.51s
...
第 N 次收敛:≤ 120s
jitter 的作用:没有 jitter 时,服务端一次抖动会让 100 万条连接同时在 T+1s 重连——瞬间 100 万个 SYN 打过来,服务端 accept backlog / SYN queue 直接爆掉,形成雪崩。加 ±20% jitter 后重连时刻均匀散在窗口内,服务端压力被抹平。这与 状态服务恢复 里"惊群与随机抖动"的思路一致。
健康检查:驱逐坏 subchannel
gRPC 有内置健康协议 grpc.health.v1.Health,服务端起一个 Health handler、客户端在 service config 里配 "healthCheckConfig": {"serviceName": "..."},LB 每隔一段调 Check RPC:
- 返回
SERVING→ subchannel 保持 READY - 返回
NOT_SERVING/ RPC 超时 → subchannel 打成 TRANSIENT_FAILURE,从 LB 池摘除 - 恢复后自动回到 READY 池
发压场景要打开这层,否则被测系统某个 Pod 挂掉但 TCP 还没断(比如 handler 挂但 accept 线程还活),发压端会把请求继续往死 Pod 灌,观测到的延迟曲线是"被测挂掉后延迟反而下降"(因为 Pod 直接错误返回,没跑业务逻辑),完全误导。
channelz:运行时可观测
不用去猜连接状态——grpc-go 内置 channelz 服务:
| 接口 | 看到什么 |
|---|---|
channelz.GetTopChannels | 所有 ClientConn 的列表 + 每个 ClientConn 当前状态 |
channelz.GetChannel(id) | 单个 ClientConn 内的 subchannel 列表 |
channelz.GetSubChannel(id) | subchannel 状态(READY / CONNECTING / TRANSIENT_FAILURE / IDLE)、绑定的 socket |
channelz.GetSocket(id) | TCP 层:本地/远端地址、收发字节数、流数、上次读/写时间 |
channelz.GetServer(id) / GetServerSockets | 服务端视角:所有 accept 到的连接 |
发压时最常用:"QPS 打不上去但没报错" → 查 channelz 看多少 subchannel 处于 TRANSIENT_FAILURE、多少 stream 卡在 flow-control window 打满。命令行工具 grpcdebug 直接 dump channelz 输出,比自己写代码调 API 快得多。
免责
上文所有 gRPC-go 接口名、参数名以官方文档 v1.65+ 为准;跨版本可能有微调(例如 WithSharedWriteBuffer 在 v1.60 前后行为演变过),实际使用先查当前版本的 GoDoc。
unary vs bidi stream:ghz 的覆盖边界
ghz 名义上支持 streaming method(--call 指向 bidi 方法、-d 给消息数组),但其语义是:每个「request」= 开一条 stream,把 -d 里的消息全发完,关闭 stream,记一次延迟。本质仍是请求-响应模型。而帧同步的真实形态是长期驻留 stream:连接建立后活几分钟到几十分钟,按固定 tick(如 33ms/帧)持续双向收发。
两者压出来的不是同一个系统:
| 帧同步的真实压力 | unary / 短 stream 压测能否覆盖 |
|---|---|
| 服务端每连接长期状态(房间、玩家、帧缓冲、AOI) | 不能。unary 无状态,压完即走,跑不出状态膨胀 |
| 广播扇出(房间 N 人,一帧进 N 帧出) | 不能。unary 是 1:1,没有扇出放大 |
| HTTP/2 单连接内 stream 队头阻塞与 flow-control window 打满 | 不能。stream 生命周期太短,window 撑不满 |
| 帧率保持能力、帧间抖动 jitter、丢帧与乱序 | 不能。unary 只有独立 RT 分布,没有「节奏」这个维度 |
| 长时间在线的内存增长与 GC 抖动累积 | 不能。短 stream 跑不出泄漏与累积效应 |
结论:必须分两套,不能混报。
- 控制面 unary 用 ghz:登录、匹配、创建房间、拉配置。这些确实是 unary,ghz 是合适工具,走 open model(
--rps)。 - 数据面 bidi 帧同步用自研 harness:
--connections条长连接常驻,每条按固定 tick 推帧,指标不是 RT 而是帧节奏(见下)。
把控制面的 unary 数字当数据面帧同步的结论,是压测报告最常见的越界。
帧同步 harness 的指标模型
帧同步的核心指标不是 RT,而是节奏保持能力——每帧是否落在预算窗口内到达:
| 指标 | 定义 | 为什么 RT 测不出来 |
|---|---|---|
| 帧到达偏差 | (实际到达时刻 − 计划 tick 时刻)的分布 | RT 只测单次往返,不测相对固定节拍的漂移 |
| 帧率保持率 | 实际帧率 / 目标帧率(如 30fps) | 服务端降频发帧时每帧 RT 可能仍然很低 |
| 丢帧率 / 乱序率 | 序号缺失、序号倒序的比例 | 丢掉的帧根本不产生 RT 样本 |
| 广播扇出延迟 | 一帧进入到房间内最后一人收到 | RT 只看自己那条 stream,看不到扇出尾部 |
| 稳态驻留时长 | 连接维持不掉线的时长 | 短 stream 压测无此维度 |
丢帧是 Coordinated Omission 的变体
帧同步里「服务端没发出的帧」和 CO 里「worker 没发出的请求」是同一类错误:没产生样本的事件不进直方图。所以 harness 必须按计划 tick 逐帧核对序号,而不是只统计收到的帧的延迟——否则丢掉一半帧,延迟曲线反而更漂亮。
Open Model vs Closed Model
这是发压工具最容易踩的陷阱,直接决定压测数字是否有意义:
| 维度 | Closed Model(固定并发) | Open Model(固定到达率) |
|---|---|---|
| 工作方式 | 固定 N 个 worker,每个发完等响应再发下一个 | 按固定速率(RPS)发请求,不等响应 |
| 被测变慢时 | worker 阻塞等待,自动降速,QPS 下降 | 继续按目标 RPS 发,请求在队列积压 |
| 延迟表现 | 被测变慢 → worker 阻塞 → QPS 降 → 延迟"看起来稳" | 被测变慢 → 队列积压 → 延迟飙升,暴露真实过载 |
| 适合测什么 | 系统在固定并发下的稳态行为 | 系统在固定到达率下的排队/崩溃点(更接近真实流量) |
| ghz 对应参数 | --concurrency(不设 --rps) | --rps(固定到达率) |
结论:测系统上限必须用 open model(
--rps)。closed model 会因被测变慢而自动降速,掩盖真实过载,产出乐观的假数字。
④ 测量正确性:让数字不撒谎
Coordinated Omission(协调遗漏)
这是压测工具最常见的系统性误差来源,会让 P99 看起来比真实值低一个数量级。
成因:发压 worker 在一次慢请求上阻塞时,"应该发出但没发出"的后续请求被跳过,它们的等待时间从未被记录。结果:直方图里只有"发出去的请求"的延迟,慢请求期间积压的等待时间凭空消失。
时间轴: t=0 t=1 t=2 t=3 t=4 t=5
计划发: req1 req2 req3 req4 req5 req6
实际发: req1 [req1 慢,阻塞 3s] req2 req3
记录延迟: 3s(req1) 1s(req2) 1s(req3) ← P99 看起来 3s
真实延迟: req2 等了 3s 才发出,实际体验 = 3+1 = 4s
req3 等了 4s 才发出,实际体验 = 4+1 = 5s ← 被遗漏
修正方法:记录意图发送时刻(按计划到达时刻),而非实际发送时刻。延迟 = 响应收到时刻 − 计划发送时刻。ghz 的 --load-schedule 配合 open model 可近似实现;HDR Histogram 的 recordValueWithExpectedInterval 提供标准 CO 修正。
HdrHistogram 与尾延迟
普通直方图(固定桶宽)在高动态范围下精度差:1ms 和 1000ms 用同一桶宽,要么桶太多浪费内存,要么精度不够。
HdrHistogram(High Dynamic Range Histogram):对数桶宽,在整个动态范围内保持相对精度(默认 0.1%),内存固定(约 ~40KB/实例),支持无锁并发写入与跨实例 merge。
关于数字
下方延迟数字为业界数量级,非本环境实测。
| 分位数 | 含义 | 为什么重要 |
|---|---|---|
| P50(中位数) | 一半请求比这快 | 反映"普通体验",但掩盖尾部 |
| P99 | 99% 请求比这快 | 1% 用户的最差体验;游戏帧预算关键指标 |
| P999 | 99.9% 请求比这快 | 高并发下每秒都有人踩到;GC/调度抖动在这里暴露 |
| Max | 最坏单次 | 受测量时长影响大,参考价值有限 |
时钟源选择:
| 时钟源 | 特点 | 适用场景 | 陷阱 |
|---|---|---|---|
CLOCK_MONOTONIC | 单调递增,不受 NTP 回拨影响 | 单机延迟测量首选 | 跨机不可比较(各机起点不同) |
CLOCK_REALTIME | 墙钟,可跨机比较 | 分布式发压时间对齐 | NTP 调整可能回拨,导致负延迟 |
| RDTSC | CPU 周期计数,纳秒级精度,无 syscall 开销 | 极低开销高精度测量 | 跨核不一致(需 RDTSCP);变频 CPU 下周期≠时间 |
分位数聚合:不可算术平均
多 worker / 多机结果合并时,P99 不能算术平均:
worker-1: P99 = 10ms
worker-2: P99 = 100ms
错误做法: 平均 P99 = 55ms ← 数学上无意义
正确做法: 合并原始分布再取分位
正确方法:
- HdrHistogram merge:各 worker 维护独立 HdrHistogram 实例,汇总时调用
add()/merge()合并原始分布,再从合并后的直方图取分位数。 - t-digest:流式分位数估计,支持增量合并,内存占用更小,适合分布式场景。
从 gRPC 到 ghz:度量 bidi 的三段延迟
前面 §③ 的 unary vs bidi stream 小节结论是"控制面 ghz、数据面自研 harness",但没给操作路径——具体要测什么、在哪一层埋点、ghz 到底覆盖到哪。这一节把它讲透。
三段延迟锚点
一条 RPC 从"想发"到"完全收到",在栈里有三个可测量层次:
| 层 | 起点 → 终点 | 可用工具 | 门槛 |
|---|---|---|---|
| L1 syscall 层 | sendmsg(2) 陷入内核 → recvmsg(2) 返回 | eBPF (bpftrace) / strace -T / tcpdump | 需内核权限,跟应用绑不紧(拿不到 stream id) |
| L2 gRPC 层 | SendMsg 返回 → RecvMsg 返回 | grpc-go stats.Handler | 应用代码里注册即可,最实用 |
| L3 应用 ACK 层 | 业务上"消息已被对端确认"(如帧号 N 被服务端广播到房间所有人) | 业务打点 / 自研 harness | 需要业务参与,能测扇出与端到端 |
结论:ghz 从设计上停在 L2 但只测"一次 stream 的 request-response"这一段;stats.Handler 也在 L2,但能精细拆到子毫秒;L3 的扇出尾延迟只有自研 harness 能碰。
ghz 对 bidi 的语义边界
ghz 支持 --call 指向 bidi 方法、-d '[{msg1},{msg2},...]' 给消息数组、--stream-interval 控制 stream 内两条消息之间的间隔、--stream-call-count 控制每条 stream 发多少条消息,但组合起来的运行时行为是:
每一次 ghz 的"request"(=每一份延迟样本):
① 从 ClientConn 池取一条连接
② 开一条新 stream(HTTP/2 HEADERS 帧)
③ 按 --stream-interval 发 --stream-call-count 条消息
④ 关闭 stream(HTTP/2 END_STREAM 帧)
⑤ 收到最后一条响应 → 记录一次延迟(stream 开到关的墙钟差)
本质仍是请求-响应模型,只是把 unary 里的"一发一收"扩成了"多发多收"。ghz 因此测不到(重复 §③ 的边界并加实证):
- 长驻 stream:一条 stream 活 30 分钟按 tick 推帧——ghz 每份样本都开新 stream,跑不出长驻状态。
- GC 抖动累积:短 stream 用完即扔,观察不到几十分钟后 Go runtime heap 增长导致的 STW 变长。
- 广播扇出:ghz 单 stream 单向视角,没有"房间"的概念,测不到"一帧进 N 帧出"。
- HTTP/2 flow-control window 打满:
SETTINGS_INITIAL_WINDOW_SIZE默认 64KB,短 stream 的消息量根本用不完;长驻 stream 才可能因为服务端不WINDOW_UPDATE被卡住。
所以"用 ghz 压过帧同步了" ≠ "帧同步被压过了"——面试反问时的杀手锏。
grpc-go stats.Handler:应用层精细埋点
想在生产 gRPC 服务里补埋点、把 L2 拆成"上行网络 / 服务端处理 / 下行网络",用不着改业务代码——grpc.WithStatsHandler / grpc.StatsHandler 注册一个 stats.Handler,HandleRPC 方法在每次 RPC 生命周期依次收到 6 类事件:
// 客户端与服务端都可注册;下面伪代码强调时间戳可拿到的位置
type MyHandler struct{}
func (h *MyHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {
switch v := s.(type) {
case *stats.Begin: // ① RPC 开始,v.BeginTime = 客户端 SendMsg 之前 / 服务端进 handler 之前
case *stats.OutHeader: // ② 头发送完(客户端)
case *stats.OutPayload: // ③ 一次 SendMsg 完成,v.SentTime 精确到发送这一条 msg
case *stats.InHeader: // ④ 头收到(客户端)
case *stats.InPayload: // ⑤ 一次 RecvMsg 完成,v.RecvTime 精确到收到这一条 msg
case *stats.End: // ⑥ RPC 结束,v.EndTime;含 v.Error
}
_ = v
}
- 客户端注册 → 拿到
Begin.BeginTime/OutPayload.SentTime/InPayload.RecvTime/End.EndTime。 - 服务端注册 → 拿到
Begin.BeginTime(进 handler 前)/InPayload.RecvTime(收到请求)/OutPayload.SentTime(发出响应)/End.EndTime。 - 客户端
SentTime与服务端RecvTime之差 ≈ 上行网络延迟(依赖跨机时钟同步,见 §⑥"多机时钟同步")。 - 服务端
RecvTime与SentTime之差 = 服务端处理耗时(单机内不依赖同步,可信)。
stats.Handler 是官方稳定接口、非侵入、开销可控(HandleRPC 是同步回调,写自己的 histogram 别加锁),比 unary interceptor 更适合埋点——interceptor 只能拿到入口/出口两个点。
自研 bidi harness:4 时间戳最小契约
如果你要自己写压测 harness(不是用 ghz),至少要记 4 个时间戳才能拆出关键分段:
| 时间戳 | 来源 | 为什么少了不行 |
|---|---|---|
planned_send_ts | open model 计划的发送时刻(比如 tick T = start + N × 33ms) | 少了它 → 丢 CO 修正,尾延迟被 worker 阻塞吞掉(见 §④ CO) |
actual_send_ts | 应用层 SendMsg 返回 / stats.OutPayload.SentTime | 少了它 → 无法算 schedule lag(§②),也无法拆"发压机自己延迟 vs 网络延迟" |
server_recv_ts | 服务端 stats.InPayload.RecvTime(或 handler 入口) | 少了它 → 无法把 L2 拆成"上行网络 vs 服务端处理" |
room_last_recv_ts | 广播场景:房间内最后一个客户端 InPayload.RecvTime | 少了它 → 完全测不到扇出尾延迟——这是帧同步真正卡人的指标 |
从这 4 个时间戳可以直接推出 4 段延迟:
① CO 修正后端到端延迟 = client_recv_ts − planned_send_ts // 用户真实感知
② 服务端处理耗时 = server_send_ts − server_recv_ts // 业务优化空间
③ 上行网络耗时 ≈ server_recv_ts − actual_send_ts // 需 NTP/PTP,见 §⑥
④ 扇出尾延迟 = room_last_recv_ts − server_send_ts // 房间越大越发散
多加一个 client_recv_ts(自己收到响应的时刻)其实是必需的,但它由 harness 内建,通常和 histogram record 位置合并,不单独强调。"最少 4 个"= 少一个就丢一整段可拆的延迟。
三工具覆盖矩阵
面试问"你打算怎么测帧广播延迟"时,一张表比一段话有力:
| 想测的延迟段 | ghz | grpc-go stats.Handler | 自研 bidi harness |
|---|---|---|---|
| syscall 层(send/recv 到内核) | ✗ | ✗(需 eBPF/tcpdump 旁路) | ✗ 同左 |
| L2 gRPC SendMsg → RecvMsg | ✓(unary + 短 stream) | ✓ 精细分段 | ✓ 必需 |
| 上行网络耗时(客户端发 → 服务端 InPayload) | ✗ | ✓(需跨机时钟) | ✓ 同 |
| 服务端处理耗时(InPayload → OutPayload) | ≈ ghz RT 减 wire | ✓ 精确 | ✓ 同 |
| CO 修正后的端到端 P99 | ✗(无 planned_ts) | ✗ 同左 | ✓ 唯一 |
| 长驻 stream 内累积泄漏 / GC 抖动 | ✗ | ✗ | ✓ 唯一 |
| 帧广播扇出延迟(房间最后一人) | ✗ 单 stream 视角 | ✗ 单连接视角 | ✓ 唯一能测 |
一句话记忆
扇出尾延迟只有自研 harness 能测——因为它跨连接、跨 stream、需要业务侧"房间"的概念,任何通用工具都碰不到这一层。这也是承载 400w PCU 帧同步为什么必须自研压测的根本原因。
报哪一侧的 P99:端到端 RT vs 服务端处理耗时
对外报发压机侧端到端 RT(用户感知的就是端到端),但必须同时报服务端处理耗时——只有一个数字时无法定位问题在哪。
客户端 RT 与服务端 handler 耗时之间的差值构成:
客户端 RT = 服务端 handler 处理耗时 ← 服务端 span 可直接测
+ 网络双向 RTT(wire time)
+ 发压机内核发送队列排队
+ 被测机内核接收队列 + accept backlog 排队
+ 服务端 goroutine 调度等待(进 handler 之前)
+ HTTP/2 stream flow-control window 等待
+ TLS 加解密(两侧各一次)
+ 响应到达后发压机读 socket 与回调处理的调度延迟
+ 发压机自身 GC STW / 调度抖动
+ 两侧时间戳打点位置差
三层打点把差值切开:
| 层 | 打点位置 | 减去下一层得到 |
|---|---|---|
| L1 客户端 RT | 发压机计划发送时刻 到 收到响应 | 减 L2 = wire RTT + 发压机侧开销 |
| L2 服务端 socket 层 | eBPF 挂 tcp_recvmsg/tcp_sendmsg | 减 L3 = 服务端内核排队 + 调度 + flow control |
| L3 服务端 handler span | handler 入口 到 出口 | 纯业务处理耗时 |
差值大头在哪,优化方向完全不同:大头在 L3 就优化业务;在 L2 就调并发与 backlog;在 wire 就看网络拓扑;在发压机侧则数字本身不可信,先修发压机。
发压机被压满时,测得延迟偏大还是偏小
方向取决于打点起点与压测模型,两种组合结论相反:
| 组合 | 偏向 | 机制 |
|---|---|---|
| open model + 记录计划发送时刻 | 系统性偏大 | 发压机 CPU 饱和后:请求实际发出晚于计划(起点是计划时刻,这段等待被算进 RT)、响应从内核读出被延后、打点本身被调度延后、GC STW 期间测量停摆。被测系统毫无变化,P99 却涨了——伪劣化,会导致去优化不存在的瓶颈 |
| closed model + 记录实际发送时刻 | 系统性偏小 | ① Coordinated Omission:worker 阻塞时「本该发出没发出」的请求从未被记录,恰恰是最该进尾部的那批消失了;② closed model 自动降速:被测变慢导致 worker 阻塞、压力自动下降,把想测的过载现象自己消掉了 |
两者同时存在时的典型表现:P50 偏大(发压机开销进了每次测量),P99 相对真实值偏小(最坏的那批从未被记录)。 这是最危险的组合——报告读起来像「平均慢一点、尾部还行」,生产上尾部随时爆。
防护三件事:① open model + 计划发送时刻打点;② HdrHistogram 的 recordValueWithExpectedInterval 做 CO 修正;③ schedule lag 硬门禁(见「schedule lag:发压机的自证指标」)。报 P99 时必须附发压机资源水位——不带水位的 P99 是无法验证的断言。
⑤ 单机理论极限手段阶梯
当调参(FD/端口/conntrack)已完成,QPS 仍受限,按以下阶梯逐步升级:
关于数字
下表所有数字为业界数量级,强依赖硬件、内核版本与负载,非本环境实测。
阶梯一:调参基线(零代码成本)
# FD
ulimit -n 1048576
sysctl -w fs.nr_open=2097152
# 临时端口
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# conntrack
sysctl -w net.netfilter.nf_conntrack_max=2097152
# TCP 缓冲(长连接可适当调大)
sysctl -w net.core.rmem_max=134217728
sysctl -w net.core.wmem_max=134217728
# TIME_WAIT 复用
sysctl -w net.ipv4.tcp_tw_reuse=1
阶梯二:绑核 + 中断亲和
单核 CPU 100% 是常见瓶颈。将发压进程绑定到独立 CPU 核,同时将网卡中断也绑到同一 NUMA 节点,避免跨 NUMA 内存访问。
# 绑核运行
taskset -c 2,3,4,5 ./loadgen
# 查网卡中断
cat /proc/interrupts | grep eth0
# 手动绑中断到指定核(关闭 irqbalance 后)
echo 4 > /proc/irq/<IRQ_NUM>/smp_affinity_list
网卡多队列(RSS):现代网卡支持多个 RX/TX 队列,每个队列绑一个核,避免单核成为网卡中断瓶颈。
阶梯三:多进程 / SO_REUSEPORT 绕 GIL/GC
Python/Ruby 等有 GIL 的运行时,单进程无法利用多核。Go 虽无 GIL,但 GC 停顿在高并发下仍可见(P999 抖动)。
- 多进程:fork N 个发压进程,各自独立连接池,结果汇总。
SO_REUSEPORT:多个进程/线程监听同一端口,内核负载均衡分发连接,避免 accept 锁竞争(发压机作为服务端时适用)。
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
阶梯四:批量 syscall(sendmmsg / io_uring)
单次 send/recv syscall 开销约数百纳秒(含用户态→内核态切换)。高 QPS 下 syscall 本身成为 CPU 瓶颈。
| 方案 | 原理 | 适用场景 | 代价 |
|---|---|---|---|
sendmmsg / recvmmsg | 一次 syscall 发/收多个消息 | UDP 批量发包 | API 较复杂 |
io_uring | 提交环 + 完成环,异步批量 I/O,极少 syscall | TCP/UDP 均可 | 内核 5.1+,调试难 |
epoll + 批量读写 | 事件驱动,减少空转 syscall | 高并发长连接 | 编程模型复杂 |
阶梯五:kernel-bypass(AF_XDP / DPDK)—— 仅 UDP
绕过内核协议栈,用户态直接操作网卡 DMA,延迟可达亚微秒级。
| 方案 | 协议 | 延迟量级 | 代价 |
|---|---|---|---|
| AF_XDP(XDP socket) | UDP | ~1–5μs | 内核 4.18+,需 XDP 驱动支持 |
| DPDK | UDP/自研协议 | <1μs | 独占网卡,专用核,难调试,不支持标准 TCP 栈 |
TCP/gRPC 场景不适合 DPDK:DPDK 绕过了内核 TCP 栈,要用 DPDK 压 gRPC 需自己实现 TCP/TLS/HTTP2,工程成本极高。TCP 长连接压测应优先走阶梯一~四。
阶梯 X:gRPC 侧的榨取点(多 ClientConn / HPACK / GOMAXPROCS / NUMA / 阶梯放大)
编号说明
阶梯 X 与阶梯一五并列,不是阶梯五的下一级**——阶梯一五是通用的"从调参到 kernel-bypass"递进;阶梯 X 是gRPC 场景的补充维度**,覆盖前五级无法覆盖的 gRPC-specific 榨取点。命名用 X 而不是六,正是要提示读者这是"另一条支路"。这条支路与阶梯二(绑核)、阶梯四(批量 syscall)有语义交叉,下文按需交叉引用。
前五级把发压机榨到"syscall 单核饱和"就到头了。但对 gRPC 压测来说,syscall 之上还有一层应用层瓶颈——loopyWriter 单 goroutine、HPACK 头压缩、protobuf marshal——这些在 pprof 里都不长成 syscall 的形状。这一节把 gRPC-specific 的榨取顺序讲清。
① 多 ClientConn 池:绕开单 loopyWriter 瓶颈
grpc-go 里一条 ClientConn 的每个 HTTP/2 transport 都只有一个 goroutine(loopyWriter)负责把所有 stream 的写请求串行化后写进 socket。这是无锁并发写共享 buffer 的必要设计,但也意味着:
症状:ghz --connections=1 --concurrency=10000
发压机 32 核,你看到的是
核 0: 100% ← loopyWriter goroutine 在这里
核 1: 5%
核 2: 5%
...
根因:所有 10000 个 stream 的 SendMsg 最终都要过同一个 loopyWriter 写路径,http2.(*Framer).WriteHeaders / http2.(*Framer).WriteData 的调用被单核串行化。
手段:建 K 个 ClientConn,把 stream 分散到不同的 ClientConn(各自有独立 loopyWriter):
| 方案 | 单 ClientConn 内 N stream | K ClientConn × N/K stream |
|---|---|---|
| loopyWriter goroutine 数 | 1 | K |
| 写路径 CPU 分布 | 单核 100%、其它空闲 | K 核均衡分布 |
| 常见吞吐差异 | baseline | 2~4× |
| 代价 | — | K 倍 subchannel/连接管理开销 |
K 的取法:物理核数量级即可(如 32 核机 K = 16~32),再大只是白花连接。ghz 已经在 --connections 参数里天然做了这件事——--connections=32 就是 32 条 ClientConn;自研 harness 需要显式 for i := 0; i < K; i++ { grpc.Dial(...) }。
别在被测侧照搬
被测服务端接受 K 倍连接会同比放大 accept backlog / channelz socket 数 / server-side keepalive 开销——发压侧多 ClientConn 池是合理的、服务端处理多连接是被动结果。若被测服务端本身设计只支持少量连接(如某些内部 mesh 组件),需要与业务方对齐 K 的量级,别为了压满发压机把被测方带崩。
② HPACK / protobuf 编解码 CPU 定位
绑核 + 多 ClientConn 后 CPU 仍打满时,用 pprof 抓一份 CPU profile,gRPC 侧常见热点符号:
| 符号 | 什么活 | 占比典型区间 |
|---|---|---|
golang.org/x/net/http2.(*Framer).WriteHeaders | HPACK 头压缩 | 5~15%(头小可忽略) |
google.golang.org/grpc/internal/transport.(*loopyWriter).run | 上面说的写路径 | 与 loopyWriter 单核瓶颈同源 |
google.golang.org/protobuf/proto.Marshal / .Unmarshal | protobuf 序列化 | 主要热点,10~40% |
google.golang.org/grpc.(*Server).processUnaryRPC | 服务端 RPC 分发 | 服务端才有 |
runtime.mallocgc / runtime.gcBgMarkWorker | GC | 反映消息 buffer 未复用 |
识别策略:
proto.Marshal占比 > 30% → 消息 schema 该动(拆大消息、去掉不必要字段、bytes替string),才有意义换 runtime。- 占比 < 30% → 不折腾 gogoproto。gogoproto 相较
google.golang.org/protobuf常见 1.5~3× 提速,但引入源码 fork、破坏工具链(buf/protoc 生态)、维护成本高——压测场景直接加发压机成本更低。 - GC 占比 > 10% → 检查是否每次 SendMsg 都在 new payload;用
sync.Pool复用 marshal buffer,或预生成 payload 只改序号(帧同步天然适合)。
关掉 WithSharedWriteBuffer:grpc-go 有一个 dial option 让多个 stream 共享写 buffer,能减内存但会加锁竞争;压测时通常关掉更快(拿默认即可)。这是 gRPC-go 内部优化的 trade-off,跨版本行为可能变,实测为准。
③ GOMAXPROCS × RSS 队列错核
Go runtime 的 P 数(GOMAXPROCS)与网卡中断亲和核不一致时会造成 cache line 抖动:
错误配置:
绑核 taskset -c 4-11 ./loadgen ← 用 8 核
GOMAXPROCS 未设 = 机器总核数 32 ← Go 以为有 32 核在跑
网卡中断 /proc/irq/*/smp_affinity 全默认 ← 撒到全部核
↓
结果:Go 调度器在 4-11 之外的核找不到线程,各种唤醒/迁移;网卡中断可能落在 4-11 之外,跨 L2/L3 cache 通信
三件套(要一起用):
# ① 显式设 GOMAXPROCS 等于绑核数
GOMAXPROCS=8 taskset -c 4-11 ./loadgen
# ② 关 irqbalance,手绑网卡中断到同一批核
systemctl stop irqbalance
for irq in $(cat /proc/interrupts | awk '/eth0/{print $1}' | tr -d :); do
echo "4-11" > /proc/irq/$irq/smp_affinity_list
done
# ③ 网卡多队列 RSS 数量匹配核数
ethtool -L eth0 combined 8
与 §⑤ 阶梯二"绑核 + 中断亲和"是同一件事的具体做法——阶梯二讲了"要绑",阶梯 X 讲"三处必须对齐"。cache line 抖动一般让吞吐降 10~20%、P99 尾巴翘 30%+。
④ NUMA 亲和:40 Gbps+ 才值得关心
跨 NUMA socket 的内存访问延迟比本 socket 内高 1.5~2 倍。单核 25 GbE 场景下 NUMA 影响通常 < 5%,可以先不管;40 Gbps 以上或 100 GbE 场景 NUMA 就变主因了。
# 查网卡挂在哪个 NUMA node
cat /sys/class/net/eth0/device/numa_node
# 绑同一 NUMA node 的 CPU + 内存
numactl --cpunodebind=0 --membind=0 taskset -c 0-15 ./loadgen
先看 numa_node,网卡跨 socket 时才折腾 NUMA;同一 socket 内 numactl 是白花力气。
⑤ 阶梯放大压测:防"一次全量打先崩自己"
400w PCU 场景第一次跑最容易犯的错——直接开 --rps=1200000 --connections=40000。结果发压机自己 OOM 或 CPU 100% 假饱和,被测方指标一切正常,报告说"被测系统只能扛 30w"——其实是发压机自己撑不住。
正确做法是阶梯放大,每一级观察 §② 的 schedule lag 与被测方指标同时是否未撞顶:
# ghz 内置:从 1000 RPS 起,每步 +1000,最多打到 50000
ghz --load-schedule=step \
--load-start=1000 \
--load-step=1000 \
--load-step-duration=30s \
--load-end=50000 \
...
# 或用 --concurrency-schedule 阶梯抬并发数
自研 harness 的等价机制:外层脚本控制 tick,每 30s 提一档 RPS/PCU;每档结束前采样一次发压机与被测方指标。合格判据:
- 发压机 schedule lag < 1ms、CPU 每核 < 65%、TX 未饱和 → 该档数据可信。
- 若发压机先撞顶 → 本档作废、终止推进(这时的"P99 差"是发压机的、不是被测的)。
这条其实是 §⑦ 400w PCU 算例里"阶梯放大验证法"的抽象——那里给的是具体挡位(1万→10万→50万→100万→200万→400万),这里给的是命令行工具语义。
pprof 定位工作流(榨到什么程度停)
标准工作流:
- 上阶梯一~四调参、绑核、多进程。
- 抓 CPU pprof(
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30),看 top 10 是否落在上面表里的 gRPC 符号。 - 若 loopyWriter 占大头 → 加 ClientConn。
- 若
proto.Marshal> 30% → 动消息 schema;否则加发压机。 - 若
runtime.gc*> 10% → 复用 buffer。 - 若 top 10 里 syscall (
runtime.exitsyscall/entersyscall) 占大头 → 回到阶梯四批量 syscall / io_uring。
判停条件:发压机每核 CPU < 65%、schedule lag 稳定、pprof 前几名不再是 gRPC 内部符号——榨到这就够了,剩下的是被测系统的问题。
TCP/gRPC vs UDP 极限路径对照
三条路径的收束:通用榨取(阶梯一~四)→ gRPC-specific(阶梯 X)→ UDP kernel-bypass(阶梯五)。
| 手段 | TCP/gRPC | UDP/自研协议 | 备注 |
|---|---|---|---|
| 调参(FD/端口/conntrack) | ✅ 必做 | ✅ 必做 | 零成本,先做 |
| 绑核 + 中断亲和 | ✅ 有效 | ✅ 有效 | 减跨 NUMA 开销 |
| 多进程 / SO_REUSEPORT | ✅ 有效 | ✅ 有效 | 绕 GIL/GC |
| io_uring | ✅ 有效 | ✅ 有效 | 内核 5.1+ |
| sendmmsg | ⚠️ 有限(TCP 流式) | ✅ 主要收益 | UDP 批量发包 |
| 多 ClientConn(阶梯 X) | ✅ 主要收益 | ❌ 不适用 | 绕 loopyWriter 单核瓶颈 |
| HPACK/protobuf pprof(阶梯 X) | ✅ 常见热点 | ❌ 无 HPACK | proto.Marshal 是主嫌疑 |
| GOMAXPROCS + RSS 对齐(阶梯 X) | ✅ 必做 | ✅ 也适用 | 三件套一起用 |
| 阶梯放大压测(阶梯 X) | ✅ 必做 | ✅ 必做 | 防发压机自己先崩 |
| AF_XDP / DPDK | ❌ 需自实现 TCP 栈 | ✅ 主要收益 | UDP 极限场景 |
读法:TCP/gRPC 场景的完整榨取顺序 = 阶梯一~四 + 阶梯 X(不走阶梯五);UDP 自研协议的完整榨取顺序 = 阶梯一~五(阶梯 X 里只有 GOMAXPROCS/RSS 与阶梯放大对它有用)。
⑥ 分布式发压
单机榨干后,横向扩展为 master-worker 集群。
Master-Worker 编排
关键设计点:
- 统一开始时刻:master 下发
start_at = now + 5s,所有 worker 在同一时刻开始发压。若各 worker 错峰启动,峰值被稀释,压不出真实极限。 - 任务分片:总目标 RPS 按 worker 数均分(
rps_per_worker = total_rps / N),各 worker 独立维护令牌桶。 - 心跳与超时:master 定期收 worker 心跳,worker 掉线时重新分配其 RPS 份额或报警。
- 结果上报:worker 定期(如每 5s)上报 HdrHistogram 序列化快照,master merge 后输出实时分位数。
多机时钟同步
分布式发压的延迟测量依赖各机时钟一致性:
| 场景 | 问题 | 方案 |
|---|---|---|
| 发压机与被测机时钟偏差 | 延迟 = 响应时刻 − 发送时刻,跨机计算可能为负或虚高 | 用 NTP/PTP 同步(PTP 精度 <1μs,NTP ~1ms);或只在发压机侧计时(发出到收到,单机闭环) |
| 各 worker 机器时钟不一致 | 统一开始时刻偏差导致错峰 | 用 master 下发绝对时刻 + 各机 NTP 校准;或用相对倒计时(T-minus)代替绝对时刻 |
| RDTSC 跨机不可比 | 各机 TSC 起点不同,不能用 RDTSC 做跨机时间戳 | 跨机时延测量用 CLOCK_REALTIME(接受 ~1ms NTP 误差);单机内部用 CLOCK_MONOTONIC |
跨机分位数合并
错误做法(数学无意义):
worker-1 P99 = 10ms
worker-2 P99 = 50ms
worker-3 P99 = 100ms
平均 P99 = 53ms ← 错误
正确做法:
各 worker 上报 HdrHistogram 序列化字节流
master 调用 histogram.add(worker_histogram) 逐个合并
从合并后的单一 histogram 取 P99 = 真实 P99
t-digest 替代方案:内存占用更小(约 1KB vs HdrHistogram 的 40KB),支持流式增量合并,适合 worker 数量多、上报频繁的场景。精度略低于 HdrHistogram,但对 P99/P999 通常足够。
⑦ 算例:400 万 PCU 需要多大的发压集群
关于数字
本节所有单机能力数字(syscall 速率、每帧编解码耗时、网卡有效水位)为业界数量级,强依赖内核版本、CPU 型号与负载,非本环境实测。方法论是要点,具体数字必须用回环基线实测替换。
输入假设
| 参数 | 取值 | 说明 |
|---|---|---|
| PCU | 400 万 | 同时在线玩家数 |
| 帧率 | 30 fps(tick 33ms) | 帧同步 tick 频率 |
| 上行 payload | 50 B | 单玩家操作输入 |
| 下行 payload | 150 B | 房间聚合帧(10 人房,每人约 10~15B 操作) |
| streams/conn | 100 | 压测侧一条 TCP 连接用 HTTP/2 多路复用模拟 100 个玩家 |
| 单机规格 | 32 核 / 25 GbE / 64 GB | 发压机基准机型 |
| 资源水位上限 | 65% | 超过则测量本身开始失真 |
四个维度分别算,取最大值
维度一:连接数
压测侧 TCP 连接数 = 400万 / 100 streams = 4 万条
每连接内存 ≈ 内核 buffer 8KB(缩过)+ 用户态 100 stream 状态 ≈ 128 KB
单机内存可承载 ≈ 64 GB / 128 KB ≈ 50 万条
4 万条连接对单机不是约束。这是 HTTP/2 多路复用带来的关键红利:如果每个玩家独占一条 TCP 连接,就是 400 万条连接,直接撞 FD 与四元组空间;多路复用后连接数降两个数量级。
维度二 + 三:CPU(编解码 + syscall 合并算一个预算)
两者抢同一份 CPU,必须合并计算,分开算会低估。
逻辑帧速率 = 上行 400万 × 30 = 1.2 亿帧/秒
+ 下行 400万 × 30 = 1.2 亿帧/秒
= 2.4 亿帧/秒
编解码 CPU:每帧 protobuf marshal/unmarshal + HdrHistogram 记录 ≈ 300 ns
2.4e8 × 300ns = 72 秒 CPU/秒 = 72 核
syscall CPU:每连接每 tick 一次 sendmsg + 一次 recvmsg(该连接上 100 个 stream
的帧合并在一次 syscall 里收发)
4万 × 30 × 2 = 240 万 syscall/秒
单核约 20 万次/秒(含协议栈)= 12 核
CPU 预算合计 = 72 + 12 = 84 核
单机有效供给 = 32 核 × 65% = 20.8 核
需要机器数 = 84 / 20.8 ≈ 4.0 台
如何把 84 核真正打到 4 台机
按算式得出的 84 核是"CPU 预算",实际能否打满还看 gRPC 层的写路径不被单核瓶颈——参见 §⑤ 阶梯 X"多 ClientConn 池":每台 32 核机上 K 至少取物理核数量级(16~32)个 ClientConn,否则 loopyWriter 会把 4 台机压成 4 核。
维度四:带宽(上下行分开算,全双工各自独立)
上行 on-wire:每逻辑帧 = 50B payload + 9B HTTP/2 帧头 + 5B gRPC 前缀 ≈ 64 B
(TCP/IP 头被合帧摊薄,可忽略)
1.2e8 × 64 B = 7.7 GB/s = 61 Gbps
下行 on-wire:1.2e8 × (150 + 14) B = 19.7 GB/s = 157 Gbps
单机网卡有效带宽 = 25 Gbps × 70% = 17.5 Gbps
上行需要 = 61 / 17.5 = 3.5 台
下行需要 = 157 / 17.5 = 9.0 台 ← 主约束
收敛结论
max(连接数 1台, CPU 4台, 上行带宽 3.5台, 下行带宽 9台) = 9 台
+ 1 台 master(只做编排与 histogram merge,不发压)
= 10 台 25GbE / 32 核发压机
换网卡比加机器便宜:同样假设换成 100 GbE,下行带宽维度降到 157 / 70 = 2.3 台,主约束转回 CPU 的 4 台,总量约 5~6 台。带宽受限的场景,升网卡的收益远高于堆机器。
两个反直觉结论
① 下行是主约束,不是上行。 帧同步是读放大系统:一帧操作进入,房间内 N 人各收一份聚合帧。发压机的规划必须按接收能力做,而不是按发送能力。只算「我能发多快」会把集群规模严重低估——本例下行是上行的 2.6 倍,房间人数越大差距越夸张。
② 「3~5 倍余量」和「65% 水位」是两个不同口径,别混用。
| 口径 | 用在哪 | 含义 |
|---|---|---|
| 回环基线 3~5 倍 | 单机 sanity check | 单机纯发送能力上限应是单机分摊到的目标压力的 3~5 倍,用于确认机型选对了 |
| 65% 水位 | 集群规模计算 | 稳态运行时每台不超过 65%,超过则测量失真(见「发压机被压满时偏大还是偏小」) |
按 3~5 倍去算集群规模会把机器数放大 3~5 倍,成本失控;按 65% 水位算规模、用回环基线验机型,两者各管一段。
阶梯放大验证法:不要一次上 400 万
直接按算出的 10 台压 400 万,压不上去时无法判断是发压机、被测系统还是算错了。按阶梯逐级放大,每级验证线性度:
1万 → 10万 → 50万 → 100万 → 200万 → 400万
每一级都记录三条曲线:总吞吐、P99 帧到达偏差、发压机资源水位。
| 观察到的现象 | 判定 |
|---|---|
| 三条曲线都线性 | 健康,继续放大 |
| 吞吐线性但 P99 开始翘 | 被测系统接近拐点,记下这个 PCU 值 |
| 吞吐停止线性、发压机水位未满 | 被测系统饱和,找到真实上限 |
| 吞吐停止线性、发压机某项水位打满 | 发压机是瓶颈,加机器或升网卡后重测该级 |
| schedule lag 开始单调上升 | 该级数据无效,废弃重测 |
外推陷阱:按 PCU 线性外推的前提是房间人数不变。房间人数从 10 人涨到 50 人时,服务端扇出是超线性的(一帧进、N 帧出),此时 100 万 PCU 的实测结果不能线性外推到 400 万。变 PCU 时必须固定房间规模,两个变量分开扫。
为什么这么做
- 为什么先榨干发压机而不是先看被测系统:发压机自身的 FD、临时端口、内存、conntrack、单核 syscall 每一项都是硬上限。发压机先到顶时,测出来的「被测系统上限」其实是发压机上限——数字有害无益。所以顺序必须是:先确认发压机不是瓶颈(回环基线 + 多发压机线性度对照),再谈被测系统的极限。
- 为什么瓶颈是这个撞墙顺序:FD 最先撞,因为每条连接消耗一个且默认
ulimit -n常年 1024;临时端口第二,因为它由四元组空间决定、对同一目的(ip, port)只有约 2.8 万~6.4 万;内存第三,因为百万连接 × 每连接内核缓冲 + 协程栈是 GB 级;conntrack 第四(默认 65536,但只在开着 NAT/iptables 时才算数);syscall 最后,因为前面几堵墙没翻过去时根本压不到那个量级。顺序不是经验排序,是「哪个默认值最小、且与连接数线性相关」决定的。 - 为什么长连接压测先撞 FD 而短连接先撞临时端口:长连接的端口消耗是固定的 N 条、与 QPS 无关,但每条都占一个 FD;短连接每个请求建一次连,端口随 QPS 线性消耗还要叠加 TIME_WAIT 的 2×MSL 占用期。同一台机器、同一个内核参数,测什么协议决定先撞哪堵墙。
- 为什么 HTTP/2 让临时端口不再是并发瓶颈:单条 TCP 连接上可跑多个并发 stream,100 条连接 × 100 stream = 10000 并发只消耗 100 个端口。所以 gRPC 压测的调参重点从「扩端口」转移到「connections 与 concurrency 的配比」——连接太多打满服务端 accept backlog,太少则每连接 stream 数超过
SETTINGS_MAX_CONCURRENT_STREAMS被拒。 - 为什么测系统上限必须用 open model:closed model 是固定 N 个 worker 发完等响应,被测系统一变慢,worker 就阻塞、发压自动降速——过载被自己掩盖掉了,延迟曲线看起来还很稳。open model 按固定到达率发、不等响应,被测变慢就积压,延迟立刻飙升,真实拐点才暴露出来。真实用户流量也是固定到达率的(用户不会因为你慢就少来),open model 更接近现实。
- 为什么必须修正 Coordinated Omission:worker 卡在一次慢请求上时,「本该发出但没发出」的后续请求的等待时间从未进入直方图。结果是慢请求期间积压的时间凭空消失,P99 能因此低一个数量级。修正方法是记录意图发送时刻(计划到达时刻)而非实际发送时刻:延迟 = 响应收到时刻 − 计划发送时刻,HdrHistogram 的
recordValueWithExpectedInterval就是这件事的标准实现。 - 为什么用 HdrHistogram 而不是普通直方图:压测延迟的动态范围极大(1ms~10s 跨四个数量级)。固定桶宽要么桶数爆炸吃内存,要么在小值区精度不够。HdrHistogram 用对数桶宽在整个范围内保持相对精度(默认 0.1%),内存固定约 40KB,且支持无锁写入与跨实例 merge——最后一条是分布式发压能正确聚合的前提。
- 为什么分位数不能算术平均:P99 是分布的一个位置,不是可加量。两个 worker 的 P99 分别 10ms 和 100ms,合并后的真实 P99 取决于两者的样本量与整条分布形状,与 55ms 没有任何关系。必须合并原始分布再取分位——这正是 HdrHistogram merge / t-digest 存在的理由。
- 为什么跨机测量要挑时钟源:
CLOCK_MONOTONIC单调不回拨,是单机延迟测量首选,但各机起点不同、跨机不可比;CLOCK_REALTIME可跨机比较但会被 NTP 调整回拨、算出负延迟;RDTSC 无 syscall 开销、纳秒精度,但跨核不一致、变频 CPU 下周期不等于时间。最稳的做法是只在发压机侧闭环计时(发出到收到),完全绕开跨机时钟问题。 - 为什么分布式发压要统一开始时刻:各 worker 错峰启动会把峰值稀释掉,压不出真实极限。做法是 master 下发绝对时刻
start_at = now + 5s(配合 NTP 校准)或用相对倒计时。 - 为什么 unary 压测覆盖不了帧同步:ghz 的 stream 语义是「开 stream、发完消息、关 stream、记一次延迟」,本质仍是请求-响应;帧同步是长期驻留 stream 上按固定 tick 持续收发。差别不只是形式——unary 无长期状态(跑不出房间/帧缓冲膨胀)、无扇出放大(1:1 而非一帧进 N 帧出)、stream 太短撑不满 HTTP/2 flow-control window、且没有「节奏」这个维度(帧到达偏差、丢帧、乱序)。所以必须分两套:控制面 unary 用 ghz,数据面帧同步用自研 harness。
- 为什么帧同步的核心指标不是 RT 而是帧到达偏差:RT 测的是单次往返,帧同步在意的是每帧是否落在预算窗口内到达。服务端降频发帧时每帧 RT 可能都很低,但帧率已经掉了——只看 RT 完全看不见。而且丢帧是 CO 的变体:没发出的帧不产生样本,丢掉一半帧延迟曲线反而更漂亮,所以必须按计划 tick 逐帧核对序号。
- 为什么要同时报端到端 RT 和服务端处理耗时:只有端到端 RT 时无法定位——差值里混着 wire RTT、两侧内核排队、accept backlog、goroutine 调度、flow-control 等待、TLS、以及发压机自身的 GC 与调度抖动。三层打点(客户端 RT / 服务端 socket 层 eBPF / handler span)逐层相减才能定位大头,而大头在发压机侧意味着数字本身不可信。
- 为什么 schedule lag 必须做成硬门禁:回环基线、资源观测、no-op 对照、线性度对照都是外部对照,需要人去解读;schedule lag(计划发送时刻与实际发送时刻之差)是发压机的自证指标,单调上升就说明自己饱和了。做成门禁自动废弃该轮数据,是因为一份发压机饱和产出的报告和一份正常报告长得一模一样,靠事后人工判断必然漏。
- 为什么发压集群规模要四个维度分别算再取最大值:连接数、CPU(编解码 + syscall 合并成一个预算,因为抢同一份 CPU)、上行带宽、下行带宽各自是独立硬上限,任一项到顶整机就到顶。只算其中一项会系统性低估——本例连接数只需 1 台、CPU 需 4 台、下行带宽需 9 台,只算 CPU 会少配一半以上。
- 为什么帧同步发压要按下行接收能力规划:帧同步是读放大系统,一帧操作进入、房间内 N 人各收一份聚合帧。本例下行流量是上行的 2.6 倍,房间人数越大差距越夸张。只算「我能发多快」会严重低估集群规模。
为什么别的选择不行
- 为什么不直接用 closed model 加大并发数来压极限:加并发确实能提高压力,但被测系统一慢,worker 就阻塞降速——系统越接近崩溃,发压强度自动越低,形成负反馈,永远测不到真正的拐点,还会得到一条「延迟很稳」的假曲线。
- 为什么不靠加大发压机数量来绕过单机调参:不调参的发压机在 FD 或端口上先崩,加机器只是把同一堵墙复制 N 份,成本线性上升而有效压力不变。正确顺序是先把单机榨干、确认线性度,再横向扩——多发压机对照实验本身就是判断瓶颈在哪一侧的手段。
- 为什么 TCP/gRPC 压测不上 DPDK:DPDK 绕过内核协议栈,用它压 gRPC 就得自己实现 TCP + TLS + HTTP/2,工程成本极高且难调试。kernel-bypass 的收益集中在 UDP/自研协议;TCP 长连接压测应该老实走「调参 → 绑核 → 多进程 → io_uring」这四级阶梯。
- 为什么不用平均延迟做压测结论:平均值掩盖尾部。平均 40ms、P99 400ms 的系统,1% 的用户每次请求都在踩 400ms,而游戏场景里这 1% 恰好是「技能放空、位置回拉」的那批人。压测要看的是 P99/P999,Max 因为受测量时长影响过大反而参考价值有限。
- 为什么不用
wrk/ab压 gRPC:它们是 HTTP/1 工具,不理解 HTTP/2 多路复用与 protobuf 编解码,压不出 stream 层的排队行为。gRPC 场景用 ghz(事实标准),它的connections × concurrency分层模型正好对应「TCP 连接数 × 每连接 stream 数」这两层。 - 为什么不能把 ghz 的 unary 数字当帧同步的结论:这是压测报告最常见的越界。unary 压出来的是控制面(登录/匹配/建房)能力,帧同步压的是数据面的节奏保持与扇出放大。两者的瓶颈位置、失效模式、优化手段全不同——用控制面数字给数据面容量做背书,等于没测。
- 为什么发压机侧「延迟偏大还是偏小」没有唯一答案:方向取决于打点起点与压测模型。open model + 计划发送时刻打点 → 偏大(发压机延迟被算进 RT,产生伪劣化,误导去优化不存在的瓶颈);closed model + 实际发送时刻打点 → 偏小(CO 让最坏的那批从未被记录,且 closed 自动降速把过载消掉)。两者同时存在时 P50 偏大而 P99 偏小,报告读起来像「平均慢一点尾部还行」,最危险。所以追问必须先问清口径,答「一定偏大」或「一定偏小」都是错的。
- 为什么不能用「回环基线 3~5 倍余量」去算集群规模:这两个是不同口径。3~5 倍是单机 sanity check,验证机型选对了;集群规模要用65% 稳态水位算。拿 3~5 倍去算规模会把机器数放大 3~5 倍,成本直接失控。
- 为什么不直接按算出的 10 台一次压 400 万:一次到顶时压不上去无法归因——发压机瓶颈、被测系统饱和、还是算错了,三种情况现象相同。必须
1万 → 10万 → 50万 → 100万 → 200万 → 400万阶梯放大,每级同时看总吞吐、P99 帧到达偏差、发压机水位三条曲线:吞吐停止线性而发压机水位未满 = 被测系统饱和(找到真实上限);水位已满 = 发压机是瓶颈(该级数据作废)。 - 为什么不能按 PCU 线性外推容量:线性外推的前提是房间人数不变。房间从 10 人涨到 50 人时服务端扇出是超线性的(一帧进 N 帧出),此时 100 万 PCU 的实测不能线性推到 400 万。PCU 与房间规模是两个变量,必须固定一个扫另一个。
- 为什么不能只关 conntrack 就当第四堵墙不存在:关 conntrack 的前提是发压机不做 NAT、且没有依赖 conntrack 的 iptables 规则(如
-m state)。生产环境的发压机常常挂着安全组规则,直接关会让规则静默失效——要么确认无依赖再关,要么老实调大nf_conntrack_max。
沉淀结论
- 一句话:极限发压 = 先榨干发压机自身,再用 open model + HdrHistogram 保证数字不撒谎,最后横向扩成分布式集群。被测系统的上限,只有在发压机不是瓶颈时才有意义。
- 撞墙顺序:FD(
ulimit -n/fs.nr_open)→ 临时端口/四元组(ip_local_port_range,多目的 IP/端口翻倍)→ 内存/每连接(tcp_rmem/tcp_wmem、协程栈)→ conntrack(nf_conntrack_max或关掉)→ TIME_WAIT(tcp_tw_reuse,短连接才明显)→ 单核 syscall。顺序由「哪个默认值最小且与连接数线性相关」决定。 - 信号对应:
Too many open files= FD;connect: Cannot assign requested address= 端口耗尽;nf_conntrack: table full= conntrack;某核 100% +perf top见__sys_sendmsg= syscall;ethtool -S的rx_missed_errors= 网卡单队列。 - 瓶颈在哪一侧:① 回环基线(压 localhost 测发压机纯发送上限)② 看发压机资源(某核满?TX 饱和?FD/端口耗尽?)③ 看被测资源(有余量却上不去 = 发压侧瓶颈)④ 多发压机对照(总 QPS 线性增长 = 发压机是瓶颈;停滞 = 被测是瓶颈)。
- 连接模型:长连接池固定消耗 N 个端口、与 QPS 无关,先撞 FD 与内存;每请求建连的端口消耗随 QPS 线性增长还叠加 TIME_WAIT,先撞端口。HTTP/2 多路复用让端口彻底退出并发瓶颈(100 conn × 100 stream = 1 万并发只用 100 个端口),瓶颈转为服务端 accept backlog 与
SETTINGS_MAX_CONCURRENT_STREAMS。 - open vs closed:closed model 固定并发、发完等响应,被测变慢就自动降速,掩盖过载、产出乐观假数字;open model 固定到达率、不等响应,变慢就积压、延迟飙升,暴露真实拐点,也更接近真实用户流量。测上限必须 open(ghz 的
--rps)。 - Coordinated Omission:worker 卡在慢请求上时,「本该发出没发出」的请求等待时间从未被记录,P99 能虚低一个数量级。修正 = 用意图发送时刻而非实际发送时刻计算延迟(HdrHistogram
recordValueWithExpectedInterval)。 - HdrHistogram:对数桶宽,全动态范围保持相对精度(默认 0.1%),内存固定约 40KB,支持无锁写入与跨实例 merge。t-digest 内存更小(~1KB)、支持流式合并,精度略低但够用于 P99/P999。
- 分位数不可平均:P99 是分布上的位置不是可加量,必须合并原始分布再取分位(
histogram.add())。10ms 与 100ms 平均出来的 55ms 数学上无意义。 - 时钟源:
CLOCK_MONOTONIC单调不回拨、单机首选但跨机不可比;CLOCK_REALTIME可跨机但 NTP 回拨会产生负延迟;RDTSC 纳秒精度无 syscall 但跨核不一致、变频下周期≠时间。最稳是只在发压机侧闭环计时。 - 极限阶梯:① 调参(FD/端口/conntrack/缓冲,零代码)② 绑核 + 中断亲和 + RSS 多队列 ③ 多进程 /
SO_REUSEPORT(绕 GIL、摊薄 GC 抖动)④ 批量 syscall(sendmmsg/io_uring,内核 5.1+)⑤ kernel-bypass(AF_XDP ~1–5μs / DPDK <1μs)——⑤ 只适用 UDP。 - TCP 不上 DPDK:绕过内核栈就得自己实现 TCP+TLS+HTTP/2,成本极高;TCP/gRPC 老实走阶梯一~四,
sendmmsg对 TCP 流式也只有有限收益。 - 分布式四要点:统一开始时刻(
start_at = now + 5s,错峰会稀释峰值)、任务分片(rps_per_worker = total_rps / N,各自令牌桶)、心跳与掉线重分配、上报 HdrHistogram 快照由 master merge(不是上报分位数)。 - unary 覆盖不了帧同步:ghz 的 stream 语义是「开-发完-关-记一次延迟」,仍是请求-响应。帧同步是长驻 stream 按固定 tick 收发。unary 测不出长期状态、扇出放大、flow-control 打满、帧节奏、GC 累积。分两套:控制面 unary 用 ghz,数据面自研 harness。
- 帧同步指标不是 RT:帧到达偏差(相对计划 tick)、帧率保持率、丢帧/乱序率、广播扇出到最后一人的延迟、稳态驻留时长。丢帧是 CO 的变体——没发出的帧不产生样本,丢一半帧曲线反而更漂亮,必须按 tick 逐帧核对序号。
- P99 报端到端 RT,但必须同时报服务端耗时:差值 = wire RTT + 两侧内核排队 + accept backlog + goroutine 调度 + flow-control 等待 + TLS + 发压机自身 GC/调度抖动。三层打点定位:客户端 RT / 服务端 socket 层(eBPF
tcp_sendmsg)/ handler span。 - 发压机压满后延迟偏哪边——看口径:open + 计划发送时刻 → 偏大(伪劣化,误导优化不存在的瓶颈);closed + 实际发送时刻 → 偏小(CO 丢掉最坏那批 + 自动降速消掉过载)。同时存在时 P50 偏大、P99 偏小,最危险。
- schedule lag 是发压机自证指标:计划发送时刻 − 实际发送时刻。低位稳定 = 数据可用;单调上升 = 发压机饱和、本轮作废。必须做成硬门禁——饱和产出的报告和正常报告长得一模一样。
- 400 万 PCU 算例(30fps / 上行 50B / 下行 150B / 100 stream 每连接 / 32 核 25GbE):连接数 4 万条 → 1 台;CPU(编解码 72 核 + syscall 12 核)→ 4 台;上行 61 Gbps → 3.5 台;下行 157 Gbps → 9 台(主约束)。取最大值 9 + 1 台 master = 10 台。换 100GbE 降到 5~6 台,升网卡比堆机器便宜。
- 两个口径别混:回环基线 3~5 倍是单机 sanity check(验机型);65% 水位用于算集群规模。用 3~5 倍算规模会把机器数放大 3~5 倍。
- 阶梯放大验证:
1万 → 10万 → 50万 → 100万 → 200万 → 400万,每级看吞吐/P99 帧偏差/发压机水位三条曲线。吞吐停止线性 + 水位未满 = 被测饱和(真实上限);水位打满 = 发压机瓶颈(该级作废)。 外推前提是房间人数不变——扇出超线性,PCU 与房间规模必须分开扫。
记忆口诀
撞墙顺序:FD → 临时端口/四元组 → 内存每连接 → conntrack → TIME_WAIT → 单核 syscall
为什么这个序:谁的默认值最小、谁与连接数线性相关,谁先撞
信号速查:Too many open files=FD / Cannot assign requested address=端口 / table full=conntrack / 某核100%+sendmsg=syscall
判瓶颈在哪侧:回环基线 / 看双侧资源 / 加一台发压机看线性度
长连接 vs 短连接:长连接端口固定先撞 FD / 短连接端口随 QPS 涨还叠 TIME_WAIT
HTTP/2:100 conn × 100 stream = 1 万并发只用 100 端口 / 瓶颈转 accept backlog 与 MAX_CONCURRENT_STREAMS
closed 会骗你:被测变慢 worker 阻塞 → 自动降速 → 延迟「看起来稳」 → 测上限必须 open model
CO 协调遗漏:没发出去的等待时间没进直方图 / P99 虚低一个数量级 / 用意图发送时刻修正
HdrHistogram:对数桶宽 / 相对精度 0.1% / 固定 40KB / 可 merge —— t-digest 更小精度略低
分位数:P99 不是可加量 / 合并原始分布再取分位 / 平均 P99 数学上无意义
时钟:MONOTONIC 单机首选跨机不可比 / REALTIME 可跨机但会回拨 / RDTSC 快但跨核不一致 / 最稳=发压机侧闭环计时
极限五阶梯:调参 → 绑核+中断亲和 → 多进程/SO_REUSEPORT → sendmmsg/io_uring → AF_XDP/DPDK(仅 UDP)
TCP 别上 DPDK:要自己写 TCP+TLS+HTTP2
分布式四要点:统一开始时刻 / 均分 RPS / 心跳重分配 / 上报直方图快照不是上报分位数
unary ≠ 帧同步:ghz stream = 开-发完-关-记一次 / 测不出长期状态·扇出·flow-control·帧节奏 / 控制面 ghz + 数据面自研 harness
帧同步指标:帧到达偏差 / 帧率保持率 / 丢帧乱序率 / 扇出到最后一人 / 丢帧=CO 变体,丢一半曲线更漂亮
报哪个 P99:端到端 RT 对外 + 服务端耗时定位 / 三层打点=客户端 RT|socket 层 eBPF|handler span
压满后偏哪边:open+计划时刻=偏大(伪劣化)/ closed+实际时刻=偏小(CO+自动降速)/ 同时存在=P50 偏大 P99 偏小
schedule lag:计划 − 实际 / 单调上升=发压机饱和 / 做成硬门禁自动废弃
400 万 PCU:连接 1 台|CPU 4 台|上行 3.5 台|下行 9 台(主约束)=10 台(含 master)/ 帧同步是读放大,按下行规划
两个口径:回环基线 3~5 倍=验机型 / 65% 水位=算规模 / 混用会多配 3~5 倍机器
阶梯放大:1万→10万→50万→100万→200万→400万 / 吞吐不线性 + 水位未满=被测饱和;水位满=发压机瓶颈 / 房间人数变了不能线性外推
内容来源
综合整理。主要参考:ghz 官方文档(ghz.sh,--connections/--concurrency/--rps 语义)、Gil Tene 关于 Coordinated Omission 的演讲《How NOT to Measure Latency》、HdrHistogram 项目文档(hdrhistogram.github.io,含 recordValueWithExpectedInterval 与 merge 语义)、t-digest 论文(Ted Dunning)、Linux 内核文档与 man 页(ulimit、fs.nr_open、ip_local_port_range、nf_conntrack_max、tcp_tw_reuse、SO_REUSEPORT、sendmmsg)、io_uring 设计文档(Jens Axboe)、AF_XDP 与 DPDK 官方文档、wrk2 关于 open/closed model 与恒定吞吐的说明。
数字口径:本文所有性能数字(syscall 开销、延迟量级、内存占用、各内核参数默认值)为业界数量级,强依赖内核版本、硬件与负载,非本环境实测,仅供方向参考。
相关专题:长连接的承载侧见 接入网关(本文讲如何压满它);内核收发与零拷贝、绑核等极限手段见 全栈极限低延迟;限流与过载保护见 限流与熔断;把压测数据反过来倒推系统瓶颈的方法论见 perf-analysis-optimization(作为性能分析主叙事的入口)。
自测:合上资料能说清楚吗?
单机长连接数从 1 万推到 100 万,会按什么顺序撞墙?为什么是这个顺序?
参考答案
顺序:① FD 上限(每连接一个 FD,默认 ulimit -n 常年 1024)→ ② 临时端口/四元组耗尽(对同一目的 (ip, port) 只有约 2.8 万~6.4 万,ip_local_port_range)→ ③ 内存/每连接开销(内核 sk_buff 缓冲默认各约 87KB + 协程栈 2–8KB,百万连接是 GB 级)→ ④ conntrack 表满(默认约 65536,仅在开着 NAT/iptables 时算数)→ ⑤ TIME_WAIT 堆积(2×MSL 约 60s,短连接才明显)→ ⑥ 单核 syscall 开销(约数十万次/核)。
顺序不是经验排序,是「哪个默认值最小、且与连接数线性相关」决定的:默认值越小越先撞,前面几堵墙没翻过去时根本压不到 syscall 那个量级。
压测 QPS 上不去,怎么判断瓶颈在发压机还是被测系统?
参考答案
四步对照实验:① 回环基线——发压机压 localhost,排除网络因素,测出发压机纯发送能力上限;② 看发压机资源——是否某核 CPU 100%?网卡 TX 是否饱和(sar -n DEV 1)?FD/端口是否耗尽?③ 看被测系统资源——CPU/内存/网卡 RX 是否还有余量,若被测空闲而 QPS 上不去,瓶颈就在发压侧;④ 多发压机对照(最有力)——加一台发压机,总 QPS 线性增长说明单机发压机是瓶颈,增长停滞说明被测系统是瓶颈。
open model 和 closed model 的本质区别是什么?为什么测系统上限必须用 open model?
参考答案
closed model:固定 N 个 worker,每个发完等响应再发下一个。open model:按固定到达率(RPS)发请求,不等响应。
区别的要害在被测系统变慢时:closed model 的 worker 会阻塞在慢请求上,发压强度自动下降——系统越接近崩溃,压力越小,形成负反馈,延迟曲线看起来还很稳,产出乐观的假数字。open model 继续按目标 RPS 发,请求在队列里积压,延迟立刻飙升,真实过载拐点暴露出来。
而且真实用户流量本来就是固定到达率的(用户不会因为你慢就少来),open model 更接近现实。ghz 里对应 --rps(open)与只设 --concurrency(closed)。
什么是 Coordinated Omission?它会让 P99 偏成什么样?怎么修正?
参考答案
成因:发压 worker 卡在一次慢请求上时,「本该按计划发出但没发出」的后续请求被跳过,它们的等待时间从未进入直方图。直方图里只有实际发出去的那些请求的延迟,慢请求期间积压的时间凭空消失。
后果:P99 可以虚低一个数量级。例:req1 阻塞 3s,req2 实际等了 3s 才发出、自身耗时 1s,真实体验 4s,但记录的只有 1s。
修正:用意图发送时刻(按计划的到达时刻)而非实际发送时刻计算——延迟 = 响应收到时刻 − 计划发送时刻。HdrHistogram 的 recordValueWithExpectedInterval 是标准实现;配合 open model 从源头减少这个偏差。
为什么 P99 不能算术平均?多机结果应该怎么合并?
参考答案
P99 是分布上的一个位置,不是可加量。worker-1 的 P99 = 10ms、worker-2 = 100ms,合并后的真实 P99 取决于两者的样本量与整条分布形状,和 55ms 没有任何关系。
正确做法:各 worker 维护独立的 HdrHistogram 实例,上报序列化的直方图快照(不是上报分位数),master 调用 histogram.add() 逐个合并原始分布,从合并后的单一直方图取 P99。或用 t-digest——内存更小(约 1KB vs 40KB)、支持流式增量合并,精度略低但对 P99/P999 通常够用。
延迟测量的三种时钟源各有什么坑?分布式发压该怎么选?
参考答案
CLOCK_MONOTONIC:单调递增、不受 NTP 回拨影响,单机延迟测量首选;坑是各机起点不同,跨机不可比较。CLOCK_REALTIME:墙钟可跨机比较,用于分布式时间对齐;坑是 NTP 调整可能回拨,算出负延迟。RDTSC:CPU 周期计数,纳秒精度、无 syscall 开销;坑是跨核不一致(需 RDTSCP)、变频 CPU 下周期不等于时间,跨机完全不可比。
分布式场景最稳的做法是只在发压机侧闭环计时(发出到收到都用本机 CLOCK_MONOTONIC),彻底绕开跨机时钟问题;确实需要跨机时用 CLOCK_REALTIME + NTP/PTP 校准(PTP <1μs,NTP ~1ms)。统一开始时刻则用 master 下发绝对时刻或相对倒计时。
单机极限手段有哪几级阶梯?为什么 TCP/gRPC 压测不该上 DPDK?
参考答案
五级:① 调参基线(FD、临时端口、conntrack、TCP 缓冲、tcp_tw_reuse,零代码成本)→ ② 绑核 + 中断亲和(taskset、smp_affinity_list、RSS 多队列,避免跨 NUMA)→ ③ 多进程 / SO_REUSEPORT(绕 GIL,摊薄 GC 停顿造成的 P999 抖动)→ ④ 批量 syscall(sendmmsg/recvmmsg、io_uring,内核 5.1+)→ ⑤ kernel-bypass(AF_XDP ~1–5μs、DPDK <1μs)。
DPDK 不适合 TCP/gRPC:它绕过内核协议栈,要用它压 gRPC 就得自己实现 TCP + TLS + HTTP/2,工程成本极高且难调试;而且 DPDK 独占网卡与专用核。kernel-bypass 的收益集中在 UDP/自研协议,TCP 长连接压测应优先走阶梯一~四(sendmmsg 对 TCP 流式也只有有限收益)。
分布式发压有哪些容易踩的坑?
参考答案
四个要点:① 统一开始时刻——master 下发 start_at = now + 5s,各 worker 同时开压;错峰启动会把峰值稀释掉,压不出真实极限。② 任务分片——总目标 RPS 按 worker 数均分,各自维护独立令牌桶。③ 心跳与掉线处理——worker 掉线时重新分配其 RPS 份额或报警,否则实际压力低于目标却不自知。④ 结果上报的是直方图快照不是分位数——worker 定期上报序列化 HdrHistogram,master merge 后取分位;上报各自的 P99 再平均是数学上无意义的。
另加时钟坑:各机 NTP 不一致会让「统一开始时刻」失准(可改用相对倒计时),RDTSC 跨机完全不可比。
gRPC 压测的 connections × concurrency 怎么配?临时端口还是并发瓶颈吗?
参考答案
临时端口不是并发瓶颈了。HTTP/2 在单条 TCP 连接上跑多个并发 stream:N 条连接 × 每连接 M 个 stream = N×M 并发,只消耗 N 个临时端口(100 conn × 100 stream = 1 万并发只占 100 个端口)。瓶颈转移到两头——连接太多打满服务端 accept backlog,连接太少则每连接 stream 数超过服务端 SETTINGS_MAX_CONCURRENT_STREAMS(默认通常 100~1000)导致请求被拒。
ghz 的参数语义:--connections N 建 N 条 TCP 长连接,--concurrency M 是总并发 worker 数,轮询分配到 N 条连接上,每条承载约 M/N 个并发 stream。配比(数量级):测吞吐上限用少量连接(10~50)× 高并发(50~200)压满 stream;测建连能力逐步加连接数、每连接单 stream;测尾延迟用少连接(5~10)+ 低并发,避免排队把真实延迟掩盖掉。
用 ghz 压 unary 能不能代表帧同步(bidi stream)场景?缺了哪些覆盖?
参考答案
不能。 ghz 名义支持 streaming method,但语义是「开一条 stream、把 -d 消息全发完、关闭、记一次延迟」——本质仍是请求-响应。帧同步是长期驻留 stream:连接活几分钟到几十分钟,按固定 tick(如 33ms)持续双向收发。
unary/短 stream 测不出五件事:① 服务端每连接长期状态(房间、玩家、帧缓冲、AOI)——压完即走,跑不出状态膨胀;② 广播扇出放大——unary 是 1:1,没有「一帧进 N 帧出」;③ HTTP/2 stream 队头阻塞与 flow-control window 打满——stream 生命周期太短撑不满;④ 帧节奏(帧到达偏差、帧率保持、丢帧、乱序)——unary 只有独立 RT 分布,没有节奏维度;⑤ 长时间在线的内存增长与 GC 累积。
正确做法是分两套:控制面 unary(登录/匹配/建房/拉配置)用 ghz 走 open model;数据面 bidi 帧同步用自研 harness,长连接常驻、按 tick 推帧。把控制面数字当数据面结论是压测报告最常见的越界。
帧同步 harness 该报什么指标?为什么 RT 不够?
参考答案
核心指标是节奏保持能力,不是 RT:① 帧到达偏差(实际到达 − 计划 tick 时刻的分布);② 帧率保持率(实际帧率/目标帧率);③ 丢帧率与乱序率(序号缺失、倒序比例);④ 广播扇出延迟(一帧进入到房间内最后一人收到);⑤ 稳态驻留时长。
RT 不够的原因:RT 只测单次往返,测不出相对固定节拍的漂移;服务端降频发帧时每帧 RT 可能都很低,但帧率已经掉了;丢掉的帧根本不产生 RT 样本;RT 只看自己那条 stream,看不到扇出尾部。
丢帧是 Coordinated Omission 的变体——「服务端没发出的帧」和 CO 里「worker 没发出的请求」是同一类错误:没产生样本的事件不进直方图。所以必须按计划 tick 逐帧核对序号,否则丢掉一半帧延迟曲线反而更漂亮。
报的 P99 是发压机侧端到端 RT 还是服务端处理耗时?差值来自哪里?
参考答案
对外报端到端 RT(用户感知就是端到端),但必须同时报服务端处理耗时,否则无法定位。
差值构成:wire RTT(双向)+ 发压机内核发送队列排队 + 被测机内核接收队列与 accept backlog 排队 + 服务端 goroutine 调度等待(进 handler 前)+ HTTP/2 flow-control window 等待 + TLS 加解密(两侧各一次)+ 响应到达后发压机读 socket 与回调的调度延迟 + 发压机自身 GC STW 与调度抖动 + 两侧打点位置差。
三层打点切开差值:L1 客户端 RT(计划发送到收到响应)→ L2 服务端 socket 层(eBPF 挂 tcp_recvmsg/tcp_sendmsg)→ L3 handler span。L1−L2 = wire RTT + 发压机侧开销;L2−L3 = 服务端内核排队 + 调度 + flow control;L3 = 纯业务。
大头在 L3 就优化业务、在 L2 就调并发与 backlog、在 wire 就看拓扑,在发压机侧则数字本身不可信,先修发压机。
发压机自己被压满时,它采到的延迟会系统性偏大还是偏小?
参考答案
没有唯一答案,取决于打点起点与压测模型——这是问题的要害。
open model + 记录计划发送时刻 → 系统性偏大。 发压机 CPU 饱和后:请求实际发出晚于计划(起点是计划时刻,这段等待被算进 RT)、响应从内核读出被延后、打点本身被调度延后、GC STW 期间测量停摆。被测系统毫无变化 P99 却涨了——伪劣化,会导致去优化根本不存在的瓶颈。
closed model + 记录实际发送时刻 → 系统性偏小。 两个机制同向压低:① Coordinated Omission——worker 阻塞时「本该发出没发出」的请求从未被记录,恰恰是最该进尾部的那批消失了;② closed model 自动降速——被测变慢导致 worker 阻塞、压力自动下降,把想测的过载现象自己消掉了。
两者同时存在时:P50 偏大(发压机开销进了每次测量),P99 相对真实值偏小(最坏那批从未被记录)。 这是最危险的组合——报告读起来像「平均慢一点、尾部还行」,生产上尾部随时爆。
防护:open model + 计划发送时刻打点、HdrHistogram recordValueWithExpectedInterval 做 CO 修正、schedule lag 硬门禁。报 P99 必须附发压机资源水位——不带水位的 P99 是无法验证的断言。
「极限压榨发压机」具体做了什么?怎么证明测出来的瓶颈不在发压机自己身上?
参考答案
五类手段:① FD 与端口解耦——ulimit -n/fs.nr_open/fs.file-max 三层都抬,核心是长连接池让端口消耗与 QPS 无关,端口不够用多目的 IP/多源 IP 翻倍四元组空间;② CPU 亲和 + 中断亲和 + NUMA 对齐——taskset 绑 worker、关 irqbalance 后手绑网卡中断、ethtool -L 开 RSS 多队列,三者同 NUMA node(跨 NUMA 内存延迟翻倍直接污染尾延迟),Go 侧 GOMAXPROCS 必须等于绑核数;③ 多进程 + SO_REUSEPORT 绕 GC STW——单进程 GC STW 让所有 worker 同时停,在 P999 打出假尖峰,拆成多进程各绑一核使 STW 互不相关,结果用 HdrHistogram merge 汇总;④ 内存池与零分配帧路径——sync.Pool 复用 marshal buffer、payload 预生成只改序号、GOMEMLIMIT 设软上限,同时缩 tcp_rmem/wmem(帧很小,默认几 MB × 万连接直接吃光内存);⑤ 时间戳采集降到可忽略——clock_gettime 走 vDSO 约 20~25ns、per-worker 独立无锁 histogram、分位数离线算(反面:共享 histogram 加 mutex 造成 cache line 争抢、热路径实时算 P99、用墙钟导致 NTP 回拨出负延迟)。
证明瓶颈不在发压机(由弱到强):① 回环基线——压 localhost 的 no-op,要求纯发送上限是目标压力的 3~5 倍;② 资源余量——每一个核都低于 60~70%(平均值会被单核打满掩盖)、TX 未饱和、dmesg 无报错;③ 换 no-op 服务端——QPS 大幅上升 = 瓶颈在被测侧,几乎不变 = 瓶颈在发压机,数字全部作废;④ 多发压机线性度(最硬)——总 QPS 线性翻倍 = 发压机是瓶颈,停滞 = 被测饱和,不依赖对任何指标的解读;⑤ schedule lag 自证——计划与实际发送时刻之差,单调上升即发压机饱和,必须做成硬门禁自动废弃该轮数据,因为饱和产出的报告和正常报告长得一模一样。
400 万 PCU 的帧同步压测,需要多大的发压集群?怎么算?
参考答案
四个维度分别算再取最大值(假设 30fps、上行 50B、下行 150B、每连接 100 stream、单机 32 核/25GbE/64GB、水位上限 65%):
① 连接数:400万/100 stream = 4 万条 TCP 连接,每连接约 128KB,单机可承载 50 万条 → 1 台。HTTP/2 多路复用是关键红利:每玩家独占连接就是 400 万条,直接撞 FD 与四元组。
② CPU(编解码 + syscall 合并算,抢同一份 CPU):逻辑帧 = 400万×30×2 = 2.4 亿帧/秒,每帧约 300ns → 72 核;syscall = 4万×30×2 = 240 万/秒,单核 20 万 → 12 核;合计 84 核 / (32×65%) → 4 台。
③ 上行带宽:1.2e8 × 64B = 61 Gbps / (25×70%) → 3.5 台。
④ 下行带宽:1.2e8 × 164B = 157 Gbps → 9 台(主约束)。
取最大值 9 + 1 台 master = 10 台。 换 100GbE 后下行降到 2.3 台,主约束回到 CPU 的 4 台,总量 5~6 台——带宽受限时升网卡比堆机器便宜。
两个反直觉点:① 下行是主约束——帧同步是读放大系统(一帧进、房间 N 人各收一份),必须按接收能力规划,本例下行是上行的 2.6 倍;② 「3~5 倍余量」与「65% 水位」是不同口径——前者是单机 sanity check 验机型,后者用于算集群规模,用 3~5 倍算规模会把机器数放大 3~5 倍。
必须阶梯放大:1万→10万→50万→100万→200万→400万,每级看吞吐/P99 帧偏差/发压机水位三条曲线。吞吐停止线性而水位未满 = 被测饱和(真实上限);水位打满 = 发压机瓶颈(该级作废);schedule lag 上升 = 数据无效。 外推前提是房间人数不变——扇出超线性,PCU 与房间规模必须固定一个扫另一个。