压测:单机连接数与延迟实测
本章目标:自研一个 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 秒),只用来示范记录格式与数量级,不是承诺值,你的机器必须自测。
| 实验 | 配置 | 建连成功率 | 在线连接数 | P50 | P99 | 判读 |
|---|---|---|---|---|---|---|
| 建连 | -c 5000 -rate 1 | 接近 100% | 数千 | — | — | 未触上限,可继续加 |
| 建连 | -c 20000 -rate 1 | 显著下降 | 万级 | — | — | 常见瓶颈在 fd 或端口,看 ss -s |
| 延迟 | -c 5000 -rate 5000 | 100% | 数千 | 亚毫秒~数毫秒 | 数十毫秒 | 正常区间 |
| 延迟 | -c 5000 -rate 40000 | 100% | 数千 | 明显抬升 | 数量级抬升 | 拐点出现,找 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 抬升说明局部排队,而压测机与服务端必须分开,否则数字只证明压测机很闲。