延時(shí)任務(wù),顧名思義:過一段時(shí)間后才執(zhí)行的任務(wù)。例如用戶開啟計(jì)劃后 24 小時(shí)發(fā)一條推送,提醒用戶堅(jiān)持練習(xí);電商業(yè)務(wù)中,成單后72 小時(shí)未評(píng)價(jià),自動(dòng)打5分等場(chǎng)景。那么這些 延時(shí)任務(wù)怎么優(yōu)雅的實(shí)現(xiàn)呢?
首先我們想到的是 crontab: 啟動(dòng)一個(gè)crontab定時(shí)任務(wù),每小時(shí)跑一次,給開啟計(jì)劃超過24小于25小時(shí)的用戶發(fā)送push。
$userList = select uid from program_schedule where create_time >= now_time - 25 * 3600 and create_time < now_time - 24 * 3600;
foreach ($userList as $uid) {
(new Service_Push())->sendPush($uid, '練習(xí)提醒', '親,今天記得練習(xí),不要偷懶哦 ^^');
}
優(yōu)點(diǎn):
簡(jiǎn)單、快速實(shí)現(xiàn)
缺點(diǎn):1、數(shù)據(jù)量大的時(shí)候,效率比較低。
2、每次需要全量查詢一次,可能會(huì)重復(fù)計(jì)算
3、時(shí)效性不夠,因?yàn)槭且粋€(gè)小時(shí)一次,極端情況,這個(gè)用戶就是當(dāng)前小時(shí)的第一秒開啟的,那就要延遲一個(gè)小時(shí)收到推送。當(dāng)然可以通過修改 crontab 的輪詢頻次(改成一分鐘或者1秒鐘),這樣放大了 1、2 步的缺點(diǎn)。
所以這類業(yè)務(wù)一定要保證時(shí)效性,可以采用時(shí)間輪實(shí)現(xiàn):
不太會(huì)用畫圖工具,只能采用農(nóng)耕文明的手段了,哈哈,湊合看。
包括兩個(gè)數(shù)據(jù)結(jié)構(gòu):1、環(huán)形隊(duì)列:一個(gè)小時(shí)對(duì)應(yīng) 3600秒,一秒一個(gè)槽,避免轉(zhuǎn)的過快和過慢
2、任務(wù)隊(duì)列: 環(huán)上每個(gè)槽都是一個(gè) 隊(duì)列
當(dāng)前指針就是 當(dāng)前時(shí)間戳 % 3600, 每個(gè)槽都是一個(gè)隊(duì)列,隊(duì)列的每個(gè)cell包含兩個(gè)數(shù)據(jù)結(jié)構(gòu):1. cycle_num: 循環(huán)次數(shù),表示第幾圈,比如 48 小時(shí)后執(zhí)行,那 cycle_num = 47 (下標(biāo)從 0 開始)
2. data: 自定義參數(shù),包含任務(wù)名稱(task_function_name)和參數(shù)列表(params)
比如現(xiàn)在游標(biāo)在1, 現(xiàn)在有個(gè)業(yè)務(wù)是7219s后執(zhí)行,首先找游標(biāo),(7219 + 1) % 3600 = 20, 那就是插入下標(biāo) = 20 的槽 對(duì)應(yīng)的隊(duì)列, cycle_num : floor((7219 + 1) / 3600) = 2
數(shù)據(jù)插入后,啟用 crontab 或者 go cron 實(shí)現(xiàn) 一個(gè) timer,輪詢頻率是 一秒一次, 每次取當(dāng)前秒對(duì)應(yīng)的時(shí)間槽,這是一個(gè)隊(duì)列,從隊(duì)列中取 cycle_num =0 的 cell, 解析數(shù)據(jù)后, 直接扔給 對(duì)應(yīng)的 task 執(zhí)行,timer 不涉及具體的業(yè)務(wù)邏輯,然后 修改 其他的 cell, cycle_num -= 1, 表示轉(zhuǎn)了一圈了, 依次循環(huán)執(zhí)行下去。
demo: 使用redis 隊(duì)列作為存儲(chǔ)
//產(chǎn)生時(shí)間輪:
private function createTimerData() {
//一天 24 個(gè)小時(shí)
for ($i = 0; $i < 23; $i++) {
//一個(gè)小時(shí)按秒算
for ($j = 0; $j < 3600; $j++) {
$key = 'task_' . $j;
$data = [
'method' => 'send_msg',
'params' => [
'uid' => $i .'--'.$j,
'msg' => 'No:' . $i .'--'.$j
],
'cycle_num' => $i
];
Util::redisRPush($key, json_encode($data));
}
}
}
//timer:
private function executeTask() {
for ($i = 0; $i < 3600; $i++) {
$key = 'task_' . $i;
$len = Util::redisLLen($key);
for ($j = 0; $j < $len; $j++) {
$data = Util::redisLIndex($key, $j);
$data = json_decode($data, true);
if ($data['cycle_num'] == 0) {
//調(diào)用 svr-task 處理具體業(yè)務(wù)
//從列表中刪除此下標(biāo)數(shù)據(jù)
Util::redisLSet($key, $j, 'delete');
} else {
--$data['cycle_num'];
Util::redisLSet($key, $j, json_encode($data));
}
}
Util::redisLRem($key, 0, 'delete');
}
}
這里使用了 redis 實(shí)現(xiàn),為了保證數(shù)據(jù)一致性,可以落盤到數(shù)據(jù)庫(kù), redis 重啟后,從數(shù)據(jù)庫(kù) loading 到 redis 。
ps: 還有個(gè)小驚喜, redis 隊(duì)列不支持 按照對(duì)應(yīng)下標(biāo)刪除,用了兩個(gè)命令實(shí)現(xiàn)
1. 將隊(duì)列中待刪除的下標(biāo)的值 標(biāo)記為 特殊字符串
$redis->lSet($key, $index, 'delete');
2. 刪除此字符串對(duì)應(yīng)的下標(biāo)
$redis->lRem($key, 0, 'delete');
lrem(key, count, value)
根據(jù)參數(shù) count 的值,移除列表中與參數(shù) value 相等的元素。
count 的值可以是以下幾種:
count > 0 : 從表頭開始向表尾搜索,移除與 value 相等的元素,數(shù)量為 count 。
count < 0 : 從表尾開始向表頭搜索,移除與 value 相等的元素,數(shù)量為 count 的絕對(duì)值。
count = 0 : 移除表中所有與 value 相等的值。