iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 28 篇

Day 28|使用者重送「下單」,同一份餐點會被扣兩次款嗎?

  • 分享至 

  • xImage
  •  

client 端呼叫 POST /order/create_user_order 下單時,如果請求已經寫入資料庫,但回應在傳送途中遺失,client 端只能重送請求。若後端把重送當成新的下單,就可能建立重複品項,導致結算時重複扣款。

因此,下單需要滿足兩個條件:同一次下單重送幾次都只建立一筆品項,但使用者想再點一份相同餐點時,仍然可以正常下單。

問題一:伺服器怎麼知道使用者是在重送?

假設使用者點了一份鍋貼,但送出請求後一直沒有收到回應,只好再送一次。對伺服器來說,兩次請求的內容完全相同,無法單靠餐點內容判斷使用者是在重送,還是真的想再點一份。

https://ithelp.ithome.com.tw/upload/images/20261012/20168667LmLq0wyZpY.png

解決方法:用 Idempotency-Key 識別同一次下單

由 client 端為每次下單產生一個 idempotency_key,放在 header 中。同一次下單重送時沿用原本的 Key;如果使用者想再點一份,則產生新的 Key。

伺服器收到請求後,根據「使用者+idempotency_key」查詢處理紀錄:

https://ithelp.ithome.com.tw/upload/images/20261012/20168667IztosY69Np.png

舉例: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,併發時可能發生死鎖

最直覺的做法,是先寫入 idempotency_key,再建立品項,利用唯一索引阻止重複請求,然而,這個順序可能與原本的下單流程產生鎖定衝突。

下單時,系統需要先透過 SELECT ... FOR UPDATE 鎖定 users 的資料列,避免同一使用者的多筆下單同時通過餘額檢查。另一方面,新增order_item_idempotency_keys時,MySQL 也會因為 user_id 的外鍵檢查,對 users 的資料列加上共享鎖。

此時,如果兩個相同 Key 的請求同時進來,就可能發生以下情況:

https://ithelp.ithome.com.tw/upload/images/20261012/20168667N3Q9BIdxPi.png

請求 A 等待請求 B 釋放 users 的共享鎖,而請求 B 又在等待請求 A 結束、釋放唯一鍵的鎖,形成互相等待的死鎖。因此,光靠唯一索引還不夠,還需要調整交易中的操作順序。

解決方法:先鎖定 users,再查詢與寫入 idempotency_key

調整後,交易一開始就先鎖定使用者資料列,再查詢 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;若先到者回滾,後到者則能繼續建立品項。唯一索引仍保留,作為最後一道防線。


上一篇
Day 27|登入 API 被暴力猜密碼,該限制誰?
系列文
做一個團購後端,順便搞懂那些事 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言