压测:单机连接数与延迟实测

本章目标:自研一个 Go 压测客户端(bench client),摸清 im-go 单机的两条线——能挂住多少条连接(connection capacity)与消息往返延迟(RTT)的分布;先把内核与 ulimit 的隐形上限清掉,再按恒定速率发压,最后拿到建连成功率与 P50/P99 两张结果表。

1. 前置检查:先清掉隐形上限

# 服务端与压测机都要看:每条 WebSocket 连接约占 1 个 fd
ulimit -n                              # 期望 ≥ 1048576;默认 1024 会最先崩在这里
cat /proc/sys/fs/file-max              # 整机 fd 上限,配 /etc/security/limits.conf 一起调

# 服务端:半连接与全连接队列
sudo sysctl -w net.core.somaxconn=65535 net.ipv4.tcp_max_syn_backlog=65535

# 压测机:本机可用端口数决定「一台压测机能建多少连接」(默认约 2.8 万)
sudo sysctl -w net.ipv4.ip_local_port_range="10000 65000"

ulimit -n 只在当前 shell 生效,长跑要写进 /etc/security/limits.conf,systemd 托管时还要加 LimitNOFILE(第 14 章)。一台压测机能模拟的连接数受限于本机端口数,想压到十万连接要加压测机,或给一台机器多绑几个 IP(net.ipv4.ip_nonlocal_bind=1)再按 IP 轮询发起连接。

2. 压测客户端骨架:并发建连 + 按速率发送 + 统计分位数

服务端在压测环境开启「回声模式(echo mode)」:收到 {"ts":t} 原样回发,客户端用回包时间减 ts 得到一次完整链路的 RTT,这个开关在生产必须关闭。压测连接必须带真实的 token 与 device_id,走完整的握手、鉴权与心跳流程(第 02、04 章),否则压出来的上限是假的;令牌建议用 /api/v1/login 批量换取一批真实账号,比所有连接共用一个 uid 更接近线上分布。

// cmd/bench/main.go:每条连接一个 goroutine,读循环只负责算 RTT
package main

import (
	"flag"
	"fmt"
	"sort"
	"sync"
	"sync/atomic"
	"time"

	"github.com/gorilla/websocket"
)

type frame struct {
	TS int64 `json:"ts"` // 发送时刻(Unix 纳秒),回声模式原样回发
}

var addr = flag.String("addr", "ws://127.0.0.1:8080/ws", "网关地址")
var token = flag.String("token", "", "压测账号令牌(第 04 章登录接口换取)")
var conns = flag.Int("c", 1000, "并发连接数")
var rate = flag.Int("rate", 5000, "全局发送速率(条/秒)")
var secs = flag.Int("d", 60, "持续秒数")

func main() {
	flag.Parse()
	var (
		mu               sync.Mutex
		samples          []int64 // RTT 样本(纳秒);长跑请换成定宽直方图
		sent, recv, fail int64
	)
	// 全局速率摊到每条连接:聚合速率 ≈ -rate。要严格控制总速率就换成全局 ticker 分发
	interval := time.Duration(float64(time.Second) * float64(*conns) / float64(*rate))
	deadline := time.Now().Add(time.Duration(*secs) * time.Second)

	for i := 0; i < *conns; i++ {
		go func(id int) {
			url := fmt.Sprintf("%s?token=%s&device_id=bench-%d", *addr, *token, id)
			c, _, err := websocket.DefaultDialer.Dial(url, nil)
			if err != nil { atomic.AddInt64(&fail, 1); return }

			go func() { // 写循环:按周期发,写失败说明该连接已被回收
				t := time.NewTicker(interval)
				defer t.Stop()
				for time.Now().Before(deadline) {
					<-t.C
					if err := c.WriteJSON(frame{TS: time.Now().UnixNano()}); err != nil { return }
					atomic.AddInt64(&sent, 1)
				}
			}()

			var f frame // 读循环:收到回声帧即得到一次端到端延迟
			for {
				if err := c.ReadJSON(&f); err != nil { return }
				atomic.AddInt64(&recv, 1)
				mu.Lock()
				samples = append(samples, time.Now().UnixNano()-f.TS)
				mu.Unlock()
			}
		}(i)
	}

	time.Sleep(time.Until(deadline) + 3*time.Second) // 留 3 秒收在途回包
	if len(samples) == 0 { panic("零回包:确认回声模式已开启") }
	sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
	pct := func(q float64) time.Duration { return time.Duration(samples[int(float64(len(samples)-1)*q)]) }
	fmt.Printf("建连失败=%d sent=%d recv=%d P50=%s P99=%s max=%s\n",
		atomic.LoadInt64(&fail), atomic.LoadInt64(&sent), atomic.LoadInt64(&recv),
		pct(0.50).Round(time.Millisecond), pct(0.99).Round(time.Millisecond),
		time.Duration(samples[len(samples)-1]).Round(time.Millisecond))
}

压测客户端直接退出、连接交给内核回收,是少数不需要优雅退出的进程;但样本用切片累积会随消息量线性吃内存,长时间高速率压测请把 samples 换成定宽直方图(histogram),只保留桶计数。

跑之前先想清楚跑哪三组(每条命令都要带上 -token=$T):建连能力(速率压到 1 条/秒,只测能不能挂住,-c 20000 -rate 1 -d 120)、延迟拐点(固定 -c 5000,速率阶梯 1000 → 5000 → 10000 → 40000 各跑一遍)、对照实验(同一组参数在「回声模式」与「真实投递链路」下各跑一次,差值就是业务处理成本)。一台机器上不要同时跑服务端和压测客户端——压测机自己也要吃 CPU 与端口,混跑得到的数字没有参考价值;每档至少跑 2 分钟,前 30 秒预热数据丢弃。

3. 结果表格模板(示例环境,仅作数量级参考)

下面的数字来自示例环境(服务端 4C8G 云主机、压测机 8C16G、同可用区内网、Go 1.22、心跳 30 秒),只用来示范记录格式与数量级,不是承诺值,你的机器必须自测。

实验配置建连成功率在线连接数P50P99判读
建连-c 5000 -rate 1接近 100%数千——未触上限,可继续加
建连-c 20000 -rate 1显著下降万级——常见瓶颈在 fd 或端口,看 ss -s
延迟-c 5000 -rate 5000100%数千亚毫秒~数毫秒数十毫秒正常区间
延迟-c 5000 -rate 40000100%数千明显抬升数量级抬升拐点出现,找 CPU/GC/队列
延迟真实投递链路—数千数毫秒数十毫秒与回声值之差即业务成本

判读三条经验:建连成功率跌破 99% 的位置就是单机连接数上限,先看 ss -s 与 lsof -p <pid> | wc -l 判断是 fd、端口还是 CPU 先满;P99 抬升而 P50 不动通常是少量连接遇到锁竞争或写队列排队,属于局部问题;P50 与 P99 一起抬升才是系统级饱和,优先看 CPU 使用率与 GC(GODEBUG=gctrace=1)。

常见坑

坑现象做法
忘了改 ulimit -n建连不到目标数就报 too many open files服务端与压测机都查,systemd 下补 LimitNOFILE
压测机和服务端同机延迟数据漂亮但线上完全不同分开部署,压测机只跑客户端
压测机端口耗尽连接数卡在 2 万上下,重试也上不去扩大 ip_local_port_range 或多绑 IP
用「固定间隔发、收完再发」的同步模式延迟数字被压得虚低(协调遗漏)恒定速率异步发压,超时也记为一次失败
只报平均延迟掩盖长尾,线上体验对不上必须报 P50/P99/max,最好带直方图
压完不清理测试数据MySQL 与 Redis 被测试消息灌满用独立测试库,压完按 uid 段清理
边压测边发版结果混入重启抖动,白跑一轮压测窗口与发布窗口分开

小结:压测的顺序是「先清 fd、队列、端口三处内核上限,再恒定速率发压,最后看分位数而不是平均值」;建连成功率跌破 99% 的位置就是单机连接数上限,P50 与 P99 一起抬升说明系统饱和、只有 P99 抬升说明局部排队,而压测机与服务端必须分开,否则数字只证明压测机很闲。

笔记加载中…