團購訂單有截止時間,時間一到,系統會自動結算,向每位參與者扣款。但是同一張訂單不能被多台機器同時處理,也不能因為其中一台機器故障就從扣款排程中消失。
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 就發生故障的情況,藉此保證是原子性操作。
舉例:
這樣訂單即使遇到機器故障,也不會因為一次認領就永久消失。
租約機制還有一個問題:「租約到期,不一定代表原本的機器沒有完成任務。」
假設 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 就會改變,舊機器的晚到回報會直接失效,不會影響新機器。這個規則同樣適用於「結算失敗、準備重新排程」的情況,避免舊機器的操作覆蓋新機器的狀態。
加入租約後,同一張訂單可能因為機器故障而被重新認領,因此排程本身是至少一次執行。租約負責讓訂單不遺失,扣款端負責讓重試不造成重複扣款。