MySQL 為什麼會被叫做「MySQL」、又為什麼現在官網寫的是「Oracle MySQL」,這背後其實是一連串的公司併購故事,跟資料庫技術本身沒什麼關係

1
MySQL 身世時間軸
從瑞典公司誕生,到被 Sun、再被 Oracle 收購
1995MySQL 誕生
最初由瑞典公司 MySQL AB 開發,創辦人是 Michael "Monty" Widenius、David Axmark。Monty 有一個女兒叫做 My Widenius,所以 MySQL = My + SQL,也就是「My 的 SQL 資料庫」。當時 Oracle 跟 MySQL 還是競爭對手。
2008Sun Microsystems 收購 MySQL
那時候 Java 也是 Sun 的資產,所以 Java、MySQL 一度都在 Sun 旗下。
2010Oracle 收購 Sun
Oracle 這次收購一口氣拿到 JavaMySQLSolarisVirtualBox 一整批技術資產。
現況:Oracle MySQL
所以現在到 MySQL 官網,看到的品牌會是「Oracle MySQL」,就是這段併購歷史留下來的痕跡。

現在的 MySQL 8.0,核心幾乎都是用 C++ 寫的;最早期是以 C 為主,後來才逐漸轉成 C++。如果去下載 MySQL 的 source code(GitHub 或 Oracle 官方的 source),會看到大量的 .cc.cpp.h 檔案,這一段就是把「一條 SQL 從進來到跑出結果」的內部分層畫出來,看看哪些環節是 C++ 的地盤。

2
MySQL 內部分層架構
從 Client 連線到底層 Storage Engine 的執行路徑
flowchart TD
    Client["Client"] --> Connector["mysql.exe / Connector"]
    Connector --> Parser["SQL Parser(C++)"]
    Parser --> Optimizer["Optimizer(C++)"]
    Optimizer --> Executor["Executor(C++)"]
    Executor --> API["Storage Engine API"]
    API --> InnoDB["InnoDB(C / C++)"]
    API --> MyISAM["MyISAM(C++)"]
💡 從 SQL Parser 解析 SQL、Optimizer 決定 Execution Plan、Executor 真正執行,一路到 Join 演算法、Cost Model、B+ Tree 操作,這些核心邏輯全部都是 C++ 寫的;C 主要只留在最底層、最早期就存在的一些相容性程式碼。

建了兩個 index,明明其中一個明顯比較好,MySQL 卻選了比較差的那個——這是 Oracle 官方有紀錄的一個真實 Bug(#36817),它很有代表性,反映的是 OptimizerCost Model 不夠成熟,而不是某一行程式寫錯。

問題是這樣:2008 年有人回報,若今天有兩個 index AB,是先後建在資料庫裡的,就算某個情境下使用 B 可以避免 filesort,MySQL 還是可能會選擇 A,原因只是因為 A 比較早被建立。也就是說,只要改變 CREATE INDEX 的先後順序,Execution Plan 就跟著變了——舊版 MySQL Optimizer 因為成本估算能力不足,可能受到 Index Metadata(通常就是建立順序)的影響,因此選到並非最佳的 Index。

實例如下圖,即使 origin -> price 會是更合理的 index 選擇,也會因為建立時間較晚而不被選中:

MySQL 因索引建立順序選錯 Index 的實例

3
Bug #36817 時間軸
從回報、確認、第一次修補,到至今仍未真正結案
2008-05-20Bug 提出
有人回報 Bug #36817:Index 建立順序會影響 Optimizer 的選擇結果。
2008-06-17MySQL 官方確認(Verified)
官方確認這是真實存在的問題,列為待處理。
2008-12-12提交第一個 Patch
改善 Optimizer 在選擇 Index 時的邏輯,讓需要 filesort 的索引增加對應的成本(cost)。
2009-07-05原作者再次回報
即使沒有 ORDER BY,仍然存在因索引建立順序導致選錯 Index 的案例,代表根因並未完全解決。
之後:至今未結案
這個 Bug 一直維持在 Verified 狀態,沒有被標成 Fixed 或 Closed。
⚠ 根因不是某一行程式碼寫錯,而是 Optimizer 的 Cost Model 在早期版本還不夠成熟,才會讓「建立順序」這種跟查詢效率無關的 Metadata,意外影響到 Execution Plan 的選擇。

大約從 MySQL 5.5 開始,Optimizer 針對這類問題做了不少改善,例如更好的 Cost Model、更好的統計資訊(statistics)、更好的 ORDER BY 最佳化、更好的 index 選擇邏輯,所以後續 MySQL 5.5、5.6、5.7、8.0 的 Optimizer 也持續在演進。

4
Patch 實際改了什麼
官方修補訊息:selecting among the available indexes, the optimizer can take into account that certain indexes may require sorting

實際修改的位置是 sql_optimizer.cc 附近的 Cost Calculation,概念大致如下(僅為示意,並非原始碼):

BEFORE 修正前的成本計算
  1. cost = rows * row_cost;
  2. 完全沒有把「是否需要額外排序」算進成本裡
AFTER 修正後的成本計算
  1. cost = rows * row_cost;
  2. if (need_filesort) cost += filesort_penalty;
💡 這個修補的重點不是「哪個 index 先建立就選哪個」這種寫死的規則,而是讓 Cost Model 多考慮一項「Sorting Cost」,藉由更真實的成本估算,讓 Optimizer 自己算出比較合理的選擇。

同一份 Role 欄位資料,換一個資料庫,取最大最小值、排序的結果居然完全不一樣——問題出在 MySQL 特有的 ENUM 型別,它的底層儲存方式跟排序邏輯根本是兩套不同的規則,而 MSSQL 因為沒有這個型別,反而規則單純很多。

5
MySQL ENUM 是什麼
預先定義好選項清單的字串型別,底層卻是用數字儲存

MySQL 支援 ENUM(列舉)資料型別,這是一種字串物件,值必須從建立資料表時就定義好的清單中挑選。欄位的值只能是清單裡的其中一個(或是空白、NULL),否則寫入會直接失敗。雖然看起來、寫起來都是字串,但 MySQL 在底層其實會自動編碼成整數索引來節省空間——如果選項數量在 1 到 255 個之間,一筆資料只需要 1 個位元組的儲存空間。

優點:自我說明+防呆
  1. 欄位本身就列出所有合法值,具備自我說明性(Self-documenting)
  2. 能直接擋掉不在清單內的亂碼輸入,不用額外加 CHECK 約束
缺點:異動麻煩+排序不直覺
  1. 未來要新增/修改列舉選項得靠 ALTER TABLE,在大型或高流量資料表上改起來比較麻煩
  2. 排序依據不夠直覺(下面會展開說明),實務上不少人改用 TINYINT 搭配對照表、或直接存 VARCHAR

讓我們看一下原子能影片中提及的例子:

Minmax-1

Minmax-2

6
MAX/MIN 與 ORDER BY 的反直覺陷阱
同一個 ENUM 欄位,兩種操作各自套用不同的排序規則
ENUM 底層是用整數索引儲存的(建立時排在第一個的選項索引為 1,第二個為 2,依此類推),但 MAX() / MIN() 會以字串的字典順序(Alphabetical order)來比大小,並不是照定義的先後順序。若想依「定義的先後順序」取最大/最小值,必須把欄位 + 0 強制轉換成底層的數字索引才行。反過來,對 ENUM 欄位做 ORDER BY 時,MySQL 預設卻是照「定義列舉時的先後順序」排序,不是英文字母——同一個欄位,兩種操作各套用一套規則,邏輯本身並不一致。

MSSQL 沒有 ENUM 這個型別,欄位本質上就是 VARCHAR(搭配 CHECK 約束)或另外建一張數字對照表,規則反而單純很多:

MySQL:ENUM 欄位的規則
  1. 底層用整數索引儲存,但只是儲存方式,不代表操作也照這個順序
  2. MAX() / MIN() 依字串字典順序比大小
  3. ORDER BY 卻依「定義時的先後順序」排序,跟 MAX() / MIN() 的邏輯不一樣
MSSQL:VARCHAR 或數字對照表
  1. 方案 A:欄位存 VARCHAR(搭配 CHECK),MAX() / MIN() 一樣依字典順序比大小
  2. 方案 B:另建數字代碼對照表,直接對數字欄位取 MAX() / MIN(),最符合邏輯也最有效率
  3. ORDER BY 不管 ASC 還是 DESC,一律照英文字母/字典順序排序,沒有例外規則

方案 A 的字典順序範例:

1
2
3
-- 假設 Role 內有 'admin', 'editor', 'user'
-- 依字串排序:'user' 最大,'admin' 最小
SELECT MAX(Role), MIN(Role) FROM Users;

方案 B 的數字代碼範例(例如 1=admin, 2=editor, 3=user):

1
2
-- 取得目前使用者中,權限代碼最大與最小的值
SELECT MAX(RoleCode), MIN(RoleCode) FROM Users;

ORDER BY 一律依字典順序,沒有例外:

1
2
3
4
5
6
7
-- 狀況 A:字母 A 到 Z 排序,取第 1 筆
SELECT TOP 1 Role FROM Users ORDER BY Role ASC;
-- 傳回結果:'admin'(因為 a 開頭最小)

-- 狀況 B:字母 Z 到 A 排序,取第 1 筆
SELECT TOP 1 Role FROM Users ORDER BY Role DESC;
-- 傳回結果:'user'(因為 u 開頭最大)

想像資料庫裡有一個「守門員」,只要有人對某張表做新增、修改或刪除,它就會自動跳出來多做一些事——不用後端程式特別呼叫,這就是 MySQL 的 Trigger(觸發器):一種會在資料變更的「之前」或「之後」自動執行的預存程序。

7
Trigger 三大核心要素
建立一個 Trigger 時,必須明確定義這三件事
觸發時機
  1. BEFORE:在資料實際寫入或變更之前執行,常用於欄位值的檢查、攔截或自動修正
  2. AFTER:在資料實際寫入或變更之後執行,常用於同步更新其他表、寫入 Log 紀錄
觸發事件
  1. INSERTUPDATEDELETE 三選一,對應要攔截的資料變更動作
觸發對象
  1. 綁定在特定的某一張資料表(Table)上,離開這張表就不會觸發
8
關鍵字:OLD 與 NEW
Trigger 程式碼裡用來看「變更前」與「變更後」資料的兩個虛擬表
💡 NEW 代表即將寫入或更新後的新資料(可用於 INSERTUPDATE);OLD 代表被修改前或被刪除的舊資料(可用於 UPDATEDELETE)。
9
實際範例
一個做資料防呆,一個自動寫入異動日誌
A
BEFORE INSERT:自動資料防呆與修正

假設有一張 users 表,如果使用者寫入年齡小於 0,自動將其修正為 0:

1
2
3
4
5
6
7
8
9
10
11
12
13
DELIMITER //

CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
-- 如果新寫入的年齡小於 0,強制改成 0
IF NEW.age < 0 THEN
SET NEW.age = 0;
END IF;
END //

DELIMITER ;
B
AFTER UPDATE:自動記錄修改日誌

當使用者的資料被修改時,自動把舊資料和新資料寫入到另一張 user_logs 表中備份:

1
2
3
4
5
6
7
8
9
10
11
12
DELIMITER //

CREATE TRIGGER after_user_update
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
-- 記錄誰被改了名字,舊名字是什麼,新名字是什麼
INSERT INTO user_logs (user_id, old_name, new_name, change_time)
VALUES (OLD.id, OLD.name, NEW.name, NOW());
END //

DELIMITER ;
10
使用 Trigger 的優缺點
是最後一道防線,但也容易變成難以排查的隱藏邏輯
優點
  1. 確保資料完整性:不管後端程式(PHP、Java、Node.js)怎麼寫,只要進到資料庫都會被強制執行,是最後一道防線
  2. 自動化維護:不需要在後端程式碼裡重複寫入 Log、同步更新其他表的邏輯
缺點(實務上不建議濫用)
  1. 隱藏邏輯、難以排查 Bug:後端開發者看程式碼以為只做了一次 INSERT,卻不知道資料庫底層偷偷跑了 5 個 Trigger,出錯時很難追蹤
  2. 效能隱憂:Trigger 是 FOR EACH ROW(逐筆觸發),一口氣更新 10 萬筆資料,Trigger 就會被執行 10 萬次,可能導致資料庫瞬間卡死
💡 在 MSSQL 中也有完全對應的 Trigger 功能,但其運作邏輯(底層是用 InsertedDeleted 虛擬表,而不是 NEW/OLD)與 MySQL 有些微不同。

MySql Bug

例如我們今天建立一個 trigger,用來監聽服裝表格的異動,如果服裝資料被修改,trigger 會正常被觸發,但是若修改是被動發生的,例如它儲存買家的 ID 作為 fk,因為買家被刪掉了導致她變成 NULL,這種被動修改在 MYSQL 中就不會觸發 trigger

Minmax-1

Bug 11472

Postgres

官方文件直接舉

1
2
ON UPDATE CASCADE
ON DELETE SET NULL

這類 FK 自動造成的修改,affected table 上相關 trigger 會被觸發

1
2
3
4
5
6
7
DELETE Buyer

FK ON DELETE SET NULL

Clothes.BuyerId = NULL

✅ Clothes UPDATE Trigger 觸發

也就是 PostgreSQL 會把 Buyer 被 DELETE 和它產生的 Clothes 被 UPDATE 看成同一個 SQL command 所引發的一連串資料異動

MSSQL

SQL Server 在這點也會處理 cascade 造成的 trigger。

Microsoft 文件明確說明 cascading referential actions 會觸發 AFTER UPDATE / AFTER DELETE triggers。
流程基本上是:

1
2
3
4
5
6
7
DELETE Buyer

ON DELETE SET NULL

Clothes 更新

✅ Clothes UPDATE Trigger

而且 SQL Server 的順序設計得很明確是 referential cascade action 與 constraint check 成功完成之後,相關 AFTER trigger 才執行