压测方案:连接数、消息吞吐与延迟分位

IM 压测比普通 Web 压测难在两点:要“先养连接再打请求”,而连接数本身就是一项容量指标;关心的是下行扇出后的字节数,不是消息条数。方案要先把“压什么”拆成可分别验证的测试项,再用能造出真实连接模型的工具去压。

测试项清单与验收口径

测试项压法验收口径常见失败现象
连接建立速率(CPS)从 0 按阶梯爬升到目标 CCU峰值 CPS 达标,建连失败率 < 0.1%鉴权先被打满、accept 队列溢出
单机连接上限单实例持续建连到延迟抖动找内存与 CPU 同时抬头的拐点,取 70% 为安全值只报“连上 20 万”,不看内存曲线
上行消息吞吐固定在线连接下按目标 QPS 上行达到设计 QPS 且 P99 ≤ 目标逻辑层先饱和,网关被误判为瓶颈
广播放大大群一条消息扇出 N 个下行按下行字节数设指标,慢客户端不拖垮网关单个群打满出口带宽
端到端延迟分位记录 ACK 与对端收包的时间差给出 P50/P90/P99/P999 四个值只给平均值,长尾被掩盖
内存占用曲线每 1 万连接采一次指标曲线线性、斜率稳定,断连后能回落斜率突然抬升(全局结构膨胀)
断线重连风暴kill 一台接入机或注入丢包后恢复重连速率可控,鉴权与路由表不被打穿重连无退避抖动,形成二次雪崩

自研 Go 压测客户端

十万级以上连接只能自己写:每条连接一个读循环,写入走共享发送池以省协程,上行用令牌桶控速,延迟按直方图统计而不保留单样本。

// 以下片段需放入 main 函数中运行(省略配置解析与结果上报实现)
func runClient(addr string, conns, qps int) {
	dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second, ReadBufferSize: 1024, WriteBufferSize: 1024}
	lat := newHistogram()            // 直方图统计分位,内部用环形缓冲
	send := make(chan []byte, 65536) // 共享发送池:省下每条连接一个写协程
	pending := newShardedMap()       // 待确认表,超时未回的条目按 TTL 清理
	var failed int64
	for i := 0; i < conns; i++ {
		go func(id int) {
			c, _, err := dialer.Dial(addr, authHeader(id)) // 每个连接独立 token
			if err != nil {
				atomic.AddInt64(&failed, 1) // 建连失败要单独计数,混进延迟里会失真
				return
			}
			defer c.Close()
			go recvAck(c, pending, lat) // 读循环:收 ACK 与下行,算出端到端延迟
			for b := range send { // 定向消息场景改成 per-conn 队列,避免乱序
				pending.Put(nextMsgID(), time.Now())
				c.SetWriteDeadline(time.Now().Add(5 * time.Second))
				if err := c.WriteMessage(websocket.BinaryMessage, b); err != nil { return }
			}
		}(i)
	}
	go feed(send, rate.NewLimiter(rate.Limit(qps), qps/10)) // 令牌桶控上行 QPS
	report(lat)
	select {} // 压测进程常驻,由外部信号控制退出
}

三条经验:建连要并发发起但限速,串行建连会把 CPS 测试变成 TLS 握手测试;待确认表必须有 TTL 清理,否则内存随超时消息无限增长;压测端自己也要看 ss -s、fd 数与 GC,客户端先崩会把结论带偏。

k6 的 WebSocket 场景

import ws from 'k6/ws';
import { check } from 'k6';
import { Trend } from 'k6/metrics';
const ackLatency = new Trend('ack_latency', true);
export const options = { // 先爬坡建连、再长时间保持,才能同时看 CPS 与延迟分位
  scenarios: { hold: { executor: 'ramping-vus', gracefulStop: '30s',
    stages: [{ duration: '2m', target: 5000 }, { duration: '10m', target: 5000 }] } },
};
export default function () {
  const res = ws.connect(__ENV.WS_ADDR + '?token=' + __ENV.TOKEN, {}, (socket) => {
    socket.on('open', () => socket.send(JSON.stringify({ type: 'auth', ts: Date.now() })));
    socket.on('message', (m) => {
      const p = JSON.parse(m);
      if (p.type === 'ack') ackLatency.add(Date.now() - p.ts); // 端到端延迟
    });
    socket.setInterval(() => socket.send(JSON.stringify({ type: 'ping' })), 30000);
    socket.setTimeout(() => socket.close(), 700000); // 长于压测时长,避免提前断开
  });
  check(res, { 'upgrade 101': (r) => r && r.status === 101 });
}
k6 run -e WS_ADDR=ws://10.0.0.10:8080/ws -e TOKEN=demo-token bench-ws.js

k6 适合“小规模、长时间、看分位”的回归:连接数上不去,但指标口径干净,可以进流水线每次发版跑一遍。

ghz 打 gRPC 与 wrk 打 HTTP 接口

ghz --insecure --proto ./proto/im.proto --call im.v1.MsgService/Send \
  -d '{"conv_id":"c_1","client_msg_id":"m_1","content":"在吗"}' \
  -c 200 --rps 8000 --duration 60s --timeout 3s \
  --metadata '{"authorization":"Bearer <token>"}' --format json --output ghz-send.json 127.0.0.1:9090

关键输出看 latency distribution(P50/P90/P99)、status code 分布与实际 rps。压之前先预热鉴权缓存与群成员缓存,否则压的是 Redis;并发(-c)与目标速率(--rps)要分开调,才能分清瓶颈在转发还是在消息处理。离线拉取与历史分页是 IO 型接口,用 wrk 并把请求打到不同位点与分页深度,否则缓存命中率虚高:

wrk -t8 -c500 -d120s --latency -s post.lua http://127.0.0.1:8080/api/message/sync
-- post.lua:每个请求换一个 uid,避免单用户热点把结论带偏
local i = 0
request = function()
  i = i + 1
  wrk.headers["Authorization"] = "Bearer " .. (i % 10000)
  wrk.body = string.format('{"cursor":%d,"limit":50}', i % 1000)
  return wrk.format("POST", nil, { ["Content-Type"] = "application/json" })
end

压测机与被压机的准备

ulimit -n 1048576                                      # 连接数与 fd 上限
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.ipv4.ip_local_port_range="10000 65535"   # 压测机的出口端口范围
sysctl -w net.ipv4.tcp_tw_reuse=1
  • 出口端口耗尽:单个 IP 最多约 5.5 万个可用端口,10 万连接至少要 2 个 IP(多网卡、多 VIP 或多台压测机);接入机侧看 ss -s(TIME_WAIT 数)、ss -lnt(accept 队列)与 /proc/net/netstat 的 ListenOverflows,两端都要校时,否则端到端延迟不可信。

广播放大与内存曲线

场景上行下行放大倍数观察点
500 人群1 条 200B500 条500网关下行字节数、单连接写缓冲
10 万人群(在线 5%)1 条 200B5000 条5000出口带宽、慢客户端堆积
混合真实负载按群规模分布加权按分布放大平均 3~10平均扇出 vs 最大扇出
放大按字节算:上行 1000 条/s × 200B = 200 KB/s,同一批消息若都发在 500 人群,下行就是 100 MB/s。大群压测的指标必须写成“下行 GB/s 与单机出口利用率”,写成消息条数会严重低估。内存曲线看 RSS、heap_inuse、goroutine 数、fd 数与 GC 暂停:斜率突然抬升通常意味着全局结构在膨胀(广播订阅 map、会话索引、无上限的离线队列);GOGC=100 时堆可能在一个周期内接近翻倍;断连后 RSS 不回落不等于泄漏,要看 heap_released。

压测报告该写什么

【结论】      X 规格 × Y 台下可达 CCU / 上行 QPS / P99 各为多少,不达标的短板在哪一层
【环境与方法】拓扑、机器规格、内核参数、数据量级与分布(群规模、人均消息数)、阶梯加压矩阵与重复次数
【曲线与拐点】CPS 与建连失败率、连接数与内存/CPU、上行 QPS 与 P99、广播下行带宽;单机连接上限、单机消息上限、P99 陡增点各附火焰图 / ss 输出 / Redis 慢日志证据
【依赖与风险】MySQL 写放大、Redis 命令量与带宽、MQ 堆积、出口带宽;未覆盖场景与下次压测计划

没有拐点证据的容量结论等于猜:只写“跑到 5 万 QPS”而不写当时的 CPU、内存、延迟与下游水位,评审时无法判断还剩多少余量。压测三纪律:环境同构(规格、内核参数、数据量级一致)、流量可识别可隔离(专用 uid 段,一键停止)、结论可复现(脚本与参数入库)。

小结:IM 压测的测试项固定为七项——建连速率、单机连接上限、上行吞吐、广播放大、延迟分位、内存曲线、重连风暴;工具按环节分工,自研 Go 客户端负责十万级以上连接,k6 负责场景回归,ghz 与 wrk 分别压逻辑层与 HTTP 接口;指标一律落到 P99 与下行字节数,报告必须给出环境、方法、曲线、拐点证据与风险清单。

笔记加载中…