問題:大規模テーブルにとってALTER TABLEは悪夢
本番環境で稼働中の5000万行のordersテーブルがあるとします。Product Managerからpayment_verified_atカラムの追加を求められました。ALTER TABLE orders ADD COLUMN payment_verified_at TIMESTAMP NULL;と入力してEnterを押した瞬間、30秒後にウェブサイトがタイムアウトし始めます。
問題は、MySQLがリビルド中にテーブル全体をロックしなければならない点にあります。回避策はありません。一般的なハードウェアで5000万行となれば、30〜60分のダウンタイムが発生します。読み書きのクエリがすべて待機状態となり、コネクションプールが枯渇し、ユーザーには真っ白な画面が表示されます。
以前、深夜3時にデータベース破損のインシデントに遭遇し、バックアップからリストアするのに2時間近くかかったことがあります。それ以来、明確な計画なしに本番スキーマには一切手を触れないようにしています。この記事では、最初から正しいやり方を紹介します。
MySQLスキーマ変更の3つのアプローチ比較
アプローチ1:ALTER TABLE 直接実行
-- 小さなテーブル(50万行未満)のみに使用可能
ALTER TABLE users ADD COLUMN phone_verified_at TIMESTAMP NULL;
シンプルで追加インストール不要。ただし、MySQLはプロセス中にテーブル全体をロックするため、1000万行のテーブルでは10分のダウンタイムが発生しやすい。問題が発覚しても途中で止めることはできません。
アプローチ2:pt-online-schema-change(Percona Toolkit)
pt-oscは長年MySQLコミュニティに親しまれてきた定番ツールです。仕組みはシャドウテーブル_users_newを作成し、元テーブルに3つのトリガー(INSERT/UPDATE/DELETE)を設定して新しいデータを同期しながら、古いデータをチャンク単位でコピーし、完了後にリネームします。
- メリット:テーブルロックなし、長年の実績あり、大きなコミュニティ。
- デメリット:トリガーがすべての書き込み操作にオーバーヘッドを追加。GaleraやNDB Clusterと非互換。一時停止・再開が困難。実行中の速度調整が不可。
アプローチ3:gh-ost(GitHub Online Schema Transmogrifier)
GitHubがgh-ostを開発したのは、pt-oscが自社データベースの規模——数十億行、極めて高い書き込みスループット——に対応できなかったからです。トリガーによるオーバーヘッドは許容できませんでした。解決策として、gh-ostはトリガーの代わりにMySQLレプリカのように接続し、データコピー中の変更をbinlogを直接読み取ることで追いかけます。
- メリット:トリガー不使用、書き込みへの追加オーバーヘッドなし。任意のタイミングで一時停止・再開可能。リアルタイムで速度調整。事前テスト用のdry-runモード。テーブルリネームのタイミングを正確にコントロール。
- デメリット:binlog形式ROWが必要。REPLICATION SLAVE権限が必要。pt-oscより若干セットアップが複雑。
メリット・デメリット分析 — どのツールを選ぶべきか?
| 基準 | 直接ALTER | pt-osc | gh-ost |
|---|---|---|---|
| 小テーブル(<50万行) | ✅ 最適 | オーバースペック | オーバースペック |
| 大テーブル(本番) | ❌ 使用不可 | ✅ 可 | ✅ 最適 |
| 既存トリガーあり | OK | ❌ 競合 | ✅ OK |
| Galera / PXCクラスター | 条件次第 | 制限あり | ❌ 非対応 |
| 一時停止・再開 | ❌ | ❌ 困難 | ✅ 容易 |
| リアルタイムスロットリング | ❌ | 制限あり | ✅ 完全対応 |
| リネームタイミング制御 | ❌ | ❌ | ✅ Postponeフラグ |
どれを選ぶべきか?小テーブルや開発環境には直接ALTER。本番環境の大テーブルにはgh-ost。Galeraやbinlogストリーミングをサポートしていない環境にはpt-oscを選びましょう。
gh-ostのステップバイステップ導入ガイド
ステップ1:gh-ostのインストール
gh-ostは単一バイナリで、ランタイム依存関係は不要です:
# GitHub Releasesからバイナリをダウンロード(最新バージョンを確認してください)
VERSION="1.1.6"
wget https://github.com/github/gh-ost/releases/download/v${VERSION}/gh-ost-binary-linux-amd64-${VERSION}.tar.gz
tar -xzf gh-ost-binary-linux-amd64-${VERSION}.tar.gz
sudo mv gh-ost /usr/local/bin/
chmod +x /usr/local/bin/gh-ost
# 確認
gh-ost --version
ステップ2:MySQL binlogの確認
gh-ostはbinlogがROW形式で有効になっていることを要求します:
-- 現在の設定を確認
SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
-- 有効化が必要な場合は /etc/mysql/my.cnf または /etc/my.cnf に追加
-- [mysqld]
-- log_bin = /var/log/mysql/mysql-bin.log
-- binlog_format = ROW
-- server_id = 1
-- その後MySQLを再起動
ステップ3:本番実行前にDry-runを実行
必ずdry-runを先に実行してください。このコマンドは接続、権限、binlog設定を確認しますが、何も変更しません:
gh-ost \
--host="127.0.0.1" \
--port=3306 \
--user="root" \
--password="your_password" \
--database="myapp" \
--table="orders" \
--alter="ADD COLUMN payment_verified_at TIMESTAMP NULL" \
--dry-run \
--verbose
ステップ4:本番マイグレーションの実行
gh-ost \
--host="127.0.0.1" \
--port=3306 \
--user="root" \
--password="your_password" \
--database="myapp" \
--table="orders" \
--alter="ADD COLUMN payment_verified_at TIMESTAMP NULL" \
--chunk-size=1000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--switch-to-rbr \
--exact-rowcount \
--panic-flag-file=/tmp/ghost.panic \
--postpone-cut-over-flag-file=/tmp/ghost.postpone \
--execute
実行前に理解しておくべきフラグです:
--chunk-size=1000:1バッチあたり1000行をコピー。サーバーに余裕があれば2000〜5000に増やし、負荷が高ければ500に下げます。--max-load="Threads_running=25":MySQLが過負荷になるとgh-ostは自動で一時停止し、負荷が下がると自動で再開します。--critical-load="Threads_running=50":状況がさらに悪化した場合は緊急停止します。--panic-flag-file=/tmp/ghost.panic:このファイルを作成するとマイグレーションが即座に停止します。--postpone-cut-over-flag-file=/tmp/ghost.postpone:このファイルを作成すると、最終的なテーブルリネームステップを延期します。
ステップ5:リアルタイム監視とコントロール
gh-ostはUnixソケット(パス形式:/tmp/gh-ost.{database}.{table}.sock)を作成し、マイグレーション実行中に直接操作することができます:
# 進捗を確認
echo "status" | nc -U /tmp/gh-ost.myapp.orders.sock
# サーバーに余裕があるときにchunk-sizeを増やす(再起動不要)
echo "chunk-size=3000" | nc -U /tmp/gh-ost.myapp.orders.sock
# 手動スロットル(一時的に速度を下げる)
echo "throttle" | nc -U /tmp/gh-ost.myapp.orders.sock
# スロットルを解除
echo "no-throttle" | nc -U /tmp/gh-ost.myapp.orders.sock
ステップ6:カットオーバーのタイミング制御
これはgh-ostで私が最も気に入っている機能です。データのコピーが完了した後、即座にテーブルをリネームするのではなく、タイミングを正確にコントロールできます:
# コピー完了後にgh-ostが待機するよう、最初からこのファイルを作成しておく
touch /tmp/ghost.postpone
# gh-ostは次のメッセージを出力する:"Postponing cut-over; waiting for /tmp/ghost.postpone to be deleted"
# 準備ができたら(例:最もトラフィックが少ない時間帯を待って)
rm /tmp/ghost.postpone
# gh-ostは即座にリネームを実行する
この機能は、スキーマ変更と同時に新しいコードをデプロイする必要がある場合に特に有効です。先にマイグレーションを実行し、コードのデプロイが完了するまで待ってからカットオーバーします。
ステップ7:緊急停止が必要な場合の対応
# 即座に停止(安全、元テーブルへの影響なし)
touch /tmp/ghost.panic
# gh-ostは2つの一時テーブルを残す。確認後に削除してください
# _orders_gho — ビルド中のゴーストテーブル
# _orders_ghc — 変更ログテーブル
gh-ostはリネームが正常に完了するまで元のテーブルを絶対に削除しません——これは意図的な設計です。マイグレーションがどんな理由で中断されても、ordersテーブルはそのまま保持され、1行たりともデータが失われることはありません。
gh-ostでよく使うALTERコマンド
# カラムを追加
--alter="ADD COLUMN is_verified TINYINT(1) NOT NULL DEFAULT 0"
# インデックスを追加
--alter="ADD INDEX idx_status_created (status, created_at)"
# 複数の変更を一度に — データの全コピーを1回に節約
--alter="ADD COLUMN notes TEXT NULL, ADD INDEX idx_user_status (user_id, status)"
# データ型を変更(データの切り捨てに注意)
--alter="MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'active'"
# デフォルト値付きのカラムを追加
--alter="ADD COLUMN tier ENUM('free','pro','enterprise') NOT NULL DEFAULT 'free'"
実践ワークフロー — マイグレーション前チェックリスト
- 当日のバックアップが取得済みで、復元可能であることを確認
- 本番と同等のデータでstaging環境にて
--dry-runを実行 - 所要時間の見積もり:サーバーとchunk-sizeに応じて100万行あたり約5〜10分
- 最もトラフィックが少ない時間帯を選ぶ — 柔軟に対応するにはpostponeフラグを活用
- 最初から
--panic-flag-fileと--postpone-cut-over-flag-fileを設定しておく - マイグレーション中はサーバー負荷を継続的に監視
- カットオーバー後、新しいスキーマを確認し、2つの一時ゴーストテーブル(
_orders_gho、_orders_ghc)を削除

