websocket基于php
记一次结合PHP多进程和socket.io解决问题的经历
公司是做棋牌游戏的。前段时间接到一个后台人工鉴定并处理通牌作弊玩家的需求,其中需要根据几个玩家的游戏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比较稳定;其二,可以避免数据查询过程时间太长导致超时。
挂载进程代码示例:
1
2
3
4
5
6
7
|
<?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多个子进程进行每个牌局详情数据的查询了,一个子进程负责查询一个牌局的详情数据,并将数据写入文件中,代码示例如下:(注意:以下代码只是基本代码框架,无法直接运行)
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
|
<?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,然后启动一个服务端,注意,只能在命令行中启动,同样可以作为一个后台挂载进程运行。
1
2
3
4
5
6
7
8
9
10
11
12
|
<?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中监听这个消息:
1
2
3
4
5
6
7
|
<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。
当然,这里有个问题,就是写数据产生的文件会越来越多,可以在每次挂载进程进行写文件之前先把之前写的文件(已经没用了的)进行删除:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
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也是很耗时间的,所以速度上并没有提升多少,只是不会再出现浏览器页面卡死的情况了。这个功能中关于速度的提升不知还有什么更好的方法呢???各位朋友,走过路过,别忘了给下建议哈~