用 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 那三步(INCR → HSET → LPUSH)是三個分開的指令。在第二步和第三步之間 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 / backoff | attempts + 指數退避,設定一行 |
| 沒有延遲 / 排程 | 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 系列的資料結構篇。)
接下來往哪走
- 非同步架構決策 — 「什麼該進 queue」的完整判斷尺,我卡過的那題的正面回答
- MVC vs 事件驅動 — 「全進 queue 那 MVC 算什麼」——這篇就在回答這個
- Redis 資料結構 — 骨架用到的 list/hash/set/zset,各自的脾氣