基础库:github.com/aiyang-zh/z…(MIT) | 引擎:github.com/aiyang-zh/z… | 官网:zhenyi-site.pages.dev
标签:Go / Actor 模型 / 高性能 / IM / 分布式系统 / 压测
前言
上一篇分享了 zhenyi 的 7 天长稳压测——2000 连接、121 亿消息、QPS 零退化、P50 延迟 0.11ms。
这次把连接数直接拉到 50,000,跑了 1 小时,重点验证:大连接量下框架的延迟和资源表现是否依然可控。
测试环境依然是阿里云 2 核 4G(客户端服务端同机),没有任何代码变更,纯配置层面放量。
核心数据
| 指标 | 值 |
|---|---|
| 在线连接数 | 50,000 |
| 稳态吞吐 | 20,000 msg/s(客户端视角) |
| Actor 邮箱吞吐 | ~40,000 次/s |
| 稳态 P50 延迟 | 0.175ms |
| 稳态 P99 延迟 | 0.50ms |
| 总处理消息量 | 7,200 万(1 小时) |
| GC 总暂停 | 约 1 秒(224 次,每次约 6ms) |
| 内存占用 | 1,024~1,506 MB |
| Goroutine 数 | 24 个,全程无增长 |
| P99 < 1ms 窗口占比 | 85.4% |
关键结论:
- 连接数涨 25 倍(2000 → 50000),P99 延迟反而从 1.3ms 降到 0.50ms
- 24 个 goroutine 全程无泄漏,资源管控依然精确
- 存在约 125 秒一次的周期性 P99 毛刺,可预测、可恢复,不影响服务可用性
- GC 总暂停 ~1 秒,vs 7 天版的 46 秒——连接越多,对象池复用率越高,GC 频率反而更低
测试架构
链路和上次完全一致:
Client → Gate Actor → IM Actor → Gate Actor → Client
- 消息经历 2 次 Actor 邮箱投递(Gate 入邮箱 → IM 入邮箱,IM 响应回 Gate 走快路径直达连接写回,跳过 Gate 邮箱入队)
- 客户端看到的 2 万 QPS,对应 Actor 邮箱实际吞吐 4 万次/s
- 压测使用 framework 模式,handler 收到消息直接返回预编码的固定 bytes,测的是纯框架开销
// framework 模式:收到即回,零业务逻辑
s.GetHandleMgr().RegisterHandle(MsgLoginReq, func(ctx context.Context, msg *zmsg.Message) {
if selectedBenchMode == "framework" {
s.SendToClient(msg, replyLoginOK) // replyLoginOK 是预编码的 []byte
return
}
// business 模式:反序列化 + state 操作 + ...
})
和 7 天版的唯一区别:连接数从 2000 → 50,000,发送间隔从每连接 100ms 调整为 2.5 秒,保持总 QPS 约 20,000 不变。
Ramp 阶段:600 秒从 0 爬升至 5 万
连接线性增长,约 10 分钟达到满载。
| 阶段 | 连接数 | QPS | P50 | P99 |
|---|---|---|---|---|
| 起步 | 1,364 | 1,080 | 0.156ms | 3.13ms |
| 爬升中 | 25,994 | 13,801 | 0.204ms | 11.7ms |
| 接近满载 | 48,740 | 19,741 | 10.0ms | 55.3ms |
| 稳态 | 50,000 | 20,001 | 0.175ms | 0.50ms |
连接数超过 3 万后,P50 出现明显抖动(升至 ~10ms),Ramp 阶段共产生 51 次 slow batch 警告。这是正常的冷启动现象——邮箱队列还没稳定,批量处理效率较低。
注意:接近满载(48,740 连接)时延迟最高,但稳态(50,000 连接)反而最低。这正是批量处理的特性,下面会详细解释。
稳态性能
稳态阶段持续约 3,290 秒(~55 分钟),658 个采样窗口(每 5 秒一个)。
吞吐
运行 2,922 秒: QPS = 19,997
运行 3,000 秒: QPS = 19,999
运行 3,100 秒: QPS = 20,001
运行 3,200 秒: QPS = 20,000
运行 3,500 秒: QPS = 20,000
峰值 QPS 21,280,全程稳定在 20,000 msg/s。
延迟
85.4% 的采样窗口(P99 < 1ms 的窗口)典型延迟:
| 指标 | 值 |
|---|---|
| P50 | 0.175ms |
| P99 | 0.50ms |
| QPS | 19,996 |
尾部延迟分布
每个 5 秒采样窗口约 10 万次请求,RTT Max 分布:
| RTT Max 范围 | 占比 |
|---|---|
| 2~5ms | 3.2% |
| 5~10ms | 4.7% |
| 10~20ms | 26.1% |
| 20~50ms | 57.1% |
| 50~100ms | 8.7% |
| > 100ms | 0% |
全局 RTT Max 峰值 91.6ms,全程无超过 100ms 的采样窗口(7 天长稳峰值 142ms)。
GC 和资源
| 资源项 | 数据 |
|---|---|
| GC 次数 | 224 次(约每 16 秒一次) |
| 每次 GC 暂停 | ~6ms |
| 1 小时 GC 总暂停 | ~1 秒(占比 0.028%) |
| Goroutine | 24 个,全程无增长 |
| 内存 | 1,024~1,506 MB |
| 系统内存 | 1,630 MB |
GC 每次暂停 6ms(vs 7 天版的 0.3ms)——5 万连接下堆上活跃对象更多,单次 GC 扫描量更大。但每 16 秒才触发一次,1 小时累计仅 1 秒,完全可接受。
周期性 P99 毛刺分析
658 个稳态窗口中,96 个(14.6%) 的 P99 超过 1ms。这些毛刺不是随机噪声:
- 平均间隔约 125 秒(~2 分钟)
- P99 毛刺峰值范围 1.1ms~66.7ms
- 每次毛刺持续 1~2 个窗口(5~10 秒)后完全恢复
这是 Go runtime 并发 GC 标记扫描阶段的典型特征。zhenyi 的对象池有效降低了 GC 频率(约每 16 秒一次),但标记扫描仍会短暂竞争 CPU,影响尾部延迟。
关键点:毛刺窗口 QPS 不受影响,消息零丢失。毛刺可预测、可恢复、不累积。
这和 7 天长稳测试中的外部 cron 干扰性质不同——那是系统干扰,这是 runtime 内部行为,更可控。
和 7 天长稳版的对比
| 维度 | 2000 连接 × 7 天 | 50,000 连接 × 1 小时 |
|---|---|---|
| QPS | 20,000 msg/s | 20,000 msg/s |
| P50 延迟 | 0.11ms | 0.175ms |
| P99 延迟 | 1.3ms | 0.50ms ↓ |
| RTT Max 峰值 | 142ms | 91.6ms |
| GC 频率 | 每 4 秒一次 | 每 16 秒一次 |
| GC 每次暂停 | 0.3ms | 6ms |
| GC 总暂停 | 46 秒 | ~1 秒 ↓ |
| Goroutine | 25 | 24 |
| 内存 | 69~125 MB | 1,024~1,506 MB |
| P99 > 1ms 窗口占比 | ~27% | 14.6% |
两个反直觉的发现:
1. 连接数涨 25 倍,P99 反而更低。
原因在于 MPSC 邮箱的批量处理机制:
batchSize := a.batcher.GetBatchSize(int64(lastBatchSize))
n := a.mailBoxQueue.DequeueBatch(msgs[:batchSize])
for i := 0; i < n; i++ {
if i > 0 && i%50 == 0 {
batchStartMs = ztime.ServerNowUnixMilli() // 每 50 条刷新一次时间戳
}
a.SafeHandleMessage(handleCtx, msgs[i], batchStartMs)
msgs[i].Release()
}
2000 连接时邮箱队列较稀,批量出队的消息少,单条消息摊到的调度开销较高;50000 连接时队列始终"满载",每次批量出队数量更多,均摊开销更低,P99 反而降了。
2. 连接数涨 25 倍,GC 总暂停反而更少。
对象池复用率随连接数增加而提升:
func GetMessage() *Message {
msg := getMessagePool().Get() // 优先复用池中对象
msg.PoolReset()
atomic.StoreInt32(&msg.RefCount, 1)
return msg
}
func (m *Message) Release() {
newRef := atomic.AddInt32(&m.RefCount, -1)
if newRef == 0 {
if cap(m.Data) > 4096 {
m.Data = nil // 大 buffer 不回池,避免内存膨胀
}
getMessagePool().Put(m)
}
}
5 万连接下消息对象的流通量更大,池的"热度"更高,热路径几乎不产生新分配,GC 频率从每 4 秒一次降到每 16 秒一次。虽然单次暂停从 0.3ms 涨到 6ms(活跃堆更大),但触发次数大幅减少,1 小时总暂停只有 1 秒。
核心设计回顾
这次压测没有代码变更,完全依赖框架的现有设计:
1. zpool.Pool 泛型对象池 + 原子引用计数
来自 zhenyi-base 的自定义泛型对象池,非 sync.Pool。消息对象在池中复用,引用归零自动回收,热路径零分配,GC 压力极低。
2. MPSC 邮箱 + FastAdaptiveBatcher
每个 Actor 一个 MPSC 邮箱,FastAdaptiveBatcher 根据负载动态调整批量大小。批量内每 50 条共享一次 time.Now(),减少系统调用。连接数越多,批量效率越高。
3. ants 协程池,固定 500 worker
24 个 goroutine(轻量调度器本身)+ ants 协程池(500 worker)处理 5 万连接,零泄漏。asyncTask 结构体同样池化,避免闭包分配。
4. 零拷贝路由
进程内 Actor 通信只做 Retain() + 指针投递,无序列化。跨进程才走 NATS 消息总线。
总结
| 指标 | 结果 |
|---|---|
| 连接数 | 50,000 |
| 稳态吞吐 | 20,000 msg/s,峰值 21,280 |
| 延迟 | P50 0.175ms,P99 0.50ms |
| GC 总暂停 | ~1 秒(0.028%) |
| 资源 | 24 goroutine,零泄漏 |
| 毛刺 | 125 秒一次,可预测可恢复 |
5 万连接、2 万 QPS,2 核 4G 还没到极限。框架层面的扩展性好于预期。
基础库:github.com/aiyang-zh/z…(MIT 协议)
项目地址:github.com/aiyang-zh/z…(AGPL-3.0 + 商业双授权)
交流群:QQ 群 1098078562