☰
GODService内存泄漏排查:ndu.sys内核池泄漏与句柄泄漏双重修复
2026/10/8 10:21:02 网站建设 项目流程

凌晨两点半,监控大屏上的红点开始闪烁。GODService 的内存又越过了 2GB 警戒线,这已经是这一周内第三次了。我这边遇到的“GODService 内存泄漏”问题,起初并没有想象中那么绕,但确实被表象带偏了快两天:任务管理器里看着像进程堆泄漏,可真正往下挖,发现锅有一半是系统底层的 ndu.sys 在背。这篇修复报告,想完整复盘一下从误判、定位到最终修复的全过程。如果你也在维护 Windows 平台上的常驻服务,或者正在被“ndu.sys 内存泄漏”这类内核池异常增长折磨,这篇内容至少能帮你省掉前两天的弯路。

1. 现象与影响范围:GODService 的内存为什么一路飙高

1.1 第一现场:监控曲线与现场证据

GODService 是我们这边维护的一个常驻服务,跑在 Windows 终端和服务器上,负责采集网卡收发字节、连接状态、丢包率,然后上报到集中监控平台。逻辑本身不复杂,但某次版本上线后,监控曲线开始不对劲:刚启动时内存大约 180MB,前 3 天基本是一条平线,第 4 天开始出现明显斜率,第 7 天冲到了 1.6GB,个别机器到第 10 天直接吃掉 2.8GB。

一开始我们用了最土的办法,重启服务。内存确实立刻掉回 200MB 以内,但两天后又开始爬。这说明不是“一次性分配没释放”,而是存在一条持续增长的内存路径。更麻烦的是,在 4GB 内存的工控机上,服务一涨系统就开始换页,鼠标都飘,用户已经能感知到卡顿。现场证据里最值得注意的一点是,任务管理器里的“句柄数”从 300 一路涨到 2000 多,但“内存(专用工作集)”并没有跟着爆炸。这个矛盾信号,后来成了定位的关键突破口。

1.2 影响面盘点:哪些环境最容易踩雷

根据我们的部署数据,并不是所有机器都涨得一样快。最容易踩雷的环境有这么几类:

  • 装有多个虚拟网卡或容器网络栈的机器,比如 Hyper-V 虚拟交换机、Docker Overlay 网卡这类环境。
  • 存在高频短连接的服务器,比如对外开放的 API 网关,每秒建立的连接数一多,问题会被明显放大。
  • 网络状态频繁变化的笔记本场景,比如休眠唤醒、Wi-Fi 漫游、网线反复插拔。
  • 系统版本集中在 Windows 10 21H2/22H2 与 Windows Server 2019/2022。

在这些环境下,GODService 的内存曲线几乎都呈现“先平后陡”的形态,区别只是陡坡来得早晚。这组规律给后续定位提供了第一手输入:问题大概率与网络状态频繁变化、连接生命周期短有关,而不是单纯的字符串拼接或日志堆积造成的用户态泄漏。把所有机器放在一起对比后,我们基本排除了硬件差异和业务流量差异,判断焦点集中在“服务与网络子系统某些底层组件的交互方式上”。

2. 泄漏源头排查:从任务管理器一路挖到 ndu.sys

2.1 第一步:先分清用户态还是内核态

绝大多数服务内存告警的第一反应是看任务管理器,看 GODService 的“内存”列。但这一步非常容易把人带偏,因为任务管理器汇报的是进程的用户态专用工作集,而 ndu.sys 这类驱动的内存占用是在内核池里,任务管理器根本不显示。

我第一次误判就是因为只盯 Working Set:涨得慢,觉得“问题不大”;真正去看系统级的“内存\池非分页字节”时,才发现系统内存已经被默默吃掉了 1.4GB。这里的关键教训是:遇到常驻服务内存持续增长,第一步不是看某个进程的内存列,而是同时抓三组数据——进程 Private Bytes、进程 Handle Count、系统 Pool Nonpaged Bytes。用性能监视器或直接开一条日志采集,每分钟采样一次,跑 24 小时,用户态和内核态谁在涨,一目了然。

注意:任务管理器里“内存(专用工作集)”对应进程私有内存,虚拟内存和内核池都不在里面。只靠任务管理器判断内存泄漏,非常容易漏掉内核态泄漏。

2.2 第二步:用性能计数器与句柄表交叉验证

我当时直接用 typeperf 起了三条计数器,每分钟记录一次,命令大概是这样的:

typeperf "\Memory\Pool Nonpaged Bytes" "\Process(GODService)\Private Bytes" "\Process(GODService)\Handle Count" -si 60 -o memory_meter.csv

一天下来结果很典型:GODService 的 Private Bytes 只有缓慢爬升,7 天涨了 40MB 左右;但句柄数从启动时的 300 涨到 2100,涨幅 7 倍。同时系统级计数器 \Memory\Pool Nonpaged Bytes 从 380MB 涨到 1.7GB。到这里基本可以排除“单纯是 GODService 堆内存泄漏”,因为真正制造出 1.4GB 内存压力的不是进程私有堆,而是内核池。

再结合句柄数增长,我判断用户态代码确实也存在资源泄漏,但量级不大,大头在两块:一块是系统内核池的非分页内存,另一块是进程自身的句柄表。紧接着用 ETW 抓了一段时间的内核内存分配,发现 Nonpaged Pool 上大量分配的内存标签是 "Ndu",这就把指向了 ndu.sys——Windows 的网络数据使用统计驱动。

2.3 第三步:用 PoolMon 实锤 Ndu 池标签

实锤过程其实就是一条命令:管理员 CMD 里运行 poolmon,按 p 键切到 Nonpaged,再按 b 按字节排序,中间那一列“Tag”会看到 Ndu 霸屏。PoolTag "Ndu" 就对应 ndu.sys 在非分页池上挂的标签。我连续盯了一小时,Ndu 的 NonPaged Bytes 只涨不跌,每隔几分钟就涨 20-50MB,说明它在持续产生不可回收的池块。

这里要说明一点,虽然最终问题指向 ndu.sys,但 GODService 本身并不干净。假如没有我们的进程在持续制造连接变化,系统池的内核泄漏不会涨这么快。两条线索最终汇合到同一个判断:这是两层泄漏叠加,一个是系统驱动的池泄漏,一个是服务自身的句柄/资源泄漏。

3. 根因拆解:GODService 与 ndu.sys 的两层耦合问题

3.1 ndu.sys 内存泄漏的机制与触发条件

ndu.sys 的全称是 Network Data Usage Monitor Driver,负责维护系统里每个应用、每个网络连接的流量统计。Windows 任务管理器里的“应用历史记录”、以及“设置-网络-已用数据”都依赖它。底层逻辑是:它对每个网络连接分配一个统计块,连接结束时回收统计块。

问题在于,当连接以极快速度创建和销毁时,WFP 回调与连接终结事件之间存在竞争条件,部分统计块的引用计数没能归零,回收逻辑就会跳过它,最终形成驱动层的内存泄漏。这部分内存在内核非分页池里,用户态程序既看不到,也没法手工释放,只能尽量减少触发它的行为模式。

触发条件里最容易踩到雷的三种:高频短连接、网卡频繁禁用/启用、笔记本休眠唤醒后网络栈重建。如果系统里还挂着多个虚拟网卡,状态变化会更多,问题会更严重。这也解释了为什么我们的服务一到有虚拟化网卡或 API 网关的机器上就涨得飞快。

3.2 GODService 自身的隐性资源泄漏点

除了 ndu.sys,代码审查也发现了自己的问题,这点不避讳,直接分享出来。主要有三个:

  • WFP 筛选器与 Callout 没有成对清理。我们调用FwpmFilterAdd0注册流量审计规则,但在服务停止流程里只调了FwpmEngineClose,没有逐个删除筛选器。FwpmEngineClose关的只是引擎句柄,不是规则本身,导致每次服务重启都会留下一批筛选器,句柄数因此只涨不跌。
  • 使用RegOpenKeyEx读取网络接口配置后,个别分支返回前忘记RegCloseKey,属于非常典型的句柄泄漏。
  • 轮询逻辑里用new []分配缓冲区,某几个异常分支直接 return,delete 被跳过。

这些点单看都很基础,但藏在几千行业务代码里就没那么显眼。我们通过 CodeQL 扫描加双人代码评审,一共确认了 3 个资源未释放点。修复之前我甚至怀疑是第三方库在捣乱,后来发现绝大部分是自己代码的问题,第三方库反而干净。

3.3 为什么是两个问题叠加而不是单一问题

关键证据来自一组对照实验:

  • 不启动 GODService,让测试机自行跑短连接风暴,系统 Pool Nonpaged Bytes 也涨,但 24 小时只涨约 120MB,属于偏慢的内核池增长。
  • 启动修复前的 GODService,让它保持最小工作集、不触发任何采样逻辑,内存涨得也不明显。
  • 两者同时运行,服务每 30 秒轮询一次 ndu 相关统计,同时频繁创建 UDP socket 做连通性检测,24 小时能吃掉 700MB 左右。

这说明 GODService 的行为模式成了 NDU 池泄漏的放大器,而不只是单方面“系统有问题”或“我们代码有问题”。如果只修系统驱动侧,或者只改自己的代码,问题都还会以另一种形式出现。修复必须双管齐下,同时对驱动触发路径做减负。

4. 修复方案落地:代码、配置与防御性降级

4.1 修复 GODService 侧的句柄与 WFP 清理

代码层面的修复核心是整治资源释放。我给所有 Windows 句柄加了 RAII 封装,能用智能句柄的地方绝不用裸句柄;临时缓冲区统一改成std::vector<uint8_t>,靠作用域析构释放;注册表句柄也在每个分支里确认了关闭路径。

WFP 清理流程尤其值得单独说,必须按顺序做:先枚举并删除当前会话创建的所有筛选器,等待内部删除操作完成,再调用FwpmEngineClose。简化示意如下:

for (auto& id : filterIds) { fwpmFilterDeleteById0(engineHandle, id); } Sleep(50); // 等待引擎处理删除同步 FwpmEngineClose(engineHandle);

当然实际工程里不能这么粗暴,需要处理返回值、重试和重复删除问题,但核心顺序不能反。为什么顺序这么重要?因为FwpmEngineClose只关闭客户端与 BFE 的会话句柄,已经添加的筛选器由 Base Filtering Engine 继续持有并生效;你不删干净,筛选器就永远留在系统里。我们最初看到“句柄数涨”,一半就是这些对象没释放。

4.2 调整监控策略,绕开 ndu.sys 的触发路径

由于 ndu.sys 的底层修复取决于微软补丁,我们能做的是减少碰撞面。这次我做了几个改动:

  • 采集频率从 30 秒调整为 5 分钟。实时性确实降了一点,但这类业务监测要的是趋势,不是秒级精度。
  • 连通性检测从高频 UDP ping 改成一个 TCP 长连接,每 30 秒发一次保活心跳,而不是每 2 秒新建一个 socket。
  • 对纯内网部署的采集端点,直接用系统的Get-NetTCPConnection增量统计替代 NDU 专用接口。
  • 服务启动参数里增加“采样模式”开关,遇到系统版本已知存在 ndu 问题的机器,自动切换低频模式。

为什么这样可以?NDU 的池分配主要在连接建立/销毁时触发。采样频率降低后,服务自身创建的连接生命周期变长,WFP 回调不会频繁触发,ndu 泄漏的增速直接从每几小时一条陡坡变成平缓直线。这种取舍换来内存稳定,在运维侧完全值得。

4.3 防御性降级与自愈机制

即使代码修干净,也不能保证微软哪天又引入新回归。所以我顺手加了一层资源看门狗:服务每 5 分钟自检一次 Private Bytes、句柄数、系统 Pool Nonpaged Bytes;三项中任一超过预设阈值,就按预案进入降级模式,停止采集、释放临时资源,并上报调度中心;如果连续 3 个周期仍然超标,就由上级进程自动拉起服务。

配合 NSSM 或 Windows 计划任务做守护,能让进程在异常状态下自愈,不会一直涨到 OOM。自愈机制不是给代码泄漏当遮羞布,而是为了兜底那些由外部环境触发的内核池异常,比如 Windows 补丁回归、网卡驱动 bug。加了这层之后,后续再遇到 ndu.sys 这类系统级问题,就算根因不在我们代码里,整个采集链路也不会被打挂。

5. 验证与回归:怎么证明内存是真的稳住了

5.1 验证方案设计:7 天 A/B 对照实验

修复不验证等于白修。我搭了两组测试机,一组跑修复前版本,一组跑修复后版本,用同样的流量脚本驱动:每 5 秒建立 50 个短连接,每小时模拟一次网卡禁用/启用,连续跑 7 天。

采集指标包括 GODService Private Bytes、句柄数、系统 Pool Nonpaged Bytes,以及 PoolTag 为 Ndu 的池字节数。用 PerfMon 记录 .blg 文件,每天再用 poolmon 快照一次 Ndu 标签内存。结果对比很清楚:

  • 修复前那组第 4 天开始指数爬坡,第 7 天差点把 8GB 内存打满。
  • 修复后那组 7 天内存曲线基本水平,Private Bytes 稳定在 400MB 左右,句柄数维持在 300 上下浮动 15,Pool Nonpaged Bytes 日内波动不超过 80MB。

最关键的变化是:Ndu 标签的内存占用从“只涨不跌”变成了锯齿状波动,说明系统开始正常回收了。

5.2 回归测试覆盖点与验收标准

回归不能只看“跑几天没炸”,要覆盖平时最容易出问题的关键操作:

  • 服务连续启停 20 次,每次停止后句柄数回到基线,不残留 WFP 筛选器。
  • 断网/恢复网络反复操作 100 次,观察 Pool Nonpaged Bytes 是否有单调递增趋势。
  • 在 4GB 内存的低配机器上跑满 72 小时,无 OOM 告警,无系统卡顿。
  • 正常业务流量下跑满 7 天,内存涨幅低于 10%。

我定的验收标准有两项硬指标:第一,7 天 GODService 内存涨幅不超过 10%;第二,句柄数波动不超过 5%。这两项同时满足,我才会真正认为修复落地。另外一个实操提醒:验证期间尽量不要手动重启服务,因为重启会把泄漏曲线打断,掩盖已经存在的增长趋势,最后得出来的结论不具备参考价值。

6. 常见问题与排查技巧实录

6.1 问题速查表

整理了一些我和同事在实际环境里高频遇到的问题,做成速查表方便直接对照:

症状可能原因快速定位方法处理建议
服务重启后恢复,几天后再复发服务资源泄漏或内核池泄漏对比进程 Private Bytes 与系统 Pool Nonpaged Bytes系统池涨就继续追 PoolTag
任务管理器内存不高但系统越来越卡内核池 Nonpaged 暴涨查看 \Memory\Pool Nonpaged Bytes用 poolmon 查 Ndu 等 Tag,确认驱动归属
GODService 句柄数持续上涨句柄或 WFP 筛选器未释放任务管理器添加“句柄数”列观察代码审查并修正清理顺序
启停服务后内存不降WFP 筛选器残留管理员执行netsh wfp show filters先删除筛选器,再关闭引擎句柄

这张表其实是从这次排障过程中提炼出来的,基本覆盖了“自己以为程序跑得好好的,但系统一天比一天慢”这类问题的常见路径。

6.2 实操心得与避坑经验

最后分享几条实打实的心得,都是这次修复里沉淀下来的:

第一,先隔离再修,不要一上来就改业务代码。用“用户态 vs 内核态”把泄漏分层,决策效率最高。如果我先埋头在 C++ 代码里找三天堆泄漏,最后可能还是找不到,因为大头根本不在进程堆里。

第二,生产环境抓内核池时,PoolTag 是突破口。poolmon 的交互命令记不住没关系,抓到一个类似 "Ndu" 的标签,顺藤摸瓜比什么都快。

第三,内存修复的验证周期别少于 72 小时。这类问题的增长曲线往往前 3 天是平的,第 4 天才露头,观察时间太短得出的结论基本无效。

第四,代码评审时让两个人各自列出“每个句柄、回调、缓冲区的释放点”,比一个人反复翻代码靠谱得多。问题发现得越早,后续返工成本越低。

第五,把 Pool Nonpaged Bytes 加进系统级监控告警。这个计数器能提前暴露很多内核态问题,宁可多一条指标,不要等用户先报障。

这次 GODService 内存泄漏的修复,对我来说最大的收获不是改掉了几个句柄,而是建立了一套从现象分类、内核态定位到修复验证的标准流程。以后再遇到类似问题,第一件事一定是先打开性能监视器把用户态和内核态拆开,而不是盲目翻代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询