场景:对接erp,内部后台每次生成数十万的兑换码,然后调用erp接口,向erp写入这些兑换码,并且erp只提供一个一个的写入,没有传一个json数组然后批量入库的,同时erp会返回写入结果,如果写入后台需要更新一下状态。如果使用传统的单进程方案,循环的调用接口写入,其效率是非常低的。简单的测试一下,用传统的单进程方案,写入一个兑换码大约需要0.2s(请求发起到响应时间),那么写入十万个大约需要5.5小时,如果是erp临时需要大量的兑换码使用,这么慢的速度是非常致命的。
本来想使用Swoole的Task来实现的,想想还要在服务器上安装许多扩展,最后还是算了。于是用了TP5官方的一个组件 think-queue
在传统的程序执行流程一般都是即时,串行,同步的。在某些场景下,会存在并发低,吞吐率低,响应时间长等问题。在大型应用中,一般会引入消息队列来提高应用的性能。
用了两个服务器,为了区分一下,就叫做A服务器和B服务器吧。都是1核2G1m的学生机
A服务器:部署队列
B服务器:模拟erp端的写入
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
composer create-project topthink/think=5.1.*
composer require topthink/think-queue
time(),'random_str' => uniqid()];
Queue::push('app\client\controller\Consume',$content,'SendJob');
}
}
public function releaseTaskOrdinary()
{
for($i=0;$i<5000;$i++){
$content = ['timestamp' => time(),'random_str' => uniqid()];
(new Client())->post('http://106.52.157.244/',[
'form_params' => [
'timestamp' => $content['timestamp'],
'random_str' => $content['random_str'],
]
]);
}
}
}
getPushStatus()){
//如果用户拒绝推送,从队列中移除该任务
print '用户拒绝推送,从队列中移除该任务'.PHP_EOL;
$job->delete();
return true;
}
if($this->send($data)){
//如果对端已经成功插入该数据,从队列中移除该任务
print '对端已经成功插入该数据,从队列中移除该任务'.PHP_EOL;
$job->delete();
return true;
}
//如果做大型的应用,用户接收的推送短信可能不只一条,如果多次发送可能会触发短信平台的防盗刷功能
//这里可以判断一下短信平台的响应码,延迟发送
if(!$this->enoughReceive()){
//$delay为延迟时间,表示该任务延迟60秒后再执行
print '延迟执行该任务'.PHP_EOL;
$job->release(60);
return true;
}
//还可以获取任务重试的次数,如果重试次数大于3次,从队列中移除该任务
if($job->attempts() > 3){
print '任务重试的次数>3,从队列中移除该任务'.PHP_EOL;
$job->delete();
return true;
}
}
/**
* 向erp端发起请求,消费队列中的消息
* @param $data ['timestamp' => time(),'random_str' => uniqid()]
* @return int 如果插入成功,返回1 否则返回0
*/
private function send($data)
{
$response = (new Client())->post('http://106.52.157.244/',[
'form_params' => [
'timestamp' => $data['timestamp'],
'random_str' => $data['random_str'],
]
]);
$response = json_decode($response->getBody()->getContents(),true);
dump($response);
return (isset($response['status']) && $response['status'] == 1) ? 1 : 0;
}
/**
* 判断用户是否可以接收短信
*/
private function enoughReceive()
{
//to do ...
return true;
}
/**
* 模拟获取用户的短信订阅状态
*/
private function getPushStatus()
{
//to do...
return true;
}
}
'Redis', // Redis 驱动
'expire' => 60, // 任务的过期时间,默认为60秒; 若要禁用,则设置为 null
'default' => 'default', // 默认的队列名称
'host' => '127.0.0.1', // redis 主机ip
'port' => 6379, // redis 端口
'password' => 'luoss,,', // redis 密码
'select' => 5, // 使用哪一个 db,默认为 db0
'timeout' => 0, // redis连接的超时时间
'persistent' => false, // 是否是长连接
];
exec('set names utf8');
$now_time = time();
$sql = "INSERT test_queue (timestamp,random_str,create_time) VALUES ('{$_POST['timestamp']}','{$_POST['random_str']}','$now_time')";
$pdo->exec($sql);
}catch (Exception $e){
die('操作失败'.$e->getMessage());
}
$pdo = null;
表结构
CREATE TABLE `testqueue_com`.`test_queue` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`timestamp` int(11) UNSIGNED NOT NULL DEFAULT 0,
`random_str` varchar(35) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
);
php think queue:work --daemon --queue SendJob
在生产环境2核4G服务器,8个进程的加持下。每秒可以写入800条左右的数据,负载15%左右,如果进程数多一点会更快。
queues:SendJob
类型为 List
列表,表示待执行的任务列表
queues:SendJob:delayed
类型为 Sorted Set
有序集合,表示延迟、定时执行的任务列表
queues:SendJob:reserved
类型为 Sorted Set
有序集合,表示执行中的任务列表
Redis驱动下为了实现任务的延迟执行和重发,任务将在这三个key之间来回移动
2.1 命令模式
queue:subscribe
queue:listen
proc_open('php think queue:work --queue="%s" --delay=%s --memory=%s --sleep=%s --tries=%s')
的方式来周期性地创建一次性的 work 进程来消费消息队列, 并且限制该 work 进程的执行时间, 同时通过管道来监听 work 进程的输出。php think queue:listen --queue SendJob
queue:work
php think queue:work --queue SendJob
2.2 命令行参数
Work 模式
php think queue:work \
--daemon //是否循环执行,如果不加该参数,则该命令处理完下一个消息就退出
--queue helloJobQueue //要处理的队列的名称
--delay 0 \ //如果本次任务执行抛出异常且任务未被删除时,设置其下次执行前延迟多少秒,默认为0
--force \ //系统处于维护状态时是否仍然处理任务,并未找到相关说明
--memory 128 \ //该进程允许使用的内存上限,以 M 为单位
--sleep 3 \ //如果队列中无任务,则sleep多少秒后重新检查(work+daemon模式)或者退出(listen或非daemon模式)
--tries 2 //如果任务已经超过尝试次数上限,则触发‘任务尝试次数超限’事件,默认为0
Listen 模式
php think queue:listen
--queue helloJobQueue \ //监听的队列的名称
--delay 0 \ //如果本次任务执行抛出异常且任务未被删除时,设置其下次执行前延迟多少秒,默认为0
--memory 128 \ //该进程允许使用的内存上限,以 M 为单位
--sleep 3 \ //如果队列中无任务,则多长时间后重新检查
--tries 0 \ //如果任务已经超过重发次数上限,则进入失败处理逻辑,默认为0
--timeout 60 // work 进程允许执行的最长时间,以秒为单位
2.3 work 模式和 listen 模式的区别
两者都可以用于处理消息队列中的任务
--daemon
参数,work命令又可分为单次执行和循环执行两种模式。
--daemon
参数,该模式下,work进程在处理完下一个消息后直接结束当前进程。当队列为空时,会sleep一段时间然后退出。ProcessTimeoutException
异常并结束 listen 进程--memory
参数的值,如果已超过, 此时 listen 进程会直接 die 掉, work 进程也会自动结束.php think queue:restart
命令重启队列来使改动生效;而listen 模式会自动生效,无需其他操作。--timeout
参数限制 work 进程允许运行的最长时间,超过该时间限制后, work 进程会被强制 kill 掉, listen 进程本身也会抛出异常并结束;在以上的推送中,有时候不仅仅只是推送短信,也有可能推送邮件等,那么think-queue怎么进行多任务处理呢?
生产者代码
get('deal_type');
switch ($deal_type){
case 'send_sms' :
$content = ['timestamp' => time(),'random_str' => uniqid(),'task_name' => 'task_a'];
Queue::push('app\client\controller\Consume@send_sms',$content,'SendJob');
break;
case 'send_email' :
$content = ['timestamp' => time(),'random_str' => uniqid(),'task_name' => 'task_b'];
Queue::push('app\client\controller\Consume@send_email',$content,'SendJob');
break;
}
}
}
消费者代码
只需要使用 任务类名@方法名
就可以了
//即时执行消息
Queue::push('app\client\controller\Consume',['date' => date('Y-m-d H:i:s'),'point' => 1],'SendJob');
//延迟10s之后执行,即时消息马上执行,10s后延迟消息执行
Queue::later(10,'app\client\controller\Consume',['date' => date('Y-m-d H:i:s'),'point' => 2],'SendJob');
$job->release()
release()
可以提供一个延迟的 秒
数,如果没有提供,表示立即进行重发$job->delete()
删除expire
不为空,则worker进程每次查询剩下的任务之前,会自动重发已过期的任务
查看任务的执行次数
上面提及到消息队列保存的方式共有三个 Redis Key
,可以在 redis-cli
中使用
zrange queues:SendJob:reserved 0 -1
zrange queues:SendJob:delayed 0 -1
lrange queues:SendJob 0 -1
查看正在执行,待执行,延迟执行的任务列表,在返回的json数据中,有一个值为 attempts
,它代表该任务重发的次数
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-17L3kjdk-1618886517094)(https://www.sakuraluo.com/usr/uploads/2020/12/479004851.png)]
除了可以在消费者中使用 $job->attempts()
获取失败次数,还可以在运行命令时加上--tries
参数,如 php think queue:work --daemon --tries 2 --queue SendJob
,该命令代表如果失败次数大于2次,将触发任务的失败回调,可以在消费者中声明 failed()
方法,$data
为生产者投递任务时传递的数据,在这里统一做任务失败之后的处理。例如通知系统管理员任务失败了。
public function failed($data)
{
dump('failed');
dump($data);
}
需要关注的是,使用 php think queue:work --daemon --tries 2 --queue SendJob
命令运行队列,如果某个任务的重试次数大于 --tries
那么系统将自动删除该任务,该写法 failed()
并没有提供 $job
对象,重发受限。
mysql 5.7.32-log
),对接erp会有一个写入状态返回,需要更新写入状态到后台。而mysql有一个连接缓存时间,如果代码中没有实现断线重连,那么就会出现这个成功入队但是没有消费的问题。SHOW GLOBAL VARIABLES LIKE 'wait_timeout';
或者
SHOW GLOBAL VARIABLES LIKE '%timeout%;
这里返回的是 28800s
,即为8小时。在TP5中,只需要在数据库配置文件中配置 break_reconnect" => true
即可以实现断线重连
变量名称 | 解析 |
---|---|
connect_timeout | mysql客户端在尝试与mysql服务器建立连接时,mysql服务器返回错误握手协议前等待客户端数据包的最大时限。 |
wait_timeout | 负责超时控制的变量,其时间为长度为28800s,就是8个小时,那么就是说MySQL的服务会在操作间隔8小时后断开,需要再次重连 |
lock_wait_timeout | sql语句请求元数据锁的最长等待时间,默认为一年。此锁超时对于隐式访问Mysql库中系统表的sql语句无效,但是对于使用select,update语句直接访问mysql库中标的sql语句有效 |
net_read_timeout / net_write_timeout | mysql服务器端等待从客户端读取数据 / 向客户端写入数据的最大时限 |
slave_net_timeout | mysql从复制连结等待读取数据的最大时限 |
为什么使用了多进程,任务却不会重复的消费?
Redis::pop()
,由于 pop()
方法是原子性的,多次进程同时到达也是分先后的,所以不会得到重复的消费任务消息可以成功入队,但是在 fire()
开始打印任意东西,屏幕不会输出?
Queue::push()
或者 Queue::later()
中的命名空间写错了有以下代码
生产者
date('Y-m-d H:i:s'),'point' => 1],'SendJob');
}
}
消费者
使用的是 php think queue:work --daemon --queue SendJob
运行队列,这个队列会错误的。在以上的 消息重发 - worker进程的自动重发提及到,如果fire()
抛出异常且没有删除任务(任务可能是没有删除的,因为异常是意向不到的),worker进程就会进行自动重发,此时代码就会进入了死循环,并且不能停止。除非队列超过了设置的内存或者被kill
getMessage();
$job->delete();
}
}
}