用 BullMQ 或 Sidekiq 的日常長這樣:queue.add('寄確認信', { orderId }),一行,生效,背景就有人把信寄掉了。方便到你不會去想底下發生什麼——直到某天 job 卡住、重複執行、或憑空消失,你盯著黑盒子完全不知道從哪查起。

拆黑盒最快的方式不是讀文件,是自己刻一個。用 Redis 刻一個會動的 job queue 大概三十行;更重要的是刻完之後你會親手撞到它的坑,而每一個坑,剛好就是成熟 queue 框架幫你做對的一件事。


骨架:四個 Redis 結構湊起來就是 queue

以「電商下單後寄確認信」為例。一個最小的 job queue 只需要四個 key:

raw:id          string   發號機:INCR 產生遞增唯一的 job id
raw:job:<id>    hash     每個 job 一包:內容 + 狀態
raw:wait        list     等待區:一端 push、另一端 pop = 排隊
raw:done        list     完成區

Producer(下單的那端)三步INCR 拿一個新 id → HSET 把 job 內容存成 hash → LPUSH 把 id 推進等待佇列。使用者馬上拿到「下單成功」,不用等信寄完。

Worker(背景那端)一個迴圈BRPOP 從佇列尾端撈一個 id(沒東西就阻塞等,不用空轉輪詢)→ HGETALL 讀出內容 → 寄信 → HSET status done

跑起來:能塞、能撈、先進先出,一個貨真價實的 job queue。三十行。

然後好戲開始。


坑一:worker 一 crash,任務人間蒸發

BRPOP 把 job 從佇列撈走——此刻這個 job 只存在於 worker 的記憶體變數裡。worker 在寄信寄到一半 crash?程序死了、記憶體沒了,這封信永遠不會有人再寄。佇列裡沒有、完成區沒有、哪裡都沒有,而且系統完全不知道它消失過。

我把這個場景實際跑給自己看過:情境 A 用 RPOP 撈走後模擬 crash,事後三個區全部是空的——掉單,無聲無息。

解法是換一個指令:RPOPLPUSH(新版叫 LMOVE)——從等待區撈出來的同時,原子地在「處理中」清單留一份底。worker crash 了,job 還躺在處理中;再配一個回收機制(發現躺太久沒動靜的,搬回等待區換人做),任務就救得回來。同一個實驗的情境 B,crash 之後 job 好端端在處理中清單裡等人接手。

差別就一個指令,但這一個指令是「會掉資料的玩具」跟「可靠佇列」的分水嶺。

坑二:add 不是原子的——孤兒 job

Producer 那三步(INCRHSETLPUSH)是三個分開的指令。在第二步和第三步之間 crash 呢?job 的內容寫進去了,id 卻沒進等待佇列——一個永遠不會被任何人撈到的孤兒,還佔著記憶體。

app 端沒有乾淨的解法,因為問題出在「多步驟不原子」。正解是把整串包成一段 Lua script 丟給 Redis 原子執行——全部成功或全部沒發生。這也正是 BullMQ 的做法:你 queue.add() 那一行,底下是一段 Lua 一次做完發號、寫 hash、進佇列。

坑三之後:全是「沒有」

繼續往下用,缺的東西一個個浮出來——失敗的 job 想自動重試?沒有。重試想先等 30 秒再試(backoff)?沒有。「十分鐘後才執行」的延遲任務?沒有。壞掉的 job 重試三次還是壞,想丟去死信區別再煩人?沒有。兩台機器跑同一個排程會不會重複觸發?會。

手刻版的坑成熟框架(以 BullMQ 為例)怎麼解
add 三步不原子 → 孤兒 job整串包成 Lua,原子執行
worker crash → 任務消失撈取即留底(active 區)+ stalled 自動回收
沒有 retry / backoffattempts + 指數退避,設定一行
沒有延遲 / 排程delayed 區(sorted set,分數=該執行的時間戳)
只有 wait / done 兩態wait / active / delayed / completed / failed 完整狀態機
出事了無從觀測事件流(stream)+ 對應的 dashboard 生態
多實例重複觸發排程共享的 scheduler key + 冪等 upsert

每一格都不難懂,但每一格都很難自己做對——尤其原子性和 crash 回收,錯了不會報錯,只會在某個深夜默默掉一筆資料。

[作者補:親手跑 crash 掉單實驗當下的感想,或第一次意識到 queue.add 底下是 Lua 的時刻]


所以,刻它幹嘛?

不是為了上線用——上線請直接用 BullMQ / Sidekiq / Celery,理由上面那張表就是。刻它是為了三件事:

一、拆魔法。 現在你知道 queue.add() 展開就是「發號、寫 hash、進佇列」,Worker 就是一個 BRPOP 迴圈加留底回收。黑盒變白盒,出事的時候你知道去 Redis 裡看哪個結構。

二、懂報價。 引入一套 queue 框架的成本(依賴、學習、維運 Redis)到底買到什麼?買到的就是那七格——你現在能逐格說出口,而不是「大家都用所以我用」。

三、面試跟選型都用得上。 「如果不能用現成的 queue,你怎麼用 Redis 做一個?要注意什麼?」——這題現在你可以從骨架講到坑,再講到為什麼最後還是選現成的。

(Queue 到底在賣什麼保證——ack、worker、持久化——見系列前面的基礎篇;Redis 那幾個資料結構本身的脾氣,見 cache 系列的資料結構篇。)

接下來往哪走