Bookingtime Job 本身的記憶體累積問題
Dispatcher 接到的任務,看起來只是「去查上一個小時有哪些回饋」。結果一打開 DDB,點數、優惠券紀錄像演唱會散場的人潮一起湧出來。GetRemainingAsync() 直接開啟「我全都要」模式:符合時段的資料不管分幾頁,先全部搬進這個 Job。
搬完還不能下班。它得挑出待回饋紀錄、按會員分組、上傳 S3,再替各組填好 NMQ 任務。原始紀錄還沒放手,新清單已經長出來;這個 Job 活像一個人同時抱著原稿、影本和待寄包裹。資料一多,Pod 記憶體就跟著吃緊,嚴重時可能 OOM Killed,同一個 Worker 的其他 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) 群組。
| 紀錄 | BookingTimeUTC | RewardStatus | 群組/類型 | OrderTime |
|---|---|---|---|---|
| R0 | 14:59:59 | WaitToReward | A/給點 | 14:10 |
| R1 | 15:03:00 | WaitToReward | A/給點 | 14:50 |
| R2 | 15:20:00 | WaitToReward | A/給點 | 14:30 |
| R3 | 15:45:00 | WaitToReward | B/給券 | 15:10 |
| R4 | 15:48:00 | 非 WaitToReward | C/給點 | 15:15 |
| R5 | 15:59:00 | WaitToReward | C/給點 | 15:30 |
| R6 | 16:00:01 | WaitToReward | C/給點 | 15:40 |
第一關看時間:R0 早了一秒,R6 晚了一秒,都被擋在門外。DDB 回傳 R1 到 R5,GetRemainingAsync() 還會把符合條件的頁面全部讀完。R4 雖然不是待回饋,這時仍混在人群裡;RewardStatus 要等程式端的 FindAll 才會檢查。還有個邊界彩蛋:Between 包含兩端,剛好 16:00:00 的紀錄也會被這次查到。
五筆資料,最後怎麼變成三個任務?
flowchart TB
Query["DDB 讀完所有頁面:R1 到 R5"] --> Status{"仍是 WaitToReward?"}
Status -->|否,R4| Skip["排除,不建立任務"]
Status -->|是,四筆| Group["依商店、會員、活動分成三組"]
Group --> A["A 組:R2 再 R1,上傳 S3"]
Group --> B["B 組:R3,上傳 S3"]
Group --> C["C 組:R5,上傳 S3"]
A --> Point["LoyaltyPoints PriorityLow 請求"]
C --> Point
B --> Coupon["Coupon PriorityLow 請求"]
Point --> Collect["累積三個 CreateTaskRequest"]
Coupon --> Collect
Collect --> NMQ["逐筆建立 NMQ 任務並累積回應"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
class Query,Group,A,B,C,Collect service
class Status decision
class Skip result
class Point,Coupon,NMQ success
第二關才看狀態。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),限重標語貼得再漂亮也沒用。真正該設關卡的地方,是資料進門以前的查詢入口。
先把看似有用的招數排一排
flowchart TB
Goal["目標:降低記憶體峰值"] --> Page{"資料進 RAM 前就分頁?"}
Page -->|否| Full["全量讀完再 Take,峰值仍在"]
Page -->|是| Token{"用 token 接續下一頁?"}
Token -->|否| Restart["從頭再查,可能卡在前段"]
Token -->|是| Same["同一 Dispatcher 逐頁派送"]
Same --> Order["跨頁分組與順序,啟用前待驗證"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
class Goal,Same service
class Page,Token,Order decision
class Full,Restart danger
一路淘汰到最後,留下「同一個 Dispatcher 帶 token 逐頁派送」作為主方案:一次只讀一頁,處理完才翻頁。它限制單次查詢握在手裡的資料量,也不用每翻一頁就召喚一個新 Dispatcher,或替 DDB 紀錄新增派送狀態。不過,翻完一本書仍可能花很久;同會員、同活動的紀錄若散在不同頁,原本合成一個任務並依 OrderTime 排序的行為就可能變樣,重試時也仍可能重複派送。
展開七種方案的完整取捨
「先查一千筆,滿了就再叫下一個 Dispatcher。」這提案乍聽是接力賽,細看卻像每位選手都被送回起跑線:下一個 Job 沒拿到查到哪裡的接力棒,只拿到同一個時間區間。大家都從第一筆開始跑,後面的紀錄只能在終點等到天荒地老。
第二棒為什麼還在起點?
flowchart TB
First["第一個 Dispatcher 查前段紀錄"] --> Sent["建立 NMQ 任務"]
Sent --> Again["下一個 Dispatcher 又從頭查"]
Again --> State{"前段紀錄現在的狀態?"}
State -->|仍待回饋| Repeat["可能再次派送相同紀錄"]
State -->|已完成回饋| Filter["仍占查詢名額,之後才被程式過濾"]
Filter --> Behind["後段待回饋紀錄可能讀不到"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
class First,Sent,Again service
class State decision
class Repeat,Filter,Behind danger
兩種劇情都不妙。前段紀錄若還是 WaitToReward,下一棒可能再派一次;即使回饋已完成,只要 BookingTime 仍符合索引條件,它們還是會占住 Query 評估名額,因為 RewardStatus 要回到程式才過濾。更不能用「沒湊滿一千筆」宣布完賽:DynamoDB Query 可能先撞到 1 MB 上限,未滿 1000 筆就停在頁面邊界。AWS Query 官方文件
另一派提議是替 DDB 紀錄貼上 Dispatched,以後看見標籤就跳過。聽起來像社群貼文按下「已讀」,事情便自動辦好了。偏偏 DDB 貼標籤和 NMQ 建任務是兩套系統;劇情在哪一秒卡住,結局就會從漏發切到重發。
標籤先貼,還是任務先建?兩邊都有破口
| 執行順序 | 中斷時可能留下什麼 |
|---|---|
先寫 Dispatched,再建立 NMQ 任務 | NMQ 建立失敗,紀錄卻已標記為派送,可能漏發。 |
先建立 NMQ 任務,再寫 Dispatched | DDB 更新失敗,下次可能把同一批再派一次。 |
| 先查未派送,再由各 Dispatcher 更新 | 兩個 Dispatcher 可能同時看到同一筆,需要 conditional update 搶佔。 |
還有續集:券不足時會更新 BookingTime,同一筆紀錄可能需要再派。單一布林標記只會說「派過」,卻說不出「上一次派過,這一次該重派」。若真要讓 Dispatched 扛起業務狀態,就得連版本識別、租約到期、搶佔與恢復流程一起設計。
輪到主角 token 登場。它像書籤,記得「下一頁從這裡翻」,卻不會幫忙確認上一頁的任務是否派成功。把書籤當成完成章,平常翻書很順;Job 一重試,才發現自己把查詢進度和業務進度演成同一個角色。
這頁沒資料,為什麼還有下一頁?
DynamoDB 的規則有點像「先點名,再看誰符合資格」:單次 Query 最多先讀到 1 MB,或先達到指定 Limit,然後才套用 FilterExpression。所以 Limit = 1000 管的是評估筆數,不保證手上真的拿到 1000 筆;這頁可能一筆符合的都沒有,卻仍有下一頁。AWS Query 官方文件
| 名稱 | 讀這個值時要知道的事 |
|---|---|
Items | 本頁回傳資料,之後還可能由程式過濾 WaitToReward。 |
Count | DDB 回傳筆數;若使用 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 不是固定快照;執行中若有紀錄新增或換了排序位置,本輪可能看不到它。
ConsistentRead = true 就直接照抄。AWS Query 官方文件現在要替 Dispatcher 加分頁,但不能像電影續集換了主角,連前作設定都一起改掉。入口仍接原本的 ExecuteTime;開關沒開或設定讀不懂,就走舊流程。只有明確啟用才進新流程,而且同一輪 Job 不會演到一半突然換劇本。
第一頁選好路線,整輪別改道
下面兩個設定名稱是規劃值,實作時可依專案慣例調整;重點是「沒開就照舊」要寫進行為,而不是靠大家默契。
| 設定 | 預設值 | 這次 Job 會怎麼做 |
|---|---|---|
_N1CONFIG:BookingDispatcherPaginationEnabled | false | 只有 true 走新分頁流程;缺少或無法解析時走舊流程。 |
_N1CONFIG:BookingDispatcherPageSize | 1000 | 新流程單次查詢上限;缺少、無法解析或非正值時回退到 1000。 |
Job 一報到就讀取模式與 page size,整輪用同一份設定。若第 3 頁處理到一半才改開關,這個 Job 也不臨時轉彎;正整數上限是否另設邊界,留到設定驗證時決定。
flowchart TB
Start["解析原有 ExecuteTime Request"] --> Mode{"分頁設定有效且啟用?"}
Mode -->|否| Old["舊全量查詢與完整分組派送"]
Mode -->|是| New["固定時段與 PageSize"]
New --> Page["讀單頁 Items 和 NextToken"]
Page --> Send["頁內過濾、分組、上傳 S3 並派送"]
Send --> More{"還有 NextToken?"}
More -->|有| Keep["只留統計與 token"]
Keep --> Page
More -->|無| Done["記錄摘要並結束"]
Old --> Done
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
class Start,New,Page,Send,Keep service
class Mode,More decision
class Old,Done result
展開各層保留與新增的介面
| 層級 | 保留 | 新增 |
|---|---|---|
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;後面的頁面先別急著翻,免得這頁還沒處理完就有更多資料擠進來。
flowchart TB
Start["固定模式、時段與 PageSize"] --> Query["讀取單頁 Items 與候選 NextToken"]
Query -->|查詢失敗| Fail["本輪失敗,不改走全量流程"]
Query -->|成功| Ready{"本頁有待回饋紀錄?"}
Ready -->|有| Send["頁內分組排序、上傳 S3、建立 NMQ 任務"]
Ready -->|沒有| Continue["本頁不建任務"]
Send -->|S3 或 NMQ 失敗| Fail
Send -->|全部成功| More{"還有 NextToken?"}
Continue --> More
More -->|有| Keep["只保留統計與 token"]
Keep --> Query
More -->|無| Done["記錄摘要並完成"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
class Start,Query,Send,Keep service
class Ready,More decision
class Fail danger
class Continue,Done result
本頁若有人待回饋,照 (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 都只知道自己那十筆,像三個人各拿到同一本故事的其中一章。
flowchart TB
P1["第 1 頁:同組 10 筆"] --> Old["舊流程:合併 30 筆,依 OrderTime 排序"]
P2["第 2 頁:同組 10 筆"] --> Old
P3["第 3 頁:同組 10 筆"] --> Old
Old --> One["建立 1 個 reward Job"]
P1 --> N1["新流程:Job 1"]
P2 --> N2["新流程:Job 2"]
P3 --> N3["新流程:Job 3"]
classDef page fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef caution fill:#f5e8d3,stroke:#a06a41,color:#65442c
class P1,P2,P3 page
class Old,One,N1,N2,N3 caution
更麻煩的是,Dispatcher 先建立 Job 1、再建立 Job 2,不代表 reward Worker 會照這個順序跑完。拆組還會讓 S3 物件、NMQ 任務和 lock 競爭一起增加。Lock 可以擋住同時處理,卻沒有發「先到先得」的號碼牌。
如果業務真的不能接受拆組,就要用外部暫存後完整分組,或另找能保留順序的做法。把跨頁會員資料一路囤在 RAM,看似守住了排序,記憶體問題卻又從後門走回來。
最後一關是重試。Job 做到第 3 頁突然中斷,第 1、2 頁的 NMQ 任務可能早就建好了。續頁 token 記得查詢位置,卻沒看見任務建立的結果;重新開機也不會自動跳出「上集提要」告訴它哪些已經派過。
第三頁斷線,重跑會從哪裡開始?
本方案沒有新增業務紀錄狀態,也不持久化 token。Dispatcher 查到一頁,就先把這頁的任務派完;成功才翻頁。途中任何一步出錯,這輪就明確失敗,讓錯誤留下來供排查,不吞例外,也不臨時改走全量流程。這樣至少知道它停在哪一步,但先前成功的任務不會因此倒帶。
stateDiagram-v2
[*] --> 查詢本頁
查詢本頁 --> 派送本頁: 查詢成功
派送本頁 --> 查詢本頁: 本頁成功且有 token
派送本頁 --> 完成: 本頁成功且無 token
查詢本頁 --> 失敗: Query 異常
派送本頁 --> 失敗: S3 或 NMQ 異常
完成 --> [*]
失敗 --> [*]
中斷在哪裡,後果就不一樣
| 失敗或變動 | 可能留下的結果 | 本次處理與待確認處 |
|---|---|---|
| Query 失敗 | 當頁未派送。 | 讓 Job 失敗,保留錯誤供排查。 |
| S3 失敗 | 該組尚未建立 NMQ;前面幾組可能已成功。 | 停止本輪;重試可能重複派送前組。 |
| S3 成功、NMQ 失敗 | 可能留下沒有任務使用的 S3 物件。 | 不記成成功;確認現有 S3 清理生命週期。 |
| NMQ 成功、回應遺失 | Dispatcher 不知道任務其實已建立。 | 重試可能再建立一次;token 無法去重。 |
| 後頁失敗或程序中斷 | 先前頁的任務已建立,後頁尚未完成。 | 初版不持久化 token;宿主若重試,從原時段第一頁重跑。 |
| 另一個 Dispatcher 同時查同一時段 | 兩輪可能派送同一筆紀錄。 | token 是查詢位置,無法當跨執行鎖。 |
執行中新增或改動 BookingTime | 紀錄可能移入已讀區段。 | 不能保證本輪全讀到;確認既有補掃。 |
| 索引更新延遲 | 本輪可能暫時看不到新資料。 | 若是 GSI,需接受最終一致性並確認補掃。 |
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。

