延時任務,顧名思義:過一段時間后才執行的任務。例如用戶開啟計劃后 24 小時發一條推送,提醒用戶堅持練習;電商業務中,成單后72 小時未評價,自動打5分等場景。那么這些 延時任務怎么優雅的實現呢?
首先我們想到的是 crontab: 啟動一個crontab定時任務,每小時跑一次,給開啟計劃超過24小于25小時的用戶發送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, '練習提醒', '親,今天記得練習,不要偷懶哦 ^^');
}
優點:
簡單、快速實現
缺點:1、數據量大的時候,效率比較低。
2、每次需要全量查詢一次,可能會重復計算
3、時效性不夠,因為是一個小時一次,極端情況,這個用戶就是當前小時的第一秒開啟的,那就要延遲一個小時收到推送。當然可以通過修改 crontab 的輪詢頻次(改成一分鐘或者1秒鐘),這樣放大了 1、2 步的缺點。
所以這類業務一定要保證時效性,可以采用時間輪實現:
不太會用畫圖工具,只能采用農耕文明的手段了,哈哈,湊合看。

包括兩個數據結構:1、環形隊列:一個小時對應 3600秒,一秒一個槽,避免轉的過快和過慢
2、任務隊列: 環上每個槽都是一個 隊列
當前指針就是 當前時間戳 % 3600, 每個槽都是一個隊列,隊列的每個cell包含兩個數據結構:1. cycle_num: 循環次數,表示第幾圈,比如 48 小時后執行,那 cycle_num = 47 (下標從 0 開始)
2. data: 自定義參數,包含任務名稱(task_function_name)和參數列表(params)
比如現在游標在1, 現在有個業務是7219s后執行,首先找游標,(7219 + 1) % 3600 = 20, 那就是插入下標 = 20 的槽 對應的隊列, cycle_num : floor((7219 + 1) / 3600) = 2
數據插入后,啟用 crontab 或者 go cron 實現 一個 timer,輪詢頻率是 一秒一次, 每次取當前秒對應的時間槽,這是一個隊列,從隊列中取 cycle_num =0 的 cell, 解析數據后, 直接扔給 對應的 task 執行,timer 不涉及具體的業務邏輯,然后 修改 其他的 cell, cycle_num -= 1, 表示轉了一圈了, 依次循環執行下去。
demo: 使用redis 隊列作為存儲
//產生時間輪:
private function createTimerData() {
//一天 24 個小時
for ($i = 0; $i < 23; $i++) {
//一個小時按秒算
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) {
//調用 svr-task 處理具體業務
//從列表中刪除此下標數據
Util::redisLSet($key, $j, 'delete');
} else {
--$data['cycle_num'];
Util::redisLSet($key, $j, json_encode($data));
}
}
Util::redisLRem($key, 0, 'delete');
}
}
這里使用了 redis 實現,為了保證數據一致性,可以落盤到數據庫, redis 重啟后,從數據庫 loading 到 redis 。
ps: 還有個小驚喜, redis 隊列不支持 按照對應下標刪除,用了兩個命令實現
1. 將隊列中待刪除的下標的值 標記為 特殊字符串
$redis->lSet($key, $index, 'delete');
2. 刪除此字符串對應的下標
$redis->lRem($key, 0, 'delete');
lrem(key, count, value)
根據參數 count 的值,移除列表中與參數 value 相等的元素。
count 的值可以是以下幾種:
count > 0 : 從表頭開始向表尾搜索,移除與 value 相等的元素,數量為 count 。
count < 0 : 從表尾開始向表頭搜索,移除與 value 相等的元素,數量為 count 的絕對值。
count = 0 : 移除表中所有與 value 相等的值。