公司是做棋牌游戲的。前段時間接到一個后台人工鑒定並處理通牌作弊玩家的需求,其中需要根據幾個玩家的游戲ID查詢並計算他們在某段時間內彼此之間玩牌輸贏次數和輸贏總額。
牌局數據是存儲在日志中心的,他們把牌局數據分成兩個表來存儲,一個表存儲牌局概況數據,例如牌局時間、牌局ID、桌子ID、用戶ID等信息,另一個表則存儲每一個牌局的詳情數據,例如,牌局有多少玩家參與,荷官在哪一輪發了什么牌,玩家每一輪都有什么動作等等。要想計算出幾個玩家在某段時間之內玩牌輸贏次數和輸贏總額,就需要知道每一個牌局的詳情數據,所以需要針對每一個玩家的游戲ID,先查詢第一個表,查出所有牌局概況數據列表,然后遍歷這個列表,根據每個牌局的牌局ID、桌子ID,從第二個表中查詢每個牌局的詳情數據,所有玩家的所有牌局詳情數據都查詢完成之后再進行統計。
日志中心的同學給出了查詢以上兩個表的接口,其中牌局詳情的查詢接口一次只能查詢一個牌局的數據(和他們使用的數據表設計有關)。剛開始我的做法是在js代碼中遍歷所有給出的玩家ID,先查詢出每個玩家的牌局列表,然后使用第二層循環來調用接口請求每一個牌局的詳情數據,但這樣做的問題是,有些用戶在某段時間內的牌局數量是很大的,盡管控制了查詢時間段的最大范圍,但還是出現了一個用戶幾千個牌局的情況,這就意味着瀏覽器需要幾乎在同一時間內對同一個域名的服務器發出幾千個請求,而瀏覽器是基於域名進行並發控制的,超過限制數量的請求會被阻塞,阻塞嚴重的時候經常導致頁面變成空白,好長時間才恢復,得到查詢結果。這樣的體驗顯然是不行的。
那怎么辦呢?在老大的指導下,幾經思慮,決定采用PHP多線程結合socket.io來完成這個任務。整體思路是這樣的:首先js向PHP發起數據查詢請求,PHP收到請求之后不是直接進行數據查詢,而是在后台掛載一個進程去處理請求,然后返回一個確認狀態值給js,這時js請求暫時結束了。這樣做好處有二:其一,js請求的PHP接口是php-fpm運行的,使用php-fpm來fork多進程不太穩定,而使用php比較穩定;其二,可以避免數據查詢過程時間太長導致超時。
掛載進程代碼示例:
<?php
$par = ['startTime' => '', 'endTime' => '', 'mids' => $mid, ...];//牌局查詢參數
$pKey = 'plog_proccess';//傳給命令行的參數,作為進程標識,便於查詢統計當前進程數量
$php = '/usr/local/php/bin/php';//php執行文件路徑
$file = '/www/query.php';//牌局查詢腳本文件
$cmd = $php.' '.$file.' '.$pKey.' '.base64_encode(serialize($par)).' > /dev/null 2>&1 &';//命令
system($cmd);//執行命令,掛載后台進程執行查詢
接下來就要在進程運行的PHP腳本/www/query.php中進行數據查詢了。首先遍歷每一個玩家ID,查出每個玩家的所有牌局列表,然后遍歷每個玩家的牌局列表,fork多個子進程進行每個牌局詳情數據的查詢了,一個子進程負責查詢一個牌局的詳情數據,並將數據寫入文件中,代碼示例如下:(注意:以下代碼只是基本代碼框架,無法直接運行)
<?php
$pKey = $argv[1];
$par = unserialize(base64_decode($argv[2]));
$mids = $par['mids'];
$max_pnum = 100;//最大子進程數量,避免搶占了過多的資源
for($mids as $mid) {//遍歷查詢各個用戶的牌局數據
$list = ...;//這里進行當前用戶牌局列表數據查詢
$num = count($list);//牌局總數
$count = 0;//已有多少個牌局在查詢
while(true) {//fork多個子進程查詢各個牌局的詳情數據
$s = "ps aux|awk '" . '/query.php/ && /' . $pKey . '/ && !/awk/' . "'|wc -l";
ob_start();
system($s);
$pNum = (int)ob_get_clean();//當前查詢進程數量
if($count >= $num) {//當前牌局列表都已經交給各個子進程查詢了
if($pnum > 1) {//有子進程沒有完成,稍等
sleep(3);
continue;
} else {//所有子進程都已經完成,退出while循環,回到for循環中查詢下一個用戶的牌局數據
break;
}
} else if($pNum > $max_pnum) {//子進程數量超出限制,稍等
sleep(3);
continue;
}
$rs = $list[$count];//從牌局列表中取出一個牌局來進行牌局詳情數據查詢
pcntl_signal(SIGCHLD, SIG_IGN);
$pid = pcntl_fork();//fork一個子進程,子進程會從此位置開始執行
if($pid < 0) {//子進程創建失敗
//這里可以做一些日志記錄
exit(0);
}
if($pid) {//子進程創建成功(主進程邏輯)
$count ++;
} else if($pid == 0) {//進行牌局詳情數據查詢(子進程邏輯)
$pid = posix_setsid();//子進程ID
//這里根據$rs中的牌局數據進行牌局詳情查詢,並將得到的數據寫入當前子進程專屬文件(文件路徑+文件名要唯一,可以使用時間戳、桌子ID和牌局ID組合表示)
exit(0);//當前子進程任務完成,退出
}
}
}
exit(0);//查詢完成,主進程退出
這個PHP后台掛載進程執行完成之后,所有需要查詢的牌局數據就已經全部寫入文件中了。現在問題來了,PHP應該怎么把這些數據傳給前端頁面呢?我們知道http協議是單向協議,只能由前端向服務器主動發起請求,而服務器是無法主動把數據發送給前端的,那怎么辦呢?使用socket.io!可以在所有子進程執行完成之后,通過socket.io使用當前sock連接通知js,js收到消息之后即發送請求給一個PHP接口,這個PHP接口的任務便是讀取上述多進程在文件中寫下的數據,返回給js進行頁面渲染。
關於socket.io,沒有進行過多研究,使用的是公司框架封裝好的,當然也可以使用原生的,簡單教程地址:http://www.workerman.net/phpsocket_io,這里只是簡單介紹一下思路。
首先需要到上面這個地址中下載phpsocket,然后啟動一個服務端,注意,只能在命令行中啟動,同樣可以作為一個后台掛載進程運行。
<?php
require_once __DIR__ . '/socketio/vendor/autoload.php';
use Workerman\Worker;
use PHPSocketIO\SocketIO;
//創建socket.io服務器,監聽2021端口
$io = new SocketIO(2021);
//向客戶端發送消息,通知數據已查詢完成
$io->emit('hello', json_encode([1 => 'hello', 'aaa' => 'ewfewr']));
Worker::runAll();
然后在客戶端js中監聽這個消息:
<script src='https://cdn.bootcss.com/socket.io/2.0.3/socket.io.js'></script>
<script>
var socket = io('http://127.0.0.1:2021');
socket.on('hello', function(par){
//這里便是發送請求到PHP接口進行數據讀取了
});
</script>
若是覺得使用原生socket.io麻煩,也可以使用封裝好的ElephantIO。
當然,這里有個問題,就是寫數據產生的文件會越來越多,可以在每次掛載進程進行寫文件之前先把之前寫的文件(已經沒用了的)進行刪除:
function rmDataDir($dir) {
if(!is_dir($dir)) return;
$handle = opendir($dir);
while($file = readdir($handle)) {
if(in_array($file, ['.', '..'])) continue;
$str = $dir . $file;
if(is_dir($str)) {
rmDataDir($str . '/');
} else {
unlink($str);
}
}
closedir($handle);
$arr = scandir($dir);//readdir()有時候沒有識別完所有文件就返回false了。。。
if(count($arr) <= 2) {//只有.和..的時候可以刪除
rmdir($dir);
}
}
同時,由於在這個功能中,每次發送查詢數據請求的代價都是比較昂貴的,可以考慮在js中對查詢過的數據進行緩存,例如,相同查詢條件下相同用戶ID,已經查詢過的就不需要查詢了,直接從js緩存中讀取數據進行頁面渲染就可以了。
然而,盡管使用了PHP多進程,但是進行了很多的文件讀寫操作,磁盤IO也是很耗時間的,所以速度上並沒有提升多少,只是不會再出現瀏覽器頁面卡死的情況了。這個功能中關於速度的提升不知還有什么更好的方法呢???各位朋友,走過路過,別忘了給下建議哈~
