なんとなく使っている MySQL の中身を、徹底的に解説する
この記事は初心者から中級のエンジニアを、本番運用に耐えるレベルまで一気に引き上げる徹底解説です。全 57 セクション・15 グループにわたって、MySQL の基礎・アーキテクチャ・InnoDB 内部・MVCC・Redo Log・インデックス・実行計画・レプリケーション・バックアップ・セキュリティ・MySQL 独自機能・8.0 の革新・クラウド運用・エコシステムまでを、図解とインタラクティブツール、そして実際のコマンド例とともに扱います。
MySQL の世界では、「初歩の入門教材」と「すでにベテラン向けの濃すぎる解説」は豊富にあるものの、その間を埋める中級レベルの教材がほとんど存在しません。入門書を読み終えた後、本番運用に出ていく前に何を学べばいいのか分からない — そういう空白を埋めるための記事です。
対象バージョンは MySQL 8.0 と 8.4 LTS です。5.7 は EOL 済みなので、本記事では「廃止された機能」「過去の挙動」として軽く触れる程度に留めます。MariaDB / Percona Server などの派生フォークは扱わず、Oracle MySQL 公式版に絞って解説します。
全 15 グループの構成です。基礎から順に積み上げていきますが、興味のあるグループから読み始めても OK です。無料と有料の境目も合わせて記載しています。
この章ではまず、MySQL を本格的に深掘りする前に押さえておきたい基礎をおさらいします。「MySQL とは何か」「どんな立ち位置の DB か」「どんな階層構造で、どんなデータ型があり、どうやって操作するか」「トランザクションとは」といった共通言語をここで揃え、次章以降の内部解剖に備えます。すでに知っている内容は読み飛ばしてもらって構いません。
MySQL(マイエスキューエル) は、1995 年にスウェーデンの MySQL AB によって生まれたオープンソースのリレーショナルデータベース管理システムです。Sun Microsystems を経て現在はOracle Corporation が所有・開発しており、Web サービスの DB としては世界で最も広く使われています。LAMP (Linux + Apache + MySQL + PHP) スタックの「M」として 2000 年代に爆発的に普及し、その遺産が今も大きな採用シェアを生み出しています。
MySQL の最大の特徴は、SQL を解釈する「上の階」と、実際にデータを読み書きする「下の階 (ストレージエンジン)」が分離されていることです。同じ SQL でもエンジンを差し替えれば挙動が変わるという、他の RDBMS にない設計です。8.x 時代の事実上の標準はInnoDB で、本記事もこれを前提に解説します。
まずは MySQL が他の RDBMS とどう違うのか、業界での立ち位置から見ていきます。
| DB | 分類 | 強み | 弱み |
|---|---|---|---|
MySQL | OSS (Oracle 所有) | シェア No.1・運用ナレッジが豊富・シンプル・速い読み取り | 機能セットは小ぶり・ストレージエンジン選択で挙動が変わる |
PostgreSQL | OSS / Object-Relational | 機能の豊富さ・標準 SQL 準拠・拡張性 | スキーマレス用途では NoSQL に劣る |
Oracle DB | 商用 (高額) | エンタープライズ機能・サポート・成熟度 | コスト・複雑さ・ベンダーロックイン |
SQLite | OSS / 組込み | 組込み・軽量・ファイル 1 つ | 同時書込が苦手・大規模不向き |
MySQL が選ばれる場面は明確です。「Web スケール・読み取り中心・運用ナレッジの蓄積」が 重要なシステム —— EC・SNS・CMS・SaaS のバックエンドなど。歴史的に Web 業界の事実上の標準であり、AWS RDS / Aurora MySQL・GCP Cloud SQL のメイン選択肢でもあります。
ここから先を読み進めるにあたって、MySQL の「データの入れ物の階層」を最初に押さえます。MySQL は PostgreSQL とは階層の数が違います。PostgreSQL は Cluster → Database → Schema → Table の 4 層ですが、MySQL は3 層です。さらに「Database = Schema」として扱われるのが MySQL の特徴です。
各階層の意味は次の通りです。
/var/lib/mysql/) に全データベースが格納される。CREATE DATABASE と CREATE SCHEMA は完全に同じ意味。接続時に USE myapp; で 1 つを選ぶ。別 DB のテーブルは db.table で参照可能。mysql (権限・ユーザー)・performance_schema (メトリクス)・information_schema (メタデータ)・sys (運用ビュー)。CREATE SCHEMA = CREATE DATABASE です。ドキュメントで両用語が混在するのはこのためです。MySQL が他の RDBMS と決定的に違うのが「テーブルごとにストレージエンジンを選べる」点です。SQL を解釈する SQL Layer と、実際のデータ I/O を担う Storage Engine が Handler API で分離されており、エンジンを差し替えるだけで挙動・トランザクション対応・ロック粒度が変わります。
MySQL のアクセス制御は「ユーザー名 + 接続元ホスト」の組で識別されます。PostgreSQL の「ロール」とは設計思想が違い、同じユーザー名でも接続元ホストが違えば別アカウントとして扱われるのが MySQL の独特な点です。
-- ① ユーザー作成 ('user'@'host' の形)
CREATE USER 'alice'@'%' IDENTIFIED BY 'xxx';
-- ↑ どこからでも接続可能な alice
CREATE USER 'alice'@'10.0.0.%' IDENTIFIED BY 'xxx';
-- ↑ 10.0.0.0/24 からのみ接続可能な alice
-- (同じ「alice」でも上とは別のアカウント)
-- ② 権限付与
GRANT SELECT, INSERT, UPDATE ON myapp.* TO 'alice'@'%';
-- ③ Role (MySQL 8.0+) — 権限の束をまとめる
CREATE ROLE 'app_writer';
GRANT SELECT, INSERT, UPDATE ON myapp.* TO 'app_writer';
GRANT 'app_writer' TO 'alice'@'%';
SET DEFAULT ROLE 'app_writer' TO 'alice'@'%';MySQL は SQL 標準型に加え、独自の型を多数持っています。日常的に使う主要なものを分類別にまとめました。
TINYINT1 byte (-128 〜 127)。フラグ用途SMALLINT2 byte (-32768 〜 32767)INT / INTEGER4 byte (約 ±21 億)。最頻出BIGINT8 byte。サロゲートキーは普通こちらDECIMAL(p, s) / NUMERIC可変精度。金額計算は必ずこれ (float は誤差)FLOAT / DOUBLE4 / 8 byte の浮動小数点。誤差ありVARCHAR(n)n 文字までの可変長。本番標準。n は文字数 (byte ではない)CHAR(n)固定長 (足りない分を空白埋め)。ほぼ使わないTEXT / MEDIUMTEXT / LONGTEXT64 KB / 16 MB / 4 GB まで。本文用BINARY(n) / VARBINARY(n)バイナリ列 (byte 数指定)BLOB / MEDIUMBLOB / LONGBLOBバイナリ可変長 (画像など)DATETIME日付+時刻。TZ なし。値をそのまま保存・取出。1000〜9999 年TIMESTAMP日付+時刻。UTC で保存・取出時にセッション TZ に変換。1970〜2038 年 (2038 年問題に注意)DATE日付のみTIME時刻 / 経過時間 (-838:59:59 〜 838:59:59)YEAR年 (2 byte)BOOLEAN / BOOL実体は TINYINT(1) のエイリアス。true / false も使えるBIT(n)n ビットのビット列JSONバイナリ JSON。インデックス可 (Generated Column 経由)。5.7 以降の標準ENUM(...)列挙型 (1 つ選ぶ)。スキーマ変更が面倒なので注意SET(...)列挙型 (複数選択可)。あまり使わないGEOMETRY / POINT / POLYGON地理空間データ (SRID 対応・R-Tree インデックス可)MySQL の文字コード回りは歴史的に最も事故が多いトピックです。「utf8」と「utf8mb4」の違いを正しく理解していないと、本番投入後に絵文字が入らない・四文字漢字 (𠮷野家など) が化ける、という事故を起こします。
collation (照合順序) は文字列の比較・ソート規則です。デフォルトは utf8mb4_0900_ai_ci (8.0+) で、大文字小文字とアクセントを区別しません。区別したい場合は utf8mb4_0900_as_cs (Accent-Sensitive Case-Sensitive) などを使います。
-- ① 推奨: utf8mb4 + 大小無視の collation CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -- ② テーブル単位で指定する場合 CREATE TABLE messages ( id BIGINT PRIMARY KEY, body TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- ③ サーバ全体のデフォルトを確認 SHOW VARIABLES LIKE 'character_set_%'; SHOW VARIABLES LIKE 'collation_%';
SHOW CREATE TABLE で文字コード・collation を必ず確認。アプリ接続側の SET NAMES utf8mb4 を忘れると、せっかく utf8mb4 のテーブルでもクライアントとの間で文字化けします。mysql は MySQL の標準対話クライアントです (PostgreSQL の psql に相当)。SQL 実行に加え、独自のメタコマンド (バックスラッシュコマンド) と SHOW 文で DB の状態を素早く確認できます。
# === 接続方法 === # 個別オプション mysql -h db.example.com -P 3306 -u alice -p myapp # ↑ ポート (デフォルト 3306) # Unix ソケット (ローカル) mysql -u root -p # 環境変数 export MYSQL_PWD=xxx mysql -h db.example.com -u alice myapp # === よく使うメタコマンド === \? -- ヘルプ \h -- 同上 \q -- 終了 \c -- 入力中の SQL をキャンセル \g -- ; の代わり (実行) \G -- 縦表示で実行 (1 カラム 1 行) # === SHOW 文 (PostgreSQL のメタコマンドに相当) === SHOW DATABASES; -- DB 一覧 (= \l in psql) USE myapp; -- DB 切替 (= \c in psql) SHOW TABLES; -- 現 DB のテーブル一覧 (= \dt) SHOW TABLES FROM warehouse; -- 別 DB のテーブル一覧 DESC users; -- カラム一覧 (= \d in psql) DESCRIBE users; -- 同上 SHOW CREATE TABLE users; -- CREATE 文を再構成 SHOW INDEX FROM users; -- インデックス一覧 SHOW PROCESSLIST; -- 現在の接続一覧 (= pg_stat_activity) SHOW STATUS LIKE 'Threads%'; -- ステータス変数 SHOW VARIABLES LIKE 'innodb%'; -- 設定変数 # === 設定 === \T /tmp/out.txt -- 出力をファイルへ (tee) \. -- ファイル実行 (= source) source schema.sql # === エクスポート === SELECT * FROM users INTO OUTFILE '/tmp/users.csv' FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\n';
覚えるべき必須コマンド: SHOW TABLES・DESC table・SHOW CREATE TABLE・SHOW PROCESSLIST・SHOW VARIABLES・\G (縦表示)。これだけで本番調査の 80% が回ります。
アプリ / ドライバが MySQL に接続するときの接続文字列 (URI) の書き方を整理します。「同じ DB なのにアプリによって接続方法が違う」の混乱を防ぐ知識です。
# ① mysql:// (Classic Protocol — port 3306) mysql://alice:xxx@db.example.com:3306/myapp?ssl-mode=REQUIRED # ② mysqlx:// (X Protocol — port 33060) mysqlx://alice:xxx@db.example.com:33060/myapp # ③ JDBC (Connector/J) jdbc:mysql://db.example.com:3306/myapp?\ useSSL=true&\ serverTimezone=UTC&\ characterEncoding=utf8mb4&\ autoReconnect=false&\ useServerPrepStmts=true # ④ Python (mysql-connector-python) mysql+mysqlconnector://alice:xxx@db.example.com:3306/myapp # ⑤ Go (go-sql-driver/mysql) alice:xxx@tcp(db.example.com:3306)/myapp?parseTime=true&loc=UTC # ⑥ Unix socket (ローカル / RDS Proxy 等) mysql://alice:xxx@localhost/myapp?unix_socket=/var/run/mysqld/mysqld.sock # ⑦ クエリパラメータでよく指定するもの # ssl-mode=REQUIRED / VERIFY_CA / VERIFY_IDENTITY # charset=utf8mb4 / characterEncoding=utf8mb4 # timezone=UTC / serverTimezone=UTC # connectTimeout=10000 (10秒) # socketTimeout=300000 (5分・クエリの最大実行時間)
大量接続のアプリで 「接続が急に遅くなる」 問題の 9 割は次の 2 つで解決します。
# ① skip_name_resolve — DNS 逆引きをスキップ # アプリが接続するたびに mysqld が DNS 逆引きでホスト名を取りに行く # DNS が遅いと接続が数秒詰まる → 本番では必須 [mysqld] skip-name-resolve = ON # ↑ ON にすると 'user'@'host' の host 部はホスト名ではなく IP のみで判定される # → mysql.user の host 列は IP / IP マスクで定義する必要 # ② back_log — accept キューの深さ # バースト的に大量接続が来たときに mysqld が accept しきれない # OS の listen() backlog を超えて新規接続が ECONNREFUSED back_log = 4096 # デフォルト 80 〜 901 (max_connections+50) # 確認 SHOW VARIABLES LIKE 'skip_name_resolve'; SHOW VARIABLES LIKE 'back_log'; # ③ bind_address — どの IF で listen するか # デフォルト 8.0 → * (全 IF) # 特定 IP のみで listen したいなら指定 bind_address = 0.0.0.0 # 全 IF bind_address = 10.0.0.5 # 特定 IF
SHOW PROCESSLIST で State 欄に login や checking permissions が長く出ているなら DNS 逆引き疑い。skip_name_resolve=ON を真っ先に確認。SQL コマンドは大きく 4 種類に分類されます。これからの章でも頻出する用語なので、ここで整理しておきます。
CREATE TABLEテーブルを作るALTER TABLEテーブル定義を変更 (列追加・型変更・index 追加など)。8.0 は Atomic DDLDROP TABLEテーブルを削除するCREATE INDEXインデックスを作るCREATE / DROP DATABASEDB を作る・削除するTRUNCATE TABLE全行高速削除 (DDL 扱い、ロールバック不可)SELECTデータを取得する。最頻出INSERT行を追加するUPDATE行を更新するDELETE行を削除するLOAD DATA INFILE大量データを高速取込 (PostgreSQL の COPY に相当)REPLACE / INSERT ... ON DUPLICATE KEY UPDATEUPSERT (存在すれば更新)BEGIN / START TRANSACTIONトランザクション開始COMMIT変更を確定ROLLBACK変更を取り消しSAVEPOINT / RELEASE SAVEPOINTトランザクション内に戻り点を作るSET TRANSACTION分離レベル等を設定GRANT権限を付与REVOKE権限を剥奪CREATE / DROP USERユーザー追加・削除CREATE / DROP ROLERole 追加・削除 (8.0+)トランザクションは、複数の SQL 文を「全部成功するか、全部なかったことにする」 1 つの単位として扱う仕組みです。本番運用で最も基本的かつ重要な概念のひとつ。MySQL ではInnoDB エンジンでのみトランザクションが使えます。MyISAM はトランザクション非対応です。
InnoDB のトランザクションは、ACID の 4 性質を満たします。
基本的な書き方は次の通りです。
-- ① 普通のトランザクション START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 'alice'; UPDATE accounts SET balance = balance + 100 WHERE id = 'bob'; COMMIT; -- 両方成功してから確定。途中エラーなら何も変わらない -- ② 失敗時の取消 START TRANSACTION; UPDATE orders SET status = 'shipped' WHERE id = 1; -- ↑ ここでエラー or やっぱり取消したい ROLLBACK; -- → 何も変わらなかったことになる -- ③ 自動コミット (デフォルト挙動) UPDATE accounts SET balance = balance - 100 WHERE id = 'alice'; -- ↑ これ単体で 1 トランザクションとして即コミット -- (autocommit=1 がデフォルト) -- 自動コミットを切ると、COMMIT を打つまで確定しない SET autocommit = 0; UPDATE ...; -- まだコミットされていない COMMIT; -- ここで確定 -- ④ 安全な実験 (本番で危険な UPDATE を試したい時) START TRANSACTION; UPDATE users SET deleted_at = NOW() WHERE created_at < '2020-01-01'; SELECT COUNT(*) FROM users WHERE deleted_at IS NOT NULL; -- ↑ 影響件数を確認 ROLLBACK; -- → 結果だけ見て、本当は何もしなかったことにできる
TRUNCATE TABLE ・ ALTER TABLE ・ CREATE TABLE などの DDL は、内部で暗黙コミットされます。トランザクション中に DDL を混ぜると、それまでの DML が予期せず確定するので注意。8.0 でAtomic DDL が入りましたが、トランザクション境界の挙動は変わっていません。SELECT 文を投げてから結果が返ってくるまで、MySQL は内部で 5 つのステージ を順に通過しています。Parse → Resolve → Optimize → Execute → Output の流れです。それぞれが何をやっているかをざっくり押さえておくと、後の章 (EXPLAIN・Optimizer・実行プラン) が一気に理解しやすくなります。
ここでは全体の流れを俯瞰します。詳細は G4 (Section 15〜17) で深掘りします。
SELECT * FROM users WHERE id=1 → ASTYou have an error in your SQL syntax)Unknown column 'X' in field list や権限不足はここSELECT SQL_CACHE もquery_cache_type も無効です。MySQL のエラーコードは「どのステージで起きたか」を教えてくれます。エラーを見たら、まずどのステージで止まったかを判別すると原因究明が速い。
ER_PARSE_ERROR (1064)① ParseER_BAD_FIELD_ERROR (1054)② ResolveER_NO_SUCH_TABLE (1146)② ResolveER_TABLEACCESS_DENIED_ERROR (1142)② ResolveER_LOCK_WAIT_TIMEOUT (1205)④ ExecuteER_LOCK_DEADLOCK (1213)④ ExecuteER_DUP_ENTRY (1062)④ ExecuteER_NO_REFERENCED_ROW (1452)④ ExecuteER_NET_PACKET_TOO_LARGE (1153)⑤ SendER_CON_COUNT_ERROR (1040)接続MySQL の内部アーキテクチャを最初に俯瞰します。SQL Layer (上の階) と Storage Engine Layer (下の階) が Handler API で分離された二層構造 ── これが MySQL を他の RDBMS と決定的に分ける設計です。
この章では、まず全体図でどんな部品が動いているかを把握し、次章以降 (mysqld・Backend Thread・Background Thread・メモリ・設定) で各部品を深掘りしていきます。
二層構造の最大のメリットは、SQL Layer を変えずに「データの保存方法だけ」を差し替えられることです。同じ SELECT * FROM users WHERE id=1 でも、テーブルに付けたエンジンによって挙動が変わります。
| 観点 | InnoDB | MyISAM | MEMORY |
|---|---|---|---|
| トランザクション | ✓ ACID 対応 | ✗ 非対応 | ✗ 非対応 |
| ロック粒度 | 行レベル (Row Lock) | テーブルレベルのみ | テーブルレベル |
| 外部キー | ✓ | ✗ | ✗ |
| インデックス | Clustered B+Tree | B-Tree + Heap | Hash / B-Tree |
| クラッシュリカバリ | ✓ Redo + Undo | ✗ 手動修復 | データ消失 |
| 永続化 | ディスク | ディスク | メモリのみ |
| 用途 | 本番標準 | レガシー / 読み専用 | 一時テーブル / 集計中間 |
一つの SELECT 文が投げられたとき、各層で何が起きるかを順に見てみます。これを把握しておくと、Section 16 で扱う EXPLAIN の出力が「どこのステージで何を計画しているか」として読めるようになります。
SELECT name, email FROM users WHERE id = 42;
│
┌─────────────────────────────┴──────────────────────────────┐
│ ① Connection Layer │
│ 接続スレッドが受信。認証済みであることを確認。 │
│ → SQL 文字列をパーサーに渡す │
├──────────────────────────────────────────────────────────────┤
│ ② SQL Layer — Parser │
│ 字句解析 + 構文解析 → パースツリー (AST) │
├──────────────────────────────────────────────────────────────┤
│ ③ SQL Layer — Resolver │
│ "users" → 実テーブルにバインド │
│ 権限チェック (alice は users を読めるか?) │
├──────────────────────────────────────────────────────────────┤
│ ④ SQL Layer — Optimizer │
│ index 候補を列挙: PRIMARY (id) ← これが最良 │
│ コスト計算: PK 直引きが最安 → eq_ref │
│ → 実行プラン完成 (EXPLAIN で見えるのはここ) │
├──────────────────────────────────────────────────────────────┤
│ ⑤ SQL Layer — Executor │
│ Handler API を呼ぶ: │
│ handler->index_read(buf, key=42, ...) │
├──────────────────────────────────────────────────────────────┤
│ ⑥ Storage Engine Layer — InnoDB │
│ B+Tree の root から id=42 を二分探索 │
│ Buffer Pool に該当ページがあれば即返却 (HIT) │
│ なければディスクから 16KB Page を読込 → Buffer Pool に乗せる │
│ → 行データを SQL Layer に返す │
├──────────────────────────────────────────────────────────────┤
│ ⑦ SQL Layer — Executor (続き) │
│ 返ってきた行から name, email を投影 │
│ 結果セットを組み立てる │
├──────────────────────────────────────────────────────────────┤
│ ⑧ Connection Layer │
│ 結果セットをネットワーク経由でクライアントへ送信 │
│ 完了ステータスを返す │
└──────────────────────────────────────────────────────────────┘上記アーキテクチャの「📀 Disk」に置かれるファイルを整理します。データディレクトリ (デフォルト /var/lib/mysql/) には次のファイル群があります。
ibdata1*.ibd (例: users.ibd)innodb_file_per_table=ON (デフォルト) で 1 テーブル = 1 ファイル。データと secondary index が同居。ib_logfile0, ib_logfile1innodb_log_file_size で制御。undo_001, undo_002binlog.000001, binlog.000002, ...mysql-slow.loglong_query_time 秒以上かかった SQL を記録 (Section 28)。mysql-error.logmysql.sockSHOW VARIABLES LIKE 'datadir'; で現在のデータディレクトリが分かります。SHOW VARIABLES LIKE 'innodb_data_file_path'; で ibdata1 のサイズと autoextend 設定を確認できます。MySQL のサーバ実体は mysqld という 1 プロセスです。PostgreSQL のように「接続ごとに新しいプロセス」が立ち上がるのではなく、mysqld の中に「接続ごとのスレッド」が走るモデルです。これをThread per Connection方式と呼びます。
この方式は 軽量(プロセス作成より速い)・メモリ共有が容易(プロセス間通信が不要) という利点があります。一方で、1 スレッドが暴走すると全体に影響しやすい、という弱点もあります。
thread_cache_size1 つのクライアントが接続してクエリを投げて切断するまでのライフサイクルを順に見ます。各ステップが SHOW PROCESSLIST の State 欄に対応しているので、本番調査で何が起きているかが読めるようになります。
Step 1 — TCP 接続 App → mysqld:3306 への TCP connect() ↓ Step 2 — Thread 割り当て Listener が accept() → Thread Cache から取り出す or 新規 spawn ↓ Step 3 — 認証 State: "Login" Client が user/password を送信 → mysqld が caching_sha2_password で照合 失敗 → 接続拒否、成功 → セッション確立 ↓ Step 4 — Idle (待機) State: "Sleep" 接続は確立しているが、まだクエリを受け取っていない状態 ↓ Step 5 — Query 受信 State: "Query" → "Reading from net" クライアントから SQL 文字列を受信 ↓ Step 6 — Parse / Resolve State: "starting" → "checking permissions" ↓ Step 7 — Optimize / Execute State: "Sending data" / "Sorting result" / "Locked" など Optimizer がプラン作成 → Executor が InnoDB へ ↓ Step 8 — 結果送信 State: "Sending data" ↓ Step 9 — 完了 State: "Sleep" に戻る ↓ (もう一度 Step 5 へ — 接続を使い回す) (接続終了) Step 10 — Disconnect mysql_close() → スレッドは Thread Cache に戻る (or 終了)
performance_schema.events_statements_current)。mysqld が同時に保持できる接続数の上限は max_connections で決まります (デフォルト 151)。これを超えると新規接続は Too many connections エラーで弾かれます。
単に大きくすれば良いというものではなく、1 接続あたりのメモリ消費 × max_connections がサーバの実メモリを圧迫します。本番設計の式は以下:
物理メモリ
≥ innodb_buffer_pool_size # ① Global バッファ
+ その他 Global バッファ (Log/Change 等) # ② Global バッファ
+ max_connections × per-thread メモリ # ③ Session バッファ
+ OS / その他
per-thread メモリ ≈
sort_buffer_size
+ join_buffer_size
+ read_buffer_size
+ read_rnd_buffer_size
+ thread_stack
+ tmp_table_size (上限)
+ binlog_cache_size
+ net_buffer_length
≒ 1〜4 MB が典型 (デフォルト)
例: max_connections=500, per-thread=4 MB → Session 合計 2 GB
Buffer Pool 12 GB を使うなら、物理 16 GB は最低欲しい本番で max_connections を闇雲に上げるのは禁忌です。アプリ側でコネクションプール (HikariCP / ProxySQL / MySQL Router) を組み、サーバ側は適正な上限を保つのが原則。詳細は Section 38 で扱います。
max_connectionsthread_cache_sizewait_timeout1 接続 = 1 スレッドの方式は、接続数が数千を超えるとコンテキストスイッチコストが無視できなくなります。これを解決するのが Thread Pool です。
1 つの接続スレッドが、SQL を受け取ってから結果を返すまでに内部でどんな段階を踏んでいるかを追います。Parser → Resolver → Optimizer → Executor → Handler → Storage Engine という流れと、それぞれが触るデータ構造を順に見ていきます。
この章のゴールは「EXPLAIN がどのステージの結果を表示しているのか」「本番でクエリが遅いとき、どのステージの問題なのか」を分けて考えられるようになることです。
You have an error in your SQL syntax 系のエラーはここSHOW STATUS の Slow_queries には現れない (実質ゼロ)Unknown column / SELECT command denied はここevents_statements_history で計測可能Iterator::Read() を実装する設計。NestedLoop / Hash / Sort / Filter / Aggregate のノードが連なって木構造になる。index_init / index_read / index_next / rnd_init / rnd_next / write_row / update_row / delete_row / external_lock。ha_innodb::index_read にブレークポイントを置いて観察 (開発時)SHOW ENGINE INNODB STATUS / Performance Schema (Section 27)SELECT 文がどの段階で何 ms 消費するか、各ステージで何が入力されて何が出力されるかを、1 ステージずつ進めて確認してください。
MySQL 8.0 から、Executor は Volcano モデル (Pull-based iterator) で実装し直されました。各実行ノードが Iterator::Read() を実装し、親が子を呼んで 1 行ずつ取り出す設計です。EXPLAIN FORMAT=TREE の出力はこの iterator 木をそのまま表示しています。
-- 例: orders と users を JOIN して集計
EXPLAIN FORMAT=TREE
SELECT u.name, COUNT(*)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.country = 'JP'
GROUP BY u.id;
-> Group aggregate: count(0) ← Aggregate Iterator
-> Nested loop inner join ← NestedLoop Iterator
-> Filter: (u.country = 'JP') ← Filter Iterator
-> Index lookup on u using
idx_country (country='JP') ← Index Lookup Iterator (InnoDB)
-> Index lookup on o using
idx_user_id (user_id=u.id) ← Index Lookup Iterator (InnoDB)
# 読み方:
# 1. 一番下から: u (users) を idx_country で 1 行ずつ取り出す
# 2. その u それぞれについて o (orders) を idx_user_id で引く (Nested Loop)
# 3. 上の Filter / Aggregate に流していくEXPLAIN FORMAT=TREE は 8.0.16+ から使えます。それ以前の古典的な表形式 EXPLAIN より実際の実行順が読みやすいので、新規プロジェクトではこれをデフォルトに。同じ SQL のParser / Resolver / Optimizer を毎回走らせるのは無駄です。MySQL は Prepared Statement でこれを再利用できます。
-- 1 回目: Parse + Optimize して実行プランをキャッシュ PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'; -- 2 回目以降: 既存プランを再利用 (Parse/Optimize スキップ) SET @uid = 42; EXECUTE stmt USING @uid; SET @uid = 99; EXECUTE stmt USING @uid; -- 解放 DEALLOCATE PREPARE stmt;
多くの言語ドライバ (Connector/J ・ mysqli ・ Python の cursor.execute(sql, params) など) は内部で自動的に Prepared Statement を使っています。Web アプリで「同じ SQL を 1 秒に 1,000 回投げる」典型ワークロードでは、これで Parser / Resolver のコストがほぼ消えます。
InnoDB の内部には 常時 14 種類前後のバックグラウンドスレッド が動いています。ユーザーの SQL (Backend Thread) とは別に、Buffer Pool の flush・Redo Log の永続化・MVCC の purge・統計情報更新などを裏で支える役割です。
どれが何をしているか分かると、本番でレイテンシが急に悪化したとき「Page Cleaner が追いついていないのか」「Purge が詰まっているのか」を切り分けて対処できます。
— (常駐 1 本)innodb_page_cleaners (4) / innodb_lru_scan_depthinnodb_purge_threads (4)innodb_purge_threadsinnodb_read_io_threads (4)innodb_write_io_threads (4)innodb_flush_log_at_trx_commit の設定で挙動が変わる。— (常駐 1 本)— (常駐 1 本)innodb_log_writer_threads (ON)— (常駐 1 本)— (常駐 1 本)innodb_lru_scan_depth (1024)innodb_stats_auto_recalc=ON のとき、テーブル変更率が 10% を超えると発火 (Section 27)。innodb_stats_auto_recalc (ON)SHOW ENGINE INNODB STATUS の情報源。innodb_lock_wait_timeout (50)バックグラウンドスレッドの最大の仕事はチェックポイントです。Buffer Pool 上の dirty page を定期的にディスクへ書き戻し、Redo Log の不要部分を捨てる作業 (Section 12 で深掘り)。
innodb_io_capacity で 1 秒あたりの書き出しページ数の上限を制御。innodb_log_file_size を 2GB 〜 4GB)。innodb_io_capacity をストレージ性能 (IOPS) に合わせる (SSD なら 2000〜5000)。詳細は Section 29 で扱います。実際に走っているスレッドは Performance Schema から見られます。
-- ① 全スレッド一覧 (Foreground + Background) SELECT thread_id, name, type, processlist_command, processlist_state FROM performance_schema.threads WHERE type = 'BACKGROUND' ORDER BY name; -- 出力例: -- thread/innodb/io_log_thread -- thread/innodb/io_read_thread -- thread/innodb/page_cleaner_thread -- thread/innodb/purge_coordinator_thread -- thread/innodb/purge_worker_thread (× 3) -- thread/innodb/log_writer_thread -- thread/innodb/log_flusher_thread -- thread/innodb/log_checkpointer_thread -- thread/innodb/srv_master_thread -- ... -- ② SHOW ENGINE INNODB STATUS でも見える SHOW ENGINE INNODB STATUS\G -- BACKGROUND THREAD のセクションを見ると -- srv_master_thread loops: ... 1_second: ... 10_second: ... -- ③ Buffer Pool の dirty page 比率 (page cleaner の負荷の指標) SELECT POOL_ID, DATABASE_PAGES, MODIFIED_DATABASE_PAGES, ROUND(MODIFIED_DATABASE_PAGES * 100 / DATABASE_PAGES, 2) AS dirty_pct FROM information_schema.innodb_buffer_pool_stats;
各スレッドの発火タイミング を 1 つの時間軸に並べると、「DB が裏で何をしているか」が直感的に分かります。
performance_schema.threads と SHOW ENGINE INNODB STATUS で生きた状態を観察。MySQL のメモリは大きく 「Global Buffer (全接続で共有)」 と 「Session Buffer (接続ごと)」 に分かれます。Global は mysqld 起動時に確保され、Session は接続が来るたびに割り当てられます。
この区別が分かると、本番で OOM Killer に殺された 事故の原因究明が一段速くなります (典型: max_connections × per-thread が物理メモリを超える)。
innodb_buffer_pool_sizeinnodb_log_buffer_sizeinnodb_change_buffer_max_size (%)innodb_adaptive_hash_indexinnodb_dict_sizeSession バッファは 接続が来たときに必要に応じて割り当てられる 性質があります (常に max まで使うわけではない)。とはいえ 最悪ケース を見積もるのが本番設計の鉄則です。
# 1 接続あたりの最大メモリ消費 (典型) sort_buffer_size 256 KB + join_buffer_size 256 KB + read_buffer_size 128 KB + read_rnd_buffer_size 256 KB + tmp_table_size 16 MB ← MEMORY 一時テーブルの上限 + binlog_cache_size 32 KB + thread_stack 1 MB + net_buffer_length 16 KB ───────────────────────────── ~ 17.9 MB / 接続 (最悪ケース) # max_connections=500 なら最悪 500 × 17.9 MB = ~9 GB # Global と合わせると 12 GB (Buffer Pool) + 9 GB (Session 最悪) + α = 物理 24 GB 程度欲しい
SET SESSION sort_buffer_size = ... で増やすのが鉄則。実際にどれだけメモリを使っているかは Performance Schema で見られます。
-- ① mysqld 全体のメモリ使用 SELECT SUM(CURRENT_NUMBER_OF_BYTES_USED) / 1024 / 1024 AS used_MB FROM performance_schema.memory_summary_global_by_event_name; -- ② 種類別のメモリ消費 TOP 10 SELECT event_name, ROUND(CURRENT_NUMBER_OF_BYTES_USED / 1024 / 1024, 2) AS used_MB, CURRENT_COUNT_USED AS alloc_count FROM performance_schema.memory_summary_global_by_event_name ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 10; -- ③ Buffer Pool ヒット率 (最重要メトリック) SELECT POOL_ID, DATABASE_PAGES, ROUND((1 - HIT_RATE / 1000) * 100, 4) AS miss_pct FROM information_schema.innodb_buffer_pool_stats; -- HIT_RATE は 1000 = 100% ヒット -- 999 以上が望ましい (= ミス率 0.1% 未満)
5.7+ から Buffer Pool を稼働中に変更できる ようになりました。仕組みは「Buffer Pool を innodb_buffer_pool_chunk_size (デフォルト 128 MB) のチャンク単位 で確保し、resize ではチャンク数を増減する」というもの。
-- ① 現状の Buffer Pool サイズと chunk 設定 SHOW VARIABLES LIKE 'innodb_buffer_pool_size'; -- 12G SHOW VARIABLES LIKE 'innodb_buffer_pool_chunk_size'; -- 128M SHOW VARIABLES LIKE 'innodb_buffer_pool_instances'; -- 8 -- 内部式: size = chunk_size × instances × N -- 12G = 128M × 8 × 12 -- ② 動的に拡張 (mysqld 再起動なし) SET GLOBAL innodb_buffer_pool_size = 16 * 1024 * 1024 * 1024; -- 16G -- ③ Resize の進行状況を見る SHOW STATUS LIKE 'Innodb_buffer_pool_resize_status'; -- "Resizing buffer pool from 12884901888 to 17179869184 (units: 134217728)." -- "Withdrawing blocks: ..." -- "Resizing also other hash tables..." -- "Completed resizing buffer pool at ..." -- ④ サイズ単位を変えたいなら chunk_size を再設定 -- ※ chunk_size は再起動が必要・小さくすると粒度が細かくなる
MySQL の挙動はすべて my.cnf (Windows では my.ini) で制御します。本章では my.cnf の構造・読み込み順序・SET PERSIST による動的永続化までを扱います。8.0 で「再起動が必要な設定変更が大幅に減った」のは運用上の大きな変化です。
mysqld は起動時に複数の設定ファイルを順番に読み込み、後勝ちのルールで上書きします。同じパラメータが複数箇所にあると最後に読まれたものが採用されます。
# Linux での読み込み順 (後勝ち) 1. /etc/my.cnf 2. /etc/mysql/my.cnf 3. SYSCONFDIR/my.cnf 4. $MYSQL_HOME/my.cnf 5. defaults-extra-file (--defaults-extra-file=...) 6. ~/.my.cnf 7. ~/.mylogin.cnf # 確認方法 mysql --help | grep -A1 "Default options" mysqld --verbose --help | grep -A1 "Default options" # include で別ファイルを取り込む !includedir /etc/mysql/conf.d/ # ディレクトリ内の *.cnf を全部 !include /etc/mysql/extra.cnf # 単一ファイル
/etc/mysql/my.cnf + !includedir /etc/mysql/conf.d/ を使い、機能ごとに別ファイル (innodb.cnf / replication.cnf / charset.cnf 等) に分けると、変更の差分管理がやりやすくなります。my.cnf は [mysqld] ・ [client] など、コマンドごとにセクションが分かれています。それぞれそのコマンドが起動時に読みます。
# /etc/mysql/my.cnf — 典型例 [mysqld] # mysqld (サーバ) のオプション datadir = /var/lib/mysql port = 3306 socket = /var/run/mysqld/mysqld.sock default-storage-engine = InnoDB # 文字コード character-set-server = utf8mb4 collation-server = utf8mb4_0900_ai_ci # InnoDB チューニング innodb_buffer_pool_size = 12G innodb_log_file_size = 2G innodb_flush_log_at_trx_commit = 1 innodb_io_capacity = 2000 # 接続制限 max_connections = 500 wait_timeout = 28800 # ログ slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1.0 # レプリケーション server-id = 1 log-bin = /var/log/mysql/binlog binlog_format = ROW gtid_mode = ON enforce_gtid_consistency = ON [client] # mysql / mysqldump 共通 port = 3306 socket = /var/run/mysqld/mysqld.sock default-character-set = utf8mb4 [mysql] # mysql CLI 専用 prompt = "\u@\h [\d]> " auto-rehash [mysqldump] quick single-transaction default-character-set = utf8mb4
8.0 から SET PERSIST / SET PERSIST_ONLY が追加され、my.cnf を編集せずに永続化できる設定変更が可能になりました。これが運用上は革命でした。
-- ① 現在の設定値を見る SHOW VARIABLES LIKE 'innodb_%'; SHOW GLOBAL VARIABLES LIKE 'max_connections'; -- ② 一時的に変える (再起動で消える) SET GLOBAL max_connections = 1000; -- ③ 8.0+ : 永続化される (mysqld-auto.cnf に保存) SET PERSIST max_connections = 1000; -- ④ 動的変更不可な変数だけ永続化したい SET PERSIST_ONLY innodb_buffer_pool_size = '12G'; -- ↑ 即時反映はせず、次回起動時から適用 -- ⑤ 永続化された設定を確認 SELECT * FROM performance_schema.persisted_variables; -- ⑥ 永続化を取り消す RESET PERSIST max_connections; RESET PERSIST; -- 全部リセット -- ⑦ セッション単位の変更 (自分の接続のみ) SET SESSION sort_buffer_size = 1048576; -- 1 MB
SET GLOBAL で動的変更 → ② 結果が良ければ SET PERSIST で永続化 → ③ Infrastructure as Code に my.cnf テンプレートを反映 → ④ RESET PERSIST で mysqld-auto.cnf をクリーンに設定回りで最もハマるのが「反映されたつもりが反映されていない」事故です。チェックポイントを整理します。
systemctl restart mysql で再起動するか、動的変更可なら SET GLOBAL で即時適用。SHOW VARIABLES の結果と my.cnf に書いた値が違ったら、読み込み順を疑う。datadir/mysqld-auto.cnf)。これが書き込み権限不足 / 読み込み権限不足だと黙って効かない。SET PERSIST_ONLY は次回起動時から適用。今すぐ反映したいなら通常の SET PERSIST。SHOW GLOBAL VARIABLES)。InnoDB の一部設定はセッション単位で上書き不可 (例: innodb_buffer_pool_size)。!includedir で機能別に分割管理がオススメ。 ③ 8.0 の SET PERSIST は強力 — my.cnf 編集なしで永続化可能。 ④ 「効かない」と感じたら読み込み順と動的変更可否を疑う。「10 年運用してもメンテしやすいテーブル設計」をするためのチェックポイントを順にみていきます。MySQL (InnoDB) で特に重要なのが、主キー設計 ・ データ型選定 ・ charset/collation 統一 ・ タイムスタンプ列の設計 の 4 点です。
InnoDB はClustered Index です。つまり主キーの順番でテーブルそのものが物理的に並びます (Section 19 で深掘り)。これにより、主キーの選び方が書き込み性能・ディスク領域・index サイズに直結します。
BIGINT AUTO_INCREMENTBESTCHAR(36) UUID v4避けるBINARY(16) UUID v7OKUUID_TO_BIN(uuid, 1) で要変換BINARY(16) で。-- ① 推奨: BIGINT AUTO_INCREMENT CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, ... ); -- ② UUID を使うなら BINARY(16) + v7 (時系列順) CREATE TABLE events ( id BINARY(16) PRIMARY KEY, ... ); INSERT INTO events (id, ...) VALUES (UUID_TO_BIN(UUID(), 1), ...); -- ↑ -- v1 の TS 部分を先頭にスワップ → B+Tree 末尾挿入になる -- (8.0+ の UUID_TO_BIN(x, 1) の swap_flag)
InnoDB の 16 KB Page にできるだけ多くの行を詰めるのがインデックス性能の鉄則です。必要十分な最小サイズの型を選びます。
status TINYINT (0-255)status BIGINTamount DECIMAL(10, 2)amount FLOATemail VARCHAR(255)email TEXTcreated_at DATETIME(6)created_at VARCHAR(20)is_deleted TINYINT(1) DEFAULT 0is_deleted VARCHAR(5)middle_name VARCHAR(50) NULLmiddle_name VARCHAR(50) (NULL を意識せず作る)role ENUM("admin","user")category ENUM(...) ← 後から増えるほぼ全テーブルに付ける監査カラムです。MySQL では DATETIME と TIMESTAMP のどちらを使うかが古典的論点。
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
-- 監査カラム (DATETIME(6) でマイクロ秒精度)
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL
DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
deleted_at DATETIME(6) NULL, -- ソフトデリート
INDEX idx_created_at (created_at),
INDEX idx_deleted_at (deleted_at)
) ENGINE=InnoDB
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;DATETIME(6) + アプリ層で UTC 統一。マイクロ秒精度は通知システム / イベントログでまず必須、レイテンシ計測でも便利。InnoDB は外部キーをサポートしますが、使うか使わないかは組織のポリシーで分かれます。
MySQL 5.7+ で導入されたJSON 型は、本来正規化すべき情報を 1 カラムにまとめる非正規化テクニックとして使えます (Section 40 で深掘り)。
命名規則は早い段階で決めておくと、10 年後の自分が救われます。「全テーブルで一貫している」が最重要。
users / order_items (複数形 / snake_case)× User / orderItemid BIGINT UNSIGNED× user_id (主キーなのに user_id)<参照先>_id (例: user_id, order_id)× fk_user / belongs_to_user_at で終わる (created_at, updated_at, deleted_at)× create_date / modified_time (バラバラ)is_active / is_deleted (TINYINT(1))× active_flag / deleted (型が曖昧)idx_<列>(_<列>) / uniq_<列>× i1 / index_users_email_2fk_<this>_<ref> (例: fk_orders_user)× orders_ibfk_1 (自動生成のまま)chk_<table>_<rule>× $_$ で始まる自動生成名users_roles / order_items (両親の組合せ)× user_role_join_tableis_xxx / has_xxx で始める× admin (boolean なのか役職なのか不明)-- users と roles の M-N 関係 CREATE TABLE users_roles ( user_id BIGINT UNSIGNED NOT NULL, role_id BIGINT UNSIGNED NOT NULL, granted_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (user_id, role_id), -- 複合 PK KEY idx_role_id (role_id), -- 逆引き用 CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_ur_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- PK の左端 user_id で「ある user の roles 一覧」が高速 -- idx_role_id で「ある role の users 一覧」が高速
-- comments が posts にも events にもつくケース CREATE TABLE comments ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, commentable_type VARCHAR(50) NOT NULL, -- 'Post' or 'Event' commentable_id BIGINT UNSIGNED NOT NULL, body TEXT, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), KEY idx_commentable (commentable_type, commentable_id) ) ENGINE=InnoDB; -- ⚠ FK が張れない (動的に参照先が変わる) -- ⚠ Rails の polymorphic と同じ設計だが MySQL では整合性が DB レベルで保証されない -- → アプリ層で慎重に / Trigger で整合性チェック
CREATE TABLE orders_audit (
audit_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
action ENUM('INSERT','UPDATE','DELETE') NOT NULL,
changed_by VARCHAR(100),
changed_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
old_data JSON, -- 変更前
new_data JSON, -- 変更後
KEY idx_order_id (order_id),
KEY idx_changed_at (changed_at)
) ENGINE=InnoDB PARTITION BY RANGE (TO_DAYS(changed_at)) (
PARTITION p2024 VALUES LESS THAN (TO_DAYS('2025-01-01')),
PARTITION p2025 VALUES LESS THAN (TO_DAYS('2026-01-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- パーティション化して古いデータの一括削除を高速に (Section 30)
-- TRIGGER で自動記録 or アプリ層で明示的に INSERT-- 論理削除: deleted_at NULL を非削除扱い deleted_at DATETIME(6) NULL, KEY idx_deleted_at (deleted_at) -- アプリは WHERE deleted_at IS NULL を常に付ける -- ★ 全インデックスに deleted_at の影響を考慮する必要あり -- 物理削除 + 別テーブルに退避 CREATE TABLE users_deleted LIKE users; -- DELETE FROM users WHERE id=X → INSERT INTO users_deleted ... + DELETE -- ★ users テーブルの肥大化を防げる / FK 整合性は崩れる -- 選び方: -- ・GDPR 等で完全削除が必要 → 物理削除 + 別テーブル -- ・「うっかり戻したい」用途が多い → 論理削除 -- ・両立したいなら、論理削除 + 定期バッチで物理削除
「文字列を保存したい」 ├ 長さ固定? → CHAR(n) (使わない方が多い) ├ 長さ < 255 文字? → VARCHAR(n) └ 本文 / コメント → TEXT / MEDIUMTEXT / LONGTEXT 「整数を保存したい」 ├ 0/1 のフラグ → TINYINT(1) ├ ステータス (列挙的) → TINYINT UNSIGNED ├ サロゲートキー → BIGINT UNSIGNED └ 一般的な ID → INT UNSIGNED 「小数を保存したい」 ├ 金額 → DECIMAL(p, s) ※ 絶対に FLOAT を使わない ├ センサーデータ等の近似値 → FLOAT / DOUBLE └ パーセント (0-100) → TINYINT UNSIGNED or DECIMAL(4,2) 「日時を保存したい」 ├ アプリで UTC 統一前提 → DATETIME(6) ├ レガシー TZ 互換が必要 → TIMESTAMP (※ 2038 年問題) ├ 日付のみ → DATE └ 時刻 / 経過時間 → TIME 「半構造化データ」 ├ 検索しない → JSON (Section 40) ├ 検索する → JSON + Generated Column + Index └ スキーマが決まる → 別カラムに正規化 「バイナリ」 ├ 数 KB まで → VARBINARY(n) / BLOB ├ 数 MB まで → MEDIUMBLOB └ それ以上 → S3 などに置いて URL を保存
ENGINE=InnoDB + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci。 ② 主キーは BIGINT UNSIGNED AUTO_INCREMENT (UUID v4 は禁忌)。 ③ created_at / updated_at / deleted_at (DATETIME(6))。 ④ FK は中規模まで使う・CASCADE は慎重に。 ⑤ JSON は検索しないデータに限定。 ⑥ 命名規則を最初に決める (snake_case / 複数形テーブル / _id / _at)。 ⑦ 履歴・audit は別テーブルで RANGE パーティション化。InnoDB の物理レイアウトを「16 KB Page」「Row Format」「Overflow Page」の 3 視点で見ていきます。これを理解すると、なぜ TEXT 列を増やすと性能が落ちるか・Row Format で何が変わるかが腑に落ちます。
InnoDB の最小 I/O 単位は 16 KB の Page です (デフォルト)。テーブルもインデックスも Undo もすべて 16 KB Page の集合体として保存されます。
innodb_page_size で 4K / 8K / 32K / 64K に変えられますが、データディレクトリ作成時 (initdb 相当) にしか変更できません。通常はデフォルト 16 KB のまま。SSD で IOPS を稼ぎたい OLTP 系で 8 KB にする選択肢はあります。1 つの Page 内に行 (Record) をどう詰め込むかのフォーマット。8.0 のデフォルトは DYNAMIC です。
| Row Format | 時期 | 可変長カラムの扱い | Page 容量超過 | 推奨 |
|---|---|---|---|---|
| REDUNDANT | 4.1 以前 | 行内に固定スロット | NG | × レガシー |
| COMPACT | 5.0+ | 先頭 768 byte を行内、残りを overflow | 部分 overflow | △ 古いシステム互換 |
| DYNAMIC | 5.6+ デフォルト 5.7+ | 完全 overflow (20 byte の参照だけ行内) | Overflow Page | ◎ 8.0 デフォルト |
| COMPRESSED | 5.5+ | DYNAMIC + zlib 圧縮 | Overflow Page | ○ ストレージ節約 |
DYNAMIC (デフォルト) のまま。テーブルが巨大で読み多めなら COMPRESSED でディスクと Buffer Pool を節約できる (CPU は増える)。1 行のサイズが Page (16 KB) の半分を超えると、可変長カラム (VARCHAR / TEXT / BLOB / JSON) が別の Overflow Page に追い出されます。PostgreSQL の TOAST に相当する仕組みです。
SELECT id, name FROM users (TEXT 列を含めない) → Overflow Page は読まれず高速。 ② SELECT * FROM users → TEXT のたびに Overflow Page を読む → 遅くなる。 → 本番では SELECT * を避けて必要列だけ取得 するのが基本。-- テーブルサイズと行サイズの目安 SELECT TABLE_NAME, TABLE_ROWS, ROUND(DATA_LENGTH / 1024 / 1024, 1) AS data_MB, ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_MB, ROUND(DATA_LENGTH / TABLE_ROWS, 1) AS avg_row_bytes FROM information_schema.tables WHERE table_schema = 'myapp' ORDER BY DATA_LENGTH DESC LIMIT 20; -- Row Format を確認 SELECT TABLE_NAME, ROW_FORMAT FROM information_schema.tables WHERE table_schema = 'myapp'; -- 1 ページあたり何行詰まっているかの目安 -- 16384 byte / avg_row_bytes ≈ 1 Page に詰まる行数
1 行 (Record) の中身は「可変長ヘッダ + NULL bitmap + 5 byte レコードヘッダ + 実データ」という構造です。
1 行のサイズには絶対上限 65,535 byte という制約があります (8 KB の Page で半分使う計算)。これは Overflow とは別の制約。
-- ① 列数が多いテーブルで起きやすい CREATE TABLE wide ( col1 VARCHAR(255), col2 VARCHAR(255), ... col300 VARCHAR(255) ); -- ERROR 1118: Row size too large (> 65535) -- ② 計算式: -- 各 VARCHAR(n) は (n * 4 byte_per_char_utf8mb4) + 2 byte (長さ) -- utf8mb4 で VARCHAR(255) は 255 * 4 + 2 = 1022 byte -- 65535 / 1022 ≈ 64 列が上限 -- ③ 解決策: -- A) ROW_FORMAT=DYNAMIC で大きな VARCHAR を Overflow に ALTER TABLE wide ROW_FORMAT=DYNAMIC; -- → 20 byte の参照ポインタだけ行内に残る -- B) TEXT / BLOB に変換 (これも overflow される) ALTER TABLE wide MODIFY col1 TEXT; -- C) テーブル分割 (1:1 リレーション) CREATE TABLE wide_basic (id BIGINT PRIMARY KEY, col1-50 ...); CREATE TABLE wide_details (id BIGINT PRIMARY KEY, col51-300 ...); -- ④ Page あたり「最低 2 行入る必要」 → 16 KB / 2 = 8 KB が実質上限 -- → 65,535 はあくまで理論値・実運用ではもっと小さく抑える
SELECT * は Overflow Page を読みに行くので避ける。 ④ Record はNULL bitmap で NULL 列を省略する設計。 ⑤ 1 行の絶対上限は 65,535 byte・実質は8 KB 以下に抑える。MVCC (Multi-Version Concurrency Control) は、読み取りトランザクションを書き込みでブロックしないための仕組みです。「同じ行の過去の姿を保持しておき、誰がどの時点の姿を見るかをトランザクションごとに切り替える」ことで実現します。
InnoDB の MVCC は 「hidden columns」・「Undo Log」・「Read View」 の 3 部品で動きます。PostgreSQL とは設計が大きく違うので、ここをしっかり理解しておくと並行制御 (G6) の章が一段読みやすくなります。
DB_TRX_ID (誰が更新したか) と DB_ROLL_PTR (どこに旧バージョンがあるか) が付いている。 ② Undo Log Chain は DB_ROLL_PTR で繋がる旧バージョンのリンクリスト。 ③ 読み手の TX は Read View を持ち、行の DB_TRX_ID が Read View の可視範囲外なら Undo Log を遡って自分に見えるバージョンを構築 する。REPEATABLE READ の中で「なぜ同じ行を 2 回読んでも同じ値が返るのか」を、TX-A (Reader) と TX-B (Writer) の並行シナリオで step-through します。Read View の中身と Undo Log Chain の動きを観察してください。
DB_TRX_ID=100 の TX で挿入された。TRX 100CURRENT# トランザクション T が行 R を読もうとする時の判定:
trx_id = R.DB_TRX_ID # 行を最後に書いた TX の ID
if trx_id < view.m_up_limit_id:
→ ✓ 見える (Read View 作成より前にコミット済み)
elif trx_id >= view.m_low_limit_id:
→ ✗ 見えない (Read View 作成より後の TX)
→ DB_ROLL_PTR を辿って前バージョンを取得
elif trx_id in view.m_ids:
→ ✗ 見えない (Read View 作成時点でまだアクティブだった TX)
→ DB_ROLL_PTR を辿って前バージョンを取得
else:
→ ✓ 見える (m_up_limit_id ≤ trx_id < m_low_limit_id かつ m_ids に含まれない)
# Undo を遡る場合、再帰的に同じ判定を繰り返して
# 最終的に自分に見えるべきバージョンを再構築する具体例で挙動を追います。REPEATABLE READ (MySQL のデフォルト) を仮定。
時刻 TX_A (REPEATABLE READ) TX_B (REPEATABLE READ)
─────────────────────────────────────────────────────────────────
T1 START TRANSACTION;
SELECT name FROM u WHERE id=1; → "alice" (V0)
# ★ ここで Read View が作られる
T2 START TRANSACTION;
UPDATE u SET name="bob"
WHERE id=1;
# → V0 を Undo へ、現行を V1 に
COMMIT;
T3 SELECT name FROM u WHERE id=1; → "alice" (V0)
# ★ TX_A は Read View が T1 のままなので
# DB_TRX_ID(TX_B) > Read View 可視範囲 → Undo を辿る
COMMIT;
T4 START TRANSACTION;
SELECT name FROM u WHERE id=1; → "bob" (V1)
# ★ 新しい Read View で TX_B は可視範囲内MySQL のデフォルトは REPEATABLE READ。PostgreSQL や Oracle のデフォルトは READ COMMITTED。違いは「いつ Read View を作るか」です。
READ COMMITTED をデフォルトに変えている運用も多い。Gap Lock 起因のデッドロックを避けるためです (Section 24/25)。一方で、REPEATABLE READ のほうが論理整合性は強い。レプリケーション・アプリ側の前提に合わせて選択する。MVCC を実現するために旧バージョンを Undo Log に蓄える 必要があります。アクティブな最古の Read View より新しい undo は捨てられません (= Purge できない)。
そのため、長時間トランザクションを放置すると、Undo が肥大化して disk・メモリを圧迫 し始めます。最悪、ディスク満杯で mysqld が止まります。
-- ① History List Length (HLL) を見る — Undo が溜まっている指標 SHOW ENGINE INNODB STATUS\G -- TRANSACTIONS セクション内の "History list length" を見る -- 100万以上になっていたら要注意 -- ② Performance Schema でも確認 SELECT NAME, COUNT FROM information_schema.innodb_metrics WHERE NAME='trx_rseg_history_len'; -- ③ アクティブな長時間 TX を特定 SELECT trx_id, trx_state, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec, trx_query, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started ASC;
BEGIN; したまま放置 → undo 肥大事故は意外と多い。idle_in_transaction をアラートで監視する設計が必要 (詳細は Section 14)。MySQL には「2 つのログ」があります。InnoDB エンジンが持つ Redo Log と、サーバ層が持つ Binary Log。この 2 つを混同するのは MySQL 初学者の鉄板ハマりポイントなので、本章で完全に整理します。
| 観点 | Redo Log (InnoDB) | Binary Log (Server 層) |
|---|---|---|
| 目的 | クラッシュリカバリ | レプリケーション / PITR |
| レイヤー | InnoDB Storage Engine | Server 層 |
| 記録内容 | 物理ログ (どの Page をどう変えたか) | 論理ログ (どんな SQL や ROW 変更が起きたか) |
| ファイル | ib_logfile0, ib_logfile1 (循環) | binlog.000001, .000002, ... (連番) |
| サイズ制御 | innodb_log_file_size | max_binlog_size + expire_logs_seconds |
| 無効化 | × できない (常に書く) | ○ 無効化可 (server_id=0 / log_bin=OFF) |
| 永続化制御 | innodb_flush_log_at_trx_commit | sync_binlog |
| 用途 | リカバリ専用 (人間は読めない) | レプリ・PITR・監査 |
Redo Log は 「Buffer Pool 上で変更した内容を、ディスクに反映できる前にクラッシュしても失わないため」 に存在します。WAL (Write-Ahead Logging) の MySQL 版です。
Redo Log の永続化強度を決めるのが innodb_flush_log_at_trx_commit です。0 / 1 / 2 の 3 段階で、性能と耐久性のトレードオフが変わります。
= 1。バッチ取り込み時だけ一時的に = 2 に変えて高速化、終わったら戻す。= 0 は本気で速度のみ欲しい時の最終手段。Binary Log は「サーバ層」のログです。InnoDB だけでなく MyISAM などすべてのエンジンの変更が記録されます。役割は 2 つ。
mysqlbinlog で再適用 → 任意の時刻に DB を巻き戻せる。binlog に何を書くかを binlog_format で制御します。
ROWSTATEMENTNOW() や UUID() で Primary/Replica の値がズレるリスクあり。非推奨。MIXEDsync_binlog は「N トランザクションごとに binlog を fsync する」設定。
ここで疑問: 「InnoDB の Redo Log と Server 層の Binary Log って、別々に書かれるなら、片方だけ書けて片方が書けてなかったらどうなる?」 答えは Two-Phase Commit (内部 XA)。
COMMIT が呼ばれたとき: Phase 1 (Prepare): ① InnoDB が Redo Log に "PREPARE" マークを書いて fsync ② Binary Log にトランザクション全体を書いて sync_binlog 設定に従い fsync Phase 2 (Commit): ③ InnoDB が Redo Log に "COMMIT" マークを書いて fsync クラッシュリカバリ時: - PREPARE + COMMIT がある → 正常完了 → 何もしない - PREPARE のみ・binlog にも記録あり → COMMIT を補完 (前進) - PREPARE のみ・binlog にも記録なし → ROLLBACK (後退) → InnoDB と binlog が必ず同じ状態になることが保証される
innodb_flush_log_at_trx_commit=1 + sync_binlog=1 だと、1 トランザクションで fsync が 3 回 走ります。これが本番 MySQL の書き込み速度の上限を決めることが多い。SSD でも 1 fsync ≒ 100μs なので、1 TX あたり ~300μs が下限。LSN は Redo Log の中での「先頭から何 byte 目か」を表す単調増加カウンタです。すべての変更ログには LSN が振られ、Page にも「この Page を最後に変更したときの LSN」が記録されます。これが InnoDB の時間軸。
-- LSN の状態を見る SHOW ENGINE INNODB STATUS\G # LOG セクション Log sequence number 1234567890 ← 現在の LSN (Buffer まで反映済) Log buffer assigned up to 1234567890 ← Log Buffer に割当て済み Log buffer completed up to 1234567000 Log written up to 1234566500 ← ファイルに書き出し済 Log flushed up to 1234566400 ← fsync 済 (= Durability の境界) Pages flushed up to 1234560000 ← データファイルに反映済 Last checkpoint at 1234555000 ← Sharp Checkpoint の起点 -- 値の意味: -- Log sequence number = 今 Buffer Pool で「ここまで変更した」 -- Log flushed up to = ディスクで「ここまで永続化された」 -- Last checkpoint at = データファイルに「ここまで書き戻した」 -- クラッシュリカバリは: -- Last checkpoint LSN から Log flushed LSN までを Redo Log から再適用 -- 差分が大きい = recovery 時間が長い → checkpoint を進める設定が必要
SHOW REPLICA STATUS の Retrieved_Gtid_Set と組合せ)。 ③ XtraBackup の差分バックアップは LSN 範囲で取る (--incremental-lsn=...)。innodb_flush_log_at_trx_commit=1 + sync_binlog=1 が本番の標準。 ③ ROW format binlog が 8.0 デフォルト。STATEMENT は非推奨。 ④ 内部 XA で Redo と binlog の整合性を担保。3 fsync が本番速度の支配項。 ⑤ LSN が InnoDB の時間軸。checkpoint と log flushed の差が recovery 時間を決める。InnoDB には「これがないと困る」のに普段意識しない 3 つの裏方機能があります。Doublewrite Buffer ・ Change Buffer ・ Adaptive Hash Index。本章でまとめて扱います。
OS のディスク書き込みは原子的とは限りません。InnoDB Page は 16 KB だが、OS のファイルシステム block は通常 4 KB なので、16 KB の書き込みは 4 つの 4 KB 書き込みに分解されます。途中でクラッシュすると4 KB だけ更新された壊れた Page (= Torn Page) が残ります。
Doublewrite Buffer はこれを防ぎます。「同じデータを 2 回書く」ことで、片方が壊れても他方で復元できる仕組み。
innodb_doublewrite=OFF で無効化できる。本番では基本 ON のまま。Secondary Index (PK 以外の index) への変更は、対象のインデックス Page を Buffer Pool に読み込んでから書き換える必要があります。Buffer Pool にない場合は毎回ディスクから読まないといけない。
Change Buffer はこれを延期します。Secondary Index への変更を一時的にバッファに溜めて、後で対象 Page が他の理由で Buffer Pool に乗ったときにまとめて反映します (= Merge)。
innodb_change_buffer_max_size (% of Buffer Pool。デフォルト 25)。書き込み多めなら大きく、読み多めなら小さく。B+Tree は深さ 3〜4 段が普通で、各段で二分探索を行います。同じ key で何度も検索すると同じ Page を辿るので無駄です。InnoDB は頻繁にアクセスされる Page を自動で Hash 化して O(1) に近い参照にできます。これが Adaptive Hash Index。
-- AHI の状態を見る SHOW ENGINE INNODB STATUS\G # INSERT BUFFER AND ADAPTIVE HASH INDEX セクション Hash table size 4425293, node heap has 31 buffer(s) 139.21 hash searches/s, 215.55 non-hash searches/s # hash / total = 39% → Hash 経由の検索の割合 -- 設定 (8.0 デフォルト OFF・5.x は ON) SHOW VARIABLES LIKE 'innodb_adaptive_hash_index'; SHOW VARIABLES LIKE 'innodb_adaptive_hash_index_parts'; -- 8 (分割数)
5.x までの Doublewrite Buffer は ibdata1 内に固定で置かれていました。8.0.20 から独立したファイルに分離され、並列性とチューニング可能性が大幅に向上しました。
-- ファイル名: #ib_<page_size>_<n>.dblwr (data dir 配下) ls -la /var/lib/mysql/'#innodb_redo' ls -la /var/lib/mysql/'#ib_16384_0.dblwr' -- 16K page サイズ用 #0 -- 設定パラメータ (8.0.20+ で追加) SHOW VARIABLES LIKE 'innodb_doublewrite%'; -- innodb_doublewrite ON (有効) -- innodb_doublewrite_dir (空) (data dir 配下に作る) -- innodb_doublewrite_files 2 (ファイル数 = bp_instances * 2) -- innodb_doublewrite_pages 128 (1 ファイルあたりの page 数) -- innodb_doublewrite_batch_size 0 (auto) -- 別ディスクに分離して並列度を上げる [mysqld] innodb_doublewrite_dir = /mnt/fast_ssd/dwb/ # 別 SSD に置く innodb_doublewrite_files = 4 innodb_doublewrite_pages = 256 # 大きいと flush バッチ大 -- 8.0.21+ では DETECT_AND_RECOVER モードも追加 (低レイテンシ志向) innodb_doublewrite = DETECT_AND_RECOVER
Section 11 (MVCC) で見たとおり、InnoDB は旧バージョンを Undo Log に保持します。これを定期的に回収するのが Purge スレッド の仕事です。本章では Purge の動作と、長時間トランザクションが引き起こす undo 肥大問題 の検出・対処を扱います。
HLL は「まだ purge されていない undo の数」です。これが本番の最重要監視指標のひとつ。
-- ① HLL を見る方法 (3 通り) -- (a) SHOW ENGINE INNODB STATUS SHOW ENGINE INNODB STATUS\G -- TRANSACTIONS セクション内の "History list length 5829" -- (b) information_schema SELECT NAME, COUNT FROM information_schema.innodb_metrics WHERE NAME='trx_rseg_history_len'; -- (c) Performance Schema SELECT * FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_history_list_length'; -- ② アクティブな長時間 TX を特定 SELECT trx_id, trx_state, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC LIMIT 10; -- ③ そのスレッドを kill する (最終手段) KILL <thread_id>;
〜 1万健全通常状態。Purge が追いついている。10万 〜 100万要注意少し遅れ気味。長時間 TX を疑う。100万 〜 1000万危険Undo Tablespace 肥大が進行中。即対処。1000万 〜事故disk 圧迫・性能劣化。最悪 mysqld 停止。8.0 から Undo Tablespace は 動的に管理 されるようになりました。それまでは ibdata1 内に閉じ込められていて、肥大すると mysqld を停止 → ibdata1 再構築という大手術が必要だったのが、ALTER で増やしたり減らしたりできるようになりました。
-- ① 現在の Undo Tablespace 一覧 SELECT TABLESPACE_NAME, FILE_NAME, INITIAL_SIZE, MAXIMUM_SIZE FROM information_schema.innodb_tablespaces WHERE FILE_NAME LIKE '%undo%'; -- ② 新しい Undo Tablespace を追加 CREATE UNDO TABLESPACE undo_003 ADD DATAFILE 'undo_003.ibu'; -- ③ Undo Tablespace を非アクティブにして縮小 ALTER UNDO TABLESPACE undo_001 SET INACTIVE; -- → 既存の undo は purge されるまで残る -- → Purge 完了後、自動で truncate される -- ④ 完全削除 ALTER UNDO TABLESPACE undo_001 SET INACTIVE; -- (purge 完了を待つ) DROP UNDO TABLESPACE undo_001;
innodb_undo_log_truncate=ON。Undo Tablespace のサイズが innodb_max_undo_log_size (デフォルト 1 GB) を超えると、自動で SET INACTIVE → truncate → SET ACTIVE が走ります。autocommit=ON をデフォに。information_schema.innodb_trx を 1 分ごとに監視 → 30 分以上ある TX があれば slack に通知。idle_in_transaction_session_timeout 相当。MySQL では wait_timeout + アプリの autocommit 管理で対処。Purge が追いつかない (HLL が増える) ときに触るパラメータを整理します。
innodb_purge_threads4 (デフォルト)Purge Worker の数。書き込み多めなら 8〜16。1 = Coordinator のみinnodb_purge_batch_size3001 回の Purge イテレーションで処理する Undo Log Record 数。大きいと一気に処理 (BurstY)・小さいと滑らかだが追いつかないこともinnodb_max_purge_lag0 (無制限)HLL がこの値を超えると DML を遅延させる。本番で 100 万くらいに設定すると Purge 詰まりが軽い遅延として現れるinnodb_max_purge_lag_delay0↑ の最大遅延量 (μs)。10000 (10ms) くらいからinnodb_undo_log_truncateON (8.0 デフォルト)Undo Tablespace が innodb_max_undo_log_size を超えたら自動 truncateinnodb_max_undo_log_size1 GB自動 truncate の発火閾値innodb_purge_threads を増やす (4 → 8 → 16)。 ③ Burst を許容できるなら innodb_purge_batch_size を 500〜1000 に。 ④ innodb_max_purge_lag を設定してHLL 過大を DML 遅延として可視化する運用も。purge_threads + purge_batch_size でチューニング。Section 02 で俯瞰した 5 ステージをもう一段深く掘ります。それぞれのステージで何が起きているかを把握することで、本番でクエリが遅いとき「どのステージが詰まっているか」を切り分けて対処できるようになります。
SQL 文字列が来たら、まず Lexer (字句解析器) がトークン列に分解し、Parser (構文解析器) が AST (抽象構文木) を組み立てます。MySQL は Bison ベースで実装されています。
SELECT u.name, COUNT(*) FROM users u WHERE u.country = 'JP' GROUP BY u.id;
# Lexer の出力 (トークン)
[SELECT] [IDENT:u] [.] [IDENT:name] [,]
[FUNC:COUNT] [(] [*] [)]
[FROM] [IDENT:users] [IDENT:u]
[WHERE] [IDENT:u] [.] [IDENT:country] [=] [STR:'JP']
[GROUP] [BY] [IDENT:u] [.] [IDENT:id] [;]
# Parser の出力 (AST)
SelectStmt
├─ Projection
│ ├─ TableRef("u").name
│ └─ Aggregate(COUNT, *)
├─ FromList
│ └─ TableRef("users", alias="u")
├─ Where
│ └─ Eq(TableRef("u").country, 'JP')
└─ GroupBy
└─ TableRef("u").idYou have an error in your SQL syntax near '...') はここ。大文字小文字・クォート種別 (シングル vs バック)・予約語 (rank・lead・desc など) でハマりがち。AST の中の名前 (テーブル名・列名・関数名) を実体に解決します。同時に権限チェックも走り、VIEW があれば展開します。
Unknown column 'X' ・ Table doesn't exist ・ SELECT command denied 等はここ。VIEW の権限と元テーブルの権限が違う場合は SQL SECURITY DEFINER / INVOKER の挙動が絡む (Section 44)。このステージがクエリ性能の 9 割を決めると言って過言ではありません。Optimizer は次のことを決めます。
EXPLAIN の表で見える。詳細は次の Section 16 で。Optimizer が決めたプランをiterator (Volcano モデル) として組み立て、ルートから Read() を呼んで 1 行ずつ取り出します (Section 05 参照)。
このステージで起きる主なコストは:
結果セットをパケット化してネットワーク経由でクライアントへ送ります。1 パケット = max_allowed_packet (デフォルト 64 MB)。大量行は連続パケットに分割。
-- 結果セットがでかすぎてつまづく場合 SET max_allowed_packet = 256 * 1024 * 1024; -- 256 MB -- アプリ側でも合わせる mysql --max_allowed_packet=256M ... -- BLOB を返すとき特にハマりやすい -- アプリ側のドライバ (JDBC / mysqli) でも同じ値を設定
同じ SQL を何度も投げる場合、毎回 Parse → Resolve → Optimize を走らせるのは無駄です。Prepared Statement で実行プランをキャッシュできます (Section 05 参照)。
-- Server-side PS の効果を見る SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count'; SHOW GLOBAL STATUS LIKE 'Com_stmt_prepare'; SHOW GLOBAL STATUS LIKE 'Com_stmt_execute'; -- Com_stmt_execute / Com_stmt_prepare が大きい → PS が効いている -- Com_stmt_execute が小さい → PS の旨味なし (毎回 prepare している)
EXPLAIN はクエリ調査の主役です。MySQL 8.0 では 4 つの形式が使えます。それぞれに得意分野があり、本番では場面で使い分けます。
| 形式 | 出力 | 実行? | 主な用途 |
|---|---|---|---|
| EXPLAIN | 表形式 (1 行 = 1 テーブル) | × (見積もりのみ) | 古典的・パッと見る用 |
| EXPLAIN FORMAT=TREE | 木構造 (実行順が読みやすい) | × (見積もりのみ) | 8.0+ の標準 |
| EXPLAIN FORMAT=JSON | JSON (詳細情報) | × (見積もりのみ) | ツール連携・cost を見たい時 |
| EXPLAIN ANALYZE | 木構造 + 実測値 | ○ 実行する | 本物の遅さの原因究明 |
-- 例題のクエリ
EXPLAIN SELECT u.name, COUNT(o.id) AS cnt
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.country = 'JP' AND o.status = 'paid'
GROUP BY u.id
ORDER BY cnt DESC
LIMIT 10;
-- ① 古典的 EXPLAIN (表)
+----+-------------+-------+--------+---------------+----------+---------+---------------+------+-------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+--------+---------------+----------+---------+---------------+------+-------------------------+
| 1 | SIMPLE | u | ref | PRIMARY,... | idx_co | 4 | const | 1234 | Using temporary; Using filesort |
| 1 | SIMPLE | o | ref | idx_user_id | idx_uid | 8 | u.id | 5 | Using where |
+----+-------------+-------+--------+---------------+----------+---------+---------------+------+-------------------------+
-- ② EXPLAIN FORMAT=TREE
-> Limit: 10 row(s)
-> Sort: cnt DESC, limit input to 10 rows per chunk
-> Group aggregate: count(o.id)
-> Nested loop inner join (cost=345.6 rows=617)
-> Index lookup on u using idx_country (country='JP') (cost=12.3 rows=1234)
-> Filter: (o.status = 'paid')
-> Index lookup on o using idx_user_id (user_id=u.id) (cost=0.5 rows=5)
-- ③ EXPLAIN FORMAT=JSON (抜粋)
{
"query_block": {
"select_id": 1,
"cost_info": { "query_cost": "345.65" },
"ordering_operation": {
"using_temporary_table": true,
"using_filesort": true,
...
}
}
}
-- ④ EXPLAIN ANALYZE (実行する)
-> Limit: 10 row(s) (actual time=23.456..23.467 rows=10 loops=1)
-> Sort: cnt DESC (actual time=23.450..23.452 rows=10 loops=1)
-> Group aggregate: count(o.id) (actual time=0.523..23.398 rows=782 loops=1)
-> Nested loop inner join (actual time=0.045..21.234 rows=4567 loops=1)
-> Index lookup on u (actual time=0.039..3.892 rows=1234 loops=1)
-> Filter: (o.status = 'paid') (actual time=0.014..0.019 rows=3.7 loops=1234)
-> Index lookup on o (actual time=0.011..0.014 rows=5 loops=1234)表形式 EXPLAIN の「type」列 がアクセス方法を表します。良い順に並べると:
system1 行しかないテーブルconstPK / UNIQUE で 1 行に絞れる (最速)eq_refJOIN 時に PK / UNIQUE で 1 行を引くref非 UNIQUE index で 1 値を引くrangeindex で範囲スキャン (BETWEEN / IN / >)indexindex 全件をスキャン (テーブル全件より少しマシ)ALLテーブル全件スキャン (Full Table Scan) — 本番では避けたいUsing indexカバリングインデックス (index だけで完結) - GOODUsing whereWHERE 条件をエンジンが評価 - 通常 OKUsing index conditionIndex Condition Pushdown 効いてる - GOODUsing temporary一時テーブル作成 - GROUP BY / DISTINCT / UNION で頻出Using filesortメモリ or ディスクでソート - index で順序が取れていないUsing join buffer (Block NL)JOIN で index が使えていない - 別 index を検討Using join buffer (hash join)8.0.18+ の Hash Join - 大規模 JOIN では速いImpossible WHERE結果が必ず空 - 条件にバグ8.0.18+ の EXPLAIN ANALYZE はクエリを実際に実行 して計測値を返します。Optimizer の見積もり (rows) と実測 (actual rows) の差が分かるのがありがたい。
-> Index lookup on u using idx_country (actual time=0.039..3.892 rows=1234 loops=1)
↑ 最初の 1 行 ↑ 全体 ↑ 実rows ↑ 呼ばれた回数
-- 読み方:
-- - cost : Optimizer が見積もったコスト (絶対値ではなく相対指標)
-- - rows : 見積もった出力行数
-- - actual time=N..M : 最初の行が取れるまで N ms、全部処理に M ms
-- - actual rows=R : 実際に返した行数
-- - loops=L : このノードが呼ばれた回数 (Nested Loop で重要)
-- ★ rows と actual rows が大きく違う → 統計情報が古い疑い
ANALYZE TABLE users; -- 統計を更新表形式 EXPLAIN の rows と filtered 列は「Optimizer が何件返ると見積もっているか」を示します。これがズレていると変なプランを選びます。
+----+-------------+-------+--------+---------------+---------+---------+------+------+----------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+--------+---------------+---------+---------+------+------+----------+-------+ | 1 | SIMPLE | u | range | idx_age | idx_age | 4 | NULL | 1000 | 33.33 | ... | +----+-------------+-------+--------+---------------+---------+---------+------+------+----------+-------+ # 読み方: # rows = idx_age で範囲スキャンしたら 1000 行 ヒットすると見積もり # filtered = うち 33.33% (= 333 行) が WHERE 条件全体を通ると見積もり # 実際の処理行数 = rows × filtered / 100 = 333 行 # ★ filtered が 100 に近い → WHERE が ほぼ全件通る = index が効いている # ★ filtered が 1 に近い → WHERE で大量に除外 = 効率悪い (別 index 検討) # 統計を更新して見積もり精度を上げる ANALYZE TABLE users; # Histogram で精度をさらに上げる (8.0+) ANALYZE TABLE users UPDATE HISTOGRAM ON age WITH 32 BUCKETS;
JSON 形式は出力量が多いが、Optimizer の判断材料がすべて見えます。実務で「なぜこの index を選ばなかったのか」を追うときに使う形式。
EXPLAIN FORMAT=JSON
SELECT u.name, COUNT(o.id)
FROM users u JOIN orders o ON o.user_id=u.id
WHERE u.country='JP' GROUP BY u.id\G
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "345.65" ← クエリ全体の総コスト
},
"grouping_operation": {
"using_temporary_table": true,
"using_filesort": true, ← 一時表 + ソート発生
"nested_loop": [
{
"table": {
"table_name": "u",
"access_type": "ref", ← ref (index 等価検索)
"key": "idx_country",
"rows_examined_per_scan": 1234, ← 走査見積もり
"rows_produced_per_join": 1234,
"filtered": "100.00", ← 100% = index で全部絞れた
"cost_info": {
"read_cost": "12.30",
"eval_cost": "0.12",
"prefix_cost": "12.42",
"data_read_per_join": "192K"
},
"used_columns": ["id", "name", "country"]
}
},
{
"table": {
"table_name": "o",
"access_type": "ref",
"key": "idx_user_id",
"rows_examined_per_scan": 5,
"rows_produced_per_join": 6170,
"filtered": "100.00",
"cost_info": { "read_cost": "61.70", "eval_cost": "617.00", ... }
}
}
]
}
}
}
# 読み所:
# - query_cost が他プランより高い → なぜそれを選んだか trace を見る
# - rows_examined_per_scan と実際の rows が違う → ANALYZE TABLEEXPLAIN。 ② 構造的に読みたい → EXPLAIN FORMAT=TREE (8.0+ 標準推奨)。 ③ 「なぜこのプランを選んだか」を追いたい → EXPLAIN FORMAT=JSON + optimizer_trace (Section 17/53)。 ④ 実測したい → EXPLAIN ANALYZE (8.0.18+)。 ⑤ filtered 列で Optimizer の絞り込み精度を確認。100% でないなら index 改善余地あり。Optimizer は何をどう計算してプランを選んでいるか。MySQL の Optimizer は「Cost-Based Optimizer (CBO)」で、各プラン候補にコスト数値を付け、最小のものを選びます。
総コスト = I/O コスト + CPU コスト I/O コスト: Page 読込数 × COST_PER_PAGE_FETCH CPU コスト: 返す行数 × COST_PER_ROW_EVALUATE + 比較回数 × COST_PER_COMPARE + ソート行数 × COST_PER_SORT 定数は mysql.server_cost / mysql.engine_cost に保存: SELECT * FROM mysql.engine_cost; +----------+--------------------+----------+ | name | default_value | cost | +----------+--------------------+----------+ | io_block_read_cost | 1.0000 | NULL | | memory_block_read_cost | 0.2500 | NULL | ← Buffer Pool ヒット時 +----------+--------------------+----------+ SELECT * FROM mysql.server_cost; +--------------------------------+-------+ | name | cost | +--------------------------------+-------+ | row_evaluate_cost | 0.10 | | key_compare_cost | 0.05 | | memory_temptable_create_cost | 1.00 | | memory_temptable_row_cost | 0.10 | | disk_temptable_create_cost | 20.0 | | disk_temptable_row_cost | 0.50 | +--------------------------------+-------+
io_block_read_cost を 0.25〜0.5 に下げると、Optimizer が「ディスク読みでも index range のほうが速い」と判断するケースが増える、というチューニング技はあります。本番でうまく効くか要計測。Optimizer は各 index のカーディナリティ (異なる値の数) を頼りにプランを選びます。これが古いと変なプランを選びます。
-- 統計を見る SHOW INDEX FROM users; -- Cardinality 列が「異なる値の数」の推定 -- これが小さい = 重複が多い (例: gender 列) -- これが大きい = ユニークに近い (例: email 列) -- 統計が古いと感じたら手動 ANALYZE ANALYZE TABLE users; ANALYZE TABLE orders; -- 自動 ANALYZE は通常 ON (Section 14 参照) SHOW VARIABLES LIKE 'innodb_stats_auto_recalc'; -- ON SHOW VARIABLES LIKE 'innodb_stats_persistent'; -- ON SHOW VARIABLES LIKE 'innodb_stats_sample_pages'; -- 20
innodb_stats_sample_pages を 100 〜 1000 に増やすと精度が上がる (ただし ANALYZE が重くなる)。8.0+ では Histogram 統計も追加された (次節)。8.0 で Histogram が追加されました。index がない列でも値の分布を Optimizer に教えられます。
-- ① Histogram を作る (列に index がなくても OK) ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 16 BUCKETS; -- ② 確認 SELECT histogram FROM information_schema.column_statistics WHERE table_name='orders' AND column_name='status'; -- ③ 削除 ANALYZE TABLE orders DROP HISTOGRAM ON status; -- 使いどころ: 偏った分布で index を使うかの判断が変わる場合 -- 例: status='paid' が 95%, 'pending' が 5% のような偏り -- → status='pending' なら index 範囲スキャンが速い -- → status='paid' なら Full Table Scan のほうが速い
Optimizer Hints でプランを強制できます (8.0 でかなり強化)。/*+ ... */ 形式。
-- ① 特定の index を使う / 使わない SELECT /*+ INDEX(u idx_country) */ * FROM users u WHERE country='JP'; SELECT /*+ NO_INDEX(u idx_country) */ * FROM users u WHERE country='JP'; -- ② JOIN 方式を指定 (Hash Join を強制) SELECT /*+ JOIN_ORDER(u, o) BNL(u, o) */ FROM users u JOIN orders o ON ...; -- ③ Subquery を Semijoin に / Materialization に SELECT /*+ SEMIJOIN(MATERIALIZATION) */ ...; SELECT /*+ NO_SEMIJOIN() */ ...; -- ④ Join Order を強制 SELECT /*+ JOIN_ORDER(o, u) */ FROM users u JOIN orders o ...; -- ⑤ クエリ単位のシステム変数オーバーライド (8.0.4+) SELECT /*+ SET_VAR(sort_buffer_size=16M) */ * FROM big_table ORDER BY x; -- ⑥ 古い "INDEX HINT" (USE INDEX / FORCE INDEX / IGNORE INDEX) SELECT * FROM users FORCE INDEX (idx_country) WHERE country='JP'; SELECT * FROM users IGNORE INDEX (idx_email) WHERE email LIKE '%@example.com';
ANALYZE TABLE で更新。 ③ 8.0+ の Histogram で index なし列の分布も Optimizer に教えられる。 ④ Hints は強力だが最後の手段。optimizer_switch はOptimizer 内部のアルゴリズム ON/OFF を切り替える フラグ群。本番でデフォルト挙動が変わったときの diagnostic に使います。
SELECT @@optimizer_switch\G # 8.0 のデフォルト主要フラグ: index_merge=on # 複数 index を組み合わせて使う index_merge_union=on index_merge_intersection=on index_condition_pushdown=on # ICP (後述) mrr=on # Multi-Range Read (後述) mrr_cost_based=on batched_key_access=off # BKA Join hash_join=on # 8.0.18+ semijoin=on # サブクエリを Join に materialization=on loosescan=on firstmatch=on condition_fanout_filter=on derived_merge=on derived_condition_pushdown=on # 8.0.22+ — 派生表に WHERE をプッシュダウン # 個別 ON / OFF SET SESSION optimizer_switch = 'mrr=off'; # 一時的に試したい時はクエリヒントで SELECT /*+ SET_VAR(optimizer_switch='mrr=off') */ ...
通常: index で行を見つけ → 行を読んで WHERE 評価。ICP 有効: index に存在する列に関する WHERE 条件を、行を読む前に index ノードで評価して不要な行読みを省く。
-- 例: idx_country_age = (country, age) という複合 index がある SELECT * FROM users WHERE country='JP' AND age > 30 AND name LIKE 'A%'; # ICP なし: # 1. idx_country_age で country='JP' の全行を index で見つける (1000 行) # 2. 各 PK で Clustered Index を引いて行を読む ← 1000 回 I/O # 3. age > 30 と name LIKE 'A%' を評価 # 4. 該当行を返す (100 行) # ICP あり: # 1. idx_country_age で country='JP' の行を index で走査 # 2. ★ index に age が含まれているので、age > 30 を index ノードで評価 # → age <= 30 の行は最初から読み飛ばす ← 100 回 I/O # 3. 残った 100 行を Clustered Index で読む # 4. name LIKE 'A%' を評価 # EXPLAIN の Extra に "Using index condition" → ICP 有効 # 確認: ICP を一時無効化 SET SESSION optimizer_switch = 'index_condition_pushdown=off'; EXPLAIN SELECT * FROM users WHERE country='JP' AND age > 30; -- Extra: Using where (ICP は使われていない)
Secondary Index で範囲スキャン → 各行を PK で引く、というアクセスはPK が飛び飛び なのでランダム I/O になります。MRR は取得すべき PK を一旦ソート してから読むことで、シーケンシャル I/O に近づけます。
-- MRR なし: SELECT * FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'; # 1. idx_created_at で範囲スキャン → PK 一覧 (1000 件) # 2. 各 PK を順番に Clustered Index で引く # → PK は時系列順に飛ぶので毎回ランダム I/O -- MRR あり: # 1. idx_created_at で範囲スキャン → PK 一覧 (1000 件) # 2. ★ PK をソート (read_rnd_buffer_size 上で) # 3. ソート済み PK 順に Clustered Index を読む # → PK 順 = ディスク順なのでシーケンシャル I/O に近づく # EXPLAIN の Extra に "Using MRR" # 設定 SET SESSION optimizer_switch = 'mrr=on,mrr_cost_based=off'; # mrr_cost_based=off にすると Optimizer のコスト判断ではなく強制 ON # read_rnd_buffer_size を増やすと多くの PK をソートできる SET SESSION read_rnd_buffer_size = 1024 * 1024; -- 1 MB
特定条件下では、GROUP BY のためにindex の全ノードを舐めずに飛び飛びに読んで結果を得る最適化。
-- 例: idx_country_city = (country, city) という複合 index
SELECT country, MAX(city) FROM users GROUP BY country;
# Tight Index Scan (通常):
# - 全 index エントリを順に読む → MAX を計算
# Loose Index Scan:
# - 各 country の先頭エントリだけを読む
# ('JP', 'Aichi'), ('JP', 'Akita'), ... の中から JP の最後を直接ジャンプ
# - 大規模で 100 倍以上速いことがある
# EXPLAIN の Extra に "Using index for group-by"
# 適用される条件 (制約強い):
# 1) 集約関数は MIN / MAX / COUNT(DISTINCT) のみ
# 2) GROUP BY 列が複合 index の左端から連続している
# 3) WHERE は index 列の等価条件のみMySQL で使えるインデックスは大きく 4 種類: B+Tree (InnoDB / MyISAM デフォルト)・Hash (MEMORY 専用)・FULLTEXT (InnoDB / MyISAM)・Spatial (InnoDB / MyISAM)。PostgreSQL の 6 種類 (GIN・GiST・BRIN ほか) と比べると選択肢は少なめです。
| 種別 | エンジン | 構造 | 得意 | 苦手 |
|---|---|---|---|---|
| B+Tree | 全エンジン | B+Tree | 範囲・等価・ソート・順序 | 部分一致 LIKE %先頭 |
| Hash | MEMORY のみ | Hash | 等価検索 (O(1)) | 範囲・ソート不可 |
| FULLTEXT | InnoDB / MyISAM | 転置 index | 全文検索・自然言語検索 | 通常の WHERE |
| Spatial | InnoDB / MyISAM | R-Tree | 地理空間データの範囲検索 | 通常の WHERE |
WHERE id = ? (PK 直引き)Clustered B+Tree (PRIMARY)WHERE email = ?B+Tree (UNIQUE INDEX)WHERE created_at BETWEEN ? AND ?B+Tree (範囲)ORDER BY created_at DESC LIMIT 10B+Tree (8.0+ で DESC index)WHERE tags MATCH ? AGAINST(?)FULLTEXT (ngram parser for 日本語)WHERE ST_Within(location, ?)SPATIAL (R-Tree)WHERE attr->'$.color' = ?Generated Column + B+Tree (Section 40)WHERE LOWER(name) = ?Functional Index (8.0.13+)Optimizer は時々複数の index を同時に使って 結果を合成します。WHERE col1 = ? OR col2 = ? や複数条件の AND で発火します。
-- idx_email と idx_phone が別々にある SELECT * FROM users WHERE email = 'a@e.com' OR phone = '090-1234-5678'; -- EXPLAIN: type=index_merge, Extra=Using union(idx_email,idx_phone) -- → 両方の index で見つけて結果を UNION -- 3 種類の Index Merge: -- index_merge_union: OR 条件で結果を合成 (重複除去) -- index_merge_intersection: AND 条件で交差を取る -- index_merge_sort_union: PK でソートしてから合成
INDEX (email, phone) ではなくINDEX (email) と INDEX (phone) の組合せが Merge を呼んでいる。Index は書き込みコストを増やすことを忘れがち。常に「効果があるか」を判断してから追加します。
gender (M/F) や is_active (0/1)。Optimizer が Full Scan を選ぶことが多いINDEX (a) があるなら INDEX (a, b) を追加する。両方持つのは無駄PRIMARY KEY (id) がある時に INDEX (id, x) はほぼ不要-- ① テーブル + index のサイズを見る SELECT TABLE_NAME, ROUND(DATA_LENGTH / 1024 / 1024, 1) AS data_MB, ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_MB, ROUND((INDEX_LENGTH / DATA_LENGTH) * 100, 1) AS index_pct FROM information_schema.tables WHERE table_schema='myapp' ORDER BY INDEX_LENGTH DESC LIMIT 10; -- ② 個別 index のサイズ SELECT STAT_NAME, STAT_VALUE, ROUND(STAT_VALUE * 16 / 1024, 1) AS size_MB -- 16K Page × Page 数 FROM mysql.innodb_index_stats WHERE database_name='myapp' AND table_name='users' AND STAT_NAME='size'; -- 目安: -- ・index_pct > 100% → index が data より大きい (要見直し) -- ・index 全体が Buffer Pool に乗らない → index が常時 disk hit -- ・Secondary index 1 件あたり = PK 値 + key 値 + 5-6 byte の overhead
InnoDB はClustered Index 方式です。これが PostgreSQL の Heap + 別 B-Tree とは決定的に違う設計で、Secondary Index の構造・性能特性が変わります。
Secondary Index で引いて、必要なカラムがすべてその index 自体に含まれて いれば、PK 側を引かなくて済みます (= Covering Index)。EXPLAIN の Extra に Using index と出ます。
-- ① idx_email = (email, name) という複合 index を作る CREATE INDEX idx_email_name ON users (email, name); -- ② name しか欲しくないクエリ SELECT name FROM users WHERE email = 'al@ex.com'; -- → idx_email_name の葉に email + name + PK が入っている -- → name もそこから取れる → Clustered Index を引かなくて済む -- EXPLAIN: Extra="Using index" (高速) -- 一方 SELECT name, address FROM users WHERE email = 'al@ex.com'; -- → idx_email_name に address は入っていない -- → Clustered Index を引いて address を取得 (2 段階探索)
16 KB Page が満杯になると、InnoDB はページ分割 (Page Split) を行います。これがコストの正体。
16 KB Page が満杯になったときに何が起きるか。「ランダム PK は遅い」の理由がこの絵で分かります。
全文検索 (FULLTEXT) と地理空間 (Spatial) は B+Tree とは別の仕組みのインデックスです。それぞれ「自然言語の検索」「地図上の検索」という、B+Tree では難しい用途のためにある。
InnoDB の FULLTEXT は転置インデックス (inverted index) の構造を持ち、文章を単語に分解 (Tokenize) して「単語 → その単語が出てくるドキュメント ID 一覧」を index に持ちます。
-- ① FULLTEXT index を作る
CREATE TABLE articles (
id BIGINT PRIMARY KEY,
title VARCHAR(200),
body TEXT,
FULLTEXT KEY ft_title_body (title, body) WITH PARSER ngram
) ENGINE=InnoDB CHARSET=utf8mb4;
-- ② 検索
SELECT id, title, MATCH(title, body) AGAINST('MySQL') AS score
FROM articles
WHERE MATCH(title, body) AGAINST('MySQL')
ORDER BY score DESC LIMIT 10;
-- ③ Boolean Mode (AND / OR / NOT)
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('+MySQL +index -PostgreSQL' IN BOOLEAN MODE);
-- ④ 自然言語モード (デフォルト) はランキング付き
-- Boolean モードは厳密な条件指定MySQL デフォルトの parser はスペース区切りを前提とした英語向け。日本語はそのままでは index されないので、WITH PARSER ngram を指定します。
ngram_token_size2 (デフォルト) — N 文字単位で切る「データベース」→「デー / ータ / タベ / ベー / ース」WITH PARSER ngramindex 作成時に指定日本語ならほぼ必須MeCab パーサプラグインで形態素解析精度高いが導入面倒・基本 ngram で十分innodb_ft_min_token_size最小語長 (デフォルト 3)英語向け — ngram の場合は 2 に変える地図上の「この範囲に含まれる点を全部取得」 のようなクエリは B+Tree では困難。Spatial Index は R-Tree で実装され、矩形のネストで範囲検索を効率化します。
-- ① テーブル定義 (8.0+ では SRID 必須)
CREATE TABLE stores (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
location POINT NOT NULL SRID 4326, -- WGS84 (緯度経度)
SPATIAL INDEX idx_location (location)
) ENGINE=InnoDB;
-- ② INSERT
INSERT INTO stores VALUES
(1, 'Tokyo Tower', ST_GeomFromText('POINT(35.6586 139.7454)', 4326));
-- ③ 範囲検索 (東京駅から 5km 以内)
SELECT id, name,
ST_Distance_Sphere(location, ST_GeomFromText('POINT(35.6812 139.7671)', 4326)) AS dist_m
FROM stores
WHERE ST_Within(
location,
ST_Buffer(ST_GeomFromText('POINT(35.6812 139.7671)', 4326), 0.05)
);
-- ④ K-Nearest Neighbor (最寄り 10 件)
SELECT id, name
FROM stores
ORDER BY ST_Distance_Sphere(
location,
ST_GeomFromText('POINT(35.6812 139.7671)', 4326)
) ASC LIMIT 10;基本の B+Tree から派生した index のバリエーションを扱います。Prefix Index・Functional Index (8.0.13+)・Descending Index (8.0.14+)・複合 (Composite) Index の 4 種。
-- email カラム (VARCHAR(255)) の先頭 16 文字だけで index を作る CREATE INDEX idx_email_prefix ON users (email(16)); -- これで OK だけど… -- ・index サイズが 1/16 に -- ・等価検索 (email = ?) は実用上問題なし -- ・LIKE 'al%' のような前方一致もそのまま効く -- ・ただし ORDER BY email では Using filesort が出ることがある
-- ① LOWER(email) で大文字小文字無視の検索を高速化 CREATE INDEX idx_email_lower ON users ((LOWER(email))); -- 効くクエリ SELECT * FROM users WHERE LOWER(email) = 'al@ex.com'; -- ② JSON 内の値を index CREATE INDEX idx_attr_color ON products ((CAST(attr->>'$.color' AS CHAR(20)))); -- ③ 日付からの抽出 CREATE INDEX idx_year ON orders ((YEAR(created_at))); SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- ① 降順 index CREATE INDEX idx_created_desc ON orders (created_at DESC); -- 効く: ORDER BY created_at DESC LIMIT 10 -- → 末尾から逆順走査で済む。Using filesort が消える -- ② 複合で混合 CREATE INDEX idx_country_created ON orders (country ASC, created_at DESC); -- 効く: WHERE country='JP' ORDER BY created_at DESC LIMIT 10
DESC 指定はパーサーが受け付けるだけで無視されていた (常に ASC として作られる)。8.0 で本当に降順 B+Tree が作られるようになった。複数カラムを束ねた index は「左端から順に」使われます。これを 「leftmost prefix」ルールと呼びます。
CREATE INDEX idx_cc ON orders (country, city, created_at); -- ○ 効く: 左端から順に使える WHERE country='JP' -- ◎ WHERE country='JP' AND city='Tokyo' -- ◎ WHERE country='JP' AND city='Tokyo' AND created_at > ? -- ◎ -- △ 部分的に効く WHERE country='JP' AND created_at > ? -- country のみ index、created_at は filter -- × 効かない WHERE city='Tokyo' -- 左端 country がないので使われない WHERE created_at > ? -- 同上
JSON 配列の各要素を個別の index エントリ として index する仕組み。「配列内のいずれかに該当値がある」検索が高速になります。
-- JSON 配列を持つテーブル
CREATE TABLE articles (
id BIGINT PRIMARY KEY,
title VARCHAR(200),
tags JSON, -- ["mysql", "database", "8.0"]
INDEX idx_tags ((CAST(tags->'$.tags[*]' AS CHAR(50) ARRAY)))
-- ↑ ARRAY が肝
);
INSERT INTO articles VALUES
(1, 'MySQL 8.0', JSON_OBJECT('tags', JSON_ARRAY('mysql', 'database'))),
(2, 'PostgreSQL', JSON_OBJECT('tags', JSON_ARRAY('postgres', 'database')));
-- 配列に "database" を含む記事を検索 — Multi-Value Index が使われる
SELECT * FROM articles
WHERE 'database' MEMBER OF (tags->'$.tags');
-- IN で複数値を OR
SELECT * FROM articles
WHERE JSON_OVERLAPS(tags->'$.tags', JSON_ARRAY('mysql', 'postgres'));
-- すべてを含む
SELECT * FROM articles
WHERE JSON_CONTAINS(tags->'$.tags', JSON_ARRAY('mysql', 'database'));
# EXPLAIN
+----+-------------+----------+-----+----------+--------+
| id | type | table | key | rows | Extra |
+----+-------------+----------+-----+----------+--------+
| 1 | range | articles | idx_tags | 1 | Using where; ... |
+----+-------------+----------+-----+----------+--------+innodb_max_n_fields_in_record = 1017 による行サイズ制限)。 ② MEMBER OF / JSON_OVERLAPS / JSON_CONTAINS でのみ使われる。 ③ Index の cardinality が読みづらいので Optimizer が選ばないこともある (FORCE INDEX が必要なケース)。ORDER BY ... DESC を高速化。 ④ Composite Index は左端優先ルール。等値 → 範囲 → 並び順の順で列を並べる。 ⑤ Multi-Value Index (8.0.17+) で JSON 配列内検索が index 駆動に。8.0+ で追加されたInvisible Index は、運用上の「これ消していい? 試したい」を解決する画期的な機能です。本セクション後半では index が効かない 10 大アンチパターン を整理します。
index を実際には残したまま、Optimizer から見えなくする機能。消したらクエリが遅くなるかもという不安を、本番で安全に検証できます。
-- ① 既存 index を見えなくする ALTER TABLE users ALTER INDEX idx_email INVISIBLE; -- → Optimizer は idx_email を無視してプランを立てる -- → 重要クエリのレイテンシを観察 -- → 大丈夫そう → 本当に DROP ALTER TABLE users DROP INDEX idx_email; -- → 駄目だったら戻す ALTER TABLE users ALTER INDEX idx_email VISIBLE; -- ② 新規 index を最初 invisible で作る (運用反映を切り離せる) CREATE INDEX idx_new ON users (col) INVISIBLE; -- 必要なときに ALTER TABLE users ALTER INDEX idx_new VISIBLE; -- ③ デバッグ・テスト時にだけ無視する (セッション単位) SET SESSION optimizer_switch = 'use_invisible_indexes=on'; EXPLAIN ...;
created_at >= '2026-01-01' AND created_at < '2027-01-01'インデックスは作って終わりではありません。INSERT / UPDATE / DELETE を繰り返すうちに断片化 (Fragmentation) し、サイズが膨らみ、パフォーマンスが落ちます。本セクションでは断片化対処と無停止スキーマ変更を扱います。
-- 断片化率を見る (data_free が大きいほど断片化) SELECT TABLE_NAME, ROUND(DATA_LENGTH / 1024 / 1024, 1) AS data_MB, ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_MB, ROUND(DATA_FREE / 1024 / 1024, 1) AS free_MB, ROUND(DATA_FREE * 100 / (DATA_LENGTH + INDEX_LENGTH + DATA_FREE), 1) AS frag_pct FROM information_schema.tables WHERE table_schema = 'myapp' ORDER BY DATA_FREE DESC LIMIT 20; -- frag_pct が 20% 超え → OPTIMIZE 候補
InnoDB 標準の方法。テーブル全体を作り直して断片を解消します。
-- ① OPTIMIZE TABLE OPTIMIZE TABLE users; -- → 内部的には ALTER TABLE ... FORCE; ANALYZE TABLE; と同等 -- → InnoDB では Online DDL なので<Em>ほぼ停止なし</Em> -- → ただし大規模テーブルでは Redo Log / Undo を大量に書く -- → 本番ではメンテ時間外に避けて打つ -- ② ALTER TABLE ... FORCE (同等) ALTER TABLE users FORCE, ALGORITHM=INPLACE, LOCK=NONE; -- ③ Online DDL の制御 ALTER TABLE users FORCE, ALGORITHM=INSTANT, -- 8.0+ 最速 (列追加など) LOCK=NONE;
8.0 で追加された ALGORITHM=INSTANT は、ALTER TABLE 系操作の一部を瞬時に 完了させます。データを書き換えず、メタデータだけ更新する仕組み。
ADD COLUMN (末尾)INSTANT○ 8.0.12+ で瞬時ADD COLUMN (任意位置)INPLACE8.0.29+ で INSTANT にDROP COLUMNINSTANT8.0.29+ で瞬時RENAME COLUMNINSTANT○ 瞬時CHANGE TYPECOPY× テーブル再構築 (重い)ADD INDEXINPLACE△ Online DDL だが書き込みは続行可CHANGE PKCOPY× テーブル再構築 (超重い)本当に重い ALTER (CHANGE TYPE / 主キー変更) を本番で打つときは、サードパーティツールを使うのが定石。
gh-ost が真に評価されるのはcut-over (テーブル名 swap) の挙動です。Atomic な RENAME で外部から見て不可分に切り替えます。
# 通常運用フェーズ: # 元: users (元テーブル) # _users_gho (新スキーマで作った shadow) # # binlog を読んで users への INSERT/UPDATE/DELETE を _users_gho にも適用 # 既存行を copy # # Cut-over フェーズ (cut-over=atomic): # 1. _users_old という magic lock テーブルを作る # 2. lock _users_gho WRITE; create _users_old ... LIKE users; # 3. RENAME TABLE users TO _users_old, _users_gho TO users; # ↑ アトミック (両 RENAME がトランザクショナル) # 4. 残った binlog イベントを apply して完了 # # つまり users への INSERT を投げ続けたまま、瞬間的にスキーマが変わる # アプリは「テーブル名は users のまま」「中身が変わった」を体験する # gh-ost 実行例 gh-ost \ --host=primary \ --user=ghost --password=xxx \ --database=myapp --table=users \ --alter="ADD COLUMN status TINYINT DEFAULT 0 AFTER name" \ --max-load='Threads_running=25' \ --critical-load='Threads_running=100' \ --chunk-size=1000 \ --throttle-control-replicas='replica1,replica2' \ --cut-over=atomic \ --switch-to-rbr \ --execute # postponed cut-over: いつでも投入準備完了 → 手動コマンドで切替 gh-ost ... --postpone-cut-over-flag-file=/tmp/ghost.postpone --execute
mysqldump より圧倒的に速い OSS ダンプツール。util.dumpInstance (Section 51) が 8.0 で公式化される前のデファクト。
# ダンプ mydumper \ --host=primary \ --user=backup --password=xxx \ --database=myapp \ --threads=8 \ --rows=1000000 \ --compress \ --trx-consistency-only \ # InnoDB のみのスナップショット (高速) --outputdir=/backup/dump # ロード (別環境) myloader \ --host=target \ --user=admin --password=xxx \ --database=myapp \ --threads=16 \ --queries-per-transaction=1000 \ --directory=/backup/dump # 比較: # - 100 GB DB # - mysqldump 単一スレッド: 数時間 # - MyDumper 8 並列: 30 分前後 # - util.dumpInstance (8.0): 20 分前後 (zstd 圧縮で更に小)
information_schema.tables.DATA_FREE で断片化を見る。 ② OPTIMIZE TABLE = ALTER FORCE。InnoDB なら Online DDL でほぼ停止なし。 ③ 8.0 の ALGORITHM=INSTANT で列追加・削除が瞬時に。 ④ 重い ALTER は gh-ost (or pt-osc) で無停止に。Atomic RENAME で cut-over が一瞬。 ⑤ ダンプ・ロードの並列化は MyDumper / util.dumpInstance。SQL 標準には 4 つの分離レベルがあります。READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE。MySQL のデフォルトは REPEATABLE READ (PostgreSQL / Oracle / SQL Server は READ COMMITTED — ここが大きく違う)。
マトリクスのセルをクリックすると、その組合せで何が起きるかの解説が表示されます。★ マークは MySQL InnoDB が SQL 標準を超えて防いでいる組合せです。
| Dirty Read | Non-Repeatable | Phantom | |
|---|---|---|---|
| READ UNCOMMITTED | ✗ 起きる | ✗ 起きる | ✗ 起きる |
| READ COMMITTED | ○ 防ぐ | ✗ 起きる | ✗ 起きる |
| REPEATABLE READ ★ | ○ 防ぐ | ○ 防ぐ | ○ 防ぐ★ |
| SERIALIZABLE | ○ 防ぐ | ○ 防ぐ | ○ 防ぐ |
| 分離レベル | Dirty Read | Non-Repeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| READ UNCOMMITTED | ✗ 起きる | ✗ | ✗ | ✗ |
| READ COMMITTED | ○ 防ぐ | ✗ | ✗ | ✗ |
| REPEATABLE READ ★ | ○ | ○ | ○ (Gap Lock で防止) | ✗ |
| SERIALIZABLE | ○ | ○ | ○ | ○ |
SQL 標準では REPEATABLE READ は Phantom Read を防がない、とされています。しかし InnoDB はGap Lock (次節 Section 25) を組み合わせることで Phantom Read を防止しています。これが MySQL の独自性。
時刻 TX_A (REPEATABLE READ) TX_B
─────────────────────────────────────────────────────────────────
T1 START TRANSACTION;
SELECT * FROM orders
WHERE user_id = 1 FOR UPDATE;
→ 0 件
# ★ user_id=1 の "範囲" に Gap Lock 確保
T2 START TRANSACTION;
INSERT INTO orders
(user_id, ...) VALUES (1, ...);
# ★ Gap Lock 待ち (ブロック)
T3 SELECT * FROM orders
WHERE user_id = 1 FOR UPDATE;
→ 0 件 (Phantom なし)
COMMIT;
T4 # ↑ Gap Lock 解放されたので
# INSERT 続行 → COMMITREAD COMMITTED をデフォルトに切り替える運用も多い。-- 現在の分離レベル SELECT @@global.transaction_isolation; -- グローバル SELECT @@session.transaction_isolation; -- セッション -- セッション単位で変更 (次 TX から) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- このトランザクションだけ SET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; ... COMMIT; -- グローバル (要 SUPER 権限) SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; -- my.cnf [mysqld] transaction_isolation = READ-COMMITTED
InnoDB のロックは本番デッドロックの 9 割の原因です。ここを理解すると、なぜ「ある UPDATE と別の INSERT がデッドロックしているのか」 が読めるようになります。
users テーブルに id = 10, 20, 30 の 3 行があるとします。SELECT * FROM users WHERE id BETWEEN 15 AND 25 FOR UPDATE でかかる Next-Key Lock の範囲:
SELECT ... FOR UPDATE したつもりが、想定外の Gap Lock がかかって別 INSERT がブロックされる」。Section 26 で実例。-- ① 現在のロック状況 (8.0+ の決定版) SELECT * FROM performance_schema.data_locks; -- カラム: -- ENGINE_LOCK_ID : ロック ID -- ENGINE_TRANSACTION_ID : TX ID -- THREAD_ID : 持っているスレッド -- OBJECT_NAME : テーブル名 -- INDEX_NAME : ロックがかかる index -- LOCK_TYPE : RECORD / TABLE -- LOCK_MODE : S / X / IS / IX / S,GAP / X,GAP / S,REC_NOT_GAP / ... -- LOCK_STATUS : GRANTED / WAITING -- LOCK_DATA : ロックされている値 -- ② Lock 待ち情報 SELECT * FROM performance_schema.data_lock_waits; -- ③ デッドロック情報 (最後の 1 件) SHOW ENGINE INNODB STATUS\G -- "LATEST DETECTED DEADLOCK" セクション -- ④ 5.7 以前は information_schema.innodb_locks (廃止) -- 8.0 では data_locks に置き換え
ALTER TABLE が「Waiting for table metadata lock」で止まるのは、別の TX がそのテーブルに対して MDL を取っているからです。
-- 確認方法 SELECT * FROM performance_schema.metadata_locks WHERE OBJECT_TYPE='TABLE'; -- 典型シナリオ -- TX_1: SELECT してから BEGIN しっぱなしで放置 -- TX_2: ALTER TABLE → MDL 待ちで永久に停止 -- → TX_1 を kill する必要 -- アラート設定 -- lock_wait_timeout で ALTER を諦めさせる SET SESSION lock_wait_timeout = 5; -- 5秒で諦める
実務で「今ブロックされている TX を全部出して」と言われたら、これ。8.0 で追加された data_lock_waits はロックの待ちグラフ を直接見せてくれます。
-- ① 待ち関係を一覧表示 (待っている side / 持っている side)
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query,
TIMESTAMPDIFF(SECOND, b.trx_started, NOW()) AS blocking_age_sec
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON w.REQUESTING_ENGINE_TRANSACTION_ID = r.trx_id
JOIN information_schema.innodb_trx b ON w.BLOCKING_ENGINE_TRANSACTION_ID = b.trx_id;
-- ② ロックの種別と対象も詳しく
SELECT
w.REQUESTING_THREAD_ID AS waiting_thread,
w.BLOCKING_THREAD_ID AS blocking_thread,
rl.OBJECT_NAME,
rl.INDEX_NAME,
rl.LOCK_MODE AS waiting_mode,
bl.LOCK_MODE AS blocking_mode,
rl.LOCK_DATA
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks rl
ON w.REQUESTING_ENGINE_LOCK_ID = rl.ENGINE_LOCK_ID
JOIN performance_schema.data_locks bl
ON w.BLOCKING_ENGINE_LOCK_ID = bl.ENGINE_LOCK_ID;
-- ③ sys schema の便利ビュー (人間向け)
SELECT * FROM sys.innodb_lock_waits;
-- ④ 一気に問題スレッドを kill するスクリプト材料
SELECT CONCAT('KILL ', blocking_pid, ';') AS kill_cmd
FROM sys.innodb_lock_waits
WHERE wait_age_secs > 30;performance_schema.data_locks でロックの全体像が見える (8.0+)。 ④ MDL で ALTER TABLE が止まったら、長期間 open な TX を kill。 ⑤ data_lock_waits + sys.innodb_lock_waits で「誰が誰を待っているか」を 1 SQL で。デッドロックとは「2 つ以上の TX が互いに相手のロック解放を待ち続けて、永遠に進めなくなる」状態。InnoDB は自動検出してどちらかを ROLLBACK させて解決します。
下のボタンで 1 step ずつ進めて、2 つのトランザクションがどうやってデッドロックに至るか、InnoDB がどうやって検出・解消するかを観察してください。
時刻 TX_A TX_B
─────────────────────────────────────────────────────────
T1 UPDATE u SET ... WHERE id=1;
# TX_A が id=1 の Record Lock 取得
T2 UPDATE u SET ... WHERE id=2;
# TX_B が id=2 の Record Lock 取得
T3 UPDATE u SET ... WHERE id=2;
# TX_A は id=2 を待つ
T4 UPDATE u SET ... WHERE id=1;
# TX_B は id=1 を待つ
# → デッドロック!
T5 ← InnoDB デッドロック検出器が発火 (1 秒以内)
← より新しい / 影響範囲の小さい TX を選んで ROLLBACK
T6 ERROR 1213 (40001): Deadlock found
when trying to get lock; try restarting transactionSHOW ENGINE INNODB STATUS\G # 出力の "LATEST DETECTED DEADLOCK" セクション ------------------------ LATEST DETECTED DEADLOCK ------------------------ 2026-06-12 10:30:45 0x7f8b... *** (1) TRANSACTION: TRANSACTION 412938, ACTIVE 0.123 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 24, OS thread handle 0x7f..., query id 482 host=10.0.0.4 user=alice updating UPDATE u SET ... WHERE id=2 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 41 page no 5 n bits 80 index PRIMARY of table myapp.users trx id 412938 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 412939, ACTIVE 0.087 sec starting index read ... UPDATE u SET ... WHERE id=1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 41 page no 5 n bits 80 index PRIMARY of table myapp.users trx id 412939 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... *** WE ROLL BACK TRANSACTION (2)
SELECT ... FOR UPDATE が広範に Gap Lock を取り、別 TX の INSERT がブロックされてデッドロック。対処: READ COMMITTED に切替 (Gap Lock が消える) / SELECT 範囲を狭める。INSERT ... ON DUPLICATE KEY UPDATE でアトミックに。SHOW ENGINE INNODB STATUS で原因分析。 ③ 「同じ順序でロックを取る」が最強の予防策。 ④ Gap Lock 由来なら READ COMMITTED を検討。Performance Schema は MySQL 内部のすべての計測ポイントを保持する仕組み。sys schema は P_S の上に作られた人間向けビューです。本番調査の主役。
-- ① 重い SQL TOP 10 (合計実行時間でソート) SELECT DIGEST_TEXT, COUNT_STAR AS exec_count, ROUND(SUM_TIMER_WAIT / 1e9 / 1000, 1) AS total_sec, ROUND(AVG_TIMER_WAIT / 1e9, 2) AS avg_ms, SUM_ROWS_EXAMINED, SUM_ROWS_SENT FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 10; -- ② スロークエリ TOP 10 (1回平均で遅いもの) SELECT DIGEST_TEXT, COUNT_STAR, ROUND(AVG_TIMER_WAIT / 1e9, 2) AS avg_ms, ROUND(MAX_TIMER_WAIT / 1e9, 2) AS max_ms FROM performance_schema.events_statements_summary_by_digest WHERE AVG_TIMER_WAIT > 1e9 * 100 -- 100 ms 以上 ORDER BY AVG_TIMER_WAIT DESC LIMIT 10; -- ③ 待ち時間の長いイベント TOP (I/O 待ち / Mutex 待ち等) SELECT EVENT_NAME, COUNT_STAR, ROUND(SUM_TIMER_WAIT / 1e9 / 1000, 1) AS total_sec FROM performance_schema.events_waits_summary_global_by_event_name WHERE COUNT_STAR > 0 ORDER BY SUM_TIMER_WAIT DESC LIMIT 20; -- ④ ファイル別 I/O 統計 (どのテーブルの I/O が支配的?) SELECT FILE_NAME, COUNT_READ + COUNT_WRITE AS total_io, ROUND(SUM_NUMBER_OF_BYTES_READ / 1024/1024, 1) AS read_MB, ROUND(SUM_NUMBER_OF_BYTES_WRITE / 1024/1024, 1) AS write_MB FROM performance_schema.file_summary_by_instance WHERE FILE_NAME LIKE '%.ibd' ORDER BY total_io DESC LIMIT 10;
-- ① 重い SQL (sys.statement_analysis) SELECT * FROM sys.statement_analysis ORDER BY total_latency DESC LIMIT 10; -- ② 効いていない (= rows_examined / rows_sent が大きい) クエリ SELECT * FROM sys.statements_with_full_table_scans LIMIT 10; SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10; -- ③ 使われていない index SELECT * FROM sys.schema_unused_indexes; -- ④ 重複 / 冗長な index SELECT * FROM sys.schema_redundant_indexes; -- ⑤ Buffer Pool ヒット状況をテーブル別に SELECT * FROM sys.x$innodb_buffer_stats_by_table LIMIT 10; -- ⑥ 接続別ステータス (誰が今 mysqld を使っているか) SELECT * FROM sys.processlist WHERE conn_id IS NOT NULL ORDER BY trx_started ASC LIMIT 20;
Performance Schema はデフォルトで一部の計測しか有効化されていません。本格的なパフォーマンス解析には個別の計測項目を有効化する必要があります。
-- ① 計測対象 (instrument) を有効化
SELECT name, enabled, timed
FROM performance_schema.setup_instruments
WHERE name LIKE 'wait/io/%';
-- enabled=NO のものを ON にする
UPDATE performance_schema.setup_instruments
SET enabled='YES', timed='YES'
WHERE name LIKE 'wait/io/%';
-- ② 消費先 (consumer) を有効化
SELECT * FROM performance_schema.setup_consumers;
-- events_statements_history_long などが OFF だと長期保存されない
UPDATE performance_schema.setup_consumers
SET enabled='YES'
WHERE name LIKE 'events_%_history%';
-- ③ Memory instruments (8.0+ で重要)
UPDATE performance_schema.setup_instruments
SET enabled='YES'
WHERE name LIKE 'memory/%';
-- ④ Threads の計測 (thread-level performance)
UPDATE performance_schema.setup_consumers
SET enabled='YES'
WHERE name = 'thread_instrumentation';
-- ⑤ 一括有効化マクロ
CALL sys.ps_setup_enable_consumer('events_statements_history_long');
CALL sys.ps_setup_enable_instrument('%');
-- ⑥ 永続化: my.cnf
[mysqld]
performance-schema-instrument='wait/%=ON'
performance-schema-instrument='memory/%=ON'
performance-schema-consumer-events-statements-history-long=ON
performance-schema-consumer-events-waits-history-long=ONevents_statements_summary_by_digest はサーバ再起動でリセット される。本番で長期間のメトリクス保存には Prometheus + exporter / Datadog Database Monitoring などのツールに送る運用が定石。スロークエリの特定と対処は本番運用で最頻出の業務。MySQL では slow_query_log + pt-query-digest が定番の組合せです。
-- 動的に有効化 SET GLOBAL slow_query_log = 'ON'; SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log'; SET GLOBAL long_query_time = 1.0; -- 1 秒以上を記録 SET GLOBAL log_queries_not_using_indexes = 'ON'; -- index 未使用も SET GLOBAL log_slow_admin_statements = 'ON'; -- DDL も -- my.cnf [mysqld] slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1.0 log_queries_not_using_indexes = ON -- ログ出力例 # Time: 2026-06-12T10:30:45.123456Z # User@Host: alice[alice] @ [10.0.0.4] Id: 482 # Query_time: 3.456789 Lock_time: 0.000123 Rows_sent: 1234 Rows_examined: 567890 SET timestamp=1718189445; SELECT * FROM orders WHERE country='JP' AND created_at > '2026-01-01';
スロークエリログを SQL のパターン (digest) ごとに集計するツール。Percona Toolkit に含まれます。
# インストール (Ubuntu) apt install percona-toolkit # 解析 pt-query-digest /var/log/mysql/slow.log > report.txt # 出力例 # Rank Query ID Response time Calls # ==== ================================= ============== ===== # 1 0x6F50A0... 234.5s 45% 1234 # avg 190ms # Tables: orders, users # SELECT * FROM orders o JOIN users u ON o.user_id=u.id ... # Performance Schema からも集計可能 pt-query-digest --type=processlist h=localhost,u=root,p=xxx
PostgreSQL には遅いクエリの実行プランを自動でログに残す auto_explain 拡張がありますが、MySQL には無い。代わりに以下のアプローチを取ります。
8.0 からlog_slow_extra でスロークエリログに大量の追加情報が記録できるようになりました。pt-query-digest が拾える情報が圧倒的に増えます。
-- ① log_slow_extra で詳細出力 SET GLOBAL log_slow_extra = 'ON'; -- ② スロークエリログの出力例 (extra ON) # Time: 2026-06-12T10:30:45.123Z # User@Host: alice[alice] @ [10.0.0.4] # Thread_id: 482 Schema: myapp QC_hit: No # Query_time: 3.456789 Lock_time: 0.000123 # Rows_sent: 1234 Rows_examined: 567890 # Rows_affected: 0 Bytes_sent: 245678 # Tmp_tables: 1 Tmp_disk_tables: 0 ← ★ 一時表が disk に # Tmp_table_sizes: 16384 # Sort_rows: 1234 Sort_scan_count: 0 Sort_range_count: 1 # Sort_merge_passes: 0 # Filesort: Yes Filesort_on_disk: No # Innodb_io_r_ops: 1234 Innodb_io_r_bytes: 20180992 Innodb_io_r_wait: 1.234567 # Innodb_rec_lock_wait: 0.000000 Innodb_queue_wait: 0.000000 # Innodb_pages_distinct: 5678 SET timestamp=...; SELECT ...; -- ③ スロークエリログの追加フィルタ SET GLOBAL log_queries_not_using_indexes = 'ON'; SET GLOBAL log_throttle_queries_not_using_indexes = 60; -- 1 分間に 60 件まで (ノイズ抑制) SET GLOBAL min_examined_row_limit = 100; -- 検査行数が 100 未満のクエリは除外 -- ④ クエリ単位でフィルタ SELECT /*+ SET_VAR(long_query_time=0) */ ... ; -- 強制ログ SELECT /*+ SET_VAR(long_query_time=999) */ ... ; -- 強制除外
pt-query-digest で上位 20 件特定 → ② 1 件ずつ EXPLAIN ANALYZE→ ③ index 追加 / クエリ書換え / アプリ修正 → ④ SET GLOBAL slow_query_log='OFF'; ON; でログをリセット → ⑤ 翌週も計測。本番チューニングの主役パラメータを「効きが大きい順」で整理します。これらが正しく設定されていれば、細かい変数いじりはほぼ不要です。
innodb_buffer_pool_size★★★★★ — これで 90% 決まる128 MB物理 RAM × 50〜75%innodb_log_file_size★★★★ — Sharp Checkpoint を防ぐ48 MB1〜4 GB (writes が多いなら大きく)innodb_io_capacity★★★★ — Page Cleaner の throughput200SSD: 2000〜5000 / NVMe: 10000+innodb_flush_log_at_trx_commit★★★ — 耐久性 vs 速度11 (本番) / 2 (バッチ取込中のみ)sync_binlog★★★ — レプリ整合性11innodb_buffer_pool_instances物理コア数Buffer Pool を N 分割して並行性向上innodb_log_buffer_size64 MB大きな TX を扱うなら増やすinnodb_thread_concurrency0 (= 制限なし)8.0 では 0 が推奨innodb_read_io_threads / write_io_threads8 / 8SSD/NVMe なら 8〜16innodb_page_cleaners物理コア数Page Cleaner の並列度max_connections500〜1000プールと組み合わせて適正にthread_cache_size50接続急増時の対応table_open_cache4000テーブル数が多いシステムで増やすinnodb_stats_persistentON統計を永続化 (デフォルト ON)innodb_stats_auto_recalcON自動 ANALYZE (デフォルト ON)[mysqld] # --- InnoDB Core --- innodb_buffer_pool_size = 12G innodb_buffer_pool_instances = 8 innodb_log_file_size = 2G innodb_log_buffer_size = 64M innodb_flush_log_at_trx_commit = 1 innodb_io_capacity = 2000 innodb_io_capacity_max = 4000 innodb_flush_neighbors = 0 # SSD では 0 が良い # --- I/O Threads --- innodb_read_io_threads = 8 innodb_write_io_threads = 8 innodb_page_cleaners = 8 # --- Replication / binlog --- server-id = 1 log-bin = /var/log/mysql/binlog binlog_format = ROW sync_binlog = 1 gtid_mode = ON enforce_gtid_consistency = ON # --- Connections --- max_connections = 500 thread_cache_size = 50 wait_timeout = 28800 # --- Charset --- character-set-server = utf8mb4 collation-server = utf8mb4_0900_ai_ci # --- Slow Log --- slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1.0
mysqld を再起動すると Buffer Pool が空になり、しばらく性能が出ません (= cold cache)。Buffer Pool dump/load で「停止前の状態の Page」を停止後すぐに復元できます。
-- ① 設定 (デフォルト ON が多い) SHOW VARIABLES LIKE 'innodb_buffer_pool_dump_%'; -- innodb_buffer_pool_dump_at_shutdown ON -- 停止時に自動 dump -- innodb_buffer_pool_dump_now OFF -- 即時 dump フラグ -- innodb_buffer_pool_dump_pct 25 -- 上位 25% の Page だけ SHOW VARIABLES LIKE 'innodb_buffer_pool_load_%'; -- innodb_buffer_pool_load_at_startup ON -- innodb_buffer_pool_load_now OFF -- innodb_buffer_pool_load_abort OFF -- ② dump ファイルの場所 ls -la /var/lib/mysql/ib_buffer_pool -- 数百 KB 〜数 MB -- ③ ファイルの中身は <space_id, page_no> のリストだけ (実データではない) -- → mysqld 起動時にこのリストを元に該当 Page を ディスクから Buffer Pool に読み込む -- ④ 手動 dump (運用中の状態を保存しておきたい) SET GLOBAL innodb_buffer_pool_dump_now = ON; -- ⑤ 進捗確認 SHOW STATUS LIKE 'Innodb_buffer_pool_dump_status'; SHOW STATUS LIKE 'Innodb_buffer_pool_load_status'; -- "Buffer pool(s) load completed at 2026-06-12 10:30:45" -- ⑥ 起動を待たずに別途 warmup したい SET GLOBAL innodb_buffer_pool_load_now = ON;
innodb_buffer_pool_size。物理 RAM の 50〜75%。 ② innodb_log_file_size を 2 GB 以上にして Sharp Checkpoint を防ぐ。 ③ innodb_io_capacity をストレージ IOPS に合わせる。 ④ sync_binlog=1 + innodb_flush_log_at_trx_commit=1 が本番標準。 ⑤ Buffer Pool dump/load で再起動後の cold cache を回避。パーティショニングは「1 つのテーブルを内部的に複数に分割」する仕組み。アプリからは 1 テーブルに見えますが、内部では条件 (PARTITION KEY) によって別ファイルに分かれます。
created_at の年月で分割 → 古いデータの DROP が高速country IN ('JP','KR') / ('US','CA') で分割HASH(user_id) % 8 で 8 個に分散 → 並列処理にPK / UNIQUE KEY ベースで自動分散CREATE TABLE events (
id BIGINT,
created_at DATETIME NOT NULL,
body JSON,
PRIMARY KEY (id, created_at) -- ← パーティションキーは PK に含める必要
) PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p2024 VALUES LESS THAN (TO_DAYS('2025-01-01')),
PARTITION p2025 VALUES LESS THAN (TO_DAYS('2026-01-01')),
PARTITION p2026 VALUES LESS THAN (TO_DAYS('2027-01-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- 古い年のデータを一瞬で削除 (Partition Drop)
ALTER TABLE events DROP PARTITION p2024;
-- 新しい年を追加
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2027 VALUES LESS THAN (TO_DAYS('2028-01-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- Partition Pruning が効くかを確認
EXPLAIN PARTITIONS SELECT * FROM events
WHERE created_at BETWEEN '2026-01-01' AND '2026-12-31';
-- → partitions: p2026 (他は読まない)MySQL のレプリケーションはBinary Log ベース。Primary が binlog にイベントを書き、Replica がそれを Relay Log にコピーして自分の DB に再適用する仕組み。
Primary でコミットされた TX が、Replica で適用されるまでのタイムラグ を可視化します。「+1s」ボタンを押して時間を進めてください。Seconds_Behind_Source がじわじわ伸びるのを観察できます。
Seconds_Behind_Source がじわじわ伸びる典型例です。昔の 「binlog ファイル名 + position」方式と違い、各トランザクションにグローバルにユニークな ID (GTID) を付ける方式。フェイルオーバー時のレプリ張り直しが圧倒的に楽。
-- my.cnf (両方で) [mysqld] gtid_mode = ON enforce_gtid_consistency = ON log-bin = mysql-bin binlog_format = ROW server-id = 1 # 各サーバでユニーク -- ① レプリ設定 (8.0+ では CHANGE REPLICATION SOURCE) CHANGE REPLICATION SOURCE TO SOURCE_HOST='primary.example.com', SOURCE_USER='repl', SOURCE_PASSWORD='xxx', SOURCE_AUTO_POSITION=1; -- GTID で自動位置決め START REPLICA; -- ② 状態確認 SHOW REPLICA STATUS\G -- 確認ポイント: -- - Replica_IO_Running: Yes -- - Replica_SQL_Running: Yes -- - Seconds_Behind_Source: 0 (遅延 0 が理想) -- - Retrieved_Gtid_Set / Executed_Gtid_Set
5.7.6+ でMulti-Source Replication が公式サポート。1 つの Replica が複数の Primary から binlog を受け取れるようになりました。各レプリ接続は Channel という名前で識別されます。
-- Channel 'shard_a' から東京の Primary を購読 CHANGE REPLICATION SOURCE TO SOURCE_HOST='tokyo-primary', SOURCE_USER='repl', SOURCE_PASSWORD='xxx', SOURCE_AUTO_POSITION=1 FOR CHANNEL 'shard_a'; -- Channel 'shard_b' から大阪の Primary を購読 CHANGE REPLICATION SOURCE TO SOURCE_HOST='osaka-primary', SOURCE_USER='repl', SOURCE_PASSWORD='xxx', SOURCE_AUTO_POSITION=1 FOR CHANNEL 'shard_b'; -- 個別 Channel 操作 START REPLICA FOR CHANNEL 'shard_a'; STOP REPLICA FOR CHANNEL 'shard_b'; -- 状態確認 SHOW REPLICA STATUS FOR CHANNEL 'shard_a'\G -- 全 Channel 一覧 SELECT * FROM performance_schema.replication_connection_status; -- ★ Replication Filter で衝突回避 CHANGE REPLICATION FILTER REPLICATE_DO_DB = (shard_a_db) FOR CHANNEL 'shard_a'; CHANGE REPLICATION FILTER REPLICATE_DO_DB = (shard_b_db) FOR CHANNEL 'shard_b'; -- ユースケース: -- ・複数シャードのデータを 1 つの分析 Replica に集約 -- ・地理分散した複数 Primary の Active-Active 構成 -- ・本番と参考環境を 1 Replica に両方ミラー (テスト用)
Group Replication (MGR) は MySQL 5.7.17+ で追加された、合意ベースの論理的同期 レプリケーション。Paxos 派生のアルゴリズムで過半数の合意を取ってからコミットします。InnoDB Cluster の基盤。
| 観点 | 従来 Replication | Group Replication |
|---|---|---|
| コミット方式 | 非同期 (Primary が単独で判断) | 過半数の合意 |
| メンバー検出 | 手動 (CHANGE REPLICATION SOURCE) | 自動 (Group の参加・離脱を検出) |
| フェイルオーバー | 手動 or MHA/Orchestrator | 自動 (新 Primary を投票で決定) |
| スプリットブレイン | 対策が別途必要 | 過半数の原理で自動回避 |
| モード | 1 Primary - N Replica | Single-Primary or Multi-Primary |
| 最小ノード数 | 2 (Primary + 1 Replica) | 3 (合意のため奇数) |
-- 各ノードの my.cnf [mysqld] server_id = 1 # 各ノードでユニーク gtid_mode = ON enforce_gtid_consistency = ON binlog_checksum = NONE plugin_load_add = 'group_replication.so' # Group Replication 設定 group_replication_group_name = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' group_replication_local_address = '10.0.0.1:33061' group_replication_group_seeds = '10.0.0.1:33061,10.0.0.2:33061,10.0.0.3:33061' group_replication_bootstrap_group = OFF group_replication_single_primary_mode = ON -- 起動 (最初のノードだけ bootstrap) INSTALL PLUGIN group_replication SONAME 'group_replication.so'; SET GLOBAL group_replication_bootstrap_group = ON; START GROUP_REPLICATION; SET GLOBAL group_replication_bootstrap_group = OFF; -- 2 番目以降のノード START GROUP_REPLICATION; -- 状態確認 SELECT * FROM performance_schema.replication_group_members; -- MEMBER_ID | MEMBER_HOST | MEMBER_STATE | MEMBER_ROLE -- (uuid) | host1 | ONLINE | PRIMARY -- (uuid) | host2 | ONLINE | SECONDARY -- (uuid) | host3 | ONLINE | SECONDARY
Group Replication では過半数の合意が必要なので、1 ノードが遅いと全体が詰まります。これを防ぐのが Flow Control: 遅延が大きいノードがあるとき、Primary 側の書込み速度を自動的に絞ります。
-- ① 設定 SHOW VARIABLES LIKE 'group_replication_flow_control_%'; -- group_replication_flow_control_mode = QUOTA (デフォルト) -- group_replication_flow_control_certifier_threshold = 25000 -- group_replication_flow_control_applier_threshold = 25000 -- group_replication_flow_control_max_quota = 0 (無制限) -- group_replication_flow_control_min_quota = 0 -- group_replication_flow_control_min_recovery_quota = 0 -- 仕組み: -- 各 Replica が「Certifier キューに何件溜まっているか」「Applier キューに何件溜まっているか」を全員に通知 -- 閾値 (25000 件) を超えたら、Primary 側は<Em>次サイクルの書込み quota</Em> を絞る -- → 全体としてキューが定常状態に収束する -- ② Flow Control の状態を確認 SELECT * FROM performance_schema.replication_group_member_stats; -- COUNT_TRANSACTIONS_IN_QUEUE: Apply 待ち -- COUNT_TRANSACTIONS_CHECKED: Certify 済 -- COUNT_CONFLICTS_DETECTED: Certify 衝突 -- ③ チューニング SET GLOBAL group_replication_flow_control_mode = 'QUOTA'; SET GLOBAL group_replication_flow_control_certifier_threshold = 50000; -- 大きくする = 遅延を許容 / 書込み詰まりを軽減 -- 小さくする = 遅延を抑える / 書込みは絞られがち -- ④ Flow Control を完全無効化 (本番非推奨) SET GLOBAL group_replication_flow_control_mode = 'DISABLED';
performance_schema.replication_group_members を 1 分ごとに polling。 ⑤ Flow Control が頻繁に効くなら遅いノードを疑う。複数の MySQL ノードに読み書き分離するためのプロキシ層が必要。MySQL の世界では MySQL Router (公式) と ProxySQL (サードパーティ) の二択。
# MySQL Router 設定 (mysqlrouter.conf) [routing:rw] bind_address = 0.0.0.0:6446 destinations = metadata-cache://mycluster/?role=PRIMARY routing_strategy = first-available [routing:ro] bind_address = 0.0.0.0:6447 destinations = metadata-cache://mycluster/?role=SECONDARY routing_strategy = round-robin-with-fallback # アプリ側はポート番号で読み書き分離 # WRITE → 6446 (Primary に振られる) # READ → 6447 (Replica に round-robin)
-- ① Hostgroup を定義 (10=write, 20=read) INSERT INTO mysql_servers (hostgroup_id, hostname, port, weight, max_connections) VALUES (10, 'primary.example.com', 3306, 1000, 200), (20, 'replica1.example.com', 3306, 500, 200), (20, 'replica2.example.com', 3306, 500, 200), (20, 'replica3.example.com', 3306, 500, 200); -- ② Query Rule で振り分け INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply) VALUES (1, 1, '^SELECT.*FOR UPDATE$', 10, 1), -- ロック取る SELECT は Primary (2, 1, '^SELECT.*FOR SHARE$', 10, 1), (3, 1, '^SELECT @@(version|hostname|server_id)', 20, 1), -- メタクエリは Replica (4, 1, '^SELECT.*FROM information_schema', 20, 1), (5, 1, '^SELECT.*FROM performance_schema', 20, 1), (6, 1, '^SELECT', 20, 1), -- 通常 SELECT は Replica (7, 1, '^', 10, 1); -- それ以外は Primary -- ③ ロード時間ベースのキャッシュ (重い集計を ProxySQL でキャッシュ) INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, cache_ttl) VALUES (100, 1, '^SELECT COUNT.*FROM big_aggregate_table', 20, 60000); -- 60 秒キャッシュ LOAD MYSQL SERVERS TO RUNTIME; LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL SERVERS TO DISK; SAVE MYSQL QUERY RULES TO DISK;
ProxySQL はConnection Multiplexing という仕組みで「アプリ接続 N 個 → MySQL 接続 1 個」のように多重化できます。Primary 障害時の挙動の鍵になります。
-- ① モニタリング設定 SET mysql-monitor_writer_is_also_reader = 0; SET mysql-monitor_history = 600000; -- 10 分の履歴 SET mysql-monitor_connect_interval = 60000; SET mysql-monitor_ping_interval = 10000; -- ② Primary failover 時の挙動: -- Step 1: ProxySQL が Primary の ping 失敗を検出 (~10 秒) -- Step 2: 該当ノードを SHUNNED (一時的に外す) に -- Step 3: 新規接続は別 Primary に振り分け -- Step 4: 既存接続は<Em>該当ノードから外される</Em> -- ③ Multiplexing を切る場面 -- ・ユーザーが SET 文を発行した (セッション変数を変えた) -- ・トランザクション開始した (BEGIN) -- ・LOCK TABLES した -- → これらは Pinning と呼ばれ、接続を独占する -- ④ 監視: 詰まりを見つける SELECT * FROM stats.stats_mysql_processlist WHERE conn_status='PINNED' AND time_ms > 60000; SELECT * FROM stats.stats_mysql_connection_pool; -- Conn_used, Conn_free, Conn_OK, Conn_ERR, Queries の比率で詰まりを検知
Oracle 公式の HA ソリューションが InnoDB Cluster。3 つの部品を組み合わせた構成です。
# MySQL Shell に入る
mysqlsh root@node1
# 各ノードの設定を InnoDB Cluster 向けに調整
dba.configureInstance('root@node1');
dba.configureInstance('root@node2');
dba.configureInstance('root@node3');
# クラスタ作成 (1 ノード目で実行)
var cluster = dba.createCluster('mycluster');
# 他ノードを追加
cluster.addInstance('root@node2');
cluster.addInstance('root@node3');
# 状態確認
cluster.status();
# MySQL Router を bootstrap
mysqlrouter --bootstrap root@node1:3306 --user=mysqlrouter --directory /opt/router
systemctl start mysqlrouter
# アプリは Router 経由でアクセス
mysql -h router.example.com -P 6446 -u app -p # 書き込み
mysql -h router.example.com -P 6447 -u app -p # 読み取り# シナリオ: Primary (node1) がダウン
# Step 1 — 自動検出 (数秒以内)
# Group Replication が node1 の通信途絶を検出
# 過半数の合意で node1 を OFFLINE 扱いに
# Step 2 — 新 Primary 選出 (自動)
# 残った node2, node3 の中から GTID が最も進んでいる方を選出
# 通常は node2 が新 Primary に
# Step 3 — MySQL Router がトポロジ更新
# metadata-cache 経由で「Primary が node2 に変わった」を検出
# 新規接続は自動的に node2 へ
# Step 4 — アプリ側の挙動
# フェイルオーバー中の数秒間は書き込み失敗
# 再接続で自動回復
# Step 5 — node1 復旧後
cluster.rejoinInstance('root@node1');
# → Secondary として再参加
# 確認
cluster.status();単一データセンターの障害からの保護として、8.0.27 でInnoDB ClusterSet が追加されました。複数の InnoDB Cluster を非同期レプリケーションで結ぶ仕組みです。
# MySQL Shell で ClusterSet 構築
mysqlsh root@tokyo_node1
# 既存 Cluster を ClusterSet に昇格
> var cs = cluster.createClusterSet('myclusterset');
# Osaka 側に Replica Cluster を作る
> var replicaCluster = cs.createReplicaCluster(
'root@osaka_node1', 'osaka_cluster'
);
> replicaCluster.addInstance('root@osaka_node2');
> replicaCluster.addInstance('root@osaka_node3');
# 状態確認
> cs.status();
# Tokyo: Primary, Osaka: Replica
# 災害時の Primary 昇格 (Tokyo 全滅シナリオ)
> cs.setPrimaryCluster('osaka_cluster');
# Forced failover (Tokyo に到達不能):
> cs.forcePrimaryCluster('osaka_cluster');dba.createCluster() で簡単。手動の Replication 設定不要。 ③ Primary 障害は数秒で自動検出 → 自動切替。アプリは再接続のみ。 ④ 大規模では Aurora MySQL / TiDB / Vitess なども選択肢。 ⑤ クロスサイト DR は InnoDB ClusterSet (8.0.27+) で対応可能に。バックアップは「取れていることより、戻せることが重要」。MySQL の主要ツールを論理 vs 物理で整理します。
| ツール | 種別 | 速度 | 稼働中可 | PITR | 備考 |
|---|---|---|---|---|---|
| mysqldump | 論理 | 遅い | ○ (lock 注意) | +binlog 必要 | 標準・全環境で使える |
| mysqlpump | 論理 | 中 (並列) | ○ | +binlog 必要 | 5.7.8+ / DB 単位並列 |
| mysqlsh dump | 論理 | 速い (並列+圧縮) | ○ | +binlog 必要 | 8.0.21+ / 公式最新 |
| MySQL Enterprise Backup | 物理 | 速い | ○ (Hot Backup) | ○ | 有償・Oracle 公式 |
| Percona XtraBackup | 物理 | 速い | ○ (Hot Backup) | ○ | OSS・標準デファクト |
| ファイルスナップショット | 物理 | 瞬時 | × (要 FLUSH+RO) | +binlog | LVM / EBS Snapshot 等 |
# 推奨オプション (本番) mysqldump \ --single-transaction \ # InnoDB を整合性ある状態でダンプ --quick \ # 1 行ずつ取り出し (メモリ節約) --routines \ # Stored Procedure も含める --triggers \ # Trigger も --events \ # Event Scheduler も --master-data=2 \ # binlog 位置をコメントで記録 (PITR 用) --flush-logs \ # binlog ローテート --hex-blob \ # BLOB を 16 進で出力 --default-character-set=utf8mb4 \ -h primary -u backup -p \ myapp | gzip > myapp_$(date +%Y%m%d).sql.gz # 注意: MyISAM テーブルが混在する場合は --single-transaction が効かない # → --lock-tables も追加 (= 短時間のテーブルロック)
# 1. フルバックアップ取得 xtrabackup --backup --target-dir=/backup/full \ --user=backup --password=xxx # 2. インクリメンタル (差分) xtrabackup --backup --target-dir=/backup/inc1 \ --incremental-basedir=/backup/full \ --user=backup --password=xxx # 3. リストア (フル+差分を適用) xtrabackup --prepare --target-dir=/backup/full \ --apply-log-only xtrabackup --prepare --target-dir=/backup/full \ --incremental-dir=/backup/inc1 # 4. データディレクトリに戻す systemctl stop mysql rm -rf /var/lib/mysql/* xtrabackup --copy-back --target-dir=/backup/full chown -R mysql:mysql /var/lib/mysql systemctl start mysql
従来の FLUSH TABLES WITH READ LOCK はテーブル全体を止めるので運用上嫌でした。8.0.23 でBACKUP_ADMIN 権限 + LOCK INSTANCE FOR BACKUP が追加され、軽量に整合性を取れるようになりました。
# 権限付与 GRANT BACKUP_ADMIN ON *.* TO 'backup'@'%'; # Backup Lock (DDL だけ止める。DML は継続) LOCK INSTANCE FOR BACKUP; # ... ファイルコピー or スナップショット ... UNLOCK INSTANCE; # XtraBackup 8.0 は自動でこれを使う
CHECKSUM TABLE や mysqlcheck で整合性検査。 ③ binlog も一緒に保管 (PITR のため)。 ④ 過去 30 日 / 90 日 などの保持期間ポリシーを明文化。リストア手順は事故が起きた時に手が動くかがすべて。本番でぶっつけ本番でやるのは絶対 NG なので、手順書 + 訓練が必須。
mysql -u root -p < backup.sql→ 数 GB なら数十分〜数時間→ 大量データは厳しいxtrabackup --prepare --target-dir=/backupsystemctl stop mysql; rm -rf /var/lib/mysql/*xtrabackup --copy-back --target-dir=/backupchown -R mysql:mysql /var/lib/mysql && systemctl start mysql1. フルバックアップで base 復元2. binlog を該当時刻まで再生 mysqlbinlog --start-position=XXX --stop-datetime="2026-06-12 10:00:00" binlog.* | mysql -u root -p3. 完了# 状況: # - 昨夜 02:00 にフルバックアップ # - 今朝 10:23 に DBA がうっかり DELETE FROM orders; # - 10:24 時点に戻したい # 1. 別環境を用意して、フルバックアップで base 復元 xtrabackup --prepare --target-dir=/backup/full # (copy-back) # 2. binlog から DELETE の前 (10:23 直前) まで再生 mysqlbinlog \ --start-position=4 \ # フルバックアップ完了時の位置 --stop-datetime="2026-06-12 10:23:00" \ /var/log/mysql/binlog.* \ | mysql -u root -p # 3. 検証 SELECT COUNT(*) FROM orders; # 戻っているはず # 4. 該当データだけ本番にエクスポート/インポート mysqldump --where="id IN (...)" myapp orders > recovered.sql mysql -h primary -u admin -p myapp < recovered.sql
本番 MySQL は「壊れる前に異常を検知する」のが理想。Performance Schema を中心に、押さえるべき監視メトリクスを整理します。
Prometheus + mysqld_exporterOSS の定番。Grafana ダッシュボードが豊富。Percona Monitoring and Management (PMM)Percona 公式 OSS。MySQL 専門ビュー多数。Datadog Database Monitoring商用。スロークエリの自動 EXPLAIN + APM 統合。New Relic / Dynatrace商用 APM。アプリ視点の DB 観測。CloudWatch (RDS / Aurora)AWS マネージド。Performance Insights が強い。監視は「異常を検知する」だけでなく「SLO (サービスレベル目標) と乖離していないか」を見る役割もあります。MySQL の典型的な SLI / SLO 例:
99.95% / 月mysqld が応答する時間 / 月の総時間。RDS Multi-AZ なら 99.95% は現実的< 100 msevents_statements_summary_by_digest の 95th_percentile< 0.1%Slow_queries / Questions 比率< 30 秒 (常時)Seconds_Behind_Source の 99 パーセンタイル> 99%Innodb_buffer_pool_read_requests / (read_requests + reads)< 10 件/時間Innodb_deadlocks 差分Prometheus から MySQL を観測する場合の本当に見るべきメトリクス。mysql_* プレフィックスの中から最重要なものを抜粋:
# === 接続関連 ===
mysql_global_status_threads_connected # 現在の接続数
mysql_global_status_threads_running # 実行中スレッド数 (= 同時実行)
mysql_global_variables_max_connections # 上限値
mysql_global_status_aborted_connects # 接続失敗数 (増加注意)
# === Buffer Pool ===
mysql_global_status_innodb_buffer_pool_read_requests
mysql_global_status_innodb_buffer_pool_reads
# Hit率 = 1 - (reads / read_requests)
mysql_global_status_innodb_buffer_pool_pages_dirty
mysql_global_status_innodb_buffer_pool_pages_total
# Dirty率 = dirty / total
# === Redo / Binary Log ===
mysql_global_status_innodb_os_log_pending_fsyncs # fsync 詰まり
mysql_global_status_binlog_cache_disk_use # binlog cache が disk へ溢れ
# === レプリケーション ===
mysql_slave_status_seconds_behind_master
mysql_slave_status_slave_io_running
mysql_slave_status_slave_sql_running
# === デッドロック / ロック待ち ===
mysql_global_status_innodb_deadlocks # rate で見る
mysql_global_status_innodb_row_lock_waits
mysql_global_status_innodb_row_lock_time # ms 単位
# === スロークエリ ===
mysql_global_status_slow_queries
mysql_global_status_questions # rate(slow/questions)
# === HLL (History List Length) ===
mysql_info_schema_innodb_metrics{name="trx_rseg_history_len"}# ① Buffer Pool Hit Rate (%) 100 * (1 - rate(mysql_global_status_innodb_buffer_pool_reads[5m]) / rate(mysql_global_status_innodb_buffer_pool_read_requests[5m]) ) # ② QPS (Queries per second) rate(mysql_global_status_queries[1m]) # ③ Slow Query Rate (%) 100 * rate(mysql_global_status_slow_queries[5m]) / rate(mysql_global_status_questions[5m]) # ④ レプリ遅延 (sec) mysql_slave_status_seconds_behind_master # ⑤ デッドロック発生レート rate(mysql_global_status_innodb_deadlocks[5m]) # ⑥ Buffer Pool Dirty Page % 100 * mysql_global_status_innodb_buffer_pool_pages_dirty / mysql_global_status_innodb_buffer_pool_pages_total # ⑦ 接続数の閾値 (max_connections の 80%) mysql_global_status_threads_connected / mysql_global_variables_max_connections * 100
# Prometheus AlertManager 用ルール
groups:
- name: mysql
rules:
- alert: MySQLDown
expr: mysql_up == 0
for: 1m
labels: { severity: critical }
annotations:
summary: "MySQL is down on {{ $labels.instance }}"
- alert: MySQLHighConnections
expr: mysql_global_status_threads_connected /
mysql_global_variables_max_connections > 0.8
for: 5m
labels: { severity: warning }
- alert: MySQLReplicationLag
expr: mysql_slave_status_seconds_behind_master > 30
for: 5m
labels: { severity: warning }
- alert: MySQLBufferPoolMissRate
expr: 100 * (1 -
rate(mysql_global_status_innodb_buffer_pool_reads[5m]) /
rate(mysql_global_status_innodb_buffer_pool_read_requests[5m])
) < 99
for: 10m
labels: { severity: warning }
- alert: MySQLDeadlocksHigh
expr: rate(mysql_global_status_innodb_deadlocks[5m]) > 0.1
for: 10m
labels: { severity: warning }
- alert: MySQLHLLTooHigh
expr: mysql_info_schema_innodb_metrics{name="trx_rseg_history_len"} > 1000000
for: 15m
labels: { severity: critical }
annotations:
summary: "Undo log not being purged - check long-running transactions"本番でアプリの接続数が増えると、max_connections を上げる前に接続プール の導入を検討します。
# 設定 (admin port 6032)
INSERT INTO mysql_servers (hostgroup_id, hostname, port)
VALUES (10, 'primary.example.com', 3306),
(20, 'replica1.example.com', 3306),
(20, 'replica2.example.com', 3306);
# Read/Write split のルール
INSERT INTO mysql_query_rules (rule_id, match_pattern, destination_hostgroup)
VALUES (1, '^SELECT.*FOR UPDATE', 10), # 更新ロックは Primary
(2, '^SELECT', 20), # 通常 SELECT は Replica
(3, '^', 10); # その他は Primary
LOAD MYSQL SERVERS TO RUNTIME;
LOAD MYSQL QUERY RULES TO RUNTIME;
# アプリは ProxySQL に繋ぐ (port 6033)
mysql -h proxysql.example.com -P 6033 -u app -p「アプリ側プールサイズはいくつにすべきか?」HikariCP の作者 (Brett Wooldridge) が提唱した公式:
# プールサイズの公式 connections = ((core_count * 2) + effective_spindle_count) # 例: 8 コア CPU + SSD (spindle=1) → (8*2 + 1) = 17 接続 # 例: 16 コア CPU + NVMe (spindle=0) → (16*2 + 0) = 32 接続 # 「もっと増やせばスループット上がる」という直感は<Em>間違い</Em>: # ・接続数を増やすとコンテキストスイッチが増える # ・disk seek の競合が増える # ・ロック競合が増える # → ある点を超えるとスループットは<Em>下がる</Em> # ピーク時の同時接続を測る: SHOW GLOBAL STATUS LIKE 'Max_used_connections'; SHOW GLOBAL STATUS LIKE 'Max_used_connections_time'; # 重要: max_connections は<Em>上限</Em>、プールサイズは<Em>常用値</Em> # 例: max_connections=500, 各アプリのプール=20, アプリ数=10 → 平均 200 接続
# application.yml (Spring Boot)
spring:
datasource:
hikari:
maximum-pool-size: 20 # ★ 公式に従う (16-32 が多い)
minimum-idle: 5 # idle 接続の最小値
connection-timeout: 30000 # 接続取得タイムアウト (30 秒)
idle-timeout: 600000 # idle 接続を切るまで (10 分)
max-lifetime: 1800000 # 接続の最大寿命 (30 分)
connection-test-query: "SELECT 1"
validation-timeout: 5000
leak-detection-threshold: 60000 # 接続リーク警告 (60 秒)
# MySQL 固有
data-source-properties:
cachePrepStmts: true
prepStmtCacheSize: 250
prepStmtCacheSqlLimit: 2048
useServerPrepStmts: true
useLocalSessionState: true
rewriteBatchedStatements: true
cacheResultSetMetadata: true
cacheServerConfiguration: true
elideSetAutoCommits: true
maintainTimeStats: false
# max-lifetime は MySQL の wait_timeout より<Em>短く</Em>すること
# (28800 秒) → max-lifetime=27000 (7.5 時間)
# でないと wait_timeout で切られた接続をプールが使ってエラー</p>ProxySQL は「アプリ接続 N 個 → MySQL 接続 M 個」(N >> M) の集約ができます。Section 33 の Multiplexing と組み合わせると劇的な接続数削減になります。
-- max_connections は Hostgroup ごと UPDATE mysql_servers SET max_connections = 200 WHERE hostgroup_id = 10; -- Multiplexing 設定 SET mysql-multiplex = 1; -- 有効化 (デフォルト) SET mysql-connection_max_age_ms = 0; -- 寿命無制限 -- プールの状態を確認 SELECT * FROM stats.stats_mysql_connection_pool; -- Conn_used = ProxySQL → MySQL の実接続数 -- Conn_free = idle 接続 -- 比較: アプリ → ProxySQL の接続数は数千でも、 -- ProxySQL → MySQL は 200 で済む -- 接続の振る舞いを監視 SELECT * FROM stats.stats_mysql_global WHERE Variable_Name LIKE '%Conn%'; -- Stmt_Server_Active_Total: 実際に MySQL に投げているクエリ数
Lambda 等は関数呼び出しごとに接続を張り直すパターンで、すぐに max_connections 枯渇を引き起こします。RDS Proxy は AWS マネージドの解決策。
# 設定の主要点 - IdleClientTimeout: 1800 # idle Lambda 接続を切るまで - MaxConnectionsPercent: 100 # RDS の max_connections の何 % まで使うか - MaxIdleConnectionsPercent: 50 # Pinning の落とし穴 (※ Multiplexing が崩れるケース): # - SET 文 (例: SET autocommit=0) # - PREPARE 文 (Server-side PS) # - LOCK TABLES # - 一部の関数 (LAST_INSERT_ID(), FOUND_ROWS()) # - SELECT GET_LOCK() # Pinning が起きると Lambda は接続を独占 → 次の Lambda は新規接続 # → 結局 max_connections を消費する # ★ IAM 認証を使うと パスワードレスで Lambda 接続可 # ★ Secrets Manager 統合で認証情報をローテート
-- MySQL 側で接続状態を確認 SHOW PROCESSLIST; -- 状態別カウント SELECT COMMAND, STATE, COUNT(*) AS cnt FROM information_schema.processlist GROUP BY COMMAND, STATE ORDER BY cnt DESC; -- 長期 idle 接続 SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO FROM information_schema.processlist WHERE COMMAND='Sleep' AND TIME > 3600 ORDER BY TIME DESC; -- 長期実行クエリ SELECT * FROM information_schema.processlist WHERE COMMAND != 'Sleep' AND TIME > 30 ORDER BY TIME DESC; -- 接続を強制切断 KILL CONNECTION <id>; -- 接続切断 KILL QUERY <id>; -- クエリだけ kill (接続は残す)
MySQL のアップグレードはマイナーバージョン (8.0.x → 8.0.y) とメジャーバージョン (5.7 → 8.0、8.0 → 8.4) で難易度が大きく違います。
mysqlsh checkForServerUpgrade を必ず実行# MySQL Shell で 5.7 → 8.0 の互換性チェック mysqlsh root@5.7-server -- util checkForServerUpgrade # 出力例: # 1) Errors found in MySQL configuration # - utf8 charset is deprecated (use utf8mb4) # 2) Schema errors # - Schema 'myapp' has tables with old Row Format # 3) Reserved keywords in identifier names # - Column 'rank' uses MySQL 8.0 reserved word # これらを潰してから upgrade ALTER TABLE ... ROW_FORMAT=DYNAMIC; RENAME COLUMN rank TO ranking;
移行の現場で「これで動かなくなった」 が頻発するポイントを優先順に整理します。事前にチェックリスト化しておくのが鉄則。
SET NAMES utf8 警告。将来削除予定rank, lead, system, group(列名)・cube・row 等が予約語化ONLY_FULL_GROUP_BY が ON になり、緩い GROUP BY がエラーALTER USER ... IDENTIFIED WITH mysql_native_passwordquery_cache_* 変数が起動エラーGROUP BY x DESC がパースエラーORDER BY x DESC に書き換えmysql.innodb_table_stats 等の権限が変わる実務で最も安全なメジャーアップグレード手順。「Green に切り替えてダメなら Blue に戻せる」可逆性が最大のメリット。
# Phase 1 — 互換性チェック (1〜2 週間前) mysqlsh root@blue_primary -- util checkForServerUpgrade # Phase 2 — Green 環境を 8.0 で構築 (1 週間前) # 1. 新サーバ群を 8.0 で構築 # 2. Blue (5.7 Primary) → Green (8.0 Primary 候補) にレプリ # ※ 5.7 → 8.0 のレプリは公式サポート CHANGE REPLICATION SOURCE TO SOURCE_HOST='blue_primary', SOURCE_USER='repl', SOURCE_PASSWORD='xxx', SOURCE_AUTO_POSITION=1; START REPLICA; # Phase 3 — Green でテスト # 1. Green を Read-Only でアプリの一部に読み取りを向ける # 2. 期待動作・性能を検証 # 3. 互換性チェックで見落とした点をパッチ # Phase 4 — 切替 (Cut-over) # 1. メンテ告知 (5-10 分のダウンタイム) # 2. Blue の書き込みを止める (read_only=ON) # 3. Green が Blue に追いついたことを確認 (GTID 比較) # 4. Green を Primary に昇格 (read_only=OFF) # 5. アプリの DB 接続先を Green に切替 # Phase 5 — ロールバック準備 (1〜数日) # Green → Blue へ逆向きレプリを張る (8.0 → 5.7 は基本不可) # 代わりに Blue を「Cold Standby」として保持 # 問題なければ Blue を撤去 # RDS / Aurora の場合 # Blue/Green Deployment 機能で同じことが GUI からできる # (RDS: 2023年〜, Aurora MySQL: 2023年〜)
ダウンタイムを切り替えの数秒に抑えたい時の手順。マイナーアップグレードでよく使います。
# 構成: Primary + Replica × 3
# Step 1: Replica #3 を停止 → 新バージョンに置換 → 起動
# レプリを再開して同期完了を待つ
# Step 2: 同じく Replica #2, #1 で繰り返す
# この時点で 3 Replica が新バージョン、Primary は旧バージョン
# Step 3: Primary failover を発火
# InnoDB Cluster: cluster.setPrimaryInstance('replica1')
# 通常 Replication: 手動で promote
# → 旧 Primary が Replica になり、新バージョンの Replica が Primary に
# Step 4: 旧 Primary (今は Replica) を新バージョンに置換 → 起動
# 結果: 全 4 ノードが新バージョン、ダウンタイム = failover 時間のみ
# (通常 < 30 秒)-- ① バージョン確認 SELECT VERSION(); -- ② 設定が反映されているか SHOW VARIABLES LIKE 'innodb_buffer_pool_size'; SHOW VARIABLES LIKE 'sql_mode'; -- ③ Buffer Pool が温まっているか SHOW STATUS LIKE 'Innodb_buffer_pool_pages_data'; SHOW STATUS LIKE 'Innodb_buffer_pool_resize_status'; -- ④ レプリが正常か SHOW REPLICA STATUS\G -- ⑤ Performance Schema が動いているか SELECT COUNT(*) FROM performance_schema.events_statements_summary_by_digest; -- ⑥ 重要クエリのプランが変わっていないか確認 EXPLAIN FORMAT=TREE SELECT ...; -- 旧バージョンのプランと比較 -- ⑦ アプリのスモークテスト (主要ユースケース)
MySQL 5.7 で JSON 型 が追加され、半構造化データを RDBMS で扱えるようになりました。PostgreSQL の JSONB と発想は近い (バイナリ JSON)。
-- ① JSON 型のテーブル
CREATE TABLE products (
id BIGINT PRIMARY KEY,
attr JSON
);
INSERT INTO products VALUES
(1, '{"color": "red", "size": "L", "tags": ["new", "sale"]}'),
(2, '{"color": "blue", "size": "M"}');
-- ② JSON_EXTRACT と -> / ->> 演算子
SELECT JSON_EXTRACT(attr, '$.color') FROM products;
SELECT attr->'$.color' FROM products; -- 同等 (JSON 値: "red")
SELECT attr->>'$.color' FROM products; -- アンクオート (red)
-- ③ JSON 内検索
SELECT * FROM products WHERE attr->>'$.color' = 'red';
SELECT * FROM products WHERE JSON_CONTAINS(attr->'$.tags', '"sale"');
-- ④ JSON 配列の長さ
SELECT JSON_LENGTH(attr->'$.tags') FROM products;JSON カラムに直接 index は張れません。代わりにGenerated Column を作って、その列を index します。
-- ① attr->>'$.color' を Generated Column として持つ
ALTER TABLE products
ADD COLUMN color VARCHAR(20)
GENERATED ALWAYS AS (attr->>'$.color') VIRTUAL,
ADD INDEX idx_color (color);
-- ② これで高速検索
SELECT * FROM products WHERE color = 'red';
-- → idx_color が使われる (高速)
-- VIRTUAL: 計算結果を保存しない (動的計算)
-- STORED: 計算結果を保存する (ディスク消費するが速い)
-- ③ 8.0.13+ なら Functional Index で直接
ALTER TABLE products ADD INDEX idx_color_func
((CAST(attr->>'$.color' AS CHAR(20))));JSON_TABLE() は JSON 配列やオブジェクトを「行ごとに展開」して SQL のテーブルとして扱える、8.0 の革命的機能。これがあるとアプリ側で JSON をパースする必要がなくなります。
-- データ例
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
items JSON
);
INSERT INTO orders VALUES (1, '[
{"sku": "A1", "qty": 2, "price": 100},
{"sku": "B2", "qty": 1, "price": 200},
{"sku": "C3", "qty": 3, "price": 50}
]');
-- ① JSON 配列を行に展開
SELECT o.id, t.*
FROM orders o,
JSON_TABLE(o.items, '$[*]'
COLUMNS (
sku VARCHAR(10) PATH '$.sku',
qty INT PATH '$.qty',
price DECIMAL(10,2) PATH '$.price'
)
) AS t;
-- 結果:
-- +----+-----+-----+--------+
-- | id | sku | qty | price |
-- +----+-----+-----+--------+
-- | 1 | A1 | 2 | 100.00 |
-- | 1 | B2 | 1 | 200.00 |
-- | 1 | C3 | 3 | 50.00 |
-- +----+-----+-----+--------+
-- ② 集計に使える!
SELECT o.id, SUM(t.qty * t.price) AS total
FROM orders o,
JSON_TABLE(o.items, '$[*]'
COLUMNS (qty INT PATH '$.qty', price DECIMAL(10,2) PATH '$.price')
) AS t
GROUP BY o.id;
-- ③ NESTED で入れ子の JSON も展開
SELECT *
FROM orders o,
JSON_TABLE(o.items, '$[*]'
COLUMNS (
sku VARCHAR(10) PATH '$.sku',
NESTED PATH '$.discounts[*]'
COLUMNS (discount DECIMAL(5,2) PATH '$')
)
) AS t;
-- ④ ERROR ハンドリング
SELECT *
FROM orders o,
JSON_TABLE(o.items, '$[*]'
COLUMNS (
qty INT PATH '$.qty' DEFAULT '0' ON ERROR DEFAULT '0' ON EMPTY
)
) AS t;MySQL 8.0 でWindow 関数 と CTE (Common Table Expression) がついに追加されました。5.7 までは SQL の表現力が他 RDBMS より一歩劣っていた領域がカバーされ、複雑な集計クエリが大幅に書きやすくなりました。
-- ① ランキング (ROW_NUMBER / RANK / DENSE_RANK) SELECT country, user_id, total, ROW_NUMBER() OVER (PARTITION BY country ORDER BY total DESC) AS rn, RANK() OVER (PARTITION BY country ORDER BY total DESC) AS rk FROM orders; -- 各 country ごとに「上位 3 件」だけ取る WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY country ORDER BY total DESC) AS rn FROM orders ) SELECT * FROM ranked WHERE rn <= 3; -- ② 累積合計 SELECT date, amount, SUM(amount) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) AS cumsum FROM revenue; -- ③ 前後の行参照 (LAG / LEAD) SELECT date, price, LAG(price) OVER (ORDER BY date) AS prev_price, LEAD(price) OVER (ORDER BY date) AS next_price, price - LAG(price) OVER (ORDER BY date) AS diff FROM stock_prices;
-- ① 普通の CTE WITH active_users AS ( SELECT id, name FROM users WHERE deleted_at IS NULL ) SELECT u.name, COUNT(o.id) AS cnt FROM active_users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id; -- ② 再帰 CTE — 組織図の階層展開 WITH RECURSIVE org AS ( -- 開始: ルート (manager_id IS NULL) SELECT id, name, manager_id, 0 AS depth FROM employees WHERE manager_id IS NULL UNION ALL -- 再帰: 親を持つメンバーを追加 SELECT e.id, e.name, e.manager_id, o.depth + 1 FROM employees e JOIN org o ON e.manager_id = o.id ) SELECT * FROM org ORDER BY depth, id; -- ③ 連番生成 WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100 ) SELECT * FROM seq;
同じ Window 定義を複数回使うとき、WINDOW 句で名前を付けて再利用 できます。SQL が読みやすくなります。
-- ① 名前付き Window
SELECT
country, user_id, total,
RANK() OVER w AS rk,
ROW_NUMBER() OVER w AS rn,
LAG(total) OVER w AS prev_total
FROM orders
WINDOW w AS (PARTITION BY country ORDER BY total DESC);
-- ② 名前付き Window を継承して拡張
WINDOW
base AS (PARTITION BY country),
rank_w AS (base ORDER BY total DESC),
moving_w AS (base ORDER BY created_at
ROWS BETWEEN 3 PRECEDING AND CURRENT ROW)再帰 CTE は停止条件を間違えると無限ループ します。MySQL は安全装置として cte_max_recursion_depth (デフォルト 1000) で打ち切ります。
-- ① 暴走例 (停止条件なし)
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq -- ★ WHERE が無い → 無限再帰
)
SELECT * FROM seq;
-- ERROR 3636: Recursive query aborted after 1001 iterations.
-- ② 正しい停止条件
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1000 -- ← 必須
)
SELECT * FROM seq;
-- ③ 最大深さを変更したい (本番では非推奨)
SET SESSION cte_max_recursion_depth = 10000;
-- ★ サブクエリでループしてしまうことが多いので注意
-- ④ 階層展開で典型的なパターン
WITH RECURSIVE org_tree AS (
SELECT id, name, manager_id, 0 AS depth, CAST(id AS CHAR(200)) AS path
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, t.depth + 1,
CONCAT(t.path, ' > ', e.id)
FROM employees e
JOIN org_tree t ON e.manager_id = t.id
WHERE t.depth < 20 -- ← 深さ制限を入れる
AND FIND_IN_SET(e.id, t.path) = 0 -- ← 循環参照を防ぐ
)
SELECT * FROM org_tree;
-- ⑤ Materialization で再帰部分をキャッシュ
WITH RECURSIVE ...
-- 8.0 デフォルトで再帰 CTE は実体化される (パフォーマンス保証)cte_max_recursion_depth を本番で上げるのは禁忌 (暴走時の保護がなくなる)。MySQL はStored Procedure / Stored Function / Trigger / Event Scheduler といった DB 内部にロジックを置く機能を持ちます。「使うべきか / アプリに寄せるべきか」は永遠の論争。
-- ① Stored Procedure (CALL で呼ぶ) DELIMITER // CREATE PROCEDURE update_balance(IN uid BIGINT, IN delta DECIMAL(10,2)) BEGIN UPDATE accounts SET balance = balance + delta WHERE id = uid; END // DELIMITER ; CALL update_balance(42, -100); -- ② Stored Function (SELECT で使える) DELIMITER // CREATE FUNCTION user_total(uid BIGINT) RETURNS DECIMAL(10,2) DETERMINISTIC READS SQL DATA BEGIN RETURN (SELECT SUM(amount) FROM orders WHERE user_id = uid); END // DELIMITER ; SELECT id, name, user_total(id) FROM users; -- ③ Trigger (DML をフックする) CREATE TRIGGER tr_orders_audit AFTER UPDATE ON orders FOR EACH ROW INSERT INTO orders_audit (order_id, old_status, new_status, changed_at) VALUES (OLD.id, OLD.status, NEW.status, NOW()); -- ④ Event Scheduler (cron 相当) CREATE EVENT ev_cleanup ON SCHEDULE EVERY 1 DAY STARTS '2026-06-13 03:00:00' DO DELETE FROM sessions WHERE expired_at < NOW(); -- 確認 SET GLOBAL event_scheduler = ON; SHOW EVENTS;
CREATE DEFINER = 'admin'@'localhost' PROCEDURE update_balance(...) SQL SECURITY DEFINER BEGIN ... END; -- ↑ ↑ -- DEFINER 句 実行権限の出所 -- (誰が定義したか) DEFINER: 定義者の権限で実行 (デフォルト) -- INVOKER: 呼び出した人の権限で実行 -- DEFINER は VIEW でも使える (Section 44) -- 「app ユーザーには SELECT 権限なし、VIEW 経由で限定的に見せる」設計に使う
MySQL のアクセス制御は「最小権限の原則」を守るのが鉄則。本番で「めんどくさいから ALL PRIVILEGES」はいつか事故を起こします。
-- 権限の粒度 GRANT SELECT ON *.* TO 'user'@'%'; -- グローバル GRANT SELECT ON myapp.* TO 'user'@'%'; -- DB 単位 GRANT SELECT ON myapp.users TO 'user'@'%'; -- テーブル単位 GRANT SELECT (name,email) ON myapp.users TO 'user'@'%'; -- カラム単位 -- 主要な権限 SELECT / INSERT / UPDATE / DELETE -- DML CREATE / ALTER / DROP / INDEX -- DDL CREATE ROUTINE / EXECUTE -- Stored Procedure CREATE VIEW / SHOW VIEW -- VIEW REFERENCES -- FK 作成 LOCK TABLES -- 明示ロック TRIGGER -- Trigger 作成 EVENT -- Event Scheduler -- 管理系 (本番アプリには付けない) SUPER / CREATE USER / RELOAD / PROCESS / REPLICATION CLIENT / SHUTDOWN
-- ① Role 作成 CREATE ROLE 'app_read', 'app_write', 'app_admin'; -- ② 権限付与 GRANT SELECT ON myapp.* TO 'app_read'; GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_write'; GRANT ALL ON myapp.* TO 'app_admin'; -- ③ ユーザーに Role 付与 CREATE USER 'alice'@'%' IDENTIFIED BY 'xxx'; GRANT 'app_read' TO 'alice'@'%'; SET DEFAULT ROLE 'app_read' TO 'alice'@'%'; -- ★ これが必須 -- ④ ログイン時に有効化 mysql -u alice -p > SELECT CURRENT_ROLE(); -- app_read > SET ROLE app_write; -- 切り替え可 > SELECT CURRENT_ROLE(); -- app_write -- ⑤ 確認 SHOW GRANTS FOR 'alice'@'%' USING 'app_read';
SET DEFAULT ROLE を忘れると Role が有効化されず権限不足エラー。ログイン直後に毎回 SET ROLE しないと使えない。-- ✗ 絶対やってはいけない (整合性破壊) UPDATE mysql.user SET ... WHERE user='alice'; DELETE FROM mysql.user WHERE user='alice'; -- ○ 必ずコマンドで ALTER USER 'alice'@'%' IDENTIFIED BY 'newpass'; DROP USER 'alice'@'%'; FLUSH PRIVILEGES; -- 通常は不要 (DCL 文は自動反映)
5.x 時代のSUPER 権限は「実質的に全部できる」スーパー権限でした。8.0 では SUPER を用途別の Dynamic Privileges に分割し、最小権限の原則を実現しやすくしました。
-- 一覧
SELECT * FROM information_schema.user_privileges
WHERE privilege_type LIKE '%_ADMIN' ORDER BY privilege_type;
-- 主要な Dynamic Privilege:
BACKUP_ADMIN -- LOCK INSTANCE FOR BACKUP / Clone Plugin Donor
BINLOG_ADMIN -- binlog 管理 (PURGE BINARY LOGS など)
BINLOG_ENCRYPTION_ADMIN -- binlog 暗号化
CONNECTION_ADMIN -- max_connections_per_hour 等を超えて接続
CLONE_ADMIN -- Clone Plugin (Recipient 側)
ENCRYPTION_KEY_ADMIN -- TDE Master Key Rotation
FIREWALL_ADMIN -- MySQL Enterprise Firewall
GROUP_REPLICATION_ADMIN -- Group Replication 操作
INNODB_REDO_LOG_ARCHIVE -- Redo Log archive (8.0.17+)
PERSIST_RO_VARIABLES_ADMIN -- SET PERSIST_ONLY 系
REPLICATION_APPLIER -- Replica が SQL を再実行
REPLICATION_SLAVE_ADMIN -- レプリケーション設定
RESOURCE_GROUP_ADMIN -- Resource Group 管理
ROLE_ADMIN -- Role 管理
SESSION_VARIABLES_ADMIN -- セッション変数の制限解除
SET_USER_ID -- DEFINER の上書き
SHOW_ROUTINE -- 全 Procedure 定義の閲覧
SYSTEM_USER -- mysql.* システム DB の保護
SYSTEM_VARIABLES_ADMIN -- 重要なシステム変数の変更
TABLE_ENCRYPTION_ADMIN -- ENCRYPTION='Y' 等の操作
XA_RECOVER_ADMIN -- XA トランザクション復旧
-- ★ 用例: バックアップユーザーには SUPER は不要
CREATE USER 'backup'@'%' IDENTIFIED BY 'xxx';
GRANT BACKUP_ADMIN, EVENT, PROCESS, REPLICATION CLIENT, SELECT,
LOCK TABLES, SHOW VIEW, RELOAD
ON *.* TO 'backup'@'%';
-- ★ レプリ用ユーザーには REPLICATION_SLAVE_ADMIN だけで足りる
CREATE USER 'repl'@'%' IDENTIFIED BY 'xxx';
GRANT REPLICATION SLAVE, REPLICATION_APPLIER ON *.* TO 'repl'@'%';
-- ★ SYSTEM_USER の重要性
-- SYSTEM_USER 保護: SYSTEM_USER を持たないユーザーは
-- SYSTEM_USER を持つ他ユーザーを編集できない
-- → DBA アカウントを「アプリ管理者」から守る-- 全ユーザーに自動で付与される Role SET PERSIST mandatory_roles = 'app_read,app_audit'; -- 確認 SHOW VARIABLES LIKE 'mandatory_roles'; -- 用途: -- ・全アプリユーザーに監査ログ用の Role を強制 -- ・基本的な SELECT 権限を初期付与 -- ・後から個別に GRANT で追加権限を足す
PostgreSQL の Row Level Security (RLS) に相当する機能はMySQL にはありません。代わりに VIEW + Stored Procedure + DEFINER の組み合わせで類似の行制御を実現します。これがマルチテナント設計の定石。
典型シナリオ: マルチテナント SaaS で「各テナントは自分のテナント ID のデータしか見えてはいけない」。
-- ① 元テーブル (テナント ID 列あり) CREATE TABLE orders ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, amount DECIMAL(10,2), ... ); -- ② テナント別 VIEW (SECURITY DEFINER + セッション変数で絞り込み) CREATE DEFINER = 'admin'@'localhost' VIEW v_orders SQL SECURITY DEFINER AS SELECT * FROM orders WHERE tenant_id = CAST(@app_tenant_id AS UNSIGNED) WITH CHECK OPTION; -- ③ アプリ用ユーザー (元テーブルへの直接アクセスを禁止) CREATE USER 'app'@'%' IDENTIFIED BY 'xxx'; GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.v_orders TO 'app'@'%'; -- ※ myapp.orders への GRANT は与えない -- ④ アプリ側はセッション変数を設定してから VIEW を使う SET @app_tenant_id = 42; SELECT * FROM v_orders; -- tenant_id=42 のデータだけ見える INSERT INTO v_orders (id, tenant_id, amount) VALUES (1, 42, 100); -- WITH CHECK OPTION により tenant_id=42 以外は INSERT 不可 -- 他テナント (tenant_id=99) は見えない SET @app_tenant_id = 99; SELECT * FROM v_orders; -- tenant_id=99 のデータだけ
@app_tenant_id) をセットせずにアクセス → NULL 比較で結果が空 (誤動作)。必ず接続直後に SET する仕組みをアプリで担保。 ② SQL Injection で SET @app_tenant_id を上書きされるリスク → アプリ層でも別途防御を。DB だけで完璧な行制御は難しい。「アプリ層 + DB 層の二重防御」 がベストプラクティスです。
CREATE POLICY 一発で書ける。MySQL は VIEW + DEFINER + WITH CHECK OPTION + 権限管理を組み合わせる必要があり、設計と運用の手間が増える。マルチテナント SaaS を本気でやるなら PostgreSQL のほうが楽。本番 MySQL は必ず SSL/TLS で通信を暗号化すべき。MySQL 8.0 から認証プラグインも刷新されました。
caching_sha2_passwordmysql_native_passwordsha256_password-- ① my.cnf (サーバ側) [mysqld] ssl_ca = /etc/mysql/ssl/ca.pem ssl_cert = /etc/mysql/ssl/server-cert.pem ssl_key = /etc/mysql/ssl/server-key.pem require_secure_transport = ON # SSL 強制 (8.0+) -- ② 自動生成された証明書を使うことも可 -- (data dir に ca.pem 等が自動配置される) -- ③ ユーザーごとに SSL を要求 ALTER USER 'app'@'%' REQUIRE SSL; ALTER USER 'admin'@'%' REQUIRE X509; -- クライアント証明書も必須に -- ④ 確認 SHOW STATUS LIKE 'Ssl_cipher'; -- Ssl_cipher TLS_AES_256_GCM_SHA384 など -- ⑤ クライアント側 mysql -h primary -u app -p \ --ssl-mode=VERIFY_IDENTITY \ # サーバ証明書を検証 --ssl-ca=/etc/mysql/ssl/ca.pem
DISABLEDSSL 不使用 (本番禁忌)PREFERRED可能なら SSL (デフォルト 5.7)REQUIREDSSL 必須・証明書は検証しないVERIFY_CASSL 必須 + サーバ証明書の CA を検証VERIFY_IDENTITYVERIFY_CA + ホスト名一致まで検証 (推奨)8.0+ でTLSv1.3 がサポートされました。古い TLSv1.0 / 1.1 は脆弱性があり、本番では明示的に除外すべき。
-- ① 現在の対応 TLS バージョン SHOW VARIABLES LIKE 'tls_version'; -- 8.0 デフォルト: TLSv1.2,TLSv1.3 -- ② TLSv1.0/1.1 を明示除外 SET PERSIST tls_version = 'TLSv1.2,TLSv1.3'; -- ③ Cipher (TLSv1.2 用) SET PERSIST ssl_cipher = 'ECDHE-ECDSA-AES256-GCM-SHA384:' 'ECDHE-RSA-AES256-GCM-SHA384:' 'ECDHE-ECDSA-CHACHA20-POLY1305:' 'ECDHE-RSA-CHACHA20-POLY1305'; -- ④ Cipher (TLSv1.3 用) SET PERSIST tls_ciphersuites = 'TLS_AES_256_GCM_SHA384:' 'TLS_CHACHA20_POLY1305_SHA256:' 'TLS_AES_128_GCM_SHA256'; -- ⑤ 動的にリロード (再起動不要 — 8.0+) ALTER INSTANCE RELOAD TLS; -- 既存接続は維持 / 新規接続から新設定で -- ⑥ 実際の接続確認 SHOW STATUS LIKE 'Ssl_version'; SHOW STATUS LIKE 'Ssl_cipher'; -- Ssl_version: TLSv1.3 -- Ssl_cipher: TLS_AES_256_GCM_SHA384
証明書は有効期限があるので必ず更新作業が発生します。本番では有効期限切れ前に ALTER INSTANCE RELOAD TLS で無停止更新 が定石。
# ① 新しい証明書を生成 (Let's Encrypt / 社内 CA / openssl 等) openssl req -new -x509 -days 365 -nodes \ -newkey rsa:4096 \ -keyout /etc/mysql/ssl/server-key.new.pem \ -out /etc/mysql/ssl/server-cert.new.pem \ -subj "/CN=db.example.com" # ② 既存ファイルを上書き (権限注意) mv /etc/mysql/ssl/server-cert.pem /etc/mysql/ssl/server-cert.old.pem mv /etc/mysql/ssl/server-key.pem /etc/mysql/ssl/server-key.old.pem cp /etc/mysql/ssl/server-cert.new.pem /etc/mysql/ssl/server-cert.pem cp /etc/mysql/ssl/server-key.new.pem /etc/mysql/ssl/server-key.pem chown mysql:mysql /etc/mysql/ssl/server-*.pem chmod 600 /etc/mysql/ssl/server-key.pem # ③ 動的リロード (mysqld 再起動なし) mysql -uroot -p -e "ALTER INSTANCE RELOAD TLS;" # ④ 確認 (新規接続が新証明書を使うか) mysql --ssl-mode=VERIFY_IDENTITY --ssl-ca=/etc/mysql/ssl/ca.pem \ -h db.example.com -u app -p \ -e "SHOW STATUS LIKE 'Ssl_cipher';" # ⑤ 自動化: cron で毎月チェック # 残り日数を計算して 30 日切ったら自動ローテート openssl x509 -in /etc/mysql/ssl/server-cert.pem -noout -enddate
caching_sha2_password + require_secure_transport=ON。 ② アプリは --ssl-mode=VERIFY_IDENTITY + 正規 CA 発行の証明書。 ③ AWS RDS / Aurora は SSL 証明書バンドルが用意されているのでそれを使う。 ④ tls_version を TLSv1.2,TLSv1.3 に限定し、古いプロトコルを除外。 ⑤ 証明書はALTER INSTANCE RELOAD TLS で無停止ローテ。本記事のここまでで扱った内容を「現場でやらかしがちな失敗 15 個」として総ざらいします。なぜ起きるか / どう予防するか / どう直すかをセットで。チェックリストとして手元に置いておけます。
CHARSET=utf8 (実体 utf8mb3) で作ったテーブルに絵文字を INSERT → エラー or 化けALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_0900_ai_ciinnodb_buffer_pool_size = 12G などKILL thread_id)・disk が full なら緊急対応idle_in_transaction 監視・information_schema.innodb_trx 定期確認UPDATE ... SET ts=NOW() で Primary と Replica の値がズレるbinlog_format=ROW に変更 → 既存 binlog は再生不可なので新規取得SET GLOBAL max_connections = 500 で緊急回避 → 根本はプール導入Seconds_Behind_Source 監視 / 重要 READ は PrimaryON DELETE RESTRICT で意図的にエラーにする設計FULLTEXT INDEX ... WITH PARSER ngram で再構築ALTER USER ... IDENTIFIED WITH mysql_native_password BY ...INSERT INTO t (a) VALUES (NULL) が a NOT NULL カラムに通るsql_mode = STRICT_TRANS_TABLES, NO_ZERO_DATE, ... 設定innodb_stats_auto_recalc=OFF になっている / 変更率 10% に達していないANALYZE TABLE を手動実行innodb_stats_auto_recalc=ON 維持 / 定期 ANALYZE 設定innodb_buffer_pool_size は物理 50% 以上。 ② 長時間 TX を監視・バッチは分割・HLL を見る。 ③ binlog_format=ROW 維持・sync_binlog=1 + innodb_flush_log_at_trx_commit=1。 ④ バックアップから戻せる ことを定期確認。「存在すれば UPDATE、なければ INSERT」を 1 文で書きたいとき、MySQL には3 つの構文があります。それぞれ挙動と落とし穴が違うので、使い分けを整理します。
INSERT ... ON DUPLICATE KEY UPDATERECOMMENDEDREPLACE注意INSERT IGNORE限定的-- ① ON DUPLICATE KEY UPDATE (推奨)
INSERT INTO product_stock (product_id, qty, updated_at)
VALUES (42, 10, NOW())
ON DUPLICATE KEY UPDATE
qty = qty + VALUES(qty), -- 既存 qty に加算
updated_at = NOW(); -- 更新日時を更新
-- 8.0.20+ では VALUES() の代わりに別 alias
INSERT INTO product_stock (product_id, qty)
VALUES (42, 10) AS new
ON DUPLICATE KEY UPDATE qty = qty + new.qty;
-- ② REPLACE (注意: 既存行が消える)
REPLACE INTO sessions (user_id, token, expires_at)
VALUES (42, 'abc', NOW() + INTERVAL 1 HOUR);
-- → 同じ user_id があれば DELETE → INSERT
-- → 同じセッションでも id (PK) が新しくなる
-- → FK で参照されていると削除できずエラー
-- ③ INSERT IGNORE
INSERT IGNORE INTO whitelisted_ips (ip, source)
VALUES ('10.0.0.1', 'manual'),
('10.0.0.1', 'auto'); -- 2 行目は重複として無視
-- ⚠ 同時にこんな問題も無視される
INSERT IGNORE INTO users (id, age) VALUES (1, 'not a number');
-- → age に文字列を入れようとした警告も無視 → 0 が入る (sql_mode 緩い時)AUTO_INCREMENT は単純な仕組みに見えて、並行性とロック設定で挙動が変わる厄介な機能です。本番事故の温床なので、内部の仕組みをしっかり押さえます。
AUTO_INCREMENT は「ユニークかつ単調増加」を保証するが、連続を保証しないのが原則です。穴が空く典型シナリオ:
INSERT ... SELECT ... 等の件数不明 INSERT は番号が予約スロットで取られ、使い切らないと穴。MAX(id)+1 を再計算 → 削除済みの最大 ID 番号が再利用 されて事故。5.7 までは AUTO_INCREMENT の最新値はメモリ上にしかなく、再起動で MAX(id)+1 を再計算していました。これが大事故の原因:
-- 5.7 までの問題シナリオ -- 1. INSERT で id=100 が採番される (採番カウンタも 101 に) -- 2. DELETE FROM users WHERE id=100 (削除済み) -- 3. mysqld 再起動 -- 4. 再起動後の AUTO_INCREMENT = MAX(id)+1 = 99+1 = 100 -- 5. 新規 INSERT で再び id=100 が割り当てられる! -- 6. → 既に存在しないと思っていた古い参照が新規行を指す事故 -- 8.0 で修正 -- ・採番カウンタを Redo Log に記録 -- ・再起動後もカウンタが復元される -- ・MAX(id)+1 のような再計算はしない -- 確認方法 SELECT AUTO_INCREMENT FROM information_schema.tables WHERE table_schema='myapp' AND table_name='users'; -- 手動リセット (危険、使うな) ALTER TABLE users AUTO_INCREMENT = 1000000;
BIGINT UNSIGNED は 0 〜 18,446,744,073,709,551,615 (約 1844 京)。普通の使い方では絶対枯渇しない。INT (約 21 億・43 億符号無し) はEC・SNS・ログ系で意外と早く到達することがある。
-- INT 枯渇の典型例 -- 1 秒に 100 INSERT → 1 日 864 万 → 約 250 日で 21 億超え -- BIGINT で同じ → 約 5 億 8 千万年で枯渇 (= 実質無限) -- 既存テーブルを INT → BIGINT に拡張 ALTER TABLE events MODIFY id BIGINT UNSIGNED AUTO_INCREMENT; -- → 巨大テーブルでは時間がかかる (gh-ost / pt-osc 推奨)
BIGINT UNSIGNED AUTO_INCREMENT がデフォルト。INT を選ぶ理由はほぼない。 ② innodb_autoinc_lock_mode = 2 (8.0 デフォルト) のままで OK。 ③ ON DUPLICATE KEY UPDATE の高頻度利用は AUTO_INCREMENT を異常消費 → 監視。 ④ 8.0 にアップグレード後は AUTO_INCREMENT が永続化されるので、5.7 時代の再利用事故は起きない。sql_mode は MySQL の「どこまで厳密にチェックするか」を決めるサーバ変数。5.7 までと 8.0 で大きく挙動が違い、レガシー DB の移行で必ず引っかかります。
STRICT_TRANS_TABLESERROR_FOR_DIVISION_BY_ZERONO_ZERO_DATE / NO_ZERO_IN_DATE0000-00-00 のような不正日付を拒否。ONLY_FULL_GROUP_BYNO_ENGINE_SUBSTITUTIONPIPES_AS_CONCAT|| を OR でなく文字連結 (Oracle 互換)。ANSI_QUOTESIGNORE_SPACE( の間のスペースを許す。SELECT @@sql_mode; -- ONLY_FULL_GROUP_BY, -- STRICT_TRANS_TABLES, -- NO_ZERO_IN_DATE, -- NO_ZERO_DATE, -- ERROR_FOR_DIVISION_BY_ZERO, -- NO_ENGINE_SUBSTITUTION -- これらは「現代的」「厳密」な設定で本番標準 -- ★ 古い PHP アプリ等を 5.7 から 8.0 に移行すると壊れる可能性 -- セッション単位で緩める (緊急回避) SET SESSION sql_mode = ''; -- 全フラグ無効化 (絶対やるな) -- グローバルでカスタム SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ENGINE_SUBSTITUTION'; -- ONLY_FULL_GROUP_BY だけ外したい時など -- my.cnf [mysqld] sql_mode = STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ENGINE_SUBSTITUTION
-- 古い MySQL (sql_mode 緩い時) でこう書けた SELECT user_id, name, COUNT(*) FROM orders GROUP BY user_id; -- 5.7 では「name は適当な行のものを返す」謎挙動だった -- 8.0 では: -- ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause -- and contains nonaggregated column 'orders.name' -- ① 正しい書き方 (集約関数を使う) SELECT user_id, MAX(name), COUNT(*) FROM orders GROUP BY user_id; -- ② ANY_VALUE() で「適当な行でいい」と明示 SELECT user_id, ANY_VALUE(name), COUNT(*) FROM orders GROUP BY user_id; -- ③ JOIN で正規化された名前を取る SELECT u.id, u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name;
ONLY_FULL_GROUP_BY を緩めて逃げるのは負債。直すコストは早いほど安い。CI でテストして、本番で壊れる前に修正。-- ① TIMESTAMP は 1970〜2038-01-19 03:14:07 UTC が上限
INSERT INTO events (ts) VALUES ('2038-01-20 00:00:00');
-- → エラー or NULL に降格 (sql_mode による)
-- ② DATETIME は 1000〜9999 年なので問題なし
ALTER TABLE events MODIFY ts DATETIME(6);
-- ③ TIMESTAMP の自動更新を意識する
-- DEFAULT CURRENT_TIMESTAMP / ON UPDATE CURRENT_TIMESTAMP は便利
-- だが <strong>明示的に値を入れたつもりが上書きされる</strong>事故あり
-- 安全な書き方: 自動更新したくない列にも明示的にデフォルト
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL
DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
-- ④ NO_ZERO_DATE は本番では必ず ON
-- 0000-00-00 や 2026-00-00 のような不正値を拒否
-- ⑤ タイムゾーン
SHOW VARIABLES LIKE 'time_zone';
-- 推奨: time_zone='+00:00' に固定して UTC で運用
-- アプリ側で各ユーザーの TZ に変換する設計SELECT @@sql_mode で旧設定を確認。 ② 全 SQL を EXPLAIN 通してみて ONLY_FULL_GROUP_BY 違反を洗い出し。 ③ TIMESTAMP 列を DATETIME に移行 (2038 問題回避)。 ④ 0000-00-00 のデータを cleanup。 ⑤ アプリの autocommit / charset / collation も合わせて棚卸し。ストレージレベルでデータを暗号化 (TDE) したり、圧縮 したりする機能。コンプライアンス要件・ディスク代の節約で実務にじわじわ効きます。
MySQL 5.7+ で導入されたInnoDB Tablespace Encryption。データファイル (.ibd) を AES-256 で暗号化します。クエリやアプリは透過的に動くのが TDE の本質。
-- ① keyring プラグインのロード INSTALL PLUGIN keyring_file SONAME 'keyring_file.so'; -- 本番では keyring_vault (HashiCorp Vault) / keyring_aws 推奨 -- my.cnf [mysqld] early-plugin-load = keyring_file.so keyring_file_data = /var/lib/mysql-keyring/keyring -- ② テーブル作成時に暗号化を指定 CREATE TABLE secrets ( id BIGINT PRIMARY KEY, data VARBINARY(1024) ) ENGINE=InnoDB ENCRYPTION='Y'; -- ③ 既存テーブルを暗号化 ALTER TABLE users ENCRYPTION='Y'; -- ④ Redo Log も暗号化 (8.0+) SET GLOBAL innodb_redo_log_encrypt = ON; SET GLOBAL innodb_undo_log_encrypt = ON; SET GLOBAL binlog_encryption = ON; -- ⑤ Master Key Rotation ALTER INSTANCE ROTATE INNODB MASTER KEY;
keyring_file はマスターキーを平文でディスクに置くのでテスト用途のみ。本番では keyring_vault (HashiCorp Vault) や keyring_aws (KMS) を使うこと。鍵を OS と同じディスクに置いたら暗号化の意味がない。KEY_BLOCK_SIZE で圧縮単位を指定。古いがシンプル-- ① テーブル作成時に指定 CREATE TABLE logs ( id BIGINT PRIMARY KEY, message TEXT ) ENGINE=InnoDB COMPRESSION='zlib'; -- 'lz4' / 'zlib' / 'none' から選択 -- ② 既存テーブルに適用 ALTER TABLE logs COMPRESSION='lz4', ALGORITHM=INPLACE; -- ③ 圧縮率の確認 SELECT NAME, ROUND(FILE_SIZE / 1024 / 1024, 1) AS file_MB, ROUND(ALLOCATED_SIZE / 1024 / 1024, 1) AS alloc_MB, ROUND((1 - ALLOCATED_SIZE / FILE_SIZE) * 100, 1) AS saved_pct FROM information_schema.innodb_tablespaces WHERE FILE_SIZE > 0 ORDER BY saved_pct DESC LIMIT 10;
8.0 で Clone Plugin と MySQL Shell の utilities が追加され、運用作業が一変しました。本セクションでこれらを整理します。
従来 Replica を新規作成するには XtraBackup でフルバックアップ → 転送 → restore → CHANGE SOURCE の手順が必要でしたが、Clone Plugin で1 コマンドで完結 するようになりました。
-- ① Donor 側 (コピー元) INSTALL PLUGIN clone SONAME 'mysql_clone.so'; -- clone 用ユーザー CREATE USER 'clone_admin'@'replica_host' IDENTIFIED BY 'xxx'; GRANT BACKUP_ADMIN ON *.* TO 'clone_admin'@'replica_host'; -- ② Recipient 側 (コピー先 — 新規 Replica) INSTALL PLUGIN clone SONAME 'mysql_clone.so'; SET GLOBAL clone_valid_donor_list = 'primary_host:3306'; -- ③ 1 コマンドで Clone 実行! CLONE INSTANCE FROM 'clone_admin'@'primary_host':3306 IDENTIFIED BY 'xxx'; -- 内部動作: -- 1. Donor の現在の状態をスナップショット -- 2. Recipient のデータディレクトリを wipe -- 3. ネットワーク経由で物理コピー (InnoDB Pages) -- 4. GTID 情報も持ってくる → CHANGE SOURCE は不要 -- 5. Recipient を再起動 → クローン完成 -- ④ そのまま Replica として参加 CHANGE REPLICATION SOURCE TO SOURCE_HOST='primary_host', SOURCE_AUTO_POSITION=1; START REPLICA;
cluster.addInstance() が裏で Clone を使う。MySQL Shell (mysqlsh) は単なるクライアントではなく、JavaScript / Python が使える運用ツール。util.* 名前空間に強力なユーティリティが揃っています。
util.dumpInstance()util.dumpSchemas()util.dumpTables()util.loadDump()util.checkForServerUpgrade()util.exportTable()util.importTable()dba.createCluster()dba.configureInstance()# mysqlsh で接続
mysqlsh root@primary
# ダンプ取得 (8 並列・最大圧縮)
> util.dumpInstance('/backup/full', {
threads: 8,
compression: 'zstd',
chunking: true,
bytesPerChunk: '128M',
ocimds: false // OCI MySQL Database Service 移行時は true
})
# 別環境に ロード
mysqlsh root@target
> util.loadDump('/backup/full', {
threads: 16,
ignoreExistingObjects: false,
showProgress: true
})
# 100 GB DB が mysqldump 数時間 → util.dumpInstance 数十分に短縮MySQL 5.7 から X Plugin + X Protocol という新しい通信プロトコルが追加され、MySQL をNoSQL ライクに使える X DevAPI が登場しました。これにより MySQL はDocument Store (MongoDB ライクな JSON ドキュメント DB) としても使えます。
# mysqlsh で X DevAPI モードに入る
mysqlsh --uri root@localhost:33060
# Collection (= JSON ドキュメントコレクション) を作る
> var schema = session.createSchema('myapp');
> var coll = schema.createCollection('articles');
# ドキュメントを追加 (MongoDB ライク)
> coll.add({
_id: '1',
title: 'MySQL 8.0 入門',
author: 'alice',
tags: ['mysql', 'database'],
views: 100
}).execute();
# 検索
> coll.find('author = :a')
.bind('a', 'alice')
.execute();
# 更新
> coll.modify('_id = "1"')
.set('views', 200)
.arrayAppend('tags', 'new')
.execute();
# 削除
> coll.remove('_id = "1"').execute();
# 内部: articles という JSON カラムを持つ InnoDB テーブル
# ↑ つまり ACID + Replication + Backup がそのまま使えるDocument Store はInnoDB の上に薄い JSON ラッパーを乗せたもので、内部は普通のテーブルです。SQL でも見えます。
-- Classic SQL で同じテーブルを見ると
mysql> USE myapp;
mysql> SHOW CREATE TABLE articles\G
CREATE TABLE `articles` (
`doc` json DEFAULT NULL,
`_id` varbinary(32) GENERATED ALWAYS AS
(json_unquote(json_extract(`doc`, _utf8mb4'$._id'))) STORED NOT NULL,
`_json_schema` json GENERATED ALWAYS AS
(_utf8mb4'{"type":"object"}') VIRTUAL,
PRIMARY KEY (`_id`),
CONSTRAINT `$val_strict_...` CHECK (json_schema_valid(...))
) ENGINE=InnoDB
-- → JSON カラム + Generated Column の組合せで JSON ドキュメントを表現
-- → ACID / Replication / Backup がそのまま使える!MySQL 8.0 の Optimizer は劇的に進化しました。Hash Join の登場・Optimizer Trace の充実・EXPLAIN ANALYZE の追加 (Section 16)・EXPLAIN for DML 文の対応など、長年 PostgreSQL に遅れていた領域が一気に追いつきました。
5.7 までは MySQL の JOIN は Nested Loop Join と Block Nested Loop Join しかありませんでした。8.0.18 でHash Join が追加され、大規模 JOIN がレベル違いに速く なりました。
-- ① 適用される典型シナリオ
EXPLAIN FORMAT=TREE
SELECT *
FROM big_table a
JOIN big_table b ON a.x = b.y;
-- index なし or index が選ばれない条件で:
-- 5.7 → Block Nested Loop で O(N×M) → 死ぬほど遅い
-- 8.0 → Hash Join で O(N+M) → 数百倍速い
-> Inner hash join (b.y = a.x)
-> Table scan on b
-> Hash
-> Table scan on a
-- ② Hash Join が選ばれる条件
-- ・等価 JOIN (=) があること
-- ・どちらかのテーブルが <Em>join_buffer_size</Em> に収まること
-- ③ メモリ不足の場合
-- 8.0.18-19: メモリ不足だと Hash Join を諦めて Block Nested Loop に降格
-- 8.0.20+: ディスクに溢れて (spill) Hash Join を継続できるように
-- ④ Hash Join を使うかどうか
SET optimizer_switch = 'hash_join=on'; -- 8.0.18+ デフォルト ON
-- ⑤ 明示的に強制
SELECT /*+ HASH_JOIN(a, b) */ ...
SELECT /*+ NO_HASH_JOIN(a, b) */ ... -- 無効化Optimizer の判断過程をJSON で詳細に出力する機能。「なぜ index を使わないのか」「なぜ join order がこの順なのか」が見えます。
-- ① 有効化 (セッション単位)
SET optimizer_trace = 'enabled=on';
SET optimizer_trace_max_mem_size = 1000000;
-- ② 問題クエリを実行
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.country = 'JP'
GROUP BY u.id;
-- ③ Trace を取得
SELECT * FROM information_schema.optimizer_trace\G
-- *************************** 1. row ***************************
-- QUERY: SELECT u.name ...
-- TRACE: {
-- "steps": [
-- {"join_preparation": {...}},
-- {"join_optimization": {
-- "rows_estimation": [
-- {"table": "u", "range_analysis": {
-- "potential_range_indexes": [
-- {"index": "PRIMARY", "usable": false, "cause": "not_applicable"},
-- {"index": "idx_country", "usable": true, "key_parts": ["country"]}
-- ],
-- "best_covering_index_scan": ...
-- }}
-- ],
-- "considered_execution_plans": [...] ← どんなプランを検討したか
-- }}
-- ]
-- }
-- ④ 無効化
SET optimizer_trace = 'enabled=off';8.0.19 から DML 文も EXPLAIN ANALYZE が使えるようになりました。本当に走らせる のでトランザクション内で。
START TRANSACTION; EXPLAIN ANALYZE UPDATE orders SET status='shipped' WHERE created_at < '2026-06-01' AND status='paid'; -- 実プランと実行時間 + 影響行数が見える -- → 巨大 UPDATE の事前検証が安全にできる ROLLBACK; -- 結果だけ見て戻す
EXPLAIN ANALYZE を BEGIN; ... ROLLBACK; で安全に検証する習慣を。「想定 1000 行が 1000 万行を巻き込んだ」事故の予防になります。複合 index (country, age) に対して WHERE age = 30 のような左端を省略したクエリ。通常は index が使えませんが、Index Skip Scan はcountry の各値を疑似的にループして index を使える場合があります。
-- INDEX (country, age) が存在
SELECT * FROM users WHERE age = 30;
-- ↑ 通常は country を絞り込んでいないので index 不可
-- 8.0.13+ の Skip Scan:
-- 1. country の distinct 値を取得 ('JP', 'US', 'CA' の 3 つ)
-- 2. 各 country について WHERE country='X' AND age=30 を index で実行
-- 3. 結果を合成
-- → country の cardinality が低いとき有効
# EXPLAIN
+----+-------------+-------+-------+---------------+---------+----------+--------------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-------+-------+---------------+---------+----------+--------------------+
| 1 | SIMPLE | users | range | idx_co_age | idx_co_age | 6 | Using where; |
| | | | | | | | Using index for |
| | | | | | | | skip scan |
+----+-------------+-------+-------+---------------+---------+----------+--------------------+
-- 設定 (デフォルト ON)
SHOW VARIABLES LIKE 'optimizer_switch';
-- skip_scan=on
-- 無効化
SET SESSION optimizer_switch = 'skip_scan=off';EXISTS / IN / NOT EXISTS / NOT IN サブクエリは内部でSemijoin / Antijoin に変換され、相関ループより速く実行されます。
-- ① Semijoin に変換される EXISTS
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- → 内部的に Semijoin (orders に該当があれば users 1 件返す)
-- 戦略は 5 種類:
-- FirstMatch: 1 件見つけたらループ脱出
-- LooseScan: index で重複を飛ばしながら処理
-- Materialization: サブクエリを一時表化
-- DuplicateWeedout: 重複除去
-- Subquery materialization with table scan
-- EXPLAIN FORMAT=JSON で戦略を見る
EXPLAIN FORMAT=JSON SELECT ...\G
-- "first_match": "..." / "materialized_from_subquery": ...
-- ② Antijoin (8.0.17+ で公式 Optimizer 変換)
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- → orders に該当がない users だけ返す
-- 戦略を制御
SET SESSION optimizer_switch = 'semijoin=on,materialization=on,firstmatch=on';
-- Hints で個別に
SELECT /*+ SEMIJOIN(@subq MATERIALIZATION) */
* FROM ... WHERE EXISTS (SELECT /*+ QB_NAME(subq) */ 1 FROM ...);NOT IN は NULL 周りで挙動が複雑 → NOT EXISTS 推奨。 ③ EXPLAIN で「Subquery」と出ていたら戦略を確認 (Materialization が選ばれていないこともある)。8.0 では SQL 機能が大幅に拡張されました。長年「あるはずなのに無い」状態だった機能がついに追加され、レガシーな書き方を強いられていた領域が解消されています。
「サブクエリの中で外側の列を参照する」がJOIN として書けるようになりました。それまでは相関サブクエリで N 回ループするか、変則的な書き方が必要でした。
-- ① ユーザーごとに最新 3 件の注文を取る (定番の難問)
-- 8.0.14+ Lateral で素直に
SELECT u.id, u.name, o.*
FROM users u,
LATERAL (
SELECT * FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC
LIMIT 3
) o;
-- 旧来: Window 関数 + サブクエリで頑張る
SELECT * FROM (
SELECT u.id, u.name, o.*,
ROW_NUMBER() OVER (PARTITION BY u.id ORDER BY o.created_at DESC) AS rn
FROM users u JOIN orders o ON o.user_id = u.id
) t WHERE rn <= 3;
-- → どちらも書けるが Lateral のほうが読みやすい5.x までの CHECK 句はパーサーが受け付けるだけで実際にはチェックされない 古典的バグでした。8.0.16 でついに本当にチェックされるように。
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance DECIMAL(10,2) NOT NULL,
status ENUM('active', 'closed') NOT NULL,
CONSTRAINT chk_balance CHECK (balance >= 0),
CONSTRAINT chk_status_balance CHECK (
NOT (status = 'closed' AND balance > 0)
)
);
INSERT INTO accounts VALUES (1, -100, 'active');
-- ERROR 3819: Check constraint 'chk_balance' is violated.
-- 制約の確認
SELECT * FROM information_schema.check_constraints
WHERE constraint_schema = 'myapp';
-- 一時的に無効化 (移行時)
ALTER TABLE accounts ALTER CHECK chk_balance NOT ENFORCED;
ALTER TABLE accounts ALTER CHECK chk_balance ENFORCED;NOT ENFORCED で無効化しておくのが安全。SELECT * に出てこないカラム。「Blue-Green 移行で新カラムを追加するが、まだアプリには見せたくない」「監査カラムを隠したい」用途。
-- 新カラムを INVISIBLE で追加 ALTER TABLE users ADD COLUMN new_field VARCHAR(100) INVISIBLE; -- SELECT * では見えない SELECT * FROM users LIMIT 1; -- (id, name, email のみ — new_field は出ない) -- 明示的に指定すれば見える SELECT id, name, new_field FROM users LIMIT 1; -- アプリの準備ができたら可視化 ALTER TABLE users ALTER COLUMN new_field SET VISIBLE;
PK のないテーブルを自動で PK 付きに変換する機能。InnoDB は実は内部で 6 byte の隠し PK (DB_ROW_ID) を作っていましたが、それを可視化したのが GIPK。レガシーテーブルでよく問題になる「PK 無し」を本番無停止で解消できます。
-- 有効化 SET sql_generate_invisible_primary_key = ON; -- PK 無しでテーブル作成 CREATE TABLE legacy_table ( data VARCHAR(100) ); -- → 自動で my_row_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY INVISIBLE が付く -- 確認 SHOW CREATE TABLE legacy_table\G -- CREATE TABLE `legacy_table` ( -- `my_row_id` bigint unsigned NOT NULL AUTO_INCREMENT INVISIBLE, -- `data` varchar(100) DEFAULT NULL, -- PRIMARY KEY (`my_row_id`) -- ) -- アプリは PK の存在に気づかない (INVISIBLE) -- でも GTID Replication や VITESS では PK が必須なので嬉しい
現代の MySQL 運用はマネージドサービス上が圧倒的多数。AWS RDS / Aurora MySQL ・ GCP Cloud SQL ・ Azure Database for MySQL の主要 3 つを抑え、本記事で扱った Self-Hosted との差分を整理します。
| 観点 | RDS for MySQL | Aurora MySQL | Cloud SQL |
|---|---|---|---|
| プロバイダ | AWS | AWS | GCP |
| Storage Engine | 純正 InnoDB | 独自分散ストレージ | 純正 InnoDB |
| コミット遅延 | 通常 | 6 多重 storage に書く | 通常 |
| Replica の遅延 | binlog 経由 | storage 共有で sub-100ms | binlog 経由 |
| Storage 上限 | 64 TB | 128 TB | 64 TB |
| Multi-AZ | 同期 Standby | storage が自動 3 AZ レプリ | HA セットアップ |
| Failover 時間 | 30〜120 秒 | ~30 秒 | 60〜120 秒 |
| バックアップ | EBS Snapshot | continuous backup | Snapshot + binlog |
| PITR | ○ | ○ 秒単位 | ○ |
| 料金 | インスタンス + EBS | インスタンス + I/O | インスタンス + storage |
Aurora MySQL はStorage Engine を AWS が書き直した ことが本質的な差分です。コミット時に Redo Log を同時に 6 つの Storage Node に書き、4/6 で成功なら OK とします (= quorum)。
SUPER や FILE 権限がない。代わりに専用 Role や Procedure が用意されている (rds_superuser など)。SHOW ENGINE INNODB STATUS やパフォーマンススキーマ経由でしか観察できない。strace や perf は不可。最後に MySQL のエコシステム — 派生・互換 DB と、運用を支えるツール群を一望します。「MySQL 公式 (Oracle 提供) だけが選択肢ではない」ことを知っておくと、技術選定で視野が広がります。
SHOW USER_STATISTICS 等。無料で Enterprise 機能が欲しい時に。Percona Toolkit (pt-*)gh-ostmysqltunerPercona Monitoring and Management (PMM)OrchestratorProxySQLMyDumper / Myloaderutil.dumpInstance が 8.0 で公式化される前のデファクト。mysqlbinlog_flashback (Percona)MySQL WorkbenchOracle 公式・無料。ER 図 / モデリング / 管理画面まで全部入り。重め。DBeaverOSS・無料。MySQL/PostgreSQL/SQLite など多 DB 対応。もっとも人気の汎用 GUI。phpMyAdminPHP 製の WebGUI。レンタルサーバなどに同梱されている。SQL Injection / XSS 等の脆弱性に注意。Adminer単一 PHP ファイルで動く軽量 WebGUI。phpMyAdmin の代替。TablePlus有料 (Mac/Win)。洗練された UI。インライン編集が高速。Sequel AceMac 専用・無料。Sequel Pro の後継。軽量で速い。DataGripJetBrains 製・有料。IDE 系ユーザーに人気。コード補完が強力。HeidiSQLWindows 専用・無料。Delphi 製 でネイティブ動作。PlanetScale は Vitess を背景にしたサーバーレス MySQL 互換 サービスとして 2021 〜 2023 年に大きな注目を集めましたが、2024 年 4 月に無料プランを廃止し、選択肢として大きく変わりました。
ここまでの 56 セクション、お疲れさまでした。総まとめと、上級者へ進むためのロードマップを置きます。
long_query_time=1.0 で 1 秒以上のクエリを記録。pt-query-digest で集計。information_schema.innodb_trx を 1 分ごとに polling。30 分超で alert。テーブル設計・InnoDB 内部・MVCC・Redo Log・インデックス・実行計画・チューニング・レプリケーション・バックアップ・セキュリティまで、MySQL の中身を本気で理解するための全要素はプレミアムプランで読めます。
プレミアムを見るなんとなく使っている Redis の中身を、徹底的に解説する。シングルスレッドモデル・データ型の内部実装・メモリ管理・永続化・レプリケーション・Sentinel・Cluster・Pub/Sub・Streams・Modules・Vector Search・クラウド (ElastiCache / MemoryDB / Valkey) まで初心者〜中級エンジニア向けに網羅。

メモリに収まらないデータをディスクを使ってソートするアルゴリズム

分散環境で小テーブルを全ノードにブロードキャストしてローカルJoinする手法

Grace Hash Joinの改良版。1パーティションをメモリに保持してI/Oを30-50%削減

メモリに収まらないテーブルをパーティション分割してディスク上でHash Joinする手法

小テーブルからハッシュテーブルを構築し大テーブルでプローブする等価結合アルゴリズム