PHP异步队列补偿

290 阅读5分钟

背景

CP礼物模块,用户赠送成功会有一系列任务操作。为了快速影响此动作,使用阿里云MNS异步队列进行业务端解耦(接口层面只负责校验是否可送礼物,异步队列执行具体送礼物的逻辑)。刚开始异步队列只有一项,后来随着业务的发展任务增加十几项,由于历史原因这些任务都写在一个方法中,每一条任务会调用其他业务。这种操作会存在一下问题:

  1. 多个任务在同一个方法中执行,前面任务失败会导致后面的任务无法执行

    1. 每个任务调用其他业务异常会直接throw,而调用方未catch
  2. 网络或者IO异常时,无法快速恢复队列执行(如果直接执行可能会出现任务重复执行)

  3. 无队列补偿,数据异常只能手工处理

  4. 修改相关业务时,影响送礼物队列时不知情,可能把bug带到线上

  5. 队列异常时,未有报警,只能等待用(承)户(受)反(怒)馈(🔥)

由于以上问题,导致送礼物每周都会有数例莫名其妙的队列异常,一下是改造流程图:

解决方案

梳理一下需要做以下改动

  1. 执行队列时,多个任务拆分多个独立执行单元,之间互不影响
  2. 队列异常时,用定时任务进行补偿,超过补偿次数则飞书报警
  3. 任务应分一次性任务(不可补偿,比如扣费),可重复性任务(可多次补偿)
  4. 提供任务超过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层省略)

  1. 创建补偿队列的基础类: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);

}

}
  1. 创建图片转存队列实现类:PictureTransferAction.php

    1. 此类有两点注意 taskMethodTypeMap 的key必须是此类一个方法,此方法必须返回true
    2. 如果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;

}

}
  1. 创建队列处理类: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);

}

}
  1. 创建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);

}

}
  1. 创建 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();
  1. 创建一个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个文件可完成队列自动补偿,并提供简单的方法复用

  1. 创建类似ProcessPictureTransferAction.php类,这个是提交mns必须要创建的类
  2. 创建类似PictureTransferAction.php类,这个是真正需要处理的业务逻辑