系列文章:這是「十年 Legacy 專案導入 Git 工作流與 CI/CD」四篇的第三篇,也是整份規劃的關鍵路徑。 (一)診斷與分支模型 · (二)CI 第一階段:零誤報 · (三)SQL Migration 版本化 ← 本篇 · (四)部署、回滾與 E2E
盤點專案現況時,有一個數字讓其他所有問題都顯得次要:
近 90 天內,commit 訊息標註「需執行 SQL」的次數:158 次。
平均每 1.7 天就有一次,得有人手動連進 N 個租戶資料庫執行腳本。
在單一資料庫的專案裡,這頂多是麻煩。在一個機構一個資料庫的多租戶架構下,這是頭號風險——因為漏掉一家不會有任何錯誤訊息。那家客戶會在某個功能上看到錯誤或空白,然後由他們先發現。
同一次盤點裡還有這些數字:
| 指標 | 數字 |
|---|---|
| CI 設定檔 | 0 個 |
| 遠端分支(合併完沒人刪) | 709 個 |
| 近 90 天「需執行 SQL」的 commit | 158 次 |
sql/ 目錄的 .sql 檔 | 348 支(另有子目錄 47 支,命名三套) |
| 一個 release 分支的壽命 | 2–7 天,等於沒有凍結窗口 |
結論寫在規劃文件裡是這樣一句話:
SQL migration 的風險大於分支策略的風險。如果只能做一件事,做這件。
這篇是那件事的完整內容。核心不是「介紹一套 migration 工具」——那些工具你都知道,問題是十年 Legacy 專案裝不上去。這篇講的是怎麼把一支已經存在、但半殘的自製腳本,修成可以信任的東西。
好消息:起點比想像中好
打開 bin/runSQL.sh 之後發現,該有的骨架其實都有:
- 已經有
migratelog狀態表,欄位是sqlfile/hash/status/created_at - 資料庫缺表時會自動建
- 執行前會先查、執行完會標
applied
換句話說,寫這支腳本的人知道 migration 該怎麼設計。所以工作內容是「修 bug + 補流程」,不是從零打造。
這個判斷很重要。面對 Legacy 的自製工具,第一直覺常常是「砍掉重練,換成業界標準」。但真正的成本從來不在工具本身,而在**「348 支既有 SQL 檔的歷史狀態怎麼辦」**——這個問題無論你用哪套工具都要面對,而現成工具通常對它沒有更好的答案。
然後我們逐行讀了那支腳本,找到七個問題。
七個 bug,七個通用教訓
① 已套用的檔還是會重跑(最嚴重)
防重跑是整支腳本存在的意義。而它從來沒有生效過。
# 現況(簡化)
installed() {
# ...查 migratelog...
if [ 已套用 ]; then
echo "2"
echo "SQL script already applied to $db" # ← 兇手
fi
}
status=$(installed "$db")
if [[ "$status" == "2" ]]; then # 永遠不成立
return
fi
$(...) 會捕捉函式的所有標準輸出。這裡 echo 了兩次,所以 $status 拿到的是:
2
SQL script already applied to demo-org-1
這個字串永遠不等於 "2",防重跑形同虛設。
修法:訊息輸出到 stderr,函式只回傳狀態值。
echo "SQL script already applied to $db" >&2
通用教訓:在「用 $() 捕捉輸出當回傳值」的函式裡,任何一行 echo 都是在污染回傳值。 進度訊息一律走 stderr,或改用真正的 exit code。
這個 bug 的可怕之處在於它看起來完全正常——執行時你會看到「SQL script already applied」這行訊息印出來,於是你相信它擋住了。訊息印出來了,但擋沒擋住是兩回事。
② baseline 標記沒生效
# 現況
migratelog() {
local sqlfn=$1 hash=$2 status=$3 # 收了第三個參數
# ...
INSERT INTO migratelog (sqlfile, hash, status)
VALUES ('$sqlfn','$HASH','init') # ← 但寫死了 'init'
}
參數收了卻沒用,而且呼叫端還把檔名傳成字串 "init"——兩個錯誤剛好互相掩護,看起來「有 init 這個狀態」,實際上狀態機制根本沒運作。
修法:VALUES ('$sqlfn','$HASH','$status'),並修正呼叫端的參數順序。
通用教訓:參數收了沒用,是「這段程式從沒被真正驗證過」的最強訊號。 review 時看到函式簽章有參數在函式體內找不到,直接當紅旗。
③ hash 認不出檔案被改過
# 現況
HASH=$(echo "$sqlfn$(date +%Y%m%d)" | md5sum)
雜湊的輸入是檔名 + 當天日期。這造成兩個相反方向的錯誤:
- 同一支檔隔天就能再跑一次(日期變了,hash 就變了)
- 檔案內容被人改過反而看不出來(內容根本不在雜湊輸入裡)
這是把 hash 的用途搞反了。migration 的 hash 只有一個目的:偵測「這支檔在被套用之後又被人改過」。
修法:
- 改成內容雜湊
sha256sum - 唯一鍵改成
UNIQUE(sqlfile),另存content_hash欄 - 內容不一致時報「已套用過但內容不同」,交人工判斷
最後這點值得強調:偵測到內容變更時,正確的行為是停下來問人,不是自動重跑,也不是默默略過。已套用的 migration 被修改,代表有人做了危險的事,需要人判斷該補一支新的還是別的處理。
通用教訓:雜湊的輸入決定它能偵測什麼。 寫下 hash 計算式之前,先把「我要偵測什麼變化」講清楚。
④ 失敗不會中止
# 現況
$mysqlcmd -D $db < $SQLSCRIPT &2>1 >> $logfile
這串重導向是壞的。&2>1 不是 2>&1——它會被 shell 解讀成完全不同的東西(背景執行 + 重導向到名為 1 的檔案)。
而且外層還接了 tee,所以 $? 拿到的是 pipeline 最後一個指令的結果,根本不是 mysql 的結果。
於是:SQL 執行失敗,腳本回傳成功。
修法:
set -o pipefail # 腳本開頭
if ! $mysqlcmd -D "$db" < "$SQLSCRIPT" >> "$logfile" 2>&1; then
exit 1
fi
通用教訓:兩條——
2>&1的字元順序不能錯,而且寫錯不會有語法錯誤,只會安靜地做別的事。- 任何 shell 腳本只要有 pipe,就需要
set -o pipefail。沒有它,cmd | tee log的退出碼永遠是tee的(幾乎總是 0)。這大概是 shell 腳本最常見的靜默失敗來源。
⑤ log 每次被清空
date +%Y%m%d > $logfile # 應該是 >>
一個字元的差別,上一次的執行紀錄就沒了。
通用教訓:在稽核用途的檔案上,> 幾乎永遠是錯的。 而且這種錯誤只有在「出事後想回查」的那一刻才會被發現——也就是最糟的時刻。
⑥ 狀態表用 MyISAM
migration 的狀態表沒有交易保護,意味著「SQL 執行了但狀態沒記到」或反過來的情況無法避免。
修法:ENGINE=InnoDB。
順帶發現一個小的:init() 產生的暫存檔是 migratelog.sql,但清理時 rm -f log.sql——名字對不上,暫存檔會一直留在目錄裡累積。
通用教訓:產生檔案與刪除檔案的名字寫在不同地方,就一定會有對不上的一天。 用變數存檔名。
⑦ 密碼出現在 process list
mysql -u"$DBUSER" -p"$DBPASS" ...
-p 後面直接接密碼,任何人在那台機器上 ps aux 都看得到。
好消息是腳本已經有 -c 選項(走 ~/.my.cnf),只是不是預設。
修法:把 -c 改成預設行為。
通用教訓:安全的選項如果不是預設,等於不存在。 提供正確做法還不夠,要讓它成為阻力最小的路徑。
修完之後唯一重要的驗收點
在本機 Docker 建兩個空庫,跑同一支 SQL 兩次
——第二次必須被 migratelog 擋下,且回傳 0。
就這一條。上面七個 bug 修得再漂亮,如果這個測試不過,整件事就沒有意義。
這是我很喜歡的一種驗收設計:把一堆修正歸結成單一個可執行的行為斷言。 它不測「程式碼有沒有照我想的改」,它測「這個工具的核心承諾成不成立」。
光修腳本還不夠:三件流程上的事
一、manifest:檔名不含順序資訊
現有檔名是 issue-<id>.sql。單號不代表執行順序——單號 500 的 SQL 可能必須在單號 300 之前跑(比方前者建表、後者改欄位)。
(目錄裡還有 11 支舊制的 0001_描述.sql,那批有順序資訊,但只剩這 11 支。)
同一批 SQL 有依賴關係時,靠人記順序遲早出事。做法是每期一個清單檔:
# sql/releases/20260916.txt
issue-001.sql
issue-002.sql
issue-003.sql
部署時照行序跑,任何一支失敗就中止:
while read -r f; do
[ -z "$f" ] && continue
bin/runSQL.sh -a -c -f "sql/$f" || exit 1
done < "sql/releases/${TAG}.txt"
manifest 由「開 release → main PR」的人彙整,來源就是該期各個 PR 的「需執行 SQL」欄位。
順便讓 CI 幫忙看著:PR 新增了 sql/*.sql 卻沒出現在任何 manifest → 警告。
這條 CI 規則值得單獨說。它防的不是「順序寫錯」,而是**「有人加了 SQL 檔但忘記告訴部署流程」**——這是實務上最常發生的那種漏。
二、回填 348 支的既有狀態
這步不做,第一次跑新腳本就會試圖重跑三年份的 SQL。
作法是把「本次上線日之前的所有 SQL 檔」在每個租戶資料庫標成 baseline:
# 對每個租戶 DB 回填
for db in $(mysql --defaults-file=~/.my.cnf -N -e \
"show databases like 'demo-org-%'"); do
for f in sql/*.sql; do
n=$(basename "$f")
h=$(sha256sum "$f" | awk '{print $1}')
mysql --defaults-file=~/.my.cnf -D "$db" -e \
"INSERT IGNORE INTO migratelog (sqlfile, hash, status)
VALUES ('$n','$h','baseline')"
done
done
回填後逐庫驗證,每個資料庫都應該看到約 348 筆 baseline:
SELECT status, COUNT(*) FROM migratelog GROUP BY status;
順序不能錯:先確認每個資料庫都有 migratelog 表 → 全套在 QA 演練一次 → 最後才對正式機跑。
還有一個反直覺但重要的注意事項:
目錄裡有 11 支舊制
NNNN_描述.sql、十餘支issue-feature-a5-v2.sql這類客戶專案檔、還有一支拼錯的issud-feature_a10_a11.sql——回填時一律照現有檔名收進去,不要順手改名。
因為狀態表的鍵就是檔名。改名會讓已回填的狀態對不上,那支檔就會被當成新的再跑一次。
想整理命名?可以,但要在回填之後、而且連同狀態表一起改。整理與回填不要混在同一次動作裡。
三、部署順序:先 migration,後程式碼
migration(全租戶)→ 驗證 → 才換程式碼
順序反了會出現「新程式找不到舊資料庫的欄位」的白屏。
這個順序也決定了 migration 必須向後相容:新加的欄位不能是 NOT NULL 沒有預設值,因為在換程式碼之前,舊程式還在對這張表寫入。
CI 的 SQL dry-run:小心假綠燈
在 CI 裡開一個 MySQL 容器試跑新的 migration,聽起來很直覺。但空的 MySQL 容器跑 ALTER TABLE 一定失敗——表根本不存在。
如果這時候有人為了讓 CI 通過而加了 || true,你就得到一個永遠綠燈的假檢查,比沒有檢查更糟。
正確做法是先餵一份只有結構的基準:
# 每季更新一次:只匯出結構,沒有任何資料
mysqldump --defaults-file=~/.my.cnf --no-data --skip-add-drop-table \
--routines --events 'demo-org-2' > sql/_schema/baseline.sql
然後 CI job:
sql-dryrun:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: ci
MYSQL_DATABASE: tenant
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -pci"
--health-interval=5s --health-timeout=5s --health-retries=20
steps:
- uses: actions/checkout@v4
- name: 套 baseline 後試跑新的 migration
run: |
new=$(grep -E '^sql/[^/]+\.sql$' changed.txt || true)
[ -z "$new" ] && exit 0
mysql -h mysql -uroot -pci tenant < sql/_schema/baseline.sql
for f in $new; do
echo "== $f"
mysql -h mysql -uroot -pci tenant < "$f"
mysql -h mysql -uroot -pci tenant < "$f" # 跑第二次驗冪等
done
注意最後那行:刻意跑兩次。
第二次失敗就代表這支腳本不能重跑。而正式機一定會有「跑到一半失敗、修完再跑一次」的情況——不冪等就是災難。
這個設計的巧妙之處在於,它把「冪等」從一個口頭要求變成一個自動執行的驗收。你可以在文件裡寫十遍「SQL 請寫成可重複執行」,效果都不如 CI 跑第二次。
實務上這代表 SQL 要這樣寫:
-- 不冪等
ALTER TABLE billing ADD COLUMN discount_rate DECIMAL(5,2);
-- 冪等(MySQL 5.7 沒有 ADD COLUMN IF NOT EXISTS,要自己查)
SET @exist := (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'billing'
AND column_name = 'discount_rate');
SET @sql := IF(@exist = 0,
'ALTER TABLE billing ADD COLUMN discount_rate DECIMAL(5,2)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
醜,但它讓「跑到一半失敗」變成可以直接重跑的狀況,而不是需要人工判斷「跑到哪了」的事故。
完整驗收清單
在 QA 完整演練一次,逐項確認:
- 回填完成 → 每個資料庫約 348 筆
baseline - 跑一支新的 migration → 成功
- 再跑同一支 → 被
migratelog擋下、不重複執行、回傳 0 SELECT * FROM migratelog ORDER BY id DESC LIMIT 5→ 狀態正確- CI 對一支故意寫成不冪等的 SQL → 紅燈
第 5 項是最容易被跳過、也最重要的一項:你必須驗證你的檢查會失敗。 一個從沒紅過的 CI 檢查,和沒有檢查沒有區別。
回頭看:這件事的通用形狀
這篇的細節是 shell 腳本與 MySQL,但骨架適用於任何「Legacy 專案要導入資料庫變更管理」的情境:
- 先確認風險排序。 分支策略好談、大家有意見、開會很熱鬧;migration 沒人想碰,但它才是會讓客戶先發現問題的那個。別把力氣花在討論度最高的事情上。
- 既有工具通常不是「沒有」,是「壞掉但沒人知道」。 讀過再決定要不要重寫。那支腳本的骨架完全正確,七個 bug 全是實作層級的。
- 歷史狀態的回填是真正的成本。 換任何工具都躲不掉。回填時一個字都不要改現有檔名。
- 驗收要收斂成可執行的斷言。 「跑兩次,第二次要被擋下」勝過一份十頁的檢查清單。
- 讓正確的做法成為預設。 安全選項不是預設就等於不存在;冪等不進 CI 就等於沒要求。
最後一點也是我覺得最重要的:這七個 bug 沒有一個是「不知道正確寫法」造成的。寫腳本的人知道要防重跑、知道要記 hash、知道要有狀態表。問題出在沒有一個機制去驗證這些設計真的生效。
程式碼會說謊——它印出「already applied」然後照跑不誤。只有可執行的驗收會說實話。
相關文章: