用 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 章。

笔记加载中…