天天看點

Python 插入百萬資料的時間優化與 OOM 問題的解決

我們小組需要從 IT 部門同步客戶資訊和機構資訊到本地,這兩部分資料大概各 400W,總共 800W 的資料量。IT 部門提供兩個存儲過程用于分别擷取這兩部分資料,是以在使用 Python 處理資料時,隻能調用存儲過程将兩部分資料分别一次性全部讀入記憶體再處理。每個存儲過程從 IT 部門的資料庫擷取資料大概需要 20min 的時間,總共 40min 的樣子。

之前一位已經離開公司的同僚已經實作了一個版本的該需求的功能,思路是一個 Python 類的兩個方法分别處理客戶資訊和機構資訊,使用 MySQLdb 子產品拼接一條 sql 語句便插入一條資料。在實際執行過程中的現象是程式大概執行了 4 個小時,然後程序被 Linux 的 OOM killer(Out_Of_Memory killer) 機制給殺掉了。下圖是截取自 Linux 的系統日志,可以看到 anon-rss 系統的實體記憶體是 9G 多些,然而程序使用的 total-vm 虛拟記憶體已經到達了 11G+。

Python 插入百萬資料的時間優化與 OOM 問題的解決

這樣就有兩個問題需要優化處理:

處理時間過長,扣除固定的擷取資料的 40min,竟然 3 個多小時都沒能将 800w 的資料入本地 mysql。

記憶體溢出,記憶體使用過大被 Linux 核心殺死導緻入庫的資料不完整。

通過列印處理時間,可以很容易發現處理時間主要都消耗在資料入本地 mysql 上。原始的入庫邏輯是拼接一條 insert 語句,然後執行一次入庫動作,這樣的效率肯定不高。提升資料庫入庫的效率的一條原則就是以“insert table(XX) values(XX),(XX)…”來代替多次插入單條資料。是以對代碼進行改造,簡單粗暴地将讀入的 400w 資料拼接成一條“insert table(XX) values(XX),(XX)…”的 sql 語句。但是執行的結果就是“Got a packet bigger than 'max_allowed_packet' bytes”,原來 mysql 對輸入 sql 語句長度有限制,最大能夠多長就是有“max_allowed_packet”這個參數決定的。

Python 插入百萬資料的時間優化與 OOM 問題的解決
Python 插入百萬資料的時間優化與 OOM 問題的解決

資料庫才允許 16M 的輸入,而 400w 的資料将就 900M 的長度,肯定處理不了。另外查資料發現“max_allowed_packet”不支援熱修改,也就是或者修改配置檔案然後重新開機 db,或者執行“set global max_allowed_packet=XX”語句,但是要重新登入。這樣對于 python 腳本的執行都不太友好,是以想到的辦法是把 400w 的資料進行拆分,組裝成小于 16M 的 insert 語句執行。思路:使用疊代器對查詢的結果集進行分割處理,傳回“(XXX),(XXX)”形式的 insert 語句後半部分,以便拼接 sql 字元串。

使用方法:

上述代碼以 200000 條記錄為分割,拼接 insert 的 sql 語句入庫。(實驗 30W 條記錄為分割也可以,但是為了防止過長的記錄集中在一起導緻超長,是以選擇 20w 為間隔)。時間優化提升:

Python 插入百萬資料的時間優化與 OOM 問題的解決

“2017-02-08 20:19:17”開始執行,到“2017-02-08 20:35:26”通過存儲過程擷取 4246379 條資料,使用時間 26 分鐘,而“2017-02-08 20:39:34”資料入 mysqldb 完成,隻有了 4 分鐘,從 4 小時到 4 分鐘時間優化提升挺明顯的。

通過觀察 Linux 的 top 指令的輸出以及 python 日志的列印,發現在處理完第一個方法裡面涉及的 400w 資料後,系統記憶體使用了 7G 左右。第二個方法需要處理另外 400w 的資料,但是在第二個方法執行時記憶體沒有減少,是在 7G 的基礎上持續增加,當增加到 11G 左右時,被 OOM Killer 幹掉。

可以看出第一個方法使用的記憶體沒有被釋放掉,這種現象很奇怪,理論上說第一個方法作用域結束後,内部的局部變量的引用計數應該都變為 0 了,應該會被記憶體回收機制清理掉,但是沒有被清理。是以當時懷疑是否 python 記憶體回收機制沒有觸發,于是做了對第一個方法裡面的所有表面能看到的變量進行了“del”,主動減少其引用計數,并調用了“gc.collect()”主動回收記憶體。但是 OOM 依然存在,現在一樣。

由于之前過長的 insert 語句插入 mysql 會報錯,順着這個思路懷疑是否是 python 中拼接 sql 的 string 占用了大量的記憶體沒回收。因為 python 中的 string 使用了常量池技術,建立一個不同内容的 string 就會在常量池中存在一份,800w 記錄的 insert 語句量也是很大的。于是又嘗試了“del”string 變量以及使用 bytearray 可變的“string”代替原來的 string 變量。但 OOM 依然存在,沒辦法隻能檢視 python 運作時的記憶體到底是怎麼樣了。

選擇安裝了 heapy 子產品檢視 python 運作時的記憶體。下載下傳位置:https://pypi.python.org/pypi/guppy/ , 安裝簡單“python setup.py build”“python setup.py install”兩條指令就安裝完成。另外 heapy 對原來的代碼侵入性也比較小,下面三行代碼幾乎就能将 python 運作時的記憶體大概情況看清楚:

介紹得很詳細。

檢視第一個方法執行完記憶體的使用情況,可以看到總共使用了 6883496272bytes 的記憶體,而使用的記憶體中 65%是 dic 類型的資料占用了 4452265816bytes。

Python 插入百萬資料的時間優化與 OOM 問題的解決

這個“dict”提示挺明顯的,在我們的方法裡面沒有什麼大的 dict,這麼大的 dict 隻有通過存儲過程查詢出來的結果集了。順着這個線索檢查發現在一個連接配接對象中竟然留有對結果集的引用,導緻結果集在執行第二個方法前沒有被回收。即:a 對象的 methodA 和 methodB 方法分别處理 400w 資料,這兩個方法中的所有對象引用都已經釋放。同時 a 對象又有一個叫 db_conn 的屬性,這個屬性是被封裝的資料庫連接配接對象,當它查詢結果集時,同時有留有了這個結果集的引用。是以,methodA 執行完後,a.db_conn 依然保持着結果集引用導緻無用記憶體沒有被釋放。a.db_conn 的引用也“del”之後,記憶體使用情況:

Python 插入百萬資料的時間優化與 OOM 問題的解決

可以看到記憶體迅速減小,隻剩下 9M 左右,dict 占用記憶體也不存在。整個 python 腳本能夠順利執行,不再出現 OOM 問題。