笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 接入与通信

    • 游戏基础架构与工具
    • 接入网关(长连接接入层)
    • 消息总线(共享内存 IPC)
    • 帧同步(Lockstep)
    • 全栈极限低延迟(物理层→应用层)
    • KCP 与 QUIC
  • 网络与服务网格

    • CNI 与 K8s 网络插件
    • 容器运行时:Docker 与 containerd
    • Istio 与 Cilium 服务网格
    • 服务网格:中心化 vs 去中心化
    • eBPF 原理与在网络/可观测/安全的落地
    • 自研 Mesh 服务网格 × K8s 部署
    • 自研 Mesh × K8s · 数据面深水区
    • Tco 协程框架 · 运行时深水区
    • K8s 异构 & 网络插件
  • 有状态服务

    • 分布式游戏存储(分布式 KV)
    • 有状态服务的数据迁移
    • 有状态服务的数据恢复与容灾
    • 有状态服务的 K8s 编排层治理(防漂移 + 节点崩溃恢复)
    • 内存配置热刷新与更新机制
  • 算法与协程

    • 一致性哈希四算法实现(Ring Hash / Ketama / Maglev / Jump Hash)
    • 蓄水池抽样(Reservoir Sampling)与加权扩展
    • C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)
    • Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播
  • 流量与承载

    • 游戏秒杀场景承载
    • 令牌桶与漏桶
    • 限流与熔断
    • 限流:互联网 vs 游戏
    • 灰度发布 · Canary / BlueGreen / A-B / Shadow
    • 服务器日常系统开发与维护 SOP
    • 极限发压工具设计(多长连接 + 发压任务)
    • 性能分析与优化方法论(六条排查线)
  • 编译与排查

    • 编译优化与 LLVM/Clang/GCC
    • Sanitizer 工具链与内存泄漏定位

极限发压工具设计(多长连接 + 发压任务)

发压机瓶颈顺序 · 连接模型 · 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 上不去时,先做对照实验确认瓶颈方,避免把发压机上限误当被测系统上限。由弱到强五条:

  1. 回环基线:发压机压自身(localhost 上的 no-op 服务),排除网络因素,测发压机纯发送能力上限。要求这个上限至少是目标压力的 3~5 倍——只有留出余量,被测系统的数字才有资格谈。
  2. 观测发压机资源:每一个核的 CPU 是否都低于 60~70%(只看平均值会被单核打满掩盖)?网卡 TX 是否饱和(sar -n DEV 1)?FD/端口/conntrack 是否有报错(dmesg)?
  3. 观测被测系统资源:CPU、内存、网卡 RX 是否有余量?若被测系统资源空闲而 QPS 上不去,瓶颈在发压侧。
  4. 换 no-op 服务端对照:把被测服务换成同协议的空实现(收包即丢弃或 echo)。QPS 大幅上升 = 原瓶颈在被测侧;几乎不变 = 瓶颈在发压机,之前的数字全部作废。
  5. 多发压机线性度对照(最硬的证据):加一台发压机,总 QPS 接近线性翻倍 = 单机发压机是瓶颈;停滞在原水平 = 被测系统饱和。这条不依赖对任何资源指标的解读,最难反驳。

schedule lag:发压机的自证指标

上面五条都是外部对照,还需要一条发压机自证指标。发压机内部同时记录计划发送时刻与实际发送时刻,二者之差即 schedule lag:

lag 稳定在低位(如 < 1ms)  = 发压机跟得上,本轮数据可用
lag 单调上升               = 发压机自身饱和,本轮数据无效,直接废弃

关键是把它做成硬门禁:超阈值就自动把该轮结果标记 invalid,而不是事后靠人判断。没有这个门禁,一份发压机饱和产出的报告和一份正常报告长得一模一样——这是压测流程里最容易缺失的一环。

③ 连接模型与发压引擎

长连接池 vs 每请求建连

维度每请求建连(短连接)长连接池(固定 N 条)
临时端口消耗每请求消耗一个,高 QPS 下极易耗尽固定消耗 N 个,与 QPS 无关
建连开销TCP 三次握手 + TLS 握手每次都付一次性摊薄,复用连接
适用场景HTTP/1 短请求、测建连能力gRPC/WebSocket 长连接压测
发压机瓶颈先撞临时端口 → TIME_WAITFD 上限 → 内存/每连接开销

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
  ...          ─┘

配比建议(数量级,非实测):

场景connectionsconcurrency/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 的边界
Clientkeepalive.ClientParameters.Time空闲 T 秒后发一次 PING服务端若认为 T 太小会回 GOAWAY(ENHANCE_YOUR_CALM) 断连
Clientkeepalive.ClientParameters.Timeout发出 PING 后 X 秒未收 ACK 则断连—
Clientkeepalive.ClientParameters.PermitWithoutStream无活跃 stream 时是否也发 PING默认 false(老 grpc-go),压测/长驻场景通常要开
Serverkeepalive.ServerParameters.Time / Timeout服务端主动探活客户端客户端不响应即断
Serverkeepalive.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 跑不出泄漏与累积效应

结论:必须分两套,不能混报。

  1. 控制面 unary 用 ghz:登录、匹配、创建房间、拉配置。这些确实是 unary,ghz 是合适工具,走 open model(--rps)。
  2. 数据面 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(中位数)一半请求比这快反映"普通体验",但掩盖尾部
P9999% 请求比这快1% 用户的最差体验;游戏帧预算关键指标
P99999.9% 请求比这快高并发下每秒都有人踩到;GC/调度抖动在这里暴露
Max最坏单次受测量时长影响大,参考价值有限

时钟源选择:

时钟源特点适用场景陷阱
CLOCK_MONOTONIC单调递增,不受 NTP 回拨影响单机延迟测量首选跨机不可比较(各机起点不同)
CLOCK_REALTIME墙钟,可跨机比较分布式发压时间对齐NTP 调整可能回拨,导致负延迟
RDTSCCPU 周期计数,纳秒级精度,无 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_tsopen 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 个"= 少一个就丢一整段可拆的延迟。

三工具覆盖矩阵

面试问"你打算怎么测帧广播延迟"时,一张表比一段话有力:

想测的延迟段ghzgrpc-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 spanhandler 入口 到 出口纯业务处理耗时

差值大头在哪,优化方向完全不同:大头在 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,极少 syscallTCP/UDP 均可内核 5.1+,调试难
epoll + 批量读写事件驱动,减少空转 syscall高并发长连接编程模型复杂

阶梯五:kernel-bypass(AF_XDP / DPDK)—— 仅 UDP

绕过内核协议栈,用户态直接操作网卡 DMA,延迟可达亚微秒级。

方案协议延迟量级代价
AF_XDP(XDP socket)UDP~1–5μs内核 4.18+,需 XDP 驱动支持
DPDKUDP/自研协议<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 streamK ClientConn × N/K stream
loopyWriter goroutine 数1K
写路径 CPU 分布单核 100%、其它空闲K 核均衡分布
常见吞吐差异baseline2~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).WriteHeadersHPACK 头压缩5~15%(头小可忽略)
google.golang.org/grpc/internal/transport.(*loopyWriter).run上面说的写路径与 loopyWriter 单核瓶颈同源
google.golang.org/protobuf/proto.Marshal / .Unmarshalprotobuf 序列化主要热点,10~40%
google.golang.org/grpc.(*Server).processUnaryRPC服务端 RPC 分发服务端才有
runtime.mallocgc / runtime.gcBgMarkWorkerGC反映消息 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 定位工作流(榨到什么程度停)

标准工作流:

  1. 上阶梯一~四调参、绑核、多进程。
  2. 抓 CPU pprof(go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30),看 top 10 是否落在上面表里的 gRPC 符号。
  3. 若 loopyWriter 占大头 → 加 ClientConn。
  4. 若 proto.Marshal > 30% → 动消息 schema;否则加发压机。
  5. 若 runtime.gc* > 10% → 复用 buffer。
  6. 若 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/gRPCUDP/自研协议备注
调参(FD/端口/conntrack)✅ 必做✅ 必做零成本,先做
绑核 + 中断亲和✅ 有效✅ 有效减跨 NUMA 开销
多进程 / SO_REUSEPORT✅ 有效✅ 有效绕 GIL/GC
io_uring✅ 有效✅ 有效内核 5.1+
sendmmsg⚠️ 有限(TCP 流式)✅ 主要收益UDP 批量发包
多 ClientConn(阶梯 X)✅ 主要收益❌ 不适用绕 loopyWriter 单核瓶颈
HPACK/protobuf pprof(阶梯 X)✅ 常见热点❌ 无 HPACKproto.Marshal 是主嫌疑
GOMAXPROCS + RSS 对齐(阶梯 X)✅ 必做✅ 也适用三件套一起用
阶梯放大压测(阶梯 X)✅ 必做✅ 必做防发压机自己先崩
AF_XDP / DPDK❌ 需自实现 TCP 栈✅ 主要收益UDP 极限场景

读法:TCP/gRPC 场景的完整榨取顺序 = 阶梯一~四 + 阶梯 X(不走阶梯五);UDP 自研协议的完整榨取顺序 = 阶梯一~五(阶梯 X 里只有 GOMAXPROCS/RSS 与阶梯放大对它有用)。

⑥ 分布式发压

单机榨干后,横向扩展为 master-worker 集群。

Master-Worker 编排

关键设计点:

  1. 统一开始时刻:master 下发 start_at = now + 5s,所有 worker 在同一时刻开始发压。若各 worker 错峰启动,峰值被稀释,压不出真实极限。
  2. 任务分片:总目标 RPS 按 worker 数均分(rps_per_worker = total_rps / N),各 worker 独立维护令牌桶。
  3. 心跳与超时:master 定期收 worker 心跳,worker 掉线时重新分配其 RPS 份额或报警。
  4. 结果上报: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 型号与负载,非本环境实测。方法论是要点,具体数字必须用回环基线实测替换。

输入假设

参数取值说明
PCU400 万同时在线玩家数
帧率30 fps(tick 33ms)帧同步 tick 频率
上行 payload50 B单玩家操作输入
下行 payload150 B房间聚合帧(10 人房,每人约 10~15B 操作)
streams/conn100压测侧一条 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。

沉淀结论

  1. 一句话:极限发压 = 先榨干发压机自身,再用 open model + HdrHistogram 保证数字不撒谎,最后横向扩成分布式集群。被测系统的上限,只有在发压机不是瓶颈时才有意义。
  2. 撞墙顺序: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。顺序由「哪个默认值最小且与连接数线性相关」决定。
  3. 信号对应: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 = 网卡单队列。
  4. 瓶颈在哪一侧:① 回环基线(压 localhost 测发压机纯发送上限)② 看发压机资源(某核满?TX 饱和?FD/端口耗尽?)③ 看被测资源(有余量却上不去 = 发压侧瓶颈)④ 多发压机对照(总 QPS 线性增长 = 发压机是瓶颈;停滞 = 被测是瓶颈)。
  5. 连接模型:长连接池固定消耗 N 个端口、与 QPS 无关,先撞 FD 与内存;每请求建连的端口消耗随 QPS 线性增长还叠加 TIME_WAIT,先撞端口。HTTP/2 多路复用让端口彻底退出并发瓶颈(100 conn × 100 stream = 1 万并发只用 100 个端口),瓶颈转为服务端 accept backlog 与 SETTINGS_MAX_CONCURRENT_STREAMS。
  6. open vs closed:closed model 固定并发、发完等响应,被测变慢就自动降速,掩盖过载、产出乐观假数字;open model 固定到达率、不等响应,变慢就积压、延迟飙升,暴露真实拐点,也更接近真实用户流量。测上限必须 open(ghz 的 --rps)。
  7. Coordinated Omission:worker 卡在慢请求上时,「本该发出没发出」的请求等待时间从未被记录,P99 能虚低一个数量级。修正 = 用意图发送时刻而非实际发送时刻计算延迟(HdrHistogram recordValueWithExpectedInterval)。
  8. HdrHistogram:对数桶宽,全动态范围保持相对精度(默认 0.1%),内存固定约 40KB,支持无锁写入与跨实例 merge。t-digest 内存更小(~1KB)、支持流式合并,精度略低但够用于 P99/P999。
  9. 分位数不可平均:P99 是分布上的位置不是可加量,必须合并原始分布再取分位(histogram.add())。10ms 与 100ms 平均出来的 55ms 数学上无意义。
  10. 时钟源:CLOCK_MONOTONIC 单调不回拨、单机首选但跨机不可比;CLOCK_REALTIME 可跨机但 NTP 回拨会产生负延迟;RDTSC 纳秒精度无 syscall 但跨核不一致、变频下周期≠时间。最稳是只在发压机侧闭环计时。
  11. 极限阶梯:① 调参(FD/端口/conntrack/缓冲,零代码)② 绑核 + 中断亲和 + RSS 多队列 ③ 多进程 / SO_REUSEPORT(绕 GIL、摊薄 GC 抖动)④ 批量 syscall(sendmmsg/io_uring,内核 5.1+)⑤ kernel-bypass(AF_XDP ~1–5μs / DPDK <1μs)——⑤ 只适用 UDP。
  12. TCP 不上 DPDK:绕过内核栈就得自己实现 TCP+TLS+HTTP/2,成本极高;TCP/gRPC 老实走阶梯一~四,sendmmsg 对 TCP 流式也只有有限收益。
  13. 分布式四要点:统一开始时刻(start_at = now + 5s,错峰会稀释峰值)、任务分片(rps_per_worker = total_rps / N,各自令牌桶)、心跳与掉线重分配、上报 HdrHistogram 快照由 master merge(不是上报分位数)。
  14. unary 覆盖不了帧同步:ghz 的 stream 语义是「开-发完-关-记一次延迟」,仍是请求-响应。帧同步是长驻 stream 按固定 tick 收发。unary 测不出长期状态、扇出放大、flow-control 打满、帧节奏、GC 累积。分两套:控制面 unary 用 ghz,数据面自研 harness。
  15. 帧同步指标不是 RT:帧到达偏差(相对计划 tick)、帧率保持率、丢帧/乱序率、广播扇出到最后一人的延迟、稳态驻留时长。丢帧是 CO 的变体——没发出的帧不产生样本,丢一半帧曲线反而更漂亮,必须按 tick 逐帧核对序号。
  16. P99 报端到端 RT,但必须同时报服务端耗时:差值 = wire RTT + 两侧内核排队 + accept backlog + goroutine 调度 + flow-control 等待 + TLS + 发压机自身 GC/调度抖动。三层打点定位:客户端 RT / 服务端 socket 层(eBPF tcp_sendmsg)/ handler span。
  17. 发压机压满后延迟偏哪边——看口径:open + 计划发送时刻 → 偏大(伪劣化,误导优化不存在的瓶颈);closed + 实际发送时刻 → 偏小(CO 丢掉最坏那批 + 自动降速消掉过载)。同时存在时 P50 偏大、P99 偏小,最危险。
  18. schedule lag 是发压机自证指标:计划发送时刻 − 实际发送时刻。低位稳定 = 数据可用;单调上升 = 发压机饱和、本轮作废。必须做成硬门禁——饱和产出的报告和正常报告长得一模一样。
  19. 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 台,升网卡比堆机器便宜。
  20. 两个口径别混:回环基线 3~5 倍是单机 sanity check(验机型);65% 水位用于算集群规模。用 3~5 倍算规模会把机器数放大 3~5 倍。
  21. 阶梯放大验证: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 与房间规模必须固定一个扫另一个。

最近更新: 2026/9/10 11:38
Prev
服务器日常系统开发与维护 SOP
Next
性能分析与优化方法论(六条排查线)