前言
这周系统更新了一个版本,部署到线上.
客户反馈整个系统全部都卡顿,随即我们上服务器检查
发现整个服务器内存竟然达到了20-30G的占用..如图:

其中有一个订单服务,独自占用13-18G内存,
当它重启以后,内存会降低下来一段时间,但过不了多久 就又会增长上去
高度怀疑出现了内存溢出的情况,由于是线上服务器而且是离线内网.
项目又都运行在docker容器中,容器为了最小化,采用了极简的系统,几乎任何常见命令都没有.
所以考虑采用挂载额外辅助容器的形式进行调试.
正文
1.创建调试用的辅助容器
这个简单,我们直接创建DockerFile如下:
# 使用 .NET 5 SDK 作为基础镜像
FROM mcr.microsoft.com/dotnet/sdk:5.0
# 安装 dotnet-dump 、 dotnet-stack 、dotnet-counters、dotnet-trace的旧版本(兼容 .NET 5)
RUN dotnet tool install -g dotnet-dump --version 5.0.220101 \
&& dotnet tool install -g dotnet-stack --version 1.0.130701 \
&& dotnet tool install -g dotnet-counters --version 5.0.251802 \
&& dotnet tool install -g dotnet-trace --version 5.0.251802 \
&& apt-get update \
&& apt-get install -y unzip procps \
&& echo 'export PATH="$PATH:/root/.dotnet/tools"' >> /root/.bashrc
#设定全局工具环境变量
ENV PATH="${PATH}:/root/.dotnet/tools"
# 默认启动 bash 以方便交互
CMD ["/bin/bash"]
这里我们使用当前系统RunTime对应版本的SDK作为基础镜像.
然后安装我们调试程序信息需要的工具:dotnet-dump 、 dotnet-stack 、dotnet-counters、dotnet-trace
先介绍一下这些工具.
dotnet-dump:可以收集和分析 Windows、Linux 和 macOS dump的信息,可以运行 SOS 命令来分析崩溃和垃圾回收器 (GC)。
dotnet-stack:可以收集 .NET 进程中的所有线程捕获和打印托管堆栈。
dotnet-counters:是一个性能监视工具,可以临时监视.NET程序的运行状况和做一些初级的性能调查
dotnet-trace:在不使用本机探查器的情况下对正在运行的.NET Core 进程进行跟踪
2.将辅助调试容器附加到应用容器运行
首先我们需要重新run一个应用容器,并给它特权模式和系统级的调试权限,大概命令如下:
docker run -d --name myapp --privileged=true --cap-add=SYS_PTRACE -e ASPNETCORE_ENVIRONMENT=dev -e COMPlus_EnableDiagnostics=1 --volume /home/tmp:/tmp order-test:5.0
重点是privileged 参数和cap-add 参数,还有应用系统的tmp文件夹需要映射到宿主机
然后我们运行我们的调试容器并附加到应用容器
docker run -it --rm --cap-add=SYS_PTRACE --pid=container:myapp --privileged=true --volume /home/tmp:/tmp dotnet-debug-tools:5.0
同样,它也需要privileged 参数和cap-add参数,tmp临时文件也需要映射在宿主机和应用容器同样的目录下
注意--pid=container:myapp 中的myapp 是上面应用容器的名称.
然后,我们在调试容器直接运行命令:
ps aux
应该就能看到应用服务中的dotnet的进程了.因为在容器中运行,所以一般dotnet的PID是1,如图:

3.分析应用容器内dotnet进程的情况.
我们可以先使用dotnet-counters进行监控.
命令如下,其中-p是dotnet的进程编号:
dotnet-counters monitor -p 1
能得到如下结果:
% Time in GC since last GC (%) 0
Allocation Rate (B / 2 sec) 98,016
CPU Usage (%) 0
Exception Count (Count / 2 sec) 0
GC Fragmentation (%) 8.189
GC Heap Size (MB) 11,419
Gen 0 GC Count (Count / 2 sec) 0
Gen 0 Size (B) 192
Gen 1 GC Count (Count / 2 sec) 0
Gen 1 Size (B) 36,742,336
Gen 2 GC Count (Count / 2 sec) 0
Gen 2 Size (B) 8.8066e+09
IL Bytes Jitted (B) 7,248,623
LOH Size (B) 3.3414e+09
Monitor Lock Contention Count (Count / 2 sec) 0
Number of Active Timers 209
Number of Assemblies Loaded 426
Number of Methods Jitted 135,063
POH (Pinned Object Heap) Size (B) 1,299,832
ThreadPool Completed Work Item Count (Count / 2 sec) 0
ThreadPool Queue Length 0
ThreadPool Thread Count 12
Working Set (MB) 14,910
我们可以直接借助AI分析这个性能指标如下:
指标 | 当前值 | 分析建议 |
---|---|---|
CPU Usage (%) | 0 |
CPU 几乎未被使用,说明当前应用 处于空闲或阻塞状态。如果此时预期它应该在处理请求,说明可能卡在 I/O、锁、数据库等等待资源上。 |
GC Heap Size (MB) | 11,419 MB |
堆内存非常大(约 11GB),说明分配对象多或存在内存泄漏风险。建议配合 dump 分析对象分布。 |
GC Fragmentation (%) | 8.189% |
碎片率较低,尚可接受。通常低于 20% 问题不大。 |
LOH Size (B) | ~3.34 GB |
Large Object Heap(LOH)占用了非常大的空间。意味着有很多大对象(如数组、字符串、缓存等)未被及时回收,或持续增长。需要进一步 dump 分析。 |
Gen 2 Size (B) | ~8.8 GB |
Gen2 表示长时间存活对象。此值非常高,说明内存中存在大量老对象未被释放,极可能存在内存泄漏。 |
Gen 0/1 GC Count | 0 |
说明当前没有发生 GC,或者是刚启动。这种情况下内存持续增长会导致最终触发 GC 或 OOM。 |
% Time in GC since last GC | 0% |
目前没有 GC 时间消耗,结合上面 GC Count 是 0,一致。 |
Working Set (MB) | 14,910 MB |
进程实际占用物理内存约 14GB,结合堆大小与 LOH/Gen2 数值合理,但这也是很大的内存占用,需要关注增长趋势。 |
Allocation Rate | 98 KB / 2 sec |
内存分配速率很低(接近空闲),说明当前没有明显的内存增长。 |
ThreadPool Thread Count | 12 |
线程池线程数正常范围,无需担心。 |
ThreadPool Queue Length | 0 |
没有待处理任务,说明任务执行速度没有瓶颈。 |
Exception Count | 0 |
无异常抛出,良好。 |
Monitor Lock Contention Count | 0 |
没有锁争用,说明线程间竞争不激烈。 |
根据AI的分析报告,我们可以得知:
GC Heap Size (MB) 堆内存极大达到了11G
**LOH Size (B)**大对象指标也很大,有3G的大对象
Gen2长期活动的对象很多,占用了8G
这样我们基本就可以断定是在应用中出现了内存泄漏的情况.
也不用额外在看别的信息了. 我们直接使用dotnet-dump 抓取内存快照进行分析,抓取命令如下:
dotnet-dump collect -p 1
我们会得到如下结果:
Writing minidump with heap to ./core_20250514_030614
Complete
由于内存快照比较大,复制回来分析..难度比较高.我们可以直接继续利用dotnet-dump analyze 在线上分析
执行命令如下:
dotnet-dump analyze core_20250514_030614
然后我们就可以使用sos命令进行分析了.
既然是内存泄漏,我们直接查看托管堆里面的到底是啥情况,命令如下:
dumpheap -stat
正常是按从小到大排序的..所以很尬,我们划到最下面,看到如下结果:

由于我比较清楚这个代码的情况,直接发现了一个很明显的问题,
Order.OperApply.ApplyVoucherDetail 只是一个业务实体而已,但是在堆里面有29W个对象,
明显是很不合理的.
我们直接分析它.可以使用dumpheap -mt 获取它的所有实列地址.
dumpheap -mt 00007f117f67e590
可以得到如下结果:

我们选择最后一个Adderss寻找对象的根方法.命令如下:
gcroot 00007f0ca9b55818
可以看到如下代码内容(由于太多,我只贴出来有用的):
-> 00007F0E643FB770 RabbitMQ.Client.Impl.AsyncConsumerWorkService
-> 00007F0DA4D0BBB8 System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[[System.Threading.Tasks.VoidTaskResult, System.Private.CoreLib],[StockManage.Handler.ProductInOutStockStatDistributedHandler+<HandleEventAsync>d__6, StockManage.Application]]
-> 00007F0AFCCE9020 System.Collections.Generic.Dictionary`2+Entry[[System.Object, System.Private.CoreLib],[Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry, Microsoft.EntityFrameworkCore]][]
-> 00007F1035A778A0 Order.OperApply.ApplyVoucher
这四条,我们就可以看出来,是RabbitMQ的消息队列,在ProductInOutStockStatDistributedHandler方法,进行消费的时候
会通过EF CORE 创建这个ApplyVoucher的实例,接下来,我们就需要查看这个ProductInOutStockStatDistributedHandler,到底做了什么.
4.排查代码
直接去查看这个方法的代码,发现竟然没有任何一处使用了ApplyVoucher实体.
所以,我们直接运行本地调试,发现在这个方法结束后会去查询ApplyVoucher表.
分析代码后,我们发现,由于我们使用的是ABP的框架,在方法结束后,会自动写入审计日志,最后才会结束整个调用.
遂调查审计日志模块,发现有小伙伴在审计日志中对ABP的AuditLogInfo对象进行了序列化操作.
查询ABP源码发现,AuditLogInfo的EntityChanges中竟然储存了Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry对象
而EntityEntry对象又包含了整个DBContext上下文..然而我们的DBContext 又开启了懒加载的功能
所以当它被序列化的时候...等于在序列化整个数据库..(这里省略一百个C...)
赶紧屏蔽这段代码,并更新到线上. 问题瞬间解决了..
5.深入求证.
解决问题后,还是比较好奇,有没有同样使用ABP的兄弟遇见相关问题,遂去查询abp源码仓库..
竟然发现在Extracting a Module as a Microservice的相关说明里.
看到了这一段...

而且ABP框架还特意创建了一个IAuditLogInfoToAuditLogConverter来转换AuditLogInfo对象..方便后面进行序列化和存储..
后记
这一次分析线上问题的过程,还是比较有参考性的,所以记录一下.也希望对各位兄弟们有帮助.觉得OK的可以点个推荐~~.3Q~