Go Actor 引擎 zhenyi 压测报告:50,000 连接 × 1 小时,7200 万消息,P99 0.50ms

0 阅读7分钟

基础库: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 分钟达到满载。

阶段连接数QPSP50P99
起步1,3641,0800.156ms3.13ms
爬升中25,99413,8010.204ms11.7ms
接近满载48,74019,74110.0ms55.3ms
稳态50,00020,0010.175ms0.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 的窗口)典型延迟:

指标
P500.175ms
P990.50ms
QPS19,996

尾部延迟分布

每个 5 秒采样窗口约 10 万次请求,RTT Max 分布:

RTT Max 范围占比
2~5ms3.2%
5~10ms4.7%
10~20ms26.1%
20~50ms57.1%
50~100ms8.7%
> 100ms0%

全局 RTT Max 峰值 91.6ms,全程无超过 100ms 的采样窗口(7 天长稳峰值 142ms)。

GC 和资源

资源项数据
GC 次数224 次(约每 16 秒一次)
每次 GC 暂停~6ms
1 小时 GC 总暂停~1 秒(占比 0.028%)
Goroutine24 个,全程无增长
内存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 小时
QPS20,000 msg/s20,000 msg/s
P50 延迟0.11ms0.175ms
P99 延迟1.3ms0.50ms ↓
RTT Max 峰值142ms91.6ms
GC 频率每 4 秒一次每 16 秒一次
GC 每次暂停0.3ms6ms
GC 总暂停46 秒~1 秒 ↓
Goroutine2524
内存69~125 MB1,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 + 商业双授权)

官网zhenyi-site.pages.dev

交流群:QQ 群 1098078562