iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

團購訂單有截止時間,時間一到,系統會自動結算,向每位參與者扣款。但是同一張訂單不能被多台機器同時處理,也不能因為其中一台機器故障就從扣款排程中消失。

Day 21 已經把結算排程搬進 Redis Sorted Set:member 是訂單編號,score 是截止時間。每台機器每秒查一次已到期的訂單,但是在多機器的場景中,不同機器可能在同一時間讀到同一筆訂單。

例如:

ZADD queue 900  "ord-001"   # 09:00 截止
ZADD queue 1100 "ord-002"   # 11:00 截止

# 10:30,機器 A 與機器 B 同時查詢
ZRANGEBYSCORE queue -inf 1030
1) "ord-001"

兩台機器都可能拿到 ord-001,因此同一張訂單可能被同時處理。問題不是「怎麼查到期訂單」,而是怎麼讓其中一台機器取得處理資格。

問題一:多台機器同時拿到同一筆訂單

最直覺的做法,是讓機器先從 queue 移除訂單,只有移除成功的機器才能開始結算:

if (settlementQueue.remove(orderId)) {
    orderService.settleOrder(orderId);
}

Redis 會依序處理命令,同一個 member 只有一台機器能得到 ZREM = 1,因此可以避免兩台機器同時取得同一筆訂單。

但如果訂單被移除後,處理它的機器就故障了呢?訂單會從此 queue 消失,但結算根本沒有完成,認領訂單與完成結算不能是同一件事,需要另外保存「這筆訂單正在被誰處理」。

解決方法:用 processing 保存處理中的訂單

把排程拆成兩個 Sorted Set:

key member score
queue 訂單編號 截止時間
processing 訂單編號 租約到期時間

流程變成:

queue
  │
  │ 認領
  ▼
processing
  │
  ├── 結算成功 → 移除
  │
  └── 租約到期 → 搬回 queue → 重新認領

認領時,把訂單從 queue 搬到 processing,並寫入租約到期時間,結算完成後才從 processing 移除。如果租約到期仍未完成,代表這筆訂單可能卡住,其他機器就可以把它搬回 queue,重新認領。不過,「查詢 → 移除 → 加入 processing」必須是同一個原子操作,因此這一段操作使用 Lua 腳本:

local ids = redis.call(
    'ZRANGEBYSCORE',
    KEYS[1],
    '-inf',
    ARGV[1],
    'LIMIT',
    0,
    ARGV[2]
)

for _, id in ipairs(ids) do
    redis.call('ZREM', KEYS[1], id)
    redis.call('ZADD', KEYS[2], ARGV[3], id)
end

return ids

KEYS[1]、KEYS[2] 分別是 queue 與 processing;ARGV 則是現在時間、最多認領幾筆,以及租約到期時間。Redis 執行 Lua 腳本時不會插入其他命令,因此多台機器同時認領時,不會出現兩台機器拿到同一筆訂單,或訂單已從 queue 移除、卻還沒加入 processing 就發生故障的情況,藉此保證是原子性操作。

舉例:
https://ithelp.ithome.com.tw/upload/images/20261001/20168667smNwNulmrh.png

這樣訂單即使遇到機器故障,也不會因為一次認領就永久消失。

問題二:舊機器的回報晚到,可能誤刪新機器的狀態

租約機制還有一個問題:「租約到期,不一定代表原本的機器沒有完成任務。」

假設 A 執行很慢,租約已經過期,B 因此接手:

時間   機器 A(舊租約)             機器 B(新租約)              processing
────────────────────────────────────────────────────────────────────────────
t1                                認領 ord-001                 {ord-001: B租約}
t2     結算完成
t3     回報完成

如果 A 在 t3 直接執行:

ZREM processing ord-001

就可能把 B 正在處理的訂單一起刪掉,因此完成回報不能只確認訂單編號,還要確認這筆訂單目前仍然屬於自己的租約。

解決方法:回報時比對租約

認領時記住自己的租約時間,完成或重新排程時,先確認 processing 裡的 score 是否仍然相同:

local lease = redis.call('ZSCORE', KEYS[1], ARGV[1])

if not lease or tonumber(lease) ~= tonumber(ARGV[2]) then
    return 0
end

只有租約仍然相同,才允許移除或重新排程。如果訂單已經被其他機器重新認領,score 就會改變,舊機器的晚到回報會直接失效,不會影響新機器。這個規則同樣適用於「結算失敗、準備重新排程」的情況,避免舊機器的操作覆蓋新機器的狀態。

結語:排程至少一次,扣款只成功一次

加入租約後,同一張訂單可能因為機器故障而被重新認領,因此排程本身是至少一次執行。租約負責讓訂單不遺失,扣款端負責讓重試不造成重複扣款。


上一篇
Day 21|截止到期觸發扣款:解決「所有機器看到同一份任務」
下一篇
Day 23|結算失敗後,重試要從哪裡繼續?
系列文
做一個團購後端,順便搞懂那些事 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言