MySQL 資料庫分區完全指南:RANGE、LIST、HASH 全解析

Read this article in English →

MySQL 資料庫分區完全指南:RANGE、LIST、HASH 全解析

一張存了三年份訂單紀錄的表,累積到兩億多行,每天凌晨的清理排程要 DELETE FROM orders WHERE created_at < '2023-01-01',結果鎖表鎖了四十分鐘,binlog 灌爆磁碟——這是資料庫分區(Partitioning)最經典的登場時機。這篇文章會把 MySQL 分區從「是什麼」講到「什麼時候不該用」,把官方文件裡容易被忽略的限制條件講清楚,同時簡單帶到 PostgreSQL 等資料庫的做法差異。

分區到底在解決什麼問題

MySQL 官方文件對分區的定義很直接:這是一種水平分區(horizontal partitioning)機制,把同一張表的不同「列」依照規則分配到不同的實體分區,每個分區在檔案系統層面其實是獨立儲存的區塊,但邏輯上仍然是同一張表,SQL 語法完全不用改。要注意的是,MySQL 目前不支援垂直分區(把不同欄位拆到不同儲存位置),坊間常把兩者混為一談,這是第一個該釐清的觀念。

分區真正的價值在於三件事:第一,讓單一張表能存放超過單一磁碟容量的資料量;第二,讓「刪除舊資料」變得極快——與其對兩億行執行 DELETE,直接對整個分區下 ALTER TABLE ... DROP PARTITION,本質上是刪掉底層檔案,幾乎是瞬間完成,不會產生龐大的 undo log 與 binlog;第三,也是最常被提及的,分區裁剪(Partition Pruning)——當查詢的 WHERE 條件命中分區鍵時,MySQL 會直接排除掉不可能符合條件的分區,只掃描真正相關的那幾塊,等於免費拿到一層索引級的優化。

儲存引擎限制:不是每種表都能分區

從 MySQL 8.0 開始,只有 InnoDB 與 NDB 兩種儲存引擎支援分區,MyISAM、MERGE、CSV、FEDERATED 等舊式引擎全部不支援。同一張分區表底下的所有分區,也必須使用同一種儲存引擎,不能一個分區用 InnoDB、另一個用別的引擎。如果你的專案還停留在較舊版本、部分表格用的是 MyISAM,第一步得先確認能不能轉成 InnoDB,這是動手做分區前最容易被忽略的前置條件。

四種核心分區類型

RANGE 分區:時間序列資料的預設選擇

RANGE 分區依照欄位值的區間切分資料,最典型的應用就是按日期切月份或年份:

CREATE TABLE orders (
    id INT NOT NULL,
    created_at DATE NOT NULL,
    amount DECIMAL(10,2),
    PRIMARY KEY (id, created_at)
)
PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p2026 VALUES LESS THAN (2027),
    PARTITION pmax VALUES LESS THAN MAXVALUE
);

日誌表、訂單表、交易紀錄這類「愈新愈常被查、愈舊愈該被清掉」的資料,幾乎是 RANGE 分區的教科書場景。另外還有一個變體叫 RANGE COLUMNS,允許直接用多個欄位、非整數型別(例如 DATE、VARCHAR)來定義區間,不必像基本 RANGE 分區那樣得先用函式把值轉成整數,彈性更高。

LIST 分區:離散值分類

LIST 分區邏輯跟 RANGE 很像,差別在於它依據的不是連續區間,而是一組明確列出的離散值,適合像「依地區代碼」「依店鋪 ID」這種有限、已知集合的分類情境。同樣也有 LIST COLUMNS 變體支援多欄位與非整數型別。

HASH 與 KEY 分區:均勻打散資料

當你要的不是「查詢優化」而是「把資料平均打散、避免單一分區過熱」時,HASH 分區會依照你指定的運算式(例如 HASH(id))計算雜湊值決定分區。KEY 分區則更省事,你只需要指定欄位,實際的雜湊函式由 MySQL 內部提供,不用自己寫運算式。兩者都各有 LINEAR HASH/LINEAR KEY 的線性變體,優點是新增或減少分區數量時,重新分配資料的成本較低,代價是資料分布不如非線性版本均勻。

Subpartitioning:分區之下再分區

MySQL 也支援在既有分區底下再做一層子分區(例如先用 RANGE 依年份分區,每個年份分區內再用 HASH 依使用者 ID 二次切分),適合資料量極大、單一維度分區仍嫌太粗的場景,但複雜度也會明顯上升,多數專案其實用不到這一層。

MySQL 沒有 Oracle 的 INTERVAL:分區要怎麼跟著時間自動長出來

Oracle 提供 INTERVAL 分區語法,只要有新月份的資料寫進來,資料庫會自動幫你建立新分區;但 MySQL 原生完全沒有這個機制——RANGE 分區的邊界必須事先存在,資料一旦落在任何既有分區範圍之外,INSERT 會直接失敗噴錯,不會自動生成分區。這代表用 RANGE 依日期分區的表,勢必得有一套「持續往後補分區」的維運計畫,否則遲早會撞到「找不到對應分區」的插入失敗。實務上常見三種做法:

做法一:循環式分區(12 個固定分區,最省事但有取捨)

用 MONTH() 函式把資料依 1~12 月分進 12 個固定分區,設定一次之後理論上永遠不用再管:

CREATE TABLE failed_jobs (
    id BIGINT NOT NULL AUTO_INCREMENT,
    failed_at DATETIME NOT NULL,
    payload LONGTEXT,
    PRIMARY KEY (id, failed_at)
)
PARTITION BY HASH(MONTH(failed_at))
PARTITIONS 12;

但這個做法有個容易被忽略的代價:2025 年 8 月跟 2026 年 8 月的資料,會被分進同一個分區,因為分區依據的只是「月份」這個週期性數字,年份完全沒被考慮進去。如果你的目的是想用 DROP PARTITION 快速砍掉「兩年前的資料」,這個做法完全達不到,因為同一分區裡永遠混著新舊好幾年的資料。它比較適合單純想把資料打散、不打算靠分區做保留期限管理的場景。

做法二:預先建好未來幾年的分區

建表時一次把未來 5~10 年的月份分區都寫好,最後留一個 MAXVALUE 分區當防呆:

CREATE TABLE failed_jobs (
    id BIGINT NOT NULL AUTO_INCREMENT,
    failed_at DATETIME NOT NULL,
    PRIMARY KEY (id, failed_at)
)
PARTITION BY RANGE COLUMNS(failed_at) (
    PARTITION p202601 VALUES LESS THAN ('2026-02-01'),
    PARTITION p202602 VALUES LESS THAN ('2026-03-01'),
    -- 中間依此類推寫到未來年份
    PARTITION p203012 VALUES LESS THAN ('2031-01-01'),
    PARTITION p_future VALUES LESS THAN (MAXVALUE)
);

這個做法不需要額外排程,設定一次能穩定撐好幾年;缺點是年限到了還是得回頭手動維護,而且因為多留了一個 p_future 的 MAXVALUE 分區,未來如果想改用「做法三」的自動化排程去新增分區,得留意 MySQL 不允許直接對已經有 MAXVALUE 的分區表用 ADD PARTITION 疊加新分區——這麼做會讓表壞掉、無法查詢,正確做法是改用 ALTER TABLE ... REORGANIZE PARTITION p_future INTO (...),把 MAXVALUE 分區重新切成「新分區+新的 MAXVALUE 分區」。

做法三:MySQL Event 自動化排程(最正規,但要留意兩個前提)

用預存程序搭配 MySQL 內建的 EVENT 排程器,讓資料庫每個月自動生出下個月的分區:

DELIMITER //
CREATE PROCEDURE auto_create_next_month_partition()
BEGIN
    DECLARE next_month_first_day VARCHAR(10);
    DECLARE partition_name VARCHAR(20);

    SET next_month_first_day = DATE_FORMAT(NOW() + INTERVAL 2 MONTH, '%Y-%m-01');
    SET partition_name = CONCAT('p', DATE_FORMAT(NOW() + INTERVAL 1 MONTH, '%Y%m'));

    SET @sql = CONCAT('ALTER TABLE failed_jobs ADD PARTITION (PARTITION ', partition_name, ' VALUES LESS THAN (\'', next_month_first_day, '\'))');
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
END //
DELIMITER ;

SET GLOBAL event_scheduler = ON;

CREATE EVENT IF NOT EXISTS evt_auto_partition
ON SCHEDULE EVERY 1 MONTH STARTS '2026-08-25 00:00:00'
DO
  CALL auto_create_next_month_partition();

這是全自動化的做法,每個月都有獨立分區,未來要清舊資料直接 DROP PARTITION 秒刪。但有兩個前提容易被忽略:第一,開啟 event_scheduler 這個全域變數需要 SUPER 權限,建立 EVENT 本身則需要獨立的 EVENT 權限,兩者都得先跟 DBA 或雲端資料庫服務商確認是否開放——不少代管的雲端 MySQL 服務預設會限制或關閉事件排程器。第二,上面這支預存程序用的是 ADD PARTITION,前提是這張表沒有 MAXVALUE 這種防呆分區;如果表定義裡已經有 MAXVALUE 分區(像做法二那樣),就得把 ADD PARTITION 改成 REORGANIZE PARTITION,否則排程一跑就會讓表壞掉。另外,如果排程曾經中斷、漏掉了某個月份沒建立分區,之後那個月份的資料寫入會直接失敗,建議額外設一個監控排程,定期檢查最新分區的邊界是否還「夠用」。

該選哪一種

資料量中等、不急著自動化的話,做法二最省事;資料量夠大、確實需要定期用 DROP PARTITION 清理舊資料的場景,做法三架構最完整,但務必先確認資料庫權限,並用不含 MAXVALUE 的表定義來搭配自動新增分區的排程,兩者要一起考慮,不要只抄語法卻沒對齊表結構。

分區裁剪怎麼運作,又怎麼失效

分區裁剪能不能生效,關鍵在於查詢的 WHERE 條件是否包含分區鍵。以前面的 orders 範例來說,WHERE created_at >= '2026-01-01' 這種查詢能讓 MySQL 直接跳過 p2024、p2025 兩個分區;但如果查詢條件完全沒提到 created_at,例如 WHERE amount > 1000,MySQL 就得掃過每一個分區才能給出正確結果——這時分區不但沒有加速查詢,反而因為要開啟、檢查更多個檔案而增加額外開銷。判斷分區裁剪有沒有生效,可以在查詢前面加上 EXPLAIN,觀察輸出裡的 partitions 欄位實際列出了幾個分區。

MySQL 也支援顯式分區選擇語法,直接告訴伺服器只查特定分區:

SELECT * FROM orders PARTITION (p2026) WHERE amount > 1000;

這個語法同樣適用於 DELETE、UPDATE、INSERT、REPLACE 等資料操作語句,在你明確知道資料落在哪個分區時,能省下伺服器自行判斷的成本。

兩個最容易踩的坑

第一個坑:唯一鍵限制。 MySQL 規定,分區運算式用到的欄位,必須出現在這張表每一個唯一鍵(含主鍵)裡。也就是說,如果一張表同時有 PRIMARY KEY(id) 跟 UNIQUE KEY(email),你幾乎不可能用 id 以外的欄位做分區鍵,除非把 email 的唯一約束拿掉或想辦法把分區欄位塞進所有唯一鍵。這條限制在改造舊系統時經常直接卡死整個分區計畫,務必在動手前先盤點表上所有的唯一鍵。

第二個坑:外鍵完全不支援。 任何使用者自訂分區的 InnoDB 表,既不能被其他表的外鍵參照,自己也不能定義外鍵去參照別的表。如果你的資料庫大量仰賴外鍵做參照完整性檢查,導入分區前得先想清楚要在應用層自己補這一段邏輯,還是接受拿掉外鍵約束。

除了這兩個硬限制,實務上還有一個常被低估的問題:分區數量不是愈多愈好。每個分區在檔案系統層面都是獨立檔案,分區數一多,開啟檔案、維護中繼資料的開銷會逐漸疊加,一般建議控制在幾十到一兩百個分區內,超過這個數量級之前,通常代表分區維度或粒度需要重新設計,而不是繼續加下去。

跟其他資料庫比一比

其他主流資料庫也都有分區機制,但設計哲學略有不同,這裡簡單帶過:

  • PostgreSQL:自 10 版起支援宣告式分區(Declarative Partitioning),概念與 MySQL 大同小異,同樣有 RANGE、LIST、HASH 等分區方式;差異在於 PostgreSQL 把「掛載/卸載分區」(ATTACH PARTITION / DETACH PARTITION)設計成純粹的中繼資料操作,不需要搬動實際資料,這在超大型資料表的維運上通常比 MySQL 更輕量、更有彈性。
  • Oracle Database 與 SQL Server:兩者的分區功能歷史更久,支援全域索引(Global Index,索引可以跨分區維護,不像 MySQL 索引預設是分區內的區域索引)等進階特性,功能完整度普遍被認為優於 MySQL,但相對地授權成本也高上不少,多數中小型專案不會單純為了分區功能去換資料庫。

該不該用分區:一個簡單的判斷原則

分區不是萬靈丹,也不是用來解決「單台資料庫撐不住流量」的工具——它解決的是單一資料表在儲存、查詢、清理上的規模問題,寫入吞吐量與伺服器負載瓶頸,該用的還是讀寫分離、快取或分庫分表(sharding)等其他手段處理。真正該考慮上分區的訊號通常是:表的資料量已經大到單次全表操作(備份、DELETE、ALTER)明顯拖慢維運節奏,而且查詢模式高度集中在某個可預期的欄位(最常見就是時間),此時分區才能真正發揮「裁剪換效能、DROP 換清理速度」的價值;反之,如果查詢條件五花八門、無法穩定命中某個分區鍵,貿然導入分區,很可能只是把一張表拆成好幾個小檔案,卻拿不到任何實質好處。

關於作者

我是 Ryan,RyanOps 的站主。平日的工作是軟體開發與自動化,在這裡整理 AI 模型、開發工具與軟體工程的重要變化,也記錄自己實際除錯、實作過的技術筆記。

關於本站與編輯流程 →