你有没有遇到过这样的情况:在使用pandas处理完批处理任务后,执行了 del df,甚至调用了 import gc; gc.collect(),但进程的内存占用却没有如预期般减少。
你的第一反应很可能是“pandas有内存泄漏”。但实际上,这不一定就是泄漏。这种现象可能是由引用、内存分配器的正常行为导致的。特别是pandas在升级到3.0版本后,由于Copy-on-Write(写时复制)机制改变了数据共享的方式,以及Arrow支持的dtype让内存行为变得更加难以预测,这类情况变得更为常见。

很多问题的根源在于,大家把操作系统报告的“常驻内存大小”(RSS)当成了Python进程实际正在使用的内存。
RSS由操作系统统计,而Python对象实际占用的内存是另一回事。为了提高效率,内存分配器(包括Python、NumPy、Arrow甚至libc的分配器)往往会预留一大块内存池(arena)以备后用。当你删除一个DataFrame时,Python层面的对象确实被释放了,但RSS却不一定立刻下降。这是因为分配器只是将这块内存标记为“可重用”,并没有立刻将其归还给操作系统。
这也就解释了那个常见的监控现象:看着像是内存泄漏,程序却能稳定运行,吞吐量不受影响。其实内存只是在进程内部被高效地重复利用,RSS保持高位往往是正常行为。
Copy-on-Write 带来的认知陷阱
pandas 3.0 默认启用了Copy-on-Write(CoW)。从用户的角度看,许多索引操作和方法返回的结果都“像是”一个独立的副本,你再也不用担心意外修改原数据了。
这听起来很棒,但这里有一个容易掉入的陷阱:CoW改善的是行为安全性,它与内存何时释放没有直接关系。
在底层实现上,CoW会让多个DataFrame或Series共享同一块底层数据缓冲区,直到其中某个对象发生“写”操作时,才会触发真正的物理复制。换句话说,你以为自己创建了好几个独立的副本,实际上它们可能全都指向同一块内存。只要任何一个派生出的对象还存活,这块内存就不会被释放。
那么,即使你删除了那个“主”DataFrame有用吗?答案是没用。只要某个Series切片还在作用域内,那一大块底层缓冲区就会一直存在。
最常见的“假泄漏”:视图比主对象活得久
import pandas as pd
df = pd.DataFrame({"a": range(10_000_000), "b": range(10_000_000)})
view = df[["a"]] # 看起来很小,但可能让df的数据块保持存活
del df # 你期望内存下降
# 视图view仍然引用着底层数据,所以缓冲区可能不会被释放
这是实际开发中最常碰到的情况。一个看起来人畜无害的视图(view),实际上在底层持有对整个大表数据块的引用。你删掉了 df,但只要 view 没被删除,内存就会一直保留。
那些不是“副本”的“副本”
即便不考虑CoW,pandas本身就有许多行为会导致类似结果:一些操作返回的对象可能共享底层数据块,或内部维护着某些隐式引用。而Python变量只是冰山一角,闭包、缓存字典、全局变量、异步任务,这些都可能悄悄地延长对象的生命周期。
列举几个高频踩坑场景:
-
把中间结果存进列表“方便调试”:
snapshots = []
for chunk in chunks:
df = transform(chunk)
snapshots.append(df) # 你让每一个chunk都保持存活了
每个处理过的chunk都活着,内存自然持续增长。
-
按用户ID或任务ID缓存结果。开发阶段觉得这个设计很聪明,上了生产环境就变成了“内存博物馆”——只进不出。
-
复杂的链式操作。例如,在GroupBy之后接一长串apply操作,中间会产生大量临时对象,如果这个过程发生在循环中,垃圾回收器可能来不及处理,内存就会居高不下。
Arrow buffers:快是真快,“粘”也是真粘
pandas 3.0 默认启用了专用的string dtype。如果安装了PyArrow,字符串列会使用Arrow作为底层存储。这带来了性能和内存效率的提升,但代价是内存行为变得更加复杂。
Arrow有自己独立的缓冲区管理和内存池机制。你可能会观察到一种诡异的现象:pyarrow.total_allocated_bytes()显示Arrow那边已经释放得差不多了,但psutil.Process().memory_info().rss报告的进程常驻内存却一直居高不下。
这不一定是内存泄漏,更可能是内存池化、内存碎片以及延迟释放机制综合作用的结果。
双缓冲区问题
从Parquet文件读取数据是大数据处理中的常见操作。通常先读成Arrow Table,再转换成pandas DataFrame。如果这两个对象都保留在作用域里,就意味着同一份数据在内存中存了两份。
import pyarrow.parquet as pq
table = pq.read_table("big.parquet")
df = table.to_pandas() # 现在你可能同时持有Arrow缓冲区和pandas对象
# 如果table变量仍被引用,内存不会像你预期的那样下降
解决方法很简单:转换完成后,立即删除源对象,例如 del table。
系统性排查检查清单
与其凭直觉猜测,不如按照以下步骤进行系统性排查。
第一步,确认趋势。 首先要区分是内存持续线性增长,还是一次性到达高水位后保持稳定。可以在同一个进程里将任务重复运行两遍。如果第一遍运行时RSS上升,第二遍稳定在某个值,那么多半是分配器在重用内存,并非泄漏。如果RSS随着每次任务处理都线性增长,那确实有对象在不断累积——可能是真正的泄漏,也可能是某个无限增长的缓存。
第二步,关注对象引用。 不要只看内存数字。使用 gc.get_objects() 定期采样,观察特定类型对象(如DataFrame、list)的数量变化趋势。利用 tracemalloc 来追踪Python层面的内存分配模式。借助 objgraph 这样的工具,可以直观地找出哪些类型的对象在增长,以及它们被谁引用着。
第三步,区分内存来源。 需要将Python堆内存和原生(C/C++)缓冲区内存分开看待。Python分配可以用 tracemalloc、pympler 来监控;进程整体的RSS用 psutil 查看;而Arrow分配的内存则用 pyarrow.total_allocated_bytes() 来追踪。如果Python层面内存平稳,但RSS持续上涨,问题很可能出在原生的内存池或内存碎片上。
第四步,排查意外引用。 仔细检查你的代码:DataFrame或Series有没有被不小心存入全局变量、类属性或某个缓存字典?有没有向列表追加数据却忘了清理?lambda表达式或回调函数是否通过闭包捕获了大对象?某些方法返回的对象内部是否持有了对大对象的引用?
第五步,终极方案:进程隔离。 如果实在难以定位和解决,对于处理Arrow/Parquet密集型任务的场景,可以考虑将工作放到独立的子进程(worker)中执行。定期回收或重启worker(例如每处理N个文件就重启一次),让操作系统来充当最终的垃圾收集器,这是一种简单有效的兜底策略。
总结
综上所述,我们遇到的所谓pandas“内存泄漏”,大多数情况下是以下几种情形:
- 视图或切片持有了对底层大缓冲区的引用,导致其无法释放。
- Copy-on-Write机制使得数据共享的时间比我们预想的要长。
- Arrow或其他原生分配器在对象释放后,仍然将内存保留在进程的内存池中。
- 缓存、列表、闭包或长期运行的任务导致对象被意外地长期持有。
真正有效的应对方式不是频繁调用 gc.collect(),而是:
- 缩短对象生命周期,及时删除不再需要的变量。
- 避免无意间保留引用,谨慎使用全局缓存和列表。
- 测量正确的指标,区分RSS与Python堆内存。
- 在必要时,采用进程隔离与回收作为兜底方案。
理解这些底层机制,能帮助我们在使用功能强大的数据工具时,更从容地应对资源管理挑战。如果你在云栈社区遇到了类似问题,不妨用这里的思路先排查一番。