Promotion Worker 每天凌晨 01:01 都會迎來一波集中的回饋派工。問題不在 CronJob 突然多跑,而是大量延後回饋紀錄被排到同一個整點,Dispatcher 又在同一輪查詢、分組、上傳 S3 並建立 NMQ Task,因而形成固定時刻的記憶體尖峰。

尖峰如何形成

固定出現在 01:01 的尖峰,與前一小時查詢窗口及整點 BookingTime 聚集相互對應。

根因可拆成三層。第一層是 RewardDays > 0 的計算會把時間收斂到日期整點。第二層是許多紀錄因此共用相同的 BookingTime。第三層是 Dispatcher 會先為所有分組準備 S3 Payload 與 Task Request,再逐一送往 NMQ,單次批量越大,處理期間保留的物件也越多。

時間集中

延後回饋把日期計算結果收斂到整點。

資料累積

大量 WaitToReward 紀錄落在相同查詢窗口。

批量放大

查詢、分組、S3 上傳與 Task 建立集中在同一輪。

DDB 主單筆數不等於 NMQ 回饋 Task 數。Dispatcher 會依 ShopId + MemberId + PromotionEngineId 分組,每個群組建立一個 Task;群組內的多筆訂單則放入同一份 S3 清單。

並非所有回饋都會集中在午夜。真正會進入分桶的是 RewardDays > 0 的延後回饋;即時回饋保留原始訂單時間,指定日期回饋則保留營運設定的時間值。

四條時間來源

三桶分配只處理加 N 天回饋,不會改寫即時回饋、指定日期回饋與券不足重試時間。
情境 BookingTime 計算方式 是否進入三桶分配
RewardDays = 0 保留 orderTime 否
RewardDays > 0 以訂單日期或重算基準加 N 天 是
RewardTimingType = ByDateTime 使用 RewardDateTime 否
優惠券庫存不足重試 CalculateNextRetryBookingTime 排到 10:00 或 22:00 否

會套用分桶的實作路徑

四條路徑使用相同分桶元件與相同 Seed 規則,避免初次計算與後續重算得到不同時間。
路徑 實作位置 分桶 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

Production 排程為 1 * * * *,時區設定為 Asia/Taipei。

Dispatcher 如何把資料轉成回饋 Task

箭頭表示本次 Dispatcher 的處理順序。所有群組的 S3 上傳與 Request 組裝完成後,才進入逐一建立 NMQ Task 的階段。
查詢窗口的計算是 startTime = ExecuteTime.AddHours(-1).ToHour(),endTime = ExecuteTime.ToHour()。DDB 查詢使用日期分區鍵與 BookingTimeUTC 排序鍵。

目前 Production 將加 N 天回饋分成三個整點桶。相同活動與訂單群組會穩定落到同一桶,目標是把原本集中在午夜的資料拆散,同時讓正流程、重算與稽核得到一致結果。

從 Seed 到 BookingTime

Prod 的 BookingTimeAllocatorBucketCount 目前為 3,輸出為目標日期的 00:00、01:00 或 02:00。
1
2
var bucketIndex = (int)(BitConverter.ToUInt32(hash, 0) % (uint)bucketCount);
var bookingTime = dateOnlyBase.Date.AddHours(startHour + bucketIndex);

實作帶來的改善範圍

這張圖只表示 BookingTime 已被分散,不代表 Dispatcher 實際負載一定形成三個等量批次。

這項實作已於 2026 年 9 月 8 日合併至 master,merge commit 為 0ac39b3c,工作項目為 VSTS661981。它改善的是寫入時間分布;實際派工分布仍會受到查詢區間邊界影響。

MD5.ComputeHash 回傳的是 16 bytes,不是可直接拿來分桶的整數。實作只取前四個 byte 轉成 uint32,先完成取餘數,再轉成 int,才能避免負數桶號。

型別轉換的正確順序

C# 的百分比運算是 remainder。被除數為負數時,結果也可能為負數。
1
2
// 正確:先在 uint 範圍取餘數,最後才轉成 int
var bucketIndex = (int)(BitConverter.ToUInt32(hash, 0) % (uint)bucketCount);

以 Seed 28343_TG20193021 為例,MD5 前四個 byte 轉出的 uint32 是 4,273,969,079。若先強制轉成 int,結果會變成 -20,998,217,再對 3 取餘數便可能得到 -2。

單元測試需涵蓋大量不同 Seed,並驗證 bucketIndex 永遠滿足 0 <= bucketIndex < bucketCount。固定 Seed 也應重複得到相同桶號。

BookingTime 改成三桶後,未出貨訂單的稽核不能再假設時間固定是 00:00。目前該路徑已改用相同的 RewardBookingTimeAllocator 重算預期值,其他稽核則依各自的時間語意維持原有比較方式。

各稽核路徑如何比較時間

只有未出貨訂單需要與三桶配置保持一致,券不足重試仍是另一套固定時刻機制。
方法 比對方式 三桶影響
VerifyBookingTimeForShippedOrder 比對日期 同日桶位不改變日期
VerifyBookingTimeForUnshippedOrder 共用 Allocator 重算預期值 已同步三桶邏輯
VerifyBookingTimeForRewardDateTime 依 RewardDateTime 比對 不受加 N 天分桶影響
VerifyBookingTimeForInsufficientECoupon 依 10:00 或 22:00 比對 不屬於本次分桶
目前未出貨稽核容忍時數採 Math.Max(bucketCount, 3)。這能包住現行三桶偏移,但配置若持續增加,仍需確認容忍語意與精確邊界是否符合預期。

三個 BookingTime 不一定形成三次各三分之一的派工。Dispatcher 使用 DynamoDB BETWEEN 查前一小時,而 BETWEEN 同時包含左右邊界,因此落在整點的桶可能被相鄰兩個查詢窗口命中。

預期窗口與實際窗口

連線表示閉區間可能命中的桶,不表示相鄰兩次一定重複建立 Task。是否重掃還取決於紀錄當時是否仍為 WaitToReward。
1
2
3
4
5
var startTime = entity.ExecuteTime.AddHours(-1).ToHour();
var endTime = entity.ExecuteTime.ToHour();

// DynamoDB QueryOperator.Between 同時包含 startTime 與 endTime
Values = new object[] { startTime, endTime };
Dispatcher 時間 閉區間可能命中的桶 對負載的影響
01:01 00:00 與 01:00 第一波可能接近兩桶資料
02:01 01:00 與 02:00 01:00 邊界資料若仍待處理,可能再次命中
03:01 02:00 與 03:00 沒有第四桶,但 02:00 邊界仍在查詢範圍

修正方向

左閉右開區間讓每個整點只屬於一個窗口,最直接對齊三桶設計。

建議優先將查詢語意改成 BookingTime >= startTime 且 BookingTime < endTime,並補上整點與跨日整合測試。在完成修正與 Production 數據複驗前,較精確的結論是:三桶已降低單一 00:00 的資料聚集,但尚不能宣稱 Dispatcher 負載穩定形成三次各三分之一。

建議補齊的整合測試
  1. ExecuteTime = 01:01 時,只撈取 00:00 <= BookingTime < 01:00。
  2. BookingTime = 01:00:00 時,不會被 01:01 與 02:01 兩次查詢同時命中。
  3. 00:00、01:00、02:00 三桶分別落在三個不同的 Dispatcher 執行。
  4. 23:00 到 00:00 的跨日窗口,能正確對應 BookingDateUTC-BookingTimeUTC-index 分區鍵。