下文:
關(guān)于php的共享內(nèi)存的使用和研究之外部存儲(chǔ)
關(guān)于php的共享內(nèi)存的使用和研究之深入剖析swoole table
最近遇到一個(gè)場(chǎng)景,服務(wù)尋址的時(shí)候,需要請(qǐng)求遠(yuǎn)程的服務(wù),獲取一批可用的ip和端口地址及其權(quán)重。根據(jù)權(quán)重和隨機(jī)算法選擇最合適的一個(gè)服務(wù)地址,進(jìn)行請(qǐng)求。由于服務(wù)地址在短時(shí)間之內(nèi)不會(huì)發(fā)生變化,因此為了避免無限制的進(jìn)行尋址的請(qǐng)求,有必要將地址緩存至本地。
對(duì)于php而言,說到用戶數(shù)據(jù)緩存本地,第一反應(yīng)出來的就是APC。但是APC首先被創(chuàng)建出來是給php做內(nèi)部緩存的,其次才是提供給用戶態(tài)使用的。根據(jù)laruence在博客的說法,opcache出現(xiàn)了之后,對(duì)zend編譯的opcode做了緩存,實(shí)際上解決了apc被創(chuàng)建出來想要解決的問題。因此現(xiàn)在APC已經(jīng)處于不再更新維護(hù)的狀態(tài)了。
對(duì)于想使用opcache,又要使用用戶態(tài)的APC的同學(xué),就需要額外的配置,同時(shí)性能上也會(huì)比原來的APC要差,差不多相當(dāng)于本機(jī)的memcache。這顯然就無法達(dá)到本機(jī)內(nèi)存訪問的效率了,因此需要尋求其他的解決方案。
php的共享內(nèi)存API
隨后我就想到了使用php的共享內(nèi)存API,反正只是緩存非常少的路由信息,加在一起不超過1k,盡管是多讀多寫的場(chǎng)景,但是覆蓋了也沒關(guān)系,出于這種出發(fā)點(diǎn),我就開始了對(duì)php的共享內(nèi)存API的研究。
php中操作共享內(nèi)存的方式一共有兩組:
- System V IPC
- 編譯增加 --enable-sysvshm
- Shared Memory
- --enable-shmop
先來看一個(gè)shmop的例子:
<?php
// 從系統(tǒng)獲取一個(gè)共享內(nèi)存的id
$key = ftok(__FILE__, 'test');
$size = 1024;
// 打開1024字節(jié)的共享內(nèi)存(如果不存在則申請(qǐng))
$shm_h = @shmop_open($key, 'c', 0644, $size);
if($shm_h === false) {
echo "shmop open failed";
exit;
}
// 讀取共享內(nèi)存中的數(shù)據(jù)
$data = shmop_read($shm_h, 0, $size);
// 對(duì)讀取的數(shù)據(jù)進(jìn)行反序列化
$data = unserialize($data);
//如果沒有數(shù)據(jù)則寫入
if(empty($data)) {
echo "there is no data";
$data = "imdonkey";
//所有寫入的數(shù)據(jù),都必須提前序列化
$write_size = shmop_write($shm_h, serialize($data), 0);
if($write_size === false) echo "shmop write failed!";
}
//如果有,顯示出來,之后刪掉
else {
echo "shared memory data: ";
print_r($data);
shmop_delete($shm_h);
}
shmop_close($shm_h);
?>
使用shmop擴(kuò)展,必須要注意數(shù)據(jù)的大小,以及讀寫時(shí)候的偏移量。同時(shí),不管你寫入的是什么數(shù)據(jù)類型,都必須進(jìn)行序列化和反序列化。
再看一下SysV的例子:
<?php
// 從系統(tǒng)獲取一個(gè)共享內(nèi)存的id
$shm_key = ftok(__FILE__, 'test');
// 獲取此共享內(nèi)存資源的操作句柄
$memsize = 1024;
$shm_h = shm_attach($shm_key, $memsize, 0644);
if($shm_h === false) {
echo "shmop open failed";
exit;
}
// 獲取共享內(nèi)存中key=222時(shí)的內(nèi)容
$var_key = 222;
$data = @shm_get_var($shm_h, $var_key);
if(empty($data)) {
$data = ['test'=>'here'];
echo "there is no data, insert $data.\n";
// 如果數(shù)據(jù)不存在,寫入數(shù)據(jù),可以是任意類型,無需初始化
shm_put_var($shm_h, $var_key, $data);
} else {
// 否則,輸出數(shù)據(jù),并清理相關(guān)內(nèi)存
echo "find data: $data\n";
shm_remove_var($shm_h, $var_key);
}
// 斷開資源的鏈接
shm_detach($shm_h);
?>
原理上來講并無不同,只是SysV做了更多的封裝,讓你使用起來更加方便一些。不用自己控制偏移量,也不用進(jìn)行序列化和反序列化。同時(shí)對(duì)于每個(gè)數(shù)據(jù),都設(shè)置了對(duì)應(yīng)的var_key, 這樣在同一個(gè)區(qū)域可以保存多個(gè)數(shù)據(jù),而無需再次申請(qǐng)另一片共享內(nèi)存。
業(yè)務(wù)中的使用
在使用兩者的時(shí)候,都要注意對(duì)數(shù)據(jù)大小的估算。否則很容易出現(xiàn)共享內(nèi)存溢出的情況。而我在使用的時(shí)候,充分評(píng)估了要存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu)的大小,我需要存儲(chǔ)的內(nèi)容是:
ip(15個(gè)字節(jié)以內(nèi))+port(8字節(jié)以內(nèi))+timestamp(15字節(jié)以內(nèi))+分隔符(3字節(jié))=41字節(jié)
假設(shè)我調(diào)用100個(gè)后端服務(wù)。那么最高需要存儲(chǔ)的路由信息就是4.1k大小。
出于這種考慮,我申請(qǐng)了1M的內(nèi)存,覺得應(yīng)該是夠夠的了。就這么悠哉哉的在線上跑了一個(gè)星期左右,有天沒事到線上看了下php的錯(cuò)誤日志,結(jié)果一臉懵逼:

什么情況,調(diào)用的后端服務(wù)一共才5個(gè),共享內(nèi)存這么快就寫滿了??經(jīng)過一個(gè)初步的判斷之后,我得出的結(jié)論是:sysV的接口能力太差,對(duì)于shareKey沒有做去重處理,而是每次都寫入了新的key,這樣就導(dǎo)致了共享內(nèi)存的寫入指針盡管是相同的shareKey,但是卻不斷的后移,最終導(dǎo)致共享內(nèi)存被寫爆,而尋址的請(qǐng)求全部都打到了尋址服務(wù),還好它比較健壯,也有短時(shí)的緩存,才沒有產(chǎn)生運(yùn)營(yíng)事故。
在得出了這么個(gè)結(jié)論之后,我修改了我的代碼,在每次完成對(duì)shareKey內(nèi)容的獲取之后,增加了一行
shm_remove_var($shareKey)
同時(shí)寫了一個(gè)腳本,把原有的共享內(nèi)存id對(duì)應(yīng)的內(nèi)容清空,經(jīng)過手工處理十臺(tái)機(jī)器之后,再全量替換一把代碼,打卡下班,感覺自己棒棒噠。
沒想到,這才是悲劇的開始。就在當(dāng)周的周六,吃著火鍋,突然就有一臺(tái)線上機(jī)器罷工了。機(jī)器服務(wù)狂core不止,打開系統(tǒng)配置的core文件輸出之后,迅速占滿磁盤,無奈之下,先讓運(yùn)維把機(jī)器摘掉,再進(jìn)一步的分析。其他機(jī)器也出現(xiàn)了不同程度的core,線上失敗率直線上升。

再把機(jī)器摘下來之后,看了一眼core文件,就發(fā)現(xiàn),哎呀,闖禍了。

趕快恢復(fù)到?jīng)]有remove的版本,至少還能撐一個(gè)星期,不至于程序core掉。
踩坑與解決
接下來開始仔細(xì)分析源碼,發(fā)現(xiàn)sysV的擴(kuò)展中,remove_var實(shí)現(xiàn)如下:
PHP_FUNCTION(shm_remove_var)
{
zval *shm_id;
long shm_key, shm_varpos;
sysvshm_shm *shm_list_ptr;
// 讀取輸入?yún)?shù)
if (SUCCESS != zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "rl", &shm_id, &shm_key)) {
return;
}
SHM_FETCH_RESOURCE(shm_list_ptr, shm_id);
// 檢查sharekey在共享內(nèi)存中是否存在
shm_varpos = php_check_shm_data((shm_list_ptr->ptr), shm_key);
// 如果不存在,返回錯(cuò)誤
if (shm_varpos < 0) {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "variable key %ld doesn't exist", shm_key);
RETURN_FALSE;
}
// 如果存在,刪除共享內(nèi)存
php_remove_shm_data((shm_list_ptr->ptr), shm_varpos);
RETURN_TRUE;
}
咋一看沒啥問題,但是深入看一下php_check_shm_data,發(fā)現(xiàn)有問題:
// ptr為整個(gè)共享內(nèi)存區(qū)塊的頭指針
static long php_check_shm_data(sysvshm_chunk_head *ptr, long key)
{
long pos;
sysvshm_chunk *shm_var;
// 從頭開始尋找
pos = ptr->start;
for (;;) {
// 找到最后了返回
if (pos >= ptr->end) {
return -1;
}
// 向前進(jìn)一個(gè)內(nèi)存區(qū)塊,由當(dāng)前區(qū)塊的next指針決定
shm_var = (sysvshm_chunk*) ((char *) ptr + pos);
if (shm_var->key == key) {
return pos;
}
pos += shm_var->next;
if (shm_var->next <= 0 || pos < ptr->start) {
return -1;
}
}
return -1;
}
這個(gè)根本就是線程不安全的版本額,在高并發(fā)的場(chǎng)景下,非常有可能出現(xiàn),對(duì)一個(gè)shareKey內(nèi)是否存在數(shù)據(jù)的錯(cuò)誤判斷,根據(jù)swoole的多進(jìn)程模型,進(jìn)程A進(jìn)行尋址,查看共享內(nèi)存,發(fā)現(xiàn)shareKey對(duì)應(yīng)的區(qū)塊無數(shù)據(jù),所以他準(zhǔn)備進(jìn)行寫入,同時(shí)進(jìn)程B之前已經(jīng)檢查了shareKey數(shù)據(jù),發(fā)現(xiàn)shareKey數(shù)據(jù)已經(jīng)過期,執(zhí)行了remove操作。這時(shí)候進(jìn)程A再想去寫入的時(shí)候,就會(huì)發(fā)生不可避免的segmentation fault。
發(fā)現(xiàn)了這個(gè)問題之后,反過來去想當(dāng)時(shí)為什么共享內(nèi)存會(huì)被寫滿,也是一樣的問題,都怪php_check_shm_data對(duì)key的判斷線程不安全,所以不可避免的,高并發(fā)下一直會(huì)用重復(fù)的key不停的向前寫入。當(dāng)時(shí)申請(qǐng)了 12M的內(nèi)存, 每秒500請(qǐng)求,swoole開了24個(gè)進(jìn)程,假設(shè)碰撞概率是1/(24*500)=1/12000。每次寫入的大小是4k*3(四個(gè)服務(wù)尋址),程序設(shè)計(jì)的是五分鐘進(jìn)行一次put。
那么12M共享內(nèi)存被寫滿的時(shí)間應(yīng)該是12M/12k/(60min/5min)/24h = 3.6天左右?;旧现荒軗蝹€(gè)這么久。
所以呢,解決方向有兩個(gè):
- 實(shí)現(xiàn)一個(gè)有鎖的共享內(nèi)存API版本
- 另辟蹊徑,使用別的本地內(nèi)存存儲(chǔ)方案
權(quán)衡之下,準(zhǔn)備采取第二種做法,預(yù)知后事如何,且看下回分解~