MySQL 底層機制筆記

🐬 外鍵、交易與隔離等級的那些意外行為

資料庫平常安安靜靜地把資料存好,但只要牽涉到外鍵、交易或並行讀寫,MySQL 就會出現一些跟直覺不一樣的行為,甚至讓其他主流資料庫的工程師都嚇一跳。這篇整理四個真實會踩到的情境,從外鍵鎖定的意外連坐、交易遇到 DDL 直接失效,到兩個隔離等級案例揭露的資料錯亂風險。

外鍵限制 Transaction 與 DDL 隔離等級案例 跨資料庫比較
01

外鍵的本意:維持兩張表的資料一致性

外鍵(foreign key)的用意是讓使用系統的人知道兩張表是相關的,更重要的是讓資料庫本身也「意識到」這層關係。當其中一張表的資料被改動時,資料庫要負責維持兩者之間該有的一致性,而不是各改各的。

下圖是一個常見的例子,買家表與訂單表,訂單表裡的買家欄位是指向買家表的外鍵。

外鍵關聯示意圖

外鍵設定了 ON UPDATE CASCADE,代表系統要保證所謂的參照完整性(referential integrity)。也就是說,一旦外鍵被更改,資料的完整性就必須被守住。實務上的效果是,如果訂單表裡正在修改買家欄位,買家表對應的那筆資料就會被鎖住,不能同時被修改。

02

意外的連坐:改訂單狀態也會鎖到買家表

如果修改的其實是訂單的狀態欄位,理論上跟買家表完全無關,但實際測試會發現買家表依然被鎖住。

修改訂單狀態時買家表被鎖定示意圖
⚠️

問題出在 INDEX:訂單表裡有一個「買家欄位加訂單狀態」的複合索引,而 MySQL 把訂單狀態也一併當作外鍵的一部分來管制,導致修改訂單狀態時,買家表也被牽連鎖住,這個行為相當令人意外。

複合索引導致外鍵鎖定範圍擴大示意圖
官方回報 Bug #94148

已經有人把這個現象提交成官方 Bug 回報:Bug #94148。有網友找到的解法是,額外加一個「純買家欄位」的索引,結果就意外地不再被鎖住。索引與外鍵原本是兩個不相干的概念,卻在 MySQL 的實作裡被混在一起,變成解法也帶著一點意外的味道。

🐬
03

當交易遇上 DDL:一個會悄悄失效的 Rollback

SQL 語法大致分成兩種類型,一種是定義資料結構的 DDL,例如 CREATE TABLEDROP TABLE;另一種是大家熟悉的 DML,例如 SELECTUPDATE。在 MySQL 裡,交易(transaction)機制對這兩種語法的保護程度並不一樣,以下用一個常見的資料搬遷情境來說明。

交易中包含 DDL 導致回滾失效示意圖
1
將資料從 old_data 備份到 new_data

這一步是單純的資料搬移,屬於 DML 操作。

2
刪除 old_data(這一步是 DDL)

因為是結構層級的操作,凌駕於交易之上,不受交易的 rollback 保護。

3
將此操作紀錄在 audit table

如果這一步不小心失敗,理論上整包交易都應該回滾。

🚨

實際結果:如果 audit table 那一步寫入失敗,整個交易理應回滾到最初的狀態,但因為第二步刪除 old_data 是 DDL,它凌駕於交易之上,不會被回滾撤銷。最後的結果是舊資料真的不見了,但整個搬遷操作卻沒有真正完整執行到,資料等於是憑空消失的。

04

案例一:明明查詢是空表,修改卻成功了

資料庫裡目前有一張空的使用者資料表,小左和小右兩位工程師幾乎同時開啟了各自的交易(transaction)。以下是實際發生的順序。

1
小右寫入並提交

小右在自己的交易裡,往這張表寫入了 3 個新使用者,隨後執行 COMMIT。此時資料庫裡實際上已經存在這 3 筆資料。

2
小左查詢,看到 0 個使用者

小左在自己尚未提交的交易裡,執行 SELECT 查詢這張表,結果看到的使用者數量是 0 個。原因是 MySQL 預設的隔離等級是可重複讀(Repeatable Read),小左的交易一開始就建立了一份資料快照,這份快照停留在交易剛開始時的狀態,因此即使小右已經提交了新資料,小左依然只能看到空表。

3
小左閉眼修改,居然成功了

雖然查出來是空表,小左還是被交辦「幫第一個使用者改名字」的任務,於是直接執行 UPDATE users SET name='新名字' WHERE id=1;,結果系統提示修改成功。原因是純查詢用的是快照讀,讀的是歷史資料,但 UPDATEDELETEINSERT 這類寫入操作用的是當前讀,會直接讀取資料庫裡最新、最真實的資料。因為小右已經提交了資料,最新資料裡確實存在 id=1 的使用者,小左的修改指令自然就找到了這筆紀錄並完成改名。

4
小左再次查詢,看到 1 個使用者

小左不放心,再次查詢整張表,這次看到了 1 個使用者,正是剛剛被自己改過名字的那一筆。根據 MySQL 的多版本併發控制(MVCC)規則,一個交易永遠可以看見自己做出的修改,該筆資料被小左修改後,最新版本的交易編號就變成小左的交易編號,因此小左的快照讀在此時被允許看見這筆被修改的資料。至於另外兩筆使用者,因為從未被小左修改過,對小左的歷史快照而言依然被隔離在外,所以看不到。

💡

最終結局:直到下班,小左都以為這張表裡只有 1 個使用者,也就是自己改過名字的那一個,完全不知道實際上已經有 3 個使用者存在。這個現象的根源,是 MySQL 在可重複讀等級下,把快照讀與當前讀的機制混用,進而打破了原本應該嚴格的隔離性。

05

案例二:查出來是乾淨資料,寫進備份表卻變髒了

這天剛好是愚人節,小左和小右同樣在差不多時間開啟了各自的交易。小右的任務是整活,要把所有使用者的名字都改成「哈基米」;小左的任務是備份,要把目前的使用者資料備份到另一張表,等愚人節活動結束後用來恢復原始資料。

1
小右修改並提交

小右很快地在交易中把所有使用者名字都改成「哈基米」,執行 COMMIT 後就下班了。此時資料庫裡最新、最真實的使用者名稱已經全部變成「哈基米」。

2
小左查詢,看到原始資料

小左在動手備份前,先執行單純的查詢 SELECT * FROM users;,看到的依然是整活前的原始資料。原因跟案例一相同,小左的交易在小右提交前就已經開啟,MySQL 的可重複讀利用快照擋住了小右的修改。小左因此鬆了一口氣,心想查出來的是乾淨的原始資料,備份應該不會有問題。

3
小左執行「查詢並寫入」備份

小左寫下了開發中很常見的一行指令:INSERT INTO backup_table SELECT * FROM users;,隨後安心下班。

4
隔天上班,備份表全部變成「哈基米」

第二天愚人節活動結束,小左準備用備份表恢復資料,打開一看卻發現備份表裡存的全部都是「哈基米」,原始資料徹底消失,根本無法恢復。

🚨

MySQL 的雙重標準:小左明明在備份前用 SELECT 親眼查出原始資料,為什麼用 INSERT ... SELECT 寫進去就變成了「哈基米」。原因在於 MySQL 一個很反直覺的底層設計。純查詢語句(如 SELECT)會乖乖遵守可重複讀規則,讀取歷史快照,所以小左看到了原始資料。但查詢加寫入的語句(如 INSERT ... SELECT)規則會突然切換,只要指令裡同時包含查詢與寫入,查詢的部分就會自動降級成讀已提交(Read Committed)規則執行。因為小左的備份指令是 INSERT ... SELECT,裡面的 SELECT 部分不再看歷史快照,而是直接執行當前讀,讀到了小右已經提交的最新資料,也就是「哈基米」,導致真正寫入備份表的全部是整活後的髒資料。

⚠️

為什麼這很危險:其他主流資料庫如 PostgreSQL、Oracle、SQL Server,都不會因為多寫了一個 INSERT 就偷偷改變 SELECT 的讀取規則,機制的一致性是這些資料庫的基本要求。更麻煩的是,這種因為隔離性混用導致的資料錯亂永遠不會報錯,只會靜悄悄地在背後寫入與預期不符的資料,代表整個業務系統可能已經在用錯誤的資料下錯誤的結論,卻完全沒有察覺。整體來說,小左會被坑,是因為相信了 MySQL 聲稱的可重複讀隔離等級,卻不知道 MySQL 在處理 INSERT ... SELECT 這類語句時會悄悄切換規則。

🐬
06

換成 PostgreSQL 或 MS SQL Server,結果會不一樣嗎

在 PostgreSQL 和 MS SQL Server 這類主流資料庫上,機制的一致性是設計上的底線,不會像 MySQL 一樣在同一個隔離等級下,因為指令種類不同就偷偷切換底層的讀取規則。以下拆解如果換成這兩個資料庫,前面兩個案例會如何處理。

先了解預設隔離等級的差異

有一個根本性的差別,MySQL 預設使用可重複讀(Repeatable Read),而 PostgreSQL 和 MS SQL Server 預設使用的都是讀已提交(Read Committed)。如果用預設的讀已提交執行,小左在做任何操作前,只要小右已經提交,小左的 SELECT 就會直接看到最新提交的資料。也就是說,小左一開始就會看到那 3 個使用者(案例一)或「哈基米」(案例二),根本不會產生資訊落差,邏輯上非常直觀,不會出現 MySQL 那種先看到舊資料、後來又冒出新資料的落差。

如果都手動切換到可重複讀,公平比較會怎樣

假設把 PostgreSQL 和 MS SQL Server 也手動切換到可重複讀(快照隔離)等級,兩者的底層機制會表現得相當嚴謹,完美避開 MySQL 的陷阱。

案例二:備份表變髒

PostgreSQL 在可重複讀等級下,所有讀取操作都強制對齊同一份快照,不會因為多寫了 INSERT 就把裡面的 SELECT 偷偷降級成讀已提交。執行 INSERT INTO backup_table SELECT * FROM users; 時,裡面的 SELECT 依然讀取小左一開始建立的歷史快照,寫入備份表的會是百分之百的原始資料,備份成功。MS SQL Server 同樣地,在可重複讀或快照隔離下,INSERT ... SELECT 中的 SELECT 部分會嚴格受到交易歷史快照的保護,寫入備份表的同樣會是原始資料,不受小右後來提交的「哈基米」影響。

機制一致性的差異

MySQL 的機制不一致,純查詢用可重複讀走快照讀,混合寫入時 SELECT 卻悄悄降級成讀已提交走當前讀,導致資料在不知不覺中錯亂。PostgreSQL 與 MS SQL Server 的機制高度一致,是可重複讀就是可重複讀,所有讀取一律看快照,不允許「看著一份資料,卻寫入另一份資料」的情況發生。