当前项目是 .NET 5 EentityFrameworkCore,疑似内存泄漏,之所以说是疑似是因为到目前位置还没有能准确的定位到问题。当前这个项目框架从 .NET Core 2.1 就开始用,期间有升级到 3.1、5.0、6.0,在排查过程中还把 5.0 分支升级到了 7.0 。不幸的是这些分支都存在疑似泄漏的现象。
项目部署在 Linux 中的 Docker,Linux 有 RedHat、CentOS、Deban,Docker 版本也是多个版本。这个项目很多分支版本,现在所在公司的主要业务存在很多相似的需求,当出现客户定制化需求的时候就会做差异化代码甚至单独切一个分支出来。根据目前的情况来看问题应该是在 .NET Core 2.1 的版本就已经存在。
发现这个问题是在大约是在2023年的6月份,这篇过程记录写于10月份,问题的现象是部署的 Docker API 服务内存占用从刚刚启动时的 3xx MB然后一直涨,只要有人访问就涨。不同的项目存在些许差异。这里主要分析的是两个 .NET Core 3.1 和 .NET 5.0 版本的两个分支项目。
这里虽然提到 .NET 版本并不是说版本存在问题,这里可以排除,首先作为大厂 Release 版本的分支理论上出现这种情况的概率小之又小,另外当前公司存在另外一个 .NET 3.1、.NET 6.0 项目使用不同的框架而并没有出现内存泄漏的现象。所以目前怀疑的主要是业务代码。版本只是区分不同项目。
这两个项目的存在使用上的明显的区别:
.NET Core 3.1:
**背景:**这个项目使用人少,但是接入物联网设备数百个,一直处于持续运行被动接收数据的状态几乎没有喘息的机会。
**现象:**内存上涨缓慢(同比 .NET 5 项目),一天 200+ MB 的涨幅。
.NET 5.0:
**背景:**这个项目使用的时段主要是工作日的白天,其中有几个接口的数据单次拉取大的时候有几十上百兆。晚上几乎没人用。
**现象:**无人访问时内存稳定,在调用大数据接口后暴增至 1.xx ~ 2.xx GB,在月末月初使用高峰时期,内存占用持续增加至物理内存上限,然后 Docker 自动重启。(服务器有升级见正文)
1. 首先想到的是存在没有释放的内存。
排查方案:使用 using 释放内存
于是排查整个项目中的代码,到处尝试加 using 手动释放资源。这个过程连续搞了大约两周,此后断断续续都在反复看代码,应该是把整个项目的代码都看完了。这段时间看代码都会自带特效,几乎一眼就能看出哪些需要加 using。这个滤镜有一个后遗症,就看大部分代码都可以加。感觉不能加的都要手动去加一个试一下,万一呢 ~~~
结论:没有效果 ~~~ 可能是因为当前项目主要是原生代码,这些 GC 都有安排就算没用手动释放,只要没有一直处于被应用状态,都会被安排回收。期间写了下面这段代码,每隔一分钟输出当前 API 资源占用情况,就是通过这个观察到 Docker 是在内存耗尽时自动重启的。
cs
#region 系统资源监测
[DllImport("kernel32.dll")]
public static extern void GlobalMemoryStatus(ref MemoryInfo32 meminfo);
[DllImport("kernel32.dll")]
public static extern void GlobalMemoryStatus(ref MemoryInfo64 meminfo);
public void GetSystemInfo()
{
var stopwatch = Stopwatch.StartNew();
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
var meminfos = System.IO.File.ReadAllText(@"/proc/meminfo").Split(' ').Where(o => o != string.Empty).ToList();
var remainPercent = decimal.Parse(meminfos[5]) / decimal.Parse(meminfos[1]) * 100;
// if (remainPercent > 79) Collect();
Logger.Info($"============================> 物理内存:{GetUnit(meminfos[1])}");
Logger.Info($"============================> 已用内存:{GetUnit(meminfos[3])}");
Logger.Info($"============================> 可用内存:{GetUnit(meminfos[5])}");
Logger.Info($"======================> 剩余内存百分比:{remainPercent:N2}%");
/*
Logger.Info(meminfos.ToJsonString());
["MemTotal:","8008064","kB\nMemFree:","1924628","kB\nMemAvailable:","4107656","kB\nBuffers:","15132","kB\nCached:","2466712","kB\nSwapCached:","0",\
"kB\nActive:","3761208","kB\nInactive:","2060276","kB\nActive(anon):","3373112","kB\nInactive(anon):","88024","kB\nActive(file):","388096",
"kB\nInactive(file):","1972252","kB\nUnevictable:","0","kB\nMlocked:","0","kB\nSwapTotal:","0","kB\nSwapFree:","0","kB\nDirty:","280",
"kB\nWriteback:","0","kB\nAnonPages:","3337920","kB\nMapped:","212864","kB\nShmem:","121496","kB\nSlab:","118600","kB\nSReclaimable:","88208",
"kB\nSUnreclaim:","30392","kB\nKernelStack:","4656","kB\nPageTables:","15832","kB\nNFS_Unstable:","0","kB\nBounce:","0","kB\nWritebackTmp:","0",
"kB\nCommitLimit:","4004032","kB\nCommitted_AS:","4199784","kB\nVmallocTotal:","34359738367","kB\nVmallocUsed:","20180","kB\nVmallocChunk:","34359684828",
"kB\nPercpu:","880","kB\nHardwareCorrupted:","0","kB\nAnonHugePages:","1921024","kB\nCmaTotal:","0","kB\nCmaFree:","0",
"kB\nHugePages_Total:","0\nHugePages_Free:","0\nHugePages_Rsvd:","0\nHugePages_Surp:","0\nHugepagesize:","2048","kB\nDirectMap4k:","81408",
"kB\nDirectMap2M:","4112384","kB\nDirectMap1G:","6291456","kB\n"]
*/
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (RuntimeInformation.OSArchitecture == Architecture.X64)
{
var memInfo = new MemoryInfo64();
GlobalMemoryStatus(ref memInfo);
var remainPercent = memInfo.AvailablePhysical / (decimal)memInfo.TotalPhysical * 100;
// if (remainPercent > 79) Collect();
Logger.Info($"============================> 物理内存:{GetUnit((long)memInfo.TotalPhysical)}");
Logger.Info($"============================> 已用内存:{GetUnit((long)(memInfo.TotalPhysical - memInfo.AvailablePhysical))}");
Logger.Info($"============================> 可用内存:{GetUnit((long)memInfo.AvailablePhysical)}");
Logger.Info($"======================> 剩余内存百分比:{remainPercent:N2}%");
}
else if (RuntimeInformation.OSArchitecture == Architecture.X86)
{
var memInfo = new MemoryInfo32();
GlobalMemoryStatus(ref memInfo);
var remainPercent = (decimal)memInfo.AvailablePhysical / (decimal)memInfo.TotalPhysical * 100;
// if (remainPercent > 79) Collect();
Logger.Info($"============================> 物理内存:{GetUnit((long)memInfo.TotalPhysical)}");
Logger.Info($"============================> 已用内存:{GetUnit((long)(memInfo.TotalPhysical - memInfo.AvailablePhysical))}");
Logger.Info($"============================> 可用内存:{GetUnit((long)memInfo.AvailablePhysical)}");
Logger.Info($"======================> 剩余内存百分比:{remainPercent:N2}%");
}
}
}
catch (Exception ex)
{
Logger.Error($"系统信息获取异常:{ex.Message}");
}
try
{
using var proc = Process.GetCurrentProcess();
Logger.Info($"============================> 启动时间:{proc.StartTime}");
Logger.Info($"============================> 运行时间:{Clock.Now - proc.StartTime}");
// Logger.Info($"====================> 2小时登录/总人数:{login}/{total}");
Logger.Info($"==============================> 线程数:{proc.Threads.Count}");
Logger.Info($"============================> 虚拟内存:{GetUnit(proc.VirtualMemorySize64)}");
Logger.Info($"========================> 应用峰值内存:{GetUnit(proc.PeakWorkingSet64)}");
Logger.Info($"========================> 应用当前内存:{GetUnit(proc.WorkingSet64)}");
Logger.Info($"======================> 专用工作集内存:{GetUnit(proc.PrivateMemorySize64)}");
}
catch (Exception ex)
{
Logger.Error($"进程信息获取异常:{ex.Message}");
}
Logger.Info($"========================> 监测日志耗时:{stopwatch.ElapsedMilliseconds}ms");
}
/// <summary>一次性回收三代内存</summary>
private void Collect()
{
GC.Collect(0);
GC.Collect(1);
GC.Collect(2);
}
#endregion
2.怀疑是服务器性能不够导致排队处理不过来
排查方案:认为服务器不行,换服务器
当前服务器是 ARM 4核8GB,在迁移到这个服务器之前使用的是 x86 服务器也是 4核8GB,并且当时在迁移过来后有明显的"反应慢"的体验。所以怀疑服务器不行,其实更怀疑是代码问题,但是换服务器比来得快,同时当前对于代码其实是没有头绪的。所以选择升级服务器至 x86 8核16GB 这种更快的方式。
结论:升级后整个站点的访问性能有明显提升,但是内存占用高的问题并没有解决,并且占用彷佛就是物理内存的上限(当前没有再继续升级内存不能验证)。从 ARM 升级至 x86 后整个站点有明显"快" 的感觉。
3.怀疑静态 ConcurrentDictionary 一直在增长
排查方案:1.确认 ConcurrentDictionary 是否一直增长。2.验证 Remove 后内存是否释放。
1.将所有代码中的 static ConcurrentDictionary 集中输出 .Count 来观察是不是一直在增加,这个动作搞了半天,把全是总数都输出了,发现只有部分静态变量出现双倍数据,并没有一直增长。解决了个小问题,去掉了一半的数据。期间还把部分 static 转移到 Redis 中去,给 API 服务腾空间(效果不怎么明显,因为内存还是在涨,可以说是没用效果),如果出现 Redis 爆了,那么问题就算确诊了。
2.通过测试代码验证到底 Remove 后有没有释放内存。观看下面代码,左边主要是两个按钮的点击事件,第一个是往 ConcurrentDictionary 中插入 10W 个 Guid。可以从右边的内存占用中看到三个峰值就是在这个动作下产生的。
然后执行第二个方法,将 ConcurrentDictionary 中的数据 Remove 和 Clear。两种操作看起来并没有完全削峰,手动多次后会释放。期间还测试了连续多次继续 TryAdd 内存会一直涨,并且在 Remove 和 Clear 后并不会立刻释放。等 GC 大佬安排,等多久?什么时候?完全不清楚 ~~~
在 67 ~ 70 行新增了手动回收 0、1、2 三代内存多次后 ConcurrentDictionary 会释放内存。
结论:static ConcurrentDictionary 变量没有一直增长。在 Remove 和 Clear 后不会释立刻放内存,除非手动回收。但是问题并没有解决,因为这两个场景的数据量都不是特别的多。
4. 升级至 .NET 7 版本,怀疑是 .NET 5 有问题(已经开始不要脸了)
排查过程:当前分支中已有 .NET 6。新版 .NET 7 说是对于性能和内存管理都有大幅的提升,所以乘此机会升级试试(万一呢),说实话,其实内心对于 .NET 出问题的概率还是比较低,不至于被我碰到,毕竟都是很普通的业务场景没有多少高科技的逻辑。并且 Visusl Studio 2022 的升级频率也相当高,但是现在对于解决问题的渴望已经达到了顶点,这种感觉就像是前面的 using ,明知道没用但是哪怕有一丝丝希望还是愿意尝试。
结论:从 .NET 5 升级到 .NET 7 后解决方案的启动性能有了明显提升,"感觉"的提升比较明显,有没有用不清楚,因为内存持续增长的问题并没用消失。最后继续不要脸(这两个版本都不是长期支持版本 ~~~ 等下一版 .NET 8 发布后继续升级)
5. 使用 Visual Studio 诊断工具分析内存占用
排查过程:在不断的搜索中,接触到 VS 诊断工具,不确定这个工具是从哪个版本开始的,但是直到2023年才知道可以分析内存占用,之前的版本中都是直接把这个窗口关了。这个过程有几天,不是全情投入,因为"摸不到头脑",又被难住了。通过工具得知 .NET 5 项目之所有在调用接口后内存暴增的原因是 EntityFrameworkCore 的缓存导致的,但是对于这个缓存机制的理解仅限于"缓存"这两个字,其他的不清楚 ~~~
又尝试使用**builder.EnableServiceProviderCaching(false);**禁用 EFCore 的缓存,确实有效果整个站点立马变得比之前 ARM 平台还要慢几倍,吓得我立刻注释掉。毕竟现在是疑似内存泄漏,就算这个动作解决了问题,但是这种体验是不能被接受的。
在搜索 Visual Studio 诊断工具如何使用的过程中,又接触到 WinDbg。也搜索了一些 WinDbg 的使用方法,成功在服务器中导出了 dmp 文件(特别大 ~~~)但是这个的使用方法还没入门就进行不下去了,大约两天时间经历了一次"从入门到放弃"。
这里顺带提一下,这个问题虽然几个月都没能解决但是在人肉运维的加持下还没有出大问题。例如 .NET 5 这个版本虽然内存增长明显,但是晚上没人用,所以我们就加了凌晨3点的重启自动任务,使得 API 每天充满活力,我们一边开发一边如痴如醉的排查问题,好几次都是把开发任务拖到 deadline 才从死胡同中抽身去完成开发任务。内心来说,因为这个问题不解决,自己面子上有点挂不住(几个月过去了,其实已经开始习惯了 ~~~)
结论:知道了 EntityFrameworkCore 默认启用缓存,看起来缓存的时间非常的长,感觉不重启彷佛一直都在(不知道缓存的内部实现规则),没有验证禁用缓存是否任然会有内存溢出的,因为不能接受没有缓存时的丝滑。
虽然没有真正定位到为题,但是根据目前搜索的信息来看 Visual Studio 诊断工具和 WinDbg 是最有可能定位到问题的方式,Visual Studio 诊断工具信息量太大排查起来非常的"困",WinDbg 不会用,搜索使用方式看起来简单就几个命令,但是我这里一用就报 not found。时间投入也不够没能继续探索下去。
6. 将实例引用改为接口引用(不知道有没有用,因为理解不够透彻)
排查方案:将项目中的实例应用改为接口引用。
这个项目已经迭代好多年了,从 2018 年开始一直由本人亲自开发维护,对于这些代码已经熟悉到忘记了。时不时的还会吐槽几年前的自己为什么这么不严谨,自己定的规范自己都不遵守。
结论:不清楚有没有用,但是感觉整个项目的代码正在蜕变并趋于"完美",同时在重构过程中梳理了业务,使各个层的代码更清晰。
7. 排查 new 关键字
排查方案:鉴定 new 关键字的业务是否会存在风险
减少多余的 new,可以不赋初值的就不赋。而且在 new 数组的时候,尽可能的不要填长度,因为如果长度太大喀得就把内存给吃了,不熟悉的从表面上还看不出来,就比如现在这之前的我就没看出来。
在搜索的过程中发现有很多人出现内存泄漏的原因是用到了 HttpClient,可惜目前项目中没有"直接"使用 HttpClient,而且使用开源项目 WebApiClientCore,老实说我怀疑过这个组件(我就没有脸,不要脸 ~~~ ),但是现在项目中应用比较多,还不能通过去掉的方式来验证。但是一边又想这个开源项目也是从 .NET Core 2.1 开始用的当时是 WebApiClient.JIT 目前 .NET Core 3.1 项目使用的最新版 1.1.4,WebApiClientCore 是在 .NET 5 项目中使用,并且也一直在升级 目前使用的版本是 2.0.2 最新版本是 2.0.4 准备有机会就把它点了。
在排查期间整出个幺蛾子,一个线程里 while(true) 监测链接状态的业务,因为前面排查 static 变量时没注意把 static 去掉了,导致一直在 new Thread,在本地开发调试没发现问题,发布到测试环境因为用的人少,也没发现问题。直到上线的时候发现线程数一直在新增直到大约13,000 的时候 Docker 会自动重启。这个问题排查代码找了三天,全工时投入期间还不要脸的怀疑有第三方组件出问题又挨个升级了一遍。这个情况比内存泄漏的问题要急迫,因为只要用户数一起来一多个小时甚至不到一小时就会重启一次,这几天搞得心惊胆战心力憔悴生怕数据出问题。
结论:也许有效果,如前面的 "3.怀疑静态 ConcurrentDictionary 一直在增长" 相似,只要不是一直被引用的部分,最终 GC 都会给安排。这次到是减少了部分可以不需要 new 赋初值的逻辑,感觉代码又变得更"完美"了,但彷佛仅限于感觉。
8. 怀疑数据库资源没有释放
排查过程:当前使用的 PostgreSQL 数据,通过下面的语句发现连接数很少一共只有5个其中还有两个是 Navicat 也就是说 API 只有三个连接数,backend_start 与 query_start 的时间都是今天的。按理说这种情况就不存在数据库的资源没有释放了。
sql
SELECT * FROM pg_stat_activity
结论:不确定是不是方法不对,虽然认为连接数没问题,但是始终认为 EntityFreamworkCore 存在没有释放的资源,肯定有什么设置可以解决(取消缓存除外)。
2023年10月9日:后续有进展再更新,目前问题仍然存在没有解决
2023年10月13日:=========== 新 进 展 ===========
上次一边整理一边记录下来后发现其中有一个 .NET 3.1 分支中正在使用 WebApiClientCore 1.0.7 版本。从正在运行的 Docker 服务来看已经持续运行几个月并且内存占用看起来很正常。于是心一横既然已经有 .NET 3.1 正常的先例,那就把 WebApiClient.JIT 1.1.4 全部重构改为 WebApiClientCore 版本(一开始是打算使用一样版本的,最终还是选择使用 .NET 5.0 分支中正在使用的 2.0.2)这样一来"假装"向正常分支版本靠近了。
重构用了将近两天时间,验证逻辑部署到服务器后老实说并没有抱希望,因为这个场景已经经历了几个月了,习以为常。但没想到10月12号17点突然发现 Docker 的内存占用是 366.3MiB这个数值在我看来特别的明显,因为这个服务见天 200+MB 的涨幅,于是赶紧 docker ps 查看服务运行时间为 22+ 小时,那说明持续运行将近一天看起来内存并没有缓慢增长,到这里"彷佛"已经证实了,看起来这个几个月的排查是出结果了(内心是狐疑居多,没有突然跳起来的狂喜甚至还很平静)。立马记录了当前的 Docker 内存占用值,下面是跨天连续记录情况:
2023年10月12日 17:00:366.3MiB
2023年10月12日 20:52:373.1MiB
2023年10月13日 09:00:366.2MB
2023年10月13日 11:13:372.4MB(现在)
至此 .NET 3.1 分支的内存缓慢上升的问题解决了,解决的方式是重构了部分使用 WebApiClient.JIT 代码为 WebApiClientCore,这里声明不是 WebApiClient.JIT 1.1.4 存在内存泄漏因为又在刚刚发现存在正在使用 WebApiClient.JIT 1.1.4 版本并且内存占用正常的分支。如果前几天要是早点看到这个分支说不定就会浇灭即将燃起的重构希望之火。
其实重构的代码不多,但要说具体是怎么解决的,这里蒙眼甩锅给下面的代码(如果不是就在这里提前说声对不起)从 GIT 提交记录来看,这个代码最初提交与 2020年11月20日 19:10:04,印象中最特别的修改就是 ResponseBase 返回类从 JsonString 改为 string 按需自己转 JSON。
cs
// 请求配置
[HttpPost, Timeout(30000)]
Task<ResponseBase> Write([Uri] string url, [JsonContent] WriteInput input);
// 原返回参数类
public class ResponseBase<T>
{
public JsonString<T> d { get; set; }
}
// 修改后的返回参数类
public class ResponseBase<T>
{
public string d { get; set; }
public T Obj
{
get
{
return d.FromJsonString<T>();
}
}
}
至此 .NET 3.1 分支通过重构部分代码摒弃了不规范的使用方式,从而解决了 .NET 3.1 分支内存缓慢增长的问题,后续继续抽时间将这个修改同步修改到其他 .NET 3.1 分支中,避免再次切分支将问题继续带入下一个分支。现在继续对 .NET 5.0 现在的 .NET 7.0 分支 EntityFrameworkCore 内存暴涨的现象进行分析,期待有朝一日能像这次一样不断检查不断修改重构,然后在一个平静的早上、中午、下午、傍晚或晚上,突然发现 Docker 内存占用怎么这么稳定呢?