你有沒有遇到過,在使用pandas的時候批處理任務跑完了,del df 執行了,甚至還使用了 import gc; gc.collect() 但是進程內存確沒有減少。
我們首先就會想到這可能是"pandas 有內存泄漏",其實這不一定就是泄漏。可能是引用、分配器的正常行為。而且在pandas 3.0 之后這類情況更多了,因為Copy-on-Write 改變了數據共享的方式,Arrow 支持的 dtype 讓內存行為變得更難預測。
![]()
RSS 不是"正在使用的內存"
很多人把 RSS 當成實際內存占用來看,這是問題的根源。
RSS 是操作系統報告的常駐內存大小,而Python 對象實際需要多少內存是另一回事。分配器為了提高效率會預留一大塊內存池(arena)以備后用。刪掉一個 DataFrame,Python 層面的對象確實釋放了但 RSS 不一定下降,因為分配器(Python 的、NumPy 的、Arrow 的、libc 的)只是把這塊內存標記為"可重用",并沒有還給操作系統。
這就解釋了一個常見現象:監控面板上看著像在泄漏,但程序跑得好好的,吞吐量很穩定。內存在進程內部被重復利用,RSS 高位運行其實是正常的。
Copy-on-Write 帶來的認知陷阱
pandas 3.0 默認啟用了 Copy-on-Write。從用戶角度看索引操作和很多方法都"像是"返回了副本,不用再擔心意外修改原數據。聽起來很好,但這里有個容易忽略的點: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"]] # looks small, but can keep df's blocks alive
del df # you expect memory drop
# view still references the underlying data, so buffers can remain
這是實際使用的時候碰到最多的情況。一個看起來人畜無害的 view,實際上在底層持有整個大表的數據塊引用。你刪掉了 df,但 view 沒刪內存就這么留著了。
那些不是"副本"的"副本"
即便不考慮 CoW,pandas 本身就有很多這類行為:操作返回的對象可能共享底層數據塊,或者內部維護著某些引用。而Python 變量只是冰山一角。閉包、緩存字典、全局變量、異步任務,這些任何一個都可能悄悄地讓對象存活下去。
幾個高頻踩坑場景:
把中間結果存進列表"方便調試":
snapshots = []
for chunk in chunks:
df = transform(chunk)
snapshots.append(df) # you keep every chunk alive
每個 chunk 都活著,內存持續增長。
按用戶 ID 或任務 ID 緩存結果,開發階段覺得挺聰明,上了生產變成了內存博物館——只進不出。
還有一種是 GroupBy 加上一長串 apply 鏈式調用,中間產生大量臨時對象,GC 來不及回收,尤其在循環里更明顯。
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() # now you may hold Arrow buffers + pandas objects
# If table stays referenced, memory won't drop as you expect
解決方法也很簡單,轉換完就 del 掉源對象。
排查檢查清單
與其憑直覺猜測,不如系統地排查。
第一步,確認到底是持續增長還是一次性的高水位。同一個進程里把任務跑兩遍,如果第一遍 RSS 上升、第二遍穩定,那多半是分配器在重用內存,不是泄漏。如果 RSS 隨著工作量線性增長,那確實有東西在不斷積累——可能是真正的泄漏,也可能是某個無限增長的緩存。
第二步,關注對象引用而不是內存數字。用 gc.get_objects() 采樣觀察對象數量變化趨勢,用 tracemalloc 追蹤 Python 層面的分配模式,用 objgraph 找出哪些類型在增長、被誰持有。
第三步,區分 Python 堆和原生緩沖區。Python 分配可以用 tracemalloc 和 pympler 看,進程 RSS 用 psutil,Arrow 的內存用 pyarrow.total_allocated_bytes()。如果 Python 層面很平穩但 RSS 在漲,問題多半出在原生內存池或碎片上。
第四步,排查意外引用。DataFrame 或 Series 有沒有被存進全局變量、類屬性或者某個緩存字典?有沒有往列表里追加數據忘了清理?lambda 或回調函數有沒有閉包了 df?有沒有返回的對象內部持有大對象的引用?
第五步,實在搞不定就用進程隔離。跑 Arrow/Parquet 密集型任務時,把工作放到 worker 進程里,定期回收 worker(比如每處理 N 個文件就重啟一次),讓操作系統來當垃圾收集器。
總結
pandas 的"內存泄漏"多數時候是下面幾種情況:視圖或切片持有大緩沖區的引用導致無法釋放;Copy-on-Write 機制讓數據共享的時間比預想的長;Arrow 或其他原生分配器即使對象釋放后仍保留內存池;緩存、列表、閉包、長期任務導致對象被意外持有。
真正有效的應對方式不是 gc.collect(),而是:縮短對象生命周期,避免無意間保留引用,測量正確的指標,必要時用進程回收來兜底。
https://avoid.overfit.cn/post/44a0a3f2e4544cbe9307e9afe262779b
by Nikulsinh Rajput
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.