定期購通知 Queue 設計:從雙層佇列到防重與併發控制
需求
REGULAR PURCHASE NOTIFICATION
購買會自動發生,重要的事也要主動告知
定期購只需設定一次,之後便依週期自動建立訂單。消費者不會每次親自操作,因此需要通知,才能掌握進度與異常。
在適當的時間,把正確的定期購資訊,可靠地通知需要知道的人。
發生什麼事,要讓消費者知道什麼?
| 定期購事件 | 通知的目的 | 說明 |
|---|---|---|
| 即將建立訂單 | 事先準備 | 確認是否需要調整。 |
| 商品庫存不足 | 知道購買可能受影響 | 這一期可能無法正常購買。 |
| 付款或建立訂單失敗 | 知道這次未完成 | 可能需要採取行動。 |
通知需要做到哪些事?
| 需求 | 要解決的問題 |
|---|---|
| 對象與內容正確 | 真的發生這件事,才通知對應的人。 |
| 及時通知 | 需要事先知道的事,不能太晚收到。 |
| 不漏送 | 通知量再多,也不能只送出一部分。 |
| 失敗能補送 | 短暫的系統或網路問題,不應讓通知消失。 |
| 避免重複 | 系統重新執行,不應一直寄相同內容。 |
| 處理過時通知 | 問題已解決或時機已過,需決定是否補送。 |
| 結果可追查 | 客服與維運能查到是否發送、時間與失敗原因。 |
同一件事重送,與下一期通知有何不同?
以「庫存不足通知」為例,是否需要再次通知,不能只看通知類型,還要看它是不是同一個業務事件。
- 本期:第一次發現商品庫存不足,通知消費者。需要通知。
- 仍是本期:系統重新執行,同一件事又準備寄一次。應避免重複。
- 下一期:新的購買再次遇到庫存不足。可能是新的必要通知。
把「通知」記錄下來
QUEUE AS A DURABLE TODO LIST
需要的不只是發送紀錄,而是一份可持續處理的通知待辦清單
通知資料不只回答之前做過什麼,也要讓發送程式知道還有哪些工作尚未完成。
通知資料如何同時成為紀錄與待辦?
- 業務事件:庫存不足、建單前提醒或處理失敗。
- 通知 Job:找到對象,組成通知內容。
notify_queue:保存內容、狀態與嘗試結果,提供待發與失敗通知。- SCM 通知 API:接收通知並回傳本次處理結果。
notify_queue 位在業務判斷與外部發送之間。如果只是保存發送結果,稱為「通知紀錄表」就足夠;它之所以具有 Queue 的角色,是因為它也參與工作的執行。
- 回顧過去,通知處理紀錄:保存之前送了什麼,以及成功或失敗。
- 推進未來,通知待辦清單:保存尚未完成的通知,讓原通知 Job 與 Retry Job 持續取出處理。
什麼情況需要在意通知順序?
處理順序可能依最早、最急或已到發送時間等需求決定,但不能從 Queue 這個名稱直接推定必須先進先出。
準備發送一筆通知時,先判斷是否屬於同一筆定期購:
- 否,不同消費者之間:通常不需要嚴格保證誰先收到。
- 是,同一筆定期購之內:檢查後來狀態是否讓舊通知失效,避免恢復後才收到過時的失敗通知。
先取出不等於先收到。中途重試或下游發送延遲,都可能改變收件順序;是否必須先進先出,需要獨立確認。
雙層 Queue
TWO QUEUES, TWO WORK UNITS
NMQ 安排 Job,notify_queue 管理每一筆通知
公司已有共用的 Message Queue 系統,但中央系統不容易直接呈現定期購服務內每一筆通知的狀態,因此仍需要自己的通知佇列表。
從排程到發送,用四個檢查點定位問題
- NMQ:Job 是否成功排入,或仍在中央 Queue 等待。
- 通知 Job:是否已執行、找到對象並準備通知。
notify_queue:通知是否產生、目前狀態與補送依據。- SCM 通知 API:是否接受通知並回傳本次處理結果。
NMQ 負責安排 Job 執行,但定期購對中央排隊過程的可見度有限。自己的通知表從「通知已產生」開始,保存可查詢與可補送的處理進度。
| 比較面向 | NMQ | notify_queue 表 |
|---|---|---|
| 工作單位 | 一次 Job 任務 | 一筆通知 |
| 例如 | 執行一次庫存通知 Job | 寄給某位消費者的庫存不足通知 |
| 關心的進度 | Job 是否執行完成 | 各筆通知是否發送成功 |
| 重試的範圍 | 重新執行 Job | 補送符合條件的個別通知 |
不同通知要拆成不同 Job
ONE PURPOSE PER JOB
不同通知拆成不同 Job,讓時間、條件與內容各自清楚
庫存提醒、建立訂單前提醒與失敗通知不是同一種工作。它們需要不同排程、不同查詢條件,也產生不同內容。
拆分通知 Job 時,分別確認執行時機、判斷條件與通知內容。有些通知需要事前提醒,有些只能在失敗發生後建立。每種通知查詢的資料與業務條件不同,通知對象、內容與下游 API 也需要依目的分開組裝,不應藏在同一支大型 Job 裡。
NMQ 排程與事件觸發:
| Job | 執行時機 | 判斷條件 | 通知內容 |
|---|---|---|---|
| 庫存通知 Job | 未來庫存檢查 | 哪些商品庫存不足 | StockNotify |
| 建單前通知 Job | 下一期訂單建立前 | 哪些定期購即將建單 | 建單前通知 |
| 失敗通知 Job | 付款或建單失敗後 | 哪些失敗需要通知 | 失敗通知 |
| Retry Job | 後續補送 | 只處理既有失敗通知 | 不建立新 Queue |
各自獨立後,每支 Job 的業務目的更清楚,也能分別安排排程、調整條件和追查問題。看到 RegularOrderStockNotifyJob,便能直接辨識這次執行要處理庫存通知。
重複發送問題
DUPLICATION AND CONCURRENCY
重複建立、重複發送與同時處理,是三個不同問題
同一支 Job 再次執行、SCM 已接收但狀態尚未回寫,以及兩支 Job 同時撈取,都可能造成重複,但需要在不同邊界處理。
同一個 Job 重做,如何避免重複建立?
在寫入 notify_queue 前,需要先用一組能代表業務事件的條件查詢。查到相同事件已建立時,就略過這筆,而不是只記錄「今天 Job 執行過」。如果只記錄整支 Job 已執行,後面 70 筆通知可能一起被跳過。
- Job 找到通知對象,組成業務識別條件。
- 判斷相同業務事件是否已建立。
- 是:略過或沿用既有 Queue,不再建立相同通知。
- 否:建立新的 Queue,保存這次業務事件的通知。
| Job | 建立的通知類型 | 目前用來判斷已建立的條件 | 查到後的處理 |
|---|---|---|---|
RegularOrderStockNotifyJob |
StockNotify |
商店 ID + 通知類型 | 跳過該商店,不新增。 |
RegularCreateOrderNotifyJob |
CreditCardOrderNotify、CashOnDeliveryOrderNotify |
定期購訂單 ID + 通知類型 | 跳過該筆通知,不新增。 |
RegularOrderFailedNotifyJob |
CreditCardFailedNotify、CreateFailedNotify |
定期購訂單 ID + 通知類型 | 跳過該筆通知,不新增。 |
RegularOrderUnsubscribeNotifyJob |
UnsubscribeNotify |
商店 ID +訂單 ID +通知類型 | 沿用既有 Queue,不新增。 |
RegularNotifyQueueRetryJob |
處理既有通知 | 不適用,不負責新增 Queue。 | 撈取既有 Queue 發送並更新狀態。 |
業務日期必須跟著通知事件。例如 10/9 補跑 10/8 的任務,仍應判斷 10/8 的通知是否存在。現有 Job 條件表沒有日期欄位,因此是否需要把業務日期納入識別鍵,仍要依同一筆 reference 在不同週期是否需要再次通知來確認。
SCM 已接受,但成功狀態尚未回寫
目前架構文件使用 nq_send_status 表示通知處理進度,原通知 Job 與 Retry Job 依狀態分工。
| 狀態 | 意義與負責工作 |
|---|---|
0 待發 |
由原通知 Job 處理。 |
2 失敗 |
由 Retry Job 處理。 |
1 成功 |
SCM 通知 API 回報成功,不代表收件者已實際收到。 |
SCM 接受通知後有兩條分支:
- 共同流程:通知 Job 讀取
status 0或2,notify_queue回傳 Queue 與固定識別碼 A,通知 Job 把 A 與通知內容送給 SCM,SCM 接受通知。 - 正常完成:通知 Job 回寫
status = 1,下次不再撈取。 - 回寫前中斷:Queue 仍是
status 0或2,SCM 卻可能已建立通知;下次再次讀到未完成狀態,並使用同一個識別碼 A 再送一次。 - SCM 也要記錄並辨認 A,才能在下游防重。
時間由上往下。SCM 接受與本地回寫是兩個動作,中間的中斷空隙無法只靠本地狀態完全消除。
跨系統冪等提案:可以評估使用 Queue PK 作為固定發送識別碼,但 SCM 必須支援以此識別碼防重,而且識別碼的範圍與保存時間要足夠。單純傳送 Queue PK 不會自動產生冪等效果。
兩個 Job 同時處理同一筆 Queue
如果兩個 Job 都可能處理同一筆 Queue,就需要「同一時間只允許其中一個取得處理權」的機制。重點是,查到可以處理與取得處理權必須一起完成,不能兩邊都先查到資料,準備發送時才各自標記。
| 比較面向 | 整批鎖 | 逐筆領取 |
|---|---|---|
| 鎖定粒度 | 以通知類型為單位,例如 StockNotify。 |
以單筆 Queue 為單位。 |
| 可否平行 | 同類型一次只由一個 Job 處理。 | 多個 Job 可同時處理不同 Queue。 |
| 實作方式 | 先取得跨 Pod 的類型鎖,再撈取、發送與回寫。 | 由資料庫原子地領取尚未被其他執行者取得的 Queue。 |
| 適合情境 | 希望改動較少,先避免同類處理範圍重疊。 | 需要平行發送並提高整體處理量。 |
整批鎖設計提案:
- Job A 嘗試取得
StockNotify的跨 Pod 鎖並成功。 - Job B 同時嘗試取得相同鎖,但未取得,因此略過本輪。
- Job A 在鎖定範圍內完成撈取 Queue、發送與回寫處理結果。
- Job A 完成後釋放鎖,同類型的處理範圍不會重疊。
設計邊界:鎖必須涵蓋「撈取、發送、回寫」,且所有 Job 都遵守同一規則。鎖也必須能跨 Pod 共用,例如 PostgreSQL advisory lock;C# lock 無法協調不同 Pod。這是待評估的設計提案,不代表現況已採用。
| 問題 | 需要保護的邊界 |
|---|---|
| 重複建立 Queue | 業務事件識別條件,必要時搭配唯一約束。 |
| SCM 已接受後重送 | 下游也支援固定發送識別碼與冪等處理。 |
| 多個 Job 同時處理 | 跨 Pod 的分散式鎖,或資料庫原子領取。 |


