iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Vibe Coding

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

Day 23|結算失敗後,重試要從哪裡繼續?

  • 分享至 

  • xImage
  •  

團購訂單到了截止時間,系統會自動結算:先把訂單從 OPEN 改成 CLOSED,再向參與者扣款,問題是:「如果結算做到一半失敗了,比方說資料庫突然斷線,下一次重試要從哪裡開始?」這時扣款交易會 rollback,但前面的 CLOSED 訂單狀態已經寫進資料庫,要讓下一次重試知道:「截止已經完成了,訂單狀態已經更新成CLOSED,應該直接從扣款繼續。」

問題:重試可能被 CLOSED 擋住

目前 settleOrder 原本只接受 OPEN:

if (order.getStatus() != OrderStatus.OPEN) {
    return;
}

因此上面的訂單重新執行時,看到自己已經是 CLOSED,就直接返回,也因為沒有拋例外,消費端也不會再次把它放回佇列,最後訂單就永遠停在 CLOSED。

因此 settleOrder 必須同時接受 OPEN 和 CLOSED:

if (order == null
        || (order.getStatus() != OrderStatus.OPEN
        && order.getStatus() != OrderStatus.CLOSED)) {
    return;
}

if (order.getStatus() == OrderStatus.OPEN) {
    if (orderMapper.updateStatusIfIn(
            orderId, OrderStatus.CLOSED, List.of(OrderStatus.OPEN)) == 0) {
        return;
    }
    order.setStatus(OrderStatus.CLOSED);
}

// Attempt payment
// ...扣款、通知

狀態是 OPEN的訂單會先轉成 CLOSED再扣款, CLOSED 會跳過截止,直接扣款,FAILED則不由自動結算重試、只能透過手動觸發重試機制。

而這邊的重試不能造成重複扣款,因為扣款前還會用條件式 UPDATE 搶狀態,只有成功把 CLOSED/FAILED 改成 SETTLED 的執行緒才能繼續。

int claimed = orderMapper.updateStatusIfIn(orderId, OrderStatus.SETTLED,
        List.of(OrderStatus.CLOSED, OrderStatus.FAILED));
if (claimed == 0) {
    throw new ConflictException("訂單狀態已變更,無法結算");
}

知道「可以從哪裡繼續」後,還要處理另一個問題:「如果資料庫真的掛掉十分鐘,不能每秒重試一次。」

解決方法:失敗後不要立刻無限重跑

因此失敗後不直接放回「現在就執行」的佇列,而是延後下一次執行:

第 1 次失敗 → 1 秒後
第 2 次失敗 → 2 秒後
第 3 次失敗 → 4 秒後
第 4 次失敗 → 8 秒後
第 5 次失敗 → 16 秒後

佇列原本就用 score 表示「什麼時候執行」,所以只要把下一次執行時間往後推即可。重試次數則另外記錄,與重新排程放在同一支 Lua 腳本中,確保增加次數、移除舊任務與重新入隊不會只完成一半。

-- KEYS[1] = 佇列          KEYS[2] = 處理中
-- KEYS[3] = 重試次數      KEYS[4] = 待通知集合
-- ARGV[1] = 訂單編號      ARGV[2] = 認領時的租約(用於前段省略的租約比對)
-- ARGV[3] = 現在時間      ARGV[4..] = 第 1~5 次重試前要等的毫秒數

-- 重試上限 = 傳入的等待時間有幾個(5 個)
local maxRetries = #ARGV - 3

-- 這筆訂單的重試次數 +1,並移出處理中
local attempt = redis.call('HINCRBY', KEYS[3], ARGV[1], 1)
redis.call('ZREM', KEYS[2], ARGV[1])

-- 次數用完:清掉次數、放進待通知集合,不再排回佇列
if attempt > maxRetries then
    redis.call('HDEL', KEYS[3], ARGV[1])
    redis.call('SADD', KEYS[4], ARGV[1])
    return 0
end

-- 還有次數:第 n 次重試等 ARGV[3 + n] 毫秒,以「現在 + 等待時間」當 score 放回佇列
local dueAt = tonumber(ARGV[3]) + tonumber(ARGV[3 + attempt])
redis.call('ZADD', KEYS[1], string.format('%.0f', dueAt), ARGV[1])
return attempt

超過次數後就不再重試,而是放進「待處理集合」,通知團主與管理員。通知失敗則重新放回集合,下一輪再通知。這樣即使結算機器在處理途中當掉,租約到期後重新執行,也會消耗同一套重試次數,不會因為換了一台機器就重新計算。


上一篇
Day 22|多台機器如何不重複結算
下一篇
Day 24|推播不能跟著交易一起送
系列文
做一個團購後端,順便搞懂那些事 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言