一個延時任務問題引發的思考

快樂的皮拉夫發表於2023-05-01

今天在論壇刷到一位樓主的提問帖,感覺挺有意思的,這也算是一個比較典型的業務場景了,於是決定寫篇小作文探討下這個問題。

原文請戳這裡

這位同學的訴求是這樣的,我用我的話來闡述一下(劇情根據原著改編,請勿介意):

當線上發生事故時,需要程式設計師張老三在 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 協議》,轉載必須註明作者和本文連結
你應該瞭解真相,真相會讓你自由。

相關文章