2.4 单机压测:先摸清单台的底

场景

机器买回来了,文档里写着预估 30 万连接,谁敢信?必须压。压测不是炫技,而是回答一个问题:这台机器的真实底线在哪,线上才好按这个数字做容量规划。我们的目标是把一台 8核 16GB 的 CVM 压到极限,记录连接数、内存、CPU、带宽四个指标,找出第一个先断气的瓶颈。可以说压测是全书里唯一一次让估算面对现实的机会,此后所有线上告警阈值都以它为标尺,重要性不亚于任何一行业务代码。云服务器规格清单见 cloud.tencent.com/document/product/213,我们这次压测用的就是标准型 SA2 的 8核 16GB 档,磁盘用了高性能云硬盘避免日志写入成为干扰项。

压测开始前还有一个准备动作:把监控先搭好。没有监控的压测等于蒙眼狂奔,出了瓶颈也不知道是哪一个指标先拐的弯。我们那次提前一天把 prometheus 采集和 Grafana 面板调好,后面每个阶段的判断都快了好几倍,这点投入在整个压测项目里回报最高。

方案

压测客户端不能用普通协程模拟器——一个 goroutine 模拟一个连接太轻量,真实度不够。我们写了一个专门的压测工具:每个虚拟用户跑独立 goroutine,按真实心跳频率发包,消息体长度按线上分布取样(短文本 80%、图片消息 15%、文件 5%)。指标采集靠服务端 prometheus 客户端导出,压测端记录成功率与延迟分位,也就是 P50/P95/P99 三档,均值这个指标在本场景里没有任何参考价值——IM 用户对长尾延迟极其敏感,均值好看照样被骂。

压测的分布设计是最容易被忽略的一环。均匀分布的消息量听上去合理,实际上 IM 流量天然不平衡:90% 的用户是透明的静默连接,少数活跃会话贡献绝大多数消息。我们的压测脚本里加了 zipf 分布选项,让热点会话模拟真实热门群的轰炸,这种情况下暴露出的瓶颈和均匀流量完全两样。压测工具的心跳频率也必须参照 心跳与重连:弱网下怎么保活 里的线上配置,用错误的心跳参数压出来的内存账目同样不可信。这套分布参数后来直接被复用到线上容量规划,见 监控告警:连接数与积压 的阈值设定逻辑。

压测阶段 并发目标 观察指标 发现的瓶颈
1 万连接 逐步加压 fd 与内存 无
10 万连接 恒定 内存与 CPU 无
30 万连接 恒定 广播延迟开始上升 CPU
40 万连接 群广播风暴 内存 90% 内存

阶段表的最后一个台阶是刻意制造的群广播风暴:我们建了十个万人群,让每个群每秒发五条消息,专门观察广播路径的极限。结果广播延迟从毫秒级抬到 300ms 以上,CPU 先于内存到顶——如果只做普通的单聊压测,这个结论永远出不来。广播是 IM 网关的隐形考核项,真实的瓶颈清单上它经常排第一。群里单条消息的扩散代价算法在 群消息:读扩散与写扩散怎么选 里有更系统的展开,压测是在为那套模型选边界。

每个阶段持续十分钟再看数据,瞬态数字会骗人。这套方法学比工具本身更重要,同部门后来压测别的系统都沿用了同一份清单。另外说一下压测端的机器别省钱:压测客户端自己也要开几万 goroutine、占不少内存,用一台 2核 4GB 的虚拟机去压 40 万连接,会先把自己压死,结论变成网关能撑而压测机撑不了,过程完全失真。我们压测端用的是和被测机同规格的 CVM,两台一起压,一台肯定不够。压测端与被测机之间的网络带宽也要确认:跨可用区压测会引入额外延迟和带宽上限,我们专门把压测机放在同一子网内,排除链路因素对结论的干扰。

配置与代码

压测客户端的核心循环只做三件事:建连、按间隔发心跳、偶发发消息:

func (u *User) run() {
    if !u.dial() {
        return // 失败计入失败率
    }
    t := time.NewTicker(u.hbInterval)
    for range t.C {
        if !u.send(Heartbeat) {
            return // 连接已断
        }
        if rand.Intn(50) == 0 { // 二十分之一概率发消息
            u.send(Msg)
        }
    }
}

服务端用 pprof 抓 goroutine 数和堆分配,配合 runtime.ReadMemStats 观察内存拐点。pprof 的火焰图是这次压测最大的功臣,40 万连接时的 CPU 热点一眼就看到是心跳扫描函数里的字符串拼接,顺手就优化掉了:

var m runtime.MemStats
runtime.ReadMemStats(&m)
log.Printf("conns=%d allocMB=%.0f sysMB=%.0f goroutines=%d",
    table.Count(), float64(m.Alloc)/1e6, float64(m.Sys)/1e6,
    runtime.NumGoroutine())

顺便一提,40 万连接时 goroutine 数也接近 40 万,这是 per-connection-per-goroutine 模型的固有代价。当时也评估过 gnet 这类事件循环框架,可以把 goroutine 数降两个数量级。没有换的原因是迁移成本和心智成本,而内存账单尚在可接受范围。规模再翻倍的话,这个决定大概率要重新做一次,工程没有永远正确的架构,只有当下合算的取舍。

踩坑

第一个坑前文提过:fd 限制。systemd 单元不写 LimitNOFILE,压测到 1 万就触发 too many open files。改了之后顺手也把 sysctl 的 somaxconn 提到 65535,否则握手队列本身先成为瓶颈。详细的原理解释:Linux 给每个进程的 fd 是一个系统级配额,Go 里每条 TCP 连接至少占一个 fd,有时还要加上 epoll 的注册项,压测前不做这个适配,瓶颈根本不在业务代码里,纯属白测。我们的做法是把所有系统参数打成一份 init 脚本,新机器开机即执行,避免每台手动调,也保证压测环境与线上一致。

第二个坑是压测结果过于漂亮。压测端只发包不处理回包,和服务端的模型不对称,延迟数字好看但不可信。改成完整模拟客户端行为(发后收、收后 ack),数字立刻难看两倍,但这个数字才是能用来做容量规划的。我们把这个教训挂到了 wiki 上:单向的压测等于自欺欺人,IM 是双向协议,任何压测模型都必须覆盖完整的往返链路,包括 心跳与重连 的心跳报文和 ack 回报,以及重连后的 sync 拉取,否则静态数字与线上体感永远差一个数量级。压测脚本里我们还专门实现了一个断线注入开关:随机杀掉百分之二的连接,观察重连恢复的速度曲线,这是检验退避策略是否生效的直接手段。

第三个坑是内核参数 tcp_tw_reuse。压测端断连又重连产生大量 TIME_WAIT,压测端自己先撑不住。压测工具和服务器都要调,不能只调服务端。这个坑很隐蔽,因为现象看起来像服务端在拒绝连接,实际上源端口耗尽的是客户端一侧。定位方法是看压测机的 ss 统计,TIME_WAIT 计数几万几万地涨就是这个病的特征。

第四个坑是压测过程中忘了停监控上报。prometheus 采集本身吃 CPU,40 万连接时每十秒一次全量采集,给 CPU 又加了一成负载,数据曲线在压测后期有个莫名其妙的抬升。后来压测时把采集间隔放大到一分钟。

第五个坑是压测期顺手把日志级别改成 debug。一个 goroutine 一行日志,40 万连接就是天文数字的字符串拼接,GC 压力瞬间上来,差点让我们得出错误的容量结论。压测环境日志级别必须与线上一致,否则测的是日志系统的极限。顺带说一句,我们最后把线上日志全量转储到 CLS(见 日志与审计:CLS 两套索引),网关本地只保留最近一小时,内存账面也由此清爽不少。

小结

单机 40 万连接是我们这台 8核 16GB CVM 的实测数,比 机器测算 的纸面数字保守得多,再次印证压测不可省略。压测得出的经验:先调系统参数、再用真实模型压、压足十分钟、盯四指标。任何单调指标都会骗人,四指标同框才是真相。这套底数后来直接写进了 监控告警 的容量告警阈值里,成为线上运维的标尺。最后给同路人留一句话:压测报告不要只写结论数字,把每个阶段的环境参数一起归档,半年后回头看时,那些参数比数字更宝贵。我们把每次压测报告存档到团队 wiki 的容量目录下,归档动作本身也写进 SOP,避免知识只存在于某个人的脑子里。压测报告模板我们统一成五段:环境参数、阶段目标、四指标曲线、瓶颈定位、优化动作,后续任何一次扩容决策都直接引用最近一次报告,不用凭记忆重新讨论。

延伸阅读