Python 内存泄漏排查实战:tracemalloc + objgraph 从症状到根因的完整复盘(2026)

📝 464 字 · ☕ 2 分钟阅读

📎 相关推荐:Python 上下文管理器深度实战:不只是 with open——6 个生产级场景从数据库事务到 ExitStack

📌 延伸阅读:Python subprocess 深度实战——Popen 死锁陷阱、asyncio 流式处理、10,000 次基准测试全覆盖

📖 相关阅读:Python itertools 惰性管道实战 — 用生成器管道替代千行循环,百万级数据处理快 3 倍、内存减半。

📌 推荐阅读:Linux /proc 文件系统深度实战——最小依赖排查法(2026) — 生产环境最小容器里连 htop 都没有?只靠 cat /proc 就能完成 CPU/内存/IO/网络全栈诊断。

相关阅读:Python 异常处理深度实战:try/except 真的慢吗? —— 异常对象的生命周期与内存,跟泄漏排查也有关联。

凌晨三点,我被一条告警吵醒

PagerDuty 弹了一条消息:web-api-pod-3 memory usage 92%。半梦半醒间打开 Grafana,果然——内存曲线像爬楼梯一样,每隔几小时 GC 回收掉一点,然后继续往上爬。48 小时后 OOM Killer 准时到场。

没有 panic,没有 error log,没有 CPU 飙升。这就是典型的慢性内存泄漏——比 crash 难排查十倍的东西。

这篇文章复盘我从「怀疑是泄漏」到「定位根因」再到「彻底修复」的完整过程。工具链:tracemalloc + objgraph + gc,不依赖任何第三方付费工具。

Python内存泄漏排查图表:48小时内存趋势与tracemalloc快照对比
▲ 左:48小时内存趋势(泄漏进程 vs 正常进程);右:tracemalloc 快照对比,锁定泄漏根因

第一步:确认「真的在泄漏」

内存增长不一定是泄漏。先排除几个干扰项:

  • 是不是业务量涨了? 看 QPS 曲线——平稳。排除。
  • 是不是缓存预热? 我们的 Redis 做远程缓存,进程内只有少量 lru_cache。排除。
  • 是不是 Python 的内存碎片?psutil.Process().memory_info() 看 RSS 和 VMS——RSS 持续增长而 VMS 稳定,说明是实际分配的堆内存在涨,不是碎片。

基本确认是泄漏。接下来上 tracemalloc。

第二步:tracemalloc — 不用重启就能定位

tracemalloc 是 Python 3.4 就有的内置模块,但很多人在生产环境不敢用——怕性能开销。实际上它的开销在 5%-10% 之间,远小于你用 memory_profiler 逐行扫描。

核心用法:拍快照,然后对比两个快照之间的差异。

import tracemalloc

# 启动追踪(只追踪当前进程的 Python 分配,不追踪 C 扩展)
tracemalloc.start(25)  # 保留最近 25 帧调用栈

# ========== 快照 1:启动后 1 小时 ==========
snapshot1 = tracemalloc.take_snapshot()

# ... 应用运行 24 小时 ...

# ========== 快照 2:运行 24 小时后 ==========
snapshot2 = tracemalloc.take_snapshot()

# 对比差异:看哪些分配在增长
top_stats = snapshot2.compare_to(snapshot1, 'lineno', limit=10)

print("===== 内存增长 Top 10 =====")
for stat in top_stats:
    traceback = stat.traceback
    # stat.size_diff: 这个位置分配的内存的增量(字节)
    # stat.count_diff: 这个位置分配的对象数量的增量
    print(f"{stat.size_diff / 1024 / 1024:>8.2f} MB | "
          f"count: {stat.count_diff:>+6d} | "
          f"{traceback.format()[0] if traceback else 'unknown'}")

输出的对比结果让我一眼看到了问题——排名第一的是 logging/handlers.pyMemoryHandler 缓冲区,增长了 280MB。下面是真实的输出片段:

===== 内存增长 Top 10 =====
  280.34 MB | count: +125000 | logging/handlers.py:847 (MemoryHandler.emit)
   22.15 MB | count:   +8500 | custom_cache.py:42 (LRUCache.__setitem__)
   18.52 MB | count:  +12000 | sqlalchemy/orm/session.py:234 (Session.add)
   15.30 MB | count:   +2100 | pandas/core/frame.py:523 (DataFrame.__init__)
...

看到这行输出我心里咯噔一下。我们的日志系统用的是 logging.handlers.MemoryHandler 做缓冲批量写入——但有人的配置里忘了设 capacity 上限。

第三步:objgraph — 可视化引用链

tracemalloc 告诉你哪里在分配内存,但要理解为什么这些对象没被回收,需要看引用关系。这时候 objgraph 出场。

import objgraph
import gc

# 强制 GC 后查看还有多少 logging.LogRecord 对象活着
gc.collect()
gc.collect()  # 二次回收,触发 __del__

# 查看最常见类型的增长
objgraph.show_growth(limit=10)

# 输出典型结果:
# LogRecord      +124500
# dict           +89230
# str            +45600
# function       +12300

12 万个 LogRecord 对象没被回收。继续追引用链:

import random

# 随机挑一个 LogRecord,看谁在引着它
log_records = [o for o in gc.get_objects() if isinstance(o, logging.LogRecord)]
if log_records:
    victim = random.choice(log_records)
    # 画出引用链(输出到 DOT 文件,用 graphviz 可视化)
    objgraph.show_backrefs([victim], max_depth=8,
                          filename='/tmp/logrecord_backrefs.png')
    print(f"LogRecord 创建时间: {victim.created}")
    print(f"最早的一条: {min(r.created for r in log_records)}")

引用链的核心是:root logger → MemoryHandler.buffer (list) → 125,000 个 LogRecord 对象。因为 buffer 没设上限,GC 永远收不回来。

三种最常见的 Python 内存泄漏模式

经过这次排查和之前积累的经验,我总结了三种最常见的泄漏模式:

模式一:无界缓冲区(Unbounded Buffer)

特征: tracemalloc 显示某个容器类型(list/dict/deque)持续增长,count_diff 很大。
根因: 生产者往 buffer 里塞的速度 > 消费者取出的速度。
典型场景: logging MemoryHandler、消息队列、metrics 聚合、事件总线。

修复:

# ❌ 无上限
handler = MemoryHandler(capacity=float('inf'), target=file_handler)

# ✅ 加容量上限 + 溢出策略
handler = MemoryHandler(capacity=1000, flushLevel=logging.ERROR, target=file_handler)
# 超过 1000 条自动 flush 到 file_handler,而不是无限堆积

模式二:循环引用 + __del__

特征: gc.garbage 列表中有对象堆积。
根因: 两个对象互相引用且都定义了 __del__ 方法。Python 的 GC 能处理普通循环引用,但遇到 __del__ 就无从下手——因为不知道先调哪个析构函数。
典型场景: 自定义的数据库连接池、回调注册、观察者模式。

修复:

# ❌ 循环引用 + __del__ = GC 放弃回收
class Connection:
    def __init__(self, pool):
        self.pool = pool  # 引用回 pool
    def __del__(self):
        self.pool.release(self)

# ✅ 用 weakref 打破循环
import weakref

class Connection:
    def __init__(self, pool):
        self._pool_ref = weakref.ref(pool)  # 弱引用,不阻止 GC
    def __del__(self):
        pool = self._pool_ref()
        if pool:
            pool.release(self)

模式三:全局缓存无限增长

特征: tracemalloc 指向某个模块级 dict,size 随时间线性增长。
根因: @lru_cache(maxsize=None) 或手动维护的全局 dict 没有淘汰策略。
典型场景: 函数结果缓存、模板编译缓存、ORM 查询缓存。

修复:

# ❌ 无限缓存
@lru_cache(maxsize=None)
def expensive_query(user_id: int) -> dict:
    ...

# ✅ 设上限 + LRU 淘汰
@lru_cache(maxsize=1024)
def expensive_query(user_id: int) -> dict:
    ...

# 或者用 cachetools.TTLCache 设过期时间
from cachetools import TTLCache
cache = TTLCache(maxsize=5000, ttl=3600)  # 最多 5000 条,1 小时过期

第四步:修复 + 验证

排查完根因,修复反而简单:

  1. MemoryHandler 加 capacity=1000: 一行配置改动,内存从 400MB 降到 150MB。
  2. 全局缓存加 maxsize=1024: lru_cache 加上限,防止无限膨胀。
  3. 加监控告警: 在健康检查端点加一个内存水位检查——超过 500MB 就返回 warning。

验证方法:发一个新版本,跑 48 小时压测。用同样的 tracemalloc 快照对比确认没有新的增长趋势。

生产环境排查工具速查表

工具 适用场景 开销 能否生产用
tracemalloc 定位内存分配热点、对比快照 5-10% ✅ 可
objgraph 可视化引用链、找增长最快的类型 仅采样时 ⚠️ 采样用
gc 模块 查 gc.garbage、手动触发回收 gc.collect() 会STW ⚠️ 低峰期
memory_profiler 逐行内存分析 30-100% ❌ 不适合
psutil RSS/VMS/线程数监控 <1% ✅ 可
py-spy 无侵入 CPU profiling(辅助排除) 极低 ✅ 可

常见问题(FAQ)

Q: tracemalloc 在生产环境开多久合适?

建议在问题排查期间临时开启,拍完快照对比就可以关掉。长期开着会让 tracemalloc 自身的追踪数据也占用内存。如果必须长期监控,设 tracemalloc.start(10)(只保留 10 帧调用栈而非默认的 1 帧)来减少开销。

Q: tracemalloc 显示泄漏在 C 扩展里怎么办?

tracemalloc 只追踪 Python 层面的内存分配,C 扩展(如 numpy、pandas 的底层数组)不在其范围内。这种情况要用 guppy3(heapy)或者直接上 valgrind --tool=memcheck。不过 90% 的 Python 内存问题都在 Python 层,C 扩展泄漏比较罕见。

Q: gc.collect() 手动触发回收安全吗?

gc.collect() 会触发 Stop-The-World 回收,暂停所有线程。在 QPS 高峰期调用会明显增加 P99 延迟。建议在低流量时段(如凌晨)按需调用。如果用了 gevent/eventlet 等协程框架,还需注意绿色线程的兼容性。

总结:内存泄漏排查的四步法

把这篇文章浓缩成一张清单,下次再遇到内存问题直接照着走:

  1. 确认泄漏 — psutil 看 RSS 趋势,排除业务增长和缓存预热。
  2. 定位热点 — tracemalloc 快照对比,compare_to('lineno') 找到增长最快的代码行。
  3. 追引用链 — objgraph.show_backrefs() 可视化谁在持有对象,gc.garbage 检查循环引用。
  4. 修复 + 验证 — 修完后跑 48 小时压测,tracemalloc 二次对比确认趋势归零。

说真的,Python 的内存泄漏排查没有 Java 那么完善的生态(没有 VisualVM、没有 MAT),但 tracemalloc + objgraph 这个组合在绝大多数场景下够用了。关键是动手拍快照——很多人卡在「怀疑有泄漏但不知道从哪查」,其实只要 tracemalloc.start() 然后 compare_to(),答案就在那 10 行输出里。


📎 如果你也在排查生产环境问题,这两篇可能对你有用:

📤 分享这篇文章