ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

.NET内存泄漏实战:ECM系统非托管内存与缓存策略问题排查

.NET内存泄漏实战:ECM系统非托管内存与缓存策略问题排查 上周五下午客户那边的电话打过来时我正准备下班。说是生产环境那套 ECM 内容管理系统的内存眼看着往上飙应用池已经被回收了三四次再这样下去就要 OOM 了。我打开计数器一看w3wp.exe 的 Private Bytes 已经逼近 14GB而服务器物理内存只有 16GB。离报警阈值只剩一步页面加载明显变慢用户已经开始抱怨。这不是第一次处理 .NET 内存问题了但 ECM 这种企业内容管理系统有它特别恶心的地方文档库、全文索引、OCR 转换、工作流引擎全都塞在一个进程里内存问题往往不是单一原因而是一堆因素叠加出来的。这次也不例外。把整个过程复盘一遍从现象、工具、转储分析到根因定位和修复争取一次讲清楚给遇到同类问题的朋友一个可直接参考的分析路径。1. 从“内存报警”到“过程回顾”1.1 客户环境与系统背景这套 ECM 系统部署在 Windows Server 2016 上IIS 10 承载 ASP.NET 应用.NET Framework 4.7Server GC 模式服务器垃圾回收物理内存 16GB没有配置内存上限。数据库是 SQL Server部署在同机房另一台机器上。系统本身是典型的 B/S 架构主要有几个模块文档上传与预览、全文检索、元数据管理、工作流审批。ECM 系统最核心的特点是“内容为王”也就是文档、图片、邮件、表单这一类非结构化数据的集中管理。用户会批量上传 PDF、Word、Excel系统在后台自动做格式转换、抽取文本、建立索引。这种场景天然就是内存消耗大户尤其是批量导入时段整个进程的内存曲线会像心电图一样剧烈波动。客户反映的问题不是一次性泄漏而是“内存涨上去之后不下来”。最初一周内存还能在 4GB 到 6GB 之间正常波动但从周一开始逐步走高到周四中午已经稳定在 12GB 以上。应用池的“内存上限回收”策略设的是 4GB理论上早该回收了但由于回收动作本身有延迟加上进程回收后业务线程还没来得及释放旧对象的引用内存不降反升最终触发多次回收仍然无济于事。1.2 现象特征是暴涨不是缓慢泄漏我接到电话后的第一反应是先判断这是“内存需要量暴涨”还是“内存泄漏”。这两个性质完全不同处理方式也截然不同。内存需要量暴涨通常是某个业务操作在短时间内加载了大量数据比如一次查询把几百万行数据拖进内存或者一次性往 List 里塞了几万个大对象。这种情况内存在操作结束后应该能降下来只是波峰太高超过了物理内存容量。内存泄漏则是对象一直被引用无法被 GC 回收内存使用量只会单调上升哪怕业务完全空闲也降不下来。典型症状是隔一段时间抓一次内存总会发现某类对象的数量只增不减。客户的运维发来的截图显示凌晨两点没有任何用户访问的时候内存依然是 11GB 上下。这就排除了“业务高峰造成的正常波峰”基本确认是泄漏或者至少是“有根对象的异常驻留”。明确了这一点后面抓 dump 和分析就有方向了。1.3 第一轮排查看进程、看性能计数器第一轮排查我没有直接上 Windbg而是先看了系统层面的几个关键计数器这一步虽然基础但信息量非常大。打开性能监视器添加以下计数器Process(w3wp)\Private Bytes进程私有内存反映进程实际占用的内存总量Process(w3wp)\Working Set物理内存中的工作集能看到进程实际触达的物理内存.NET CLR Memory# Bytes in all Heaps托管堆的总大小包含所有代Gen0、Gen1、Gen2 和 LOH大对象堆.NET CLR Memory\Large Object Heap size大对象堆大小.NET CLR Memory# Gen 2 CollectionsGen2 垃圾回收次数.NET CLR Memory% Time in GC垃圾回收线程占用的时间比例实测数据是这样的Private Bytes 稳定在 13GB 以上Working Set 大约 9GB。# Bytes in all Heaps 只有不到 3GB说明托管堆本身并不算特别大。但 Large Object Heap 有 1.7GB明显偏高。再看 % Time in GC平均在 18% 到 25% 之间这意味着系统有大量时间花在垃圾回收上。这里就出现一个经典的“矛盾信号”如果托管堆只有 3GB那剩下的 10GB 私有内存去哪了答案只有一个——非托管内存。也就是说要么有非托管组件在申请内存后没有释放要么托管对象引用了大量非托管句柄又或者 GC 堆本身存在大量保留但未提交的虚拟内存空间。带着这个判断我开始抓转储文件。2. 抓取与分析前的准备工具选型与现场保护2.1 为什么优先用 ProcDump 而不是手动导出转储很多人习惯在任务管理器里右键进程“创建转储文件”。这个方法在应急时可以但有个致命问题任务管理器创建的 dump 是“最小转储”还是“堆转储”取决于系统配置很多时候抓出来的东西信息不全连托管堆统计都跑不出来白耽误时间。我这边用的是 Sysinternals 的 ProcDump一条命令就能解决procdump -ma -n 2 -s 30 进程ID参数解释-ma抓完整的内存转储包含进程的完整地址空间这是分析 .NET 托管堆的前提-n 2抓两份 dump间隔 30 秒-s 30每份 dump 之间的等待时间为什么要抓两份因为只看一份 dump 很难判断对象数量是在增长还是只是瞬时的业务数据。两份之间有个时间差对比一下同类对象的数量变化就能直观看出是不是在泄漏。这个习惯我后来一直保留实际效果非常好。另外ProcDump 也可以配置在内存达到某个阈值时自动触发比如procdump -ma -performancetimer -t 80 w3wp.exe这样可以在服务器内存占用达到 80% 时自动生成 dump不需要人守在那里等现场。生产环境出问题的时候人往往不在场这种自动化抓取机制非常重要。2.2 转储文件获取时的注意事项抓 dump 有几个细节必须注意否则容易踩坑。第一抓 dump 的瞬间会短暂挂起进程虽然通常只有一两秒但生产环境如果 QPS 很高建议在业务低谷期操作。不过话说回来内存都已经飙到这种程度了风险本身就很大短暂的停顿换来可分析的现场是值得的。第二磁盘空间要提前确认。一个 13GB 的 32 位进程 dump 大约 1GB但 64 位进程的完整转储往往和进程内存接近也就是十几 GB。我们这次抓出来的 dump 将近 15GB如果磁盘不够可能抓一半就失败了。建议抓之前先查一下目标盘的剩余空间。第三转储文件建议找个独立目录保存并且抓完后立即拷贝到分析机不要直接在服务器上分析。分析工具本身也会消耗内存和正在崩溃边缘的进程抢资源得不偿失。2.3 我使用的分析工具链这次分析用到的工具组合是ProcDump 抓取转储Windbg SOS 扩展 做核心分析VMMap 看虚拟内存分布PerfView 辅助采样确认热点对还在用 .NET Framework 的老系统来说Windbg SOS 的组合依然是诊断内存问题的金标准没有之一。虽然现在 .NET Core / .NET 5 时代有 dotnet-dump、dotnet-gcdump 这些更好用的工具但 ECM 这种企业级老系统绝大多数还是跑在 .NET Framework 上Windbg 路线必须会。SOS 扩展的加载方式要区分一下如果是 .NET Framework 4.x在 Windbg 里执行.loadby sos clr就行如果是 2.0/3.5要换成loadby sos mscorwks。版本搞错了会直接提示加载失败。如果你抓 dump 的那台服务器上有多个 .NET 版本分析机上最好也装对应的运行时不然 SOS 解析会报版本不匹配。这一步看似不起眼但实际上坑过很多人。3. 转储分析过程从托管堆到非托管堆3.1 用 !analyze -v 先探路打开 Windbg 加载 dump 之后我先执行了!analyze -v。这个命令对崩溃型 dump 非常有用能直接给出自动分析的结论但对内存型 dump 往往只会显示“内存使用量过大”之类的泛泛信息。所以我的用法只是走个流程真正有价值的是接下来手动执行的几个 SOS 命令。接着用!eeheap -gc查看 GC 堆分布。输出大概长这样Heap 0 (generation 0): ... Heap 0 (generation 1): ... Heap 0 (generation 2): ... Heap 0 (large object heap): 1.7GB可以看到 LOH大对象堆占了 1.7GB。LOH 里的对象是 85,000 字节以上的大型对象GC 对 LOH 的管理策略和普通小对象不一样它不压缩所以容易产生碎片而且 LOH 回收条件是 Gen2 回收频率比 Gen0/Gen1 低很多。3.2 托管堆分析!dumpheap -stat 与 !gcroot接下来执行!dumpheap -stat看托管堆里到底堆了些什么东西。这是整个分析过程中最关键的一步。输出的统计列表里占用空间排在前几位的是这些东西System.Byte[]占了大头约 800MBSystem.String约 350MBSystem.Object[]约 120MBSystem.Data.DataRow约 80MBSystem.Xml.XmlDocument约 60MB看到 Byte[] 排第一第一反应是字节流或缓冲区没释放。再看 String 大量存在可能是拼接字符串、缓存 Key或者数据库查询结果转字符串时产生的副本。然后我针对 Byte[] 执行了!dumpheap -type System.Byte[] -stat想看看这些字节数组都分布在哪些方法栈上结果发现大量 Byte[] 的大小集中在 4MB 到 8MB 之间。这个尺寸非常典型——很像是某个文档转换组件在工作时申请的缓冲区而且数量还在不断增加。接着对几个疑似有根对象执行了!gcroot定位对象引用链。比如对某个 8MB 的 Byte[] 做!gcroot输出显示它被一个静态字典引用。这个静态字典位于某个缓存管理类里面看起来是缓存了转换后的文档字节流。到这里托管堆的轮廓基本清晰了有大量文档内容被缓存在静态字典中没有淘汰策略也没有过期时间。3.3 非托管内存分析!address 与 !heap -s虽然托管堆已经发现了问题但 3GB 的托管堆和 13GB 的进程私有内存之间还有巨大差距。我需要弄清剩下的内存去哪了。先用!address -summary看虚拟内存分配概况。关键输出是Reserved保留约 18GBCommitted提交约 12GBPrivate私有约 13GB这里的 Reserved 和 Committed 需要解释一下。Windows 的虚拟内存是这样的进程可以先“保留”一段地址空间但不一定立即对应物理内存真正分配物理内存或页文件的是“提交”操作。我们的情况是 Reserved 远大于物理内存但 Committed 也非常高这意味着虚拟内存空间本身已经不足了。64 位进程地址空间理论上有 8TB但 Windows 默认有一个坑加载 DLL 的地址空间和某些系统保留区会限制可用范围不过程序员一般不会主动去碰这个限制。问题在于 Private Committed 达到了 13GB而托管堆只有 3GB中间差出来的 10GB 不在托管堆里。接着执行!heap -s -v看 Win32 堆的分配情况。输出显示有两个堆特别异常其中一个 C 运行时堆的已使用内存达到了 6.7GB已分配块数量超过 50 万个。这块东西托管堆完全看不到属于纯非托管内存。到了这一步根因的调查方向已经很明确了非托管堆里存在大量残留分配最大概率是某个非托管组件文档转换、OCR、图像处理这类在反复申请内存后没有正确释放。3.4 时间线对比两个不同时间点的 dump 差异我抓的两份 dump 这时候派上了用场。分别对两份 dump 执行!dumpheap -stat把结果导出后做对比Byte[] 数量第一份 128,642 个 → 第二份 152,377 个增加了 23,735 个String 数量第一份 81,205 个 → 第二份 90,118 个增加了 8,913 个非托管堆使用量第一份 4.9GB → 第二份 6.7GB增加了 1.8GB这个增长趋势非常明显说明不是瞬时业务数据而是持续累积标准的泄漏特征。同时看 Gen2 回收次数两份 dump 之间只相差 12 次。正常情况下如果有大量大型对象不断产生又被回收Gen2 回收次数会非常频繁。现在 Gen2 回收次数不多但内存还在增加说明这些新增对象都被“有根”引用挂住了GC 根本无法把它们识别为垃圾。4. 根因定位文档转换组件与缓存策略的叠加效应4.1 问题核心第三方转换组件的非托管资源释放异常继续深挖非托管堆我用 VMMap 对照分析可以看到有一块标记为 Heap 的内存区域随时间增长最快调用栈指向了文档转换服务的某个 C 原生方法。这个情形的典型场景是这样的ECM 系统的文档导入流程里上传的 Word、Excel、PDF 文件都会先经过一个转换组件把原始文件转成可用于网页预览的格式通常是 PDF 或 HTML。这个转换组件是 C 写的通过 P/Invoke 或 COM 接口被托管代码调用。问题就出在这个组件的调用方式上。按当时代码里的写法转换结束后只调用了托管侧的Dispose()但这个组件的Dispose()并没有正确释放它内部申请的非托管缓冲区。更准确地说这个组件有一个内部线程池每次转换任务完成之后线程池里缓存的工作线程会保留对应的工作缓冲区并且这些缓冲区不是按“任务结束”释放的而是按“线程池销毁”释放的。结果就是每一次转换都留下 4MB 到 8MB 的非托管内存持续累积不会归还。这类问题在 .NET 内存分析里特别容易被忽视因为你看托管堆一切正常看 CPU也不算高但内存就是在涨。只有把视角拉到非托管层才能看到真相。4.2 为什么缓存策略放大了问题非托管泄漏是主因但光靠它内存未必会涨到 14GB 这么夸张。真正把问题放大的是 ECM 系统自己的缓存策略。从!gcroot看那个静态字典缓存的是“文档转换后的字节流”也就是每个文档的预览内容。问题在于缓存没有上限没有容量限制缓存没有过期时间键值对永远不会自动移除缓存的新增逻辑是在用户第一次预览文档时写入之后每次该文档被预览都直接取缓存正常情况下这种缓存确实能提升性能。但一旦非托管内存持续增长加上托管堆这边的 LOH 里全是这些字节流缓存两股力量叠加内存就像坐了火箭一样往上蹿。更要命的是这缓存字典是进程级的静态变量。哪怕是应用池按 4GB 内存上限触发回收新进程起来后依然要重新执行一遍文档转换非托管组件继续泄漏缓存继续增长。所以应用池回收根本无法解决问题。4.3 代码层面的诱因同步锁、并发、重试机制还有个有意思的细节这个转换组件被调用时外层代码用了一个lock同步锁。因为组件本身不是线程安全的开发人员为了规避这个问题直接在转换方法外面套了个全局锁。这样做的副作用是所有文档转换任务变成串行执行在批量导入场景下转换队列不断积压。而队列本身又是内存中的一个ConcurrentQueue每个待处理项可能包含原始文档路径、元数据对象这些也全都堆在内存里。批量导入时如果转换速度跟不上导入速度队列就会越积越长。我分析 dump 时看到ConcurrentQueueint和System.Threading.Tasks.Task对象数量也有明显增长虽然不是大头但也是压垮内存的最后一根稻草之一。这个例子再次说明内存暴涨往往不是单点问题非托管组件泄漏、缓存无策略、并发控制不当三者叠加才是真正的灾难配方。5. 修复方案与经验总结5.1 短期缓解应用池回收与资源限制在根因修复完成之前客户不能停机业务不能中断。我需要先做一个短期方案把内存洪水挡住。第一个动作是调整 IIS 应用池设置将“虚拟内存限制”设为 6GB让进程在内存达到 6GB 时自动回收将“私有内存限制”设为 5GB设置“空闲超时回收”为 20 分钟为什么设 6GB 而不是 4GB因为正常业务高峰期进程内存可能就有 4GB 到 5GB如果限制设成 4GB会导致正常业务频繁被回收性能反而更差。6GB 是一个折中既能拦住异常增长又不至于干扰正常业务。第二个动作是让批量导入任务错峰执行。原来的导入任务没有批次大小限制客户一次导入几千个文档。我在导入代码里增加了一个开关每批次最多处理 200 个文档处理完一批休息几秒再继续下一批。这样能给 GC 留下喘息的时间也让非托管组件的泄漏速度慢下来。第三个动作是紧急加了一个定时任务每天凌晨 3 点强制回收一次应用池。这个不是长久之计但能在根因修复前保证系统每天都能回到干净状态。5.2 长期修复代码改造与组件升级长期修复分两条线并行。第一条线替换或升级文档转换组件。我和组件厂商确认后拿到了一个修复非托管缓冲区释放问题的新版本。升级后做了一次压测连续转换 5,000 个文档非托管内存保持稳定不再持续增长。这一步等于釜底抽薪从源头上解决了泄漏。第二条线改造 ECM 系统的缓存策略。具体改动有三个给静态字典加上容量上限比如最多缓存 500 个文档的预览数据超过后按最近最少使用LRU策略淘汰最久未访问的项给缓存项增加一个“保活时间”比如 30 分钟到期自动失效把缓存数据从进程内存迁到 Memcached 或者 SQL Server 中让缓存与工作进程解耦这套改造做完之后即使某天业务量翻倍最坏情况也只是缓存穿透导致数据库压力大一点而不会让 w3wp.exe 直接爆掉。代码层面还有一个容易忽略的地方所有实现了IDisposable的托管对象尤其是FileStream、Bitmap、MemoryStream必须用using包裹。我在排查代码时发现很多文档预览图生成逻辑里使用了Bitmap对象但没有显式调用Dispose()。托管堆里大量未释放的System.Drawing.Bitmap最终也会转入 LOH。虽然这次不是主因但这类问题积累多了同样会变成定时炸弹。5.3 监控与预防让下一次问题更早暴露修复完成后我给客户配置了一套监控机制目标是让类似问题在 24 小时内自动暴露而不是等到用户投诉。监控方面重点盯这几个指标指标阈值告警动作w3wp.exe 私有内存连续 10 分钟超过 5GB触发性能告警自动调用 ProcDump 抓 dump 到独立磁盘.NET CLR 内存 LOH 大小超过 1.5GB发送告警通知提示检查大对象堆% Time in GC超过 30% 持续 5 分钟发送告警提示可能的 GC 压力Gen2 回收次数超过 50 次/分钟发送告警提示可能存在大对象频繁分配同时写了一个简单的脚本在告警触发时自动抓取两份 dump 并保留最近 7 天的转储文件。这样即使问题发生在深夜第二天也能有完整现场做分析不用干着急。预防方面还有几条硬性要求所有批量任务必须有批次大小限制不允许一次性把全量数据加载进内存所有上传文件必须走流式处理不能直接读成 Byte[] 后再处理所有外部组件调用必须包一层超时和异常处理防止组件挂起导致内存积压代码评审里增加一条“静态变量中不得持有可变更的集合类对象必须有容量上限”说实话这些问题很多团队不会放到代码评审里去检查但 ECM 这类企业系统恰恰最吃这些细节。一个静态字典、一个未释放的非托管缓冲区看着不起眼在低并发场景下可能几年都不出事但在批量导入、高吞吐场景下就是压垮整台服务器的最后一根稻草。最后分享一点个人体会这次排查给我留下最深印象的不是某个命令或工具而是“时间线对比”的价值。如果没有第二份 dump单看一份 14GB 的转储我可能只会得出“内存占用过高”这种泛泛结论很难确认到底是不是泄漏。抓两份 dump 只多等了 30 秒但价值完全不一样。这已经成为我处理内存问题的固定流程。另一个体会是处理内存问题要有一点耐心不要急着下结论。最初看到托管堆只有 3GB 时我一度以为是吃内存的 I/O 缓冲区太多后来才发现真正的大头在非托管层。如果当时只盯着托管堆分析很可能浪费半天最后一无所获。先看整体分布再看看每一个堆的消耗最后再针对异常对象深挖引用链整个路径比从某个对象类型入手要稳得多。如果你也在维护类似的 .NET 老系统建议提前把 ProcDump 的自动抓取脚本配好把常见的 SOS 命令整理成一个笔记等内存问题真的发生时你不会后悔这些准备。而且记住一点任何内存问题只要能拿到两三个时间点的完整转储就已经赢了一半。剩下的只是顺着对象的引用链一步步走到终点而已。
返回列表