凌晨0101的記憶體尖峰問題
Promotion Worker 每天凌晨 01:01 都會迎來一波集中的回饋派工。問題不在 CronJob 突然多跑,而是大量延後回饋紀錄被排到同一個整點,Dispatcher 又在同一輪查詢、分組、上傳 S3 並建立 NMQ Task,因而形成固定時刻的記憶體尖峰。
尖峰如何形成
flowchart LR
Orders["多筆訂單命中延後回饋"] --> Normalize["BookingTime 集中到整點"]
Normalize --> Wait["DDB 累積 WaitToReward"]
Wait --> Cron["01:01 CronJob 啟動"]
Cron --> Dispatch["同一輪查詢與建立大量 Task"]
Dispatch --> Peak["Worker 記憶體形成尖峰"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef waiting fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
class Orders,Normalize service
class Wait,Cron waiting
class Dispatch,Peak danger
根因可拆成三層。第一層是 RewardDays > 0 的計算會把時間收斂到日期整點。第二層是許多紀錄因此共用相同的 BookingTime。第三層是 Dispatcher 會先為所有分組準備 S3 Payload 與 Task Request,再逐一送往 NMQ,單次批量越大,處理期間保留的物件也越多。
延後回饋把日期計算結果收斂到整點。
大量 WaitToReward 紀錄落在相同查詢窗口。
查詢、分組、S3 上傳與 Task 建立集中在同一輪。
ShopId + MemberId + PromotionEngineId 分組,每個群組建立一個 Task;群組內的多筆訂單則放入同一份 S3 清單。並非所有回饋都會集中在午夜。真正會進入分桶的是 RewardDays > 0 的延後回饋;即時回饋保留原始訂單時間,指定日期回饋則保留營運設定的時間值。
四條時間來源
flowchart TB
Start["計算 BookingTime"] --> Timing{"回饋時間類型"}
Timing -->|RewardDays 等於零| Immediate["保留原始訂單時間"]
Timing -->|指定日期| ByDate["使用 RewardDateTime"]
Timing -->|RewardDays 大於零| Delayed["以日期加 N 天"]
Delayed --> Allocate["RewardBookingTimeAllocator 分桶"]
Retry["優惠券庫存不足重試"] --> Fixed["固定排到 10:00 或 22:00"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
class Start,Delayed,Retry service
class Timing decision
class Allocate success
class Immediate,ByDate,Fixed result
| 情境 | BookingTime 計算方式 | 是否進入三桶分配 |
|---|---|---|
RewardDays = 0 |
保留 orderTime |
否 |
RewardDays > 0 |
以訂單日期或重算基準加 N 天 | 是 |
RewardTimingType = ByDateTime |
使用 RewardDateTime |
否 |
| 優惠券庫存不足重試 | CalculateNextRetryBookingTime 排到 10:00 或 22:00 |
否 |
會套用分桶的實作路徑
flowchart LR
ECom["線上訂單初次計算"] --> Allocator["RewardBookingTimeAllocator"]
CRM["線下訂單初次計算"] --> Allocator
Point["給點時間重算"] --> Allocator
Coupon["給券時間重算"] --> Allocator
Allocator --> Booking["00:00、01:00 或 02:00"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
class ECom,CRM,Point,Coupon service
class Allocator decision
class Booking success
| 路徑 | 實作位置 | 分桶 Seed |
|---|---|---|
| 線上訂單初次計算 | EComOrderPromotionRewardCalculateService.GetRewardConditionSetting |
PromotionEngineId_TradesOrderGroupCode |
| 線下訂單初次計算 | OthersCrmSalesOrderPromotionRewardCalculateService.GetRewardConditionSetting |
PromotionEngineId_TradesOrderGroupCode |
| 未出貨或出貨後給點重算 | LoyaltyPointService.SetBookingTimeAsync |
PromotionEngineId_TradesOrderGroupCode |
| 未出貨或出貨後給券重算 | CouponService.SetBookingTimeAsync |
PromotionEngineId_TradesOrderGroupCode |
K8s CronJob 只負責建立 Dispatcher Task。查詢 DynamoDB、依會員與活動分組、上傳 S3,以及建立真正的給點或給券 Task,都由 BookingRewardLoyaltyPointsDispatcherV2Job 接手。
每小時 01 分如何喚起 Dispatcher
sequenceDiagram
autonumber
participant Cron as K8s CronJob
participant Script as CreateTask Script
participant Admin as NMQ Admin API
participant Queue as NMQ
participant Dispatcher as Booking Dispatcher
Cron->>Script: 每小時 01 分啟動
Script->>Script: 產生含時區的 ExecuteTime
Script->>Admin: POST /api/v1/task
Admin->>Queue: 建立 Dispatcher Task
Queue-->>Dispatcher: 交付 BookingRewardLoyaltyPointsDispatcherV2
Note over Cron,Dispatcher: CronJob 只建立入口 Task,尚未建立給點或給券 Task
1 * * * *,時區設定為 Asia/Taipei。Dispatcher 如何把資料轉成回饋 Task
flowchart TB
Execute["解析 ExecuteTime"] --> Window["計算前一小時整點窗口"]
Window --> Query["依日期分區與 BookingTime 查詢 DDB"]
Query --> Filter["保留 WaitToReward"]
Filter --> Any{"有符合紀錄?"}
Any -->|否| Stop["本次結束"]
Any -->|是| Group["依 ShopId、MemberId、PromotionEngineId 分組"]
Group --> Sort["群組內依 OrderTime 排序"]
Sort --> Source["判斷 ECom 或 Others 來源"]
Source --> Upload["上傳 memberDataList 至 S3"]
Upload --> RewardType{"RewardType 是 ECoupon?"}
RewardType -->|是| Coupon["建立 Coupon PriorityLow Request"]
RewardType -->|否| Point["建立 LoyaltyPoints PriorityLow Request"]
Coupon --> Collect["加入 taskRequests"]
Point --> Collect
Collect --> More{"還有下一個群組?"}
More -->|是| Sort
More -->|否| Create["逐一呼叫 Task.CreateAsync"]
Create --> Worker["回饋 Worker 下載 S3 清單並依序處理"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
class Execute,Window,Query,Filter,Group,Sort,Source,Upload,Collect service
class Any,RewardType,More decision
class Coupon,Point,Create,Worker success
class Stop result
startTime = ExecuteTime.AddHours(-1).ToHour(),endTime = ExecuteTime.ToHour()。DDB 查詢使用日期分區鍵與 BookingTimeUTC 排序鍵。目前 Production 將加 N 天回饋分成三個整點桶。相同活動與訂單群組會穩定落到同一桶,目標是把原本集中在午夜的資料拆散,同時讓正流程、重算與稽核得到一致結果。
從 Seed 到 BookingTime
flowchart LR
Seed["PromotionEngineId 與 TradesOrderGroupCode"] --> MD5["計算 MD5"]
MD5 --> Bytes["取前四個 byte"]
Bytes --> UInt["轉成 uint32"]
UInt --> Mod["對 bucketCount 取餘數"]
Mod --> B0["bucket 0,00:00"]
Mod --> B1["bucket 1,01:00"]
Mod --> B2["bucket 2,02:00"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
class Seed,MD5,Bytes,UInt service
class Mod decision
class B0,B1,B2 success
BookingTimeAllocatorBucketCount 目前為 3,輸出為目標日期的 00:00、01:00 或 02:00。1 | var bucketIndex = (int)(BitConverter.ToUInt32(hash, 0) % (uint)bucketCount); |
實作帶來的改善範圍
flowchart TB
subgraph Before["分桶前"]
All["全部延後回饋"] --> Midnight["00:00"]
end
subgraph After["分桶後"]
Split["延後回饋"] --> H0["00:00"]
Split --> H1["01:00"]
Split --> H2["02:00"]
end
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
class All,Split service
class Midnight danger
class H0,H1,H2 success
這項實作已於 2026 年 9 月 8 日合併至 master,merge commit 為 0ac39b3c,工作項目為 VSTS661981。它改善的是寫入時間分布;實際派工分布仍會受到查詢區間邊界影響。
MD5.ComputeHash 回傳的是 16 bytes,不是可直接拿來分桶的整數。實作只取前四個 byte 轉成 uint32,先完成取餘數,再轉成 int,才能避免負數桶號。
型別轉換的正確順序
flowchart TB
Hash["MD5 的前四個 byte"] --> UInt["BitConverter.ToUInt32"]
UInt --> Choice{"先做哪一步?"}
Choice -->|先取餘數| SafeMod["uint32 % bucketCount"]
SafeMod --> SafeInt["結果再轉 int"]
SafeInt --> Safe["桶號落在合法範圍"]
Choice -->|先轉 int| Overflow["超過 int.MaxValue 時變成負數"]
Overflow --> Negative["負數 remainder 可能產生負桶號"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
class Hash,UInt service
class Choice decision
class SafeMod,SafeInt,Safe success
class Overflow,Negative danger
1 | // 正確:先在 uint 範圍取餘數,最後才轉成 int |
以 Seed 28343_TG20193021 為例,MD5 前四個 byte 轉出的 uint32 是 4,273,969,079。若先強制轉成 int,結果會變成 -20,998,217,再對 3 取餘數便可能得到 -2。
bucketIndex 永遠滿足 0 <= bucketIndex < bucketCount。固定 Seed 也應重複得到相同桶號。BookingTime 改成三桶後,未出貨訂單的稽核不能再假設時間固定是 00:00。目前該路徑已改用相同的 RewardBookingTimeAllocator 重算預期值,其他稽核則依各自的時間語意維持原有比較方式。
各稽核路徑如何比較時間
flowchart TB
Verify["PromotionRewardBookingTimeAuditor"] --> Type{"驗證情境"}
Type -->|已出貨訂單| Shipped["比較 BookingDate"]
Type -->|未出貨訂單| Unshipped["用相同 Allocator 重算預期 BookingTime"]
Type -->|指定日期回饋| DateTime["依 RewardDateTime 比對"]
Type -->|券不足重試| Coupon["依 10:00 或 22:00 比對"]
Unshipped --> Tolerance["容忍範圍至少涵蓋三桶"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
class Verify service
class Type decision
class Unshipped,Tolerance success
class Shipped,DateTime,Coupon result
| 方法 | 比對方式 | 三桶影響 |
|---|---|---|
VerifyBookingTimeForShippedOrder |
比對日期 | 同日桶位不改變日期 |
VerifyBookingTimeForUnshippedOrder |
共用 Allocator 重算預期值 | 已同步三桶邏輯 |
VerifyBookingTimeForRewardDateTime |
依 RewardDateTime 比對 |
不受加 N 天分桶影響 |
VerifyBookingTimeForInsufficientECoupon |
依 10:00 或 22:00 比對 | 不屬於本次分桶 |
Math.Max(bucketCount, 3)。這能包住現行三桶偏移,但配置若持續增加,仍需確認容忍語意與精確邊界是否符合預期。三個 BookingTime 不一定形成三次各三分之一的派工。Dispatcher 使用 DynamoDB BETWEEN 查前一小時,而 BETWEEN 同時包含左右邊界,因此落在整點的桶可能被相鄰兩個查詢窗口命中。
預期窗口與實際窗口
flowchart TB
B0["bucket 0,00:00"] --> Q1["01:01 查詢,00:00 到 01:00"]
B1["bucket 1,01:00"] --> Q1
B1 --> Q2["02:01 查詢,01:00 到 02:00"]
B2["bucket 2,02:00"] --> Q2
B2 --> Q3["03:01 查詢,02:00 到 03:00"]
Q1 --> Load1["第一波可能同時承接兩桶"]
Q2 --> Load2["第二波包含新桶與邊界重掃可能"]
Q3 --> Load3["第三波可能只剩邊界重掃"]
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 B0,B1,B2 service
class Q1,Q2,Q3 decision
class Load1,Load2 danger
class Load3 result
WaitToReward。1 | var startTime = entity.ExecuteTime.AddHours(-1).ToHour(); |
| Dispatcher 時間 | 閉區間可能命中的桶 | 對負載的影響 |
|---|---|---|
| 01:01 | 00:00 與 01:00 | 第一波可能接近兩桶資料 |
| 02:01 | 01:00 與 02:00 | 01:00 邊界資料若仍待處理,可能再次命中 |
| 03:01 | 02:00 與 03:00 | 沒有第四桶,但 02:00 邊界仍在查詢範圍 |
修正方向
flowchart TB
Issue["整點桶落在閉區間邊界"] --> Choice{"選擇修正層級"}
Choice -->|查詢層| HalfOpen["改為左閉右開區間"]
Choice -->|時間層| Offset["BookingTime 避開整點邊界"]
Choice -->|派工層| Idempotent["增加派工鎖或冪等檢查"]
HalfOpen --> Preferred["優先方案,直接消除窗口重疊"]
Offset --> Sync["同步調整 Auditor 與測試"]
Idempotent --> Limit["降低重派風險,但不降低第一波兩桶負載"]
classDef service fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef decision fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef success fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef result fill:#fbf7ef,stroke:#b49b78,color:#493b2d
classDef danger fill:#f9e9e3,stroke:#bb765f,color:#704737
class Issue danger
class Choice decision
class HalfOpen,Preferred success
class Offset,Idempotent,Sync,Limit result
建議優先將查詢語意改成 BookingTime >= startTime 且 BookingTime < endTime,並補上整點與跨日整合測試。在完成修正與 Production 數據複驗前,較精確的結論是:三桶已降低單一 00:00 的資料聚集,但尚不能宣稱 Dispatcher 負載穩定形成三次各三分之一。
建議補齊的整合測試
ExecuteTime = 01:01時,只撈取00:00 <= BookingTime < 01:00。BookingTime = 01:00:00時,不會被 01:01 與 02:01 兩次查詢同時命中。- 00:00、01:00、02:00 三桶分別落在三個不同的 Dispatcher 執行。
- 23:00 到 00:00 的跨日窗口,能正確對應
BookingDateUTC-BookingTimeUTC-index分區鍵。



