PHP 記憶體洩漏分析定位

翁建勇發表於2018-03-13

轉載地址:https://mp.weixin.qq.com/s/98D_VtkFEM5bZsu9cazggg?

目錄

  • 場景一 程式運算元據過大
  • 場景二 程式操作大資料時產生拷貝
  • 場景三 配置不合理系統資源耗盡
  • 場景四 無用的資料未及時釋放
  • 深入瞭解
  • php記憶體管理
  • php-fpm記憶體洩露問題
  • 常駐程式記憶體洩露問題

前言

本文開始撰寫時我負責的專案需要用 php 開發一個通過 Socket 與服務端建立長連線後持續實時上報資料的常駐程式程式,在程式業務功能開發聯調完畢後實際執行傳送大量資料後發現記憶體增長非常迅速,在很短的時間內達到了 php 預設可用記憶體上限 128M ,並報錯:

Fatal error: Allowed memory size of X bytes exhausted (tried to allocate Y bytes)

我第一反應是記憶體洩露了,但是不知道在哪。第二反應是無用的變數應該用完就 unset 掉,修改完畢後問題依舊。經過了幾番周折終於解決了問題。就決定好好把類似情況整理一下,遂有此文,與諸君共勉。

觀察 PHP 程式記憶體使用情況

php 提提供了兩個方法來獲取當前程式的記憶體使用情況。

  • memory_get_usage(),這個函式的作用是獲取目前PHP指令碼所用的記憶體大小。
  • memory_get_peak_usage(),這個函式的作用返回當前指令碼到目前位置所佔用的記憶體峰值,這樣就可能獲取到目前的指令碼的記憶體需求情況。

int memory_get_usage ([ bool $real_usage = false ] )
int memory_get_peak_usage ([ bool $real_usage = false ] )


場景一:程式運算元據過大

情景還原:一次性讀取超過php可用記憶體上限的資料導致記憶體耗盡

<?php
ini_set('memory_limit', '128M');
$string = str_pad('1', 128 * 1024 * 1024);
?>
複製程式碼

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 134217729 bytes) in /Users/zouyi/php-oom/bigfile.php on line 3

這是告訴我們程式執行時試圖分配新記憶體時由於達到了PHP允許分配的記憶體上限而丟擲致命錯誤,無法繼續執行了,在 java 開發中一般稱之為 OOM ( Out Of Memory ) 。

PHP 配置記憶體上限是在 php.ini 中設定 memory_limit,PHP 5.2 以前這個預設值是 8M,PHP 5.2 的預設值是16M,在這之後的版本預設值都是128M。

問題現象:特定資料處理時可復現,做任何IO操作都有可能遇到此類問題,比如:一次 mysql 查詢返回大量資料、一次把大檔案讀取程式序等。

解決方法:

能用錢解決的問題都不是問題,如果程式要讀大檔案的機會不是很多,且上限可預期,那麼通過 ini_set('memory_limit', '1G'); 來設定一個更大的值或者 memory_limit=-1。記憶體管夠的話讓程式一直跑也可以。

如果程式需要考慮在小記憶體機器上也能正常使用,那就需要優化程式了。如下,程式碼複雜了很多。

<?php
//php7 以下版本通過 composer 
//引入 paragonie/random_compat ,為了方便來生成一個隨機名稱的臨時檔案
require "vendor/autoload.php";

ini_set('memory_limit', '128M');
//生成臨時檔案存放大字串
$fileName = 'tmp'.bin2hex(random_bytes(5)).'.txt';
touch($fileName);
for ( $i = 0; $i < 128; $i++ ) {
    $string = str_pad('1', 1 * 1024 * 1024);
    file_put_contents($fileName, $string, FILE_APPEND);
}
$handle = fopen($fileName, "r");
for ( $i = 0; $i <= filesize($fileName) / 1 * 1024 * 1024; $i++ )
{
   //do something
   $string = fread($handle, 1 * 1024 * 1024);
}

fclose($handle);
unlink($fileName);
複製程式碼

場景二:程式操作大資料時產生拷貝

情景還原:執行過程中對大變數進行了複製,導致記憶體不夠用。

<?php
ini_set("memory_limit",'1M');

$string = str_pad('1', 1* 750 *1024);
$string2 = $string;
$string2 .= '1';
複製程式碼
Fatal error: Allowed memory size of 1048576 bytes exhausted (tried to allocate 768001 bytes) in /Users/zouyi/php-oom/unset.php on line 8

Call Stack:
    0.0004     235440   1. {main}() /Users/zouyi/php-oom/unset.php:0
zend_mm_heap corrupted
複製程式碼

問題現象:區域性程式碼執行過程中佔用記憶體翻倍。

問題分析

php 是寫時複製(Copy On Write),也就是說,當新變數被賦值時記憶體不發生變化,直到新變數的內容被操作時才會產生複製。

解決方法

及早釋放無用變數,或者以引用的形式操作原始資料。

<?php
ini_set("memory_limit",'1M');

$string = str_pad('1', 1* 750 *1024);
$string2 = $string;
unset($string);
$string2 .= '1';
複製程式碼
<?php
ini_set("memory_limit",'1M');
$string = str_pad('1', 1* 750 *1024);
$string2 = &$string;
$string2 .= '1';
unset($string2, $string);
複製程式碼

場景三:配置不合理系統資源耗盡

情景還原:因配置不合理導致記憶體不夠用,2G 記憶體機器上設定最大可以啟動 100 個 php-fpm 子程式,但實際啟動了 50 個 php-fpm 子程式後無法再啟動更多程式

問題現象:線上業務請求量小的時候不出現問題,請求量一旦很大後部分請求就會執行失敗

問題分析

一般為了安全方面考慮, php 限制表單請求的最大可提交的數量及大小等引數,post_max_size、max_file_uploads、upload_max_filesize、max_input_vars、max_input_nesting_level。

假設頻寬足夠,使用者頻繁的提交post_max_size = 8M資料到服務端,nginx 轉發給 php-fpm 處理,那麼每個 php-fpm 子程式除了自身佔用的記憶體外,即使什麼都不做也有可能多佔用 8M 記憶體。

解決方法

合理設定 post_max_size、max_file_uploads、upload_max_filesize、max_input_vars、max_input_nesting_level 等引數並調優 php-fpm 相關引數。

php.ini

$ php -i |grep memory
memory_limit => 1024M => 1024M //php指令碼執行最大可使用記憶體
$php -i |grep max
max_execution_time => 0 => 0 //最大執行時間,指令碼預設為0不限制,web請求預設30s
max_file_uploads => 20 => 20 //一個表單裡最大上傳檔案數量
max_input_nesting_level => 64 => 64 //一個表單裡資料最大陣列深度層數
max_input_time => -1 => -1 //php從接收請求開始處理資料後的超時時間
max_input_vars => 1000 => 1000 //一個表單(包括get、post、cookie的所有資料)最多提交1000個欄位
post_max_size => 8M => 8M //一次post請求最多提交8M資料
upload_max_filesize => 2M => 2M //一個可上傳的檔案最大不超過2M

複製程式碼

如果上傳設定不合理那麼出現大量記憶體被佔用的情況也不奇怪,比如有些內網場景下需要 post 超大字串 post_max_size=200M,那麼當從表單提交了 200M 資料到服務端, php 就會分配 200M 記憶體給這條資料,直到請求處理完畢釋放記憶體。

php-fpm.conf

pm = dynamic //僅dynamic模式下以下引數生效
pm.max_children = 10 //最大子程式數
pm.start_servers = 3 //啟動時啟動子程式數
pm.min_spare_servers = 2 //最小空閒程式數,不夠了啟動更多程式
pm.max_spare_servers = 5 //最大空閒程式數,超過了結束一些程式
pm.max_requests = 500 //最大請求數,注意這個引數是一個php-fpm如果處理了500個請求後會自己重啟一下,可以避免一些三方擴充套件的記憶體洩露問題
複製程式碼


一個 php-fpm 程式按 30MB 記憶體算,50 個 php-fpm 程式就需要 1500MB 記憶體,這裡需要簡單估算一下在負載最重的情況下所有 php-fpm 程式都啟動後是否會把系統記憶體耗盡。


場景四:無用的資料未及時釋放

情景還原:這種問題從程式邏輯上不是問題,但是無用的資料大量佔用記憶體導致資源不夠用,應該有針對性的做程式碼優化。

Laravel開發中用於監聽資料庫操作時有如下程式碼:

 DB::listen(function ($query) {
            // $query->sql
            // $query->bindings
            // $query->time
        });
複製程式碼

啟用資料庫監聽後,每當有 SQL 執行時會 new 一個 QueryExecuted 物件並傳入匿名函式以便後續操作,對於執行完畢就結束程式釋放資源的 php 程式來說沒有什麼問題,而如果是一個常駐程式的程式,程式每執行一條 SQL 記憶體中就會增加一個 QueryExecuted 物件,程式不結束記憶體就會始終增長。

問題現象:程式執行期間記憶體逐漸增長,程式結束後記憶體正常釋放。

問題分析

此類問題不易察覺,定位困難,尤其是有些框架封裝好的方法,要明確其適用場景。

解決方法

本例中要通過DB::listen方法獲取所有執行的 SQL 語句記錄並寫入日誌,但此方法存在記憶體洩露問題,在開發環境下無所謂,在生產環境下則應停用,改用其他途徑獲取執行的 SQL 語句並寫日誌。


深入瞭解

  1. 名詞解釋
  • 記憶體洩漏(Memory Leak):是程式在管理記憶體分配過程中未能正確的釋放不再使用的記憶體導致資源被大量佔用的一種問題。在物件導向程式設計時,造成記憶體洩露的原因常常是物件在記憶體中儲存但是執行中的程式碼卻無法訪問他。由於產生類似問題的情況很多,所以只能從原始碼上入手分析定位並解決。

  • 垃圾回收(Garbage Collection,簡稱GC):是一種自動記憶體管理的形式,GC程式檢查並處理程式中那些已經分配出去但卻不再被物件使用的記憶體。最早的GC是1959年前後John McCarthy發明的,用來簡化在Lisp中手動控制記憶體管理。 PHP的核心中已自帶記憶體管理的功能,一般應用場景下,不易出現記憶體洩露。

  • 追蹤法(Tracing):從某個根物件開始追蹤,檢查哪些物件可訪問,那麼其他的(不可訪問)就是垃圾。

  • 引用計數法(reference count):每個物件都一個數字用來標示被引用的次數。引用次數為0的可以回收。當對一個物件的引用建立時他的引用計數就會增加,引用銷燬時計數減少。引用計數法可以保證物件一旦不被引用時第一時間銷燬。但是引用計數有一些缺陷:1.迴圈引用,2.引用計數需要申請更多記憶體,3.對速度有影響,4.需要保證原子性,5.不是實時的

2. php 記憶體管理

    在 PHP 5.2 以前, PHP 使用引用計數(Reference count)來做資源管理, 當一個 zval 的引用計數為 0 的時候, 它就會被釋放.。

    雖然存在迴圈引用(Cycle reference), 但這樣的設計對於開發 Web 指令碼來說, 沒什麼問題, 因為 Web 指令碼的特點和它追求的目標就是執行時間短, 不會長期執行。

    對於迴圈引用造成的資源洩露, 會在請求結束時釋放掉. 也就是說, 請求結束時釋放資源, 是一種部補救措施( backup ).

    然而, 隨著 PHP 被越來越多的人使用, 就有很多人在一些後臺指令碼使用 PHP , 這些指令碼的特點是長期執行, 如果存在迴圈引用, 導致引用計數無法及時釋放不用的資源, 則這個指令碼最終會記憶體耗盡退出.

    所以在 PHP 5.3 以後, 我們引入了 GC .
複製程式碼

—— 摘自鳥哥部落格文章《請手動釋放你的資源》


在 PHP 5.3 以後引入了同步週期回收演算法(Concurrent Cycle Collection)來處理記憶體洩露問題,代價是對效能有一定影響,不過一般 web 指令碼應用程式影響很小。

PHP 的垃圾回收機制是預設開啟的,php.ini 可以設定 zend.enable_gc=0 來關閉。也能通過分別呼叫 gc_enable() 和 gc_disable() 函式來開啟和關閉垃圾回收機制。

雖然垃圾回收讓 php 開發者在記憶體管理上無需擔心了,但也有極端的反例: php 界著名的包管理工具 composer 曾因加入一行 gc_disable();效能得到極大提升。

引用計數基本知識(http://php.net/manual/zh/features.gc.refcounting-basics.php)

回收週期(Collecting Cycles)(http://docs.php.net/manual/zh/features.gc.collecting-cycles.php)

上面兩個連結是php官方手冊中的記憶體管理、GC相關知識講解,圖文並茂,這裡不再贅述。

3. php-fpm 記憶體洩露問題

在一臺常見的 nginx + php-fpm 的伺服器上:

nginx 伺服器 fork 出 n 個子程式(worker), php-fpm 管理器 fork 出 n 個子程式。

當有使用者請求, nginx 的一個 worker 接收請求,並將請求拋到 socket 中。

php-fpm 空閒的子程式監聽到 socket 中有請求,接收並處理請求。
複製程式碼

一個 php-fpm 的生命週期大致是這樣的:

模組初始化(MINIT)-> 請求初始化(RINIT)-> 請求處理 ->
請求結束(RSHUTDOWN) -> 請求初始化(RINIT)-> 請求處理 ->
請求結束(RSHUTDOWN)……. 請求初始化(RINIT)-> 請求處理 ->
請求結束(RSHUTDOWN)-> 模組關閉(MSHUTDOWN)。
複製程式碼

在請求初始化(RINIT)-> 請求處理 ->請求結束(RSHUTDOWN)這個“請求處理”過程是: php 讀取相應的 php檔案,對其進行詞法分析,生成 opcode , zend 虛擬機器執行 opcode 。

php 在每次請求結束後自動釋放記憶體,有效避免了常見場景下記憶體洩露的問題,然而實際環境中因某些擴充套件的記憶體管理沒有做好或者 php 程式碼中出現迴圈引用導致未能正常釋放不用的資源。

在 php-fpm 配置檔案中,將pm.max_requests這個引數設定小一點。這個引數的含義是:一個 php-fpm 子程式最多處理pm.max_requests個使用者請求後,就會被銷燬。當一個 php-fpm 程式被銷燬後,它所佔用的所有記憶體都會被回收。

總結

遇到了記憶體洩露時先觀察是程式本身記憶體不足還是外部資源導致,然後搞清楚程式執行中用到了哪些資源:寫入磁碟日誌、連線資料庫 SQL 查詢、傳送 Curl 請求、 Socket 通訊等, I/O 操作必然會用到記憶體,如果這些地方都沒有發生明顯的記憶體洩露,檢查哪裡處理大量資料沒有及時釋放資源,如果是 php 5.3 以下版本還需考慮迴圈引用的問題。

多瞭解一些 Linux 下的分析輔助工具,解決問題時可以事半功倍。

最後宣傳一下穿雲團隊今年最新開源的應用透明鏈路追蹤工具 Molten:https://github.com/chuan-yun/Molten

相關文章