Python 生产环境内存泄漏排查实战:从发现到修复的完整复盘(2026)

📝 590 字 · ☕ 2 分钟阅读

凌晨3点,Kubernetes把我的Pod杀了

那是去年上线一个数据处理服务的时候。服务跑了两天,一切正常——直到第三天凌晨,Kubernetes的OOM Killer突然杀出来,把Pod干掉了。重启,再挂。重启,再挂。

一看监控:RSS内存从启动时的200MB,像爬坡一样稳稳涨到了2GB——典型的内存泄漏

这篇就复盘一下当时我是怎么从一头雾水到定位根因、再到彻底修好的完整过程。会用到 tracemalloc、objgraph、gc 这三个工具,最后还会总结几个我踩过的 Python 内存泄漏坑,帮你防患于未然。

第一步:确认泄漏真实存在

Python 有垃圾回收(GC),很多人第一反应是「Python 不会内存泄漏」。这话对了一半:Python 的引用计数 + 循环检测器能处理绝大多数情况,但逻辑泄漏(logical leak)——你把对象放进一个全局容器里忘了删——GC 帮不了你。

首先确认是不是真的泄漏。最简单的办法是看 RSS:

# 每5秒采样一次,观察趋势
watch -n 5 'ps aux | grep python | grep -v grep | awk "{print \$6}"'

如果你看到 RSS 单调递增、GC 后不回落到初始值,那就是泄漏。我的服务当时是每小时涨 150MB,48 小时后踩到 2GB 上限。

也可以用 Python 自带的 tracemalloc 做快照对比——比看 RSS 更准,因为能直接告诉你哪行代码分配了最多内存

第二步:tracemalloc 快照对比——精准定位泄漏点

tracemalloc 是 Python 3.4+ 内置模块,零依赖。核心思路:在服务的两个时间点分别拍快照,对比差异。

import tracemalloc

tracemalloc.start()

# === 拍第一张快照(服务启动后)
snapshot1 = tracemalloc.take_snapshot()

# ... 跑你的业务逻辑(比如处理1000条数据)...

# === 拍第二张快照
snapshot2 = tracemalloc.take_snapshot()

# 对比:找出增量最大的 10 个内存分配点
stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in stats[:10]:
    print(stat)

输出会长这样:

data_processor.py:42: size=89.3 MiB (+78.2 MiB), count=1452000 (+1273000), average=64 B
cache_manager.py:128: size=34.1 MiB (+30.5 MiB), count=890000 (+780000), average=39 B

这一下就锁定了——data_processor.py 第 42 行和 cache_manager.py 第 128 行在疯狂分配内存。打开源码一看:

# data_processor.py:42
self._results_cache.append(processed_item)  # 全局 list,永不清空!

# cache_manager.py:128  
self._metrics_history[timestamp] = metrics   # 全局 dict,key 是时间戳,无限增长

问题一目了然:两个全局容器充当了「黑洞」,数据只进不出。tracemalloc 从启动到定位,前后不到 5 分钟。

第三步:objgraph 追踪循环引用

修完上面两个明显的问题后,重启服务。内存涨得慢了,但还在涨——说明还有别的泄漏。这次 RSS 涨得慢且不规则,怀疑是循环引用

循环引用在 Python 里会被分代 GC 处理,但如果对象定义了 __del__,GC 不会自动回收它们——因为 Python 不知道先调哪个 __del__

objgraph 来可视化对象增长:

pip install objgraph
import objgraph

# 查看当前增长最多的对象类型
objgraph.show_growth(limit=10)

# 输出示例:
# function         1205     +12
# dict             4580     +8
# MyCustomClass    3210     +3200  ← 这个类在暴涨!

MyCustomClass 数量两天涨了 3200 个——显然没被回收。接着查是谁在引用它们:

# 随机选一个实例,画出引用链
objgraph.show_backrefs(
    objgraph.by_type('MyCustomClass')[0],
    max_depth=5,
    filename='/tmp/backrefs.png'
)

引用链显示:每个 MyCustomClass 实例被一个 ThreadLocal 对象持有,而线程池里的线程在执行完后,thread-local 没有被清理。修复方案:在线程任务结束时显式清理:

# 修复前
def worker(task):
    local = threading.local()
    local.processor = MyCustomClass()  # 泄漏!线程复用时 local 不清
    local.processor.run(task)

# 修复后
def worker(task):
    local = threading.local()
    local.processor = MyCustomClass()
    try:
        local.processor.run(task)
    finally:
        del local.processor  # 显式清理

线程池里用 threading.local() 是常见的坑——线程复用但 local 对象没清,相当于隐式全局变量。

第四步:修复前后对比

三个泄漏全部修完后,重新压测:

指标 修复前 修复后 改善
初始 RSS 200 MB 180 MB -20 MB
48h RSS 2.0 GB (OOM) 220 MB -89%
内存增长率 ~150 MB/h ~0.8 MB/h -99.5%
GC 后回落到基线?

关键是 RSS 在 GC 后能回到基线附近,说明泄漏彻底堵住了。剩余的 0.8 MB/h 是正常的堆碎片波动,不用管。

第五步:常见 Python 内存泄漏场景速查

总结一下我在生产环境见过的 Python 内存泄漏元凶,排名分先后:

1. 全局容器无限增长(占比 40%)

# ❌ 泄漏:全局 list/dict 只 append 不清
processed_items = []
def handle(item):
    processed_items.append(item)  # 永不清空

# ✅ 修复:用 deque 设 maxlen 或用定期清理逻辑
from collections import deque
processed_items = deque(maxlen=10000)  # 自动淘汰老的

2. 缓存未设上限(占比 25%)

# ❌ 泄漏:LRU 缓存无 maxsize
@lru_cache()  # 默认 maxsize=128,但如果 key 是动态的……
def compute(key, data):
    ...

# ✅ 修复:显式设 maxsize
@lru_cache(maxsize=256)
def compute(key, data):
    ...

特别要注意:如果 lru_cache 的参数每次都不一样(比如带时间戳),即使 maxsize=128 也会导致缓存无限膨胀——因为每个新 key 都是 cache miss。

3. Thread-Local 不清理(占比 15%)

上面已经讲过了,线程池 + thread-local 是黄金搭档也是黄金陷阱。

4. 循环引用 + __del__(占比 10%)

# ❌ 泄漏:__del__ 阻止了循环引用的 GC
class Node:
    def __init__(self, parent=None):
        self.parent = parent
        self.children = []
    def __del__(self):
        self.children.clear()  # 有 __del__ 时,循环引用不会被回收!

# ✅ 修复:用 weakref 打破循环
import weakref
class Node:
    def __init__(self, parent=None):
        self.parent = weakref.ref(parent) if parent else None
        self.children = []

5. 未关闭的文件/连接(占比 10%)

# ❌ 泄漏:文件句柄 + 缓冲区
def read_logs():
    f = open('/var/log/app.log')  # 异常时未 close
    return f.read()

# ✅ 修复:context manager
def read_logs():
    with open('/var/log/app.log') as f:
        return f.read()

第六步:预防工具链

修好一个泄漏不算完,关键是别再引入新的。我在 CI 里加了这些检查:

# pytest fixture:每轮测试后检查是否有泄漏
import gc, tracemalloc

@pytest.fixture(autouse=True)
def check_memory_leak():
    gc.collect()
    tracemalloc.start()
    before = tracemalloc.take_snapshot()
    yield
    after = tracemalloc.take_snapshot()
    stats = after.compare_to(before, 'lineno')
    # 如果测试后内存增长 > 5MB,直接报 FAIL
    total_diff = sum(s.size_diff for s in stats)
    assert total_diff < 5 * 1024 * 1024, \
        f'疑似内存泄漏:{total_diff / 1024 / 1024:.1f} MB'

再配合生产环境的 RSS 监控告警(Prometheus + Grafana),只要 RSS 增长率超过阈值就触发告警,不用等 OOM。

加分技巧:用 pympler 做对象级别的内存账本

有时候 tracemalloc 告诉你某行代码分配了很多内存,但你搞不清是哪个对象类型在吃内存。这时候 pympler 就很有用——它能告诉你每种类型的对象占了多少字节:

pip install pympler

from pympler import summary, muppy

all_objects = muppy.get_objects()
sum1 = summary.summarize(all_objects)
summary.print_(sum1, limit=10)

# 输出示例:
#                                 types |   # objects |   total size
# ===================================== | =========== | ============
#                           dict |       15320 |     12.45 MB
#              numpy.ndarray |        4500 |      8.92 MB
#                            str |      120000 |      7.68 MB
#                  MyCustomClass |        8900 |      6.23 MB

这比 tracemalloc 的按代码行统计更直观——你一眼就能看出是 dict 太多还是自定义对象太多。我一般先用 tracemalloc 定位行号,再用 pympler 确认对象类型,两步交叉验证。

拓展:用 gc 模块的 DEBUG_LEAK 标志自动发现泄漏

Python 的 gc 模块有一个很少人知道的标志位 DEBUG_LEAK,能自动打印所有无法回收的循环引用:

import gc

gc.set_debug(gc.DEBUG_LEAK)

# 正常跑你的代码...
# 当 GC 检测到无法回收的循环引用时,会自动打印:
# gc: uncollectable 
# gc: uncollectable 
# gc: uncollectable 

输出告诉你哪些对象被卡住了。结合上面提到的 __del__ 陷阱——有 __del__ 的循环引用会被标记为 uncollectable,DEBUG_LEAK 会在 stderr 直接打印出来。

不过 DEBUG_LEAK 在生产环境太吵了(每次 full GC 都打印),我一般在 debug 模式下手动触发一次 full GC 来看:

import gc, sys

gc.set_debug(gc.DEBUG_LEAK | gc.DEBUG_SAVEALL)
gc.collect()  # 强制执行 full GC
if gc.garbage:
    print(f"发现 {len(gc.garbage)} 个不可回收对象:", file=sys.stderr)
    for obj in gc.garbage[:5]:
        print(f"  - {type(obj).__name__}: {str(obj)[:100]}", file=sys.stderr)

gc.garbage 列表会收集所有被 DEBUG_SAVEALL 标记的不可回收对象——你可以直接 inspect 它们的内容,进一步确认泄漏源头。


相关阅读

📖 相关推荐:Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治

🔗 延伸阅读:排查完内存问题后,如果还遇到进程无响应但内存正常的诡异情况,strace 生产环境调试完全指南 能帮你从系统调用层面定位根因。

相关推荐:生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断 — 跨进程视角的完整排查手册,涵盖 dmesg、cgroup、smem 工具链。

免责声明: 本文中的代码示例用于教学目的,在生产环境使用前请根据实际情况调整。内存分析工具(tracemalloc、objgraph)在生产环境开启会有性能开销,建议在 staging 环境复现问题。


Q: tracemalloc 对性能影响大吗?

tracemalloc 开启后大约有 5-15% 的性能开销,取决于分配频率。不建议在生产环境长期开启,推荐做法是:先在 staging 复现泄漏 → 开 tracemalloc 定位根因 → 修复后关闭。如果必须在生产开,用 tracemalloc.start(nframe=1) 只记录 1 帧可以显著降低开销。

Q: 为什么 gc.collect() 后内存还是不降?

Python 的垃圾回收释放的是 Python 对象,但归还给操作系统的内存由 pymalloc(Python 自己的内存分配器)管理。pymalloc 倾向于持有已释放的内存池以备复用,所以 RSS 不会立刻下降。这是正常行为,不是泄漏。判断标准是:RSS 是否持续增长不回落,而非是否一次性下降。

Q: 要不要用 memory_profiler 逐行分析?

memory_profiler 适合新代码开发时的逐行优化,但不适合生产泄漏排查——它太慢了(10x+ 减速),而且展示的是瞬时分配,不是增量趋势。排查泄漏首选 tracemalloc 快照对比。


总结

Python 内存泄漏排查的核心思路:

  • 确认泄漏:RSS 趋势监控,GC 后不回落 = 泄漏
  • 定位代码:tracemalloc 快照对比,直接指向具体行号
  • 查循环引用:objgraph.show_growth() + show_backrefs()
  • 修复 + 预防:全局容器设上限、lru_cache 设 maxsize、thread-local 显式清理、避免 __del__ 循环
  • CI 拦截:pytest fixture 做内存回归测试

手里有这三个工具(tracemalloc、objgraph、gc),绝大多数 Python 内存泄漏都能在一个小时内定位到根因。别再等到凌晨 3 点被 OOM 叫醒了。
📎 推荐阅读:Python性能剖析三件套:py-spy、Scalene、memray实战对比 —— 系统了解Python性能剖析工具链,配合本文的排查经验效果更佳。

📤 分享这篇文章