背景
CP礼物模块,用户赠送成功会有一系列任务操作。为了快速影响此动作,使用阿里云MNS异步队列进行业务端解耦(接口层面只负责校验是否可送礼物,异步队列执行具体送礼物的逻辑)。刚开始异步队列只有一项,后来随着业务的发展任务增加十几项,由于历史原因这些任务都写在一个方法中,每一条任务会调用其他业务。这种操作会存在一下问题:
-
多个任务在同一个方法中执行,前面任务失败会导致后面的任务无法执行
- 每个任务调用其他业务异常会直接throw,而调用方未catch
-
网络或者IO异常时,无法快速恢复队列执行(如果直接执行可能会出现任务重复执行)
-
无队列补偿,数据异常只能手工处理
-
修改相关业务时,影响送礼物队列时不知情,可能把bug带到线上
-
队列异常时,未有报警,只能等待用(承)户(受)反(怒)馈(🔥)
由于以上问题,导致送礼物每周都会有数例莫名其妙的队列异常,一下是改造流程图:
解决方案
梳理一下需要做以下改动
- 执行队列时,多个任务拆分多个独立执行单元,之间互不影响
- 队列异常时,用定时任务进行补偿,超过补偿次数则飞书报警
- 任务应分一次性任务(不可补偿,比如扣费),可重复性任务(可多次补偿)
- 提供任务超过3次,排查问题后,重新执行队列接口
实现这个功能存储层可以用mysql或者redis,这里使用mysql进行处理
相关代码
Mysql : 新建两张表
-- 根据id分64张表
CREATE TABLE `queue` (
`id` bigint(20) unsigned NOT NULL COMMENT '队列id',
`status` tinyint(3) NOT NULL DEFAULT '0' COMMENT '0默认1成功2失败3处理中',
`class_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '调用类名',
`error_info` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '调用返回的错误信息',
`times` tinyint(3) NOT NULL DEFAULT '0' COMMENT '调用次数',
`is_warn` tinyint(3) NOT NULL DEFAULT '0' COMMENT '0默认 1已报警',
`payload` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '额外信息',
`ctime` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '当前时间戳',
PRIMARY KEY (`id`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='队列';
-- 根据id分128张表
CREATE TABLE `queue_task` (
`id` bigint(20) unsigned NOT NULL COMMENT '队列id',
`method_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '调用方法名',
`type` tinyint(3) NOT NULL DEFAULT '0' COMMENT '0自动补偿 1手动补偿',
`status` tinyint(3) NOT NULL DEFAULT '0' COMMENT '0默认1成功2失败',
`error_info` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '调用返回的错误信息',
`times` tinyint(3) NOT NULL DEFAULT '0' COMMENT '调用次数',
`ctime` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '当前时间戳',
UNIQUE KEY `id_method_name` (`id`,`method_name`),
KEY `id` (`id`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='队列任务表';
以图片转存自动补偿为例(数据库操作Dao层省略)
- 创建补偿队列的基础类:base.php
<?php
*/***
** 补偿队列补偿的基础类*
** Class Base*
**/*
abstract class Base
{
protected static *$auto* = 'auto';//自动补偿 可尝试执行多次
protected static *$manual* = 'manual';//手动补偿 任务只会尝试执行一次
*/***
** 创建队列*
*** ***@param*** *$id int 队列的主键id*
*** ***@param*** *array $param 队列的参数必须为数组*
*** ***@param*** *int $time 写入时间延时队列用*
**/*
**public static function createQueue($id, $param = [], $time = 0)
{
if (!$id || !$param || !is_array($param)) {
throw ServicesException::*create*('queue param error');
}
$className = static::class;
self::*checkChildTaskMethod*();
!$time && $time = time();
QueueDao::*upsert*($id, $className, $param, $time);
return true;
}
*/***
** 检查子类是否包含足够的task方法,此方法主要用户测试时队列方法格式是否正确*
**/*
**private static function checkChildTaskMethod()
{
if (Env::*isDev*()) {
$class = new static();
$maps = $class::*taskMethodTypeMap*();
if (empty($maps)) {
throw ServicesException::*create*('queue task method is empty');
}
$allowType = [self:: *$auto*, self:: *$manual*];
foreach ($maps as $method => $type) {
if (!method_exists($class, $method)) {
throw ServicesException::*create*('queue task method does not exist : ' . $method);
}
if (!in_array($type, $allowType)) {
throw ServicesException::*create*('queue task type does not allow : ' . $type);
}
}
}
}
*/***
** 任务的方法和类型映射,主要是执行队列的时候,写入task表中的方法。(备注:有多少个key,子类对应多少个共有静态方法)*
*** ***@example*** *['addWeekStar'=>'auto','addLoverPower'=>'manual']*
**/*
**protected abstract static function taskMethodTypeMap();
*/***
** 抢队列并执行*
*** ***@param*** *$id*
**/*
**public static function grabQueueAndExecute($id)
{
$res = QueueDao::*grabQueue*($id);
if ($res['affected_rows'] == 1) {
$class = new static();
$maps = $class::*taskMethodTypeMap*();
//创建队列的任务
$res = QueueHandle::*createTask*($id, $maps);
if ($res) {
//执行任务
QueueHandle::*executeTask*($id);
}
}
}
*/***
** 执行队列补偿*
*** ***@param*** *$id*
**/*
**public static function compensateQueue($id)
{
//执行任务
QueueHandle::*executeTask*($id, true);
}
}
-
创建图片转存队列实现类:PictureTransferAction.php
- 此类有两点注意 taskMethodTypeMap 的key必须是此类一个方法,此方法必须返回true
- 如果OtherClass::method方法有问题必须throw异常即可
<?php
*/***
** 图片转存队列*
**/*
class PictureTransferAction extends Base
{
protected static function taskMethodTypeMap()
{
return [
'task1' => self:: *$auto*,//可多次执行
'task2' => self:: *$manual*,//只可执行一次,不会尝试
];
}
//方法名必须为 taskMethodTypeMap 的key,参数必须为$id (array)$params
public static function task1($id, $params)
{
$url = $params['url'];
OtherClass::*method*($url,$id);//此方法处理逻辑如果异常必须throw出来
return true;
}
//方法名必须为 taskMethodTypeMap 的key,参数必须为$id (array)$params
public static function task2($id, $params)
{
$url = $params['url'];
OtherClass::*method2*($url,$id); //此方法处理逻辑如果异常必须throw出来
return true;
}
}
- 创建队列处理类:QueueHandle.php
<?php
class QueueHandle
{
//重试3次
private static *$compensateLimit* = 3;
//飞书token
const *QUEUE_ROBOT_ACCESS_TOKEN* = 'feishu token';
*/***
** 获取当前时间*
*** ***@return*** *false|string*
**/*
**private static function getNowTime()
{
return date('Y-m-d H:i:s');
}
*/***
** 创建队列*
*** ***@param*** *$id*
*** ***@param*** *$methodTypeMap*
*** ***@return*** *bool*
**/*
**public static function createTask($id, $methodTypeMap)
{
$queue = QueueDao::*getQueue*($id);
if (empty($queue)) {
return false;
}
try {
$time = time();
foreach ($methodTypeMap as $method => $type) {
QueueTaskDao::*upsert*($id, $method, $type, $time);
}
} catch (Exception $ex) {
QueueDao::*update*($id, ['error_info' => sprintf("创建任务失败%s", self::*getNowTime*())]);
return false;
}
return true;
}
*/***
** 执行队列*
*** ***@param*** *$id*
*** ***@param*** *bool $isCompensate*
**/*
**public static function executeTask($id, $isCompensate = false)
{
$queue = QueueDao::*getQueue*($id);
//如果1.不存在队列 2.状态不是失败和进行中 3.重试了三次 直接返回
if (empty($queue) || !in_array($queue['status'], [QueueDao::*STATUS_FAIL*, QueueDao::*STATUS_PROCESSING*]) || $queue['times'] > 3) {
return false;
}
$payload = $queue['payload'];
$class = $queue['class_name'];
$tasks = QueueTaskDao::*getTaskListById*($id);
if (!$tasks) {
self::*updateQueueByError*($id, '没有找到任务', $queue);
return false;
} else {
foreach ($tasks as $task) {
$method = $task['method_name'];
$type = $task['type'];
$status = $task['status'];
$times = $task['times'];
//成功不需要再执行 或者尝试了3次
if ($status == QueueTaskDao::*STATUS_SUCCESS* || $times >= self:: *$compensateLimit*) {
continue;
}
//如果1.是补偿 2.类型手动 3.次数大于等于1次则跳过
if ($isCompensate && $type == QueueTaskDao::*TYPE_MANUAL* && $times >= 1) {
continue;
}
try {
$res = call_user_func_array([$class, $method], [$id, $payload]);
if ($res === true) {
//成功
QueueTaskDao::*update*($id, $method, ['status' => QueueTaskDao::*STATUS_SUCCESS*], ['times' => 1]);
} else {
//失败
self::*updateQueueTaskByError*($id, $method, $res ?? '未知错误', $task);
}
} catch (Exception $ex) {
//异常错误
self::*updateQueueTaskByError*($id, $method, sprintf(sprintf('queue catch exception %s %s %s', $ex->getCode(), $ex->getMessage(), $ex->getTraceAsString())), $task);
}
}
//更新队列状态
self::*updateQueueStatus*($id, $queue);
return true;
}
}
*/***
** 更新队列状态*
*** ***@param*** *$id*
*** ***@param*** *bool $queue*
**/*
**public static function updateQueueStatus($id, $queue = false)
{
if (!$queue) {
$queue = QueueDao::*getQueue*($id);
}
$tasks = QueueTaskDao::*getTaskListById*($id);
if (!$tasks) {
self::*updateQueueByError*($id, '没有找到任务', $queue);
} else {
$taskStatus = QueueTaskDao::*STATUS_SUCCESS*;
foreach ($tasks as $task) {
if ($task['status'] != $taskStatus) {
$taskStatus = $task['status'];
break;
}
}
if ($taskStatus == QueueTaskDao::*STATUS_SUCCESS*) {
QueueDao::*update*($id, ['status' => QueueDao::*STATUS_SUCCESS*], ['times' => 1]);
} else {
self::*updateQueueByError*($id, '队列未完全成功', $queue);
}
}
}
*/***
** 队列错误时更新信息*
*** ***@param*** *$id*
*** ***@param*** *$errorInfo*
*** ***@param*** *bool $queue*
**/*
**public static function updateQueueByError($id, $errorInfo, $queue = false)
{
if (!$queue) {
$queue = QueueDao::*getQueue*($id);
}
$errorInfo = sprintf("%s%s%s", $queue['error_info'], $errorInfo, self::*getNowTime*());
QueueDao::*update*($id, ['error_info' => $errorInfo], ['times' => 1]);
$queue = QueueDao::*getQueue*($id);
if ($queue['times'] >= self:: *$compensateLimit* && $queue['is_warn'] == QueueDao::*NOT_WARNED*) {
//尝试了3次错误接入飞书
$message = [
'ID' => $queue['id'],
'类名' => sprintf('%s %s', $queue['class_name'], *ENV_TAG*),
'信息' => $queue['error_info'],
];
QueueDao::*update*($id, ['is_warn' => QueueDao::*IS_WARNED*, 'status' => QueueDao::*STATUS_FAIL*]);
MVendor_FeiShuRobot::*sendFeiShuPostMessage*(self::*QUEUE_ROBOT_ACCESS_TOKEN*, '队列执行报警', MVendor_FeiShuRobot::*buildWrapText*($message));
}
}
*/***
** 队列任务错误时更新信息*
*** ***@param*** *$id*
*** ***@param*** *$method*
*** ***@param*** *$errorInfo*
*** ***@param*** *bool $task*
**/*
**public static function updateQueueTaskByError($id, $method, $errorInfo, $task = false)
{
if (!$task) {
$task = QueueTaskDao::*getTask*($id, $method);
}
$taskTimes = $task['times'];
$errorInfo = sprintf("%s%s%s-----", $task['error_info'], $errorInfo, self::*getNowTime*());
$update = [
'error_info' => $errorInfo,
];
if ($taskTimes >= (self:: *$compensateLimit* - 1)) {
$update['status'] = QueueTaskDao::*STATUS_FAIL*;
}
QueueTaskDao::*update*($id, $method, ['error_info' => $errorInfo], ['times' => 1]);
}
*/***
** 删除3天前队列*
*** ***@param*** *$id*
**/*
**public static function deleteQueueAndTask($id)
{
$queue = QueueDao::*getQueue*($id);
if ($queue['status'] != QueueDao::*STATUS_SUCCESS*) {
return;
}
QueueDao::*delete*($id);
QueueTaskDao::*delete*($id);
}
}
- 创建mns发送和接受处理类 ProcessPictureTransferAction.php
<?php
*/***
** 图片转存*
**/*
class ProcessPictureTransferAction extends BaseAsyncAction
{
private $id;
//mns发送方法
public static function create($id, $params = [])
{
if (!$id || !$params) {
return false;
}
$instance = new self();
$instance->id = $id;
//创建队列
$params['url'] = isset($params['url']) ? $params['url'] : '';
$params['id'] = isset($params['id']) ? (int)$params['id'] : 0;
//发送数据放到 $params中 $id为queue表中的id
PictureTransferAction::*createQueue*($id, $params);
return $instance;
}
//mns订阅执行方法
public function execute()
{
$id = $this->id;
//执行队列
PictureTransferAction::*grabQueueAndExecute*($id);
}
}
- 创建 cli 模式下补偿脚本 Compensate.php
<?php
*/***
** 异步队列补偿脚本*
**/*
class Compensate extends CliConsoleBase
{
public function main()
{
$this->delQueue();
$this->grabQueue();
$this->compensateQueue();
}
//抢队列在30秒~1分钟30秒之间
public function grabQueue()
{
$res = QueueDao::*getQueueNotExecuted*();//获取对应数据
foreach ($res as $item) {
call_user_func_array([$item['class_name'], 'grabQueueAndExecute'], [$item['id']]);
}
}
//补偿数据
public function compensateQueue()
{
$res = QueueDao::*getExecutionFailedQueue*();//获取需要补偿的数据
foreach ($res as $item) {
Base::*compensateQueue*($item['id']);
}
}
//队列只保存三天时间
public function delQueue()
{
//每小时执行一次
if (date('i') != '11') {
return;
}
$res = QueueDao::GetThreeDaysAgoQueue();//获取需要清理的数据数据
foreach ($res as $item) {
QueueHandle::*deleteQueueAndTask*($item['id']);
}
}
}
$app = new App();
$app->run();
- 创建一个demo.php
<?php
class demo
{
function main()
{
//生成唯一id
$id = getUnoinId('queue');
//创建mns队列
$action = ProcessPictureTransferAction::*create*($id, ['id' => $id, 'url' => Request::*getUrl*('url')]);
//提交mns队列
$action && $action->submit();
}
}
总结
经过这一番操作,在开发测试阶段就可知修改原有代码对送礼物的影响。上线后由于有数据补偿很少出现网络抖动出现的数据异常,出现异常后5分钟以内报警给研发同学,相关同学速度修正数据。
花费了这些功夫做队列补偿,不能复用怎么行。以上前5个文件可完成队列自动补偿,并提供简单的方法复用
- 创建类似ProcessPictureTransferAction.php类,这个是提交mns必须要创建的类
- 创建类似PictureTransferAction.php类,这个是真正需要处理的业务逻辑