压测方案:连接数、消息吞吐与延迟分位
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 条 200B | 500 条 | 500 | 网关下行字节数、单连接写缓冲 |
| 10 万人群(在线 5%) | 1 条 200B | 5000 条 | 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 与下行字节数,报告必须给出环境、方法、曲线、拐点证据与风险清单。