压测与容量评估:连接数、吞吐与延迟测量

长连接压测和 HTTP 压测最大的区别是:建连只是开始。真正要回答的是「维持 20 万连接之后还能推多少消息、延迟涨到多少」。因此压测必须分三层做,而且压测机自己先成为瓶颈的概率,远高于被测服务先到极限。

三层压测,不要混在一起

层次目标工具关键指标常见误判
建连(Handshake)每秒能升级多少连接、fd 与内存上限k6、自研 Go 压测器handshake/s、失败率、内存/连接压测机端口耗尽被当成服务端拒绝
保活(Keepalive)维持 N 条连接的成本k6 ws 长跑、自研长连接压测器内存/连接、心跳丢失率、GC 次数只压 5 分钟,看不到累积上涨
收发(Throughput)消息吞吐与延迟分位k6 ws、服务端打点msg/s、P50/P99/P999、写队列水位压测客户端 GC 停顿算进服务端延迟

wrk 只能压 HTTP 接入层(登录取 token、拉历史消息、健康检查),它不会发送 Upgrade: websocket,用它冒充 WebSocket 压测会得到一个毫无意义的数字。ghz 用于内部推送 RPC(业务到网关、网关之间的 gRPC 投递),关心的是吞吐与 P99。

先用 curl 确认握手本身是对的

curl --http1.1 -i -N -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  'http://127.0.0.1:8080/ws?token=bench'
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

拿到 101 才算握手通过。拿到 200 说明中间层把 Upgrade 吃掉了(按普通 HTTP 处理);400/426 多半是版本或 Sec-WebSocket-Key 不对;502 说明上游没在监听或已过载。压测前先跑通这一条,能省掉一半的排查时间。

k6 压 WebSocket

import ws from 'k6/ws';
import { check } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 5000 },   // 台阶一:确认基线
    { duration: '5m', target: 50000 },  // 台阶二:稳态观察累积增长,试探上限
    { duration: '2m', target: 0 },      // 恢复段:看资源是否回落
  ],
  thresholds: {
    ws_connecting: ['p(95)<500'],        // 握手 P95 小于 500ms
    checks: ['rate>0.99'],
  },
};

export default function () {
  const res = ws.connect('ws://10.0.0.12:8080/ws?token=bench', {}, (socket) => {
    socket.on('open', () => socket.send(JSON.stringify({ t: 'hello', ts: Date.now() })));
    socket.on('message', (m) => socket.send(JSON.stringify({ t: 'ack', ts: Date.now() })));
    socket.setTimeout(() => socket.close(), 60000); // 每条连接保活 60 秒
  });
  check(res, { 'status is 101': (r) => r && r.status === 101 });
}
k6 run --out json=result.json ws-bench.js
     ✓ status is 101
     ws_connecting........: avg=41.2ms  med=33.1ms  max=842ms  p(95)=118.4ms
     ws_msgs_received.....: 4821093   24091.9/s
   vus_max................: 50000

vus_max 到 50000 而 ws_connecting 的 P95 开始抬头,这个台阶就是拐点所在区间。

ghz 压内部推送 RPC

ghz --insecure -c 50 -n 200000 --proto ./im.proto \
  --call im.PushService.Push -d '{"uid":"u-1001","msgId":"m-1"}' 10.0.0.12:9090
Summary: Count: 200000  Total: 41.28s  Average: 10.29ms  Requests/sec: 4844.55
Distribution: 10% in 3.21ms / 50% 8.42ms / 95% 24.10ms / 99% 41.77ms

平均值 10ms 但 P99 41ms,说明推送链路存在排队;此时应同时看网关的写队列长度与 go_goroutines,而不是继续加压。

压测期间必须同步观测服务端

ss -s ; ss -tan state established | wc -l      # 连接总数与已建立连接数
sar -n DEV 1 10 ; sar -u 1 10                  # 带宽与 CPU 是否打满
cat /proc/$(pgrep -f im-gateway)/status | grep -E 'Threads|VmRSS'
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -3

报告里必须把「连接数、msg/s、延迟 P99」画在同一时间轴上。只有同轴对比,才能看出拐点:连接数还在涨但吞吐不再涨、延迟开始陡增的那一刻,就是这套配置的容量上限。

容量估算:先算上限,再留余量

单机容量 = min(
  内存 / 每连接内存(无 TLS 约 20~50KB,TLS 再加 30~80KB),
  fd 上限(ulimit -n,需预留 20% 给日志、下游连接),
  端口与 conntrack(经过 NAT 或作为客户端时才会成为瓶颈),
  CPU:心跳与推送的每连接成本 × 连接数
)
示例:128GB 内存、每连接 40KB → 理论 320 万;ulimit -n = 1048576 → 约 100 万;
      实测 20 万连接、CPU 70% 时延迟抬头 → 按 20 万/机 定容,预留 2 倍余量。
资源参考量级说明
内存20 ~ 50KB / 连接含两个协程栈、读写缓冲、映射条目
内存+30 ~ 80KB / 连接(TLS)用会话复用可降低握手期峰值
CPU心跳 30s 一次,10 万连接约 3.3k 心跳/s推送成本远高于心跳,要分开压
文件句柄1 连接 1 fd进程内还要为日志、DB、下游预留
带宽消息尺寸 × 条数 × 2(上下行)小包多时协议头放大明显
连接建立速率受 somaxconn、tcp_max_syn_backlog 限制重连风暴时最先撞到这一项

常见坑

坑现象正确做法
压测机与被测服务同机双方互相抢 CPU,数据无意义压测机独立节点,且与被测在同一网段
压测机端口/fd 先耗尽dial: cannot assign requested address加源 IP、开 tcp_tw_reuse、提高 ulimit -n
只压建连不压保活上线三天后内存涨满至少跑 30 分钟以上稳态,观察累积曲线
压测客户端把心跳挤掉服务端主动断开,误判为服务端不稳压测器必须实现心跳与 pong 响应
只看平均延迟掩盖长尾,P99 才是用户体感固定输出 P50/P95/P99 与最大值
没有基线版本对比无法判断优化是否有效每次压测记录版本号、内核参数、压测脚本 hash
压到极限直接结束无法判断资源能否回落必须包含恢复段,验证 fd 与协程回落到基线

巡检项

  • 每季度重跑一次容量压测,记录版本、内核参数、连接数上限与拐点位置。
  • 压测脚本与参数(连接数、消息尺寸、心跳间隔、发送频率)纳入版本库,与线上配置一致。
  • 压测报告模板固定七项:连接数、msg/s、P50/P95/P99、内存/连接、CPU、错误率、拐点位置。
  • 压测结束必须检查 fd、goroutine、内存是否回到基线,否则先按第 17 章排查泄漏。

小结:容量不是一个数字而是一组约束——先分清建连、保活、收发三层分别压,再用 curl 验证握手、用 k6 压 WebSocket、用 ghz 压内部推送 RPC;压力上来时同步采集 ss -s、sar、pprof 三类数据画在同一时间轴上找拐点,最后按内存、fd、CPU、带宽里的最小值定容并预留两倍余量,同时留出恢复段验证资源能回落。

笔记加载中…