十年 Legacy 專案導入 Git 工作流(三):SQL Migration 版本化


系列文章:這是「十年 Legacy 專案導入 Git 工作流與 CI/CD」四篇的第三篇,也是整份規劃的關鍵路徑。 (一)診斷與分支模型 · (二)CI 第一階段:零誤報 · (三)SQL Migration 版本化 ← 本篇 · (四)部署、回滾與 E2E

盤點專案現況時,有一個數字讓其他所有問題都顯得次要:

近 90 天內,commit 訊息標註「需執行 SQL」的次數:158 次。

平均每 1.7 天就有一次,得有人手動連進 N 個租戶資料庫執行腳本。

在單一資料庫的專案裡,這頂多是麻煩。在一個機構一個資料庫的多租戶架構下,這是頭號風險——因為漏掉一家不會有任何錯誤訊息。那家客戶會在某個功能上看到錯誤或空白,然後由他們先發現

同一次盤點裡還有這些數字:

指標數字
CI 設定檔0
遠端分支(合併完沒人刪)709 個
近 90 天「需執行 SQL」的 commit158 次
sql/ 目錄的 .sql348 支(另有子目錄 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

通用教訓:兩條——

  1. 2>&1 的字元順序不能錯,而且寫錯不會有語法錯誤,只會安靜地做別的事。
  2. 任何 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 完整演練一次,逐項確認:

  1. 回填完成 → 每個資料庫約 348 筆 baseline
  2. 跑一支新的 migration → 成功
  3. 再跑同一支 → migratelog 擋下、不重複執行、回傳 0
  4. SELECT * FROM migratelog ORDER BY id DESC LIMIT 5 → 狀態正確
  5. CI 對一支故意寫成不冪等的 SQL → 紅燈

第 5 項是最容易被跳過、也最重要的一項:你必須驗證你的檢查會失敗。 一個從沒紅過的 CI 檢查,和沒有檢查沒有區別。


回頭看:這件事的通用形狀

這篇的細節是 shell 腳本與 MySQL,但骨架適用於任何「Legacy 專案要導入資料庫變更管理」的情境:

  1. 先確認風險排序。 分支策略好談、大家有意見、開會很熱鬧;migration 沒人想碰,但它才是會讓客戶先發現問題的那個。別把力氣花在討論度最高的事情上。
  2. 既有工具通常不是「沒有」,是「壞掉但沒人知道」。 讀過再決定要不要重寫。那支腳本的骨架完全正確,七個 bug 全是實作層級的。
  3. 歷史狀態的回填是真正的成本。 換任何工具都躲不掉。回填時一個字都不要改現有檔名。
  4. 驗收要收斂成可執行的斷言。 「跑兩次,第二次要被擋下」勝過一份十頁的檢查清單。
  5. 讓正確的做法成為預設。 安全選項不是預設就等於不存在;冪等不進 CI 就等於沒要求。

最後一點也是我覺得最重要的:這七個 bug 沒有一個是「不知道正確寫法」造成的。寫腳本的人知道要防重跑、知道要記 hash、知道要有狀態表。問題出在沒有一個機制去驗證這些設計真的生效

程式碼會說謊——它印出「already applied」然後照跑不誤。只有可執行的驗收會說實話。


相關文章: