用 AI 压长连接:连接数与消息吞吐
长连接(Long Connection)压测的交付物是三个数字:建连速率(CPS,connections per second)、稳定在线连接数(CCU,concurrent connections)、消息延迟 P99。让 AI 生成脚本时就把这三项写成脚本的输出,否则你拿到的报告只会说「压了 10 万连接」,却说不清这些连接能不能撑住。
一、三个核心指标
| 指标 | 定义 | 合格线参考 | 容易混淆的点 |
|---|---|---|---|
| 建连速率 CPS | 每秒完成握手(含鉴权)的连接数 | 峰值 CPS ≥ 重连风暴理论峰值 × 2 | 与总连接数不是一回事:2 秒建 10 万和 10 分钟建 10 万是两种系统 |
| 在线连接数 CCU | 稳态维持的连接数,同时盯每条连接的内存成本 | 内存与 fd 都留 30% 余量 | 压测机端口数常先于服务端 fd 耗尽 |
| 消息延迟 P99 | 发到收的端到端延迟分位 | 业务消息 P99 达标 | 心跳混进业务统计会稀释 P99、虚高 QPS |
二、给 AI 的提示词
角色:性能测试工程师,输出可直接运行的 k6 脚本与 Go 客户端骨架各一份。
协议:WebSocket(RFC 6455),ws://10.0.0.12:8080/ws,明文。
鉴权:连接 URL 带 token=...,token 来自 ./tokens.txt,每行一个,每个连接取一个。
业务:建连后每 30 秒发心跳 {"t":"ping"},服务端回 {"t":"pong"};客户端每 1 秒发一条
业务消息 {"t":"msg","seq":N,"ts":T},服务端回 {"t":"ack","seq":N}。
目标:20000 条稳定在线连接 + 全体合计 5000 条/秒业务消息,消息 P99 < 200ms,持续 10 分钟。
硬性要求:
1) 输出必须区分「建连失败数」「握手耗时 P95」「在线连接数」「业务消息 P99」「心跳超时数」
2) 连接不得提前主动关闭,必须活满压测全程
3) 说明服务端 idle timeout 与心跳间隔的关系,不确定就写进「待确认」
4) 附上被压端与压测端的 fd / 端口检查命令
第 2 条必须写进提示词:AI 默认喜欢给每条连接设一个几十秒的定时关闭,那是快速建连压测,不是长连接稳态压测。
三、k6 的 WebSocket 脚本
// bench/ws-hold.js:先爬坡建连,再长时间保持
import ws from 'k6/ws';
import { check } from 'k6';
import { Trend, Counter } from 'k6/metrics';
const ackLatency = new Trend('msg_ack_latency', true); // 业务消息端到端延迟
const heartbeats = new Counter('heartbeats'); // 心跳单独计数,不进业务 QPS
const tokens = open('./tokens.txt').split('\n').filter(Boolean);
export const options = {
scenarios: {
hold: {
executor: 'ramping-vus',
stages: [
{ duration: '2m', target: 2000 }, // 爬坡:看 CPS 能否顶住
{ duration: '1m', target: 20000 }, // 冲目标连接数
{ duration: '10m', target: 20000 }, // 稳态:看内存与延迟的累积曲线
],
gracefulStop: '30s',
},
},
thresholds: {
ws_connecting: ['p(95)<500'], // 握手耗时
checks: ['rate>0.99'], // 建连成功率
msg_ack_latency: ['p(99)<200'],
},
};
export default function () {
const token = tokens[(__VU + __ITER) % tokens.length]; // 每连接一个真实 token
const res = ws.connect(`${__ENV.WS_ADDR}?token=${token}`, {}, (socket) => {
socket.on('open', () => {
// 心跳间隔要明显小于服务端 idle timeout,否则连接会被判空闲断开
socket.setInterval(() => { socket.send(JSON.stringify({ t: 'ping' })); heartbeats.add(1); }, 30000);
// 业务消息:1 秒周期近似 1 条/秒,需要精确速率就换全局限速器
socket.setInterval(() => socket.send(JSON.stringify({ t: 'msg', seq: __ITER, ts: Date.now() })), 1000);
});
socket.on('message', (m) => {
const p = JSON.parse(m);
if (p.t === 'ack') ackLatency.add(Date.now() - p.ts); // 只算业务消息
});
// 刻意不调用 socket.close():连接必须活满全程,由 stage 结束统一回收
});
check(res, { 'upgrade 101': (r) => r && r.status === 101 });
}
k6 run -e WS_ADDR=ws://10.0.0.12:8080/ws --out json=ws-raw.json bench/ws-hold.js
四、自研 Go 客户端骨架
// cmd/wsbench/main.go:只保留骨架要点,省略参数解析与指标上报
func run(addr string, conns, ratePerSec int, d time.Duration) {
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second} // 握手超时必须设,否则爬坡卡死
hist := newHistogram() // 定宽直方图,长跑不用切片存样本
limiter := rate.NewLimiter(rate.Limit(ratePerSec), ratePerSec/10) // 全局令牌桶,聚合速率才可控
deadline := time.Now().Add(d)
for i := 0; i < conns; i++ {
go func(id int) {
c, _, err := dialer.Dial(addr+"?token="+tokenOf(id), nil)
if err != nil {
atomic.AddInt64(&connFail, 1) // 建连失败单独计数,不混进延迟
return
}
defer c.Close()
go heartbeat(c) // 心跳与业务发送分离,否则心跳被挤掉会被误判为服务端不稳
go sendLoop(c, limiter, deadline) // 业务发送受全局令牌桶限制
readLoop(c, hist, &hbTimeout) // 读循环:算端到端延迟并响应心跳
}(i)
time.Sleep(time.Second / 50) // 关键:并发发起但限速,串行建连会把 CPS 测试变成握手测试
}
}
k6 适合小规模长跑回归与阈值断言;两万条以上、或者要精确控制消息速率时用自研客户端,readLoop 里统计的是端到端延迟,connFail 与 hbTimeout 必须单独计数——混进延迟会得到一份无法归因的报告。
五、连接不关与心跳的处理
| 处理项 | 错误做法 | 正确做法 |
|---|---|---|
| 连接生命周期 | 每条连接 30~60 秒后 close() | 不主动关闭,由 stage 结束统一回收 |
| 空闲断开 | 心跳间隔大于服务端 idle timeout | 心跳间隔取 timeout 的 1/3 以内,并在报告里记录两个值 |
| 心跳统计 | 心跳计入 QPS,拉高吞吐、稀释 P99 | 心跳与业务消息分别计数、分别统计延迟 |
| 断线处理 | 静默重连不计数,重连风暴被隐藏 | 重连次数与退避策略单独上报 |
| 全局速率 | 每条连接各发各的,聚合速率随连接数线性上涨 | 用全局令牌桶摊到所有连接,聚合速率才可控 |
六、被压端与压测端的检查命令
ulimit -n ; cat /proc/sys/fs/file-max # 两端都看:fd 上限决定能挂多少连接
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog # 服务端:半连接与全连接队列
sysctl net.ipv4.ip_local_port_range # 压测端:单 IP 可用端口决定单机上限
ss -s # 连接总数与 TIME_WAIT 数
ss -tan state established | wc -l # 已建立连接数,与期望值比对
ss -lnt # 监听队列 Send-Q,溢出即开始丢连
lsof -p $(pgrep -f wsbench) | wc -l # 压测端实际 fd 占用
两端都要改:只调服务端时,压测机会先在 dial: cannot assign requested address 上崩掉,而这个现象很容易被误读成服务端拒绝连接。
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
| 把建连压测当长连接压测 | 报告里有 CPS 没有稳定性 | 建连与稳态分两段跑,稳态不少于 30 分钟 |
| 压测机与被压机同机 | 双方抢 CPU,数字无意义 | 分开部署,同网段同可用区 |
| 只报连接数不报内存 | 内存线性上涨到 OOM 才暴露 | 每 1 万连接采一次内存、fd、goroutine |
| 所有连接共用一个 token | 单账号被限流,结论失真 | 每连接独立 token,压测前批量预取 |
| 没有恢复段 | 无法判断资源能否回落 | 末尾加降为 0 的 stage,检查 fd 与内存回基线 |
小结:长连接压测的三项交付物是 CPS、CCU 与业务消息 P99,写提示词时就要把这三项写成「脚本必须输出的字段」,并明确要求连接不得提前关闭、心跳与业务消息分开统计;k6 的 ws 负责小规模长跑回归与阈值断言,两万条以上改用自研客户端,配齐建连限速、全局令牌桶与直方图三件套;开压前先把两端的 ulimit -n、somaxconn、端口范围查一遍。IM 场景的连接模型、广播放大与容量估算见 im-ws 目录第 18 章、im-system 目录第 26 章与 im-project 目录第 13 章。