訂單超時自動取消
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 的經典做法是兩個佇列:
- 緩衝佇列:故意不掛消費者,訊息設 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 的兩塊積木
緩衝佇列(無消費者+TTL)→ 死信 → DLX → 業務佇列 ⚠️ 同佇列 TTL 必須一致,否則隊頭阻塞 RocketMQ 4.x 只有 18 個延遲等級;Kafka 無原生延遲
🎯 什麼情境該想到我
當你「訊息有時效,過了期限就不該再被處理」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:標示訊息在多久後失效,避免接收端處理到過時、無意義的資料。
做法要點:
- 在訊息標頭設定過期時間(有效期限)。
- 若訊息在期限內仍未被消費,訊息系統將其作廢。
- 作廢的訊息通常轉送到 Dead Letter Channel(死信通道),而非交付給接收者。
★ 各家 broker 的 TTL 機制(粒度差很多)
查證 2026-09-25。
| broker | 機制 | ★ 粒度 |
|---|---|---|
| RabbitMQ | x-message-ttl(queue 級)、expiration(每則訊息) | ★ 兩種都有;過期會進 DLX(見下) |
| SQS | 訊息 retention | ★ 只有 queue 級:預設 4 天,最短 60 秒,最長 14 天 |
| Kafka | retention.ms(topic 級) | ★★ 沒有 per-message TTL;而且它是「保留期」不是「過期」 |
| NATS JetStream | stream 的 max_age | stream 級 |
★★ 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分開計數。
🔗 相關工具
- 事件訊息 Event Message(時效性強的通知常需設過期)
- 請求-回覆 Request-Reply(回覆等太久即過時,可用過期作廢)
- 企業整合模式
- 死信通道 —— TTL 過期的去處之一
- 訊息存放區 —— 需要長期保留
- 通道清除器 —— 主動清空整個通道
- 請求-回覆 —— 請求端的 deadline
- 保證投遞 —— 過期與保證是對立的
- 事件訊息 —— 為什麼事件不該過期
🎯 什麼情境該想到我
當你「有訊息無法被成功投遞或處理(重試多次仍失敗、過期、找不到目的地)」的時候:需要一個地方收容它,而不是無限卡在原通道。
⚙️ 怎麼用(步驟 / 公式)
意圖:當訊息系統無法投遞一則訊息時,把它移到專門的死信通道,而不是丟棄或阻塞。
做法 / 結構要點:
- 由訊息系統(而非應用邏輯)在投遞失敗、逾時、過期或重試上限時,把訊息轉入死信通道。
- 死信通道集中收容這些訊息,供監控、告警、人工或自動重處理。
- 保護正常通道不被投不出去的訊息塞住。
★★ 各家 broker 的實際機制(差異很大,選型時最該問的一題)
查證 2026-09-25。
| broker | 機制 | ★ 關鍵細節 |
|---|---|---|
| RabbitMQ 4.x | Dead Letter Exchange(DLX),用 policy 設定 | ★ 四個觸發條件見下。官方建議用 policy 而非硬寫 x-arguments |
| SQS | Redrive policy:maxReceiveCount 超過就進 DLQ | ★ 計數是「被接收次數」,處理超時被重送也算一次 |
| Kafka | ★★ 沒有原生 DLQ | 要自己寫(catch → produce 到 xxx.dlq topic);Kafka Connect 與 Streams 有各自的 DLQ 設定 |
| NATS JetStream | max_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,否則救不回來也查不出原因。
🔗 相關工具
- 無效訊息通道 Invalid Message Channel(對照:內容語意問題 vs 投遞失敗)
- 保證投遞 Guaranteed Delivery(搭配持久化,確保失敗訊息不會憑空消失)
- 點對點通道 Point-to-Point Channel(消費失敗的訊息常被送進死信通道)
- 企業整合模式
- 無效訊息通道 —— ★ 界線:誰判定的
- 訊息過期 —— TTL 過期是進 DLQ 的原因之一
- 冪等接收者 —— 重放的前提
- 訊息存放區 —— 需要長期保留時
- 保證投遞 —— DLQ 是它的另一半
- 工具-MQ消費端防禦三原則 —— 重試、退避、DLQ 的完整做法
- 工具-服務容錯設計 —— 重試放大與斷路器
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 標記,記憶體會被撐大。
🔗 相關工具
- 工具-延遲任務方案選型 — 它是三個方案裡的方案 C,什麼時候該選它看那張卡
- 工具-時間複雜度分析 — 「O(1) 插入」與「攤還成本」不是同一件事
- 工具-攤還分析 — 每任務被摸
d/(S×T)次,正是攤還分析要處理的那類成本 - 工具-資料結構的選擇 — 最小堆 vs 桶+鏈結串列的取捨範例
取消撞付款:別三步走(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 腳本裡的條件判斷——原理相同:把判斷和寫入變成一個原子操作。
🔗 相關工具
- 工具-延遲任務方案選型 — 超時取消撞上付款回呼,就是這張卡的原生場景
- 工具-MQ消費端防禦三原則 — at-least-once 下的冪等,條件更新是最常用的實作手段
- 工具-Transactional-Outbox-Pattern — 狀態變更的副作用怎麼可靠地發出去
- 工具-區分修復與掩蓋症狀 — 用 upsert 硬蓋掉競態,就是典型的掩蓋症狀
- 工具-交易與隔離等級 — 為什麼快照讀擋不住別人寫
Redis 掛了? 定時任務沒死,降級成 低頻小窗口對帳:每 10 分鐘掃 「建立於 30~40 分鐘前」的窄區間。