xue微xue微深入地聊一聊PHP session
- 2019 年 11 月 25 日
- 筆記
大家好,我今天打算換一個新的出場方式。所以,我打算從下面倒計時後開始重新打招呼,你們就假裝開頭這句話我沒寫配合一下,謝謝。
你們準備好了嗎,我要重新從頭開始了...
5
4
3
2
1
.
.
.
大家好!

事情是這樣的,昨天晚上我先發了一篇關於API Token的文章,然後又引入了PHP Session,雖然這兩篇文章閱讀量創了歷史新低(我彷彿看到了永強新欣慰的臉龐泛著笑容和淫光),但是還是依舊有些個問題就像屎一樣甩在了我臉上,其中第一個問題角度還是比較刁鑽的,你們感受下:
- 老李,雙11那麼一大坨人訪問PHP商城,PHP session id會不會重複啊? 答:你確定你們用戶量過一千了嗎?
- 老李,為毛我多個控制器訪問同一個session成員,其他頁面會被卡住,你遇到過咩? 答:沒遇到過,就特么你事兒多...告訴用戶讓TA們等等就行了,又不是不能用
- 用什麼方法可以精確控制PHP session過期以及刪除 答:用愛
看到這三個令人絕望的回答,我穿過網線就已經聽到了有人似乎在說:「 老李,你變了... 」,然而我要告訴你並沒有,你李哥辦事你們心裡不清楚么?

第一個問題
這個問題實際上是在考驗session id的生成策略,抽象一下就是【某個空間中生成全局唯一的id】。這個其實沒啥好說的,得去簡單翻一下PHP源碼中關於生成session id這裡的部分了,我手裡常年備著一份PHP 7.2.8的源碼,但我基本沒這麼看過只是有需要的時候翻翻,比如現在。你可以在ext / session / session.c 文件里連蒙帶搜加grep找到相關程式碼,你們感受下(如果我找錯了,記得來打我臉,我專門出一期修正):
/* 這個叫做 php_session_create_id 的函數生成了session id 但是生成的核心函數是調用的 bin_to_readable 函數 */PHPAPI zend_string *php_session_create_id(PS_CREATE_SID_ARGS) /* {{{ */{ // 聲明一個 char 數組,數組長度就是後面兩個常量相加 unsigned char rbuf[PS_MAX_SID_LENGTH + PS_EXTRA_RAND_BYTES]; // zend_string 是zend封裝好的字元串struct,類似於redis里d的 sds // 這裡是聲明一個指向 zend_string 的指針 zend_string *outid; /* Read additional PS_EXTRA_RAND_BYTES just in case CSPRNG is not safe enough */ // 這裡看起來就是如果生成失敗的情況. if (php_random_bytes_throw(rbuf, PS(sid_length) + PS_EXTRA_RAND_BYTES) == FAILURE) { return NULL; } // zend_string_alloc 應該是zend封裝好的為string分配記憶體的函數 // 功能類似於 malloc 函數... outid = zend_string_alloc(PS(sid_length), 0); /* ZSTR_LEN可以獲取zend_string的長度 ZSTR_VAL可以獲取zend_string的值 但誰能告訴我這個PS宏是做什麼用的... ... */ ZSTR_LEN(outid) = bin_to_readable(rbuf, PS(sid_length), ZSTR_VAL(outid), (char)PS(sid_bits_per_character)); return outid;} static char hexconvtab[] = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,-";/* returns a pointer to the byte after the last valid character in out *//* * 注意這個函數里的玩意略風騷。至於你們能不能頂住,我反正頂不住 */static size_t bin_to_readable(unsigned char *in, size_t inlen, char *out, char nbits) /* {{{ */{ unsigned char *p, *q; unsigned short w; size_t len = inlen; int mask; int have; p = (unsigned char *)in; q = (unsigned char *)in + inlen; w = 0; have = 0; mask = (1 << nbits) - 1; while (inlen--) { if (have < nbits) { if (p < q) { // 。。。。。。。。 // 你們有興趣好好研究一下這行,反正我特么不看了,艹 w |= *p++ << have; have += 8; } else { /* consumed everything? */ if (have == 0) break; /* No? We need a final round */ have = nbits; } } /* consume nbits */ // 關鍵行在這裡...out是字元串數組指針 // 這裡就是將hexconvtab數組裡的字元一個一個 // 隨機出來賦值給out指針 *out++ = hexconvtab[w & mask]; w >>= nbits; have -= nbits; } // 這個,沒啥好說的,就是給字元數組最後加上一個,變成字元串 *out = ''; return len;}說句實話,跟我想像中猜測推理的還是不太一樣的,按照我之前理解,PHP的session id生成應該至少有時間戳在其中的,然而真的並沒有...這段充斥著位移運算和位運算的程式碼,真的是...給我整吐了,恕我直言我沒仔細研究。不過既然核心依然是偽隨機出一個偏移量,然後取出偏移量位置上字元,那麼重複還是有一定概率,只是這個概率一定是非常非常非常低,我感覺我在說廢話...
然後是上面那坨程式碼,如果以前哪位分析過,可以簡單給投稿介紹下。我感覺這個C函數可以拿走實現自己的低碰撞率隨機序列了。
第二個問題
這個問題其實還是有點兒意思的,而且我估計注意到的人不多。我給下demo程式碼,你們複製粘貼走感受下:
// 首先在a.php里<?phpsession_start();$_SESSION['name'] = 'wahaha';sleep( 30 ); // 在b.php里 <?phpsession_start();echo $_SESSION['name'];
復現方法就是:先訪問a.php,然後再訪問b.php,這會兒b.php就會被阻塞住一直等到a.php的sleep(30)完事兒後才會運行...這就是傳說中session阻塞問題。這個,咱就不去扒源碼了,首先請找到session文件所在的目錄找到session文件,然後用lsof命令簡單分析你們感受下,如下圖:

上圖中,我一共執行了兩次lsof命令。
第一次執行的時候,PID為29645的fpm進程率先打開session,注意第一排的第二個紅圈裡FD那一列,值為6uW,6表示為當前文件描述符,u表示該文件已經被某進程打開並且正在被讀或者被寫,W表示全文件寫鎖。
第二次執行的時候,PID為29645的fpm進程還在sleep中,而此時又來了一個新的fpm進程,也就是PID為29640的fpm進程,但是由於PID為29645的進程持有當前session文件的文件鎖,所以29640就只能等...
解決方案是什麼?session_write_close()函數了解一下...
然後寫到這裡我突然想到另外一個問題,如果說我們把session扔到redis或者memcache後,這個鎖機制還會生效么?... ...這個我沒試過,真沒試過,不騙你們,真的沒有...
第三個問題
這個如果你用文件方式存session,是真的沒有辦法的,全憑信仰。如果一定要精確,只有說你把session存儲到mem或者redis中的時候,利用人家的key ttl屬性才能實現精準控制。。。實際上,redis key ttl如果是惰性策略,到期後也不會真的被刪除的... ...

