iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰系列 第 13

Day 13 | DLQ Redrive 設計:不重複打款的保證,與人工介入的邊界

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-13

如果我們現在有一些付款請求,它們的 job 已經被移除了,我們會發現那些請求依然存在,但「實際上是哪些 job 被移除了」這件事沒有存下來:queue 上是空的,Stats.Poison 能記的是這一輪放棄了幾份,沒有紀錄是哪幾份、為什麼被放棄,也沒有辦法通知對應的角色來檢查或處理它們。

今天的目標

我們今天要做一個 DLQ(dead-letter queue),讓停止重試的 job 停在裡面等待人工介入。這邊可以參考的公開設計有三個:

  1. 根據 SQS 的 dead-letter queue redrive:「Use dead-letter queue redrive to move unconsumed messages from a dead-letter queue to another destination for processing」,預設把 dead-letter 拋回的目的地就是原本的 queue,所以我們的 redrive 也是把 job 放回 relayer 吃到的那一條
  2. 同一份文件的另一句是「Amazon SQS doesn't support filtering and modifying messages while redriving them」,放回去的東西不給改;我們實作上照這條做,redrive 送回去的就是原本那份 job,什麼資訊都不動
  3. Azure Service Bus 的 dead-letter queue 與 Sidekiq 的 Dead set 都在強調同一件事,「There's no automatic cleanup of the DLQ. Messages remain in the DLQ until you explicitly retrieve them」、「Sidekiq will not retry those jobs, you must manually retry them via the UI」,所以我們也不做任何自動回收或處理,能 redrive 的只有人工

repo 中 internal/relayerExample_redriveJob 用跟昨天同樣的三份 job:先讓它們停進 DLQ,再一份一份放回去。輸出長這樣:

dlq     3 parked
        pi_0001/settle   #1  parked   failed       -    retrying will not help; nothing was sent, so the intent failed
        pi_0002/settle   #3  parked   needs_review -    no luck after 3 deliveries; last broadcast unknown, needs review
        pi_0003/settle   #3  parked   created      -    no luck after 3 deliveries; job dropped, intent still created

sign    pi_0003 signed at last, back to authorized

redrive pi_0001/settle   by ops
worker  pi_0001/settle   #1  no-op        (already failed)
redrive pi_0002/settle   by ops
worker  pi_0002/settle   #1  no-op        (already needs_review)
redrive pi_0003/settle   by ops
worker  pi_0003/settle   #1  sent         tx 0x0003
redrive pi_0003/settle   refused (dlq: record is not parked: pi_0003/settle is redriven)

dlq     0 parked, 3 redriven
  • 第一段是三份 job 停在 DLQ 裡的樣子,六個欄位:job、這一趟被交付過幾次、目前的處置、停進來時那筆 intent 在哪一格、誰處置的、理由
  • pi_0001pi_0002 放回去只換到一次 no-op,因為那兩筆付款早就不在等 relayer 了
  • pi_0003 是 redrive 真正救得回來的那一種:付款人補簽之後,同一份便條放回去就走完了
  • 最後一行是同一筆紀錄被按第二次

為什麼 DLQ 後面沒有 worker

其實很合理吧,都判成 poison 了,除了人工介入之外理論上也沒輒。

圖上 Redrive 那條線是繞回原本那條 queue 的,所以兩邊都會遇到「同一份 job 進來兩次」,只是去重的範圍不一樣:queue 只對「還在排隊的同 ID job」不重複收,DLQ 只要同一份 job 還停著就不重複收。至於已經被處理掉的同 ID 的 job 如果再進來一次,就算新的一趟、Cycles 加一。

redrive 要保證不重複打款

redrive 之後會發生什麼,全部由那筆 intent 現在停在哪一狀態決定:

可以從圖得知,redrive 只讓那份 job 再被交付一次,沒有額外做任何事情;而 worker 每一次都重讀 intent、照它現在的狀態決定要做什麼。所以放回一筆已經走掉的 intent,只會換到一次 no-op 加一次 Ack,帳本不會多任何紀錄。

這也解釋了為什麼 SQS 乾脆不給改內容。一份 job 只帶 intent id 與 ref,改它沒有意義,因為決定接下來會發生什麼的本來就是 intent store 現在的內容。反過來說,如果 job 身上帶著金額與地址,redrive 就會變成一件危險的事:relayer 重新收到的會是一份幾天前的 snapshot,而我們根本看不出那份 snapshot 跟 intent store 現在是不是有落差。

先放回 queue,再標記那筆紀錄

開始 redrive 後有兩種進行方式可以選擇:

我們過去處理一個 intent 的順序都是「帳先記錄、狀態再開始變化」(因為比較安全),但今天 DLQ redrive 反過來比較適合:先把 job 放回 queue,再標記那筆紀錄。就像圖中說的那樣,「先放回、再標記」出事的話了不起再 redrive 一次,但「先標記、再放回」就要重新人工介入,而且流程會更複雜。

	// 先問一次「它還停著嗎」,才不會把一份已經被 Drop 掉的 job 放回 queue。真正的把關是下面的 Resolve,
	// 這裡只是不要白做一次 Enqueue。
	if r.Status != StatusParked {
		return Record{}, fmt.Errorf("%w: %s is %s", ErrNotParked, jobID, r.Status)
	}
	if _, err := q.Enqueue(ctx, r.Job, now); err != nil {
		return Record{}, err
	}
	return s.Resolve(ctx, jobID, StatusRedriven, by, now)

人工介入過程也可能發生「兩個人同時 redrive」的問題,但這也有解法:兩邊都 Enqueue,然後只有一個人的 Resolve 會成功,另一個拿到 ErrNotParked。萬一那份 job 剛好已經做完被 Ack 掉了,第二次 Enqueue 就會變成真的新的一份工作,而那也不危險,因為它一樣只會換到一次 no-op。

人工介入的邊界

前面都在講「人按下去之後會發生什麼」,這裡把邊界寫清楚:哪些事 redrive 做得到、哪些事它從頭到尾都碰不到。

  • DLQ 上的資訊全是過去式:一筆紀錄上的理由與 intent 狀態,講的是「這份 job 當初為什麼被放棄、放棄的那一刻那筆付款停在哪一格」,也就是那一刻拍下來的一張照片。人打開來看的時候那張照片多半已經過期了——付款人可能剛補完簽名,也可能早就被 operator 結案。所以 dlq 這個 package 不 import intent,那一欄就只是個字串,用途是讓人一眼分出「已經結案」與「還在等」,要判斷還是得回 intent store 重讀。

  • 「有沒有救」只有 intent 現在的狀態說得準:放回去的那份 job 只帶 intent id 與 ref,worker 領到之後一定重讀 intent。所以 DLQ 上的理由再怎麼像「再試一次就會好」,只要那筆 intent 已經不在等 relayer,換到的就是一次 no-op。

  • redrive 救不動停在 needs_review 的付款:轉移表上它的出口只有 settledfailed,而且只有 operator 走得動。便條放回去,worker 一樣重讀 intent、一樣 no-op、一樣 Ack 掉;真正要被推一把的是那筆 intent,不是那份 job。Drop 也一樣——丟掉的是便條,不是付款,一筆停在 needs_review 的付款不會因為便條被丟掉就結案。

  • 每一次處置都要簽名Resolve 除了目標狀態還要收下按按鈕的人是誰,沒填就不收。理由很簡單:一筆付款被人推了一把、跟它自己走完,在帳本上長得一模一樣,差別只留在這一欄。

  • 放回去又回來的要算一趟Cycles 數的就是這個。一份 job 進出兩次以上,多半不是再放一次就會好,它該讓人停下來去看鏈上發生了什麼。

(題外話:DLQ 本身還缺一個保留上限,Sidekiq 那邊的預設是留 10000 筆或六個月;我們目前沒寫入資料庫,所以可以先不管這件事。)

小結

我們今天討論了被移除的 job 去了哪裡:被丟進一個沒有 consumer 的 DLQ,連著 Reason 與「intent 當下狀態」一起寫下來,能讓它動起來的只有人工介入並且按下 redrive 或 drop。redrive 把原本那份 job 原封不動放回原本那條 queue,順序是先放回、再標記,因為 Enqueue 重放是 no-op 而 Resolve 是 CAS。至於「放回去不會多付一次錢」這件事由 worker 保證,它每一次都重讀 intent;redrive 真正救得回來的,只有那筆 intent 還在等 relayer 動它的那一種。

明天我打算讓 listener 上場,先把四條鏈各自的「不可逆」是什麼意思弄清楚,更之後再來想想鏈上鏈下對齊的實作。

明天見。


上一篇
Day 12 | 失敗任務的分類:Retryable vs Poison
下一篇
Day 14 | Chain Listener 與 Finality Policy:四條鏈的「不可逆」分別是什麼意思
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言