遷移資料庫最可怕的不是 crash,是默默算錯 — 行為等價驗證法
跨資料庫遷移的錯誤多半不會 crash,只會默默算錯數字。這篇給一套可放進 CI 的行為等價(parity)驗證三階段:讀路徑 golden snapshot、寫路徑場景驗證、三級 diff 分類,外加 PostgreSQL → MySQL 方言對照表與三個致命隱形陷阱。
💡 本文原刊於 qa.9niche.com,2026-08 併入 9niche.com 懶人包,內容照原文完整搬遷。
目錄
1. 前言
資料庫遷移出事,你會期待它大聲地壞——查詢報錯、pod 起不來、CI 紅一片。那種其實好處理,因為你看得到。
真正可怕的是默默算錯:查詢照樣跑、頁面照樣開、沒有一行紅字,只是回來的數字悄悄錯了。字串串接變成布林、大小寫比對突然不分、upsert 靜默插了重複——這些在 PostgreSQL 和 MySQL 之間全是行為差異,而且都不會 crash。
所以「遷移完測一測、沒壞就上」是危險的。你需要的是行為等價(parity)驗證:證明「新舊 DB 對同一批操作的行為一致」,而不是「跑起來沒噴錯」。
2. 核心:不是測「有沒有壞」,是測「行為一不一致」
做成一條 CI 可跑的 pass/fail,而不是人肉盯畫面。分三階段。
3. 階段一:讀路徑 golden snapshot
證明「同一個查詢在新舊 DB 回一樣的結果」。
- 挑約 40 支代表性且高風險的查詢——專挑方言差異大的:JSON 抽值、
LIKE文字比對、DISTINCT ON、回Decimal的聚合。不是隨便挑,是挑最容易在方言差異上翻車的。 - 對舊 DB 跑一輪,結果序列化成 JSON fixtures。
- 切一個環境變數把輸出目錄換掉,對新 DB 再跑同一批。
- 順手 pin 每張表的 row count 當 bulk sanity check——整批數量對不上,先別談細節。
精髓:golden snapshot 挑的是「高風險 query」不是「常用 query」。常用的通常簡單、不會出事;出事的都是那些用到方言特性的角落查詢。
4. 階段二:寫路徑場景驗證
讀路徑對了,不代表寫路徑對。設計 5 個寫入場景:upsert 冪等性、JSON round-trip(unicode / 巢狀)、CRUD + 稽核表、欄位鎖。
兩個關鍵原則:
- 斷言值寫在程式碼裡,不是從 DB 反推。 同一份斷言在新舊 DB 都跑,不一致 = 遷移 bug。如果你從 DB 讀值再拿去比 DB,那是自我循環,測不到東西。
- 頭號場景是冪等性。 專抓「upsert 的 conflict target 沒對到真的 unique key → 默默雙插」——這是遷移最陰的靜默 bug 之一。
用 TEST-* 前綴的合成資料,走 SETUP → ACT → ASSERT → CLEANUP 自清,不污染真實資料。
5. 階段三:三級 diff 分類(精髓所在)
直接比對兩份 fixtures 會被合法的非決定性淹沒——時間戳、排序、浮點誤差每次都不同,但那些不算行為改變。所以先正規化:
- 排序所有 list——消掉
GROUP_CONCAT/ 聚合的順序噪音。 - 截斷浮點——吸收
NOW()之類的時間漂移。 - 數字字串統一——
"1"與1別當成不同。
正規化之後,每個 fixture 分三級:
| 級別 | 意思 | 動作 |
|---|---|---|
| IDENTICAL | 完全一樣 | 通過 |
| TRIVIAL | 只差合法的非決定性 | 通過(但記錄) |
| SIGNIFICANT | 真的行為不同 | 失敗,exit 非零 |
只有 SIGNIFICANT / MISSING 才讓 CI 紅。
這一步的精髓:你是在「論證為什麼一個 diff 不算行為改變」,不是無腦比對。每一條被判 TRIVIAL 的差異,背後都有一個「因為它是排序噪音 / 時間漂移,所以不算 bug」的理由。這才是 parity 驗證跟「diff 兩個檔案」的本質差別。
6. 附錄:PostgreSQL → MySQL 方言對照表
遷移時逐一踩過的坑。這些錯多半不會 crash、只會默默算錯:
| 情境 | PostgreSQL | MySQL 8.0 | 備註 | ||||
|---|---|---|---|---|---|---|---|
| 字串串接 | `a \ | \ | b` | CONCAT(a, b) | ⚠️ MySQL 的 `\ | \ | ` 是邏輯 OR 不是串接——最陰的一條 |
| 取每組最新一筆 | DISTINCT ON | ROW_NUMBER() OVER (PARTITION BY…) 子查詢 | 大表用 INNER JOIN (MAX… GROUP BY) 更快 | ||||
| 字串聚合 | STRING_AGG | GROUP_CONCAT | |||||
| epoch 秒 | EXTRACT(EPOCH FROM ts) | UNIX_TIMESTAMP(ts) | |||||
| JSON 抽值 | col->'a'->>'b' | col->>'$.a.b' | MySQL 要 $. 前綴 | ||||
| 型別轉整數 | CAST(x AS BIGINT) | CAST(x AS SIGNED) | MySQL 沒有 BIGINT 這個 cast 目標 | ||||
| Upsert | ON CONFLICT … DO UPDATE | INSERT … AS new ON DUPLICATE KEY UPDATE col=new.col | 別用已 deprecated 的 VALUES(col) | ||||
| 大小寫 | 預設 case-sensitive | utf8mb4_0900_ai_ci 預設大小寫 + 重音都不分 | 嚴格比對用 LIKE BINARY |
三個致命隱形陷阱
ON DUPLICATE KEY UPDATE的 conflict target 必須對到真的 PRIMARY / UNIQUE KEY,否則不觸發、默默插重複。ai_cicollation 讓WHERE name = 'Hotfix'也吃到'hotfix'——unique key 會意外衝突。- Upsert 對「沒列出的欄位」天然保留舊值,別用子查詢自我 reference(MySQL 1093 錯誤)。
MySQL 環境還要注意
ONLY_FULL_GROUP_BY:MySQL 不會從 PK 推函數依賴(PG 會)→ PG 能跑的 GROUP BY 在 MySQL 丟 1055,補欄位或用ANY_VALUE()。STRICT_TRANS_TABLES:INT欄收到'abc'會硬報錯而非默默變 0 →int(epoch)cast 要在 Python 先做。RETURNING id沒有 MySQL 對應 → 用cursor.lastrowid。- 時區:server
time_zone=SYSTEM(=UTC)+ 連線後SET time_zone='+00:00',本機 my.cnf 也設default-time-zone='+00:00',否則遷移後時間全體偏移。
7. 一句話
遷移最可怕的不是 crash,是默默算錯——因為 crash 你看得到,錯數字你看不到。用查詢快照逐筆比對證明新舊行為一致,而不是『測一測沒壞就上』。 做成一條 CI 的 pass/fail,讓「行為變了」在合併前就紅燈。
相關連結
從零搭起 API 測試框架。pytest fixtures、requests session、JSON schema 驗證、auth 處理、CI 整合,附完整範例。
給「會寫 test 但不懂 CI/CD」的 QA 看的入門。Pipeline 七階段、QA 在每一階段做什麼、quality gate 怎麼設、常見坑。
Cross-browser 測試完整策略。Browser matrix 怎麼決定、Playwright 跨瀏覽器、BrowserStack / Sauce Labs 比較、何時用真機、何時用 emulator、CI 整合。
相關懶人包
2026 QA 趨勢實戰:我看到的 5 個轉變(AI、Shift-Left、Observability)
從手動 QA 到 AI 輔助、從測試金字塔到測試獎盃。這篇分享我這 10+ 年看 QA 從「測完才知道」到「shift-left + AI」的真實觀察。
AI / LLM 功能 Spec Review — 幻覺 / 評估 / 成本 / 法遵 8 個必問
AI 功能 spec review 完整指南。LLM 不確定性處理、評估指標、Prompt versioning、成本控制、安全護欄、法遵(EU AI Act / GDPR)、Fallback、人工 review 流程。
AI Agent 系統測試 — 自主執行 / 工具呼叫 / 多步推理的 QA 策略
測試 AI Agent 完整方法。Tool calling 驗證、Trajectory 評估、Failure mode 分類、無限迴圈防止、成本上限、安全 sandbox、Multi-agent 協作測試。
一般聲明
本站提供之資訊僅供參考,不保證其完整性與正確性。使用者應自行判斷資訊之適用性。