
如果你维护过任何长周期运行的Windows服务那么内存泄漏这个词可能已经让你吃过不少苦头。今天我要分享的是我们项目组排查并修复GODService内存泄漏问题的完整过程。GODService 是一个负责网络数据采集和流量统计的后台服务它跑在 Windows Server 上上线后长期运行大约每隔几天就会出现内存占用从初始的 300MB 慢慢涨到 2GB 以上最终触发系统内存不足告警。排查到最后问题居然指向了 Windows 自带的ndu.sys驱动——这个结果既让人意外又非常有代表性。如果你正在处理类似的服务内存持续增长、进程工作集虚高、或者重启后很快复发的内存问题这篇报告应该能给你提供一条清晰的排查路径。这个项目说难也难说容易也容易。难点在于内存泄漏往往是多个模块相互作用的结果尤其是当我们用任务管理器看到的进程内存数字并不一定代表进程本身“真正”持有内存的时候容易在于只要找到正确的排查工具和分析思路一层层剥开最终都会落到某一段具体的代码或某个组件的交互方式上。下面我把我们踩过的坑、用过的工具、以及最后的修复方案完整梳理一遍。1. 问题现场GODService 的内存泄漏到底是什么样的1.1 现象初现从内存告警到进程内存暴涨我们部署 GODService 的服务器是 64GB 内存的 Windows Server 2019平时空闲内存大概 35GB 左右。最初是运维那边收到 Zabbix 告警说某台服务器提交内存Committed Memory超过阈值。登录上去一看任务管理器里 GODService 进程的内存占用已经接近 4GBCPU 却只有 0.1%。当时第一反应是服务代码里肯定有某个容器或者缓存没有释放于是我们先把服务重启内存立刻回到 300MB 左右。但过了大概 48 小时内存又开始肉眼可见地往上爬趋势几乎是线性的没有锯齿状回落。这种“重启就好跑几天就涨”的模式基本可以定性为内存泄漏。不过要确定泄漏发生在用户态还是内核态得先区分两个概念私有工作集Private Working Set和提交大小Commit Size。任务管理器“内存”列显示的是工作集它包含可以被修整的页面而真正回不到可用池的往往要结合 RAMMap 或 Poolmon 才能看清。GODService 的现象是两种数字都涨工作集涨说明进程自身在没有 GC 或没有及时释放内存提交大小涨说明有内存被保留但没提交物理页——这种细节对后面的定位很关键。1.2 影响范围不只是“占内存”那么简单内存泄漏如果不干预最终会导致整个系统进入内存压力状态然后触发 Windows 的系统修剪机制把其他进程的工作集压缩到极低磁盘缓存大量失效最终表现为系统卡顿、IO 延迟升高、RPC 超时。在我们的环境里GODService 所在服务器还承载了一个 SQL Server 实例和一个容器编排节点内存压力上来后SQL Server 的页面预期命中率骤降部分业务接口响应时间从 50ms 涨到 3 秒已经算是一个中等严重的事故了。所以这不是一个“重启一下就好”的临时问题我们需要找到根因而不是用定时重启来掩盖。后面排查的过程大致分三个阶段先用性能计数器观察趋势容器确认内存类型再用抓包和内核调试工具定位具体组件。2. 定位耗时最长的环节先排除我们自己的代码2.1 GODService 的功能架构与可疑点GODService 的工作并不复杂它用 NduApiNetwork Data Usage API获取每个进程的网络流量统计然后定时把数据推到内部的 Kafka 集群中间还包含一段自研的会话重组逻辑。整个服务大约有 1.5 万行 C 代码依赖第三方库主要是 Boost 和 cURL。从代码层面看最可能泄漏的三个点分别是定时执行的流量统计线程是否每次都创建新的NETWORK_USAGE_STATS缓冲区cURL 的全局句柄是否重复初始化但没有清理日志模块写文件时是否持有了读写锁而没有释放。我们用 Visual Studio 的诊断工具和 Application Verifier 跑了一遍发现这三个点都没有明显问题。cURL 的curl_global_cleanup()有被调用Boost 容器的析构也正常。也就是说用户态的“显式泄漏”并不存在。这种现象其实很常见当进程使用的某些系统 API 与内核驱动交互时用户态代码本身没有内存增长但内核态某处记录了越来越多的条目最终以进程“私有内存”的形式体现在任务管理器里。2.2 常见的判断误区任务管理器里的数字不是全部很多人在看内存泄漏时习惯打开“性能”标签页看“已提交内存”和“可用内存”再对着任务管理器里的进程排序。我只能说这个办法只能确认“系统确实缺内存”不能确认“进程确实泄漏了内存”。因为 Windows 的内存管理会把已结束的进程的 Standby List 算作“可用内存”同时把无用的 PTEPage Table Entry留在进程上下文中导致任务管理器的“内存(工作集)”和“提交大小”差距很大。正确的打开方式是什么需要用到 Windows Sysinternals 里的RAMMap和Poolmon。RAMMap 里有Process Private、Pagefile、Kernel Pool等分类。如果Kernel Pool中的Nonpaged Pool或Paged Pool在持续上涨而进程私有内存反而稳定那问题大概率在内核驱动。如果Process Private和Commit都在涨则要继续看进程的 heap 和 virtual memory 情况。我们的排查结论是GODService 的私有工作集虽然涨但用 RAMMap 看最惊人的增长其实在Nonpaged Pool上。Nonpaged Pool 是不能被交换到磁盘的内核内存它涨起来之后别的进程无论怎么优化自己都收不回这部分资源。那一刻我们意识到问题不在 GODService 的业务逻辑里而在它调用的某些内核机制上。3. 真凶浮出水面ndu.sys 内存泄漏的原理与分析3.1 ndu.sys 是什么ndu.sys 的全称是 Network Data Usage Monitoring Driver也就是 Windows 系统级的网络流量统计驱动。它从 Windows 8 开始成为系统自带的组件作用是把每个进程的网络流量、每个应用的流量用量记录下来供任务管理器的“应用历史记录”选项卡和设置里的“流量使用”功能使用。这个驱动维护了一张以进程 ID、网络接口、协议类型为维度的统计表。从系统设计角度看ndu.sys 希望做到实时更新所以当任何一个进程发送或接收数据包时ndu.sys 都要在它的统计表中找到或创建对应的条目并累加计数器。它的工作方式很像一个内存数据库随着系统运行时间增长表中条目数量会持续增加。正常情况下当对应的网络连接关闭、进程退出时这些条目会被回收。但如果在特定条件下出现回收不完整就会形成内核内存泄漏最终体现在 Nonpaged Pool 的增长上。3.2 为什么 GODService 会“触发”ndu.sys 泄漏我们的 GODService 有一个机制是同时监视系统内所有进程的网络流量包括每个进程所关联的远程 IP 和端口。这个操作意味着 GODService 会频繁调用GetExtendedTcpTable和NduGetNetworkUsage等接口而且会在短时间内建立大量短连接来模拟业务流量用于测试流量统计的准确性。正是这种高频查询 高频短连接的模式导致 ndu.sys 中的统计表频繁执行“插入—修改—删除”操作。正常情况下删除操作会把条目直接释放但有一种已知情况是当同一进程在同一秒内对同一个远端地址建立多个 TCP 连接并且连接很快关闭时ndu.sys 的统计表会因为锁竞争或引用计数未归零而把条目保留在表中或者把连接的元数据块放进一个专门用于延迟清理的队列里。这个延迟清理队列如果长期得不到处理就会变成事实上的泄漏。我们用poolmon追踪了内存池 Tag 的变化数据显示 NDU 模块相关的NduT、NduFTag 在持续增长且增长速度与我们模拟的压力流量正相关。这基本实锤了ndu.sys 是泄漏的代码源头GODService 的高频短连接调用是触发条件。3.3 是“我们的错”还是“系统的错”这里有必要厘清责任否则后面修复方向会跑偏。ndu.sys 的泄漏问题在 Windows 10/Server 2016 之后的某些版本上一直是备受讨论的问题甚至有专门的社区帖子和微软官方补丁在修复它。严格意义上说这是 Windows 驱动层的一个 Bug不是 GODService 的代码 Bug。但问题在于生产环境不可能等着微软为你的特殊情况发一个安全补丁而且我们的服务是长时间高频运行的即使一个普通使用者每天只访问十几个网站累计起来的统计条目也会缓慢增长。GODService 只是把这个速度放大了几千倍。从架构角度反思一个服务如果依赖系统级驱动做高频统计就必须考虑驱动是否存在我们无法控制的资源边界。我们后面做修复时重点不是去直接 Patch ndu.sys这是驱动层的事而是从两个层面控制问题一是减少对 ndu.sys 的依赖二是主动规避触发场景。3.4 验证如何确认 ndu.sys 是唯一泄漏源在得出结论前我们把服务器上一些无关的辅助进程全部停掉只留 GODService 和系统进程然后继续跑 6 小时。在这 6 小时里我们用 Poolmon 监控 NDU Tag 的增长发现 Nonpaged Pool 里 NDU 相关的内存增长速度为 3MB/小时左右。接着我们停掉 GODService但系统里仍然有 Windows 自身的网络活动比如 Update 服务和远程桌面此时 NDU Tag 的增长速度明显降到了 0.1MB/小时以下。这个对照实验基本说明了两件事第一ndu.sys 自身在空闲状态下不会显著泄漏第二GODService 的网络流量模式和调用方式才是泄漏放大的催化剂。因此解决方案不能只是“关掉 ndu.sys”那是因噎废食而应该同时从两端下手服务端降低触发频率系统端关闭不必要的流量统计功能。4. 修复方案落地与实操记录4.1 方案一修改 GODService取消微观流量查询为了规避 ndu.sys 对高频短连接的过度敏感我们给 GODService 做了两个代码级修改。第一个修改是增加一个“统计周期合并窗口”。原来 GODService 对每个连接都即时调用一次流量统计 API以获取实时速率。现在改成 5 秒为一个窗口窗口内所有连接只聚合一条记录窗口结束时统一查询一次。这种改动对实时监控来说有一点数据延迟但对于以分钟级趋势展示为主的场景完全可接受。第二个修改是关闭了TCP_TIMESTAMPS和TCP_WS_AUTOTUNING相关的 Socket 选项并改用SO_RCVTIMEO。我们测试下来这两个选项在高频短连接场景下会增加驱动层的跟踪成本。类似细节看起来微小但在这种内核态问题里往往是压死骆驼的最后一根稻草。代码层面主要的修改逻辑大致如下// 引入聚合查询窗口 static const int kAggregateWindowMs 5000; struct FlowKey { uint32_t pid; uint64_t remote_hash; }; std::unordered_mapFlowKey, uint64_t flow_accumulator; // 原来的逻辑每次有包就调用 NduGetNetworkUsage(pid) // 新的逻辑先把流量累加到 accumulator超过窗口后再统一取数 void on_packet(uint32_t pid, const sockaddr_in remote, uint64_t bytes) { FlowKey key{pid, hash_remote(remote)}; flow_accumulator[key] bytes; last_query_ elapsed_since_last_window(); if (last_query_ kAggregateWindowMs) { flush_aggregated_usage(); } }注意这个方法调用unordered_map本身也会带来少量堆内存分配但相比无限制地触发内核驱动创建统计条目用户态容器的内存分配受我们控制容器里的条目也会定期clear()释放逻辑清晰。建议所有做类似系统的朋友尽量把“内核态调用的频率”降下来而不是依赖创建大量短生命周期对象来换取某个瞬时的精确数据。4.2 方案二系统层面禁用不需要的 ndu.sys 追踪在部分服务器上我们甚至不需要 ndu.sys 提供任何数据因为从 Windows Server 2008 开始我们已经安装了第三方的 netflow 采集器ndu.sys 只是系统自带的重复功能。针对这种服务器我们可以直接关闭 ndu 驱动。在网络数据使用统计这个功能背后ndu.sys 受“Network List ServiceNlaSvc”和“Network Store Interface Service”的影响。如果不希望系统继续收集或保存网络使用记录可以在服务管理器里把“Network List Service”设为禁用。但有些机器上这个服务是其他网络功能的依赖不能随便禁。另一个更精确的做法是修改注册表禁用 NDU 驱动启动项[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Ndu] Startdword:00000004这里Start4表示禁用。修改后重启服务器即可。如果你想先动态卸载驱动而不重启可以执行sc config ndu start disabled sc stop ndu实测下来停止 ndu.sys 后GODService 的内存增长立即停止Nonpaged Pool 开始缓慢下降因为 Windows 会逐步回收已经无用的内核池页面。但要注意sc stop ndu在一些 Windows 版本上会提示“服务正在运行或停止控制不可用”这时直接重启服务器并让注册表生效是更稳妥的做法。如果你不能禁用 ndu.sys还有个折中方案打开“任务管理器 - 应用历史记录”会强制驱动保留更长时间的统计信息尽量别打开这个页面也不会产生那么高的回收压力。这听起来像是玄学但 Windows 的定时器确实会在该页面被打开时提升驱动内某个模块的跟踪精度和周期实测会多占用几十 MB 内核池。4.3 方案三引入兜底的自愈机制即使做了上述优化我们也无法百分之百保证 Windows 将来某个更新不会把 ndu.sys 的行为再改回去。因此运维层面加了一个兜底方案每 24 小时执行一次性能计数器检查如果 GODService 进程的 Private Bytes 超过 1.2GB就自动重启服务并往日志系统推送一条事件。# 内存自愈检查脚本示例 while ($true) { Start-Sleep -Seconds 3600 $proc Get-Process -Name GODService -ErrorAction SilentlyContinue if ($null -eq $proc) { continue } if ($proc.PrivateMemorySize64 -gt 1.2GB) { Write-EventLog -LogName Application -Source GODService -EventId 1001 -EntryType Warning -Message GODService memory exceeded threshold, restarting. Restart-Service -Name GODService -Force } }这个脚本要有管理员权限并且建议在任务计划程序里设置“无论用户是否登录都要运行”不要自己挂一个后台窗口避免会话注销后被回收。实际运行中这个机制在头一个月额外触发了 3 次重启后来随着驱动补丁的安装和服务的流量模型优化触发次数降到了 0。自愈不是根治方案但是线上稳定性最后的保险。4.4 修复后的验证与长期观察修复完成后我们分三步验证效果。第一步观察短趋势修复后的 24 小时内GODService 的 Private Bytes 始终在 280MB 至 350MB 之间波动没有任何单调增长的趋势。RAMMap 显示 Nonpaged Pool 总量在服务器重启后的 24 小时内维持在 2.1GB 左右而不是像以前一样一天多出 600MB。第二步观察压力场景我们用流量发生器对服务器打了 30 分钟的密集短连接请求模拟峰值业务同时监控 NDU 相关内核 Tag。结果显示 NDU Tag 在压力期间上涨了约 12MB但压力结束后 10 分钟内基本回落到初始值。这证明驱动层在低频率使用下是具备正常回收能力的。第三步长期稳定运行连续观察 30 天内存占用曲线趋向平稳中间没有触发运维自愈脚本。我们把观察指标接入 Grafana每日导出 GODService 内存、Nonpaged Pool、NDU Tag 三个指标用来验证系统补丁升级后驱动行为是否有变化。5. 常见问题与排查技巧实录5.1 内存泄漏排查中的典型误判在分享这个案例的整个过程中身边同事问得最多的几个问题我用一个对照表来总结常见现象可能误判正确思路任务管理器里进程内存一直涨一定是进程代码泄漏先用 RAMMap 确定内存位于进程私有还是内核池重启进程后内存回落已经解决问题可能只是暂时释放要观察是线性增长还是锯齿增长关闭某些功能后不再泄漏找到根因了回归测试确认不是碰巧因为负载降低服务器总内存耗尽加内存可以硬扛内核态泄漏会持续消耗 Nonpaged Pool加内存只能延迟崩溃第一个误判是我最常看到的。很多人用任务管理器看到某个进程占内存多就一股脑怪业务代码结果代码读了个遍也没发现问题。正确做法是先明确“内存的归属”进程私有的内存能被进程生命周期回收内核池内存只能等驱动自释放。如果你的进程恰好调用了某个内核 API那么你看到进程内存上涨只是因为驱动把统计信息归属于该进程名而实际上驱动自己持有这些数据结构。5.2 推荐排查工具清单与用法这次排查中我重度依赖下面这些工具。它们都是 Windows 环境下的内存排查标配建议提前装好备用。RAMMap可以按进程、按优先级、按用途看到物理内存分配尤其注意Process Private、Kernel Pool、Driver Locked这几项。Driver Locked如果持续增长几乎可以断定是某个驱动文件在泄漏。Poolmon从 Windows Driver Kit 或 Sysinternals 获取。使用/p参数按非分页池排序观察 Tag 的增长情况。我们利用它看到 NDU 相关 Tag 快速增长从而锁定元凶。WinDbg / 内核调试如果要拿到驱动内部调用栈需要双机调试设置内核符号。微软的符号表是公开的设置_NT_SYMBOL_PATHsrv*C:\Symbols*https://msdl.microsoft.com/download/symbols后可以用!pool、!memusage分析具体内存块。Process Explorer用于快速查看进程的句柄数和 GDI 对象数。它不像 RAMMap 能精确定位内核池但对于排除 API 句柄泄漏简单有效。perfmon 计数器重点盯Memory\Pool Nonpaged Bytes、Memory\Pool Paged Bytes、Process\Private Bytes、Process\Working Set这四项。工具的使用顺序也很重要。我建议从小颗粒度到大先看性能计数器趋势判断是进程私有增长还是系统池增长再用 RAMMap 确认归属接着上 Poolmon 找具体 Tag最后使用 WinDbg 针对疑似驱动模块做深度分析这样不会一开始就陷进海量的调试输出里。5.3 难以定位时的杀手锏二分法与灰度验证如果在你的场景里你根本不知道进程调用了哪个驱动还有一种更粗暴但有效的办法在服务器上把服务迁移到不同版本的操作系统上做对照。比如同一份 GODService 部署在 Windows Server 2016 上持续运行 7 天内存增长 300MB部署在 Windows Server 2022 上运行 7 天内存增长 30MB。这种情况下基本可以断定问题更多来自于驱动级差异。我们当时做了类似操作在 Windows Server 2019 上复现泄漏在 Windows Server 2022 上测试同一服务。结果 2022 的表现好很多这从侧面确认了微软在新版本中改进了 ndu.sys 的回收策略。生产环境无法直接升级系统的就用禁用驱动的方式作为过渡方案修复效果也很明显。还有个技巧是灰度验证修复效果把 GODService 分成两个实例一个保持原逻辑一个运行修复后的逻辑同时跑在隔离的两个虚拟机上。用同样的流量脚本压测观察两边的 Nonpaged Pool 增长斜率。我通常以 2 小时为一个对比周期斜率差距超过 5 倍说明修复方向正确低于 1.5 倍说明改的代码根本没碰触到真正的触发路径。5.4 关于 ndu.sys 泄漏的补充说明在写这篇文章的时候网上关于 ndu.sys 内存泄漏的讨论仍然很多常出现在安装了第三方安全软件或网卡驱动更新的 Windows 10 机器上症状表现为系统运行几天后无故卡顿提交内存在切换应用时暴涨而任务管理器里却没有一个明确的进程“占用”这些内存。本质上就是 ndu.sys 内核池泄漏被放大的结果。由于驱动无法像普通进程一样被任务管理器“结束”只能通过更新系统补丁或停用驱动来解决。如果你想确认自己机器是否也受影响可以打开“资源监视器”的“内存”选项卡看“硬错误/秒”是否异常高再结合 RAMMap 的 Nonpaged Pool 总量判断。对普通用户来说最简单的缓解手段是定期重启电脑或者检查是否存在第三方网卡驱动和 ndu.sys 的兼容问题对服务器管理员来说跟踪当时的月度 Windows 更新补丁看一下是否包含 Ndu.sys 相关修复条目这比盲目禁用驱动更优雅。5.5 团队协作与文档沉淀的细节最后一点经验和内存本身关系不大但很多人容易忽略内存泄漏类问题的排查过程非常冗长团队里如果有多个成员参与每个人对“内存增长”的观察基准必须统一。我们这次前两周的排查效率并不高原因是三个人用三个口径记录内存数据——有人看工作集有人看提交大小有人看可用内存。后来我们强制规定所有报告里的内存指标必须包含当前时间、RAMMap 截图、perfmon 计数器值以及可复现的负载场景描述。等到修复完成后我把这些排查过程和结论整理成了一篇内部故障报告附上了每一步的执行命令和图表。后来另一个项目组遇到了相似的 NDU 相关内存问题直接拿这份报告做参考两天内就完成了验证和修复。这就是文档沉淀的价值——记住你自己踩过的坑等于替团队省掉未来五天甚至两周的摸索时间。如果你正在处理类似的 Windows 服务内存泄漏我最后想说的是不要一开始就怀疑自己的代码写出低级错误先判断内存归属再定位触发条件。很多系统级问题本质上就是某个驱动在特定频率和特定访问模式下“失控”了。GODService 修复到现在已经稳定运行了半年内存曲线平得像一条直线这说明方向对了方法也就有了。