伯樂線上注:本文來自文章作者 @CFC4N 的投稿(原文連結)。PS:文中圖片均用了縮圖,點選可以檢視原圖。
在我們的一款WebGame的生產環境中,一次無意的strace抓包時,發現了php與mysql大量通訊的資料。這種情況,在遊戲伺服器剛啟動時,是正常的,但如果是執行一段時間之後,出現大量SELECT的SQL查詢,絕對是有問題的,而且,所操作的資料庫並不是配置庫,那意味著,我們程式設計師的程式出現了違規的操作。具體結果大約如下:
如上圖所示,php持續接收讀取程式內描述符為3的響應包資料,描述符為3的為php與mysql建立的TCP通訊連結,這點也可以從313行的SELECT語句來確認。(原始資料丟失了,我模仿了一條。所以是配置庫的SQL語句)
這是什麼程式,想實現什麼邏輯?為何要取這麼多資料?
跟著這裡的SELECT的sql語句,我定位到了相應的程式段:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/* ** 業務邏輯的程式碼 */ public function SItem($roleId,$baseId) { //... // ############寫出下面這種程式碼的人都得死.################## $this->dbrRole->select('*'); $this->dbrRole->from('role_items'); $this->dbrRole->where('role_id',$roleId); $this->dbrRole->where('baseId',$baseId); $result = $this->dbrRole->get()->row(); //看上去,這裡好像正常,我們都以為框架會給我們只取一條。 //... } |
我們從程式碼上來看,好像明白程式設計師想根據對應的role_id到role_items表裡取一條想符合的資料,所以,他呼叫了row方法,來取一條。看上去,這裡好像正常,我們都以為框架會給我們只取一條。但實際上,框架是如何處理的呢?
我們來看下框架的對應row方法的實現過程。對了,我們是CodeIgniter框架的一個較老的版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
/* ** 框架中,DB drive中,row相關方法的程式碼 ** */ public function row($n = 0,$type = 'array'){ if(!is_numeric($n)){ if(! is_array($this->_rowData)){ $this->_rowData = $this->rowArray(0); } if(isset($this->_rowData[$n])){ return $this->_rowData[$n]; } $n = 0; } return ($type == 'object') ? $this->rowObject($n) : $this->rowArray($n); } //繼續跟進rowArray方法 public function rowArray($n = 0){ $result = $this->resultArray(); if(count($result) == 0){ return $result; } if($n != $this->_current && isset($result[$n])){ $this->_current = $n; } return $result[$this->_current]; } //繼續跟進resultArray方法 ###這個方法是重點### public function resultArray(){ if(count($this->resultArray) > 0){ return $this->resultArray; } if(false === $this->resulter || 0 == $this->recordCount()){ return array(); } $this->_dataSeek(0); while($row = $this->_fetchAssoc()){ $this->resultArray[] = $row; //###########這個陣列每次都增加_fetchAssoc()結果的記憶體大小數量######################### } return $this->resultArray; } //繼續跟進_fetchAssoc方法 /* ** 對應driver的_fetchAssoc方法的程式碼 */ protected function _fetchAssoc(){ return mysql_fetch_assoc($this->resulter); } |
我們可以看到CodeIgniter框架的resultArray方法使用mysql(我們的php呼叫mysql的api用的是mysql函式,有點繞,後面解釋)的mysql_fetch_assoc函式對緩衝區的資料進行遍歷轉換。將所有緩衝區的資料全部複製給$this->resultArray屬性,再判斷row方法中所需要的key的結果是否存在,再與返回的。
也就是說,框架層並沒有只從mysql server(潛意識上的mysql server)那邊取一條給我們呼叫者,而是取了所有結果,再返回一條。(先別噴,後面解釋) 當然,CI這種做法,也不是錯。但我覺得有更好的改進方法。
這個問題,我們組的dietoad (徵婚) 發現了這個問題,並給了修復方案。有些同學認為,這是程式設計師的錯,程式設計師的SELECT語句沒有加limit來限制條數。這我絕對贊同,而且,覺得寫出這種程式碼的人都得死。
- 業務層:為這種業務需求的SQL語句加上limit限制
- 框架層:框架對於這種需求,自動控制,發現這種情況,直接返回1條
對於解決方案1,我寫了一個正則,匹配select()方法被呼叫之後,row()方法被呼叫之前,中間沒有使用limit()方法的所有程式碼,結果,發現量並不小。後來,我們決定兩種方案同時實施,防止第二種出現漏掉的情況。
dietoad給出如下改進:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
/* ** //改進為當_rowData不存在時,從_rowData的數量開始取,取小於$n條記錄,避免 上面 resultArray方法中從緩衝區取所有資料,複製雙倍資料,佔用記憶體的情況 */ public function row ($n = 0, $type = 'array') { if(isset($this->_rowData[$n])) { return $this->_rowData[$n]; } if (! is_numeric($n)) { return $this->rowObject($n); } $ln=count($this->_rowData); //繼續上次位置 while($ln++<=$n&&$r=$this->_fetchAssoc()) { $this->_rowData[]=$r; } //需要幾條就讀幾條 //防止記錄集為空報warning return isset($this->_rowData[$n])?$this->_rowData[$n]:array(); } |
在今年的4月末,鄙人寫過另一篇關於CodeIgniter框架的設計缺陷問題,給我們遊戲專案帶來較大的影響,後來提交到github issues,並沒得到回覆,想了想,雖然官方的2.1.3版本中,也存在這個小問題。不過我覺得,這就不提交了,或許,我們的做法也符合他們的設計初衷。不過,我們還是在我們的專案中改進了。
如此改進之後,我們使用php的memory_get_usage()函式觀察前後兩個row()方法的結果時,果然發現記憶體使用情況有較大改善(改善幅度取決於SELECT的返回資料量)。
似乎,到這裡就應該結束了,問題就這麼被發現,被解決了。
但,我總覺得少了些什麼呢?當我再次strace抓包時,發現仍然存在大量的資料通訊,就像文章開頭的那副截圖一模一樣。然而,這又是什麼原因呢?
我順手寫了個記憶體佔用的測試程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
$db = mysql_connect('192.168.xx.xx','xxxx','xxxx'); $sql = 'SELECT * from items'; mysql_select_db('jv01',$db); echo 'SELECT_DB: ',convert(memory_get_usage()),"\n"; //619.26 kb $r = mysql_query($sql,$db); echo 'QUERY_SQL: ',convert(memory_get_usage()),"\n"; //619.98 kb ###什麼?查詢完之後,記憶體大小居然只增加了不到1k?我那個表可是幾十M的資料啊 //sleep(50); // hold住程式,別銷燬,留著看當前程式的記憶體分配1 $arr = array(); while ($rs = mysql_fetch_assoc($r)) { $arr[]=$rs; } echo 'FETCH_RS: ',convert(memory_get_usage()),"\n"; //27.11 mb ###什麼?剛剛不是隻增加了1k嗎?這裡的遍歷的結果集怎麼突增幾十M啊?尼瑪這到底是什麼情況? unset($arr); echo 'UNSET: ',convert(memory_get_usage()),"\n"; //620.12 kb #### $arr z佔了 幾十M mysql_free_result($r); echo 'FREE_R: ',convert(memory_get_usage()),"\n"; //620 kb ### 結果集居然只有0.12 k?這不扯淡麼? 莫非。。。莫非緩衝區的資料php統計不到?莫非不是呼叫zend 記憶體申請函式來申請記憶體的? //sleep(50); // hold住程式,別銷燬,留著看當前程式的記憶體分配2 function convert($size) { $unit=array('b','kb','mb','gb','tb','pb'); return @round($size/pow(1024,($i=floor(log($size,1024)))),2).' '.$unit[$i]; } /* //返回結果如下: SELECT_DB: 619.26 kb QUERY_SQL: 619.98 kb FETCH_RS: 27.11 mb UNSET: 620.12 kb FREE_R: 620 kb */ |
看到結果時,我不禁XX一緊,什麼?這你媽什麼情況?查詢完之後,記憶體大小居然只增加了不到1k?我那個表可是幾十M的資料啊?遍歷結果集之後,怎麼突增幾十M啊?尼瑪這到底是什麼情況?strace返回的大量資料到底存在哪的?算不算php程式申請的?
後來,我再次執行如上程式,再定時用free、/proc/PID/maps 之類系統工具,檢視系統的記憶體使用情況,確認了當前程式的記憶體佔用確實存在。那麼可能的情況就是memory_get_usage()函式並沒有獲取到mysql_query之後的記憶體佔用情況。由於比較懷疑,末學跟進了memory_get_usage()函式的原始碼,該函式直接交給zend_memory_usage函式處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
//這個是php的memory_get_usage()函式的 相關程式碼,見Zend_alloc.c line:2640 ZEND_API size_t zend_memory_usage(int real_usage TSRMLS_DC) { if (real_usage) { return AG(mm_heap)->real_size; } else { size_t usage = AG(mm_heap)->size; #if ZEND_MM_CACHE usage -= AG(mm_heap)->cached; #endif return usage; } } //這個是Zend記憶體分配函式的程式碼 //Zend_alloc.c line:2418 ZEND_API void *_emalloc(size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC) { TSRMLS_FETCH(); if (UNEXPECTED(!AG(mm_heap)->use_zend_alloc)) { return AG(mm_heap)->_malloc(size); } return _zend_mm_alloc_int(AG(mm_heap), size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC); } |
php的記憶體管理 (中文地址:php-zend的記憶體管理中文版)這塊,對於末學來說,太複雜了,只是稍微看懂直接 返回了mm_heap結構體的real_size/size的值。(兩篇都是鳥哥寫的,中文的地址也就是鳥哥部落格最近一直打不開,抽風得厲害)
那mysql_query的結果集,存在哪的呢?如何申請記憶體的,莫非不是呼叫zend的_emalloc記憶體分配函式的?這得先明確mysql客戶端類庫問題,也就是我們使用哪個類庫?libmysql還是mysqlnd,通過檢視編譯引數,發現(我的虛擬機器)是libmysql,編譯引數是這樣的:
1 2 3 4 |
./configure' '--prefix=/services/php_5.3.19' '--with-config-file-path=/services/php_5.3.19/etc' '--with-pdo-mysql=/usr/bin/mysql_config' '--with-mysql=/usr/bin/mysql_config' '--with-mysqli=/usr/bin/mysql_config' '--enable-bcmath' '--enable-fpm //生產伺服器如下: ./configure' '--prefix=/services/php' '--with-config-file-path=/services/php/etc' '--with-pdo-mysql=mysqlnd' '--with-mysql=mysqlnd' '--with-mysqli=mysqlnd' '--enable-bcmath' '--enable-fpm |
有點亂:
mysql、mysqli、pdo-mysql、libmysql、mysqlnd 好多名詞,有點亂,沒關係,一張圖讓你清晰起來:
mysqlnd跟libmysql一樣,都是直接與mysql server通訊的驅動類庫。 而php程式設計師使用的mysql、mysqli、pdo-mysql是面向程式設計師呼叫的API介面。。
繼續:
libmysql類庫是MYSQL官方提供的類庫,每次PHP編譯都是指定引數來確定mysqlmysqlipdo-mysql所使用的連線驅動是哪個。並且,前提你的得先裝好mysql的客戶端(libmysql類庫),以確保有libmysqlclient.so ,
末學抱著試試看的心態,心情沉重的開啟了libmysql的原始碼,終於在Safemalloc.c的line:120附近找到類似libmysqlclient申請記憶體的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
//libmysql客戶端庫Safemalloc.c line:120 /* Allocate some memory. */ void *_mymalloc(size_t size, const char *filename, uint lineno, myf MyFlags) { ... /* Test for memory limit overrun. If compiled with DBUG, test for error injection. Described in my_sys.h. */ if ((size + sf_malloc_cur_memory > sf_malloc_mem_limit) IF_DBUG(|| my_malloc_error_inject)) { IF_DBUG(if (my_malloc_error_inject) errno= ENOMEM; my_malloc_error_inject= 0); irem= 0; } else { /* Allocate the physical memory */ irem= (struct st_irem *) malloc (ALIGN_SIZE(sizeof(struct st_irem)) + sf_malloc_prehunc + size + /* size requested */ 4 + /* overrun mark */ sf_malloc_endhunc); //系統的記憶體分配函式 malloc } ... } //下面是mysqlnd驅動的程式碼,為了省的再弄一個程式碼高亮的區塊,特意放一起了. // Mysqlnd客戶端庫Mysqlnd_alloc.c line:77 /* {{{ _mysqlnd_emalloc */ void * _mysqlnd_emalloc(size_t size MYSQLND_MEM_D) { ... ret = _emalloc(REAL_SIZE(size) ZEND_FILE_LINE_CC ZEND_FILE_LINE_ORIG_RELAY_CC); //呼叫zend的記憶體分配函式 _emalloc ... if (ret && collect_memory_statistics) { *(size_t *) ret = size; MYSQLND_INC_GLOBAL_STATISTIC_W_VALUE2(STAT_MEM_EMALLOC_COUNT, 1, STAT_MEM_EMALLOC_AMOUNT, size); } TRACE_ALLOC_RETURN(FAKE_PTR(ret)); } /* }}} */ |
也就是說,libmysql沒有呼叫zend的內分分配函式_emalloc,就沒法將記憶體的使用情況記錄到mm_heap結構體中,也就是PHP的memory_get_usage()函式統計不到的原因。好了,雖然末學不是很能讀懂原始碼,但似乎符合問題發生的現象了。
好像,末學又想到一個問題,如果libmysql儲存的結果集所佔用的記憶體的話,那麼php的配置檔案中的memory_limit也就無法限制他的記憶體使用情況了?也就是說,如果我們很理想的根據系統剩餘記憶體分配了若干個php-fpm程式來啟動執行的話,如果發生這情況,將會出現記憶體不夠用的情況,libmysql佔用的記憶體沒有被統計到。。。結果是顯然的,果然限制不了它。
那mysqlnd可以嗎?mysqlnd的記憶體分配是使用zend的_emalloc函式嗎?是的,沒錯mysqlnd 是我們的大救星。Mysqlnd_alloc.c line:77裡程式碼中,明確看到了。各位SA在編譯php時,一定要使用mysqlnd作為php連線mysql server的類庫驅動哦。
Mysqlnd的好處可不止這麼一點點啊。
記憶體還是記憶體:
末學苦於薄弱的英語,冒死翻過GFW,終於在“萬惡的資本主義”國家的網站上找到了這些資料,mysqlnd將比libmysql節省將近40%的記憶體佔用哦。如圖:
,而且,memory_limit引數可以管的了它哦…
速度,速度:
國外友人給了一份測試結果,比較的API是mysqlmysqli,比較的驅動是libmysqlmysqlnd
- 使用mysqlnd驅動的extmysqli介面速度最快
- 使用libmysql驅動的extmysqli介面慢了6%
- 使用libmysql驅動的extmysql介面慢了3%
並且給出了mysqli在兩個驅動下的執行時間:
還有,還有哦…
mysqlnd還支援各種debug除錯哦,各種strace跟蹤哦…還支援….算了,你自己下載mysqlnd相比libmysql的優點看吧。末學可是搜了很久才搜到這個ppt。
推薦:
1,再推薦一片關於mysqlnd持久連結的文章:PHP 5.3: Persistent Connections with ext/mysqli
2,你的應用的cache的儲存是程式設計師自己根據DB資料結果,查詢條件,hash取值,存到memcache中的嗎?想不想嘗試下自動實現的?mysqlnd的外掛可以嘗試下:PHP: Client side caching for all MySQL extensions ,支援memcached,apc,sqlit哦。
回到開始:
有人說,當php呼叫mysql_query時,mysql server會返回本次查詢的結果到php所在伺服器的緩衝區中。當程式呼叫mysql_fetch_assoc/mysql_fetch_row/mysql_fetch_array/mysql_fetch_object之類函式時,都是呼叫php_mysql_fetch_hash函式去緩衝區讀取資料的。我要是用mysql_unbuffered_query()函式呢?讓結果集不直接在查詢之後返回,當呼叫mysql_fetch_x函式時,再拉回來呢? 這…你讓mysql server的緩衝區來儲存這些資料麼?你以為客戶端就你自己麼?其他的客戶端也要連的啊,尤其是php,如果用mysql_unbuffered_query()函式,他們都會將結果集放到mysql server的緩衝區的,mysql server的記憶體佔用豈不是成本增長…你想讓DBA砍死你?
手冊上也說了,mysql_unbuffered_query返回的結果集之上不能使用 mysql_num_rows() 和 mysql_data_seek()。我幾乎沒用過這個函式,這算非主流的函式麼?
有人說我們方案1節省了從結果集取出,遍歷賦值給新陣列的記憶體佔用,並沒有減少網路資料的傳輸。沒錯,你說的對,一點都沒錯。也就是說,我們的解決方案2只能稍微緩解這種問題的負面效果,徹底解決的話,還得程式層上去正確的呼叫,取回該要的資料。(其實,如果使用mysqlnd驅動的話,我們的改動基本沒有優勢,節省不了記憶體。mysqlnd時,結果集的讀取只是引用緩衝區的資料。libmysql的話,有明顯效果。)我更加鑑定的贊同的那句話“寫出這種程式碼的人都得死”。不使用mysqlnd作為php連線驅動的SA都是耍流氓。
結論:
api推薦mysqli,驅動推薦mysqlnd.
溫故而知新?
在回家之後,末學刷了幾局《保衛蘿蔔》,除了幾個需要養成才解鎖的關卡之外,均可恥的”全清”+”金蘿蔔”,玩著玩著,突然想起一件事情,就是末學在去年寫過一篇部落格php5.3.8中編譯pdo_mysql的艱難歷程中,之前運維的編譯引數中,mysqli使用的是mysqlnd,而mysql使用的是libmysql,後來再裝的pdo-mysql也使用了libmysql了….3個api,指定兩個連線驅動,莫非上次的錯誤是因為這個?而末學的編譯引數雖然巧合的解決了問題,當初並沒有理解真正的原因?下週驗證一下… [2012/12/15 23:31更新]
知恥而後勇?
今天剛寫完這篇學習筆記後,回家玩遊戲時,想起鳥哥曾提到過mysqlnd,再次回去看看,看鳥哥如何講解mysqlnd的,我理解的是否有誤,才發現鳥哥這裡已經有了個Ulf Wendel部落格的連結,末學卻在網路搜尋N久才找到那篇文章,同時,發現其blog上有大量mysqlnd的文章,還暗自偷笑,以為自己發現了大金礦,現在才發現….哎,慚愧慚愧…[2012/12/15 23:58更新]
末學對於本次學習經歷中遇到的知識點,有大量的盲區,將會在以後的時間裡,慢慢摸索熟悉,也歡迎各位前輩的點撥。