📌 30 秒摘要

「訂單 30 分鐘未付款怎麼自動取消?」答「寫個定時任務每分鐘掃資料庫」會被面試官嫌棄——不是錯,是笨重:精度上限被掃描週期綁死,而且多實例還要自己處理重複。正解是把被動輪詢換成到點主動觸發,三個方案:Redis ZSet(member=訂單號、score=到期時間,Lua 原子取出,預設選它)、MQ 死信佇列(緩衝佇列設 TTL → 過期成死信 → DLX 轉業務佇列,可靠性最高但同佇列 TTL 必須一致)、時間輪(純記憶體、插入 O(1),Netty/Kafka 的作法)。追問鏈裡最值錢的一題:取消撞上付款,別三步走,直接 UPDATE ... WHERE status='UNPAID' 用影響行數判勝負。

🗺 心智圖(Canvas)

訂單超時自動取消的三種延遲任務方案

訂單超時自動取消

N 分鐘後做一件事

① 定時掃描錯在哪

② 方案 A|Redis ZSet (預設選這個)

③ 方案 B|Broker TTL + 死信

④ 方案 C|時間輪

⑤ 追問鏈

精度被綁死 誤差上界=掃描週期。 要準就得縮短週期=壓 DB, 兩者同一個旋鈕 → 這才是換方案的理由。

「掃全表」要打折 有 (status, create_time) 複合索引就是 索引範圍掃;取消後離開 UNPAID 前綴, 待掃區間有界。

member=訂單號 score=到期時間戳 Lua 原子取出:ZRANGE ... BYSCORE + ZREM (Redis 6.2 起 ZRANGEBYSCORE 已 deprecated)

⚠️ ZREM 後 crash=任務蒸發 要做 visibility timeout 兩階段: 搬到 processing set,做完才 ack。

🎯 什麼情境該想到我

當你「需要在未來某個時間點對某一筆資料做一件事」——訂單 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 的經典做法是兩個佇列:

  1. 緩衝佇列:故意不掛消費者,訊息設 30 分鐘 TTL
  2. 業務佇列:真正的消費者在這裡等

訊息在緩衝佇列躺滿 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 ZSetBroker TTL/延時訊息時間輪
精度秒級~百毫秒(取決於 worker 睡眠)秒級取決於 tick,可做到毫秒
外部依賴RedisMQ 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)
  • 延遲檔次只有一兩種、量又小(每天幾千筆)時,原本的定時任務就是對的答案,別為了漂亮多引入一個元件。

🔗 相關工具

緩衝佇列(無消費者+TTL)→ 死信 → DLX → 業務佇列 ⚠️ 同佇列 TTL 必須一致,否則隊頭阻塞 RocketMQ 4.x 只有 18 個延遲等級;Kafka 無原生延遲

🎯 什麼情境該想到我

當你「訊息有時效,過了期限就不該再被處理」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:標示訊息在多久後失效,避免接收端處理到過時、無意義的資料。

做法要點:

  • 在訊息標頭設定過期時間(有效期限)。
  • 若訊息在期限內仍未被消費,訊息系統將其作廢。
  • 作廢的訊息通常轉送到 Dead Letter Channel(死信通道),而非交付給接收者。

★ 各家 broker 的 TTL 機制(粒度差很多)

查證 2026-09-25。

broker機制★ 粒度
RabbitMQx-message-ttl(queue 級)、expiration(每則訊息)★ 兩種都有;過期會進 DLX(見下)
SQS訊息 retention★ 只有 queue 級:預設 4 天,最短 60 秒,最長 14 天
Kafkaretention.ms(topic 級)★★ 沒有 per-message TTL;而且它是「保留期」不是「過期」
NATS JetStreamstream 的 max_agestream 級

★★ Kafka 的 retention 不是訊息過期——這是最常見的誤解

★ RabbitMQ 的 TTL:訊息過期 → 對消費者「不可見」(或進 DLX)
★ Kafka 的 retention:★ 到期的是「整個 log segment」,而且是在「清理時」才刪
  → 訊息在 retention 內都會被投遞,包含「業務上已經過期」的
  → ★★ Kafka 不會幫你擋過期訊息。你必須在消費端自己判斷
// ★ Kafka 上必須自己做「業務層過期」
if !e.ExpiresAt.IsZero() && time.Now().After(e.ExpiresAt) {
    metrics.ExpiredMessages.Inc()          // ★ 一定要有指標,否則你不知道在丟東西
    return msg.Ack()                       // ★ 過期就 ack 掉,不要重試
}

★★ RabbitMQ 的兩個陷阱

① ★ queue TTL 是「惰性」的
   x-message-ttl 到期的訊息不會馬上消失 —— 它在「到隊首時」才被判定過期
   → ★ 佇列中間的過期訊息仍佔著空間;而且如果前面卡住,它們會一直留著

② ★★ 整個佇列過期時,裡面的訊息「不會」進 DLX
   (官方文件明說,2026-09-25 查證)
   → ★ 你設了 DLX 也救不到,訊息靜默消失

★★ 最重要的設計問題:過期的語義是什麼

★ 三種完全不同的「過期」,不要混:
① 技術性過期(broker retention):★ 我們不想無限保存訊息
   → 這是容量管理,不是業務規則

② ★ 業務性過期(這則訊息做了也沒意義了)
   「三小時前的限時折扣通知」「已經取消的訂單的出貨命令」
   → ★★ 這必須放在訊息內容裡(ExpiresAt 欄位),由消費端判斷
   → ★ 依賴 broker 的 TTL 來表達業務規則,是把業務邏輯藏進基礎設施設定

③ ★ 請求逾時(我不想等了)
   → 那是請求者端的事(見 [[請求-回覆]] 的 deadline)

★★ 判準:這個時限是「業務規則」還是「容量管理」?
業務規則 → 放在訊息裡(ExpiresAt),讓它可測試、可 code review、進版控。
容量管理 → 放 broker 設定。

★★ 最危險的陷阱:過期的訊息去哪了

① ★ 靜默丟棄(SQS retention、Kafka retention)
   → ★★ 沒有任何指標顯示你丟了東西。這是「資料默默消失」的典型
   → 對策:監控 consumer lag,並對「lag 接近 retention」告警

② 進 DLQ(RabbitMQ 的 TTL → DLX)
   → ★ 比較好,但 DLQ 會被過期訊息灌滿,淹沒真正的失敗
   → 對策:DLQ 訊息帶 failure_kind=expired,分開計數(見 [[死信通道]])

③ ★ 業務層過期(自己判斷)
   → ★ 最好,因為你能記錄「丟了什麼、為什麼」

★★ consumer lag > retention 是最該告警的一個指標:
它代表「訊息正在被刪掉,而消費者還沒讀到」——資料正在遺失,而沒有任何錯誤。

★ 什麼時候不要設過期

  • ★ 事件通道:事件是歷史事實。OrderPlaced 過了三天仍然為真。
    → 設 TTL 等於「三天後這件事就不算發生過」,幾乎一定是錯的。
  • ★ 需要重放/回填的通道:TTL 就是你重放能力的上限。
  • 命令通常也不該過期,除非業務上真的有時效(限時優惠、即時報價)。

★ 兄弟辨析

差別
死信通道★ TTL 過期是進 DLQ 的原因之一(RabbitMQ)
訊息存放區需要長期保留時,把訊息落地
通道清除器★ 主動清掉整個通道,不是逐則過期
請求-回覆請求端的 deadline 是另一回事

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 過期後的行為(丟棄或轉死信)依訊息系統而定。

  • 各節點時鐘不同步會影響過期判定。

  • 只適合有明確時效性的訊息;長期有效的資料不需設過期。

  • ★★ Kafka 的 retention.ms 不是訊息過期。retention 內的訊息都會被投遞,包含業務上已過期的——必須在消費端自己判斷。

  • ★★ RabbitMQ:整個佇列過期時訊息不會進 DLX,直接消失(2026-09-25 官方文件查證)。

  • ★ RabbitMQ 的 queue TTL 是惰性的:訊息在「到隊首時」才判定過期,中間的仍佔空間。

  • ★★ 業務性時限要放在訊息裡(ExpiresAt),不要用 broker TTL 表達——否則業務邏輯藏進基礎設施設定,不可測、不進版控。

  • ★★ consumer lag > retention 是最該告警的指標:資料正在遺失而沒有任何錯誤。

  • ★ 過期訊息被靜默丟棄時沒有任何指標。自己判斷過期時一定要加計數器。

  • ★ 事件通道不該設 TTL。事件是歷史事實,設 TTL 等於「過了三天就不算發生過」。

  • TTL 就是你重放能力的上限。需要回填就不要設短。

  • RabbitMQ 的 TTL 過期進 DLQ 會淹沒真正的失敗。要用 failure_kind 分開計數。

🔗 相關工具

🎯 什麼情境該想到我

當你「有訊息無法被成功投遞或處理(重試多次仍失敗、過期、找不到目的地)」的時候:需要一個地方收容它,而不是無限卡在原通道。

⚙️ 怎麼用(步驟 / 公式)

意圖:當訊息系統無法投遞一則訊息時,把它移到專門的死信通道,而不是丟棄或阻塞。

做法 / 結構要點:

  • 由訊息系統(而非應用邏輯)在投遞失敗、逾時、過期或重試上限時,把訊息轉入死信通道。
  • 死信通道集中收容這些訊息,供監控、告警、人工或自動重處理。
  • 保護正常通道不被投不出去的訊息塞住。

★★ 各家 broker 的實際機制(差異很大,選型時最該問的一題)

查證 2026-09-25。

broker機制★ 關鍵細節
RabbitMQ 4.xDead Letter Exchange(DLX),用 policy 設定★ 四個觸發條件見下。官方建議用 policy 而非硬寫 x-arguments
SQSRedrive policy:maxReceiveCount 超過就進 DLQ★ 計數是「被接收次數」,處理超時被重送也算一次
Kafka★★ 沒有原生 DLQ要自己寫(catch → produce 到 xxx.dlq topic);Kafka Connect 與 Streams 有各自的 DLQ 設定
NATS JetStreammax_deliver + advisory 事件超過次數會發 advisory,要自己接

RabbitMQ 的四個 dead-letter 觸發條件(官方文件原文,2026-09-25):

① 訊息被 nack/reject 且 requeue=false(AMQP 1.0 的 rejected outcome)
② 訊息因 per-message TTL 過期
③ 訊息因佇列超過長度上限被丟棄
④ ★ 訊息被退回 quorum queue 的次數超過 delivery-limit
★★ 但:整個佇列過期時,裡面的訊息「不會」被 dead-letter —— 直接消失

★★ 最後那一條是最容易踩的陷阱:你設了 DLX,也設了 queue TTL,
結果佇列整個過期時訊息靜默消失
,DLQ 裡什麼都沒有。

★ Kafka 沒有原生 DLQ,這是選型時最該知道的一件事。
你必須自己實作「重試 N 次 → 發到 dlq topic」,而且要自己管重試計數(放在標頭)。

★ Go 的 DLQ 實作骨架(Kafka 版)

const maxAttempts = 5
 
func consume(ctx context.Context, msg Message) error {
    attempt := attemptFromHeader(msg) + 1        // ★ 重試次數放標頭,Kafka 不幫你記
 
    if err := handle(ctx, msg); err != nil {
        if errors.Is(err, ErrInvalidMessage) {    // ★ 永久錯誤 → 不重試
            return toDLQ(ctx, msg, err, attempt, "invalid")
        }
        if attempt >= maxAttempts {
            return toDLQ(ctx, msg, err, attempt, "exhausted")
        }
        // ★ 重試:延遲重發(Kafka 沒有原生延遲投遞 → 用分層 retry topic)
        return republish(ctx, retryTopicFor(attempt), msg, attempt)
    }
    return nil
}

★ 「分層 retry topic」是 Kafka 的標準做法:
retry.5s / retry.1m / retry.10m 各一個 topic,消費者先 sleep 到時間再處理。
⚠️ 直接在原 topic 上重試會阻塞整個分區(head-of-line blocking)——
這是 Kafka 當任務佇列時最痛的一點(見 訊息傳遞)。

★★ DLQ 訊息一定要帶的欄位

type DeadLetter struct {
    Original       json.RawMessage   `json:"original"`         // ★ 原始 body,不改一個位元
    Headers        map[string]string `json:"headers"`
    SourceTopic    string            `json:"source_topic"`     // ★ 重放要送回哪裡
    Partition      int32             `json:"partition"`
    Offset         int64             `json:"offset"`           // ★ 能回去原地查
    Attempts       int               `json:"attempts"`
    FailureKind    string            `json:"failure_kind"`     // ★★ invalid / transient / exhausted
    LastError      string            `json:"last_error"`
    FirstFailedAt  time.Time         `json:"first_failed_at"`
    ServiceVersion string            `json:"service_version"`  // ★ 哪一版判定它失敗
}

★ FailureKind 是最有價值的一欄:它讓「上游發壞資料」和「我的服務掛了」
在指標上分得開(見 無效訊息通道)。

★★ DLQ 必須配的三件事,以及各自的失敗樣貌

缺了什麼會發生什麼
沒有 DLQ★ 毒訊息卡住整個佇列——一直重試同一筆,後面全部堵住
有 DLQ、沒有告警★ DLQ 累積幾萬筆沒人看 = 資料默默遺失,而你以為有防護
有告警、沒有重放★ 知道壞了但救不回來,只能人工補資料
有重放、消費端不冪等★★ 重放製造重複副作用(重複扣款、重複寄信)

★ 告警要看「速率」而非「總數」:
DLQ 有 3 筆是資料品質問題(開票追蹤);一分鐘進 500 筆是事故(立刻叫人)。

★ 兩個常被忽略的細節

① ★ DLQ 自己也會滿、也有 retention
   SQS 的 DLQ 仍受 retention 限制(預設 4 天,最長 14 天,查證 2026-09-25)
   → ★ 「訊息進了 DLQ 就安全了」是錯的。兩週內沒人處理照樣消失
   → 需要長期保留就把 DLQ 訊息落地到資料庫/S3([[訊息存放區]])

② ★ 重放的順序
   DLQ 裡的訊息重放回主通道時,會插在「當前訊息」後面 ⇒ 順序亂了
   → ★ 對順序敏感的流程,重放前要先暫停該實體的處理,或用版本號防舊蓋新

★ 兄弟辨析

差別
無效訊息通道★ 應用程式判定「看不懂」;DLQ 是 broker 判定「交付不了」
訊息過期TTL 過期是進 DLQ 的原因之一(RabbitMQ 條件②)
繞道刻意把訊息導去別處(除錯/驗證),不是失敗處理
訊息存放區保存所有訊息以供查詢;DLQ 只存失敗的

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 與無效訊息通道分工:死信是「系統無法投遞/處理」,無效訊息是「接收端判定內容語意有問題」。

  • 死信通道會堆積,需要監看與處理策略,否則問題只是被藏起來。

  • ★★ Kafka 沒有原生 DLQ。重試計數、延遲重試、DLQ topic 全部要自己寫。

  • ★★ RabbitMQ:整個佇列過期時,訊息不會進 DLX,直接消失。 設了 DLX 也救不到。

  • ★ 在 Kafka 原 topic 上重試會阻塞整個分區。要用分層 retry topic。

  • ★ SQS 的 maxReceiveCount 算的是「被接收次數」,處理超時被重送也算——所以處理慢會意外進 DLQ。

  • ★★ DLQ 也有 retention(SQS 預設 4 天、最長 14 天)。「進了 DLQ 就安全」是錯的。

  • ★ 有 DLQ 沒告警 = 資料默默遺失,而你以為有防護。告警要看速率而非總數。

  • ★★ 重放前必須確認消費端冪等,否則重放製造重複扣款/重複寄信。

  • ★ 重放會打亂順序。對順序敏感的流程要先暫停該實體的處理,或用版本號防舊蓋新。

  • DLQ 訊息要帶 source topic/partition/offset/failure kind/service version,否則救不回來也查不出原因。

🔗 相關工具

slot=(cur+d/T)%S circle=(d/T)/S 插入 O(1),但每任務被空摸 d/(S×T) 次 實測 20 萬 × 30 分鐘 = 每任務 30 次

Go 實測:time.AfterFunc 20 萬個 = 69 ns/op、32 MB → 十萬量級別自己造輪子

🎯 什麼情境該想到我

當你「單機要同時管理成千上萬個『N 秒後做一件事』的任務」——連線閒置逾時、心跳偵測、重試退避、單機版延遲佇列——而每個任務各開一個 timer 讓你開始擔心效能時。

⚙️ 怎麼用(步驟 / 公式)

結構:一塊機械秒錶

想像一個有 S 個刻度的錶盤,指針每 T(tick)走一格:

  • 任務延遲 d → 落在第 (cur + d/T) mod S 格
  • 每格掛一條鏈結串列,插入就是 append → O(1)
  • 指針走到某格,就把那格的串列拿出來執行

不排序、不比大小,這是它和最小堆最大的差別。

圈數(circle):讓小錶盤裝下長延遲

錶盤只有 S 格,最多表示 S × T 的延遲。要掛 30 分鐘、而錶盤只有 60 格 × 1 秒:

steps  = d / T          = 1800 秒 / 1 秒 = 1800 格
slot   = (cur + steps) % S = (cur + 1800) % 60
circle = steps / S      = 1800 / 60 = 30 圈

任務掛上去時記 circle = 30;指針每繞完一圈路過這格就把圈數減 1,減到 0 才真的執行。

type task struct {
    id     int64
    circle int // 還要等幾圈
}
 
// 新增:O(1)
func (w *wheel) add(id int64, d time.Duration) {
    steps := int(d / w.tick)
    slot := (w.cur + steps) % len(w.slots)
    w.slots[slot].PushBack(&task{id: id, circle: steps / len(w.slots)})
}
 
// 每個 tick 推進一格
func (w *wheel) advance() {
    w.cur = (w.cur + 1) % len(w.slots)
    l := w.slots[w.cur]
    for e := l.Front(); e != nil; {
        next := e.Next()
        t := e.Value.(*task)
        t.circle--
        if t.circle == 0 {
            fire(t.id)
            l.Remove(e)
        }
        e = next
    }
}

★「插入和取出都是 O(1)」這句話只對了一半

這是到處被抄、但少有人講清楚的地方。插入確實是 O(1),但單層+圈數的版本,每個任務在真正到期前會被「摸」很多次:

每個任務被觸碰次數 = circle = d / (S × T)

也就是說,錶盤一圈的長度 S × T 比延遲 d 小多少倍,任務就被空摸多少次。這是攤還成本不是 O(1),只是常數很小(動一個 int)。

本機實測(Go 1.26.4 darwin/arm64,2026-09-25),20 萬個 30 分鐘延遲任務、60 格 × 1 秒錶盤:

項目數值
時間輪插入 20 萬筆4.6 ms(23 ns/op),heap +12.8 MB
time.AfterFunc 插入 20 萬筆13.9 ms(69 ns/op),heap +31.9 MB
推進 1800 格(模擬 30 分鐘)觸碰任務 600 萬次 / 到期 20 萬個 = 每任務被摸 30.0 次,純推進耗時 9.6 ms

「每任務被摸 30 次」跟公式 1800 / (60 × 1) = 30 完全吻合。

什麼時候這個常數會咬人:把 tick 降到 1 ms、錶盤 512 格(一圈 0.512 秒),同樣 30 分鐘的延遲 → circle = 1800000/512 ≈ 3516 圈。20 萬個任務平均每格 390 個,指針每毫秒就要空摸 390 次 ≈ 每秒 39 萬次無用的遞減。單層時間輪在「高精度 × 長延遲」的組合下會退化。

解法是階層時間輪(hierarchical timing wheel):像時鐘的秒針/分針/時針,秒輪轉滿一圈就讓分輪走一格,分輪那一格的任務再「降級」灑回秒輪。任務只在跨層時被搬動,每個任務的總搬動次數 = 層數(常數),這才是真正的攤還 O(1)。Kafka 的 DelayedOperationPurgatory 用階層時間輪;Netty 的 HashedWheelTimer 用的是單層+圈數(所以 Netty 文件才會建議別拿它掛數量極大又極長的任務)。

★ 在 Go 裡,先別急著自己寫

Java 生態需要 HashedWheelTimer,是因為 java.util.Timer / ScheduledThreadPoolExecutor 用的是最小堆,插入 O(log n) 且每個任務要一個物件。Go runtime 的 timer 是「每個 P 一個最小堆」,插入/刪除也是 O(log n)——但上面的實測告訴我們:20 萬個 timer 也只要 69 ns/op、32 MB。

所以在 Go 的實務判準是:

  • < 10 萬個 timer:直接 time.AfterFunc / time.After,不要自己造輪子。省下來的心力拿去做別的。
  • 幾十萬~百萬,且任務極輕(純標記逾時):時間輪的 3 倍插入速度、2.5 倍記憶體優勢才開始有意義。
  • 真的要用:先找現成的(github.com/RussellLuo/timingwheel 這類階層實作),別手寫單層版本。

🧪 我實際套用的紀錄

  • 2026-09-25:在 Go 1.26.4 上把單層時間輪與 time.AfterFunc 對跑 20 萬個 30 分鐘任務,數字如上表。結論是 20 萬這個量級 Go 原生 timer 完全夠用,時間輪的價值在「任務極輕+量更大」的場景。

⚠️ 注意 / 什麼時候不適用

  • 純記憶體,行程重啟任務全失。要持久性就得每次 add 同步寫一份到 Redis/DB,那你多半該直接用 工具-延遲任務方案選型 的方案 A。
  • 多實例要自己分片(依 orderId % N 決定歸誰管),時間輪本身沒有任何跨行程協調能力。
  • 執行不能卡住指針。到期任務必須丟給 worker pool,在 advance() 裡同步執行一個慢任務會拖垮整個輪子的精度。
  • 精度上限就是 tick。tick 設 1 秒,就別承諾毫秒級;要毫秒級請上階層時間輪,別把單層 tick 硬壓小(見上面退化分析)。
  • 任務可能被取消時要留得住 handle(slot + list element),否則取消只能靠 tombstone 標記,記憶體會被撐大。

🔗 相關工具

取消撞付款:別三步走(TOCTOU) UPDATE ... WHERE id=? AND status='UNPAID' 影響行數 0=別人先改掉,放棄

🎯 什麼情境該想到我

當你「同一筆資料有兩條路徑會去改它的狀態」——超時取消 vs 使用者剛好付款成功、退款 vs 出貨、重複投遞的同一則 MQ 訊息——而你正要寫「先 SELECT 出來、判斷狀態、再 UPDATE」這種三步走的時候。

⚙️ 怎麼用(步驟 / 公式)

問題:三步走中間有一道窗

// ❌ 反模式
o, _ := repo.Get(ctx, orderID)
if o.Status != "UNPAID" {
    return nil              // 已經付款了,不取消
}
repo.SetStatus(ctx, orderID, "CANCELED")   // ← 這一行執行時,它可能已經被付款了

SELECT 到 UPDATE 之間就是 TOCTOU(time-of-check to time-of-use)窗口。在預設的 READ COMMITTED/REPEATABLE READ 下,這個 SELECT 是快照讀,不會擋住別人改。付款回呼在這個縫隙裡把狀態改成 PAID,然後你的 UPDATE 把它蓋回 CANCELED——使用者付了錢,訂單卻被取消。

解法:把判斷塞進 WHERE,讓 DB 的行鎖當裁判

UPDATE orders
   SET status = 'CANCELED', cancel_time = NOW()
 WHERE id = ? AND status = 'UNPAID';

規則只有一條:affected rows = 1 表示你贏了,= 0 表示別人先改掉了,放棄。

res, err := db.ExecContext(ctx,
    `UPDATE orders SET status='CANCELED', cancel_time=NOW()
      WHERE id=? AND status='UNPAID'`, orderID)
if err != nil {
    return err
}
n, err := res.RowsAffected()
if err != nil {
    return err
}
if n == 0 {
    // 已被付款回呼/前一次重試改掉了 —— 這是正常結果,不是錯誤
    return nil
}
// 只有真的改成功的這一次,才做後續副作用
return releaseStock(ctx, orderID)

為什麼安全:UPDATE 會對符合條件的列加排他行鎖,兩個並發的 UPDATE 被序列化,第二個看到的是第一個已提交的 status,條件不成立 → 改到 0 列。誰先搶到誰贏,資料庫的行鎖幫你判勝負,你不需要額外的分散式鎖。

這同時就是冪等:同一個取消請求重跑十次,第二次之後條件都不成立,只會有一次真的生效。

★ 兩個會讓「affected rows」說謊的陷阱

1. MySQL 的 changed rows vs found rows。 MySQL 預設 RowsAffected 回傳的是實際被改動的列數,不是符合 WHERE 的列數。所以 SET status='CANCELED' WHERE status='CANCELED'(值沒變)會回 0,即使有列符合條件。

寫成 WHERE status='UNPAID' 時這不成問題(值一定會變),但如果你的條件允許「改成同一個值」,判斷就會誤判。go-sql-driver/mysql 可以在 DSN 加 clientFoundRows=true 切成「回傳 matched 列數」——但這是連線層級的全域行為,改之前要確認整個服務都能接受。比起改 flag,把條件寫成「狀態一定會改變」更穩。

PostgreSQL 沒這個歧義:UPDATE 的 command tag 回傳的就是被 WHERE 命中並更新的列數。

2. 副作用必須跟狀態變更同一個 transaction。 上面的 releaseStock 如果在 UPDATE 提交後才跑、中間 crash,就會出現「訂單取消了但庫存沒還」。兩種正解:

  • 庫存也在同一個 DB → 包進同一個 BEGIN ... COMMIT。
  • 庫存在別的服務/要發事件 → 用 工具-Transactional-Outbox-Pattern,把「取消」和「待發事件」一起提交。

進階:明確寫出狀態機的合法轉移

條件不只能寫一個值。把整張轉移表放進 WHERE:

-- 出貨只允許從 PAID 或 PACKING 進來
UPDATE orders SET status='SHIPPED'
 WHERE id=? AND status IN ('PAID','PACKING');

好處是非法轉移不會靜默發生,而是回 0 列讓你決定怎麼處理(記 log/告警/回錯誤)。狀態機一旦超過四五個狀態,把轉移表寫在程式裡一份、WHERE 裡用它產生條件,比散落各處的 if 好維護。

🧪 我實際套用的紀錄

  • 2026-09-25:(待填)

⚠️ 注意 / 什麼時候不適用

  • n == 0 不是錯誤。它是「別人先做了」的正常結果,不要回 error 讓上游一直重試。
  • 需要知道「為什麼輸」時要多讀一次。條件更新只告訴你贏或輸,不告訴你對方把狀態改成什麼。要記錄原因就在 n == 0 後補一次 SELECT(此時已無競態,狀態是終態)。
  • 不適用於「需要讀取當前值來計算新值」且值域無界的情況(例如餘額加減)。那要嘛用 SET balance = balance + ?(DB 端計算),要嘛帶版本號做樂觀鎖 WHERE version = ?。
  • 不能取代交易隔離等級的思考。條件更新解的是「同一列的狀態競賽」;跨列、跨表的不變量(例如「庫存總量必須等於各訂單佔用之和」)要靠交易或聚合邊界 → 工具-交易與隔離等級、工具-實體值物件與聚合。
  • 在 MongoDB/Redis 上對應的是 findOneAndUpdate 的 filter 條件、以及 Lua 腳本裡的條件判斷——原理相同:把判斷和寫入變成一個原子操作。

🔗 相關工具

Redis 掛了? 定時任務沒死,降級成 低頻小窗口對帳:每 10 分鐘掃 「建立於 30~40 分鐘前」的窄區間。

Link to original

🎯 為什麼存這篇 / 未來想拿它做什麼

  • 下次要做「N 分鐘後自動做某事」(訂單取消、優惠券回收、延遲通知、延遲重試)時,直接翻選型表,不要反射性寫排程。
  • 準備系統設計/後端面試時,這題是高頻題,而且「定時任務是兜底、主動觸發才是方案」這句話可以直接用。
  • 把文中 Java 味的實作換算成 Go:時間輪在 Go 裡常常不必自己寫(我實測了,見工具卡)。

🧰 這篇給我的工具

✨ 關鍵重點

  • 定時掃描的兩個指控,一個成立一個要打折。
    • ✅ 精度:誤差上界 = 掃描週期。12:00 下單、12:30 該取消,每分鐘掃一次就可能拖到 12:31,這一分鐘裡庫存鎖、優惠券、秒殺名額都被一張不會付款的單白佔著。要精度就得縮短週期,要省 DB 就得拉長——兩者綁在同一個旋鈕上,這才是換方案的真正理由。
    • ⚠️ 「全表掃描」要打折:只要有 (status, create_time) 複合索引,那就是索引範圍掃,而且取消後訂單離開 UNPAID 前綴,待掃區間有界。「每分鐘犁三千萬行」多半是索引沒做好,不是方案的死罪。(原文這點講得偏重,我在工具卡裡修正了)
  • Redis ZSet:score = 下單時間 + 30 分鐘,worker 盯最小 score。skiplist 取最小是 O(log N),隊伍裡只有未到期的單,天生沒有無效資料。取出必須用 Lua 把 ZRANGE BYSCORE + ZREM 包成原子,否則多實例重複消費。
    • 原文用的 ZRANGEBYSCORE 自 Redis 6.2 起已 deprecated,新寫法是 ZRANGE key 0 now BYSCORE LIMIT 0 1。
    • 原文沒提的洞:ZREM 成功後、業務執行完前 crash,任務就消失了。要補成 visibility timeout 兩階段(搬到 processing set,做完才 ack)。
  • MQ 死信佇列:緩衝佇列不掛消費者+設 TTL → 過期成死信 → DLX 路由到業務佇列。業務程式碼最乾淨、可靠性最高。前提是同一佇列裡所有訊息 TTL 一致,因為 per-message TTL 只在訊息走到隊首才被檢查,混用會造成隊頭阻塞。多級延遲 → 多組佇列/x-delayed-message 外掛/RocketMQ(4.x 只有 18 個固定延遲等級,5.x 才支援任意時間)。Kafka 無原生延遲訊息。
  • 時間輪:slot = (cur + d/T) % S、circle = (d/T) / S,指針每繞一圈把該格任務圈數減 1,減到 0 才執行。插入是 O(1)(鏈結串列 append,不排序)。
    • 但「取出也 O(1)」是半真:單層+圈數的版本,每個任務在到期前會被空摸 d/(S×T) 次。我實測 20 萬個 30 分鐘任務、60 格 × 1 秒錶盤 → 每任務被摸 30.0 次,和公式吻合。真正攤還 O(1) 的是階層時間輪(Kafka 用這個;Netty HashedWheelTimer 是單層+圈數)。
  • 追問一|Redis 掛了訂單就永遠不取消? 定時任務沒死,只是降級成低頻小窗口對帳:每 10 分鐘掃一次「建立時間落在 30~40 分鐘前」的窄區間走索引,不是全表犁地。
  • 追問二|取消和付款撞在一起? 別「查出來 → 判斷 → 更新」三步走,那中間有 TOCTOU 窗口。直接 UPDATE orders SET status='CANCELED' WHERE id=? AND status='UNPAID',影響行數 0 就代表已被付款回呼改掉,放棄取消。行鎖幫你判勝負,不必加分散式鎖。
  • 追問三|時間輪精度怎麼定? 訂單超時是秒級需求,tick 1 秒 × 60 槽就夠。要毫秒級請上階層時間輪,別把單層 tick 硬壓小(會讓圈數暴增到幾千圈)。
  • 選型結論:預設 Redis ZSet(夠簡單、夠準、延遲長度任意);日均千萬單、對可靠性有硬要求再上 MQ;時間輪留給「單機、不想多一個依賴、量體大」。真實專案常常混用:Redis 扛主流程,定時任務做對帳兜底。

💬 原文摘錄

  • 「這不是錯,是笨重。」
  • 「把笨重的被動輪詢,換成基於事件的主動觸發。」
  • 「誰 ZREM 成功誰負責執行。」
  • 「定時任務是兜底,不是方案;主動觸發才是方案。」
  • 「影響行數為 0 就說明已經被支付回調改掉了,直接放棄取消、回滾庫存預佔。誰先搶到狀態誰贏,資料庫行鎖幫你判勝負。」

🔗 相關