目录
- 一、问题现象:内存从 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 管不了:
- 对象被长期持有引用——比如放进了全局列表、缓存、闭包变量里,引用计数永远不为 0,GC 永远不回收
- 循环引用且涉及
__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 GB | 320 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 条编码规范
排查一次泄漏要花半天到一天,不如提前规避:
- 不要给大对象参数加
lru_cache——缓存的是参数本身,大对象会被持有。如果非要缓存,只缓存计算结果,用业务 key 而不是对象本身做 key。 - 全局列表/字典要有清理机制——
app_state = {}这种全局容器,一定要有淘汰策略(LRU、TTL、或定期清理)。 - 观察者模式记得注销——
register(handler)之后,对象销毁时要unregister(handler),否则 handler 被事件系统持有,永远不会被回收。 - 闭包别捕获大对象——
def outer(): big = load(); def inner(): return big.size,这个inner会一直持有big。改成传值或用 weakref。
九、环境与版本
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.12.4 | tracemalloc 从 3.4 开始内置 |
| memory_profiler | 0.61 | pip install memory_profiler |
| objgraph | 3.6.2 | pip install objgraph |
| graphviz | 12.0.0 | objgraph 画图依赖 |
| pandas | 2.2.1 | 泄漏对象是 DataFrame |
本文的方法在 Python 3.8-3.13 上都适用。tracemalloc 的 API 在 3.9 之后稳定,低版本可能行为略有差异。
如果这篇文章帮到了你,点个赞让更多遇到同样问题的人看到。有其他 Python 内存排查的技巧,欢迎评论区交流。