在分布式系统开发中,gRPC 作为一种高性能、开源的远程过程调用(RPC)框架,被广泛应用于构建高效的服务间通信。
我们的项目也不例外,利用 gRPC 实现了多个微服务之间的通信。然而,在系统上线并经过一段时间的运行后,我们遇到了一个棘手的问题 —— 内存占用持续增长,虽然并没有影响到系统的性能和稳定性。但在测试人员的眼里,就是一个致命的问题,测试通不过,版本就会被打回,怎么办?
当然需要排查问题,查找根源了。
我们的系统由多个基于.NET 开发的微服务组成,通过 gRPC 进行通信。
在日常的系统监控中,我们注意到其中一个核心微服务的内存占用呈现出持续上升的趋势。
起初,这种增长较为缓慢,并未引起我们的足够重视。但随着时间的推移,内存占用不断攀升,导致服务器的资源逐渐紧张,系统响应时间变长,甚至出现了偶尔的卡顿现象。
为了更直观地了解内存增长情况,我们使用了性能监控工具,
通过这些工具,我们绘制了内存使用情况的时间序列图,清晰地看到内存占用曲线几乎是一条不断上升的斜线。这表明内存泄漏问题的存在,我们必须尽快找到根源并解决,否则系统可能会因为内存耗尽而崩溃。
首先怀疑是代码中存在内存泄漏的漏洞。
为进一步深入剖析内存问题,借助内存分析工具。在.NET 生态系统中,有诸如 dotMemory 和 CLR Profiler 等强大的内存分析工具可供选用。我们最终选择了 dotMemory 进行内存分析。
先在开发环境中对出现内存问题的微服务进行内存快照。通过 dotMemory 的可视化界面,查看内存中的对象分布状况,试图找出那些占用大量内存的对象类型。
一些与 gRPC 通信相关的对象在内存中数量众多,且随着时间不断增多。比如,gRPC 的元数据(Metadata)对象以及一些请求 / 响应消息对象。
然而,仅仅知晓这些对象在内存中大量存在,还不足以确定问题的根源。😮💨😮💨😮💨
还需要进一步分析这些对象为何未被正确释放。
dotMemory 具备对象引用关系分析功能,通过该功能,我们查看了这些 gRPC 相关对象的引用链,试图找出阻止它们被垃圾回收(GC)的原因。
经过深入分析,我们发现一些对象之间存在循环引用的情况。在 gRPC 通信过程中,某些自定义的上下文对象和 gRPC 的内部对象形成了循环引用,这使得垃圾回收器无法正常回收这些对象,从而导致内存占用持续增加。
发现循环引用问题后,我们迅速着手对代码进行修改。针对涉及循环引用的自定义上下文对象,我们重新设计架构,尽量避免与 gRPC 内部对象形成不必要的引用关系。
具体而言,我们将一些不必要的对象引用改为弱引用(WeakReference),如此一来,在对象没有其他强引用时,垃圾回收器便能正常回收这些对象。
例如,在我们的 gRPC 服务方法中,原本有一个自定义的上下文对象 Context,它持有对 gRPC 请求对象 Request 的引用,同时 Request 对象又通过一些中间对象间接持有对 Context 的引用。我们将 Context 对 Request 的引用改为弱引用,
修改后的代码如下:
public classContext
{
privateWeakReference<Request> _requestWeakReference;
publicContext(Request request)
{
_requestWeakReference =newWeakReference<Request>(request);
}
publicRequestGetRequest()
{
Request request;
_requestWeakReference.TryGetTarget(out request);
return request;
}
}
我们对涉及循环引用的代码部分进行全面修改,并重新部署微服务至测试环境进行验证。然而,经过一段时间的测试,我们失望地发现内存持续增长的问题并未得到解决。
这让我们陷入困惑,难道问题的根源并非循环引用?
在解决循环引用问题失败后,我们开始怀疑是否是由于对 gRPC 内部机制理解不够深入,导致我们的解决方案未能生效。
于是我们深入钻研 gRPC 的源代码和官方文档,试图从中找出可能与内存问题相关的线索。
我们发现,gRPC 在处理通信过程中,会运用一些内部缓存机制来提升性能。
例如,gRPC 会缓存一些常用的元数据和消息对象,以减少重复创建和序列化的开销。这使我们联想到,是否是这些缓存机制在某些情况下未能正确清理,从而导致内存占用不断增加。
为验证这一猜想,我们尝试调整 gRPC 的一些配置参数,关闭一些可能与缓存相关的功能。
在 gRPC 客户端和服务端的配置中添加了以下参数:
// 关闭gRPC客户端的元数据缓存
var channel = GrpcChannel.ForAddress("https://localhost:5001",newGrpcChannelOptions
{
MaxRetryAttempts =,
LoggerFactory = loggerFactory,
// 关闭元数据缓存
MetadataRefreshInterval = TimeSpan.Zero
});
// 关闭gRPC服务端的消息缓存
var server =newGrpc.Core.Server
{
Services ={ MyService.BindService(newMyServiceImpl())},
Ports ={newServerPort("localhost",, ServerCredentials.Insecure)}
};
// 关闭消息缓存
server.Options =newServerOptions
{
MaxReceiveMessageSize =null,
MaxSendMessageSize =null,
// 关闭消息缓存
KeepAliveTime = TimeSpan.Zero,
KeepAliveTimeout = TimeSpan.Zero
};
再次部署微服务到测试环境进行验证。
然而,内存增长问题依旧存在,排查工作陷入僵局…
在多次尝试解决问题均无果后,我们决定重新审视整个系统的性能状况。
我们采用了更全面的性能测试工具,对系统在不同负载下的性能进行详细测试。
在测试过程中,我们不仅关注内存使用情况,同时还监测 CPU 使用率、网络流量等其他关键性能指标。
通过对性能测试数据的分析,我们发现一个异常现象。当系统负载较低时,内存增长速度相对缓慢;而随着系统负载逐渐升高,内存增长速度显著加快。这一现象让我们意识到,内存问题可能与系统的运行模式或者资源调度机制存在关联。
一位伙计提出了.NET 的运行时配置,探寻是否存在某些配置参数会影响内存的使用和管理。
查阅大量资料和官方文档后,我们注意到一个关键信息: .NET 默认启用 ServerGarbageCollection,这一设置虽然在许多场景下能提升性能,但在我们的特定系统环境中,却可能引发内存管理问题。
ServerGarbageCollection 采用多线程的垃圾回收算法,在一些情况下,尤其是在我们基于 gRPC 的微服务架构中,由于其对内存分配和回收的策略特点,可能导致内存碎片增加,回收效率降低,进而造成内存持续增长。
我们的微服务应用作为服务器端应用,默认启用的 ServerGarbageCollection 在复杂的 gRPC 通信和业务逻辑交互中,未能有效地管理内存,导致内存占用不断攀升。
确定问题根源后,我们立即着手修改应用程序的运行模式配置。
在.NET 中,可以通过修改项目的配置文件或者在代码中设置环境变量来调整垃圾回收模式。
我们选择在代码中设置环境变量的方式,以确保应用程序不再使用默认的 ServerGarbageCollection 模式。
在应用程序的入口点(通常是 Program.cs 文件),我们添加了以下代码:
using System;
classProgram
{
staticvoidMain()
{
// 禁用ServerGarbageCollection
Environment.SetEnvironmentVariable("COMPlus_GCServer","0");
// 其他应用程序启动代码
}
}
通过设置COMPlus_GCServer
环境变量为0,我们告知.NET 运行时不再采用 ServerGarbageCollection 模式。修改代码后,我们重新编译并部署微服务到测试环境。
经过测试,我们惊喜地发现内存持续增长的问题得到了彻底解决。内存使用情况趋于稳定,不再出现不断上升的趋势。同时,系统的整体性能也得到显著提升,响应时间明显缩短,系统的稳定性和可靠性得到极大增强。
为进一步验证结果,我们进行了长时间的高负载压力测试。在持续数小时的高强度测试过程中,内存使用始终维持在一个合理范围内,未出现任何异常波动。这充分证明我们通过修改运行模式配置,成功解决了内存泄漏问题。
如果我们想使用ServerGarbageCollection模式获取其优势,又想积极回收内存,可以采用下列方式。
<PropertyGroup>
<TargetFramework>net8.</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ServerGarbageCollection>true</ServerGarbageCollection>
<GarbageCollectionAdaptationMode></GarbageCollectionAdaptationMode>
</PropertyGroup>
如果使用代码,可以采用如下:
// 设置运行模式为服务器模式
Environment.SetEnvironmentVariable("COMPlus_GCServer", "1");
// 设置GarbageCollectionAdaptationMode为Responsiveness模式,以优化响应性
Environment.SetEnvironmentVariable("COMPlus_GCHeapHardLimitPercent", "80");
Environment.SetEnvironmentVariable("COMPlus_GCServer", "1");
Environment.SetEnvironmentVariable("COMPlus_GCConcurrent", "1");
Environment.SetEnvironmentVariable("COMPlus_GCLatencyMode", "LowLatency");
Environment.SetEnvironmentVariable("COMPlus_GCHeapHardLimitPercent", "80");
调整后,稳如老狗! 🐕🐕🐕
.NET 的运行模式有两种:服务器模式(
Server GC
)和工作站模式(Workstation GC
)。这两种模式在垃圾回收算法和资源分配策略上有所不同。服务器模式(
Server GC
)主要针对服务器端应用程序,它采用了多线程的垃圾回收算法,并且在内存分配上更倾向于充分利用服务器的多核 CPU 资源,以提高垃圾回收的效率和应用程序的整体性能。在 .NET Core 中,服务器垃圾回收既可以是非并发也可以是后台执行。工作站模式(
Workstation GC
)则更适合桌面应用程序,它采用单线程的垃圾回收算法,在资源分配上更注重减少应用程序的暂停时间,以提供更流畅的用户体验。工作站垃圾回收既可以是并发的,也可以是非并发的。 并发(或后台 )垃圾回收使托管线程能够在垃圾回收期间继续操作。 后台垃圾回收替换 .NET Framework 4 及更高版本中的并行垃圾回收。
如下,示意了服务器上执行垃圾回收的专用线程。
GC 内存分配原则: GC heap用于保存0、1、2代的对象时,需要向系统申请时的基本单位是Segment,系统会分配指定值大小的Segment用于存储对象,这些值会随着程序的实际执行情况,由GC动态调整。正是由于有Segment的概念所以回出现内存碎片的问题,所以GC在垃圾回收过程中会进行内存整理,以减少内存碎片提高内存使用率。 Segment的大小取决于系统是32位还是64位,以及它正在运行的垃圾收集器的类型,下表列出了分配时系统所使用的默认值: [表格]
.NET Core GC的几种配置模式:
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
特点:在吞吐量和相应速度上寻找平衡点, GC Heap数量为1,GC threads在分配空间的线程,GC线程优先权和工作线程具有相同的优先权,工作线程(非GC线程)会因为GC工作过程中短暂多次挂起。
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
特点:最大化吞吐量并优化gen2 GC性能, GC Heap数量为1,background GC线程与工作线程有相同优先级,但都低于前台GC线程 ,工作线程(非GC线程)会因为GC工作过程中短暂多次挂起,较并发性能更加(针对Gen2的)。
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
特点:多处理器机器上使用多线程处理相同类型的请求以便最大化服务程序吞吐量, GC Heap数量为每处理器1个,每个处理器都有一个专职的GC线程,GC线程拥有最高线程的优先级,工作线程(非GC线程)会因为GC工作过程中会被挂起。
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
特点:在并发& Server GC基础上优化gen2 GC性能, GC Heap数量为每处理器1个,每个处理器都有一个专职的GC background线程,background GC线程与工作线程有相同优先级,但都低于前台GC线程,工作线程(非GC线程)会因为GC工作过程中短暂多次挂起,较并发性能更加(针对Gen2的)ephemeral generation的前台GC工作时会挂起其他所有线程。
我们的微服务应用程序作为服务器端应用,默认使用的是 Server GC 模式,但可能存在一些配置不合理的情况。
进一步研究发现,在 Server GC
模式下,GarbageCollectionAdaptationMode
参数对垃圾回收机制有着重要影响。这个特性在.net8/9
中被引入,服务器 GC 现在支持动态堆计数,它们添加了一个被称为“动态适应应用程序大小”或 DATAS 的特性。所使用的算法能够随着时间的推移增加和减少堆计数,试图最大化其对吞吐量的视图,并在此和总体内存占用之间保持平衡。
DATAS
允许在内存受限环境中使用服务器 GC 模式,例如在 Docker 容器、Kubernetes Pod 。在您的服务将受到大量请求的攻击突发期间,GC 将动态增加托管堆的数量,以便从服务器 GC 的优化吞吐量设置中受益。突发结束后,GC 将再次减少托管堆的数量,从而减少应用使用的内存总量。即使在突发期间,GC 也可能选择将托管堆增加到每个逻辑 CPU 内核少于 1 个,因此您最终可能会使用更少的内存,而无需手动配置托管堆的数量。
DATAS
是一项很棒的新功能,它将 Workstation GC
和 Server GC
的优势结合在一起:您开始时内存更少,当请求激增时,GC 可以动态扩展其托管堆的数量以提高吞吐量。当请求数在以后的某个时间点减少时,也可以减少托管堆的数量以释放内存。
该参数有两种主要模式:Throughput
和Responsiveness
。
Throughput
模式下,垃圾回收器更侧重于最大化应用程序的整体吞吐量,会尽量减少垃圾回收的频率,以避免因频繁回收导致的性能开销,这在长时间运行且对吞吐量要求较高的服务器应用场景中较为适用;Responsiveness
模式则更注重应用程序的响应及时性,垃圾回收器会更积极地进行垃圾回收,尽量减少因内存不足导致的应用程序停顿时间,适用于对响应延迟敏感的应用。在我们的基于 gRPC 通信的微服务系统中,由于业务特点,可能需要在吞吐量和响应性之间找到一个更好的平衡。
我们检查项目的配置,发现当前并未对GarbageCollectionAdaptationMode
参数进行显式设置,系统采用的是默认配置,这或许在复杂的 gRPC 通信及高并发业务场景下,无法有效地管理内存,从而导致内存占用持续增长。
回顾整个内存持续增长问题的排查历程,历经诸多曲折与挑战。但路虽远行则将至、事虽难做则必成
,虽然很难熬,但一定要扛过来。
从最初的代码审查和内存分析,到尝试解决循环引用问题、调整 gRPC 配置,每一步都倾注了大量的时间和精力,但问题始终未能得到解决,压力随之而来,没办法,高技术行业就是如此!
直到我们通过全面的性能测试和深入研究.NET 运行时配置,才最终揪出问题的元凶 —— 默认使用的 ServerGarbageCollection
模式。
通过这次经历,我们深刻认识到在排查复杂技术问题时,不能仅局限于表面现象和常见的问题根源,而需从多个维度进行全面深入的分析与研究。对底层技术原理和运行机制的透彻理解,是解决问题的关键所在。在未来的开发工作中,我们将更加注重系统性能的优化和监控,提前预防类似问题的发生。
这次内存问题的排查过程虽充满挑战,但也为我们积累了宝贵的经验。
希望本文所记录的内容,能为其他开发者在遇到类似问题时提供有益的帮助,共同提升分布式系统开发的质量与可靠性。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有