🎯 什麼情境該想到我

當你「收到一則格式正確、但內容不合理或無法被這個接收端處理的訊息」的時候:需要一個地方把它放著、而不是硬處理或直接丟棄。

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

意圖:當接收端判定訊息無效(語意上無法處理)時,把它移到一個專門的通道,讓正常流程繼續進行。

做法 / 結構要點:

  • 接收端在解讀訊息後發現不符預期(缺欄位、值不合理、非本端該處理的內容),就把它轉送到無效訊息通道。
  • 由監控機制或人工去檢視這個通道裡的訊息,做後續處理或修正。
  • 讓一則壞訊息不會阻塞或污染正常的處理流程。

★★ 和死信通道的界線:這是整個可靠性設計最容易搞混的一組

無效訊息通道死信通道
誰放進去的★ 應用程式(我看懂了通道,但看不懂這則訊息)★ broker(我沒辦法把它交付出去)
典型原因JSON 解不開、schema 版本不認得、必填欄位缺失、路由找不到目的地超過重試次數、TTL 過期、佇列滿了、consumer 一直 nack
★ 重試有用嗎沒用——訊息本身壞了,重試一萬次一樣★ 可能有用——可能只是下游暫時掛了
修法修生產者,或人工修訊息後重放修消費者/等下游恢復,然後重放

★★ 實務上很多團隊只有 DLQ,把兩者混在一起。
後果是:你無法從指標區分「上游發了壞資料」和「我的服務掛了」——
而這兩件事的值班處理方式完全不同(一個要找上游、一個要修自己)。
★ 最低成本的改善:不要分兩個通道,但在 DLQ 訊息的標頭加 failure-kind(invalid / transient)並分開計數。

★ Go 的實作:關鍵是「分類錯誤」

var ErrInvalidMessage = errors.New("invalid message")   // ★ 哨兵錯誤,永久性
 
func handle(ctx context.Context, msg Message) error {
    var e Envelope
    if err := json.Unmarshal(msg.Body, &e); err != nil {
        return fmt.Errorf("%w: unmarshal: %v", ErrInvalidMessage, err)   // ★ 永久
    }
    if e.SchemaVersion > maxSupportedVersion {
        return fmt.Errorf("%w: schema v%d 不支援", ErrInvalidMessage, e.SchemaVersion)
    }
    if err := repo.Save(ctx, e); err != nil {
        return fmt.Errorf("save: %w", err)                                // ★ 可能是暫時的
    }
    return nil
}
 
// 消費迴圈
if err := handle(ctx, msg); err != nil {
    if errors.Is(err, ErrInvalidMessage) {
        publishTo(invalidChannel, msg, err)   // ★ 不重試,直接搬走,讓後面的訊息繼續
        _ = msg.Ack()                          // ★ ack 掉,否則它會永遠卡住佇列
        continue
    }
    _ = msg.Nack(true)                         // ★ 可重試 → 重送
}

★★ 「壞訊息要 ack 掉」是最反直覺但最重要的一點:
不 ack 它就會一直重送,把後面所有訊息堵住(poison message)。
搬到無效訊息通道 = 已處理,這是唯一能讓佇列繼續前進的做法。

★ 放進去的訊息要帶什麼(不然它等於垃圾)

type InvalidRecord struct {
    Original      json.RawMessage `json:"original"`        // ★ 原始 body,一個位元都不要改
    Headers       map[string]string `json:"headers"`
    Reason        string          `json:"reason"`          // ★ 為什麼看不懂
    SourceChannel string          `json:"source_channel"`  // ★ 從哪來
    FailedAt      time.Time       `json:"failed_at"`
    ServiceVersion string         `json:"service_version"` // ★★ 哪一版的程式碼判定它無效
}

★ ServiceVersion 常被忽略但很關鍵:
「這些訊息是不是因為我上週的 bug 才被判定無效?」——沒有版本號就答不出來,
而且修好之後你無法判斷哪些該重放。

★ 必須配的三件事(和 DLQ 一樣)

  1. ★ 告警:有訊息進來就通知。⚠️ 但要分級——一天一則是資料品質問題,一分鐘一千則是上游炸了。
  2. ★ 可檢視:能看到原始 body 與原因。
  3. ★ 可重放:修好 schema/修好訊息後能送回原通道。重放前先確認消費端冪等。

⚠️ 沒有這三件事的無效訊息通道,就只是「資料安靜消失的地方」,比直接丟掉更糟——
因為你以為自己有做防護。

🧪 我實際套用的紀錄

  • (待填)

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

  • 這是「訊息本身內容有問題」,由接收端判定;和「訊息系統無法投遞」不同(那是死信通道的職責)。

  • 只是移走不等於解決,仍需有人監看並處理堆積的無效訊息。

  • ★★ 壞訊息一定要 ack(或搬走)。不 ack 它會永遠重送,把後面所有訊息堵住。

  • ★★ 無效訊息(應用程式看不懂)和死信(broker 交付不了)是兩件事,值班處理方式完全不同。至少要在標頭分 failure-kind 並分開計數。

  • ★ 無效訊息重試沒有用。訊息本身壞了,重試一萬次結果一樣。

  • ★ 原始 body 要一個位元都不改地存下來,否則無法重放也無法判斷原因。

  • ★ 要記 ServiceVersion,否則修好 bug 後你無法判斷哪些訊息該重放。

  • ★ 沒有告警/檢視/重放流程的無效訊息通道比直接丟掉更糟——你以為有防護。

  • 告警要分級:一天一則是資料品質問題,一分鐘一千則是上游炸了。

  • 重放前先確認消費端冪等,否則重放會製造重複副作用。

🔗 相關工具