資料庫效能與維運 ch06 Schema 與型別的代價:改一個欄位長度鎖住整張表 40 分鐘
下一章→
CH 06 建表時就決定了

Schema 與型別的代價:改一個欄位長度鎖住整張表 40 分鐘

主鍵選擇與頁分裂UUID 的寫入放大型別的隱藏成本NULL 的代價範式與反範式大欄位拆表INSTANT/INPLACE/COPYgh-ost

前面五章處理的都是「查詢怎麼跑」。但有一類成本,在建表的那一刻就已經決定了,之後不管怎麼調索引、怎麼改 SQL 都繞不開:

主鍵用自增還是 UUID、金額用 DECIMAL 還是 DOUBLE、狀態欄位允不允許 NULL、備註欄位要不要跟主表放在一起——這些決定會一路影響寫入速度、索引大小、Buffer Pool 命中率,最後甚至決定「改一個欄位要不要停機」。

而 schema 的問題有一個殘酷的性質:它是唯一一種「發現得越晚,修起來越貴」的問題。索引可以隨時加、SQL 可以隨時改,但改一個型別可能要重建整張表——而那張表現在有兩億列。

這一章要拆的是:

  • 主鍵選擇:為什麼隨機 UUID 會讓寫入變慢、讓所有二級索引一起變胖。
  • 型別的隱藏成本:VARCHAR(255) 真的「不佔空間」嗎?TIMESTAMP 與 DATETIME 差在哪?
  • NULL:它的代價常被誇大,但有兩個地方是真的要小心。
  • 反範式:它買到什麼、付出什麼——這是「代價守恆」最赤裸的一次。
  • 線上 DDL:INSTANT/INPLACE/COPY 三種演算法,以及怎麼在下指令之前就知道會不會鎖表。
  • 一個真實場景:把 VARCHAR(60) 改成 VARCHAR(100)——一個看起來最無害的變更,鎖住整張表 40 分鐘。

這一章與 ch05 是連著的:DDL 的災難幾乎都不是 DDL 本身慢,而是它拿不到 MDL、然後擋住後面所有人。 讀這章之前,ch05 那個 MDL 佇列 FIFO 的機制要先記得。

先回憶 backend ch01 的那個事實:在 InnoDB 裡,表本身就是主鍵索引(聚簇索引),資料列按主鍵順序實際存放在頁裡。

這件事推出一個很多人沒想過的結論:主鍵的取值方式,決定了資料是「順著寫」還是「隨機插」。

自增主鍵:順著寫

新資料的主鍵永遠最大 → 永遠插在最後一頁的尾端
→ 那一頁寫滿了就開新的一頁
→ 頁利用率接近 100%,幾乎不需要搬動既有資料

隨機 UUID:插在中間

新資料的主鍵是隨機值 → 該插在哪一頁完全隨機
→ 那一頁如果滿了,必須「頁分裂」:
     把原本那頁的資料搬一半到新頁,再插入
→ 一次寫入變成:讀舊頁 + 寫兩頁 + 更新父節點

四個連鎖後果

1
寫入放大:一次邏輯上的 INSERT,變成多次實體的頁讀寫。
2
頁利用率掉到 5~6 成:分裂後兩頁都只有半滿。同樣的資料量佔用近兩倍空間,而空間變大就代表 Buffer Pool 裝得下的比例變小(回扣 ch04 那個快取被沖掉的故事)。
3
每一個二級索引都變胖:InnoDB 的二級索引葉節點存的是主鍵值。主鍵是 BIGINT 就是 8 bytes;是 CHAR(36) 的 UUID 字串就是 144 bytes(utf8mb4)。有五個二級索引,這個代價就付五次。
4
插入位置隨機 → Buffer Pool 命中率下降:自增只會反覆碰最後那幾頁(一直在快取裡);隨機插入要碰到整棵樹的任意位置。
所以「UUID 當主鍵」不是多花一點空間而已,它同時傷到寫入速度、空間、快取命中率、以及每一個二級索引。而這些帳單全部在寫入端,通常等到資料量上來才會發現——那時候改主鍵幾乎等於重建整張表。

如果業務上真的需要 UUID

需求通常是「id 不要能被猜到」或「要在寫入資料庫之前就有 id」。這兩個需求都可以滿足,而且不必付上面的帳:

  • 用 BINARY(16) 存,不要用 CHAR(36):36 個字元的字串換成 16 bytes 的二進位,省下 8 成空間。
  • 用有序的 UUID:MySQL 8.0 的 UUID_TO_BIN(uuid, 1) 會把時間戳的高低位對調,讓值大致遞增——順序性回來了,頁分裂就消失了。UUIDv7/ULID 也是同樣的思路。
  • 或者:內部主鍵用自增,對外另外給一個唯一的隨機碼。兩個目標分開達成,各自用最適合的形式——這通常是最乾淨的解法。
-- 有序 UUID 的建表方式
id BINARY(16) NOT NULL PRIMARY KEY,
-- 寫入:INSERT ... VALUES (UUID_TO_BIN(UUID(), 1), ...)
-- 讀出:SELECT BIN_TO_UUID(id, 1) FROM ...

「VARCHAR 只按實際長度儲存,所以宣告長一點沒差」——這句話對了一半,而錯的那一半會在最意外的地方咬你。

對的部分

在資料頁裡,VARCHAR(50) 存 'abc' 確實只佔 3 bytes 加上長度前綴。宣告長度不影響磁碟上的實際佔用。

錯的部分(三個)

  • 記憶體暫存表按「最大可能長度」配置。回扣 ch04:需要暫存表或排序時,MySQL 會依欄位的宣告長度來算每列要多少空間。一個 VARCHAR(255) 的 utf8mb4 欄位=1020 bytes,十個這種欄位就是每列 10KB——於是本來能在記憶體完成的操作,改成落磁碟。
  • 索引長度受限。InnoDB 單一索引鍵最長 3072 bytes。VARCHAR(255) 在 utf8mb4 下就是 1020 bytes,複合索引很容易撞到上限。
  • 長度前綴會跳級。欄位最大位元組數小於 256 時,長度前綴只要 1 byte;達到 256 就要 2 bytes。而跨過這條線的 ALTER 必須重建整張表——這正是本章那個失敗場景。

其他幾個實務上會痛的選擇

選擇差別怎麼決定
INT vs BIGINT4 vs 8 bytes;乘以列數,再乘以每個二級索引會超過 21 億就用 BIGINT,不要為了省 4 bytes 冒溢位風險
DECIMAL vs DOUBLEDOUBLE 是二進位浮點數,0.1 + 0.2 ≠ 0.3金額一律 DECIMAL,沒有例外
DATETIME vs TIMESTAMP5 vs 4 bytes;TIMESTAMP 會做時區轉換,且 2038 年溢位存「事件發生的絕對時間」用 DATETIME;需要跟著時區跑再考慮 TIMESTAMP
ENUM vs TINYINT vs VARCHARENUM 省空間但增刪值要 ALTER;VARCHAR 每列重複存字串狀態碼用 TINYINT + 應用層列舉,最好維護
TEXT/BLOB超長時存在 off-page,另外讀一次不要跟熱欄位放同一張表(見下一節)
型別選擇的判準不是「省多少 bytes」,是「乘上多少次」。一個欄位多 4 bytes,在兩億列的表上、又出現在三個二級索引裡,就是好幾 GB——而那些 GB 是從 Buffer Pool 裡搶走的。

「欄位不要允許 NULL,會影響效能」是流傳很廣的說法。它有道理,但理由常常被講錯。

被誇大的部分

  • 儲存成本幾乎可以忽略:InnoDB 每列有一個 null bitmap,每個可為 NULL 的欄位佔 1 個 bit。16 個這種欄位才多 2 bytes。
  • NULL 值可以進索引(與 Oracle 不同),WHERE col IS NULL 也可以走索引。這點常被誤傳成「有 NULL 就不走索引」。

真的要小心的三件事

1
三值邏輯的語意陷阱。回扣 ch03:NOT IN (子查詢) 只要結果含一個 NULL,整句回傳空集合;col != 'x' 不會撈到 col IS NULL 的列;COUNT(col) 會忽略 NULL 而 COUNT(*) 不會。這些都不報錯,只是安靜地給你錯的答案。
2
優化器的估算會變差。NULL 的分佈通常極度傾斜(例如 99% 的列 deleted_at 都是 NULL),而這正是 ch02 說的「均勻分佈假設」失效的場景。
3
key_len 會多 1 byte(ch01 算過)——本身微不足道,但在複合索引接近長度上限時可能是壓垮的最後一根。
所以正確的原則不是「禁用 NULL」,而是「不要用 NULL 表達『沒有值以外』的意思」。NULL 的語意是「不知道/不適用」。用它來表示「數量是 0」「狀態是預設值」就是在自找語意問題。而真的表示「不適用」時(例如 deleted_at),NULL 是正確且清楚的選擇——比用 '1970-01-01' 這種魔術值好得多。

ch03 提過「與其優化那個 JOIN,不如把欄位冗餘過來」。這一節把這筆帳算清楚——這是「代價守恆」最赤裸的一次。

反範式買到的

  • 少一次 JOIN(尤其是列表頁那種一次要顯示幾十筆的場景)。
  • 可以在冗餘欄位上建索引、可以直接排序——這是單靠 JOIN 拿不到的能力。例如「按商家名稱排序訂單」,欄位在別張表時幾乎不可能有效率地做。

付出的

  • 一致性維護:來源改了誰負責同步?同步失敗怎麼辦?漏同步的資料怎麼補?
  • 每一個寫入路徑都要記得:新功能、資料修補腳本、匯入批次——只要有一條路徑忘了,資料就開始不一致,而且不會有人發現。
  • 對帳成本:實務上必須另外寫一支定期比對的排程,否則你永遠不知道它歪了沒。
判斷準則:反范式適合「來源幾乎不變」的資料(商家名稱、商品分類名),不適合「經常變動」的資料(庫存、餘額、狀態)。前者的同步成本趨近於零,後者會讓你維護兩份真相。

大欄位拆表:一個常被低估的優化

InnoDB 的頁是 16KB。一張表如果有 TEXT 型態的備註、JSON 設定、序列化內容,會有兩個效應:

  • 超長的欄位會存到頁外(off-page),讀它要多一次 I/O。
  • 更重要的是:每一頁裝得下的列數變少。列變寬 → 同樣的查詢要讀更多頁 → 全表掃描與範圍掃描一起變慢,即使你根本沒有 SELECT 那個大欄位。
-- 拆之前:一頁只裝得下 8 列
orders(id, user_id, amount, status, created_at, remark TEXT, snapshot JSON)

-- 拆之後:主表一頁裝得下 100+ 列,範圍查詢快一個數量級
orders(id, user_id, amount, status, created_at)
order_details(order_id PK, remark TEXT, snapshot JSON)

代價是取詳情時要多一次查詢(或一次 JOIN)——但詳情頁一次只看一筆,而列表頁一次要看幾十筆。把成本從「常做的操作」搬到「少做的操作」,這是這類優化的通用形式。

順帶一提:這也是 SELECT * 的第三個代價

ch04 已經說過兩個(推向雙路排序、暫存表落磁碟)。第三個是:把 off-page 的大欄位一起撈出來,多一次 I/O、多一份網路傳輸、多一份 ORM 映射。三個代價都不會出現在 EXPLAIN 上。

MySQL 8.0 的 DDL 有三種演算法,成本差了好幾個數量級。而預設行為是「自己挑一個能用的」——所以你可能在完全不知情的狀況下觸發了最貴的那種。

三種演算法

  • INSTANT:只改資料字典(metadata),不碰資料。幾乎瞬間完成,不論表多大。8.0.12 起支援在尾端加欄位,8.0.29 起支援任意位置加欄位。
  • INPLACE:在原表上重建(例如加索引),不複製整張表,過程中允許併發 DML。但開始與結束時仍需要短暫的 exclusive MDL——這正是危險所在。
  • COPY:建一張新表、把資料一列一列複製過去、再換名。過程中阻塞寫入,時間與表大小成正比。

最重要的一招:明確宣告,不要讓它默默降級

直接寫 ALTER TABLE t MODIFY ... 時,如果那個變更不支援 INPLACE,MySQL 會安靜地改用 COPY,然後鎖住整張表 40 分鐘。唯一的防禦是把期望明確寫出來——不支援時它會立刻報錯,而不是開始鎖表:
ALTER TABLE orders
  MODIFY COLUMN memo VARCHAR(100),
  ALGORITHM=INPLACE, LOCK=NONE;

-- 不支援就會得到:
-- ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported.
--   Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.
這個報錯是好消息——它讓你在 10 毫秒內知道要換方法,而不是在 40 分鐘的鎖表中途才知道。所有線上 DDL 都應該這樣寫。

常見操作各屬於哪一種

操作演算法會不會鎖寫入
在尾端加欄位INSTANT不會(瞬間)
改欄位預設值INSTANT不會
加/刪二級索引INPLACE不會,但頭尾要短暫 MDL
改欄位名稱INPLACE不會
VARCHAR 加長(不跨 255 bytes 界線)INPLACE不會
VARCHAR 加長(跨過界線)COPY會
改欄位型別(INT→BIGINT 等)COPY會
改字元集/collationCOPY會
加/改主鍵COPY會

大表怎麼辦:影子表工具

當變更無法避開 COPY,而表又大到不能鎖,就用 gh-ost 或 pt-online-schema-change:

1
建一張新結構的影子表。
2
分批把舊資料複製過去(可以限速、可以暫停——這是它比原生 DDL 好的關鍵)。
3
期間的新寫入靠 binlog(gh-ost)或觸發器(pt-osc)同步到影子表。
4
追平之後,原子換名——只有這一瞬間需要鎖,通常是毫秒等級。

代價:需要一倍的額外磁碟空間、整體耗時更長(幾小時)、外鍵支援不好。但它把「不可控的 40 分鐘鎖表」換成了「可控的幾小時背景作業」,在正式環境幾乎永遠是對的取捨。

徵狀

需求很小:會員暱稱欄位不夠長,從 VARCHAR(60) 改成 VARCHAR(100)。工程師確認過「VARCHAR 加長是 INPLACE,不會鎖表」,於是排在週三下午上線。

ALTER TABLE members MODIFY COLUMN nickname VARCHAR(100) NOT NULL;
  • 指令下去之後沒有回應。
  • 兩分鐘內,所有會員相關的 API 全部逾時——連純 SELECT 也是。
  • 接著連線池耗盡,整個服務掛掉,包括跟會員無關的功能。
  • 總共 40 分鐘。

診斷

SHOW PROCESSLIST;
-- id   time  state
-- 88   2410  copy to tmp table          ← ALTER 正在複製整張表
-- 91   2380  Waiting for table metadata lock
-- 92   2380  Waiting for table metadata lock
-- ...(幾百個)

兩件事同時發生了,而且互相加乘。

根因一:VARCHAR 加長跨過了 255 bytes 界線

1
表的字元集是 utf8mb4,每字元最多 4 bytes。
2
VARCHAR(60) = 最多 240 bytes → 小於 256 → 長度前綴只需要 1 byte。
3
VARCHAR(100) = 最多 400 bytes → 大於等於 256 → 長度前綴要 2 bytes。
4
長度前綴變了,每一列的實體格式就變了 → 必須逐列重寫 → 只能用 COPY 演算法。
「VARCHAR 加長是 INPLACE」這句話是對的——但有一個沒人記得的但書:只要不跨過 255 bytes 的界線。而在 utf8mb4 下,這條界線落在 VARCHAR(63) 與 VARCHAR(64) 之間,是一個非常容易在毫無察覺中跨過的位置。60 改 100 看起來比 100 改 200 更無害,實際上前者要重建整張表,後者是瞬間完成。

根因二:MDL 佇列把局部問題變成全站故障

如果只是 COPY,那是「這張表 40 分鐘不能寫」。但實際上連 SELECT 都掛了,原因是 ch05 那個機制:

  • ALTER 持有/等待 MDL 寫鎖。
  • MDL 佇列是 FIFO——排在 ALTER 後面的每一個新查詢,即使只是讀,也要排隊。
  • 請求塞住 → 佔住連線 → 連線池耗盡 → 跟會員無關的 API 也拿不到連線(ch05 最後一節那條外溢路徑)。

所以真正的災難不是「一張表被鎖 40 分鐘」,是「一張表的 DDL 讓整個服務掛掉」。

修法

1
當下:KILL 88; 中止 ALTER。(注意:COPY 到一半被 kill 要回滾,中止本身也需要時間。)
2
重做:改用 gh-ost 在離峰執行,可限速、可暫停、換名只鎖毫秒。
3
防再犯(最重要的一條):所有 DDL 一律明確宣告 ALGORITHM=INPLACE, LOCK=NONE。這個變更會在 10 毫秒內報 ERROR 1846,工程師當場就知道要換 gh-ost,而不是在 40 分鐘的鎖表中途才發現。
4
流程:DDL 前先查長交易(ch05);設 lock_wait_timeout = 5 讓 DDL 等不到就放棄,而不是無限排隊擋住所有人。

這個案例的教訓

schema 變更的風險,不能靠「這個改動看起來很小」來判斷。看起來最小的變更(加長 40 個字元)觸發了最重的演算法,而看起來更大的變更(100 → 200)反而是瞬間的。唯一可靠的判斷方式是:把期望的演算法明確寫進 SQL,讓資料庫自己告訴你答案。

把前面幾節收成一套可以直接照著跑的流程。schema 變更與查詢優化最大的差別是:它幾乎不能回滾(改回去一樣要重建),所以流程比技巧更重要。

上線前

1
先在測試環境問資料庫:帶著 ALGORITHM=INPLACE, LOCK=NONE 執行一次,看它報不報 ERROR 1846。這一步只要 10 毫秒,卻能擋掉九成的 DDL 事故。
2
估算表大小:SELECT table_rows, data_length/1024/1024 AS mb FROM information_schema.tables WHERE table_name='...'。超過幾 GB 就直接走 gh-ost,不要賭。
3
檢查長交易(ch05):information_schema.innodb_trx 裡有沒有開很久的交易。有的話先處理它。
4
設定 lock_wait_timeout:讓 DDL 等不到 MDL 就放棄,避免它排隊擋住整個服務。

相容性:先加後刪,分兩次上線

應用程式與資料庫不會同時更新,所以每個 schema 變更都要能與新舊兩版程式碼同時共存:

  • 加欄位:先加(可為 NULL 或有預設值)→ 部署會寫入它的程式 → 回填舊資料 → 最後才加 NOT NULL 約束。
  • 刪欄位:先部署「不再使用它」的程式 → 觀察一段時間 → 才真的 DROP。不要在同一次上線裡同時改程式和刪欄位,否則回滾程式碼時會直接爆炸。
  • 改名:等於「加新的 + 雙寫 + 遷移 + 刪舊的」四步,不要用一次 RENAME 解決。

什麼時候「不要改 schema」

  • 只是為了省空間:BIGINT 改 INT 省下的空間,通常遠不值得那次 COPY 的風險。
  • 只是為了美觀:欄位名不好看、順序不對——這些沒有實質收益,卻要付全額風險。
  • 問題其實出在查詢上:先確認不是索引或 SQL 的問題(前五章)。schema 是最後才動的東西,因為它最貴、最難回頭。

把這一章收成三句

① 主鍵的取值方式決定資料是順著寫還是隨機插——隨機 UUID 同時傷到寫入、空間、快取與每一個二級索引。
② 型別成本的判準不是「省幾 bytes」,是「乘上多少列、多少個索引」。
③ DDL 一律明確寫 ALGORITHM=INPLACE, LOCK=NONE:不支援時它會立刻報錯,而不是安靜地開始鎖表。

剩下兩章要把前面六章串起來:ch07 是「線上變慢了,從哪裡開始查」的完整流程;ch08 則回到 Java 這邊,看 ORM 到底發出了什麼 SQL——因為前面所有的分析,前提都是「SQL 真的長你以為的那樣」。

五筆帳

  • 自增主鍵 → 順序寫入很理想,但高併發插入時最後一頁會成為熱點(所有交易都在搶同一頁的鎖,回扣 ch05)。極高寫入量的場景反而要考慮分段或有序 UUID。另外自增值是可預測的,對外暴露 id 要另外考慮。
  • 有序 UUID(UUID_TO_BIN(x, 1)) → 解決了頁分裂,但仍是 16 bytes(自增 BIGINT 是 8),每個二級索引還是比較胖;而且值不再是「完全隨機」,時間資訊可以被還原出來。
  • 大欄位拆表 → 列表查詢快一個數量級,但取詳情多一次查詢,而且寫入時要維護兩張表的一致性(沒有外鍵時尤其要小心孤兒資料)。
  • 反範式 → 買到讀取速度與排序能力,付出每一條寫入路徑的維護成本,還要多寫一支對帳排程。
  • gh-ost → 把不可控的鎖表換成可控的背景作業,但需要一倍磁碟空間、耗時更長、外鍵支援不好。

這一章的判斷看不到的東西

  • DDL 在複本上也要跑一次。主庫 40 分鐘的 ALTER,複本也要花 40 分鐘——期間複本延遲會一路累積。如果有讀寫分離,那段時間讀到的都是舊資料(ch07 會談複本延遲)。
  • 型別變更可能改變語意。VARCHAR 縮短會截斷資料;collation 變更會改變排序與比較結果('a' = 'A' 在某些定序下成立、某些不成立),甚至讓原本沒有衝突的唯一索引突然有衝突。
  • schema 沒有「這句 SQL 慢」這種訊號。它的代價分散在每一次寫入、每一個索引、每一頁的利用率上——沒有任何一條慢查詢日誌會指向它。要靠定期檢視表大小、索引大小、頁利用率主動發現。
最後這點是這一章存在的理由:schema 的問題不會自己浮上來。它只會表現成「這個系統整體就是比較慢」,而那是最難被立案處理的一種問題。
主鍵選擇:四種做法的代價
做法寫入空間與二級索引適用
自增 BIGINT順序寫入,頁利用率接近 100%8 bytes,最省預設選擇;但高併發插入時最後一頁是熱點
隨機 UUID(CHAR(36))頁分裂、寫入放大、快取命中率降144 bytes,每個二級索引都變胖不要用
有序 UUID(BINARY(16) + UUID_TO_BIN(x,1))大致遞增,無頁分裂16 bytes需要在寫入前就有 id、或 id 不可預測時
內部自增 + 對外隨機碼順序寫入8 bytes + 一個唯一索引最乾淨:兩個目標分開達成
常見 DDL 的演算法與代價
操作演算法阻塞寫入大表怎麼做
尾端加欄位、改預設值INSTANT不會(瞬間)直接做
加/刪二級索引、改欄位名INPLACE不會,但頭尾需短暫 MDL直接做,但先查長交易
VARCHAR 加長(不跨 255 bytes)INPLACE不會直接做
VARCHAR 加長(跨過 255 bytes)COPY會gh-ost —— 這是最容易誤判的一格
改型別(INT→BIGINT)、改 collationCOPY會gh-ost
加/改主鍵COPY會gh-ost,且要特別小心
型別選擇速查
場景選它不要選理由
金額DECIMALDOUBLE / FLOAT二進位浮點數無法精確表示小數
事件時間DATETIMETIMESTAMP(除非要跟時區跑)TIMESTAMP 有時區轉換與 2038 溢位
狀態碼TINYINT + 應用層列舉ENUMENUM 增刪值要 ALTER 整張表
短字串剛好夠用的 VARCHAR一律 VARCHAR(255)記憶體暫存表按宣告長度配置,且可能跨過 255 bytes 界線
備註/JSON/序列化內容拆到附屬表跟熱欄位放同一張表列變寬 → 每頁裝的列數變少 → 範圍查詢全部變慢

練習題 點選選項查看解析

0 / 10
01 / 10
為什麼用隨機 UUID 當主鍵會拖慢寫入?
A 因為 UUID 的產生本身很慢
B 因為 InnoDB 的表就是主鍵索引,隨機主鍵代表插入位置隨機,會造成頁分裂、寫入放大與頁利用率下降
C 因為 UUID 不能建索引
D 因為 UUID 會導致主鍵衝突
解析
自增主鍵永遠插在最後一頁尾端(順序寫、頁利用率接近 100%);隨機主鍵插在中間,該頁滿了就要把資料搬一半到新頁。連帶後果還有:頁利用率掉到 5~6 成、Buffer Pool 裝得下的比例變小、插入位置隨機導致快取命中率下降。
02 / 10
主鍵型別對二級索引有什麼影響?
A 沒有影響,二級索引是獨立的
B InnoDB 的二級索引葉節點存的是主鍵值,所以主鍵越大,每一個二級索引都跟著變大
C 只影響第一個建立的二級索引
D 會讓二級索引無法使用覆蓋索引
解析
BIGINT 主鍵是 8 bytes,CHAR(36) 的 UUID 在 utf8mb4 下是 144 bytes。有五個二級索引,這個代價就付五次。這是「UUID 當主鍵」最容易被低估的一筆帳。
03 / 10
「VARCHAR 只按實際長度儲存,所以宣告長一點沒差」——這句話錯在哪?
A 完全正確,沒有問題
B 記憶體暫存表按宣告的最大長度配置空間、索引長度受限、且跨過 255 bytes 界線的加長需要重建整張表
C VARCHAR 其實是定長儲存
D VARCHAR 不能建索引
解析
資料頁裡確實只佔實際長度,但排序與暫存表會按宣告長度算每列需要多少空間——十個 VARCHAR(255) utf8mb4 欄位就是每列 10KB,本來能在記憶體完成的操作因此落磁碟。另外長度前綴在 256 bytes 處會從 1 byte 跳成 2 bytes。
04 / 10
關於 NULL,下列何者正確?
A NULL 值無法進入索引,WHERE col IS NULL 一定走全表掃描
B NULL 可以進索引、IS NULL 也能走索引;真正要小心的是三值邏輯的語意陷阱與優化器估算變差
C 每個可為 NULL 的欄位會多佔 1 byte
D NULL 會導致該欄位的索引失效
解析
「有 NULL 就不走索引」是從 Oracle 傳過來的誤解。InnoDB 的儲存成本是每欄位 1 bit(null bitmap),可忽略。真正的問題是 NOT IN 遇到 NULL 回傳空集合、col != 'x' 撈不到 NULL 列、COUNT(col) 忽略 NULL——都不報錯只給錯答案。
05 / 10
反範式(把欄位冗餘過來避免 JOIN)最適合什麼樣的資料?
A 經常變動的資料,例如庫存與餘額
B 幾乎不變的資料,例如商家名稱、商品分類名
C 所有需要顯示在列表頁的資料
D 資料量最大的那張表的欄位
解析
反範式買到的是少一次 JOIN,以及「可以在冗餘欄位上建索引與排序」這個單靠 JOIN 拿不到的能力;付出的是每一條寫入路徑都要記得同步、還要寫對帳排程。來源幾乎不變時同步成本趨近於零;經常變動的資料則會讓你維護兩份真相。
06 / 10
把 TEXT 大欄位拆到附屬表,主要買到什麼?
A 節省磁碟空間
B 主表每一頁裝得下更多列,於是範圍查詢與全表掃描都變快——即使原本就沒有 SELECT 那個大欄位
C 讓 TEXT 欄位可以建索引
D 避免 NULL 值
解析
InnoDB 頁是 16KB,列越寬每頁裝的列數越少,同樣的查詢就要讀更多頁。把成本從「常做的操作」(列表頁一次看幾十筆)搬到「少做的操作」(詳情頁一次看一筆),這是這類優化的通用形式。
07 / 10
把 VARCHAR(60) 改成 VARCHAR(100)(utf8mb4),為什麼會觸發 COPY 演算法鎖表?
A 因為所有 MODIFY COLUMN 都是 COPY
B 因為 60×4=240 bytes 用 1 byte 長度前綴,100×4=400 bytes 需要 2 bytes 前綴——每列實體格式改變,必須逐列重寫
C 因為 VARCHAR 不支援 INPLACE
D 因為表上有索引
解析
「VARCHAR 加長是 INPLACE」的但書是「只要不跨過 255 bytes 界線」。在 utf8mb4 下這條線落在 VARCHAR(63)/(64) 之間。所以 60→100 要重建整張表,而 100→200 反而是瞬間完成——看起來更小的變更觸發了更重的演算法。
08 / 10
所有線上 DDL 都應該明確寫上 ALGORITHM=INPLACE, LOCK=NONE,為什麼?
A 因為這樣執行速度比較快
B 因為不支援時 MySQL 會立刻報 ERROR 1846,而不是安靜地降級成 COPY 開始鎖表
C 因為這是語法要求
D 因為這樣可以跳過 MDL
解析
預設行為是「自己挑一個能用的演算法」,所以你可能在毫不知情下觸發最貴的那種。明確宣告之後,不支援的變更會在 10 毫秒內報錯,工程師當場知道要改用 gh-ost——而不是在 40 分鐘的鎖表中途才發現。這一招能擋掉九成 DDL 事故。
09 / 10
一個 COPY 演算法的 ALTER 為什麼會讓「連 SELECT 都掛掉」,甚至拖垮無關的 API?
A 因為 ALTER 會鎖住整個實例
B 因為 MDL 佇列是 FIFO:排在 ALTER 後面的新查詢(含 SELECT)全部排隊,請求塞住後佔滿連線池,無關的 API 也拿不到連線
C 因為 COPY 會消耗所有磁碟 I/O
D 因為查詢快取被清空
解析
真正的災難不是「一張表被鎖 40 分鐘」,而是「一張表的 DDL 讓整個服務掛掉」。這是 ch05 那條外溢路徑:MDL 佇列 FIFO → 請求堆積 → 連線池耗盡 → 全站故障。防禦是設定較短的 lock_wait_timeout,讓 DDL 等不到就放棄。
10 / 10
在應用程式與資料庫無法同時更新的前提下,刪除一個欄位的正確做法是什麼?
A 在同一次上線裡同時部署新程式並 DROP COLUMN
B 先部署「不再使用該欄位」的程式,觀察一段時間,之後才真的 DROP
C 先 DROP COLUMN,再部署新程式
D 用 RENAME 把欄位改名即可
解析
每個 schema 變更都要能與新舊兩版程式碼共存。同一次上線同時改程式和刪欄位,會讓「程式碼回滾」變成不可能——舊版程式還會存取那個欄位。加欄位同理要先加後用、最後才補 NOT NULL 約束;改名則等於加新的+雙寫+遷移+刪舊的四步。

點擊卡片翻面查看答案,共 13 張。

QUESTION
為什麼主鍵的取值方式會影響寫入效能?
點擊翻面
ANSWER
InnoDB 的表本身就是主鍵索引,資料按主鍵順序實際存放。自增=永遠插在最後一頁尾端(順序寫);隨機 UUID=插入位置隨機,該頁滿了就要頁分裂,把資料搬一半到新頁。
點擊翻回
QUESTION
隨機 UUID 當主鍵的四個連鎖後果?
點擊翻面
ANSWER
①寫入放大(一次 INSERT 變成多次頁讀寫)②頁利用率掉到 5~6 成 ③每個二級索引都變胖(葉節點存主鍵值,CHAR(36) 是 144 bytes vs BIGINT 8 bytes)④插入位置隨機使 Buffer Pool 命中率下降。
點擊翻回
QUESTION
業務上真的需要 UUID 時怎麼做?
點擊翻面
ANSWER
①用 BINARY(16) 不要用 CHAR(36) ②用有序 UUID(UUID_TO_BIN(x, 1) 交換時間位元、UUIDv7、ULID)讓值大致遞增 ③或內部主鍵用自增、對外另給一個唯一隨機碼(最乾淨)。
點擊翻回
QUESTION
「VARCHAR 宣告長一點沒差」錯在哪三點?
點擊翻面
ANSWER
①記憶體暫存表與排序按宣告的最大長度配置空間,可能讓操作落磁碟 ②索引鍵長度受限(單鍵上限 3072 bytes)③跨過 255 bytes 界線時長度前綴從 1 變 2 bytes,加長要重建整張表。
點擊翻回
QUESTION
NULL 真正該小心的是什麼?(不是儲存成本)
點擊翻面
ANSWER
①三值邏輯:NOT IN 遇 NULL 回傳空集合、col != 'x' 撈不到 NULL 列、COUNT(col) 忽略 NULL ②分佈極度傾斜讓優化器估算變差 ③key_len 多 1 byte。儲存成本只有 1 bit,可忽略。
點擊翻回
QUESTION
NULL 的正確使用原則是什麼?
點擊翻面
ANSWER
不是「禁用 NULL」,而是「不要用 NULL 表達『沒有值』以外的意思」。它的語意是不知道/不適用;用來表示數量 0 或預設狀態就是自找語意問題。而 deleted_at 這種真的不適用的情況,NULL 比魔術值好得多。
點擊翻回
QUESTION
反範式買到什麼、付出什麼?適合哪種資料?
點擊翻面
ANSWER
買到少一次 JOIN,以及能在冗餘欄位上建索引與排序(JOIN 拿不到的能力);付出每一條寫入路徑的同步責任與對帳成本。適合幾乎不變的資料(商家名稱),不適合經常變動的(庫存、餘額)。
點擊翻回
QUESTION
把 TEXT/JSON 大欄位拆到附屬表,主要效果是什麼?
點擊翻面
ANSWER
主表列變窄 → 每頁裝得下更多列 → 範圍查詢與全表掃描快一個數量級,即使原本就沒 SELECT 那個大欄位。代價是取詳情多一次查詢——把成本從常做的操作搬到少做的操作。
點擊翻回
QUESTION
MySQL 8.0 三種 DDL 演算法各自的特性?
點擊翻面
ANSWER
INSTANT:只改資料字典、瞬間完成(尾端加欄位、改預設值)。INPLACE:原表重建、允許併發 DML,但頭尾需短暫 exclusive MDL(加索引、改欄位名)。COPY:複製整張表、阻塞寫入(改型別、改 collation、跨界線的 VARCHAR 加長)。
點擊翻回
QUESTION
為什麼 VARCHAR(60) → VARCHAR(100) 要重建整張表,而 100 → 200 卻是瞬間的?
點擊翻面
ANSWER
utf8mb4 下 60×4=240 bytes(長度前綴 1 byte),100×4=400 bytes(前綴 2 bytes)——跨過 255 bytes 界線,每列實體格式改變必須逐列重寫。而 100→200 兩者都在 2 bytes 前綴區間,不用改格式。
點擊翻回
QUESTION
線上 DDL 最重要的一個防禦習慣是什麼?
點擊翻面
ANSWER
一律明確寫 ALGORITHM=INPLACE, LOCK=NONE。不支援時會立刻報 ERROR 1846,而不是安靜降級成 COPY 開始鎖表——10 毫秒就知道要改用 gh-ost,而不是在 40 分鐘鎖表中途才發現。
點擊翻回
QUESTION
gh-ost / pt-online-schema-change 的原理與代價?
點擊翻面
ANSWER
建影子表 → 分批複製(可限速可暫停)→ 用 binlog 或觸發器同步新寫入 → 追平後原子換名(只鎖毫秒)。代價:需一倍磁碟空間、整體耗時更長、外鍵支援不好。把不可控的鎖表換成可控的背景作業。
點擊翻回
QUESTION
為什麼 schema 的問題最難被發現?
點擊翻面
ANSWER
它沒有「這句 SQL 慢」的訊號——代價分散在每一次寫入、每個索引、每頁的利用率上,沒有任何慢查詢日誌會指向它。只會表現成「這個系統整體就是比較慢」,要靠定期檢視表與索引大小主動發現。
點擊翻回