概述

项目使用Swoole+Redis,更新迭代了4版,从最初的消息提醒、到后来的客服系统,它终于慢慢的长大了,具备了可持续推送的能力和动力,我要总结一下其中的酸甜苦辣。

PHP的运行模式的生命周期来讲一种有2种,一种是fpm的,这种模式下,主要编写Web服务,一种是Cli模式的,Swoole是php7版本之后的神作,可以说它开启了php也可以提供更多的网络服务的可能。

学习了源码后,发现php7真的是划时代的一个产品,重构了数组(从物理Hash链路调整成逻辑链结构)、字符串(二进制安全)、新增了Ast(抽象语法树)还有很多,主要提升了性能,说的有点多,回到项目本身。

项目我愿意把它定义成PMQ,主要的功能就是推送+拉取,尽量的精简以免后期维护上的麻烦,选取EasySwoole这个框架,因为当时慕课网上有视频教程,而且文档丰富,加入群有问题可以及时反馈给开发作者。

使用WebSocket服务来推送消息,WebSocket是一个建立在Http协议上的全双工通信协议,掌握以后发现还是挺简单,在这之前做了大量的学习和积累,对我的成长帮助非常大,做一个东西往往要深挖背后的原理,掌握好原理在去指导实践事半功倍,其实我当时也是瑟瑟发抖,没有心情考虑那么多,第一个想法就是实现功能。

失败和解决问题

第一次主要的功能是心跳、登陆、拉取未读消息数、评论和通知,消息确认几个功能。

第一次上线就,直接失败了,并发量太大,只上线了半个小时,Mysql没有扛住直接就挂了,服务紧急叫停,查找原因,当时正在年前,正好春节因为疫情也没有回家,就查找原因,进行下一次尝试。

1.添加缓存,更改缓存失效算法

从垮掉的Mysql开始修复,首先判断是这个原因,在登陆验证用户身份时请求主站的地方,做了一层缓存,主站过期的策略是7300s,为了防止同一时刻回收缓存引起雪崩,过期策略采用固定时长+随机过期的方法。

public function getUserId(string $token) :int
{
    $uid = \EasySwoole\RedisPool\RedisPool::invoke(function (\EasySwoole\Redis\Redis $redis) use ($token) {
        $uid = $redis->get($token);
        if (!isset($uid) || empty($uid)) {
            //远程验证token
            $uid = OAuth::getUserInfo($token);
            if (isset($uid) && !empty($uid) && intval($uid) > 0) {
                //存入缓存时间,过期时间小于 7300s
                $expireTime = 3650 + rand(1, 3000);
                $redis->setEx($token, $expireTime, $uid);
            }
        }
        return $uid;
    }, self::REDIS_CONN_NAME);
    return intval($uid);
}

2.函数内实现功能

当时对Swoole的新特性不太清楚,好像也报了几个跨协程调用的错误,为了保证服务的稳定和可靠,都把改成自己函数处理,当时有点惊弓之鸟。

还把所有的Redis链接池的defer模式改成了invoke模式,invoke模式的特点就是使用完成后立刻回收资源,defer模式是等执行完成后统一回收,区别是这点。

后来V1不那么完美,但是成功上线了。

计数

V2的功能只是在基础通信的基础上添加了计数功能,这版本平平无奇,只是加了个计数器的功能,很平静。

设计方案使用字符串为每个使用的人单独做key,在接收到主站的http请求时,未读数+1,读取消息数后清零。

在使用Crontab里的计划任务执行队列里的消息 1000/分,因为主站的消息没有事实请求我的服务,所以只能采用折中的方案,有一定的延迟。

public function commentsCounter(int $toUid, int $commentUid)
{
    if ($toUid == $commentUid || empty($toUid) || empty($commentUid)) return false;

    \EasySwoole\RedisPool\RedisPool::invoke(function (\EasySwoole\Redis\Redis $redis) use ($toUid, $commentUid) {
        //收到评论数 +1
        $redis->incr(Category::_getUnReadFromCommentsKeyName($toUid));
        //更新消息未读数
        $redis->lPush(self::PUSH_UNREAD_NUMBER_All, $toUid);
        $redis->lPush(self::PUSH_UNREAD_NUMBER_All, $commentUid);
    }, self::REDIS_CONN_NAME);
}

客服IM

客服功能是一个绝对优化和升华的精品项目,进行了大刀阔斧的改革,重构、设计方案、优化方案都得到了质的飞跃。

1.链接到WebSocket服务,进行用户验证(第一版功能)

2.进行链接客服管理员 【建立 - 通信 - 结束】 ,建立一次通信的生命周期过程和流程。

客服的user_id,使用的是虚拟ID,66666666做为起始值(理论上当公司用户发展到6千万或者客服发展到3千人的时候,会出现问题)。

3.分配方法:上次聊天的客服管理员优先分配,第一次咨询的用户随机分配。

4.支持离线消息/消息确认/聊天记录/发送图片等等功能

5.离线消息按用户和管理员关系进行,1对1分配。

6.重构和优化,重构了第一版中函数的代码冗余,优化了初始化的路由层。

下面是代码细细说部分:

1.关于服务的优化

Swoole的高效在于预加载和常驻内存的特点,所以Swoole服务的热启动只支持Controller部分,所以对路由层做了一个大的整改,提高可用性。

把所有对路由的参数交给ForwardRoute类,去处理和控制参数,是路由变的灵活,可自动重启。

/**
 * 解析器避免高耦合,从解析器开始分发请求,使控制器分离
 */
public function decode($raw, $client): ?Caller
{
    $caller = new Caller();
    $this->data = json_decode($raw, true);

    $toolRoute = new ForwardRoute($this->data);
    $controllerRoute = $toolRoute->_getRouter();
    $this->action = $toolRoute->_getAction();
    $this->body = $toolRoute->_formatBody();

    $caller->setControllerClass($controllerRoute);
    $caller->setAction($this->action);
    $caller->setArgs($this->body);
    return $caller;
}

2.停止服务时,添加onShutdown方法,完善清理fd旧关系数据

$register->set(EventRegister::onShutdown,function (\swoole_server $server ) use ($websocketEvent) {
    $websocketEvent->onShutdown($server);
});

3.分离Server层

新建Server层的原因有两个,减少代码冗余,将函数分离出来还有一个好处,函数在执行完以后,回收内存,尽可能的减少内存开销。

├── ChangPeiServer
│   └── UserServer.php
├── MysqlServer
│   └── PushMsg.php
├── RedisServer
│   ├── CountServer.php
│   ├── FdServer.php
│   └── QueueServer.php
├── Server.php
└── WebSocketServer
    └── PushServer.php

4.消费消息的双队列(这是第一版内容)

消费队列设计了快慢两个队列进行消费,快速队列6分支为一组,设置超时时间,如果用户超过1天不上线,放入延迟队列,延迟队列最多保持15天,15天后系统进行回收。

在使用Nosql缓存时,一定要注意的是内存的回收,设置超时时间。

$redis = \EasySwoole\RedisPool\RedisPool::defer('redis');
$server = ServerManager::getInstance()->getSwooleServer();
//每分钟消费limit条
$data = $redis->lRange(self::PUSH_MSG_COMMENT_LISTS, 0, $this->limit );
if (!empty($data) && is_array($data)){
    $pushLists = [];
    $lRemList = [];
    foreach ($data as $json){
        $msgPushInfo = json_decode($json,true);
        if(isset($msgPushInfo['to_uid']) && !empty($msgPushInfo['to_uid'])){
            $delayList = [];
            //用户超过1天不上线,放入延迟队列
            $diff_unix_times = time() - $msgPushInfo['create_time'];
            if( $diff_unix_times > $this->timeOut  ){
                $delayList[] = $json;
            }  else {
                $lRemList[$msgPushInfo['to_uid']][] = $json;
                $pushLists[$msgPushInfo['to_uid']]['uid'] = $msgPushInfo['to_uid'];
                $pushLists[$msgPushInfo['to_uid']]['noce_ack'] = $msgPushInfo['noce_ack'];
            }
        }else{
            //对错误数据及时清理
            $redis->lRem(self::PUSH_MSG_COMMENT_LISTS, 1, $json);
        }
    }

还有问题?

万万没想到,竟然还有问题,链接数变化正常,但是内存好像没有得到很好的释放,而且进程里也出现了很多野进程?

统一消息推送逻辑架构 统一推送上线了吗_php

野进程多可能存在的原因是这样的,你没有守护启动,然后主进程挂了,后面的进程找不到父进程,变成了僵尸进程或者是孤儿进程。

统一消息推送逻辑架构 统一推送上线了吗_redis_02

内存也不对劲,大概率是我执行脚本里出了问题,去掉了修改配置的语句,在Base类里加入了unset,及时释放掉内存。

ini_set('memory_limit', '1024M');
set_time_limit(0);

第二天查看了日志,错误大小只有9.1K了,有明显改善,放心了。

未来

php的未来多半是扩展开发,集成Swoole的服务功能,集成Swoole想对来说还比较简单,扩展开发就有难度了,所有的方向都是殊途同归的,就是让性能达到最优,但我觉得性能不能单纯的寄托给语言,也要和Redis、Nginx、Linux参数配合使用,把思路融入在项目生产中,会创造更大的价值吧。