
1. 从一次线上告警说起GODService 到底出了什么问题GODService 是我们内部一个常驻型的后台服务跑在 Windows 平台上负责设备状态采集、指令下发和日志回传这一整套链路。它本身不算大代码量也就几万行但胜在稳定上线两年多基本没怎么动过。直到上个月运维那边开始频繁报同一个问题机器跑个三五天任务管理器里的分页缓冲池和非分页缓冲池就一路往上涨最后要么服务自己卡死要么直接把整台机器的内存吃满只能重启了事。一开始大家的第一反应都是“业务代码里有泄漏”毕竟这是最直觉的判断。但排查了一圈下来业务层的对象创建释放看着都挺正常用 Performance Monitor 抓Pool Nonpaged Bytes和Pool Paged Bytes两个计数器发现涨得最凶的恰恰是内核池这一块而不是普通的进程私有内存。这就有点意思了——内核池的泄漏通常意味着有驱动或者底层库在反复申请没释放跟业务逻辑关系不大。顺着这条线往下挖最后定位到了一个叫libdlt的第三方日志库。这个库负责把 GODService 的运行日志落盘内部用了strdup来复制字符串。问题就出在这儿strdup 每次调用都会 malloc 一块内存而 libdlt 在某些分支里复制完字符串之后压根没调 free。更麻烦的是这个库还做了一层符号拦截把系统里原本的 strdup 给 hook 掉了导致我们自己的代码里哪怕老老实实配对 free走的也是它那套有问题的实现。这篇文章就把整个排查和修复过程完整复盘一遍。如果你也在 Windows 上做后台服务尤其是碰到过分页缓冲池和非分页缓冲池内存泄漏这类问题或者你的项目里也用了带符号拦截的第三方库那这篇内容应该能帮你少走不少弯路。我会从问题现象、定位思路、libdlt 和 strdup 的坑、符号拦截的机制一直讲到最终的修复方案和验证方法尽量把每一步的“为什么”都说清楚。2. 内存泄漏的定位思路别一上来就盯着业务代码2.1 先分清是私有内存泄漏还是内核池泄漏很多人一看到内存涨第一反应就是打开任务管理器看进程的“内存”那一列。但那个数字其实只是进程的工作集Working Set它受系统调度影响很大涨涨跌跌很正常不能直接当成泄漏的证据。真正要看的是私有字节Private Bytes和内核池这两块。我一般的做法是分三步走第一步用 Performance Monitor 同时抓Process\Private Bytes、Process\Handle Count、Memory\Pool Paged Bytes、Memory\Pool Nonpaged Bytes这四个计数器采样间隔设成 10 秒跑上至少两三个小时。第二步看趋势。如果 Private Bytes 稳定但 Pool 两个计数器持续上涨那基本可以判定是内核态或者底层库的问题业务代码的嫌疑可以往后放。第三步如果 Private Bytes 也在涨那就得结合 Handle Count 一起看。句柄数同步上涨往往是资源没释放句柄稳定但内存涨才更可能是纯内存泄漏。这次 GODService 的情况很典型Private Bytes 涨得不算夸张但Pool Nonpaged Bytes几乎是一条斜向上的直线几天下来涨了好几百 MB。非分页池是内核里不能被换出到磁盘的内存它涨成这样说明有内核对象或者驱动在反复申请没释放优先级一下子就排到最前面了。提示分页池和非分页池的区别可以简单理解成“能不能被换到硬盘”。非分页池必须常驻物理内存所以它泄漏的后果比普通内存泄漏严重得多系统会更快进入不稳定状态。2.2 用 Poolmon 锁定泄漏的 Tag确定了是内核池的问题下一步就是找出到底是哪个组件在申请内存。Windows 自带一个叫Poolmon的工具在 WDK 里它能按 Tag 统计内核池的分配和释放情况。每个内核池分配都会带一个 4 字节的 Tag驱动和系统组件一般都有自己的固定 Tag。操作上先跑poolmon -b按字节数排序然后隔一段时间再跑一次对比两次的差值。哪个 Tag 的差值持续为正哪个就是嫌疑对象。我们当时跑下来有一个 Tag 的计数一直在涨查了一下对应的驱动模块正好指向 libdlt 加载的那个动态库。这里有个小坑Poolmon 需要管理员权限而且不同 Windows 版本上 Tag 的命名规则略有差异。如果拿到的 Tag 查不到对应模块可以试试用findstr在驱动目录里搜或者直接看 libdlt 的文档里有没有声明它用的 Tag。我们那次是直接在 libdlt 的源码里搜到了它注册的 Tag 名省了不少事。2.3 从 Tag 反查到 libdlt 的调用链锁定 Tag 之后接下来就是确认调用链。因为 libdlt 是被 GODService 静态加载的所以最直接的办法是在它申请内存的那几个函数上下断点看是谁调进来的。我们用 WinDbg 附加到进程在 libdlt 内部疑似泄漏的函数上打了断点然后让服务跑一会儿断点命中之后看调用栈很快就定位到了日志落盘那条路径。具体来说libdlt 在写日志的时候会先把日志内容用 strdup 复制一份然后交给一个异步队列去处理。问题在于如果队列满了或者写盘失败它会走一个错误分支在这个分支里直接 return把之前 strdup 出来的那块内存给忘了。这个分支平时很少走到所以测试环境一直没暴露只有线上高负载、磁盘 IO 偶尔抖动的时候才会触发。3. libdlt 与 strdup一个被忽视的经典陷阱3.1 strdup 到底做了什么为什么容易漏 freestrdup 这个函数标准定义是“复制一个字符串并返回指向新内存的指针”。它内部等价于先 malloc 一段长度为 strlen(s)1 的内存然后把原字符串拷进去最后返回新指针。关键点在于这块内存是调用方负责释放的strdup 自己不会帮你 free。这就带来一个很常见的误区很多人把 strdup 当成一个“纯函数”来用觉得它只是复制字符串用完就不管了。实际上它每次调用都是一次堆分配如果调用频率高、又没配对 free泄漏速度会非常快。libdlt 的问题就在这儿——它在错误分支里漏掉了 free而这个分支在特定条件下会被反复触发。我见过不少项目在代码审查时对 malloc/free 很敏感但对 strdup 却放松了警惕觉得它“看起来不像分配内存”。这是个很危险的直觉。只要函数名里带 dup、copy、clone 这类字眼基本都要留个心眼确认返回的内存谁来释放。3.2 libdlt 的错误分支为什么这么隐蔽libdlt 的日志写入流程大致是这样的先判断队列是否可写可写就把日志内容 strdup 一份塞进队列然后唤醒写盘线程如果队列满了就进入一个降级分支直接尝试同步写盘。问题出在同步写盘失败的时候——它会打印一条错误日志然后直接返回而之前 strdup 的那块内存既没入队也没释放。这个分支隐蔽在几个地方触发条件苛刻需要队列满 磁盘写失败同时发生测试环境很难复现。错误日志本身也走 libdlt形成了一种“错误处理里再出错”的嵌套容易让人看花眼。代码里没有明显的 goto 或者多层嵌套就是一条直线走下来肉眼审查很容易滑过去。我们后来是靠着在 strdup 的返回值上做标记配合内存分配追踪工具才把这个分支揪出来的。如果你也在排查类似的泄漏建议对 strdup 的每一次调用都做配对检查尤其是那些带错误处理的路径。3.3 符号拦截strdup 被 hook 之后发生了什么libdlt 为了统一管理内存做了一层符号拦截把系统里的 strdup 替换成了自己的实现。它的本意是好的——想在所有 strdup 调用上加上统计和追踪方便定位问题。但问题在于它自己的实现里同样有那个漏 free 的分支而且因为拦截是全局的连我们业务代码里正常的 strdup/free 配对也被它接管了。符号拦截在 Windows 上一般通过修改导入地址表IAT或者用 Detours 这类库来实现。它的风险在于拦截之后调用方看到的还是 strdup 这个名字但实际执行的是另一套逻辑行为可能和标准实现有差异。如果拦截实现本身有 bug影响面是全局的所有调用 strdup 的模块都会中招。调试的时候容易懵因为断点打在系统 strdup 上可能根本不命中实际走的是拦截后的版本。我们当时的应对办法是先在 libdlt 的拦截实现里加上分配计数和释放计数跑一段时间看差值。差值持续为正就说明确实有漏释放。然后再结合调用栈定位到具体是哪个分支漏了。4. 修复方案从临时规避到彻底解决4.1 临时方案先止血把泄漏速度降下来线上问题不能等所以第一步是临时规避。我们做了两件事把 libdlt 的日志级别从 DEBUG 调到 WARN减少 strdup 的调用频率。日志少了泄漏速度自然就慢了能给彻底修复争取时间。在 GODService 的启动脚本里加了一个定时重启逻辑比如每 24 小时重启一次服务。这虽然治标不治本但能保证服务在修复上线前不至于把机器拖垮。这两个措施都是权宜之计但实际效果还不错。调整日志级别之后非分页池的增长速度大概降到了原来的三分之一配合定时重启基本能稳住。注意临时方案一定要有明确的退出条件比如“修复版本上线后立即移除”。否则很容易变成技术债后面没人记得清理。4.2 彻底修复给 libdlt 打补丁补上漏掉的 free临时方案只能撑一阵子真正的修复还是得改 libdlt 的代码。因为 libdlt 是第三方库我们没法直接改它的源码所以走了两条路第一条联系库的维护方把我们的分析结果和复现步骤发过去请他们出官方补丁。这条路比较慢但最正规。第二条在本地做一个 patch用 LD_PRELOAD 类似的机制Windows 上可以用 Detours 或者直接改 IAT把 libdlt 里那个有问题的函数替换成我们自己的实现。这个实现里strdup 之后无论走哪个分支都保证 free 被调用。我们最终采用的是第二条路因为等不起。具体做法是写一个 wrapper 函数内部先调用原始的 strdup然后用一个 RAII 风格的 guard 对象管理这块内存确保函数退出时一定会释放。这样即使中间有 returnguard 的析构也会兜底。4.3 修复代码示例与关键点说明下面是我们 wrapper 的核心逻辑用 C 写的简化之后大概是这样char* safe_strdup(const char* src) { if (src nullptr) return nullptr; char* copy original_strdup(src); if (copy nullptr) return nullptr; // 用 unique_ptr 管理确保异常或提前 return 时也能释放 std::unique_ptrchar, decltype(free) guard(copy, free); // 这里做原本 libdlt 要做的入队、写盘等操作 bool ok enqueue_or_write(copy); if (ok) { // 成功入队所有权转移给队列释放 guard 的持有 guard.release(); } // 如果失败guard 析构时自动 free return ok ? copy : nullptr; }关键点有三个用unique_ptr加自定义 deleter 来管理 strdup 返回的内存这样无论中间怎么 return析构都会执行。成功入队时调用release()把所有权交出去避免双重释放。失败路径不需要显式写 freeguard 会兜底代码更干净也不容易再漏。这个模式其实可以推广到所有“申请资源后可能提前返回”的场景。核心思想就是用 RAII 把资源的生命周期绑定到作用域上而不是靠人肉在每个分支里写释放代码。4.4 符号拦截的收尾处理补丁打完之后还有一个收尾问题libdlt 的符号拦截还在它依然会接管全局的 strdup。我们的 wrapper 是在拦截之后再加一层所以调用链变成了“业务代码 - libdlt 拦截 - 我们的 wrapper - 原始 strdup”。这能工作但多了一层性能上有轻微损耗。更干净的做法是在 libdlt 的拦截实现里直接修掉那个 bug然后去掉我们的 wrapper。我们后来跟库的维护方同步了补丁他们在新版本里修了这个问题我们升级之后就把 wrapper 撤了。所以如果你也遇到类似情况建议优先推动上游修复本地 wrapper 只作为过渡。5. 验证与回归怎么确认泄漏真的被修好了5.1 用 Poolmon 和 Performance Monitor 做长稳测试修复上线之后不能只看“感觉好像不涨了”得有数据支撑。我们的验证方法是用 Poolmon 持续监控那个泄漏 Tag 的计数跑 72 小时看差值是否稳定在 0 附近。用 Performance Monitor 抓 Pool Nonpaged Bytes 和 Pool Paged Bytes确认两条曲线都趋于平稳。同时观察 Private Bytes 和 Handle Count确保没有引入新的泄漏点。实测下来修复版本跑满 72 小时之后非分页池的波动范围在正负几 MB 以内基本可以认为泄漏被堵住了。对比修复前动辄几百 MB 的增长效果非常明显。5.2 构造边界条件复现原来的错误分支光跑正常流程还不够因为原来的 bug 是在错误分支里触发的。所以我们专门构造了边界条件把日志队列的容量调到很小强制它频繁进入降级分支。用工具模拟磁盘写失败比如把日志目录设成只读或者用磁盘限速工具制造 IO 抖动。在这些条件下跑上几个小时再用 Poolmon 看计数。这一步很关键因为很多泄漏修复在正常路径下看不出问题只有把原来的触发条件复现出来才能确认补丁真的生效了。我们当时跑下来修复版本在同样的边界条件下池计数保持稳定说明那个漏 free 的分支确实被堵住了。5.3 回归测试中容易忽略的几个点回归测试的时候有几个地方容易漏日志内容是否正确修了内存问题别把日志写坏了。我们对比了修复前后的日志文件确认内容一致。性能是否有回退加了一层 wrapper 之后strdup 的调用开销略有增加。我们用压测工具对比了 QPS确认在可接受范围内。符号拦截是否还有副作用libdlt 的拦截会影响所有调用 strdup 的模块我们检查了其他依赖库确认没有因为拦截行为变化而出现异常。6. 常见问题与排查技巧速查6.1 内存泄漏排查常见问题对照表现象可能原因排查手段非分页池持续上涨驱动或底层库反复申请未释放Poolmon 按 Tag 排序找持续为正的 Tag分页池持续上涨内核对象或缓存未释放结合 Poolmon 和 Performance Monitor 对比Private Bytes 上涨但句柄稳定纯内存泄漏用内存分配追踪工具定位分配点句柄数同步上涨资源未释放用 Handle 工具查看句柄类型和调用栈断点打在系统函数上不命中符号被拦截检查是否有 IAT hook 或 Detours6.2 几个我踩过的坑别只看任务管理器的内存列那个数字受工作集影响涨跌不代表泄漏。要看 Private Bytes 和内核池计数器。strdup 一定要配对 free尤其是错误分支最容易漏。建议用 RAII 包装别靠人肉记。符号拦截要留文档如果项目里用了 hook一定要在显眼的地方写清楚否则后面调试的人会一脸懵。临时方案要有退出条件定时重启、降日志级别这些手段一定要在修复上线后清理掉不然会变成隐患。验证要跑长稳短时间看不出问题至少跑 72 小时最好覆盖边界条件。6.3 给后来者的几条实操建议如果你正在处理类似的内存泄漏问题我的建议是先把监控做起来用数据说话别靠猜。Poolmon 和 Performance Monitor 这两个工具虽然老但真的管用。定位到具体模块之后优先推动上游修复本地补丁只作为过渡。修复之后一定要做长稳测试和边界条件复现确认原来的触发路径被堵住了。另外符号拦截这个东西用好了是利器用不好就是坑。如果非要用一定要在拦截实现里加上完善的日志和计数方便出问题的时候快速定位。我们这次能这么快找到 libdlt 的问题很大程度上就是因为拦截层里留了分配和释放的计数差值一出来方向就明确了。最后再分享一个小技巧在排查内存泄漏的时候可以给可疑的分配点加上一个唯一的标记比如在分配的内存头部写一个 magic number。这样用内存 dump 工具看的时候能快速识别出这块内存是谁申请的比单纯看地址高效得多。这个技巧在排查第三方库的泄漏时特别有用因为你不一定能拿到它的源码但可以通过内存特征反推它的行为。