🎯 什麼情境該想到我
當你「需要在未來某個時間點對某一筆資料做一件事」——訂單 30 分鐘未付款要取消、優惠券到期要回收、推播要延後 2 小時發——而你正打算寫一個「每分鐘掃一次資料庫」的排程時。
⚙️ 怎麼用(步驟 / 公式)
第 0 步:先搞清楚定時掃描真正的問題在哪
常見說法是「每分鐘掃三千萬行的訂單表很浪費」。這句話一半對一半錯,先分開看:
- 掃描成本其實可以壓下來。只要有
(status, create_time)複合索引,WHERE status='UNPAID' AND create_time < now()-30min走的是索引範圍掃,不是全表掃;而且訂單一旦被取消就離開UNPAID這個前綴,待掃區間的大小是有界的(約等於「一個掃描週期內新產生的過期單」)。所以「掃全表」通常是實作沒做好,不是方案本身的死罪。 - 真正無解的是精度。掃描週期
P決定了誤差上界:任務最晚會在到期時間 + P才執行。要把誤差壓到 1 秒,就得每秒喚醒一次 DB;要省 DB,就得拉長P。精度和成本被綁死在同一個旋鈕上,這才是換方案的理由。 - 附帶的第三個問題:多實例部署時,掃描任務還得自己搞一把分散式鎖或分片,否則同一筆訂單被取消兩次。
一句話:把「被動輪詢」換成「事件到點主動觸發」,精度與成本才解耦。
方案 A:Redis ZSet 當延遲佇列(預設選這個)
把 sorted set 當成一條照時間排好的隊伍:
member= 訂單號score= 到期時間戳(下單時間 + 30 分鐘)
ZADD delay:order 1758800000000 order-1001
背後一個 worker 盯著 score 最小的成員:還沒到期就睡到它到期為止,到期就取出來執行。
取出必須是原子的,否則多實例會重複消費。用 Lua 把「查」和「刪」包成一個指令:
-- KEYS[1] = delay:order ARGV[1] = 現在時間(毫秒)
local jobs = redis.call('ZRANGE', KEYS[1], 0, ARGV[1], 'BYSCORE', 'LIMIT', 0, 1)
if #jobs == 0 then return nil end
if redis.call('ZREM', KEYS[1], jobs[1]) == 1 then
return jobs[1] -- 誰 ZREM 成功,誰負責執行
end
return nil -- 被別的實例搶走了指令版本(查證 2026-09-25):
ZRANGEBYSCORE自 Redis 6.2 起標記為 deprecated,官方建議改用ZRANGE ... BYSCORE(舊指令仍可用,不會馬上消失)。另外別想用ZMPOP/BZMPOP取代這段 Lua——它們是「彈出分數最小的成員」,沒有辦法附加score ≤ now這個條件,時間沒到也會被彈出來。
為什麼比掃 DB 好:ZSet 底層是 skiplist,取最小 score 是 O(log N);而且隊伍裡只有還沒到期的單,天生沒有無效資料要過濾。精度取決於 worker 的睡眠策略,秒級甚至百毫秒級都做得到。
Worker 的睡眠別寫死成固定間隔——拿到隊首的 score,睡 min(score - now, 上限),沒東西時睡上限。這樣閒時不空轉、忙時不遲到。
⚠️ 這個方案有一個大家常忽略的可靠性缺口:ZREM 成功之後、業務真正執行完之前,如果 worker 當掉,這個任務就人間蒸發了(Redis 裡沒了,也沒人做完)。正解是做成兩階段(visibility timeout,跟 SQS 一樣):
-- 取出時不是刪掉,而是搬到 processing,score = now + 可見度逾時
local jobs = redis.call('ZRANGE', KEYS[1], 0, ARGV[1], 'BYSCORE', 'LIMIT', 0, 1)
if #jobs == 0 then return nil end
if redis.call('ZREM', KEYS[1], jobs[1]) == 1 then
redis.call('ZADD', KEYS[2], ARGV[2], jobs[1]) -- KEYS[2] = delay:order:processing
return jobs[1]
end
return nil業務做完才 ZREM delay:order:processing(ack);另一條 worker 專門把 processing 裡逾時未 ack 的搬回主佇列。代價是變成 at-least-once,所以執行端一定要冪等——見 工具-用條件更新做狀態機冪等。
成本估算:ZSet 每個成員大約 dict entry + skiplist node + SDS 字串 ≈ 130 位元組(20 字元左右的訂單號)。一百萬筆待取消訂單 ≈ 130 MB 量級。自己用 MEMORY USAGE 量一次比較準。
方案 B:交給 Broker(TTL + 死信 / 原生延時訊息)
RabbitMQ 的經典做法是兩個佇列:
- 緩衝佇列:故意不掛消費者,訊息設 30 分鐘 TTL
- 業務佇列:真正的消費者在這裡等
訊息在緩衝佇列躺滿 TTL 變成死信 → Broker 丟進死信交換機(DLX)→ DLX 路由到業務佇列 → 消費者取消訂單。細節見 訊息過期 Message Expiration 與 死信通道 Dead Letter Channel。
好處是業務程式碼極乾淨,延遲邏輯整包外包給中介軟體,而且訊息可持久化、可靠性最高。
⚠️ 這套設計的前提是「同一個緩衝佇列裡所有訊息的 TTL 一樣」。原因是 RabbitMQ 的佇列是 FIFO,per-message TTL 只在訊息走到隊首時才被檢查。把 5 分鐘、30 分鐘的訊息混在同一條佇列,一則 30 分鐘的訊息卡在前面,後面那則 5 分鐘的就算早就過期也出不來——這就是隊頭阻塞(head-of-line blocking)。
要做多級延遲,三條路:
- 一個延遲檔次開一組佇列(5m / 30m / 2h 各一組)——延遲檔次是有限集合時最務實。
- 裝
rabbitmq_delayed_message_exchange外掛(x-delayed-message交換機),支援任意延遲,代價是它把待發訊息存在節點本機的 Mnesia/ETS,叢集與擴充性不如原生佇列。 - 換 RocketMQ:4.x 是 18 個固定延遲等級(1s/5s/10s/30s/1m…2h),5.x 才支援任意時間的定時訊息。Kafka 沒有原生延遲訊息,得靠「每個延遲檔次一個 topic」或消費端
pause/seek自己扛。
方案 C:時間輪(單機、不依賴外部中介軟體)
純記憶體、插入 O(1)、Netty HashedWheelTimer 與 Kafka 延時元件的作法。原理、圈數推導、以及「在 Go 裡其實常常不需要自己寫」的實測數字,獨立成一張卡:工具-時間輪。
怎麼選
| 維度 | Redis ZSet | Broker TTL/延時訊息 | 時間輪 |
|---|---|---|---|
| 精度 | 秒級~百毫秒(取決於 worker 睡眠) | 秒級 | 取決於 tick,可做到毫秒 |
| 外部依賴 | Redis | MQ Broker | 無,純記憶體 |
| 多實例 | Lua 原子取出保證不重複 | 天然支援 | 要自己分片 |
| 可靠性 | 看 Redis 持久化,需 visibility timeout 補洞 | 最高,可持久化 | 最差,行程重啟即全失 |
| 任意延遲長度 | 天生支援 | RabbitMQ 需多組佇列/外掛;RocketMQ 4.x 只有 18 級 | 天生支援 |
| 適用量級 | 十萬~百萬 | 百萬級以上 | 單機幾十萬 |
| 落地成本 | 低 | 中 | 中高,要自己寫或找輪子 |
預設選 Redis ZSet:夠簡單、夠準、團隊都熟,而且延遲長度任意。日均訂單上千萬、對「絕不掉單」有硬要求,再上 MQ。時間輪留給「單機、不想多一個依賴、量體大」的場景。
不管選哪個,定時任務都不要刪掉——它降級成兜底對帳
事件觸發的方案都會掉東西(Redis 沒持久化到、訊息被誤 ack、worker crash)。保留一個低頻、小窗口的對帳任務:
-- 每 10 分鐘跑一次,只看「早該取消但還活著」的那個小窗口
SELECT id FROM orders
WHERE status = 'UNPAID'
AND create_time BETWEEN now() - INTERVAL 40 MINUTE
AND now() - INTERVAL 30 MINUTE;配上 (status, create_time) 複合索引,這是一次窄範圍索引掃描,跟「每分鐘犁一遍表」是兩回事。
心法:定時任務是兜底,不是方案;主動觸發才是方案。
🧪 我實際套用的紀錄
- 2026-09-25:(待填)
⚠️ 注意 / 什麼時候不適用
- 延遲超過幾天不要放 Redis/MQ。跨天、跨月的排程(訂閱續約、帳單結算)該放 DB 加索引+每小時撈一次,記憶體型佇列存不起也不該存。
- 執行端必須冪等。三種方案在 crash/重試下都是 at-least-once,取消邏輯要能重跑不出錯 → 工具-用條件更新做狀態機冪等。
- 取消的副作用(釋放庫存、退優惠券)要跟狀態變更同一個 transaction,或走 outbox,否則會出現「訂單取消了但庫存沒還」→ 工具-Transactional-Outbox-Pattern。
- Redis 持久化設定要看清楚。AOF
everysec最多丟 1 秒的寫入,純 RDB 可能丟掉整個 snapshot 週期;重啟後要靠上面的對帳任務補。 - 不要用 Redis 的 key 過期事件(keyspace notification)當觸發器。常見的「把訂單號 SET 進 Redis、TTL 30 分鐘、訂閱
__keyevent@0__:expired」有三個洞,官方文件都寫明了:- 發了就不管(fire and forget):事件走 Pub/Sub,訂閱端斷線或重啟期間的事件全部遺失,這筆訂單就永遠不會被取消。
- 不準時:
expired事件是在 Redis 真正刪掉 key 的那一刻才發出,不是 TTL 歸零的那一刻。刪除靠「被存取時才檢查」加「背景抽樣掃描」兩條路,key 多又沒人存取時,官方的說法是「可能有顯著延遲」。 - 叢集下只在本節點發出:事件不會廣播,客戶端得訂閱每一個節點。
(補充自 https://juejin.cn/post/7597742970281525300,對照 Redis 官方 keyspace notifications 文件,查證 2026-09-27)
- ZSet 太大時拆成 N 個 key,吞吐不會自動變 N 倍。依
hash(訂單號) % N拆成 N 個 ZSet、每個一條輪詢執行緒,這個做法可行,但要知道它分攤了什麼:- 單一 Redis 實例的指令處理是單執行緒的,拆 key 只分攤了客戶端的輪詢與處理;Redis 端的處理能力不會跟著變 N 倍。
- 在 Redis Cluster 下,N 個 key 分散到不同 hash slot(落在不同節點)才真正分擔負載;如果用了 hash tag
{…}把它們綁在同一個 slot,就又全擠回一個節點。 - 代價:N 條執行緒大多時候在空輪詢;跨分片不保證全域的到期順序;日後改 N 要搬資料。
(同上來源,查證 2026-09-27)
- 延遲檔次只有一兩種、量又小(每天幾千筆)時,原本的定時任務就是對的答案,別為了漂亮多引入一個元件。
🔗 相關工具
- 工具-時間輪 — 方案 C 的完整原理與實測
- 工具-跳表 — 方案 A 憑什麼快:ZSet 大資料量時的底層結構
- 工具-用條件更新做狀態機冪等 — 取消與付款撞在一起怎麼裁決
- 工具-Transactional-Outbox-Pattern — 狀態變更與發事件不同步的正解
- 工具-MQ消費端防禦三原則 — 消費端在 at-least-once 下的三件事
- 訊息過期 Message Expiration、死信通道 Dead Letter Channel — 方案 B 的兩塊積木