目录一、问题现象内存从 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 管不了对象被长期持有引用——比如放进了全局列表、缓存、闭包变量里引用计数永远不为 0GC 永远不回收循环引用且涉及__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: size856 MB, count1200000, average731 B # 这行就是罪魁祸首 /app/cache.py:18: size124 MB, count8500, average15.0 KB /app/utils.py:67: size45 MB, count312000, average150 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: size812 MB (812 MB), count1198000, average731 B # 812 MB 说明这行在跑业务期间又分配了这么多 /app/cache.py:18: size98 MB (98 MB), count6700, average15.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(limit10) # 输出 # 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_depth5, filenameleak_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(targetperiodic_snapshot, daemonTrue).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_depth8, filenameleak.png)图里清清楚楚DataFrame → lru_cache 的字典 → 被装饰的函数。步骤 4看代码确认# cache.py 第 18 行 functools.lru_cache(maxsize1000) 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 MB24 小时后内存OOM 崩溃340 MB稳定GC 频率每 5 分钟一次全量 GC正常分代回收七、三种工具对比与选用建议工具定位能力性能开销适用场景依赖tracemalloc定位到代码行10-30%第一步排查找增量最大的代码无标准库memory_profiler定位到函数内每行10-50 倍确认某个函数内部哪步在涨pip installobjgraph定位到引用链低只在调用时找谁在持有对象不释放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。九、环境与版本组件版本说明Python3.12.4tracemalloc 从 3.4 开始内置memory_profiler0.61pip install memory_profilerobjgraph3.6.2pip install objgraphgraphviz12.0.0objgraph 画图依赖pandas2.2.1泄漏对象是 DataFrame本文的方法在 Python 3.8-3.13 上都适用。tracemalloc 的 API 在 3.9 之后稳定低版本可能行为略有差异。如果这篇文章帮到了你点个赞让更多遇到同样问题的人看到。有其他 Python 内存排查的技巧欢迎评论区交流。