Python 程序跑着跑着内存就爆了?用这 3 个工具精准定位泄漏点
2026/8/4 10:46:21 网站建设 项目流程

目录

  • 一、问题现象:内存从 200MB 涨到 4GB
  • 二、为什么 Python 也会内存泄漏
  • 2.1 不是 C 程序才有的问题
  • 2.2 Python 泄漏的三种常见类型
  • 三、工具一:tracemalloc——标准库自带的内存追踪
  • 四、工具二:memory_profiler——逐行内存监控
  • 五、工具三:objgraph——对象引用链可视化
  • 六、实战:定位一个真实泄漏的全过程
  • 七、三种工具对比与选用建议
  • 八、预防比排查更重要:4 条编码规范
  • 九、环境与版本

一、问题现象:内存从 200MB 涨到 4GB

上个月线上一个数据处理服务跑了大概 6 小时后,内存从启动时的 200MB 涨到了 4GB,然后被 OOM Killer 干掉了。重启之后一切正常,但跑几个小时又炸。

日志里没有任何报错,业务逻辑看起来也没问题。这种"慢慢涨"的内存问题最难搞——你不知道是哪一行代码在漏,也不知道是哪个对象没释放。

排查了两天,最后发现是一个lru_cache装饰器惹的祸。函数入参是一个很大的 DataFrame 对象,缓存一直留着不释放,跑了几个小时就积累到几个 G。

这篇文章记录一下排查过程和用到的三个工具。如果你也遇到 Python 程序内存只涨不降的问题,按这个流程走一遍基本能定位到。

二、为什么 Python 也会内存泄漏

2.1 不是 C 程序才有的问题

很多人觉得 Python 有 GC(垃圾回收),不会内存泄漏。这是个误解。

Python 的 GC 主要靠引用计数,加上分代回收处理循环引用。但有两种情况 GC 管不了:

  1. 对象被长期持有引用——比如放进了全局列表、缓存、闭包变量里,引用计数永远不为 0,GC 永远不回收
  2. 循环引用且涉及__del__——Python 3.4 之前循环引用带__del__的对象 GC 处理不了,3.4 之后虽然能处理但效率低

2.2 Python 泄漏的三种常见类型

类型典型场景特征
缓存型泄漏lru_cache、自建 dict 缓存、functools.cache内存线性增长,跟数据量成正比
引用型泄漏全局列表追加不清理、闭包捕获大对象、观察者模式未注销内存阶梯式增长,跟事件次数成正比
循环引用对象互相引用,且有__del__方法内存增长缓慢,分代回收能处理一部分但不彻底

三、工具一:tracemalloc——标准库自带的内存追踪

这是 Python 3.4+ 标准库自带的,不用装任何东西。原理是 hook 掉内存分配器,记录每次分配的调用栈。

优点:零依赖,能精确到"哪一行代码分配了多少内存"。

缺点:有性能开销(大约 10-30%),生产环境别一直开着。

基础用法

import tracemalloc # 启动追踪,保留 25 帧调用栈 tracemalloc.start(25) # ... 你的业务代码 ... run_data_pipeline() # 拍快照 snapshot = tracemalloc.take_snapshot() # 打印分配内存最多的 Top 10 top_stats = snapshot.statistics('lineno') for stat in top_stats[:10]: print(stat)

输出长这样:

/app/pipeline.py:42: size=856 MB, count=1200000, average=731 B # 这行就是罪魁祸首 /app/cache.py:18: size=124 MB, count=8500, average=15.0 KB /app/utils.py:67: size=45 MB, count=312000, average=150 B ...

一眼就能看到pipeline.py 第 42 行分配了 856MB,这就是排查起点。

对比两个快照找增量

更实用的用法是拍两个快照,对比增量——这能过滤掉启动时的正常分配,只看"增长"的部分:

import tracemalloc tracemalloc.start(25) # 第一个快照(基准) snapshot1 = tracemalloc.take_snapshot() # 跑一段业务 for _ in range(1000): process_batch() # 第二个快照 snapshot2 = tracemalloc.take_snapshot() # 对比差异,按增量排序 top_stats = snapshot2.compare_to(snapshot1, 'lineno') for stat in top_stats[:10]: print(stat)

输出会显示每行代码的内存增量:

/app/pipeline.py:42: size=812 MB (+812 MB), count=+1198000, average=731 B # +812 MB 说明这行在跑业务期间又分配了这么多 /app/cache.py:18: size=+98 MB (+98 MB), count=+6700, average=15.0 KB
实战技巧:在长时间运行的服务里,每隔 10 分钟拍一个快照存下来,内存报警时拿最后两个快照对比,增量最大的那行就是泄漏点。

四、工具二:memory_profiler——逐行内存监控

tracemalloc 告诉你"哪行分配得多",但不告诉你"哪行导致内存不释放"。这时候用memory_profiler

pip install memory_profiler

用法

from memory_profiler import profile @profile def process_data(): df = load_large_csv("data.csv") # Line 2 result = transform(df) # Line 3 cache_result(result) # Line 4 return summarize(result) # Line 5 process_data()

运行后输出:

Line # Mem usage Increment Occurrences Line Contents ============================================================= 2 85.2 MB 85.2 MB 1 df = load_large_csv("data.csv") 3 180.5 MB 95.3 MB 1 result = transform(df) 4 180.5 MB 0.0 MB 1 cache_result(result) 5 180.5 MB 0.0 MB 1 return summarize(result)

Increment列:transform(df)涨了 95MB 是正常的(生成了新数据),但cache_result(result)之后内存没降——说明result被缓存持有了,函数返回后也不会释放。

memory_profiler 会显著拖慢程序(10-50 倍),只在排查时用,别上生产。

五、工具三:objgraph——对象引用链可视化

前两个工具告诉你"哪行代码在分配内存",但如果原因是"某个对象被意外持有引用",你需要objgraph来看引用链。

pip install objgraph # 可视化还需要 graphviz brew install graphviz # macOS # apt install graphviz # Ubuntu

找到哪些对象在增长

import objgraph import gc # 先强制 GC,排除正常对象 gc.collect() # 看当前各类对象数量 Top 10 objgraph.show_most_common_types(limit=10) # 输出: # dict 45213 # list 23109 # DataFrame 856 # ← 这个异常,不应该有这么多 # function 12034 # tuple 9876

如果你发现某个自定义类的实例数远超预期,那就是泄漏对象。

画出引用链,找到"谁在持有"

import objgraph # 找到一个泄漏的 DataFrame 实例 leak_obj = [o for o in gc.get_objects() if isinstance(o, pd.DataFrame)][0] # 画出"谁引用了这个对象"的链路 objgraph.show_backrefs( [leak_obj], max_depth=5, filename='leak_chain.png' )

生成的图会显示这个 DataFrame 被谁引用,一路追到根。比如你可能会看到:

DataFrame ↑ dict (lru_cache 的内部存储) ↑ function (被 @lru_cache 装饰的函数) ↑ module (全局模块级别)

这就定位到了:lru_cache持有了这个 DataFrame,永远不释放。

六、实战:定位一个真实泄漏的全过程

把三个工具串起来用,下面是排查那个线上泄漏的完整流程:

步骤 1:用 tracemalloc 确认有泄漏

# 每 10 分钟拍一个快照 import tracemalloc, time, threading tracemalloc.start(10) snapshots = [] def periodic_snapshot(): while True: snapshots.append(tracemalloc.take_snapshot()) time.sleep(600) threading.Thread(target=periodic_snapshot, daemon=True).start()

跑 1 小时后,对比第 1 个和第 6 个快照,发现cache.py:18增长了 3.2GB。

步骤 2:用 memory_profiler 确认是哪个函数

cache.py的函数加上@profile,跑一次,发现cache_result()调用后内存不降。

步骤 3:用 objgraph 找到引用源头

import objgraph, gc, functools gc.collect() # 发现 DataFrame 实例有 8000+ 个 objgraph.show_most_common_types() # 找一个看引用链 leak_df = [o for o in gc.get_objects() if isinstance(o, pd.DataFrame)][0] objgraph.show_backrefs([leak_df], max_depth=8, filename='leak.png')

图里清清楚楚:DataFrame → lru_cache 的字典 → 被装饰的函数。

步骤 4:看代码确认

# cache.py 第 18 行 @functools.lru_cache(maxsize=1000) def get_features(key, df): # df 是个大 DataFrame,被缓存了 return df.groupby('user_id').sum()

问题很蠢:lru_cache把整个df作为缓存 key 的一部分存下来了,1000 个就是 1000 个大 DataFrame。改成只缓存计算结果:

# 修复版本 _feature_cache = {} def get_features(key, df): if key in _feature_cache: return _feature_cache[key] result = df.groupby('user_id').sum() _feature_cache[key] = result return result # df 不被持有,函数返回后 GC 回收

修复效果

指标修复前修复后
6 小时后内存4.1 GB320 MB
24 小时后内存OOM 崩溃340 MB(稳定)
GC 频率每 5 分钟一次全量 GC正常分代回收

七、三种工具对比与选用建议

工具定位能力性能开销适用场景依赖
tracemalloc定位到代码行10-30%第一步排查,找增量最大的代码无(标准库)
memory_profiler定位到函数内每行10-50 倍确认某个函数内部哪步在涨pip install
objgraph定位到引用链低(只在调用时)找"谁在持有对象不释放"pip + graphviz

推荐排查顺序:tracemalloc 找到嫌疑代码行 → memory_profiler 确认哪步不释放 → objgraph 画出引用链找到根因。

八、预防比排查更重要:4 条编码规范

排查一次泄漏要花半天到一天,不如提前规避:

  1. 不要给大对象参数加lru_cache——缓存的是参数本身,大对象会被持有。如果非要缓存,只缓存计算结果,用业务 key 而不是对象本身做 key。
  2. 全局列表/字典要有清理机制——app_state = {}这种全局容器,一定要有淘汰策略(LRU、TTL、或定期清理)。
  3. 观察者模式记得注销——register(handler)之后,对象销毁时要unregister(handler),否则 handler 被事件系统持有,永远不会被回收。
  4. 闭包别捕获大对象——def outer(): big = load(); def inner(): return big.size,这个inner会一直持有big。改成传值或用 weakref。

九、环境与版本

组件版本说明
Python3.12.4tracemalloc 从 3.4 开始内置
memory_profiler0.61pip install memory_profiler
objgraph3.6.2pip install objgraph
graphviz12.0.0objgraph 画图依赖
pandas2.2.1泄漏对象是 DataFrame
本文的方法在 Python 3.8-3.13 上都适用。tracemalloc 的 API 在 3.9 之后稳定,低版本可能行为略有差异。

如果这篇文章帮到了你,点个赞让更多遇到同样问题的人看到。有其他 Python 内存排查的技巧,欢迎评论区交流。

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

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

立即咨询