Project

General

Profile

CR #201 » SDD-004_Conflict-Handling與一致性設計.md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

SDD-004 Conflict Handling 與一致性設計

項目 內容
文件編號 SDD-004
版本 1.0
狀態 已核定
撰寫者 SD / SA
審查者 TL / QA
對應需求 SYS-2.2、ISS-2.3、ISS-2.6、ISS-2.12;POC-09、POC-11、POC-14

1. 衝突類型總覽

類型 衝突本質 主要場景 處理策略 章節
C1 併發寫入 同一資源同時被多請求異動 額度扣減、餘額更新 樂觀鎖 + 條件更新 §2
C2 重複請求 同一業務請求被送出多次 網路重試、使用者連點 冪等鍵 §3
C3 重複入帳 同一筆帳務被重複記帳 請款檔重送、批次重跑 業務冪等鍵 + 偵測 §3.2
C4 事件重複 同一事件被消費多次 至少一次投遞 消費者冪等 §4.1
C5 事件亂序 同一聚合事件順序錯置 分區重平衡、重試 序號檢核 + 暫存 §4.2
C6 跨服務不一致 部分成功部分失敗 分期佔用後入帳失敗 Saga + 補償 §5
C7 外部系統不一致 本行與外部狀態不同 授權已核准但主機無紀錄 對帳 + 補正 §6
C8 狀態衝突 對不允許之狀態執行操作 對已註銷卡片開卡 狀態機檢核 §7
C9 時序衝突 事件早於資料提交 事件先於交易 Outbox §4.3
C10 版本衝突 契約或參數版本不一致 新舊服務並存 版本容忍 + 雙軌 §8

2. 併發寫入衝突(C1)

2.1 樂觀鎖

所有可異動聚合含 version 欄位;更新時以 WHERE id = ? AND version = ? 為條件。

flowchart TD
  A["讀取聚合(含 version)"] --> B["執行領域行為"]
  B --> C["以 version 為條件更新"]
  C --> D{"影響筆數"}
  D -->|"1"| E["成功,version + 1"]
  D -->|"0"| F["ConcurrencyConflictError"]
  F --> G{"重試次數 < 3"}
  G -->|"是"| A
  G -->|"否"| H["回覆 409 衝突"]

2.2 條件更新(金額類必要)

金額類異動不得僅依賴樂觀鎖,須加上業務條件,避免「先讀後寫」造成超額:

UPDATE lim.credit_limits
   SET available_amount = available_amount - :amount,
       used_amount      = used_amount + :amount,
       version          = version + 1,
       updated_at       = now()
 WHERE id      = :id
   AND version = :version
   AND available_amount >= :amount;
影響筆數 判定 處理
1 成功 繼續
0 且 available < amount 額度不足 回 CC-LIM-1001(業務拒絕,不重試)
0 且 available >= amount 版本衝突 重試 3 次

區分兩種 0 筆影響是關鍵:前者為業務拒絕(不可重試),後者為併發衝突(可重試)。
實作須以二次查詢區分,不得一律視為衝突重試。

2.3 重試策略

情境 重試次數 退避 逾時後
授權額度扣減 3 10 ms → 20 ms → 40 ms(含抖動) 拒絕交易(回應 CC-AUTH-1901)
一般餘額更新 3 50 ms → 100 ms → 200 ms 回 409,由呼叫端重試
批次處理 5 1 s → 2 s → 4 s → 8 s → 16 s 記錄並跳過,續跑後補

3. 重複請求與重複入帳(C2 / C3)

3.1 請求層冪等(C2)

flowchart TD
  A["寫入請求"] --> B{"帶 X-Idempotency-Key"}
  B -->|"否"| C["400 缺少冪等鍵"]
  B -->|"是"| D{"查 idempotency_records"}
  D -->|"無紀錄"| E["計算請求指紋(SHA-256)"]
  E --> F["執行業務邏輯"]
  F --> G["寫入結果與冪等鍵(同一交易)"]
  G --> H["回傳結果"]
  D -->|"有紀錄,指紋相同"| I["回傳前次結果(不重複執行)"]
  D -->|"有紀錄,指紋不同"| J["409 冪等鍵衝突"]
規則 說明
冪等鍵保存 ≥ 24 小時;授權與請款類 ≥ 14 天
指紋組成 HTTP 方法 + 路徑 + 正規化後之請求主體
併發同鍵 以 UNIQUE (idempotency_key) 保證;第二筆觸發唯一鍵衝突後改讀前次結果
未完成請求 冪等記錄於業務交易內寫入,避免「記錄存在但業務未完成」

3.2 業務層重複入帳防範(C3)

請求層冪等無法防止「不同請求、同一筆帳務」(如請款檔重送)。因此須有業務冪等鍵。

帳務類型 業務冪等鍵組成
請款入帳 卡號雜湊 + 授權碼 + 請款日期 + 金額 + 幣別
繳款入帳 繳款編號 + 繳款日期 + 金額
費用入帳 帳戶 + 費用代碼 + 計費期間
利息入帳 帳戶 + 計息期間 + 利息類型
分期攤還 分期計畫 + 期數
沖正 原交易編號 + 沖正序號
點數入帳 帳戶 + 來源交易 + 點數類型

請款檔重複偵測(POC-09)

flowchart TD
  A["收到請款檔"] --> B["計算本次檔案的<br/>筆數與金額總計"]
  B --> C["查詢前 14 天內之請款批次"]
  C --> D{"筆數與金額均相同"}
  D -->|"否"| H["正常處理"]
  D -->|"是"| E["逐筆比對請款明細"]
  E --> F{"明細完全相同"}
  F -->|"否"| H
  F -->|"是"| G["跳出警示,標記為疑似重複<br/>由人工介入處理"]
  G --> I["人工判定"]
  I -->|"確認為重複"| J["整批排除並留存稽核軌跡"]
  I -->|"確認為新請款"| H

三重防護

層次 機制 攔阻對象
L1 請求層 X-Idempotency-Key 同一請求重送
L2 帳務層 業務冪等鍵唯一約束 同一筆帳務重複記帳
L3 批次層 檔案指紋比對(筆數+金額+明細) 整批請款檔重送

4. 事件衝突(C4 / C5 / C9)

4.1 事件重複(C4)

機制 說明
消費者冪等 以 event_id 寫入 consumed_events 表;重複則略過
投影冪等 投影以 event_id 去重,重複套用不改變結果
業務冪等 即使投影重複套用,業務層冪等鍵仍可攔阻
保留期 consumed_events 保留 ≥ 7 天(大於最大重投遞窗口)

4.2 事件亂序(C5)

flowchart TD
  A["收到事件"] --> B{"sequence_no 是否連續"}
  B -->|"是"| C["套用事件"]
  B -->|"小於期望值"| D["判定為重複,略過"]
  B -->|"大於期望值"| E["寫入 pending_events 暫存"]
  E --> F{"等待逾時 5 秒"}
  F -->|"逾時"| G["標記為缺漏事件"]
  G --> H["觸發補償查詢<br/>向來源服務取得現況"]
  H --> I["以現況覆寫投影"]
  F -->|"收到缺漏事件"| C
  C --> J["更新期望 sequence_no"]
  J --> K["檢查 pending_events 是否有可套用者"]
  K --> C
規則 說明
分區鍵 以 aggregateId 為 Kafka 分區鍵,同一聚合必落同一分區
序號 每個聚合維護遞增 sequence_no
缺漏處理 逾時未到 → 主動向來源服務查詢現況覆寫(避免永久卡住)
監控 out_of_order_total、missing_event_total 為監控指標

4.3 事件早於資料(C9)

問題:若先發布事件後提交交易,消費者可能讀不到資料。

解法:Outbox 模式——事件與聚合異動同一交易寫入 outbox 表,
交易提交後才由背景工作者發布。

sequenceDiagram
  participant Handler as 命令處理器
  participant DB as 資料庫
  participant Outbox
  participant Worker as Outbox 工作者
  participant Kafka
  participant Consumer

  Handler->>DB: BEGIN
  Handler->>DB: 更新聚合
  Handler->>Outbox: INSERT 事件(同一交易)
  Handler->>DB: COMMIT
  Note over DB,Outbox: 兩者同時成立或同時不成立
  Worker->>Outbox: 輪詢未發布事件
  Worker->>Kafka: 發布事件
  Kafka->>Consumer: 投遞
  Worker->>Outbox: 標記 published_at

5. 跨服務一致性(C6)

5.1 樣式選擇

情境 樣式 理由
分期建立(佔用 → 入帳) 編排式 Saga 步驟少、需明確補償
請款入帳(授權 → 帳務 → 額度) 協同式 Saga 事件驅動、可延遲補償
繳款(入帳 → 恢復額度) 同步雙軌 + 事件補正 額度恢復需即時
點數兌換(扣點 → 授權 → 帳務) 編排式 Saga 授權失敗須回滾扣點
清算(比對 → 撥款 → 分錄) 批次 + 人工介入 差異須人工判定

5.2 補償動作對照

正向動作 補償動作 冪等要求
佔用額度 釋放佔用 釋放具冪等(已釋放則略過)
入帳 反向沖正(成對) 沖正冪等(同沖正序號僅一次)
扣點 還點 還點冪等
建立分期 取消分期並釋放額度 取消冪等
建立 Token 刪除 Token 刪除冪等
發送通知 不補償(通知可重複,不可撤回) —
產生會計分錄 反向分錄 反向分錄冪等

5.3 Saga 範例:分期建立

sequenceDiagram
  participant Client
  participant Inst as installment-service
  participant Limit as limit-service
  participant Posting as posting-service
  participant Saga as Saga 協調者

  Client->>Saga: 建立分期
  Saga->>Inst: 建立分期計畫(PENDING)
  Inst-->>Saga: planId
  Saga->>Limit: 佔用分期額度
  alt 佔用成功
    Limit-->>Saga: holdId
    Saga->>Posting: 首期入帳
    alt 入帳成功
      Posting-->>Saga: txId
      Saga->>Inst: 標記分期成立
      Saga-->>Client: 201 成功
    else 入帳失敗
      Posting-->>Saga: 失敗
      Saga->>Limit: 補償:釋放佔用
      Limit-->>Saga: 已釋放
      Saga->>Inst: 補償:取消分期
      Saga-->>Client: 422 失敗(已回復)
    end
  else 佔用失敗
    Limit-->>Saga: 額度不足
    Saga->>Inst: 補償:取消分期
    Saga-->>Client: 422 額度不足
  end

5.4 Saga 狀態持久化

欄位 說明
saga_id Saga 唯一識別碼
saga_type Saga 類型
current_step 目前步驟
status RUNNING / COMPLETED / COMPENSATING / COMPENSATED / FAILED
payload 步驟間傳遞之資料
compensation_log 補償執行紀錄

規則:Saga 狀態須持久化,服務重啟後可續行;補償失敗須告警並可人工介入。


6. 外部系統不一致(C7)

情境 偵測 處理
本行已核准、外部無紀錄 日終對帳 以本行紀錄為準;補送確認
外部已核准、本行無紀錄 日終對帳 補建交易並標記來源
Stand-in 代授權後主機恢復 補正佇列 逐筆補正;拒絕者產生追償
清算檔金額不符 批次比對 差異分類處理(時間差/匯率差/實質差異)
通知已發但未送達 通道回報 重送(通知可重複)

6.1 對帳機制

flowchart TD
  A["日終批次"] --> B["取得本行交易彙總"]
  B --> C["取得外部對帳檔"]
  C --> D["逐筆配對"]
  D --> E{"配對結果"}
  E -->|"雙邊相符"| F["標記已對帳"]
  E -->|"本行有外部無"| G["產生待確認清單"]
  E -->|"外部有本行無"| H["產生補建清單"]
  E -->|"金額不符"| I["產生差異清單"]
  G --> J["人工或自動處理"]
  H --> J
  I --> J
  J --> K["處理結果回寫並留痕"]
  K --> L{"差異歸零"}
  L -->|"否"| D
  L -->|"是"| M["對帳完成"]

規則:對帳差異須為 零 方得結案(BR-08-01);無法歸零者須經主管核可並留痕。


7. 狀態衝突(C8)

所有狀態轉換須經狀態機檢核(詳 SDD-005)。

規則 說明
非法轉換 回 StateTransitionError → HTTP 409,錯誤碼 CC-*-3001
並發轉換 以樂觀鎖 + 狀態條件更新(WHERE status = :expected)
終態保護 已 CLOSED 之資源不得再轉換
冪等轉換 重複執行同一轉換(目標狀態已達成)→ 回 200 而非 409
-- 以狀態為條件的轉換(避免並發下非法轉換)
UPDATE card.cards
   SET block_code = :target,
       version    = version + 1,
       updated_at = now()
 WHERE id         = :id
   AND version    = :version
   AND block_code = :expected_source;

8. 版本衝突(C10)

情境 容忍策略
服務新舊版本並存 API 契約向後相容;舊版至少維持一個 release
事件 v1/v2 並存 消費者同時支援兩版
參數版本不一致 參數帶生效日;服務依業務日取對應版本
資料庫 migration 期間 採向後相容之遷移(先加欄位、後填值、最後加約束)

8.1 資料庫遷移三階段(Expanded Contract)

flowchart LR
  A["階段一<br/>新增欄位(可空)"] --> B["階段二<br/>雙寫並回填"]
  B --> C["階段三<br/>加上約束並移除舊欄位"]
  C --> D["下一個 release<br/>移除舊欄位"]

規則:任一階段皆不得造成舊版服務無法運作。


9. 衝突處理對照表(速查)

衝突 偵測方式 處理 錯誤碼 可否重試
併發寫入(版本) 影響筆數 0 且條件成立 重試 3 次 CC-*-2001 ✅
額度不足 影響筆數 0 且 available < amount 業務拒絕 CC-LIM-1001 ❌
重複請求 冪等鍵已存在且指紋相同 回前次結果 — —
冪等鍵衝突 冪等鍵存在但指紋不同 拒絕 CC-*-2002 ❌
重複入帳 業務冪等鍵唯一約束 攔阻並記錄 CC-POST-4001 ❌
整批重複請款 檔案指紋比對 警示並人工處理 CC-POST-4002 ❌
事件重複 event_id 已消費 略過 — —
事件亂序 sequence_no 不連續 暫存等待或補查 — ✅
狀態非法轉換 狀態條件更新影響 0 筆 拒絕 CC-*-3001 ❌
資源不存在 查無資料 拒絕 CC-*-4004 ❌
外部逾時 逾時 重試或降級 CC-*-5001 ✅(冪等者)
Saga 補償失敗 補償步驟失敗 告警 + 人工介入 CC-*-5002 ✅

10. 驗證方式

驗證項 方法 通過標準
併發扣減不超額 併發 200 筆同帳戶授權 成功筆數 ≤ 額度允許筆數,available 不為負
同冪等鍵僅一筆 併發 200 筆同冪等鍵 僅 1 筆成功,其餘回前次結果
重複入帳防範 同業務冪等鍵重送 僅 1 筆入帳
整批重複偵測 同一請款檔重送 觸發警示,未重複入帳
事件重複 同一事件投遞 3 次 投影結果與投遞 1 次相同
事件亂序 亂序投遞同聚合事件 最終狀態正確
補償正確性 注入入帳失敗 額度與點數回復至原值
狀態機 逐條嘗試非法轉換 全部被拒並回 409
對帳歸零 日終對帳 差異為 0
同步延遲 量測事件到投影 P95 ≤ 1 秒
(21-21/67)