假設商店要在結帳頁新增「希望上午、下午,或不限時段」三個選項。PM 說新訂單必填,工程師於是打算新增 delivery_window NOT NULL DEFAULT 'any',讓舊程式和歷史資料都能繼續使用。
部署可能順利,資料的意思卻變了。昨天買東西的顧客根本沒看過這個問題,今天查訂單時,畫面卻寫著「您選擇不限時段」。如果客服用這份紀錄回覆客訴,系統就把沒有問過的問題,當成顧客已經答過。
這是「系統設計每日實戰」第 16 課。這次採用的做法是歷史資料保留「尚未詢問」,只對新結帳契約要求有效選項;先部署能保留新值的整條履約路徑,再開放顧客選擇。 不為了讓整張表符合 NOT NULL,替舊訂單補一個沒有依據的答案。
先定義空值,不急著寫 migration
這個功能只收集配送偏好。假設合作物流確實支援相應欄位,但沒有保證在指定時段送達。結帳文案、訂單明細和客服後台都要保留這個限制。若實際供應商不支援,應先停止開放選項,不能先收再默默丟掉。
資料分成兩件事:
delivery_window:morning、afternoon、any分別表示顧客明確選擇的偏好;NULL表示沒有取得選擇。checkout_version:訂單成立時使用的伺服器結帳契約。舊流程為1,要求配送選項的新流程為2。版本由伺服器依入口與功能開關決定,不能照抄客戶端任意傳來的值。
版本 1 的歷史訂單若保留 NULL,明細顯示「這筆訂單成立時尚未提供時段選擇」。版本 2 則必須保存三個合法值之一。顧客選 any 時,明細才顯示「不限時段」。若版本 1 有可信來源可補回選擇,明細依實際值與來源呈現,checkout_version 仍保留 1,不改寫成立時的契約。所有權檢查仍沿用上一課,不能為了顯示新欄位另開未授權的查詢。
這裡的 checkout_version 是持久化的業務契約標記,和部署 commit、資料庫 migration 編號不同。程式更新十次,也不應把舊訂單全部改成版本 2。
資料庫先容納兩種契約
第一步只擴充儲存,不開放新介面。先在與正式表結構相符的環境驗證這份候選 DDL:
ALTER TABLE orders
ADD COLUMN delivery_window VARCHAR(16) NULL DEFAULT NULL,
ADD COLUMN checkout_version SMALLINT NOT NULL DEFAULT 1,
ALGORITHM=INSTANT;
兩個預設值有不同的理由。NULL 保存「沒取得答案」;1 表示未明確採用新契約的寫入仍屬舊流程。這個預設不代表可以永遠放任舊寫入入口存在。
MySQL 8.4 的預設值文件說明欄位省略時使用預設值的行為。這只是資料庫填值規則,填進去的值是否忠於業務事實,要由設計者決定。
明寫 ALGORITHM=INSTANT 是為了在不支援時失敗,讓團隊重新評估,而不是默默接受更昂貴的表操作。實際表能否使用 instant 仍受列格式、索引、欄位和 row version 等限制,應依 Online DDL operations逐項查核。這個範例不加 LOCK=NONE;ALTER TABLE 文件對 instant 操作只允許預設的 lock 選項。
即使操作可以 instant,也可能等待 metadata lock。MySQL 的併發文件列出長交易讓 DDL 等待、排隊中的排他鎖再影響後續交易的情況。因此先查長交易、設定經演練確認的等待上限,遇到鎖等待超標就停止本次 migration,重新安排時段。不能把「不搬整張表」寫成零停機保證。
舊版 SQL 若使用明列欄位的 INSERT,可以省略這兩個新欄位。若用 INSERT INTO orders VALUES (...) 依整張表的欄位順序填值,新增欄位就可能破壞它;ORM、匯入腳本與報表讀取也都要測。相容性需要實際跑過,不能從「只是加欄位」推定。
開關先關著,把值送到出貨指令
只更新結帳 controller 不夠。出貨快照那一課已經把地址凍結進不可變派工指令。這次新增的偏好,也要在相同的派工交易中一起複製,並由物流 adapter 明確對應供應商欄位。對版本 1 的未知偏好,保留舊版派工契約,不合成一個顧客選過 any 的紀錄。
部署可分成三段。第一段加欄位;第二段部署相容版,讓所有讀取端辨識 NULL,讓下單、派工與 worker 都能保留新值,但功能開關仍關閉;第三段才切換結帳契約並開放選項。
歷史 NULL 是尚未詢問;any 是明確選擇不限時段。
1 · 擴充欄位
開關關閉。驗證舊 SQL、讀取和匯入仍可使用。沒有歷史來源就保留未知。
2 · 部署相容版
開關仍關閉。訂單、快照和物流 adapter 都保存偏好。排空或隔離舊 worker,指定相容回滾版。
3 · 開放新契約
新的購買要求合法選項;舊操作重放原收據。關閉新入口後,已受理偏好仍須完成履約。
「所有寫入端」包含 HTTP instance、排程、延遲工作、匯入程式,以及可能被拿來回滾的舊映像。假設 HTTP 全換完,佇列裡仍有舊 worker,它可能從新訂單產生一份沒有配送偏好的指令。顧客看到的訂單是上午,送到物流的要求卻沒有這個欄位。
首版先採單一開關,不做複雜灰度分流。相容版全部就緒、測試訂單的偏好已到達供應商測試環境,才打開功能。舊客戶端在切換後若送來一筆全新的下單請求而沒有選項,回覆可辨識的驗證錯誤,要求重新確認結帳;不要替它選 any。伺服器切換後不再接受新增版本 1 訂單,例外匯入若確實需要,另訂明確入口與來源紀錄。
有一種請求必須先辨識:原本已成功成立的舊訂單重試。冪等收據仍然回傳原結果,不因今天多了必填欄位,就重建一張版本 2 訂單。收據查找、授權與原操作內容比對需要保留;只有確定是新的購買操作,才套用目前的必填規則。不能利用補欄位把同一冪等鍵改成另一個操作。
回填只能搬移已知資料
如果歷史系統從未收集配送偏好,這次就沒有可以回填的答案。保留 NULL 是完成的遷移結果,不是等待日後「清乾淨」的髒資料。
另一種情況是舊系統曾把顧客選擇存於可追溯的結帳紀錄,而且能證明紀錄和這張訂單一一對應。這時可以寫映射程式,把已知值搬到新欄位,保留來源識別。不能從地址、過往收件時間或客服猜測推算偏好。
假設有 100 萬筆可確認來源的紀錄,每批 500 筆,至少要處理 1,000,000 ÷ 500 = 2,000 批。假設演練得到每批交易平均 100 ms,批次之間休息 400 ms,單純相加約 2,000 × 0.5 = 1,000 秒,約 16.7 分鐘。這是以假設耗時推算的排程預算,還沒包含查來源、重試、稽核與停止等待,也不是正式完成時間承諾。
按穩定主鍵範圍分批,保存已完成範圍和來源版本。每筆更新附帶 delivery_window IS NULL 及預期契約條件,避免覆蓋期間顧客已確認的新值;衝突另外核對。每批獨立提交,失敗可以重跑。若訂單已經派工,不改寫先前已送出的不可變快照;新補證只作有來源的歷史補充,不能讓物流端與本地紀錄悄悄分歧。
暫定演練停止條件為下單 p95 超過 300 ms 連續兩分鐘,或鎖等待超過團隊核定上限。觸發時暫停回填,保留進度,確認負載恢復再決定是否降低批量。這些門檻要先和原本 SLO、基線流量核對,不能把教學數字直接搬到正式環境。
預設值和一次停機,都有適用的時候
如果產品規則原本就明定「沒有填配送備註等同沒有備註」,delivery_note NOT NULL DEFAULT '' 可能比版本區分簡單,舊 writer 省略欄位也能相容。不能宣稱所有新增 NOT NULL 欄位都會讓舊版壞掉。這次拒絕 DEFAULT 'any',原因是它會改寫「顧客是否作過選擇」的事實。
小型內部系統也可以安排維護時段:停止受理新訂單,等所有在途請求與工作結束,修改 schema、更新全部程式,完成驗收再恢復。這省去新舊版同時執行的複雜度,代價是明確的服務中斷,而且歷史未知值仍然未知。停機不能補回從未收集過的資料。
本例選擇分階段上線,是為了在持續下單期間換版。它增加了相容分支、契約版本與開關管理成本。不要再同時加上微服務、事件串流或影子資料庫,讓一次小型欄位擴充變成整套架構重建。
回滾不能把已接受的選擇丟掉
功能開放前,可以回退到舊版應用,留下尚未使用的新增欄位;但仍要確認沒有任何新語意資料已產生。功能開放後,關閉新入口只能停止新的選擇,已成立的版本 2 訂單仍須依原偏好履約。
因此預先指定一個「能讀取並傳遞新欄位」的相容版作為回滾目標。若手上只有完全不認識新欄位的舊版,應暫停受影響的派工、修復或部署相容版,不能為了讓 worker 跑起來就忽略偏好。資料庫新增欄位通常可以先保留,不急著和應用一起反向刪除。
本例不把全表 NOT NULL 當終點。新訂單應在伺服器用例中驗證「版本 2 必須是三個合法值之一」,並持續查核違規資料。如果寫入端逐漸增加,再評估條件式資料庫約束,明確保留版本 1 的未知值;不要只加一個無法區分版本的限制。MySQL 的 NULL 處理說明也區分空值和空字串,查核時應使用 IS NULL,不是 = NULL。
驗收要把新舊程式放在一起跑
在具代表性的測試資料與真實資料存取層上,至少完成以下案例,再開功能:
- migration 完成後跑舊版下單,新訂單仍能成立為版本 1,偏好為
NULL。測原本的 SQL 寫法與匯入入口,不只測新程式。 - 相容版讀取舊訂單,顯示尚未詢問;讀取明確
any的訂單,顯示不限時段。兩者不能被 DTO 或前端 fallback 合併。 - 開關開啟後,新請求缺少或帶非法選項時不得成立訂單;合法請求的選擇要和訂單同一交易保存。所有新寫入均為版本 2。
- 使用同一冪等鍵重送先前成功的版本 1 請求,拿到原訂單,不新增資料、不改版本。再用相同鍵但不同操作內容,應拒絕衝突。
- 讓版本 2 訂單完成派工,逐段核對訂單、不可變快照和物流測試端收到的值,並測關閉功能後既有訂單仍能完成。
- 暫停一筆舊 worker 工作,再換版與開功能。舊程序不得恢復處理新契約;部署流程要能證明它已排空或被隔離。
- 有可信來源才測回填。讓回填與顧客確認競爭,確認條件更新不覆寫新值;停止後重跑,不重複修改已完成資料。
最後演練回滾到相容版,再核對版本 2 訂單是否仍完整。只驗證 migration 成功,尚未驗證顧客的選擇能被履行。下一次新增必填欄位前,先在變更單寫下:歷史空值代表什麼、何時開始要求新值,以及哪個版本可以安全回滾。
練習
新功能已開放,最近一小時的新訂單都有合法配送偏好。工程師想把全部歷史 NULL 更新成 any,改成 NOT NULL,再刪除舊版讀取分支。他說這樣可以讓程式簡單很多。你會先問哪兩個問題?如果沒有歷史選擇紀錄,應保留哪一種資料表示?
參考思路
先問 any 是否能代表從未被問過的顧客,以及是否還有延遲工作、匯入或回滾版本會寫入舊契約。一小時的新訂單都合格,只能說這段觀察窗內的結果,不能證明所有舊路徑已消失。
沒有歷史證據時,保留版本 1 與 NULL,新契約則強制合法偏好。要刪除的是已不再使用的寫入路徑,不是資料裡真實存在的未知。若另有可證明的來源,就只搬移那些紀錄,並保留映射與回填驗證。
