client 端呼叫 POST /order/create_user_order 下單時,如果請求已經寫入資料庫,但回應在傳送途中遺失,client 端只能重送請求。若後端把重送當成新的下單,就可能建立重複品項,導致結算時重複扣款。
因此,下單需要滿足兩個條件:同一次下單重送幾次都只建立一筆品項,但使用者想再點一份相同餐點時,仍然可以正常下單。
假設使用者點了一份鍋貼,但送出請求後一直沒有收到回應,只好再送一次。對伺服器來說,兩次請求的內容完全相同,無法單靠餐點內容判斷使用者是在重送,還是真的想再點一份。

由 client 端為每次下單產生一個 idempotency_key,放在 header 中。同一次下單重送時沿用原本的 Key;如果使用者想再點一份,則產生新的 Key。
伺服器收到請求後,根據「使用者+idempotency_key」查詢處理紀錄:

舉例:Alice 使用 k1 下單後,伺服器建立 item 101 並記錄結果。重送時會直接回傳 item 101,不再建立新品項;若 Alice 想再點一份,則使用新的 k2。
如果伺服器發現這是重送的請求,就不會再次建立品項,而是直接回傳第一次下單建立的 itemId,並在回應中附上 Idempotent-Replayed: true,讓 client 知道這次拿到的是先前的處理結果。
為了記住每個 idempotency_key 對應的結果,需要新增一張 MySQL 資料表:
CREATE TABLE `order_item_idempotency_keys` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` VARCHAR(20) NOT NULL,
`idempotency_key` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`item_id` INT NULL COMMENT '第一次寫入的品項;與品項在同一交易中寫入',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idempotency_user_key` (`user_id`, `idempotency_key`),
KEY `idx_idempotency_created_at` (`created_at`),
CONSTRAINT `fk_idempotency_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
其中,UNIQUE (user_id, idempotency_key) 確保同一個使用者不能建立兩筆相同 Key 的紀錄,而 item_id 則用來回傳第一次下單建立的品項。
最直覺的做法,是先寫入 idempotency_key,再建立品項,利用唯一索引阻止重複請求,然而,這個順序可能與原本的下單流程產生鎖定衝突。
下單時,系統需要先透過 SELECT ... FOR UPDATE 鎖定 users 的資料列,避免同一使用者的多筆下單同時通過餘額檢查。另一方面,新增order_item_idempotency_keys時,MySQL 也會因為 user_id 的外鍵檢查,對 users 的資料列加上共享鎖。
此時,如果兩個相同 Key 的請求同時進來,就可能發生以下情況:

請求 A 等待請求 B 釋放 users 的共享鎖,而請求 B 又在等待請求 A 結束、釋放唯一鍵的鎖,形成互相等待的死鎖。因此,光靠唯一索引還不夠,還需要調整交易中的操作順序。
調整後,交易一開始就先鎖定使用者資料列,再查詢 idempotency_key。如果紀錄已存在,就回傳原本的 itemId;否則才建立品項並記錄 Key。
@Transactional(isolation = Isolation.READ_COMMITTED)
public CreateOrderItemResult createUserOrder(CreateOrderItemRequest request, String userId,
String idempotencyKey) {
...
User user = userMapper.selectForUpdate(userId);
...
if (idempotencyKey != null) {
OrderItemIdempotencyKey existing = idempotencyKeyMapper.selectOne(
new LambdaQueryWrapper<OrderItemIdempotencyKey>()
.eq(OrderItemIdempotencyKey::getUserId, userId)
.eq(OrderItemIdempotencyKey::getIdempotencyKey, idempotencyKey));
if (existing != null) {
return new CreateOrderItemResult(existing.getItemId(), true);
}
}
...
orderItemMapper.insert(item);
if (idempotencyKey != null) {
...
keyRecord.setItemId(item.getItemId());
idempotencyKeyMapper.insert(keyRecord);
}
...
}
同一使用者的下單都必須先取得 users 的列鎖,因此相同 Key 的請求會依序執行:先到者成功建立品項,後到者就回傳原本的 itemId;若先到者回滾,後到者則能繼續建立品項。唯一索引仍保留,作為最後一道防線。