前面五章處理的都是「查詢怎麼跑」。但有一類成本,在建表的那一刻就已經決定了,之後不管怎麼調索引、怎麼改 SQL 都繞不開:
主鍵用自增還是 UUID、金額用
DECIMAL還是DOUBLE、狀態欄位允不允許NULL、備註欄位要不要跟主表放在一起——這些決定會一路影響寫入速度、索引大小、Buffer Pool 命中率,最後甚至決定「改一個欄位要不要停機」。
而 schema 的問題有一個殘酷的性質:它是唯一一種「發現得越晚,修起來越貴」的問題。索引可以隨時加、SQL 可以隨時改,但改一個型別可能要重建整張表——而那張表現在有兩億列。
這一章要拆的是:
VARCHAR(255) 真的「不佔空間」嗎?TIMESTAMP 與 DATETIME 差在哪?INSTANT/INPLACE/COPY 三種演算法,以及怎麼在下指令之前就知道會不會鎖表。VARCHAR(60) 改成 VARCHAR(100)——一個看起來最無害的變更,鎖住整張表 40 分鐘。這一章與 ch05 是連著的:DDL 的災難幾乎都不是 DDL 本身慢,而是它拿不到 MDL、然後擋住後面所有人。 讀這章之前,ch05 那個 MDL 佇列 FIFO 的機制要先記得。
先回憶 backend ch01 的那個事實:在 InnoDB 裡,表本身就是主鍵索引(聚簇索引),資料列按主鍵順序實際存放在頁裡。
這件事推出一個很多人沒想過的結論:主鍵的取值方式,決定了資料是「順著寫」還是「隨機插」。
新資料的主鍵永遠最大 → 永遠插在最後一頁的尾端
→ 那一頁寫滿了就開新的一頁
→ 頁利用率接近 100%,幾乎不需要搬動既有資料新資料的主鍵是隨機值 → 該插在哪一頁完全隨機
→ 那一頁如果滿了,必須「頁分裂」:
把原本那頁的資料搬一半到新頁,再插入
→ 一次寫入變成:讀舊頁 + 寫兩頁 + 更新父節點INSERT,變成多次實體的頁讀寫。BIGINT 就是 8 bytes;是 CHAR(36) 的 UUID 字串就是 144 bytes(utf8mb4)。有五個二級索引,這個代價就付五次。需求通常是「id 不要能被猜到」或「要在寫入資料庫之前就有 id」。這兩個需求都可以滿足,而且不必付上面的帳:
BINARY(16) 存,不要用 CHAR(36):36 個字元的字串換成 16 bytes 的二進位,省下 8 成空間。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 加上長度前綴。宣告長度不影響磁碟上的實際佔用。
VARCHAR(255) 的 utf8mb4 欄位=1020 bytes,十個這種欄位就是每列 10KB——於是本來能在記憶體完成的操作,改成落磁碟。VARCHAR(255) 在 utf8mb4 下就是 1020 bytes,複合索引很容易撞到上限。ALTER 必須重建整張表——這正是本章那個失敗場景。| 選擇 | 差別 | 怎麼決定 |
|---|---|---|
| INT vs BIGINT | 4 vs 8 bytes;乘以列數,再乘以每個二級索引 | 會超過 21 億就用 BIGINT,不要為了省 4 bytes 冒溢位風險 |
| DECIMAL vs DOUBLE | DOUBLE 是二進位浮點數,0.1 + 0.2 ≠ 0.3 | 金額一律 DECIMAL,沒有例外 |
| DATETIME vs TIMESTAMP | 5 vs 4 bytes;TIMESTAMP 會做時區轉換,且 2038 年溢位 | 存「事件發生的絕對時間」用 DATETIME;需要跟著時區跑再考慮 TIMESTAMP |
| ENUM vs TINYINT vs VARCHAR | ENUM 省空間但增刪值要 ALTER;VARCHAR 每列重複存字串 | 狀態碼用 TINYINT + 應用層列舉,最好維護 |
| TEXT/BLOB | 超長時存在 off-page,另外讀一次 | 不要跟熱欄位放同一張表(見下一節) |
「欄位不要允許 NULL,會影響效能」是流傳很廣的說法。它有道理,但理由常常被講錯。
NULL 的欄位佔 1 個 bit。16 個這種欄位才多 2 bytes。NULL 值可以進索引(與 Oracle 不同),WHERE col IS NULL 也可以走索引。這點常被誤傳成「有 NULL 就不走索引」。NOT IN (子查詢) 只要結果含一個 NULL,整句回傳空集合;col != 'x' 不會撈到 col IS NULL 的列;COUNT(col) 會忽略 NULL 而 COUNT(*) 不會。這些都不報錯,只是安靜地給你錯的答案。NULL 的分佈通常極度傾斜(例如 99% 的列 deleted_at 都是 NULL),而這正是 ch02 說的「均勻分佈假設」失效的場景。NULL 的語意是「不知道/不適用」。用它來表示「數量是 0」「狀態是預設值」就是在自找語意問題。而真的表示「不適用」時(例如 deleted_at),NULL 是正確且清楚的選擇——比用 '1970-01-01' 這種魔術值好得多。ch03 提過「與其優化那個 JOIN,不如把欄位冗餘過來」。這一節把這筆帳算清楚——這是「代價守恆」最赤裸的一次。
InnoDB 的頁是 16KB。一張表如果有 TEXT 型態的備註、JSON 設定、序列化內容,會有兩個效應:
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)——但詳情頁一次只看一筆,而列表頁一次要看幾十筆。把成本從「常做的操作」搬到「少做的操作」,這是這類優化的通用形式。
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 | 會 |
| 改字元集/collation | COPY | 會 |
| 加/改主鍵 | COPY | 會 |
當變更無法避開 COPY,而表又大到不能鎖,就用 gh-ost 或 pt-online-schema-change:
代價:需要一倍的額外磁碟空間、整體耗時更長(幾小時)、外鍵支援不好。但它把「不可控的 40 分鐘鎖表」換成了「可控的幾小時背景作業」,在正式環境幾乎永遠是對的取捨。
需求很小:會員暱稱欄位不夠長,從 VARCHAR(60) 改成 VARCHAR(100)。工程師確認過「VARCHAR 加長是 INPLACE,不會鎖表」,於是排在週三下午上線。
ALTER TABLE members MODIFY COLUMN nickname VARCHAR(100) NOT NULL;SELECT 也是。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
-- ...(幾百個)兩件事同時發生了,而且互相加乘。
utf8mb4,每字元最多 4 bytes。VARCHAR(60) = 最多 240 bytes → 小於 256 → 長度前綴只需要 1 byte。VARCHAR(100) = 最多 400 bytes → 大於等於 256 → 長度前綴要 2 bytes。VARCHAR(63) 與 VARCHAR(64) 之間,是一個非常容易在毫無察覺中跨過的位置。60 改 100 看起來比 100 改 200 更無害,實際上前者要重建整張表,後者是瞬間完成。如果只是 COPY,那是「這張表 40 分鐘不能寫」。但實際上連 SELECT 都掛了,原因是 ch05 那個機制:
ALTER 持有/等待 MDL 寫鎖。ALTER 後面的每一個新查詢,即使只是讀,也要排隊。所以真正的災難不是「一張表被鎖 40 分鐘」,是「一張表的 DDL 讓整個服務掛掉」。
KILL 88; 中止 ALTER。(注意:COPY 到一半被 kill 要回滾,中止本身也需要時間。)ALGORITHM=INPLACE, LOCK=NONE。這個變更會在 10 毫秒內報 ERROR 1846,工程師當場就知道要換 gh-ost,而不是在 40 分鐘的鎖表中途才發現。lock_wait_timeout = 5 讓 DDL 等不到就放棄,而不是無限排隊擋住所有人。把前面幾節收成一套可以直接照著跑的流程。schema 變更與查詢優化最大的差別是:它幾乎不能回滾(改回去一樣要重建),所以流程比技巧更重要。
ALGORITHM=INPLACE, LOCK=NONE 執行一次,看它報不報 ERROR 1846。這一步只要 10 毫秒,卻能擋掉九成的 DDL 事故。SELECT table_rows, data_length/1024/1024 AS mb FROM information_schema.tables WHERE table_name='...'。超過幾 GB 就直接走 gh-ost,不要賭。information_schema.innodb_trx 裡有沒有開很久的交易。有的話先處理它。lock_wait_timeout:讓 DDL 等不到 MDL 就放棄,避免它排隊擋住整個服務。應用程式與資料庫不會同時更新,所以每個 schema 變更都要能與新舊兩版程式碼同時共存:
NULL 或有預設值)→ 部署會寫入它的程式 → 回填舊資料 → 最後才加 NOT NULL 約束。DROP。不要在同一次上線裡同時改程式和刪欄位,否則回滾程式碼時會直接爆炸。RENAME 解決。BIGINT 改 INT 省下的空間,通常遠不值得那次 COPY 的風險。ALGORITHM=INPLACE, LOCK=NONE:不支援時它會立刻報錯,而不是安靜地開始鎖表。剩下兩章要把前面六章串起來:ch07 是「線上變慢了,從哪裡開始查」的完整流程;ch08 則回到 Java 這邊,看 ORM 到底發出了什麼 SQL——因為前面所有的分析,前提都是「SQL 真的長你以為的那樣」。
UUID_TO_BIN(x, 1)) → 解決了頁分裂,但仍是 16 bytes(自增 BIGINT 是 8),每個二級索引還是比較胖;而且值不再是「完全隨機」,時間資訊可以被還原出來。ALTER,複本也要花 40 分鐘——期間複本延遲會一路累積。如果有讀寫分離,那段時間讀到的都是舊資料(ch07 會談複本延遲)。VARCHAR 縮短會截斷資料;collation 變更會改變排序與比較結果('a' = 'A' 在某些定序下成立、某些不成立),甚至讓原本沒有衝突的唯一索引突然有衝突。| 做法 | 寫入 | 空間與二級索引 | 適用 |
|---|---|---|---|
| 自增 BIGINT | 順序寫入,頁利用率接近 100% | 8 bytes,最省 | 預設選擇;但高併發插入時最後一頁是熱點 |
| 隨機 UUID(CHAR(36)) | 頁分裂、寫入放大、快取命中率降 | 144 bytes,每個二級索引都變胖 | 不要用 |
| 有序 UUID(BINARY(16) + UUID_TO_BIN(x,1)) | 大致遞增,無頁分裂 | 16 bytes | 需要在寫入前就有 id、或 id 不可預測時 |
| 內部自增 + 對外隨機碼 | 順序寫入 | 8 bytes + 一個唯一索引 | 最乾淨:兩個目標分開達成 |
| 操作 | 演算法 | 阻塞寫入 | 大表怎麼做 |
|---|---|---|---|
| 尾端加欄位、改預設值 | INSTANT | 不會(瞬間) | 直接做 |
| 加/刪二級索引、改欄位名 | INPLACE | 不會,但頭尾需短暫 MDL | 直接做,但先查長交易 |
| VARCHAR 加長(不跨 255 bytes) | INPLACE | 不會 | 直接做 |
| VARCHAR 加長(跨過 255 bytes) | COPY | 會 | gh-ost —— 這是最容易誤判的一格 |
| 改型別(INT→BIGINT)、改 collation | COPY | 會 | gh-ost |
| 加/改主鍵 | COPY | 會 | gh-ost,且要特別小心 |
| 場景 | 選它 | 不要選 | 理由 |
|---|---|---|---|
| 金額 | DECIMAL | DOUBLE / FLOAT | 二進位浮點數無法精確表示小數 |
| 事件時間 | DATETIME | TIMESTAMP(除非要跟時區跑) | TIMESTAMP 有時區轉換與 2038 溢位 |
| 狀態碼 | TINYINT + 應用層列舉 | ENUM | ENUM 增刪值要 ALTER 整張表 |
| 短字串 | 剛好夠用的 VARCHAR | 一律 VARCHAR(255) | 記憶體暫存表按宣告長度配置,且可能跨過 255 bytes 界線 |
| 備註/JSON/序列化內容 | 拆到附屬表 | 跟熱欄位放同一張表 | 列變寬 → 每頁裝的列數變少 → 範圍查詢全部變慢 |
點擊卡片翻面查看答案,共 13 張。