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 秒 |