一个延时任务问题引发的思考

今天在论坛刷到一位楼主的提问帖,感觉挺有意思的,这也算是一个比较典型的业务场景了,于是决定写篇小作文探讨下这个问题。

原文请戳这里

这位同学的诉求是这样的,我用我的话来阐述一下(剧情根据原著改编,请勿介意):

当线上发生事故时,需要程序员张老三在 T 时间内处理掉,如果正常处理掉则好说,如果超过这个时间还未处理完的话,那么不好意思,产品经理李老四会给 Boss 发送一条『告状短信』,短信的内容无非是『老板,程序员张老三这个月已经第八次出问题了,不行就劝退吧』云云。更有甚者,这个规定的处理时间 T 还不是固定的,完全看李老四心情,心情好了给你一小时让你处理,心情不好,五分钟内处理不完的话,就准备收拾铺盖卷吧。

群里各路水友的回答也是众说纷纭,建议最多的就是让楼主使用 Laravel 的延时队列来实现,但是从讨论的结果来看,延时队列好像仍然不能完全实现楼主的诉求。

在讨论这个问题之前,我想先来唠点题外话:其实很多时候我们在面对问题时,往往会过早地去关注用什么解决方案,什么技术手段之类的问题,而却容易忽略了最关键的点——到底是什么问题?

不要为了解决问题而解决问题,站在历史发展的角度探索问题的来龙去脉要比解决问题更有意思。正如圣经里提到的一句话:你应该了解真相,真相会让你自由。

回归正题。

接下来我们围绕开篇提到的场景通过三种不同的方案来展开讨论。这并不是一个挑选『最优方案』的过程,我们会认真讨论每一种方案的实现过程,并进行对比,然后根据你的实际情况,来选择『最适合你的方案』。

方案一 MySQL 方案

我们先看看用最常规的 MySQL 如何来实现这个诉求。

最佳解决方案往往都是在历史演变的过程中,通过不断升级优化得到的。同样,站在山顶能够『一览众山小』,而只有经历过从山底到山顶的人,才能算的上真正地成长。

最开始的时候,李老四作为『监工』,是这么做的:

每当线上发生事故时,李老四都会准备两样东西:小本本和闹钟。小本本用来记录张老三的『案底』—— XXXXXX秒,程序员张老三线上发生重大事故,限其于XXXXX秒必须处理完。闹钟干嘛用的呢?李老四毕竟是一位敬业好员工,他告诉张老三,如果在规定的时间处理完,就来找他把案底划掉,而李老四要做的,就是盯着闹钟,每隔五分钟刷一遍小本本,看看有哪些『案底』是到期还没划掉的,然后把这些案底拎出来,逐一进行上报。

是个狠人!够专业!

用 MySQL 实现的话,大致需要以下这几个步骤:

Step 1. 生成告警记录

首先需要建一个告警记录表 alarms(就是所谓的小本本),核心字段如下:

字段 描述
event_id 事件ID
developer_uid 开发人员ID
manager_uid 产品经理ID
reported_at 告警时间
expect_resolving_at 期望处理时间
resolved_at 实际处理时间
status 处理状态 1.未处理 2.已处理
is_notify 是否通知 N.未通知 Y.已通知

Step 2. 更新记录状态

以下操作需要对记录进行更新:

当张老三处理完任务以后,需要更新 statusresolved_at 字段。

UPDATE `alarms`
SET `status` = 2, `resolved_at` = time()
WHERE `event_id` = {id};

当超时时间 T 发生变更时,需要更新 expect_resolving_at 字段。

UPDATE `alarms`
SET `expect_resolving_at` = {T}
WHERE `event_id` = {id};

Step 3. 定时任务扫描超时未完成记录

启用定时任务,定期扫描超时未完成的任务。

SELECT *
FROM `alarms`
WHERE `is_notify` = 'N'
    AND `expect_resolving_at` < NOW()
    AND (`resolved_at` > `expect_resolving_at`
        OR `resolved_at` IS NULL);

查询到记录以后,执行通知逻辑,然后还需要更新记录,避免重复处理。

UPDATE `alarms`
SET `is_notify` = 'Y'
WHERE `event_id` = {id};

这种方案虽然简单,但是比较费产品经理,每隔五分钟就扫描一遍小本本也不是个小活,特别是在记录越来越多的时候。而且如果老板如果盯得紧的话,五分钟的周期可能会嫌长,极端情况就是:第一秒的时候就已经超时了,却要等到第五分钟定时调度的时候才能触发告警,能不能接受这个『上报延迟』就取决于老板了。

方案二 Redis 队列方案

隔壁桌的产品经理刘老五就比李老四要轻松一些,毕竟他是富二代出身,不差钱,他的解决方案要比李老四的看上去『高端』一些:

刘老五整了一台『任务扫描机』(高端科技,充电五分钟,工作一星期),可以近乎无间断地扫描刘老五的专用『科技小黑板』,只要小黑板上张老三的任务到时间还没处理掉的话,就会被『任务扫描机』扫描到,并贴心地跑去告诉主人:『主人,主人,那孙子又没完成任务,该告状啦』。

整人还得靠『科技与狠活』啊,要不然像李老四一样,人还没整死,自己先累趴了。

这里我们撇开 Laravel 的队列,看看用原生的 Redis 如何实现( Laravel 延时队列的处理逻辑类似,大家可自行查阅相关资料)。

Step 1. 创建基础数据结构

首先我们需要一个 Hash 结构来存储事件的基本信息。Redis 命令如下:

HMSET OVERTIME_EVENT_DETAIL:{id} username 张老三

然后我们需要一个 List 结构来作为基础队列。 Redis 命令如下:

LPUSH OVERTIME_EVENT_QUEUE {id}

如果是普通队列的话,仅这两个基本的数据结构就可以支撑了。但是这里不同的是,我们的任务并不是立即执行的,是需要延时 T 时间执行甚至是不需要执行的(如果规定时间完成的话),所以,我们还需要一个 Zset 结构来辅助运行。Redis 命令如下:

ZSET OVERTIME_EVENT_QUEUE_DELAYED {id] T

这里的 T 代表超时时间的时间戳,在创建超时任务的时候,该时间戳已经确定。我们用 T 作为 Zset 的 score ,是为了方便处理过期时间。

Step 2. 更新记录状态

当张老三提前完成任务时,需要从 Zset 结构中删除数据。Redis 命令如下:

ZREM OVERTIME_EVENT_QUEUE_DELAYED {id}

当王老五调整超时时间 T 的时候,只需要更新 Zset 的 score 值即可,Redis 命令如下:

ZADD OVERTIME_EVENT_QUEUE_DELAYED {id} T

当添加集合内已经存在的成员时,并不会重复添加,而是根据 score 值是否变化而进行更新。

Step 3. 运行队列

这里用一段伪代码来描述队列的大概逻辑。

...
while (1) {
    // 获取到期的任务
    $overtimeEvents = Redis::zrangebyscore('OVERTIME_EVENT_QUEUE_DELAYED', '-inf', time());
    if (!empty($overtimeEvents)) {
        // 转入执行队列
        foreach ($overtimeEvents as $eventId) {
            Redis::rpush('OVERTIME_EVENT_QUEUE', $eventId);
        }
        // 从 Zset 结构删除
        Redis::zrem('OVERTIME_EVENT_QUEUE_DELAYED', $eventId);
    }
    // 队列获取记录
    $eventId = Redis::rpop('OVERTIME_EVENT_QUEUE');
    if (!is_null($eventId)) {
        // 获取事件详情
        $event = Redis:hgetall("OVERTIME_EVENT_DETAIL:{$eventId}");
        // 事件处理逻辑
    }
    // 贴心沉睡
    sleep(1);
}
...

在这段代码中,核心逻辑就是在每次『出队』操作之前,都需要先从延时 Zset 中取出已经超时的任务,然后推入队列中,走队列的处理逻辑。

之所以没有使用原生的 Laravel 队列,主要是因为这里的处理逻辑和 Laravel 队列的底层处理思路相似,用原生代码描述更容易理解。因为 Laravel 队列的处理逻辑都封装在底层代码中,如果想在 laravel 队列的基础上实现,只能在外层逻辑上做控制,这里读者如果想用的话自由发挥即可。

在 Laravel 的队列实现中,是不提供修改和删除队列中的任务操作的。所以这个场景和真实的队列场景并不完全契合。正如原文中有位水友的评论一样:『就好比屎都拉到一半了,要求等一哈儿再拉』。所以我们对原始的队列方案进行『改造』,才能实现我们的诉求。

方案三 Redis 键空间通知方案

程序员张老三对产品经理提出的方案有着不同的看法:

用的着这么费劲吗,干脆,你想要多长时间,你就定个闹钟,只要闹钟一响,我还没处理完的话,不用您费心,我收拾铺盖卷儿走人。如果闹钟一直没响呢?不好意思,我问题处理完了还等它响作甚?直接毁之。

张老三提出的这个方案,有两个新奇的地方:

  • 让闹钟自己『响起来』
  • 只要闹钟『响起来』,这事就算卯上了(是不是有点像早晨闹钟不响不起床的感觉)

那么如何实现让闹钟自己『响起来』呢?

我们都知道,Redis 有个特性叫『键空间通知』,借助这个特性,我们可以通过订阅的方式,来监听 Redis 键的各种事件,如:键的修改、删除、过期等。过期?等等,你的意思是当键过期的时候也能监听到?这不正好契合了我们的诉求么 —— 键自动过期的那一刻,不就是让闹钟『响起来』的那一刻吗?

参考文献:doc.redisfans.com/topic/notificatio...

接下来,我们就看看怎么借助『键空间通知』来实现我们的诉求。

Step 1. 生成事件监听键,并设置过期时间

当线上发生事故时,首先生成一个用于触发超时事件的 Key :OVERTIME_EVENT:{id},并设置过期时间 T (单位:秒),Redis 命令如下:

SETEX OVERTIME_EVENT:{id} {T} 1

键的命名需要注意:前半部分作为键的标识符,后半部分作为键的唯一标识,前后以英文冒号分割,方便程序处理。使用超时时间 T 作为键过期时间,键的值写入一个简单的数字 1 即可,意义不是很大。

只有一个超时事件通知的 Key 还是不够的,我们还需要一个辅助的 Key ,用于存储超时事件的详细信息(用 MySQL 存储也可以,这里为了方便也使用 Redis ),类型选择 Hash 类型即可。Redis 命令如下:

HMSET OVERTIME_EVENT_DETAIL:{id} username 张老三

这里你可能会有疑问,能不能直接把超时事件的详情用一个 json 直接存到第一个 Key 呢?这样不就省了一步吗?答案是不可以的,因为键过期事件发生在键真正过期之后,这个时候我们是无法拿得到键的内容的。所以需要一个辅助的数据结构来存储。

Step 2. 订阅键过期事件

设置完事件监听键以后,接下来就需要实现订阅的逻辑了。
首先我们需要修改 redis 的配置,开启键通知事件配置的命令如下:

redis-cli config set notify-keyspace-events Kx

这里举例的是 Redis 本地部署的操作方法,如果是云服务的话,可自行搜索云服务的配置方法。

然后使用客户端进行订阅键事件通知,命令如下:

redis-cli --csv psubscribe '__keyspace@0__:OVERTIME_EVENT:*'

这里我们给出的是 Redis 客户端的订阅逻辑,如果在 PHP 程序中处理的话,需要以『守护进程』的方式实现订阅逻辑。原理是一样的:在订阅逻辑中需要取出触发事件的键和具体触发的事件,然后作逻辑处理。

Redis 提供了两种方式的订阅,一种是基于键的,一种是基于事件的,这里了我们选择基于键的通知,即『键空间通知』,该通知会接收到键的各种事件。

Step 3. 删除或修改记录

因为我们这里设置的是『键空间通知 + 过期事件』,所以我们仅会收到键过期事件的通知。当张老三在规定的时间内处理完的话,会直接将通知键删除。Redis 命令如下:

DEL OVERTIME_EVENT:{id}

此时,闹钟就再也不会『响起来』了。

而如果李老四想修改超时时间 T 的话,直接调整事件监听键的过期时间即可。Redis 命令如下:

EXPIRE OVERTIME_EVENT:{id} {T} 

修改完以后,并不会影响过期事件的正常触发。

Step 4. 触发键过期事件

当张老三在规定的时间内未能处理完任务时,会导致事件监听键 OVERTIME_EVENT:{id} 的自动过期,这就会触发『键过期事件』,此时 Redis 订阅程序就会收到键过期的通知,然后就会执行到订阅程序中的逻辑了。

这种方案虽然看上去『小巧玲珑』,但也并非万全之策。

首先,这种玩法不建议和其他业务用的 Redis 放在一起,最好单独起一个服务。因为『键空间通知』的配置默认就是关闭的,开启它的话会有一定的性能开销,而且这个开销会随着库里 Key 的数量的增加而变大,所以,最好另起炉灶自己玩。

其次,Redis 文档中在介绍这个功能时有这样一段说明:

因为 Redis 目前的订阅与发布功能采取的是发送即忘(fire and forget)策略,所以如果你的程序需要可靠事件通知(reliable notification of events),那么目前的键空间通知可能并不适合你:当订阅事件的客户端断线时,它会丢失所有在断线期间分发给它的事件。

所以,如果对可靠性不是 100% 要求的话,那还是可以考虑的。像这种场景我觉得还是可以用一用的,毕竟还是要给程序员留点活路么不是。

写在最后的话

最后来概括下以上三种方案的优缺点:

名称 优点 缺点
方案一 简单易用 灵活性不够,特别是当调度频率变高时,会对数据库造成压力
方案二 优雅高效 需要对框架原生队列逻辑进行『改造』,且需要借助 Redis 存储才能发挥优势
方案三 小巧玲珑 需要考虑服务器配置和客户端的稳定性

当然其他实现方案还有很多,考虑篇幅限制,这里就不再一一赘述了。

最后借用《亮剑》里的一句台词来进行收尾:能拔浓的就是好膏药。适合自己的才是最好的。

本作品采用《CC 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
本帖由系统于 10个月前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 16

:+1: 这篇文章我看的可是一字不漏啊。

10个月前 评论

好文章

10个月前 评论

新技能,get起来

10个月前 评论

我是原文中那个提问贴的作者,非常感谢博主的反馈 :kissing_heart:, 你真的是太帅了。

10个月前 评论
快乐的皮拉夫 (楼主) 10个月前
快乐的皮拉夫 (楼主) 10个月前
快乐的皮拉夫 (楼主) 10个月前
Neutrino (作者) 10个月前

以上三种都没有解决你在一个循环中的堵塞问题,假设一个任务你执行了1小时,那么你的循环可能不是你预期的时间

10个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!