做客服系统的同学应该都有体会:在线会话一多,服务器 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 去切,每个字段都只是一个视图,不会产生任何子串对象。等整条消息处理完了,这些视图跟着栈帧一起消失,堆上干干净净。
另一个关键点是 Span<T> 可以和很多高性能 API 直接配合。比如 Encoding.UTF8.GetString 有接收 ReadOnlySpan<byte> 的重载,int.TryParse 有接收 ReadOnlySpan<char> 的重载,string.Create 甚至可以让你直接往字符串内部写入内容。这意味着很多原本需要中间分配的操作,都能在 Span 层面完成。
2.2 Memory:让“异步”也能安全持有缓冲区
Span<T> 有个硬限制:它不能进堆,所以不能作为类的字段、不能放进 lambda、不能用在 async 方法里。为什么?因为 ref struct 的生命周期必须在栈上,一旦进了堆,GC 移动对象后就无法保证它引用的内存地址仍然有效。
但真实业务里大量操作是异步的,尤其是在线消息这种高并发 IO 场景。这时候就该 Memory<T> 出场了。Memory<T> 不像 Span<T> 那么激进,它可以驻留在堆上,可以在异步方法里传递,同时它又提供了 .Span 属性让你在需要时拿到一个 Span<T> 来高效操作。
你可以在网上搜到一种比较常见的用法模式:方法签名接收 ReadOnlyMemory<byte>,在方法内部需要同步处理时,用 .Span 转成 ReadOnlySpan<byte>;如果需要异步处理,就直接持有 Memory<byte>,不需要的时候再用 .Span 去取。这个设计让异步和高性能不再冲突。
2.3 选 Span 还是 Memory:一个简单的判断标准
很多刚接触这两个类型的人会纠结,其实判断标准特别简单:如果你这段逻辑是同步执行的,方法栈活着的时候就能完成所有操作,那就用 Span<T>;如果这段数据要被异步方法持有、要在多个方法间传递、要存到字段里,那就用 Memory<T>。
我们当时的整体策略是:所有热路径上的同步解析逻辑全部改 Span<T>,所有涉及异步 IO 和异步处理的路径全部改 Memory<T>。比如消息读取进来后先同步解析头部,用 Span;解析完头部要把整个消息体交给异步处理流程,就切成 ReadOnlyMemory<byte> 往下传。这样既拿到了 Span 的零拷贝优势,又不违反生命周期约束。
另外一个值得提前说的点是:Memory<T> 和 ArrayPool<T> 配合起来,可以做到“缓冲区完全复用”。需要临时缓冲区时从 ArrayPool<byte>.Shared 租,用完再还回去,整个生命周期内堆上不会新增一个多余的字节数组。这个组合是后面几个案例里反复出现的核心手段。
3. 落地改造:升讯威客服系统的四个高价值优化点
3.1 消息收发链路:JSON 序列化从字符串改为 UTF-8 直写
客服系统最核心的链路就是消息收发。原实现里,消息对象先 JsonSerializer.Serialize 成字符串,再把字符串转成 byte[] 发给前端;接收端则是先拿 byte[] 转字符串,再反序列化成对象。这一来一回,字符串这个中间产物就是纯浪费的分配。
改造方向非常明确:用 Utf8JsonWriter 直接把对象写进 UTF-8 缓冲区,跳过字符串这个中间形态。示例代码大致长这样:
csharp复制public static void WriteMessage(Utf8JsonWriter writer, ChatMessage message)
{
writer.WriteStartObject();
writer.WriteString("id", message.Id);
writer.WriteString("sender", message.Sender);
writer.WriteString("content", message.Content);
writer.WriteNumber("timestamp", message.Timestamp);
writer.WriteEndObject();
}
配合池化缓冲区使用:
csharp复制var buffer = ArrayPool<byte>.Shared.Rent(1024 * 4);
try
{
using var stream = new MemoryStream(buffer);
using (var writer = new Utf8JsonWriter(stream))
{
WriteMessage(writer, message);
}
int written = (int)stream.Position;
await webSocket.SendAsync(new ReadOnlyMemory<byte>(buffer, 0, written), WebSocketMessageType.Text, CancellationToken.None);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
这个改造有两个收益:一是去掉了字符串中间物,每条消息省掉一次 string 分配和一次 byte[] 分配;二是 Utf8JsonWriter 本身是流式写入,不会额外生成中间副本。实测中,单条消息的分配量下降了大概五成,高频路径上省得非常多。
接收端同理,用 JsonDocument.Parse(ReadOnlyMemory<byte>) 直接从字节解析,不要先 Encoding.UTF8.GetString。这个 API 接受 ReadOnlyMemory<byte>,内部零拷贝。
3.2 协议解析:从 Substring 到 Span 切片
客服系统里除了 JSON,还有很多自定义协议头或者类似“IM 消息帧”的字节结构。原代码里有人为了方便,直接 Encoding.UTF8.GetString 之后再 Substring,一次解析能产出十几二十个临时字符串对象。
改成 Span 切片之后,整个解析过程不再分配任何托管对象。核心是替换两个操作:
Substring 换成 Slice,string 比较换成 SequenceEqual,或者直接用 span 和常量字符串的 UTF-8 字节做比较。典型的代码形状是这样:
csharp复制public static bool TryParseFrame(ReadOnlySpan<byte> data, out ReadOnlySpan<byte> command, out ReadOnlySpan<byte> payload)
{
if (data.Length < 4) return false;
int headerEnd = data.IndexOf((byte)':');
if (headerEnd < 0) return false;
int payloadStart = headerEnd + 1;
command = data.Slice(0, headerEnd);
payload = data.Slice(payloadStart, data.Length - payloadStart);
return true;
}
这个方法内部零 new,零 Encoding.GetString,零 Substring。所有返回的 ReadOnlySpan<byte> 都指向原始缓冲区,调用方处理完即释放,GC 完全无感。
如果你需要把这些字段最终转成字符串(比如存数据库),那就在最后一个环节再用 Encoding.UTF8.GetString(span.ToArray()) 或者更优雅点,直接给 EF Core 传 ReadOnlyMemory<byte>,彻底省掉转换。这个思路用下来,协议解析这条路径的分配量基本降到了接近零。
3.3 日志与监控:复用缓冲区,降低低频但大量的分配
日志是个容易被忽略的重灾区。每条消息进来,要记请求日志、要上报监控指标、可能还要写审计日志,这些日志模板里夹杂着大量字符串插值和 string.Format。单个看量不大,但乘上消息总数就是一笔巨款。
我们当时的做法有两层。第一层是把日志事件结构化,不再拼大段字符串,而是把结构化字段写进 Utf8JsonWriter,输出到池化缓冲区再一次性写出。第二层是针对确实需要拼文本的场景,用 StringBuilder 无法完全避免分配(底层 char 数组会扩容),我们干脆改成自己拼字节缓冲:
csharp复制public static void AppendLogLine(IBufferWriter<byte> writer, ReadOnlySpan<byte> level, ReadOnlySpan<byte> category, ReadOnlySpan<byte> message)
{
var span = writer.GetSpan(level.Length + category.Length + message.Length + 4);
level.CopyTo(span);
int pos = level.Length;
span[pos++] = (byte)' ';
category.CopyTo(span.Slice(pos));
pos += category.Length;
span[pos++] = (byte)' ';
message.CopyTo(span.Slice(pos));
pos += message.Length;
span[pos++] = (byte)'\n';
writer.Advance(pos);
}
IBufferWriter<byte> 配合池化底层,写日志也不会在托管堆上留下一堆字符串。这对那些低频但一次产生很多垃圾的场景(比如导出、备份、异常堆栈记录)尤其有效。
3.4 异步长链路:用 Memory + ArrayPool 管理临时缓冲区
客服系统里有一条比较长的异步链路:收到消息 -> 敏感词过滤 -> 内容审核 -> 落库 -> 推送。这条链路上每一步都可能需要临时缓冲区,如果每一步都 new byte[],那在高峰期分配量会非常可怕。
我们的统一做法是:定义一个 RentedBuffer 包装类,内部持有 ArrayPool<byte>.Shared.Rent 租来的数组,实现 IDisposable 在末尾归还:
csharp复制public sealed class RentedBuffer : IDisposable
{
private byte[] _buffer;
private RentedBuffer(int minimumLength)
{
_buffer = ArrayPool<byte>.Shared.Rent(minimumLength);
}
public static RentedBuffer Rent(int minimumLength) => new RentedBuffer(minimumLength);
public Memory<byte> Memory => _buffer;
public Span<byte> Span => _buffer;
public void Dispose()
{
var toReturn = _buffer;
if (toReturn != null)
{
_buffer = null;
ArrayPool<byte>.Shared.Return(toReturn, clearArray: false);
}
}
}
整个异步流程里,大家在方法签名上传 ReadOnlyMemory<byte> 或 Memory<byte>,临时加工需要缓冲区时就用 RentedBuffer.Rent,用完立刻 Dispose。这套模式跑了几个版本,最大的感受就是:托管堆上的大型字节数组肉眼可见地减少了,LOH 基本不再增长。
注意:
ArrayPool<byte>.Shared.Rent返回的数组长度往往大于你请求的长度,所以使用时必须记录实际写入长度,千万别直接按数组长度取数据。这是最经典的坑,我们初期也踩过。
4. 数据说话:优化前后 GC 与延迟对比
4.1 压测场景与采集方式
为了让数据有意义,我们搭了一个相对贴近生产的压测环境:模拟 2000 个并发在线用户,每个用户按照客服会话的节奏发送消息,平均每秒约 5000 条消息进入系统。服务端压力机上跑完整链路(接入 -> 解析 -> 序列化 -> 推送 -> 日志),排除下游数据库和第三方审核接口的干扰。
采集方式上用 dotnet-counters 记录 GC 相关计数,同时在网关层记录端到端延迟,分别统计 P50、P95、P99。优化前、优化后各跑 15 分钟,等稳定后取后 10 分钟数据。
4.2 优化结果与性能数据
直接上对比数据(以下是我们内部压测结果,具体数值会因机器配置和业务结构有差异,趋势可参考):
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 分配速率 (MB/s) | 约 420 | 约 150 | 下降约 64% |
| Gen0 GC 次数/分钟 | 约 180 | 约 60 | 下降约 67% |
| Gen1 GC 次数/分钟 | 约 45 | 约 12 | 下降约 73% |
| Gen2 GC 次数/分钟 | 约 8 | 约 2 | 下降约 75% |
| LOH 大小 (MB) | 持续增长到 200+ | 稳定在 20 左右 | 基本不再膨胀 |
| P50 延迟 (ms) | 8 | 6 | 略降 |
| P99 延迟 (ms) | 45 且波动大 | 18 且平稳 | 大幅下降 |
| 最大 GC 暂停 (ms) | 120 | 30 | 下降约 75% |
最有说服力的其实是 P99 那条曲线。优化前每隔一段时间就会出现 40ms 以上的尖刺,和 GC 触发点完全吻合;优化后曲线几乎是一条直线,偶尔几个小尖刺也都在 20ms 以内。对客服这种实时交互场景来说,这个变化可以说是质变。
4.3 配套 GC 配置调整,别让优化成果打折
代码改完后,我们顺手把运行时配置也调整了一遍。这里要特别提醒:GC 配置不是越激进越好,要和你自己的分配情况匹配。
我们最终用的是 Server GC + 并发 GC,配置项大致如下:
xml复制<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
这个组合在多核机器上能明显降低单次 GC 暂停时间。但如果你是小内存单核实例,Server GC 反而不合适,它会因为启动多个 GC 堆带来额外开销。我一贯的观点是:先靠减少分配把 GC 频率降下来,再配合 GC 模式让剩余的 GC 更平滑,而不是指望靠调 GC 参数去兜底一个高分配的应用。
另外建议在启动时预留 DOTNET_GCHeapHardLimit 或容器的内存限制,避免 GC 在内存接近上限时做过于激进的回收。这个视你的容器平台而定,不要把整个物理机的内存都塞给 GC 判断可用空间。
5. 避坑指南:Span 和 Memory 的高频翻车现场
5.1 Span 进异步、进 lambda、装箱的编译期陷阱
用了半年 Span,我见过最多的报错就是 CS4012 和 CS8175,翻译成大白话就是:你让一个 ref struct 跑到了它不该待的地方。
比如你写了个同步方法,里面用了 Span<byte>,然后又图省事在同一个方法里 await 了一下,编译器直接给你报错。再比如你写 LINQ 的 Select(x => x.Span),也会报错,因为 lambda 会被编译器提升成闭包类,而闭包类是个堆对象,装不下栈上的 Span。
应对方案有三个:一是把异步和同步分离开,异步方法签名用 Memory<T>,进来后同步段用 .Span;二是跳出用 Span 的代码段再 await;三是如果确实需要在 lambda 里处理,那就先把要的数据拷贝成局部变量再用,别跟编译器硬刚。
这个问题的本质不是麻烦,而是在提醒你:Span 的性能优势来自它“不出堆”的纪律。你每次试图把它塞进堆里,说明设计上可能该用 Memory<T> 了。
5.2 Memory 的“所有权”和 ArrayPool 归还纪律
Memory<T> 本身不负责回收底层缓冲区。当它绑定到 ArrayPool 租来的数组时,谁负责归还,必须有明确约定。我们内部定的纪律是:谁 Rent 谁 Return,且必须在同一个逻辑单元内完成。
最容易出问题的是异步回调场景。你在一个方法里 Rent 了缓冲区,传给另一个异步方法用,结果外层方法先结束了,Dispose 把数组还回池子,但内部的异步方法还在写这个数组。这会导致数据错乱,甚至下一次 Rent 的人拿到脏数据。我们后来给 RentedBuffer 加了 _disposed 标志,再在关键路径上断言,一旦出现提前归还直接抛异常,宁可启动失败也不让问题悄悄带进生产。
另外归还时机上注意,ArrayPool<byte>.Shared.Return(buffer, clearArray: false) 虽然省了清空数组的 CPU,但如果你的场景涉及敏感信息,建议还是用 true,否则内存中有残留数据,安全上不好交代。
5.3 别把优化写成负优化:几个反模式提醒
不是所有地方都适合用 Span 和 Memory。我用血缘关系总结几个熟悉的负优化场景:
- 小数组也用 ArrayPool。数组长度小于 256 字节时,池化租借的开销可能比直接
new byte[]还高,而且池里维护的数组本身也占内存。小而短的对象直接分配反而划算。 - 无脑把字符串改为
Span<char>。如果字符串本来就不大、生命周期短,Substring产生的临时对象对 GC 压力微乎其微,改成 Span 后代码可读性大幅下降,纯属得不偿失。 - 在异步链路上传
Memory<T>后忘记.Span。有些人把Memory<T>传给异步方法,方法里又拿Memory.Span去做长时间持有,这跟开了个口子让 ref struct 进堆没区别。 - 把所有返回值改成
Span<T>。如果你要返回的数据来自一个需要复用的缓冲区,而调用方会长时间持有,用只读视图就是在制造悬挂引用。这时应该拷贝一份或改用所有权转移方案。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 应对策略 |
|---|---|---|
| 编译报错 CS4012 / CS8175 | Span 进入了 async/lambda/闭包 | 改用 Memory 或在出堆前完成操作 |
| 数据被莫名覆盖 | ArrayPool 数组被提前归还后复用 | 严格 Rent/Return 配对,加断言 |
| 内存不降反升 | 池化数组归还时 clearArray=false + 租借过大 | 限制最小租借长度,按需归还 |
| 延迟尖刺仍在 | 改完代码但 GC 模式配置不匹配 | 根据分配率调整 Server GC/并发 GC |
| P99 虽降但 GC 次数仍多 | 冷路径还有大量字符串拼接 | 用 dotnet-trace 重新抓分配热点 |
这个速查表是我自己被坑之后整理出来的,每次排查问题时照着看一眼,能省不少时间。尤其是最后一行,一定要记住:优化是持续迭代的过程,不可能一次改造解决所有问题。
最后再分享一点个人体会:Span 和 Memory 的整套实践,表面上看是技术升级,本质上是逼着你重新思考对象生命周期。客服系统这种高并发实时场景,性能和可维护性其实不矛盾——当你把每条消息的临时对象数量从几十降到几个,代码逻辑反而更清晰,因为每一个缓冲区的来源和去向都变得明确。如果你也在做类似的高性能改造,我的建议是不要急着全量重构,先挑一条最核心、分配最狠的链路下手,把方法跑通、把收益测出来,再逐步铺开。
