做客服系统的同学应该都有体会:在线会话一多,服务器 CPU 看着不高,但用户端的消息就是会时不时卡一下。前阵子我们升讯威客服系统做了一次大版本压测,最头疼的问题就是 GC 抖动——P99 延迟像心电图一样忽高忽低,而 GC 停顿恰恰就是那个藏在背后的“心跳不齐”。后来我们沿着分配链路一点点查,用 Span 和 Memory 把高分配路径逐条改掉,才把服务从“时不时抖一下”拉回到稳定的低延迟区间。
这篇文章就把整个实践过程完整展开:先是问题怎么定位,再讲 Span 和 Memory 的原理为什么要这么用,然后给出客服系统里几个典型的改造案例和代码,最后是压测数据和一堆实际踩过的坑。适合正在做 .NET 服务端性能优化、或者被 GC 问题困扰过的同学参考。
1. 先定位:客服系统的 GC 抖动到底怎么来的
1.1 在线客服场景里,哪些代码在悄悄制造垃圾
很多人一提到 GC 问题就以为是“对象太多了”,其实更准确的说是“分配率太高”。客服系统这个业务形态天然就是高分配的代表。
在线会话是一个长连接场景,每个客服同时挂多个会话,每个会话又有大量消息在收发。消息体要序列化、要记录日志、要做协议解析、要推送给前端,这里每一步都在产生临时对象。举个典型的链路你感受下:客户端发来一条 JSON 消息,服务端先读字节流,转成字符串,再反序列化成对象,处理完再序列化回去,过程中还要拼日志、拼监控字段、做敏感词过滤。这条路走一遍,字符串对象、字节数组、LINQ 迭代器、装箱后的 object,统统成了第 0 代堆里的“一次性餐具”。
更麻烦的是,客服系统有比较明显的峰值特征。一旦营销活动触发大量用户同时发起会话,瞬时消息量可能翻好几倍,分配率直接冲上去。分配率一高,Gen0 经常满,GC 频繁触发;如果有些大对象(比如很大的消息体、批量导出的内存流)进了 LOH,那每次 LOH 回收都是一次比较明显的停顿。用户体验就是:一切看起来正常,但消息发出去偶尔要等一两秒才弹出来。
我见过很多团队在这个阶段会先去调 GC 模式,开 Server GC、关并发 GC、调阈值,但说实话,如果你的分配率本身下不来,这些调整都只是把垃圾清理的时间往后挪,不是真正解决问题。我们当时的判断也很简单:先把分配率打下来,GC 自然就不那么频繁了。
1.2 用 dotnet-trace 和 PerfView 抓现场,而不是靠猜
定位 GC 抖动,绝对不能靠猜。我们的排查工具其实就是 .NET 官方那套东西,按顺序说就是 dotnet-counters、dotnet-trace、PerfView 三件套轮着用。
先用 dotnet-counters 做粗筛,看几个关键指标:
bash复制dotnet-counters monitor --process-id <pid> --counters System.Runtime
重点关注 gen-0-gen-1-gen-2-size、alloc-rate、gc-time 这几项。如果你看到 alloc-rate 长时间维持在高位,或者 gc-time 那条曲线出现明显毛刺,基本可以确定是分配压力过大。我们当时有个节点在压测时每秒分配能飙升到几百 MB,这个数量级下 GC 不抖才怪。
拿到方向后,用 dotnet-trace 抓 CPU 和 GC 事件:
bash复制dotnet-trace collect --process-id <pid> --profile gc-verbose
抓完用 PerfView 打开,直接看 GC 的 Allocation Tick 或者按 GC Heap Alloc 排序,就能看到哪些调用栈分配最多。我们当时看到的结果非常典型:排名靠前的几乎都是字符串拼接、JSON 序列化、日志格式化、字节数组拷贝这几个模块。后来也印证了,这些正是后面要动刀的地方。
这里分享一个实操经验:抓 trace 别只抓几秒钟,最好覆盖一次完整的业务峰值。比如你压测时长 10 分钟,就中间抓 3 到 5 分钟,这样能看到 GC 发生频率最高那一段的代表性数据。如果你只抓空闲时段,大概率什么都看不出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原理拆解:Span 和 Memory 为什么能把分配打下来
2.1 Span:栈上的连续内存视图,切片不产生新对象
先聊 Span。Span<T> 是一个 ref struct,它本质上只保存两个东西:一个指向连续内存的引用,一个长度。你把它放在栈上,它不分配托管堆内存,也不产生新的对象,所以 GC 完全感知不到它的存在。
它最大的价值在于“切片零成本”。传统写法里,你要从一个字节数组里取一段出来,要么拷贝一次生成新数组,要么通过 Substring 生成新字符串,这两者都会产生新对象。而 Span<T> 的 Slice 只是创建一个新的视图——新的引用和长度,底层还是同一块内存,没有任何拷贝。
这个特性太适合协议解析、消息处理这类场景了。比如一条 WebSocket 消息进来,里面可能包含消息头、路由字段、消息体,你用 Span 去切,每个字段都只是一个视图,不会产生任何子串对象。等整条消息处理完了,这些视图跟着栈帧一起消失,堆上干干净净。
另一个关键
