今天在论坛刷到一位楼主的提问帖,感觉挺有意思的,这也算是一个比较典型的业务场景了,于是决定写篇小作文探讨下这个问题。
这位同学的诉求是这样的,我用我的话来阐述一下(剧情根据原著改编,请勿介意):
当线上发生事故时,需要程序员张老三在 T 时间内处理掉,如果正常处理掉则好说,如果超过这个时间还未处理完的话,那么不好意思,产品经理李老四会给 Boss 发送一条『告状短信』,短信的内容无非是『老板,程序员张老三这个月已经第八次出问题了,不行就劝退吧』云云。更有甚者,这个规定的处理时间 T 还不是固定的,完全看李老四心情,心情好了给你一小时让你处理,心情不好,五分钟内处理不完的话,就准备收拾铺盖卷吧。
群里各路水友的回答也是众说纷纭,建议最多的就是让楼主使用 Laravel 的延时队列来实现,但是从讨论的结果来看,延时队列好像仍然不能完全实现楼主的诉求。
在讨论这个问题之前,我想先来唠点题外话:其实很多时候我们在面对问题时,往往会过早地去关注用什么解决方案,什么技术手段之类的问题,而却容易忽略了最关键的点——到底是什么问题?
回归正题。
接下来我们围绕开篇提到的场景通过三种不同的方案来展开讨论。这并不是一个挑选『最优方案』的过程,我们会认真讨论每一种方案的实现过程,并进行对比,然后根据你的实际情况,来选择『最适合你的方案』。
我们先看看用最常规的 MySQL 如何来实现这个诉求。
最开始的时候,李老四作为『监工』,是这么做的:
每当线上发生事故时,李老四都会准备两样东西:小本本和闹钟。小本本用来记录张老三的『案底』—— X年X月X日X时X分X秒,程序员张老三线上发生重大事故,限其于X年X月X时X分X秒必须处理完。闹钟干嘛用的呢?李老四毕竟是一位敬业好员工,他告诉张老三,如果在规定的时间处理完,就来找他把案底划掉,而李老四要做的,就是盯着闹钟,每隔五分钟刷一遍小本本,看看有哪些『案底』是到期还没划掉的,然后把这些案底拎出来,逐一进行上报。
是个狠人!够专业!
用 MySQL 实现的话,大致需要以下这几个步骤:
首先需要建一个告警记录表 alarms
(就是所谓的小本本),核心字段如下:
字段 | 描述 |
---|---|
event_id | 事件ID |
developer_uid | 开发人员ID |
manager_uid | 产品经理ID |
reported_at | 告警时间 |
expect_resolving_at | 期望处理时间 |
resolved_at | 实际处理时间 |
status | 处理状态 1.未处理 2.已处理 |
is_notify | 是否通知 N.未通知 Y.已通知 |
以下操作需要对记录进行更新:
当张老三处理完任务以后,需要更新 status
和 resolved_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};
启用定时任务,定期扫描超时未完成的任务。
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};
这种方案虽然简单,但是比较费产品经理,每隔五分钟就扫描一遍小本本也不是个小活,特别是在记录越来越多的时候。而且如果老板如果盯得紧的话,五分钟的周期可能会嫌长,极端情况就是:第一秒的时候就已经超时了,却要等到第五分钟定时调度的时候才能触发告警,能不能接受这个『上报延迟』就取决于老板了。
隔壁桌的产品经理刘老五就比李老四要轻松一些,毕竟他是富二代出身,不差钱,他的解决方案要比李老四的看上去『高端』一些:
刘老五整了一台『任务扫描机』(高端科技,充电五分钟,工作一星期),可以近乎无间断地扫描刘老五的专用『科技小黑板』,只要小黑板上张老三的任务到时间还没处理掉的话,就会被『任务扫描机』扫描到,并贴心地跑去告诉主人:『主人,主人,那孙子又没完成任务,该告状啦』。
整人还得靠『科技与狠活』啊,要不然像李老四一样,人还没整死,自己先累趴了。
这里我们撇开 Laravel 的队列,看看用原生的 Redis 如何实现( Laravel 延时队列的处理逻辑类似,大家可自行查阅相关资料)。
首先我们需要一个 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
当张老三提前完成任务时,需要从 Zset 结构中删除数据。Redis 命令如下:
ZREM OVERTIME_EVENT_QUEUE_DELAYED {id}
当王老五调整超时时间 T 的时候,只需要更新 Zset 的 score 值即可,Redis 命令如下:
ZADD OVERTIME_EVENT_QUEUE_DELAYED {id} T
这里用一段伪代码来描述队列的大概逻辑。
...
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 队列的基础上实现,只能在外层逻辑上做控制,这里读者如果想用的话自由发挥即可。
程序员张老三对产品经理提出的方案有着不同的看法:
用的着这么费劲吗,干脆,你想要多长时间,你就定个闹钟,只要闹钟一响,我还没处理完的话,不用您费心,我收拾铺盖卷儿走人。如果闹钟一直没响呢?不好意思,我问题处理完了还等它响作甚?直接毁之。
张老三提出的这个方案,有两个新奇的地方:
那么如何实现让闹钟自己『响起来』呢?
我们都知道,Redis 有个特性叫『键空间通知』,借助这个特性,我们可以通过订阅的方式,来监听 Redis 键的各种事件,如:键的修改、删除、过期等。过期?等等,你的意思是当键过期的时候也能监听到?这不正好契合了我们的诉求么 —— 键自动过期的那一刻,不就是让闹钟『响起来』的那一刻吗?
接下来,我们就看看怎么借助『键空间通知』来实现我们的诉求。
当线上发生事故时,首先生成一个用于触发超时事件的 Key :OVERTIME_EVENT:{id}
,并设置过期时间 T (单位:秒),Redis 命令如下:
SETEX OVERTIME_EVENT:{id} {T} 1
只有一个超时事件通知的 Key 还是不够的,我们还需要一个辅助的 Key ,用于存储超时事件的详细信息(用 MySQL 存储也可以,这里为了方便也使用 Redis ),类型选择 Hash 类型即可。Redis 命令如下:
HMSET OVERTIME_EVENT_DETAIL:{id} username 张老三
设置完事件监听键以后,接下来就需要实现订阅的逻辑了。
首先我们需要修改 redis 的配置,开启键通知事件配置的命令如下:
redis-cli config set notify-keyspace-events Kx
然后使用客户端进行订阅键事件通知,命令如下:
redis-cli --csv psubscribe '__keyspace@0__:OVERTIME_EVENT:*'
这里我们给出的是 Redis 客户端的订阅逻辑,如果在 PHP 程序中处理的话,需要以『守护进程』的方式实现订阅逻辑。原理是一样的:在订阅逻辑中需要取出触发事件的键和具体触发的事件,然后作逻辑处理。
因为我们这里设置的是『键空间通知 + 过期事件』,所以我们仅会收到键过期事件的通知。当张老三在规定的时间内处理完的话,会直接将通知键删除。Redis 命令如下:
DEL OVERTIME_EVENT:{id}
此时,闹钟就再也不会『响起来』了。
而如果李老四想修改超时时间 T 的话,直接调整事件监听键的过期时间即可。Redis 命令如下:
EXPIRE OVERTIME_EVENT:{id} {T}
修改完以后,并不会影响过期事件的正常触发。
当张老三在规定的时间内未能处理完任务时,会导致事件监听键 OVERTIME_EVENT:{id}
的自动过期,这就会触发『键过期事件』,此时 Redis 订阅程序就会收到键过期的通知,然后就会执行到订阅程序中的逻辑了。
这种方案虽然看上去『小巧玲珑』,但也并非万全之策。
首先,这种玩法不建议和其他业务用的 Redis 放在一起,最好单独起一个服务。因为『键空间通知』的配置默认就是关闭的,开启它的话会有一定的性能开销,而且这个开销会随着库里 Key 的数量的增加而变大,所以,最好另起炉灶自己玩。
其次,Redis 文档中在介绍这个功能时有这样一段说明:
所以,如果对可靠性不是 100% 要求的话,那还是可以考虑的。像这种场景我觉得还是可以用一用的,毕竟还是要给程序员留点活路么不是。
最后来概括下以上三种方案的优缺点:
名称 | 优点 | 缺点 |
---|---|---|
方案一 | 简单易用 | 灵活性不够,特别是当调度频率变高时,会对数据库造成压力 |
方案二 | 优雅高效 | 需要对框架原生队列逻辑进行『改造』,且需要借助 Redis 存储才能发挥优势 |
方案三 | 小巧玲珑 | 需要考虑服务器配置和客户端的稳定性 |
当然其他实现方案还有很多,考虑篇幅限制,这里就不再一一赘述了。
最后借用《亮剑》里的一句台词来进行收尾:能拔浓的就是好膏药。适合自己的才是最好的。
如果觉得博客文章对您有帮助,异或土豪有钱任性,可以通过以下扫码向我捐助。也可以动动手指,帮我分享和传播。您的肯定,是我不懈努力的动力!感谢各位亲~