一、为什么不能只报 QPS
“单机 50 万 QPS”这句话在游戏网关里几乎没有信息量。QPS 没有说明消息多大、连接多少、延迟分布如何、错误率多少,也没有说明测试时后端是否真的处理了业务。一个只回空包的网关可以轻松跑出很高的数字,但它跟真实对局没有关系。
游戏长连接的负载至少要用一组指标描述:
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| 在线连接数 | 当前保持的 TCP/WebSocket 数量 | 决定内存、文件描述符和心跳扫描成本 |
| 消息速率 | 每秒上行/下行消息数与字节数 | 区分小包风暴与大包带宽瓶颈 |
| p50 / p95 / p99 延迟 | 分位延迟,而不是平均值 | 尾延迟决定玩家体感 |
| 错误率 | 按超时、断连、协议错误、后端错误分类 | 高吞吐伴随高错误率没有意义 |
| 每连接内存 | RSS 或堆内存除以连接数 | 决定单机能承载多少玩家 |
| GC 暂停 | 每秒次数与最大暂停 | 长暂停会造成批量卡顿 |
| 文件描述符 | 已用/上限 | 最常见的隐性上限 |
| 重连速率 | 每秒新建连接数 | 反映稳定性与发布影响 |
这些指标要一起报,并且注明测试条件:消息大小、连接数、心跳间隔、是否 TLS、后端是真实逻辑还是回显。离开条件的 QPS 无法比较,也无法复现。
指标采集本身也有成本。如果每条消息都打一条日志、每次都查一次监控后端,压测测到的就是日志系统。热路径只做原子计数或分片计数,由后台协程定期汇总;延迟直方图按固定桶记录,避免每毫秒一次的高基数标签。
另外要区分“网关自身耗时”和“端到端耗时”。网关只能准确测量自己处理的那一段,客户端到网关的网络延迟需要由客户端侧上报或通过回显探测估算。报告里最好把两段分开,否则网络抖动会被误判成服务端慢。
二、压测模型:匀速、阶梯、尖峰
不同模型回答不同问题。只做一种,很容易得出错误结论。
| 模型 | 怎么发 | 回答的问题 | 观察重点 |
|---|---|---|---|
| 匀速 | 固定连接数、固定消息速率,持续 10~30 分钟 | 稳定态下的延迟和资源占用 | p99、内存增长、GC |
| 阶梯 | 每 2~5 分钟把负载提高一档 | 饱和点在哪里、先崩哪个资源 | 吞吐曲线拐点、错误率 |
| 尖峰 | 短时间内把连接或消息速率拉高数倍 | 突发重连、登录风暴、队列积压 | 恢复时间、是否雪崩 |
| 浸泡 | 中低负载持续数小时 | 内存泄漏、连接泄漏、句柄泄漏 | RSS 斜率、连接数偏差 |
阶梯压测要预留冷却时间。上一档的连接没有完全释放就进入下一档,测到的是累积状态,不是该档位的稳态。每档结束后等待连接归零或回到基线,再开始下一档。
压测流量还要区分开环和闭环。闭环客户端收到响应才发下一条,服务端变慢时客户端自动降速,测不出真实排队;开环按固定速率发送,才能看到延迟随负载上升的拐点。游戏消息更接近开环,但登录和重连又带闭环特征,最好两种都做。
一次完整的压测计划至少要写清楚:
- 目标:验证容量、找拐点、回归对比,还是验证发布影响。
- 场景:连接如何建立、鉴权用什么 token、消息尺寸与比例、心跳间隔。
- 负载曲线:起步值、每档增量、每档持续时间、冷却时间。
- 观测:服务端与客户端各自的指标、采集频率、日志级别。
- 判据:p99 上限、错误率上限、内存斜率上限,以及失败后如何回滚。
- 清理:连接如何关闭、端口和句柄如何回收、数据如何复位。
没有清理步骤的压测会污染下一轮。上一轮残留的连接仍占着内存和心跳表,第二轮起步时的基线就已经偏移,阶梯曲线会整体失真。
三、别让压测机先成为瓶颈
压测客户端和服务端跑在同一台机器上,是最常见的错误。两者争抢 CPU、内存带宽和网卡队列,测出来的“服务端瓶颈”可能只是压测机跑满了。至少满足:
- 客户端与服务端分机,最好分可用区,避免共享网卡和 NAT。
- 压测机 CPU 使用率留出 30% 余量;客户端自身也要采集 CPU、内存和 GC。
- 调整
ulimit -n,客户端和服务端的文件描述符上限都要高于目标连接数。 - 关注临时端口范围,短连接压测很容易把
ephemeral port耗尽。 - 每个压测进程承载的连接数不要过高,必要时用多进程分散调度开销。
- 关闭压测客户端的详细日志和逐条打印,日志本身可能比网络更耗 CPU。
判断压测机是否先瓶颈,可以逐步增加客户端进程:如果客户端数量翻倍而服务端吞吐不变,瓶颈在服务端;如果吞吐随客户端增加而线性上升,之前的“上限”其实是压测机上限。
网络路径也要检查。同机回环看起来最快,却绕过了真实的网卡、交换机和 NAT;跨可用区压测更接近线上,但要把网络 RTT 从服务端耗时里扣除。压测期间用 ss -s、netstat 或监控平台观察重传、连接队列溢出和端口耗尽,这些现象在应用指标里往往只表现为“偶发超时”。
四、分段定位:把一次请求拆开
只看端到端延迟,无法判断慢在哪。网关至少要记录四段耗时,并用直方图而不是平均值统计:
| 阶段 | 测什么 | 常见问题 |
|---|---|---|
| 连接建立 | TCP 握手、TLS 握手、协议升级 | 握手队列满、证书链慢、TLS CPU 高 |
| 首包/鉴权 | 客户端首消息到鉴权完成 | 登录服慢、token 校验阻塞、同步查库 |
| 转发 | 网关收到完整帧到写出后端 | 锁竞争、序列化、队列排队 |
| 后端 | 游戏服处理并返回 | 业务逻辑慢、数据库慢、广播放大 |
| 回程 | 网关读到响应到写回客户端 | 写缓冲满、慢客户端、Nagle |
每一段都要能单独出分位图。若 p99 在“转发”段突然抬升,而“后端”段平稳,问题就在网关内部的锁、队列或序列化;反之则应该去查游戏服。没有分段,优化只能靠猜。
连接建立耗时尤其容易被忽略。发布或故障恢复时,大量客户端同时重连,握手和鉴权阶段的 p99 会远高于稳态;这部分容量要单独压,不能只用已连接状态下的消息速率代表网关能力。
分段之后还要能关联。每条慢请求的 trace 应带上连接 id、会话 id 和消息序号,网关、游戏服、数据库用同一个 trace id 串起来。否则压测只能告诉你“转发段慢”,却定位不到是哪把锁、哪个队列、哪次下游调用。
五、结果怎么读:找拐点,不找峰值
压测报告最容易犯的错是只写峰值。峰值往往出现在错误率已经上升、延迟已经失控的区间,对容量规划没有价值。更有用的是一条曲线:横轴是负载,纵轴是吞吐、p99 和错误率。
- 吞吐饱和点:继续加负载,吞吐不再上升甚至下降,说明某个资源已经打满。
- 尾延迟拐点:p99 开始陡升而 p50 还平稳,通常是队列开始积压的早期信号。
- 错误率拐点:超时或断连开始出现,说明系统已经越过可用容量。
- 内存斜率:浸泡测试中 RSS 持续上升且不回落,优先怀疑连接、缓冲或缓存泄漏。
- GC 影响:GC 频率和暂停时间随连接数上升,若最大暂停超过帧间隔,玩家会感到卡顿。
- 连接数偏差:服务端统计的连接数与客户端统计不一致,说明关闭流程或心跳驱逐有问题。
容量规划通常取“p99 可接受、错误率接近零”的最大负载,再留 30%~50% 余量给突发和发布。不要拿压测峰值直接当生产上限。
可以用 Little 定律做交叉检查:并发请求数 ≈ 到达速率 × 平均延迟。如果实测并发远高于这个乘积,说明有大量请求卡在队列里,平均延迟被低估了。
一份可用的压测报告至少包含:
- 测试目标与版本:代码 commit、配置、机器规格、Go 版本。
- 场景描述:连接数、消息尺寸分布、速率曲线、持续时间。
- 吞吐曲线:每秒消息数与字节数,标出饱和点。
- 延迟分位:p50、p95、p99、p999,按阶段拆分。
- 错误分类:超时、断连、后端错误、协议错误各自的数量。
- 资源曲线:CPU、RSS、GC、文件描述符、连接数偏差。
- 结论与余量:建议生产容量、需要继续优化的瓶颈。
- 复现命令:脚本、参数、数据文件位置。
只贴一张“峰值 QPS”截图不算报告,因为别人无法判断这个数字是否可信,也无法在下一个版本上复现。
六、失败场景:平均值掩盖了尾延迟
某网关压测报告写着“平均延迟 8 毫秒,QPS 12 万”,团队据此上线。真实对局中,每 20 秒左右出现一次 300 毫秒的卡顿,玩家频繁投诉。复盘发现:平均延迟把少量长尾摊平了,而长尾来自每 20 秒一次的全局心跳扫描——扫描持锁遍历十万条连接,期间所有转发协程排队。
修复不是换更快的机器,而是改结构:心跳表分片、扫描分批、锁粒度缩小到分片,转发路径不再等待全表扫描。压测时如果只看平均值,这个问题永远不会暴露;加上 p99 和“每秒最大暂停”后,拐点一目了然。
另一个常见失败是只压空包。空包不触发序列化、不查后端、不写数据库,测出的是框架转发上限。真实消息要带典型载荷,至少覆盖小包、中包和大包三种尺寸,并按真实比例混合。
七、Go 基准:用 net.Pipe 隔离转发路径
下面的基准用 net.Pipe 构造一个回显对端,测量“组包、写、对端读、回写、本端读”的完整往返。它不经过内核协议栈,因此数字只代表本机调度和内存拷贝成本,不能当成生产网关的吞吐。SetBytes 让结果同时给出 MB/s,ReportAllocs 用来确认热路径有没有额外分配。
第二个基准把组包单独拆出来,用于判断 CPU 成本是否可忽略。benchSink 防止编译器把只写不读的循环优化掉。
需要注意的是,基准只覆盖单连接单帧。真实网关的瓶颈常出现在锁竞争、批量广播和 GC,单线程基准用来守住热路径回归,不能替代多客户端系统压测。
// file: ch05/scale/bench_test.go
// tier: L1
// verify: go test ./ch05/scale/
package scale
import (
"encoding/binary"
"io"
"net"
"testing"
)
const benchPayload = 256
// benchSink 防止编译器把只写不读的组包循环优化掉。
var benchSink []byte
func newEchoPipe(tb testing.TB) net.Conn {
tb.Helper()
client, server := net.Pipe()
frame := make([]byte, 4+benchPayload)
go func() {
defer server.Close()
for {
if _, err := io.ReadFull(server, frame); err != nil {
return
}
if _, err := server.Write(frame); err != nil {
return
}
}
}()
tb.Cleanup(func() { client.Close() })
return client
}
func forwardOnce(conn net.Conn, seq uint32) error {
frame := make([]byte, 4+benchPayload)
binary.BigEndian.PutUint32(frame[:4], seq)
if _, err := conn.Write(frame); err != nil {
return err
}
_, err := io.ReadFull(conn, frame)
return err
}
func TestForwardEcho(t *testing.T) {
conn := newEchoPipe(t)
if err := forwardOnce(conn, 1); err != nil {
t.Fatal(err)
}
if err := forwardOnce(conn, 2); err != nil {
t.Fatal(err)
}
}
// BenchmarkForwardEcho 测的是一次完整往返:编码、写、对端读、回写、本端读。
// 数字只代表本机 net.Pipe 的调度成本,不能当成生产网关的吞吐。
func BenchmarkForwardEcho(b *testing.B) {
conn := newEchoPipe(b)
frame := make([]byte, 4+benchPayload)
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
binary.BigEndian.PutUint32(frame[:4], uint32(i))
if _, err := conn.Write(frame); err != nil {
b.Fatal(err)
}
if _, err := io.ReadFull(conn, frame); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkFrameEncode 隔离出纯 CPU 的组包成本,用来判断瓶颈在客户端还是链路。
func BenchmarkFrameEncode(b *testing.B) {
payload := make([]byte, benchPayload)
dst := make([]byte, 4+benchPayload)
b.SetBytes(int64(len(dst)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
binary.BigEndian.PutUint32(dst[:4], uint32(i))
copy(dst[4:], payload)
}
benchSink = dst
}
八、运行与解读
cd .local/checks/gs-practice-lab && gofmt -l ch05/scale && go vet ./ch05/scale/ && go test ./ch05/scale/ -run '^$' -bench . -benchmem -count=1
goos: darwin
goarch: arm64
pkg: gspractice/ch05/scale
cpu: Apple M1 Pro
BenchmarkForwardEcho-10 901726 1360 ns/op 191.11 MB/s 0 B/op 0 allocs/op
BenchmarkFrameEncode-10 206837100 6.060 ns/op 42901.91 MB/s 0 B/op 0 allocs/op
PASS
ok gspractice/ch05/scale 4.196s
这组数字说明两件事:256 字节帧的纯内存往返约 1.36 微秒,热路径零分配;组包本身约 6 纳秒,相比一次往返可以忽略。生产环境还要叠加系统调用、TLS、真实后端和锁竞争,端到端延迟会高一个数量级以上。基准的作用是建立下限和发现回归,不是预测线上容量。
跑基准时至少用 -count=3 观察波动,并记录机器型号、Go 版本和负载情况。不同机器、不同后台任务下的数字不可直接比较。
解读时先看 B/op 和 allocs/op:热路径一旦出现每次请求的堆分配,吞吐和尾延迟都会在连接数上升后恶化。再看 ns/op 是否随版本回归;单个绝对值没有意义,趋势才有。
如果要做更接近生产的基准,可以补充:
- 用回环 TCP 替代
net.Pipe,把系统调用计入。 - 并发多个客户端,观察锁竞争和 GC。
小结
游戏网关的性能要用一组指标描述:连接数、消息速率、分位延迟、错误率、每连接内存和 GC。压测模型要覆盖匀速、阶梯、尖峰和浸泡,压测客户端必须与服务端分机。定位问题时把一次请求拆成连接、鉴权、转发、后端和回程五段,分别看 p99。结果解读找饱和点和尾延迟拐点,而不是只报峰值 QPS;容量规划留出余量,并用 Little 定律做交叉检查。
常见错误与边界
- 压测客户端与服务端同机,先跑满的是压测机。
- 只压空包,不覆盖真实载荷和业务后端。
- 只报平均延迟,忽略 p99 和最大暂停。
- 压测结束不清理连接,下一轮在残留连接上开始。
- 把本机
net.Pipe基准当成生产吞吐,忽略系统调用和 TLS 成本。 - 用峰值 QPS 直接做容量规划,没有留突发和发布余量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。