MySQLテーブルパーティショニング:大規模データベースの高パフォーマンスを実現するRANGE、LIST、HASH

Database tutorial - IT technology blog
Database tutorial - IT technology blog

MySQLテーブルがパフォーマンスのボトルネックになるとき

データベースを使ったアプリケーションは、ある時点で必ず限界を迎えます。ミリ秒単位で動いていたクエリが、数秒かかるようになります。ordersテーブルには5000万行のレコードがあり、インデックスも設定済みなのに、MySQLは必要なデータを探すために膨大なデータをスキャンし続けます。

原因はほぼ決まって同じです。すべてのデータが1つのフラットなモノリシックテーブルに格納されているのです。インデックスがあっても、データセットが大きくなるにつれてクエリプランナーの負担は増します。テーブルパーティショニングを使うと、MySQLはそのデータを物理的に小さなチャンクに分割できます。そして、テーブル全体ではなく関連するチャンクだけをクエリするようになります。

これは複数サーバーへのシャーディングとは異なります。パーティショニングは単一のMySQLインスタンス内で行われ、アプリケーションコードを変更する必要はありません。同じ方法でテーブルにクエリを実行するだけで、MySQLがどのパーティションを参照するかを判断します。

背景と理由:パーティショニングが実際に行うこと

MySQLは各パーティションをディスク上の独立したセグメントとして保存します。パーティションキーに対してフィルタを使ったクエリを実行すると、MySQLはパーティションプルーニングを適用します。つまり、目的のデータが含まれる可能性がないパーティションをスキップします。12ヶ月分のパーティションがあり、1月のレコードを検索する場合、MySQLは12個すべてをスキャンするのではなく、1月のパーティションだけを読み取ります。

数字で見ると興味深いことがわかります。12ヶ月のパーティションに分割された5億行のテーブルでは、以前は5億行をスキャンしていた日付範囲クエリが、約4200万行だけを参照するようになります。同じインデックス、同じクエリ構文で、パフォーマンスが劇的に向上します。

パーティショニングが特に威力を発揮するのは、データのクリーンアップです。昨年のデータを削除したい場合、テーブルを何時間もロックする低速なDELETE FROM orders WHERE year = 2022の代わりに、ALTER TABLE orders DROP PARTITION p2022を実行します。ディスクからファイルを削除するだけなので、即座に完了します。

3種類のパーティションタイプがほとんどのユースケースをカバーします:

  • RANGE — 連続した範囲内に収まるカラム値に基づいて行を割り当てます(時系列データに最も一般的)
  • LIST — 離散的な値のセットに一致するカラム値に基づいて行を割り当てます(地域、ステータスコード、カテゴリなどに適している)
  • HASH — ハッシュ関数を使用してN個のパーティションに行を均等に分散します(自然な範囲境界がない場合の負荷分散に適している)

インストール:パーティションサポートの確認

パーティショニングはMySQL 5.6以降に組み込まれており、8.0や8.4を含む現在のすべてのバージョンで使用できます。別途インストールは不要です。有効かどうかを確認するには:

SHOW PLUGINS WHERE Name = 'partition';

partition | ACTIVE | STORAGE ENGINEと表示されるはずです。MySQL 8.0以降では、パーティショニングは常にコンパイルされています。

事前に知っておくべき重要な制約があります:すべての一意インデックス(主キーを含む)にはパーティションカラムを含める必要があります。これはMySQLの絶対的な要件です。シンプルな自動インクリメントの主キーがある場合、パーティションカラムを複合主キーに組み込むか、開始前にキーの設計を見直す必要があります。

また、5.7と8.0ではシンタックスの違いがあるため、MySQLのバージョンも確認してください:

SELECT VERSION();

設定:3種類のパーティションタイプの実装

RANGEパーティショニング — 日付範囲処理の定番

ほとんどのチームはRANGEパーティショニングから始めます。特にログ、注文、イベントなどの時系列テーブルに対してよく使われます。連続した値の範囲を定義し、MySQLが各行を対応するパーティションに割り当てます。

CREATE TABLE orders (
    order_id    INT NOT NULL,
    customer_id INT NOT NULL,
    order_date  DATE NOT NULL,
    amount      DECIMAL(10,2),
    PRIMARY KEY (order_id, order_date)
)
PARTITION BY RANGE (YEAR(order_date)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

MAXVALUEのキャッチオールパーティションは省略できません。指定しない場合、定義された範囲外の行はMySQLに拒否されます。つまり、新年の1月1日に最初の行を挿入するとエラーになります。

年が変わる前に新しいパーティションを追加するには:

ALTER TABLE orders ADD PARTITION (
    PARTITION p2026 VALUES LESS THAN (2027)
);

古いデータの削除は、RANGEパーティショニングの最大の特長です:

-- 2022年のデータをすべて即座に削除(テーブルスキャンなし、ロックなし)
ALTER TABLE orders DROP PARTITION p2022;

既存のテーブルをパーティション構造に移行する場合、一般的な手順はCSVとしてデータをエクスポートし、新しいスキーマにインポートし直すことです。エクスポートしたデータをすばやく確認するために、toolcraft.app/ja/tools/data/csv-to-jsonをブラウザのタブで開いたままにしています。区切り文字を自動検出してCSVをJSONに変換し、すべてブラウザ内で処理するため、データが外部に送信されません。エクスポートに注文記録や顧客データが含まれる場合、これは非常に重要な点です。

LISTパーティショニング — 固定された値セットがある場合

LISTパーティショニングは、事前定義されたリストに対する完全一致に基づいて行を割り当てます。典型的な用途は、地域ごとにデータをまとめたいマルチリージョンシステムやステータス管理です。

CREATE TABLE customers (
    customer_id INT NOT NULL,
    region      VARCHAR(10) NOT NULL,
    name        VARCHAR(100),
    email       VARCHAR(255),
    PRIMARY KEY (customer_id, region)
)
PARTITION BY LIST COLUMNS (region) (
    PARTITION p_asia     VALUES IN ('JP', 'KR', 'CN', 'SG', 'TH'),
    PARTITION p_europe   VALUES IN ('DE', 'FR', 'GB', 'NL', 'IT'),
    PARTITION p_americas VALUES IN ('US', 'CA', 'BR', 'MX')
);

単純なLISTではなくLIST COLUMNSを使っていることに注目してください。COLUMNSバリアントは文字列値を直接受け付けます。単純なLISTは整数式のみに対応しており、ISO国コードには不便です。

重要な注意点があります:パーティションリストに含まれていない地域値を持つ行を挿入しようとすると、MySQLはエラーをスローします。値のカバレッジは事前に計画してください。値セットがアプリケーションで完全に制御されていない場合は、本番環境での予期しないエラーを避けるためにキャッチオールパーティションを追加してください。

パーティションの再構成は、データを失わずに後から行うことができます:

-- p_americasを2つの独立したパーティションに分割
ALTER TABLE customers REORGANIZE PARTITION p_americas INTO (
    PARTITION p_north_america VALUES IN ('US', 'CA', 'MX'),
    PARTITION p_south_america VALUES IN ('BR', 'AR', 'CL', 'CO')
);

HASHパーティショニング — ビジネスロジックなしの均等分散

HASHパーティショニングでは、範囲や値セットを定義する必要がありません。MySQLにカラムとパーティション数を指定すると、モジュロ演算を使って行を均等に分散します。自然な範囲やカテゴリの境界はないが、一貫した負荷分散が必要なテーブルに最適です。

CREATE TABLE user_sessions (
    session_id  VARCHAR(64) NOT NULL,
    user_id     INT NOT NULL,
    created_at  DATETIME NOT NULL,
    payload     JSON,
    PRIMARY KEY (session_id, user_id)
)
PARTITION BY HASH (user_id)
PARTITIONS 8;

各行はuser_id MOD 8のパーティション番号に格納されます。ユーザーIDが適度に分散している限り、分散は均等に保たれます。連番の自動インクリメントIDでもうまく機能します。

ただし注意点があります:HASHパーティショニングはRANGEのようなクエリのプルーニングには役立ちません。正確なハッシュキー値でフィルタリングしない限り、MySQLはすべてのパーティションをスキャンします。HASHを使うのは、選択的なスキャンではなく、並列I/Oとストレージの分散が目的の場合です。

より動的なワークロードに対しては、MySQLはLINEAR HASHも提供しています。2の累乗のアルゴリズムを使用しており、パーティションの追加や削除が速くなりますが、分散の均一性はやや低下します。

検証とモニタリング:パーティショニングが実際に効果を発揮しているかの確認

パーティション構造と行分散の確認

パーティションテーブルを作成した後、最初に確認すべきはinformation_schema.PARTITIONSです:

SELECT 
    PARTITION_NAME,
    TABLE_ROWS,
    FORMAT(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
    FORMAT(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'your_db'
  AND TABLE_NAME = 'orders'
ORDER BY PARTITION_ORDINAL_POSITION;

このクエリはパーティションごとの行数とストレージサイズを表示します。HASH/LISTパーティションの不均等な分散を検出したり、テーブルの成長に伴って各期間にどれだけのデータが蓄積されているかを確認するために使います。

EXPLAINによるパーティションプルーニングの確認

RANGEパーティショニングのパフォーマンス上のメリット全体は、プルーニングが正しく機能しているかどうかにかかっています。確認方法:

EXPLAIN 
SELECT * FROM orders 
WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31';

EXPLAINの出力のpartitions列を確認してください。p2024だけが表示されていれば、プルーニングは機能しています。すべてのパーティションが表示されている場合は、何かがプルーニングを妨げています。多くの場合、パーティションカラムを関数で囲んでいることが原因です。

これは最もよくあるプルーニングの落とし穴です:

-- NG:カラムを関数で囲むとプルーニングが無効になる — MySQLはすべてのパーティションをスキャン
WHERE DATE_FORMAT(order_date, '%Y') = '2024'

-- OK:カラムの直接比較でプルーニングが有効 — MySQLはp2024のみを読み取る
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

パーティション設計でのタイムスタンプの扱い

ログテーブルではエポックベースのRANGEパーティションがよく使われます。Unixタイムスタンプ形式で正しいVALUES LESS THANの境界値を計算する必要がある場合、toolcraft.app/ja/tools/datetime/timestamp-converterを使って人間が読める日付とUnixタイムスタンプの間で変換しています。複数のタイムゾーンに対応しており、データが複数のリージョンにまたがっており、パーティション境界をローカル時間ではなくUTC午前0時に合わせる必要がある場合に特に便利です。

-- Unixタイムスタンプカラムに対するRANGEパーティショニング
CREATE TABLE access_logs (
    log_id      BIGINT NOT NULL AUTO_INCREMENT,
    user_id     INT NOT NULL,
    logged_at   INT UNSIGNED NOT NULL,  -- Unixタイムスタンプ
    path        VARCHAR(500),
    PRIMARY KEY (log_id, logged_at)
)
PARTITION BY RANGE (logged_at) (
    PARTITION p_2024_q1 VALUES LESS THAN (1711929600),  -- 2024-04-01 00:00:00 UTC
    PARTITION p_2024_q2 VALUES LESS THAN (1719792000),  -- 2024-07-01 00:00:00 UTC
    PARTITION p_2024_q3 VALUES LESS THAN (1727740800),  -- 2024-10-01 00:00:00 UTC
    PARTITION p_2024_q4 VALUES LESS THAN (1735689600),  -- 2025-01-01 00:00:00 UTC
    PARTITION p_future  VALUES LESS THAN MAXVALUE
);

パーティショニングが適さない場合

パーティショニングが常に正しい答えとは限りません。数百万行以下のテーブルでは通常メリットはなく、パーティションメタデータと計画のオーバーヘッドによってクエリが実際に遅くなることもあります。全文インデックス、外部キー制約、一部の空間インデックスタイプはパーティションテーブルでは機能しません。プルーニングできないクエリ(パーティションカラム以外でフィルタリングする場合)は、パーティショニングなしのテーブルよりもわずかに遅くなる可能性もあります。

パーティショニングが効果的なのは、テーブルが数千万行以上になっており、最も一般的なクエリが自然なパーティションキーになるカラム(通常は日付や固定カテゴリのカラム)でフィルタリングしており、アーカイブや古いレコードの削除などのライフサイクル管理ニーズがある場合です。この3つが揃えば、パーティショニングは追加インデックスよりもほぼ確実に優れたパフォーマンスを発揮します。そしてアプリケーションコードを1行も変更せずに実現できます。

Share: