Python的内存分析把我坑惨了,原来tracemalloc和memory_profiler的区别这么大

小唐那天接到一个告警:一个后台服务的内存曲线在缓慢爬升,从启动时的两百兆,两天涨到了八百兆。

重启服务,内存回落,但过两天又涨回去。典型的慢速内存泄漏。小唐打开代码,开始凭感觉加检测。他在几个可疑的函数入口和出口分别记录了 psutil.Process().memory_info().rss,对比进出前后的差值。跑了一轮,每个函数的进出差值都在几兆以内,看起来谁都不像罪魁祸首。但服务整体就是在涨。

他又换了另一种方式,在代码里到处插入 sys.getsizeof(),检查大对象的大小。结果更离谱------sys.getsizeof返回的数字和进程实际占用的内存对不上,有些明显在膨胀的数据结构,getsizeof报出来的值几乎没变。

两天过去,内存还在涨,小唐还在猜。

同事看了一眼他的代码,说:"你别用 psutil 和 getsizeof 来回凑了。把 tracemalloc 打开,跑一段时间,取两个快照对比一下,它会告诉你哪一行代码分配的内存变多了。"

小唐照做了:

ini 复制代码
import tracemalloc
tracemalloc.start()

# ... 服务运行一段时间 ...

snapshot1 = tracemalloc.take_snapshot()
# ... 再运行一段时间 ...
snapshot2 = tracemalloc.take_snapshot()

for stat in snapshot2.compare_to(snapshot1, "lineno")[:10]:
    print(stat)

输出里排在第一行的,是一个他从未怀疑过的地方:一个缓存字典,每次请求都往里塞一个键,但从来没有清理逻辑。tracemalloc 精确指出了这个字典是在哪一行被创建的,以及两次快照之间它增长了多少字节。

"我之前用 psutil 看进程总内存,差值淹没在噪音里;用 getsizeof 看对象大小,又看不到引用和分配源头。tracemalloc 直接告诉我是哪一行在漏。"

他问同事:"那 memory_profiler 呢?我搜内存分析的时候也看到它,和 tracemalloc 是一回事吗?"

不是。这两个工具回答的是不同的问题:tracemalloc 回答"Python 在什么地方分配了多少内存",memory_profiler 回答"代码执行到每一行时,进程内存大概是多少"。

先看一个最直观的对比

同一个函数,两种工具的输出形态完全不同。

scss 复制代码
import tracemalloc

def make_data():
    return [list(range(1000)) for _ in range(500)]

tracemalloc.start()
data = make_data()
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:3]:
    print(stat)

tracemalloc 的输出是按代码行聚合的分配统计:

ini 复制代码
/path/script.py:5: size=38.2 MiB, count=501, average=78.1 KiB

它告诉你:第 5 行分配了 38.2 MiB,涉及 501 次分配,平均每次 78.1 KiB。

memory_profiler 用同样的代码,输出是一张逐行内存增量表:

scss 复制代码
Line #    Mem usage    Increment   Line Contents
================================================
     4    45.2 MiB    45.2 MiB   def make_data():
     5    83.1 MiB    37.9 MiB       return [list(range(1000)) for _ in range(500)]

它告诉你:执行第 5 行之前进程用了 45.2 MiB,执行之后用了 83.1 MiB,增量约 37.9 MiB。

两者的数字可能接近,但含义完全不同。 tracemalloc 的 38.2 MiB 是"Python 解释器记录到的、由这一行触发的对象分配总量";memory_profiler 的 37.9 MiB 是"进程 RSS 在这一行前后发生了多大变化"。它们碰巧接近,但在复杂程序里,这两个数字可以差出几倍甚至一个数量级。

核心区别一:测量的是什么

tracemalloc 在 Python 解释器的内存分配器层面挂钩。每当 Python 分配或释放一个内存块,tracemalloc 记录下这次操作的大小和调用栈。它测量的是Python 对象占用的内存。

这意味着:

  • 它能精确告诉你"哪个文件的哪一行分配了多少字节"。
  • 它可以通过两次快照的差异,找到"什么在增长"。
  • 它对纯 C 库通过 malloc 直接分配的内存是看不见 的。一个 numpy 数组的数据缓冲区如果是由底层 C 库直接分配的,tracemalloc 可能只记录到 Python 包装对象本身的开销,而不是整个数组的字节数。官方文档的说法很明确:它追踪的是 Python 分配的内存块。对于 NumPy 等扩展,情况取决于它们是否使用了 Python 的内存分配接口;如果绕过了,tracemalloc 就追踪不到那部分数据。

memory_profiler 的机制完全不同。它通过轮询操作系统(通常借助 psutil),读取整个进程的常驻内存集(RSS) 。它不关心内存是 Python 解释器分配的,还是 C 扩展分配的,还是内存映射文件占用的。它只关心:进程此刻向操作系统要了多少内存。

这个差异带来一个关键后果:memory_profiler 的数字里包含了 Python 已经释放、但还没有归还给操作系统的内存。 Python 的内存分配器为了性能,不会在每次 del 或作用域结束时立刻把内存还给 OS,而是保留在内部空闲池里。memory_profiler 看到的是 RSS,这些"已释放但未归还"的内存仍然算在里面。tracemalloc 追踪的是分配/释放事件本身,已经释放的对象不会再出现在它的统计里。

所以你会看到同一段代码,memory_profiler 报 82MB,tracemalloc 只报 36MB 的情况------这不是谁算错了,是它们测量的对象本来就不是同一个东西。

核心区别二:输出粒度与使用方式

tracemalloc 的核心用法是快照对比。你在一个时间点拍一张快照,过一段时间再拍一张,然后比较两者:

ini 复制代码
snapshot1 = tracemalloc.take_snapshot()
# ... 运行 ...
snapshot2 = tracemalloc.take_snapshot()

for stat in snapshot2.compare_to(snapshot1, "lineno")[:10]:
    print(stat)

输出会按增长量排序,告诉你哪些行在两次快照之间增长了最多。这是排查慢速泄漏最直接的方式:你不需要知道"总共用了多少",只需要知道"什么在变多"。

tracemalloc 也可以直接查看当前所有分配的统计(statistics("lineno")),但它的真正威力在对比。单次快照只能告诉你"现在内存里有什么",两次对比才能告诉你"什么不该多却多了"。

memory_profiler 的核心用法是逐行装饰 。你给一个函数加上 @profile,用 python -m memory_profiler script.py 运行,它会输出每一行执行前后的 RSS 变化:

python 复制代码
from memory_profiler import profile

@profile
def process():
    a = [i for i in range(1000000)]   # 这一行 RSS 涨了多少
    b = sum(a)                         # 这一行 RSS 变了多少
    del a                              # 这一行 RSS 降了多少
    return b

它的输出是一张"行号---当前RSS---增量---代码"的表。你一眼就能看到哪一行让进程内存跳了一大截。

这两种粒度的差别决定了它们的适用场景:tracemalloc 适合"我不知道哪里在漏,让工具告诉我增长点";memory_profiler 适合"我大概知道是哪个函数,想看函数内部哪一行最吃内存"。

核心区别三:开销,差了一个数量级

这是选型时最实际的约束。

tracemalloc 的开销相对可控。它需要在每次分配时记录调用栈,这有成本,但通常在可接受范围内。社区中的经验值大致是:分配密集型负载下 CPU 开销约 15--25%,内存开销可能增加数倍(因为它要保存每次分配的追踪记录),但整体仍被认为"可以在生产或准生产环境临时打开"的量级。Python 官方文档也建议尽可能早地启动追踪(通过 PYTHONTRACEMALLOC 环境变量或 -X tracemalloc),并且可以通过控制保存的栈帧深度来平衡精度和开销。

memory_profiler 的开销要高得多。它需要不停地轮询进程 RSS,而且这种采样本身并不便宜。常见的说法是:内存分析可以让代码慢上 10 到 100 倍 ,具体取决于采样频率和函数调用密度。在 BLAS 密集型或高频调用的代码上,20 倍以上的减速是常见的。这意味着 memory_profiler 基本只能在开发阶段、小规模测试数据上使用,不适合直接挂到生产环境去观察一个已经上线的慢速泄漏。

这个开销差异直接决定了小唐的排查路径:他的服务已经在线上了,内存是慢慢涨的,他不可能让一个 20 倍减速的 profiler 挂在生产进程上跑两天。tracemalloc 的低开销和快照对比能力,才是匹配"在线慢速泄漏"的工具。

坑一:用 memory_profiler 追踪 numpy 的大数组分配

很多人第一反应是用 memory_profiler 看 pandas 或 numpy 操作在哪一行吃了内存。但它有一个根本限制:它只能通过轮询 OS 来观察进程地址空间的大小,而 NumPy 等库并不总是通过 Python 的内存分配接口来申请内存 。这意味着某些情况下,memory_profiler 对 numpy 内部大块分配的归因是不精确的------它能看到 RSS 涨了,但不一定能正确地把增长归到"创建这个数组"的那一行 Python 代码上。

tracemalloc 对纯 C 分配的局限同样存在,但它在 Python 层面的归因更可靠:至少它记录的分配点就是 Python 解释器实际分配包装对象和内部缓冲的位置(如果扩展库使用了 Python 的分配接口)。对于"Python 对象在泄漏"的场景,tracemalloc 的快照对比往往比 memory_profiler 的行级 RSS 增量更精准。

坑二:用 tracemalloc 看"进程总内存"

tracemalloc 的数字通常小于 你从 psutil 或 top 看到的进程 RSS。原因不止一个:它只追踪 Python 分配的部分;它不包含已释放但未归还 OS 的内存;它不包含 C 库直接 malloc 的大块内存。

所以你不能用 tracemalloc 的输出直接回答"我的进程为什么占了 1GB"。它能回答的是"Python 对象分配在什么位置增长了",而不是"进程地址空间的全部构成"。如果你需要理解进程级的总体内存,psutil 的 RSS 是起点;要分解 RSS,tracemalloc 负责 Python 层,剩下的需要其他工具(比如 memory_profiler 的行级采样、objgraph 的引用分析,或者针对 C 扩展的专门工具)。

什么时候用 tracemalloc,什么时候用 memory_profiler?

判断标准很清晰:

怀疑有慢速内存泄漏,需要定位"哪一行代码在持续分配" :用 tracemalloc。快照对比是排查泄漏的标准手段,开销可接受,可以在接近真实运行的环境中运行一段时间。

已经知道是哪个函数,想知道函数内部每一行的内存增量 :用 memory_profiler。逐行表直观,适合在开发阶段对小规模输入做一次性的函数级分析。

生产环境临时诊断 :优先 tracemalloc。它的开销相对可控,而且不需要安装第三方包(标准库自带)。memory_profiler 的 10--100 倍减速通常不适合直接挂生产。

分析 numpy/pandas 的大块内存归属 :两者都有局限。tracemalloc 能给出 Python 分配点的可靠归因,但对 C 层数据缓冲区可能低估;memory_profiler 能看到 RSS 变化,但行级归因在 C 扩展密集的代码上可能失真。通常需要结合两者,或者用专门针对数值栈的工具(如 memory_profiler 配合 --precision 调整采样,或使用 filprofiler 等专门工具)。

需要零依赖、标准库方案 :tracemalloc 是唯一选择。memory_profiler 必须 pip install。

一个正确的排查流程

小唐最终的流程是这样的:

第一步,确认是否真的是 Python 对象在泄漏。 用 psutil 观察进程 RSS 的长期趋势,确认内存单调上升。然后打开 tracemalloc,在服务启动稳定后取快照 A,运行数小时后再取快照 B,用 compare_to 看增长排名。

第二步,如果增长排名指向明确的 Python 代码行,直接审查那一行的逻辑(通常是缓存未清理、全局列表追加、循环引用等),修复后用同样的快照对比验证。

第三步,如果增长排名不明确,或者怀疑是 C 扩展的内存 ,用 memory_profiler 在测试环境对可疑函数做逐行分析,观察 RSS 增量集中在哪一段。注意用 --precision 控制采样精度,避免在高频函数上产生海量噪音。

第四步,如果两者都指向模糊 ,用 objgraph 查看对象引用图,或者用 gc.get_objects() 按类型统计数量增长,缩小范围。

核心原则是:先定位"什么在涨",再决定"用哪个工具深入"。 不要一上来就挂 memory_profiler 到生产上,那样你得到的是减速和噪音,不是答案。

回到小唐的故事

小唐用 tracemalloc 的快照对比定位到了那个只增不删的缓存字典。修复方式很简单:给缓存加一个最大容量,或者用 functools.lru_cache 替代手写的字典。

他后来在文档里写了一条:"tracemalloc 是标准库里的分配追踪器,用快照对比找增长点,适合排查慢速泄漏,开销相对可控。memory_profiler 是第三方逐行 RSS 采样器,适合在开发阶段看一个函数内部哪一行吃内存,开销大(10--100倍),别挂生产。两者测量的不是同一个东西:一个看 Python 分配,一个看进程 RSS。先想清楚你要回答的是'什么在涨'还是'哪一行涨了',再选工具。"

内存分析的工具选择,不是"哪个更准"的问题。tracemalloc 和 memory_profiler 站在两个不同的观测点上:一个从解释器内部看分配事件,一个从操作系统层面看进程足迹。用对了,它们互相补充;用错了,你会在两个对不上的数字之间反复怀疑自己。

相关推荐
Captaincc1 小时前
转译:《软件工程师现在该怎么办》
前端·后端·ai编程
用户407522065031 小时前
Vibing Coding 之次享通小程序
后端
Dumbness2 小时前
不传 day 参数,座位图显示全空:用 Python 标准库手写 Web 框架,请求到业务代码前的 5 个细节
后端·python
AI入职记2 小时前
本地 266MB 模型,追平大模型 API,还快 0.66 秒
后端·ai编程
Swot2 小时前
SvelteKit 踩坑记:+page.js 里忘了 ...data,服务端数据全没了
前端·后端·svelte
Csvn2 小时前
Nginx 配置语法与上下文:一次搞懂继承、变量与用 map 替掉 if
后端·nginx
Arone聊架构2 小时前
把 WeKnora v0.9 + 多站点客服工单塞进 1.9G 小鸡:248MB 内存压测、纯 Go 解析桥与 SQLite 写锁踩坑实录
后端
泡海椒2 小时前
jquick-pdf 饼图(JPie)实战:Java 生成贷款五级分类资产质量报告
后端
学长毕设3 小时前
基于SpringBoot的景点管理系统-附源码
java·spring boot·后端