尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

高并发下GC抖动实战:用Span与Memory将P99延迟从300ms降至15ms

高并发下GC抖动实战:用Span与Memory将P99延迟从300ms降至15ms 做 .NET 服务端的朋友应该都体会过这种滋味平时压测指标漂亮得像教科书一上线流量稍微抖一下P99 延迟曲线就像心电图。尤其是在线客服这类典型的高并发低延迟场景用户每发一条消息、每敲一个字后台都要做鉴权、路由、存储、推送任何一个环节多分配几 KB 的临时对象积累到峰值就是一场 GC 风暴。升讯威客服系统是做多通道在线客服消息处理的每天要扛住几百万条消息我们对“稳定低延迟”这条线的要求几乎到了苛刻的程度。今天我想用这次优化经历聊聊我们是怎么用 Span 和 Memory 把 GC 抖动压下去让延迟曲线重新变平直的。这次的功课我记了很久从最开始定位到 GC 时间偏高到一点点把协议解析、序列化、缓冲区分配改造成 Span/ArrayPool 模式再到最终把 P99 延迟降下来整个过程中踩了不少坑也积累了一些可复用的套路。这篇文章会从 GC 问题的出现讲起拆解 Span 和 Memory 的核心机制然后完整走一遍我们在升讯威客服系统里的实际改造流程最后附上常见坑位和排查手段希望能给正在被 GC 抖动折磨的朋友一些思路。1. 先复盘一下GC 抖动是怎么拖垮低延迟的1.1 客服系统里最典型的 GC 压力点升讯威客服系统的核心链路并不复杂客户端上线、连网关、发消息、消息进路由、匹配坐席、推送到对端、写历史记录。这个链路里有几个热路径几乎每条消息都会经过协议解析我们从 WebSocket/TCP 上读下来的原始字节要先转成业务协议对象。旧代码里大量使用了Encoding.UTF8.GetString、Substring、Split、Regex每条消息都要产生几个甚至十几个临时字符串和 char 数组。消息序列化回包要序列化成 JSON当时用的JsonSerializer.SerializeToUtf8Bytes每次都会 new 一个byte[]。消息一多字节数组就像流水一样进堆其中个头大的直接进 LOH。日志与埋点为了排查问题每一条消息都打了详细日志日志模板里到处都是string.Format和$...虽然日志是异步写的但字符串对象仍然在业务线程里产生。临时缓冲以前写发送逻辑图省事直接new byte[4096]或者new byte[8192]高频连接下小数组分配反而成了最大的 GC 来源。这些问题单独看都不致命但叠在一起就麻烦了。每一条消息可能制造 20~30KB 垃圾按每秒 1000 条消息算每秒就是 20MB 垃圾。这个速率看起来还能接受但一旦流量翻倍GC 就会从“可接受”变成“肉眼可见”卡顿和超时立刻冒头。1.2 从一次 P99 飙升说起比理论更直观的是线上事故。我记得是某次推广活动刚结束坐席后台的延迟监控直接红了。平常 P99 稳定在 20ms 左右那几天 P99 飙到 300ms 以上偶发消息发送要转好几秒用户“正在输入”的提示都出不来。我们第一反应是数据库慢查询但查了一圈SQL 都很正常。后来用dotnet-counters盯了一下进程问题立刻现形% Time in GC竟然到了 18%Gen 2 GC 达到每分钟 30 多次LOH 大小冲到 1.8GB。再用 PerfView 抓分配热点两个大头清清楚楚协议层用Regex.Replace清洗消息正文每分钟分配 600MB 左右的字符串JsonSerializer.SerializeToUtf8Bytes每次序列化都会直接分配大字节数组达到 LOH 阈值后反复触发 Gen 2 回收。那个瞬间我意识到真正要解决的已经不是“某段代码怎么写”而是整个热路径上的分配策略。GC 抖动不是一个点的问题而是一条链路的系统性问题。2. 为什么是 Span 和 Memory内存抽象带来的范式转变2.1 Span 到底是什么Span 是 .NET Core 2.1 引入的一种特殊值类型它本质上是一块连续内存的“视图”一个指针加一个长度。它可以是数组的一部分、字符串的一部分、stackalloc出来的栈内存甚至非托管内存。使用它的最大意义在于切片不需要复制数据也不会像Substring那样产生新对象。可以类比成图书馆查资料以前你需要把一本书的某一页复印走才能拿回座位慢慢看现在你只需要记下“第几册第几页到第几页”随时随地翻原文而且这个“书签”不占多少空间。代码里最直接的体现是这样// 以前Substring 会产生新的 string string json {\type\:1,\session\:\abc123\,\payload\:\hello\}; string typeStr json.Substring(8, 1); // 现在ReadOnlySpanchar 只是指向原字符串的某一段零分配 ReadOnlySpanchar span json.AsSpan(); ReadOnlySpanchar typeSpan span.Slice(8, 1);ReadOnlySpanchar用起来和字符串很像生活常见的解析操作都有重载比如int.TryParse(ReadOnlySpanchar, out int result)、DateTime.TryParse等都不需要额外转 string。但 Span 有严格限制它是ref struct不能装箱不能作为类的字段不能出现在async方法里跨越await使用也不能作为泛型类型参数比如ListSpanint这种写法就是非法的。这些限制都是为了安全Span 可能指向栈内存或非托管内存如果允许它被堆对象持有底层内存释放后就悬空了。2.2 Memory 和 Span 的分工既然 Span 不能在异步里用那跨异步传递数据怎么办答案是MemoryT。MemoryT是 Span 的“可堆存版本”它描述一块连续内存区域可以存在类字段里可以传给 async 方法可以通过.Span属性拿到对应的SpanT。它可以被理解为 Span 的一块安全外衣Span 负责高效地“做事”Memory 负责安全的“搬运”。举一个很典型的配合用法async Task RelayAsync(byte[] rawData, int length) { Memorybyte memory rawData.AsMemory(0, length); // 需要同步解析的地方用 Span var span memory.Span; int type ParseType(span); // 需要异步发送的地方用 Memory await _output.WriteAsync(memory, CancellationToken.None); }在升讯威客服系统里凡是“拿到字节后要立刻解析再同步判断”的短生命周期场景我都用 Span凡是“解析完还要丢进队列、异步持久化、异步转发”的场景就用 Memory。两者不是替代关系而是根据数据的生命周期各取所需。2.3 内存租用模式ArrayPool 和 MemoryPool除了 Span 和 Memory真正把 GC 压下来的另一个功臣是数组池。ArrayPoolT.Shared让开发者可以从一个共享池子里租用数组用完再还回去而不是每次都 new。这个模式对于热路径上的大数组尤其有效因为数组不再反复进入 GC 堆LOH 的碎片和回收压力会大幅下降。看这段代码就会立刻明白差别// 旧模式每次发送都分配一个 64KB 数组超过 LOH 阈值 byte[] buffer new byte[64 * 1024]; // 新模式从池里租用用完归还GC 几乎无感知 byte[] buffer ArrayPoolbyte.Shared.Rent(64 * 1024); try { // 使用 buffer } finally { ArrayPoolbyte.Shared.Return(buffer); }MemoryPoolbyte则是更高级的封装它直接把“租用”和“Memory”结合返回一个IMemoryOwnerbyteusing IMemoryOwnerbyte owner MemoryPoolbyte.Shared.Rent(4096); Memorybyte memory owner.Memory; // 填充数据然后传给别的组件 await _channel.Writer.WriteAsync(memory, ct);注意MemoryPoolbyte.Shared.Rent(4096)返回的 Memory 长度可能大于 4096所以一定要用Slice取你真正想用的那一段别直接拿owner.Memory.Length当实际长度。3. 在升讯威客服系统里的具体改造实践3.1 消息体解析从 string.Substring 到 ReadOnlySpan 切片我们的消息协议最早是一段 JSON后来为了吞吐改成自定义的紧凑格式首部是协议类型和会话 ID后面是负载。老版本解析代码很直白byte[] raw await ReceiveAsync(ct); string line Encoding.UTF8.GetString(raw); int firstColon line.IndexOf(:); int type int.Parse(line.Substring(0, firstColon)); string payload line.Substring(firstColon 1);每次调用至少分配三样东西整个字符串、类型子串、负载子串。负载往往还不小一传大消息内存直接飞掉。改成 ReadOnlySpan 后的核心逻辑byte[] raw ArrayPoolbyte.Shared.Rent(4096); try { int length await ReceiveIntoAsync(raw, ct); ReadOnlySpanbyte span raw.AsSpan(0, length); int firstColon span.IndexOf((byte):); if (firstColon 0) { // 协议错误处理 return; } var typeSpan span.Slice(0, firstColon); var payloadSpan span.Slice(firstColon 1); if (TryParseInt32(typeSpan, out int type)) { // 用 payloadSpan 继续处理全程零分配 } } finally { ArrayPoolbyte.Shared.Return(raw); }TryParseInt32是我自己写的小工具用来在byte切片上解析整数避免把typeSpan转成 stringprivate static bool TryParseInt32(ReadOnlySpanbyte bytes, out int value) { value 0; foreach (byte b in bytes) { if ((uint)(b - 0) 9) { return false; } value checked(value * 10 (b - 0)); } return true; }这段代码看起来简单但效果非常明显。原先解析一条消息最少分配 3 个字符串对象现在从读取到解析结束GC 分配里基本看不到这个环节的贡献。如果你用的是 UTF8 文本协议还可以直接用Utf8Parser.TryParse做整数解析性能和手写一样好。3.2 消息序列化用 Utf8JsonWriter Span 重写协议编码解析省了发送环节也不能浪费。以前我们给前端的回包是这样写的byte[] payload JsonSerializer.SerializeToUtf8Bytes(new { type 1, session sessionId, data messageData }); await _webSocket.SendAsync(payload, WebSocketMessageType.Text, true, ct);问题很明显JsonSerializer.SerializeToUtf8Bytes要先计算出 JSON 的字节长度再分配一个byte[]然后才写入。消息体一大这个临时数组就直接进 LOH。后来我们把它改成Utf8JsonWriterIBufferWriterbyte的模式把序列化输出直接写进 ArrayPool 租用的缓冲区sealed class PooledBufferWriter : IBufferWriterbyte, IDisposable { private byte[] _buffer ArrayPoolbyte.Shared.Rent(256); private int _written; public Memorybyte WrittenMemory _buffer.AsMemory(0, _written); public Spanbyte WrittenSpan _buffer.AsSpan(0, _written); public void Advance(int count) _written count; public Memorybyte GetMemory(int sizeHint) { EnsureCapacity(sizeHint); return _buffer.AsMemory(); } public Spanbyte GetSpan(int sizeHint) { EnsureCapacity(sizeHint); return _buffer; } private void EnsureCapacity(int sizeHint) { if (_buffer.Length - _written sizeHint) { int newSize Math.Max(_buffer.Length * 2, _written sizeHint); byte[] newBuffer ArrayPoolbyte.Shared.Rent(newSize); _buffer.AsSpan(0, _written).CopyTo(newBuffer); ArrayPoolbyte.Shared.Return(_buffer); _buffer newBuffer; } } public void Dispose() ArrayPoolbyte.Shared.Return(_buffer); }使用方式变成using var writerBuffer new PooledBufferWriter(); using (var json new Utf8JsonWriter(writerBuffer)) { json.WriteStartObject(); json.WriteNumber(type, 1); json.WriteString(session, sessionId); json.WriteString(data, messageData); json.WriteEndObject(); } await _webSocket.SendAsync( writerBuffer.WrittenMemory, WebSocketMessageType.Text, true, ct);这里还有一个很值得做的小优化把 JSON 里的固定属性名用JsonEncodedText预编码避免每次序列化时重复做 UTF8 编码。比如private static readonly JsonEncodedText SessionProp JsonEncodedText.Encode(session);然后在Utf8JsonWriter里json.WriteString(SessionProp, sessionId)。这样可以少掉一部分内部临时分配对高频回包场景很划算。3.3 缓冲区管理用 ArrayPool Memory 消除大对象堆碎片在升讯威客服系统里消息转发还有一类很隐蔽的消耗发送大消息时的临时缓冲区。旧代码喜欢按“最大消息长度”直接 new 一个 byte[]一分配就是 64KB 甚至 256KB很快就塞满 LOH。有个发送线程的本地数组平时看起来没什么问题但流量一上来LOH 频繁分配、回收、压缩% Time in GC直接暴涨。改成 ArrayPool 之后大数组会在池子里复用不再反复进 LOH。关键代码如下int maxFrameSize 64 * 1024; byte[] buffer ArrayPoolbyte.Shared.Rent(maxFrameSize); try { // 从某个地方读取数据到 buffer int received await channel.ReadAsync(buffer.AsMemory()); Memorybyte payloadMemory buffer.AsMemory(0, received); // 转发或持久化 await _broker.PublishAsync(payloadMemory, ct); } finally { ArrayPoolbyte.Shared.Return(buffer); }这里有两个细节非常重要第一Rent拿到的数组长度不一定等于我们请求的长度可能更大。所以后续所有操作必须使用received或者buffer.AsMemory(0, length)这种显式长度不能直接拿buffer.Length。第二如果要转发到异步管道最好在finally里归还之前确保所有异步对 buffer 的引用已经结束。所以在一个方法内部既要异步读又要异步写的时候建议用IMemoryOwnerbyte绑定生命周期using IMemoryOwnerbyte owner MemoryPoolbyte.Shared.Rent(maxFrameSize); Memorybyte bufferMemory owner.Memory; int received await channel.ReadAsync(bufferMemory); Memorybyte payloadMemory bufferMemory.Slice(0, received); await _broker.PublishAsync(payloadMemory, ct);用using包裹当异步链路执行完毕owner 自动归还就不会出现数据还没发完缓冲已经被还回池里的问题。3.4 异步路径上的 Memory 使用套路客服系统免不了做“异步化”比如消息进来先丢进 Channel/队列后台消费者再做持久化和 ES 写入。这个环节很容易犯一个错误想把 Span 塞进队列结果编译器不让。正确思路是凡是需要跨线程/跨异步传递的缓冲区一律用Memorybyte或者IMemoryOwnerbyte。我习惯在接收环节直接租用一个 ownerasync ValueTask OnRawMessageAsync(ReadOnlySequencebyte frame, CancellationToken ct) { using IMemoryOwnerbyte owner MemoryPoolbyte.Shared.Rent((int)frame.Length); Memorybyte buffer owner.Memory; frame.CopyTo(buffer.Span); Memorybyte payloadMemory buffer.Slice(0, (int)frame.Length); // 这里可以同步用 Span 做格式化、解析 var span payloadMemory.Span; MessageType type ParseMessageType(span); // 这里异步写库/推送用 Memory await _historyStore.AppendAsync(payloadMemory, ct); }这套流程写起来并不复杂但它是整个低延迟架构里比较关键的一环。它让缓冲区生命周期非常清晰谁租用谁负责在消费完以后释放。释放之后池里的数组立刻可以被下一条消息复用内存整体处于一个“周转”状态GC 里很难再出现大块垃圾。4. 改造效果与性能数据对比4.1 分配量下降Allocated Bytes/op 的对比整个改造完成后我们用 BenchmarkDotNet 专门为热路径写了基准测试。基准分为两种一种解析 1000 条协议消息另一种序列化一条带有 20 个字段的消息。结果如下操作版本平均耗时每次操作分配解析 1000 条协议消息旧代码1.34 ms1.83 MB解析 1000 条协议消息新代码0.52 ms0 B序列化一条消息旧代码3.08 us2.87 KB序列化一条消息新代码1.21 us0 B新代码为什么能做到 0 B因为池化分配不会计入 GC 分配统计Span 操作全部在栈上或者原有数据上进行不产生新对象。不要去纠结 BenchmarkDotNet 输出里的 0 B这是池化方案的正常表现。4.2 延迟数据P99、P99.9 的变化基准只是局部验证真正的效果要从压测和线上数据看。我们在测试环境模拟了 5000 个在线连接、每分钟 10 万条消息的持续压力分别对比改造前后的延迟分布指标优化前优化后平均延迟21 ms9 msP5016 ms7 msP9962 ms15 msP99.9180 ms28 ms最大超时次数每分钟400最明显的变化是长尾消失了。之前 P99.9 动不动飙到 150ms 以上现在整体被压在了 30ms 内即使偶尔来一波大消息也没有再出现远超基线的尖峰。4.3 GC 次数和耗时统计用dotnet counters在压测现场采集到的 GC 数据更能说明问题指标优化前优化后Gen 0 GC 次数/分钟1830220Gen 1 GC 次数/分钟31038Gen 2 GC 次数/分钟422% Time in GC18.5%2.1%LOH Compaction 触发偶发无Gen 2 回收从每分钟 42 次降到 2 次意味着 Stop-The-World 几乎没有机会再跳出来打断业务。以前那种“某一条消息突然延迟 300ms”的现象很大程度上就是频繁 Gen2 回收造成的。5. 常见问题与排查技巧实录5.1 典型坑Span 不能用于 async、不能装箱、不能作为字段这次改造里我们踩过的坑几乎都集中在 Span 的 ref struct 限制上。整理成一张清单方便大家避雷Span 不能跨 await 使用。如果异步方法里想用 Span编译器直接报错。解决办法是把同步解析放在 await 之前或者改用 Memory。Span 不能作为类字段。如果你觉得一块数据以后还要用要么复制成数组或 string要么换成MemoryT存储。Span 不能装箱。不可以转 object、转接口、存到dynamic、存进 ArrayList这些都会让 Span 进入堆内存编译器不允许。Span 不能再 yield return 的迭代器里用。迭代器本质是状态机会把局部变量存到堆上所以同样受限。ArrayPool.Rent返回的数组长度可能超过预期。很多人第一次用会拿buffer.Length当真实长度结果把 padding 数据一起发送出去了。必须自己记住写入长度。MemoryPoolbyte.Rent的owner.Memory是整个租用缓冲区的 Memory不是恰好 4096 的精确长度用之前必须Slice。池化缓冲区用完后一定要归还否则池会持续增长或者不再复用内存反而比之前更容易爆。如果数据敏感Return时传clearArray: true。5.2 用 BenchmarkDotNet 和 PerfView 定位 GC 问题如果你也遇到 GC 抖动不要一上来就乱改。先用工具定位定位比优化重要十倍。第一步跑dotnet-counters monitor -p pid --counters System.Runtime能看到实时 GC 次数和% Time in GC。如果% Time in GC超过 10%基本说明分配压力已经很大了。第二步用 PerfView 抓进程的GC AllocTick事件。它会列出当前运行期间每个方法分配了多少字节。升讯威客服系统里一抓一个准排名前几的分配站点基本就是我们下一轮要改造的地方。第三步用dotnet-trace收集 GC 相关事件生成时间线可以看到 Gen2 回收发生在哪些时间点、持续了多久。这个对确认“GC 停顿导致超时”很有帮助。第四步在代码里也可以用GC.GetAllocatedBytesForCurrentThread()快速测量某段函数的分配量long before GC.GetAllocatedBytesForCurrentThread(); DoSomething(); long after GC.GetAllocatedBytesForCurrentThread(); Console.WriteLine($Allocated: {after - before} bytes);我自己的习惯是先跑基准拿到精确数据再结合 PerfView 定位具体方法最后才动手改代码。没有数据支撑的“感觉这里应该优化”基本都会改偏。5.3 速查表什么时候用 Span / Memory / ArrayPool / stream场景推荐方案原因同步解析小段文本或二进制ReadOnlySpanT零分配切片不需要拷贝栈上高效异步处理消息缓冲区MemoryT MemoryPoolbyte可跨 await池化复用避免堆分配临时大缓冲ArrayPoolT.Shared减少 LOH 分配避免 Gen2 回收网络流/文件流处理PipeReader / SequenceReader底层已用 SpanMemory天然低分配需要长期缓存的共享数据普通数组 / string生命周期长池化反而增加管理成本这里我想特别说一句并不是所有地方都要用 Span。业务层拿到 string 做普通判断完全没有必要硬改成 Span那是给自己找麻烦。优化要瞄准热路径上的“短生命周期临时对象”只有这些对象才值得用池和 ref struct 消除。最后再分享一个小技巧改造后的日常维护里我给自己定了一条纪律每次提交性能相关代码必须附带 BenchmarkDotNet 跑出来的分配数据不能只说“这样更快”。这样做的原因很简单性能优化本质上是在跟“分配模型”博弈没有数据就不知道改对了没有。如果你现在正被 GC 抖动困扰我建议你先从“去掉热路径里的 string.Substring”和“用 ArrayPool 替换大数组”这两个点入手。这两个动作最容易做见效也最快。等真正吃透了 Span 和 Memory 的行为边界再扩展到更复杂的序列化和异步管道路线会顺畅很多。
返回列表