Dispatcher 接到的任務,看起來只是「去查上一個小時有哪些回饋」。結果一打開 DDB,點數、優惠券紀錄像演唱會散場的人潮一起湧出來。GetRemainingAsync() 直接開啟「我全都要」模式:符合時段的資料不管分幾頁,先全部搬進這個 Job。

搬完還不能下班。它得挑出待回饋紀錄、按會員分組、上傳 S3,再替各組填好 NMQ 任務。原始紀錄還沒放手,新清單已經長出來;這個 Job 活像一個人同時抱著原稿、影本和待寄包裹。資料一多,Pod 記憶體就跟著吃緊,嚴重時可能 OOM Killed,同一個 Worker 的其他 Job 也會被波及。

先別急著怪資料量。 真正有戲的是這些資料怎麼查進來、怎麼在記憶體裡分身,又怎麼排隊去派送。下面讓這個 Job 跑一輪給大家看。

鏡頭切到 16:01:10 UTC。Dispatcher 收到 NMQ 的 task data,裡面只有 ExecuteTime,沒有「這次要發給誰」的名單。它先驗證、解析資料,接著像拿著時間戳去查班表:上一個時段有哪些待辦,得自己到 DDB 找。

16:01 上班,為什麼回頭查 15:00?

這次拿 ExecuteTime = 2026-09-17T16:01:10Z 實際走一遍。往前一小時是 15:01:10,再截到整點,startTime 就是 15:00:00 UTC;原時間截到整點,endTime 是 16:00:00 UTC。Job 雖然在 16:01 報到,翻的卻是 2026 年 9 月 17 日 15:00 到 16:00 這一頁帳。

實際公式是 startTime = ExecuteTime.AddHours(-1).ToHour()、endTime = ExecuteTime.ToHour()。DDB 使用 BookingDateUTC-BookingTimeUTC-index,條件為 BookingDateUTC = 2026-09-17 且 BookingTimeUTC Between 15:00:00Z and 16:00:00Z。

DDB 找到五筆,但不是五筆都要發

把七筆示意紀錄放上桌,看看誰能進場。時間都是同一天的 UTC;A、B、C 代表不同的 (ShopId, MemberId, PromotionEngineId) 群組。

紀錄BookingTimeUTCRewardStatus群組/類型OrderTime
R014:59:59WaitToRewardA/給點14:10
R115:03:00WaitToRewardA/給點14:50
R215:20:00WaitToRewardA/給點14:30
R315:45:00WaitToRewardB/給券15:10
R415:48:00非 WaitToRewardC/給點15:15
R515:59:00WaitToRewardC/給點15:30
R616:00:01WaitToRewardC/給點15:40

第一關看時間:R0 早了一秒,R6 晚了一秒,都被擋在門外。DDB 回傳 R1 到 R5,GetRemainingAsync() 還會把符合條件的頁面全部讀完。R4 雖然不是待回饋,這時仍混在人群裡;RewardStatus 要等程式端的 FindAll 才會檢查。還有個邊界彩蛋:Between 包含兩端,剛好 16:00:00 的紀錄也會被這次查到。

五筆資料,最後怎麼變成三個任務?

由上往下讀:五筆符合時間條件,四筆仍待回饋,形成三組派送任務。圖中的筆數與紀錄 ID 只用於示範一次執行。

第二關才看狀態。R4 被 FindAll 請出隊伍,剩下四筆分成 A、B、C 三組;分組鍵依序是 (1001, 2001, 3001)、(1001, 2002, 3002)、(1002, 2003, 3003)。A 組還有個小反轉:先查到的是 R1,但 OrderTime 更早的是 R2,所以會員訂單清單要排成 R2 → R1。三組各上傳一份 S3 資料,先累積三個 CreateTaskRequest,才逐筆建立 NMQ 任務,並留下 CreateTaskResponse 和 TaskId 清單。A、C 走給點路由;B 是 ECoupon,改走給券路由。

五筆、四筆、三個任務,怎麼聽都像越做越輕鬆。偏偏記憶體不是數學課的刪去法:原始紀錄還握在手裡,過濾清單、分組結構、派送資料已經排隊進場。來看是哪三個環節讓它越抱越滿。

它到底把哪些東西抱在手上?

查詢:整箱先搬進來

GetRemainingAsync() 把這個時段的每一頁都讀完,整批 records 留在同一個 Job。查詢 logging decorator 在一定筆數內還會序列化完整結果,等於搬完一箱,又替箱內物品拍了一份清單。

規劃改善每次只讀一頁;新查詢方法只記必要摘要。

加工:邊整理邊多拿幾份

FindAll 挑出待回饋清單,GroupBy 再排出會員隊伍;兩者都可能繼續握著 record 的參考。每組轉成會員訂單資料、上傳 S3 時,DTO、字串和序列化 buffer 也會加入隊伍。

規劃改善只在頁內過濾與分組;每組處理完就不再保留轉換與上傳的暫存資料。

派送:清單還在排隊

目前先把整輪 taskRequests 存起來,才逐筆建立 NMQ 任務;回來的 responses 和 TaskIds 又各自排成新清單。任務派出去了,手上的便條紙卻還沒丟。

規劃改善每組完成後直接建立 NMQ 任務,只保留成功數量的統計。

討論解法時,有個提議很像網購結帳才發現超重:「那就只拿一千筆吧!」可惜若整車資料已經進了記憶體,這時才 Take(1000),限重標語貼得再漂亮也沒用。真正該設關卡的地方,是資料進門以前的查詢入口。

先把看似有用的招數排一排

一路淘汰到最後,留下「同一個 Dispatcher 帶 token 逐頁派送」作為主方案:一次只讀一頁,處理完才翻頁。它限制單次查詢握在手裡的資料量,也不用每翻一頁就召喚一個新 Dispatcher,或替 DDB 紀錄新增派送狀態。不過,翻完一本書仍可能花很久;同會員、同活動的紀錄若散在不同頁,原本合成一個任務並依 OrderTime 排序的行為就可能變樣,重試時也仍可能重複派送。

展開七種方案的完整取捨
方案看得到的好處必須付出的代價本次選擇
BookingTime 時間分桶把預約量分散到不同時段會改變排程時間;單一時段仍可能很多不採用,相關 code 已 stash
全量查完再 Take(1000)改動表面上簡單全量資料早已進入記憶體;沒取到的部分還要處理不採用
每次從頭查 Limit 1000,滿批再建下一個 booking限制單次查詢量重讀前段、可能重派;前段資料會阻塞進度,未滿千筆也不代表結束不採用
DDB 回寫 Dispatched辨識已派送資料DDB 與 NMQ 不是同一交易;要處理搶佔、到期恢復、補發版本,舊索引也不會自動跳過標記不採用
帶 token,每頁另建 Dispatcher每個 Job 較短、單次處理量較小多了續頁 Job 建立失敗、重試分叉及 token 持久化管理暫不採用
同一 Dispatcher,帶 token 逐頁派送降低同時持有的資料量,不新增續頁 Job 或 DDB 標記整輪仍可能很久;跨頁分組、順序及重試問題待解本文件主方案,排序取捨待確認
逐頁查詢加外部暫存,最後完整分組不把全量留在 RAM,同時保留整組彙整要設計暫存、清理及恢復;單一超大群組仍需處理若必須維持跨頁排序,再另行規劃

「先查一千筆,滿了就再叫下一個 Dispatcher。」這提案乍聽是接力賽,細看卻像每位選手都被送回起跑線:下一個 Job 沒拿到查到哪裡的接力棒,只拿到同一個時間區間。大家都從第一筆開始跑,後面的紀錄只能在終點等到天荒地老。

第二棒為什麼還在起點?

建立 NMQ 任務不代表下游已完成。圖示的是再次從頭查詢可能走到的兩條路,並非每次執行都一定重派。

兩種劇情都不妙。前段紀錄若還是 WaitToReward,下一棒可能再派一次;即使回饋已完成,只要 BookingTime 仍符合索引條件,它們還是會占住 Query 評估名額,因為 RewardStatus 要回到程式才過濾。更不能用「沒湊滿一千筆」宣布完賽:DynamoDB Query 可能先撞到 1 MB 上限,未滿 1000 筆就停在頁面邊界。AWS Query 官方文件

另一派提議是替 DDB 紀錄貼上 Dispatched,以後看見標籤就跳過。聽起來像社群貼文按下「已讀」,事情便自動辦好了。偏偏 DDB 貼標籤和 NMQ 建任務是兩套系統;劇情在哪一秒卡住,結局就會從漏發切到重發。

標籤先貼,還是任務先建?兩邊都有破口

執行順序中斷時可能留下什麼
先寫 Dispatched,再建立 NMQ 任務NMQ 建立失敗,紀錄卻已標記為派送,可能漏發。
先建立 NMQ 任務,再寫 DispatchedDDB 更新失敗,下次可能把同一批再派一次。
先查未派送,再由各 Dispatcher 更新兩個 Dispatcher 可能同時看到同一筆,需要 conditional update 搶佔。

還有續集:券不足時會更新 BookingTime,同一筆紀錄可能需要再派。單一布林標記只會說「派過」,卻說不出「上一次派過,這一次該重派」。若真要讓 Dispatched 扛起業務狀態,就得連版本識別、租約到期、搶佔與恢復流程一起設計。

一張貼紙牽出一整套跨系統協調。這條路值得另開設計,但這次的主題是降低單次 Job 的記憶體峰值,先把兩件事分開處理。

輪到主角 token 登場。它像書籤,記得「下一頁從這裡翻」,卻不會幫忙確認上一頁的任務是否派成功。把書籤當成完成章,平常翻書很順;Job 一重試,才發現自己把查詢進度和業務進度演成同一個角色。

這頁沒資料,為什麼還有下一頁?

DynamoDB 的規則有點像「先點名,再看誰符合資格」:單次 Query 最多先讀到 1 MB,或先達到指定 Limit,然後才套用 FilterExpression。所以 Limit = 1000 管的是評估筆數,不保證手上真的拿到 1000 筆;這頁可能一筆符合的都沒有,卻仍有下一頁。AWS Query 官方文件

名稱讀這個值時要知道的事
Items本頁回傳資料,之後還可能由程式過濾 WaitToReward。
CountDDB 回傳筆數;若使用 FilterExpression,是過濾後的筆數。
ScannedCount本次評估筆數,位於 DDB 過濾之前。
LastEvaluatedKey低階 Query 回傳的完整續頁 key。
ExclusiveStartKey低階 Query 下一次請求的起始位置。
NextToken本專案擬定的分頁合約名稱;型別與 SDK 對應仍待確認。

真正的書籤不能只抄頁碼。低階 API 要把本次的 LastEvaluatedKey 原樣交給下次的 ExclusiveStartKey;它不是單純的最後一筆 BookingTime,也不是天然的字串。若想用 SDK 高階 PaginationToken,得先查目前套件與查詢 API 怎麼支援,兩種表示不能直接混搭。AWS 分頁說明

書籤要完整

SDK token 或完整 key 要原樣保存。只抄 BookingTime,遇到同一時間有多筆紀錄,就可能像跳頁一樣漏掉其中幾筆。

翻頁規則別臨時改

同一輪的 table、index、時間範圍、排序及查詢條件都要固定。有續頁 key 就繼續,哪怕眼前這頁沒有有效回饋資料。

書籤不會幫忙派送

token 沒有 NMQ 去重功能,也不能證明資料已回饋。多次 Query 不是固定快照;執行中若有紀錄新增或換了排序位置,本輪可能看不到它。

還有一個容易踩的坑:若部署中的索引是 GSI,讀取只能採最終一致性。新查詢實作要核對索引與 SDK 設定,不能看到既有 ConsistentRead = true 就直接照抄。AWS Query 官方文件

現在要替 Dispatcher 加分頁,但不能像電影續集換了主角,連前作設定都一起改掉。入口仍接原本的 ExecuteTime;開關沒開或設定讀不懂,就走舊流程。只有明確啟用才進新流程,而且同一輪 Job 不會演到一半突然換劇本。

第一頁選好路線,整輪別改道

下面兩個設定名稱是規劃值,實作時可依專案慣例調整;重點是「沒開就照舊」要寫進行為,而不是靠大家默契。

設定預設值這次 Job 會怎麼做
_N1CONFIG:BookingDispatcherPaginationEnabledfalse只有 true 走新分頁流程;缺少或無法解析時走舊流程。
_N1CONFIG:BookingDispatcherPageSize1000新流程單次查詢上限;缺少、無法解析或非正值時回退到 1000。

Job 一報到就讀取模式與 page size,整輪用同一份設定。若第 3 頁處理到一半才改開關,這個 Job 也不臨時轉彎;正整數上限是否另設邊界,留到設定驗證時決定。

兩條路只在入口分流。新流程處理完本頁才使用續頁位置,舊流程維持原有語意。
展開各層保留與新增的介面
層級保留新增
IDynamoDBClient既有 SearchItemsAsync 簽章與語意單頁查詢介面
AWSDynamoDBClient原 GetRemainingAsync 路徑只取單頁及續頁資訊的實作
DynamoDBClientWithLogging舊方法與行為新分頁方法的代理及摘要 log
IPromotionRewardService/PromotionRewardService原 BookingTime 全量查詢方法相同條件的單頁查詢方法
Dispatcher舊全量讀取與派送方法新分頁 orchestration;入口增加模式判斷
派送ProcessAllMemberGroupsAsync 保留每組直接上傳與派送的新方法
Request原 ExecuteTime 格式與 Job 名稱本方案不用新增跨 Job token 欄位
舊方法的招牌不能換內容。 SearchItemsAsync 原本會讀完整批,就維持這個語意;新流程另走單頁查詢,也不能先全量查完再截斷。舊流程最多做必要的機械式抽取來分流,查詢與業務邏輯維持原意。

翻頁時最怕交接只說「這頁有幾筆」,卻忘了講「下頁從哪裡接」。前者是眼前拿到的東西,後者才是能繼續往下查的線索。單頁資料合約得把兩件事一起交出去。

交接單少一欄,就可能漏整頁

這張交接單暫名 QueryPageEntity<T>,建議維持一類別一檔案。欄位不用多,但每個都要把意思說清楚:

欄位合約
Items當頁集合;沒有結果時回空集合,不回 null。
NextToken沒有下一頁時回空值;具體型別等 SDK spike 後定案。
EvaluatedCount,選用SDK 若能提供,就記錄評估筆數;不能拿 Items.Count 冒充。
這裡有個會真的漏資料的陷阱。 如果先讀一大頁,自己 Take 前半段,卻拿整頁尾端的 token 去查下一頁,中間那半段就被直接跳過。續頁位置只能接在「已完整處理的本頁」後面。

這版 Dispatcher 的口訣很樸素:「這頁寄完,才翻下一頁。」如果任務還沒建好,token 已經跑到後頁,就像包裹還躺在桌上,寄件簿卻先蓋了下一頁的章;出事時連哪一批沒寄出都很難說清。

本頁派完,才輪到下一頁

Job 先解析輸入與設定,把 startTime、endTime 和 page size 定下來。接著只打開一頁,拿到本頁 Items 和候選 NextToken;後面的頁面先別急著翻,免得這頁還沒處理完就有更多資料擠進來。

圖中的 token 是候選續頁位置。只有本頁各組處理成功後,才用它查下一頁;本頁空白但仍有 token,也要繼續。

本頁若有人待回饋,照 (ShopId, MemberId, PromotionEngineId) 分組,組內再按 OrderTime 排隊。每組先整理會員資料、上傳 S3,確認上傳成功才建立對應 reward Job;S3 key 格式、member request 結構與 GetJobName 路由都沿用現行規則。要等這頁每一組都派送成功,Dispatcher 才有資格翻頁。

處理完的那頁就該退場。頁內工作放進短生命週期方法,不把頁內物件塞進跨頁集合;整輪只留下頁數、筆數、成功任務數和耗時等摘要。至於 GC 何時清場由 runtime 決定,這裡不每頁喊一次 GC.Collect,也不保證 process working set 會立刻下降。

展開空頁、錯誤與取消時的分支
條件行為
開關為 false、缺少或無效執行舊流程,不呼叫新查詢。
有 Items、有 token派送本頁,再續頁。
少於 PageSize、有 token仍續頁,不以筆數判斷結束。
Items 為空,或過濾後為空,仍有 token本頁不建立 reward Job,繼續查下一頁。
有 Items、無 token派送最後一頁後結束。
Items 為空、無 token正常結束。
Query、S3 或 NMQ 發生錯誤拋出錯誤,不讀後頁,也不自動改走全量流程。
接到 cancellation停止後續讀取與派送,依宿主處理取消;已建立任務不撤回。
SDK 回傳無效或不前進的 token記錄可診斷摘要並失敗,避免靜默重頭查或無限迴圈。

分頁方案一路看到這裡,好像準備過關了。偏偏同一位會員的訂單可能上演「被迫分組」:DDB 按 BookingTimeUTC 翻頁,根本不認識會員彼此的關係。查詢頁面切在哪裡,會員的資料就可能被切在哪裡。

同一組 30 筆,可能變三個 Job

假設同一 (ShopId, MemberId, PromotionEngineId) 在這個時段有 30 筆訂單,剛好散在第 1、2、3 頁。舊流程把三頁讀完再集合,30 筆依 OrderTime 排成一個 Job。新流程每頁處理完就派,於是同一組被拆成三個 Job;每個 Job 都只知道自己那十筆,像三個人各拿到同一本故事的其中一章。

圖中每頁 10 筆只是示意。分頁邊界按查詢結果決定,和會員群組邊界無關。

更麻煩的是,Dispatcher 先建立 Job 1、再建立 Job 2,不代表 reward Worker 會照這個順序跑完。拆組還會讓 S3 物件、NMQ 任務和 lock 競爭一起增加。Lock 可以擋住同時處理,卻沒有發「先到先得」的號碼牌。

這關不能靠感覺通過。 若同會員、同活動必須先處理較早訂單,新流程就不能直接啟用。先查清楚順序會不會影響累積門檻、活動額度,以及給券/給點結果,再決定要不要跨頁合組。

如果業務真的不能接受拆組,就要用外部暫存後完整分組,或另找能保留順序的做法。把跨頁會員資料一路囤在 RAM,看似守住了排序,記憶體問題卻又從後門走回來。

最後一關是重試。Job 做到第 3 頁突然中斷,第 1、2 頁的 NMQ 任務可能早就建好了。續頁 token 記得查詢位置,卻沒看見任務建立的結果;重新開機也不會自動跳出「上集提要」告訴它哪些已經派過。

第三頁斷線,重跑會從哪裡開始?

本方案沒有新增業務紀錄狀態,也不持久化 token。Dispatcher 查到一頁,就先把這頁的任務派完;成功才翻頁。途中任何一步出錯,這輪就明確失敗,讓錯誤留下來供排查,不吞例外,也不臨時改走全量流程。這樣至少知道它停在哪一步,但先前成功的任務不會因此倒帶。

這是 Dispatcher 的執行狀態,不代表 DDB 紀錄或下游回饋已完成。

中斷在哪裡,後果就不一樣

失敗或變動可能留下的結果本次處理與待確認處
Query 失敗當頁未派送。讓 Job 失敗,保留錯誤供排查。
S3 失敗該組尚未建立 NMQ;前面幾組可能已成功。停止本輪;重試可能重複派送前組。
S3 成功、NMQ 失敗可能留下沒有任務使用的 S3 物件。不記成成功;確認現有 S3 清理生命週期。
NMQ 成功、回應遺失Dispatcher 不知道任務其實已建立。重試可能再建立一次;token 無法去重。
後頁失敗或程序中斷先前頁的任務已建立,後頁尚未完成。初版不持久化 token;宿主若重試,從原時段第一頁重跑。
另一個 Dispatcher 同時查同一時段兩輪可能派送同一筆紀錄。token 是查詢位置,無法當跨執行鎖。
執行中新增或改動 BookingTime紀錄可能移入已讀區段。不能保證本輪全讀到;確認既有補掃。
索引更新延遲本輪可能暫時看不到新資料。若是 GSI,需接受最終一致性並確認補掃。
「有 lock 應該沒事吧?」這句先別說太快。 上線前要逐一查明下游 lock、最新 RewardStatus 檢查,以及外部給券/給點是否具備冪等性。Lock 的存在,不能直接證明重跑不會再產生一次業務效果。

這次先不直接新增補掃 Job,但得查明現有 audit、redo 或補掃機制能不能接住漏讀與中斷。如果接不住,補償方案就得在上線前定案。「有 next token 就保證不漏發」這張保證書,token 本人可沒有簽。

最後放一份可以直接拿去對時鐘的 task data。看到 16:01 先別興奮地查 UTC 的 15:00 到 16:00:這筆輸入帶的是 +08:00。換成 UTC 其實是當天 08:01:10.6854808Z,因此查詢區間是 07:00:00Z 到 08:00:00Z。

1
{"Data":"{\"ExecuteTime\":\"2026-09-17T16:01:10.6854808+08:00\"}"}
時區是最後的伏筆。 這筆資料和前面用 16:01:10Z 解說的示意案例,並不是同一個執行時間。比對 DDB 查詢結果時,先把實際輸入換成 UTC,再核對 BookingDateUTC 與 BookingTimeUTC。