假設顧客把已付款訂單的收件地址從 A 改成 B,畫面顯示成功。倉庫稍早已經讀取 A,稍後照 A 印出面單。兩個 API 各自完成,顧客收到的承諾卻落空了。
把倉庫改成「印單前再查一次最新地址」,也只是把競爭往後移。查完到列印之間仍能改址,重試時還可能讓同一份派工出現兩個目的地。
這是「系統設計每日實戰」第 13 課,接續報價有效期,練習另一種截止點:本地派工交易一旦提交,就凍結這張訂單的配送資料,停止直接改址。 截止點早於物流商受理,也早於包裹離開倉庫。
修改地址簿,不會替既有訂單改址
沿用單倉、單幣別、最多 20 個 SKU、MySQL 8.4 InnoDB 單一寫入主庫與模組化單體。現在增加單包裹配送,從已付款且仍保有履約資格的 paid 訂單開始。未付款改址、拆包裹、跨區運費重算與已出貨攔截暫不開放。
首版允許的修改只限同一配送區域,運費和成交總價不變。伺服器在進入交易前驗證欄位、長度與區域資格,形成完整的收件人、電話、郵遞區號及地址資料。需要遠端地址驗證時,先完成驗證再取鎖;若驗證結果有期限,則另訂其有效性規則。
這裡有三份用途不同的資料:
- 會員地址簿是下次填表的來源。顧客修改它,不會連動改寫已成立的訂單。
- 訂單地址是這一單目前同意的收件資料。建立訂單時複製完整值,以
address_revision = 1起算,之後每次接受改址加一。 - 派工快照是交給倉庫的固定指令,保存
dispatch_id、order_id、來源地址版本、完整收件資料與品項快照。重送這份指令時,內容保持相同。
只在派工裡放 address_id,執行時再查地址簿,會讓同一個 ID 的意思隨時間改變。地址版本也必須隨完整收件資料一起保存,不能只有版本號而找不回對應內容。
訂單查詢應分別呈現「訂單收件資料」及「已交付派工的收件資料」,並標示是否還能修改。個資只提供給必要的角色,操作紀錄記錄訂單、操作者與版本即可,避免把完整地址和電話複製進一般錯誤 log。
改址與派工,競爭同一張訂單的決定權
產品規則選得保守:paid 可直接改址;派工成功提交後改為 fulfilling,一般改址入口回覆已截止。fulfilling 只表示配送指令已在本地主庫定案,不代表物流商已受理或已寄出。
顧客修改時必須帶上剛才看到的 expected_revision。這能擋住另一種問題:兩個分頁都讀到版本 7,第一頁改成 B,第二頁仍把舊地址 A 存回去。列鎖能讓寫入依序執行,卻不會替第二頁知道自己過期了。
改址用例沿用第 4 課的操作收據,以顧客、操作種類與冪等鍵識別這次修改。摘要包含訂單、預期版本及正規化後的完整收件資料;同鍵不同內容回衝突。新操作取得唯一收據資格後,在同一條連線繼續:
-- 已開始交易,且取得本次改址操作的唯一收據資格。
SELECT id, status, address_revision, shipping_address
FROM orders
WHERE id = :order_id AND customer_id = :authenticated_customer_id
FOR UPDATE;
-- 應用程式確認查有資料、status = 'paid',且版本等於 expected_revision。
UPDATE orders
SET shipping_address = :validated_address_json,
address_revision = address_revision + 1
WHERE id = :order_id
AND status = 'paid'
AND address_revision = :expected_revision;
-- 必須確認恰好更新一列,再保存新版本與成功收據,一起提交。
找不到屬於顧客的訂單就按授權政策拒絕。狀態已截止或版本不符則保存此次操作的拒絕結果,不改地址。版本衝突後,畫面重新讀取目前資料,讓顧客確認要保留哪一份,再以新鍵提出修改。
MySQL 的鎖定讀文件說明,FOR UPDATE 讀取最新可用資料並取得排他鎖,直到交易提交或回滾才釋放。派工和改址都先鎖相同訂單主鍵,便能在鎖內核對狀態並作決定。普通 SELECT 讀完 paid,離開交易後才更新,留不住這項資格。客服後台、批次修正等寫入入口也要遵守相同狀態與版本條件,避免用舊 ORM 物件整列覆寫。
成功回覆要等共同交易提交確認。沿用原鍵恢復已提交的改址時,先重放原收據,不能因為後來已進入 fulfilling 就把原成功改判失敗。回覆裡的「改為 B、版本 8」是那次操作的結果,目前是否可改仍由新查詢呈現。
派工快照和截止狀態必須一起提交
派工用例不接受前端提供的地址,改從鎖定後的訂單取得。它在同一筆本地交易完成四件事:
- 依訂單主鍵取得
FOR UPDATE鎖,確認仍為paid,且收件資料完整。 - 建立唯一的
dispatch_id,保存完整地址、source_address_revision及訂單品項。首版每單一份派工,以UNIQUE(order_id)保護。 - 建立持久交付待辦,指向這份不可變派工快照;每份派工只有一筆這類初始待辦,以唯一鍵約束。
- 把訂單改為
fulfilling,共同提交。
兩個派工程序同時遇到同一訂單時,後取得鎖的程序看到 fulfilling,回查既有 dispatch_id,不再建立第二份。若狀態與快照對不起來,例如 fulfilling 卻沒有派工,停止自動交付並回報資料異常。
起點:paid、地址 A、版本 7。兩個入口都先取得訂單列鎖。
改址先提交
訂單改為 B/8。派工取得鎖後,快照固定為 B/8。列表先前看到的 A 不參與決定。
派工先提交
快照固定為 A/7,訂單改為 fulfilling。後來直接改址遭拒絕,無須等物流商受理。
共同提交範圍
不可變快照(含地址版本)、持久交付待辦及 fulfilling 狀態共同提交。任何一步失敗,整筆回滾。外部呼叫在鎖釋放後才執行。
同一 dispatch_id 重送相同快照。截止後改址需要另外確認撤回或替換。
這裡的原子範圍是本地主庫裡的狀態、快照及待辦。MySQL 的交易文件區分多敘述交易與預設的逐敘述 autocommit;模組各自提交,就無法把這四步綁在一起。Laravel 的交易回呼也必須使用各模組實際共用的那條連線,對應第 3 課的邊界。
提交後,背景程序只從派工快照讀取資料,再把同一 dispatch_id 和相同內容送往倉庫。外部 HTTP 呼叫放在交易之外。AWS 的 Transactional outbox pattern說明,把業務變更和待送紀錄放在同一筆交易,可避免只寫成功其中一邊;交付仍可能重複,接收端需要去重。
因此一筆本地派工不保證只印一張面單。倉庫介接必須按穩定的 dispatch_id 去重並能查詢受理結果。若對方只有不支援去重的建立 API,呼叫逾時後要保留結果待確認,改走查詢或人工核對,不盲目再建。這一課只定義不可變的交付內容,交付重試與工作接管沿用第 9 課的重試預算與第 10 課的接管規則。
哪一邊先提交,結果就沿著那一邊走
地址目前為 A、版本 7。改址先取得鎖並提交 B、版本 8,派工稍後取得鎖,就必須保存 B/8。倉庫稍早在列表看到 A,不影響這次決策;列表只提供候選訂單 ID,不能拿舊地址當指令。
反過來,派工先保存 A/7 並提交 fulfilling,改址取得鎖後就拒絕。顧客即使比派工更早按下儲存,只要尚未取得並提交修改資格,也沒有保住改址權。介面應顯示「進入出貨處理後無法直接改址」,不要承諾到物流商取件前都能改。
若派工保存快照後發生例外,整筆交易回滾,訂單仍為 paid,也沒有可交付待辦。背景程序只處理已提交的待辦,不從交易中的記憶體事件直接發送。MySQL 的錯誤處理文件指出,死結會回滾整筆交易,鎖等待逾時預設通常只回滾失敗敘述;應用在兩種錯誤後統一清理整筆交易,再依原操作恢復規則處理。
COMMIT 回覆遺失則先核對主庫中的派工快照與訂單狀態。無法確認時保留待確認,不另建新 ID,也不把訂單改回 paid 來放行改址。已提交後就算倉庫尚未受理,一般改址入口仍維持截止。
如果產品要在截止後協助改址,客服可先收下申請,狀態只能是「待確認」。撤回或替換指令需要倉庫確認舊指令不會繼續執行,並保存新舊指令的關係。單改本地地址、刪待辦或重設 paid,都不能當成改址或取消已成功。
替代方案是下單即定址,代價由顧客承擔
更小的方案是在下單時就凍結地址,之後一律不接受直接修改。它省去已付款期間的修改競爭與版本衝突,也比較容易向倉庫說明每單只有一份固定資料。代價是顧客剛下單發現填錯,也得進入客服流程,不能假設每張已付款訂單都可無成本取消重下。
選擇派工時凍結,是因為這間商店希望在開始出貨處理前保留自行改址的機會。首版多付出的成本是地址版本、共同鎖定入口、派工快照及交付待辦,暫不增加 Kafka、獨立地址服務或分散式交易。
以每天 10,000 張派工、每份地址快照平均 1 KiB 作教學估算,單算新增的地址快照約 10,000 × 1 KiB ≈ 9.8 MiB/日。這沒有包含品項、索引、操作收據、備份與資料保留期。個資清理要另外按營運需要規劃,不能把「不可變」解讀成永遠不得刪除。
先給本地改址及派工交易各自一個待驗預算,例如 p95 小於 100 ms,計時到 COMMIT 確認,排除外部配送呼叫。量測訂單鎖等待、版本衝突率、截止後改址申請,以及快照提交到倉庫受理的時間。若最後一項變長,應修復交付積壓;不要延長鎖持有時間來等待倉庫。
只有當截止後改址成為明確產品需求,才增加可撤回、已撤回及替換確認的協定。若只是同一訂單偶爾有兩個修改入口競爭,先修正入口與版本條件,沒有理由立刻拆服務。
用兩種提交順序驗收
測試先建一張屬於顧客 C 的 paid 訂單,地址 A、版本 7。使用兩條真實資料庫連線和同步屏障控制取鎖順序,不能只靠 sleep 猜時間:
- 改址先取鎖。暫停在更新 B/8 之後,確認派工仍等待;改址提交再放行派工,快照只能是 B/8。
- 派工先取鎖。保存 A/7 與待辦後暫停,確認改址等待;派工提交後,改址應拒絕,所有資料仍指向 A/7。
- 兩個分頁都帶版本 7。第一個改址提交後,第二個即使帶不同冪等鍵也應得到版本衝突,不能覆寫版本 8。
- 注入「快照已插入、狀態尚未更新」及「狀態已更新、COMMIT 尚未送出」的例外。回滾後不得留下孤立快照、待辦或
fulfilling。 - 提交後丟掉 HTTP 回覆,原鍵重送改址須重放原結果;重跑派工須取得同一
dispatch_id。再修改會員地址簿,已保存的派工內容應完全不變。
另外用另一位顧客存取訂單,確認授權拒絕且不洩露收件資料。倉庫端重複受理與逾時核對屬於獨立介接驗收,與本地交易測試分開記錄。
練習
派工交易已提交 A/7,訂單為 fulfilling,交付待辦仍未送出。客服收到顧客改成 B 的要求,工程師打算直接改訂單地址,再讓 worker 執行時查最新值,理由是「倉庫還沒收到」。這符合目前承諾嗎?如果要支援這項需求,至少缺少哪個確認?
參考思路
不符合。截止點是本地派工提交,A/7 已成為固定指令;一般 worker 重送時也必須使用相同快照。查最新值會讓同一 dispatch_id 改變內容,破壞重試與對帳。
要支援截止後改址,得先建立撤回或替換流程,確認舊指令不會再被執行。單看待辦欄位「未送出」不足以排除 worker 已取走工作、外部已受理但回覆遺失等情況。完成這個確認前,只能回覆申請待處理,不能宣稱已改址成功。
把你現在的改址入口和出貨入口各圈出一行:哪裡取得決定權,哪裡保存要交出去的完整地址?用前兩個測試交換提交順序,檢查它們是否真正在同一個截止點相遇。
一手參考資料
查核日期:2026-10-23。地址可修改狀態、單包裹、截止點、版本欄位與預算均為教學案例的設計選擇。
- Oracle MySQL 8.4 §17.7.2.4:Locking Reads:鎖定讀、最新可用資料與鎖的生命週期。
- Oracle MySQL 8.4 §17.7.2.2:autocommit, Commit, and Rollback:多敘述交易與提交/回滾。
- Oracle MySQL 8.4 §17.20.5:InnoDB Error Handling:死結及鎖等待逾時的回滾範圍。
- AWS Prescriptive Guidance:Transactional outbox pattern:業務變更與待送紀錄同交易,交付重複及接收端去重。
