こんにちは!普段からWordPressのカスタマイズや開発を楽しんでいますか?
ブログやメディアのアクセスが急増したり、ECサイト(WooCommerceなど)でセールを開催した瞬間に、「Error establishing a database connection」 や 「Deadlock found when trying to get lock; try restarting transaction」 というエラーに遭遇したことはないでしょうか。
「普通に `update_post_meta()` を使っていいね数や閲覧数をカウントアップしていただけなのに、なぜかデータベースが固まってしまう……」
これは決してあなたの書き方が悪いわけではありません。実はWordPressの標準的なデータベース構造と、MySQL(InnoDB)のトランザクションの仕組みが衝突することで起きる「構造的な落とし穴」なのです。
今回は、WordPressの心臓部である `wp_postmeta` の物理構造を紐解きながら、なぜ高トラフィックでデッドロックが起きるのか、そしてトランザクション分離レベルと更新ロジックの改善でこれを華麗に解決する方法を、基礎からわかりやすく解説しますね!
ここをマスターすれば、高負荷環境でもビクともしない堅牢なWordPressシステムを構築できるようになりますよ。
—
1. そもそも `wp_postmeta` の物理構造はどうなっている?
まずは敵を知ることから始めましょう。WordPressの `wp_postmeta` テーブルは、投稿(`wp_posts`)に付随するあらゆるカスタムフィールドや追加データを格納する「EAV(エンティティ・アトリビュート・バリュー)モデル」に近い設計になっています。
テーブルの構造をシンプルに見ると、次のようになっています。
CREATE TABLE wp_postmeta (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT ‘0’,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ここで注目してほしいのがインデックス(Index)です。
1. `PRIMARY KEY (meta_id)`:主キー。物理的な並び順(クラスタドインデックス)。
2. `KEY post_id (post_id)`:投稿IDで検索するためのセカンダリインデックス。
3. `KEY meta_key (meta_key(191))`:キー名で検索するためのセカンダリインデックス。
なぜこれが問題になるの?
`wp_postmeta` には `(post_id, meta_key)` の複合ユニークインデックスが存在しません。つまり、同一の投稿に対して同じ `meta_key` のレコードが複数存在できる仕様(複数値の許容)になっています。
そのため、WordPress内部で `update_post_meta( $post_id, ‘views_count’, 100 )` を実行すると、WordPressは裏側で以下のようなステップを踏みます。
1. SELECT で既存の meta_id を探す
2. 存在すれば UPDATE wp_postmeta SET meta_value = … WHERE meta_id = …
3. 存在しなければ INSERT INTO wp_postmeta …
この「同一行を一意に特定するための厳密なユニーク制約がない」という特徴が、並行アクセス時にMySQLのロック機能を刺激してしまうのです。
—
2. デッドロックが発生するメカニズム(図解)
MySQLの標準ストレージエンジンである InnoDB は、データの整合性を守るために「行(レコード)」や「行と行の間の空間(ギャップ)」にロックをかけます。
2つのリクエスト(トランザクションAとトランザクションB)が同時に `wp_postmeta` を更新しようとしたときの様子をイメージしてみましょう。
[ トランザクション A ] [ トランザクション B ]
│ │
├─① レコードXを更新(Xをロック) │
│ ├─② レコードYを更新(Yをロック)
│ │
├─③ レコードYの更新を試みる │
│ (Bがロック中のため待機…⏳) │
│ ├─④ レコードXの更新を試みる
│ │ (Aがロック中のため待機…⏳)
│ │
▼ ▼
💥【デッドロック発生!】お互いがお互いのロック解除を永遠に待ち続ける状態に!
MySQL「これ以上進まないので、トランザクションBを強制終了(Rollback)します!」
特にMySQLのデフォルトのトランザクション分離レベルである `REPEATABLE READ` では、存在しない行を検索・挿入する際に「ネクストキーロック(レコードロック + ギャップロック)」が広範囲にかかります。
そのため、「全く別の記事のカスタムフィールドを更新しているはずなのに、インデックスの隙間が巻き添えでロックされてデッドロックになる」 という不可解な現象が頻発するのです。
—
3. 解決策①:トランザクション分離レベルの最適化
InnoDBには4つの分離レベルがありますが、Webアプリケーションで主に対象となるのは以下の2つです。
| 分離レベル | ギャップロック | デッドロックの発生頻度 | 特徴 |
| :— | :— | :— | :— |
| REPEATABLE READ
(MySQLデフォルト) | あり | 高い | トランザクション中、常に同じスナップショットを読む。ロック範囲が広い。 |
| READ COMMITTED
(推奨設定) | なし(外部キー等を除く) | 劇的に低い | コミットされた最新のデータを読む。行ロックのみでギャップロックがほぼ発生しない。 |
WordPressのようなWebシステムにおいて、ギャップロックによる強力な整合性が必要なケースはごく稀です。分離レベルを `READ COMMITTED` に変更するだけで、`wp_postmeta` のロック競合は劇的に減少します。
設定方法(MySQL側)
MySQLの設定ファイル(`my.cnf` や `my.ini`)に以下を追記して再起動します。
[mysqld]
トランザクション分離レベルを READ-COMMITTED に設定
transaction-isolation = READ-COMMITTED
binlog_format = ROW
(※ `READ-COMMITTED` を使う場合、バイナリログ形式は `ROW` に設定するのが業界標準のベストプラクティスです)
コード側で一時的に切り替える場合
サーバー全体の設定を変更できない共有ホスティング環境などでは、WordPressの接続時にセッション単位で変更することも可能です。
// functions.php またはプラグインの初期化フック
add_action( ‘init’, function() {
global $wpdb;
// 現在のDBセッションの分離レベルを READ COMMITTED に変更
$wpdb->query( “SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;” );
} );
—
4. 解決策②:更新ロジックの改善(アトミックな直接更新)
分離レベルの調整に加えて、PHP側の書き方を見直すことで、さらに安全性を高めることができます。
⚠️ 初心者が陥りがちなアンチパターン
例えば、「記事の閲覧数(views)」を更新したいとき、次のように書いていませんか?
// ❌ デッドロックと競合(レースコンディション)を引き起こすコード
$post_id = 123;
$views = get_post_meta( $post_id, ‘views_count’, true ); // ① SELECT
$views = $views ? (int) $views + 1 : 1;
update_post_meta( $post_id, ‘views_count’, $views ); // ② UPDATE (または INSERT)
この書き方だと、複数ユーザーが同時にアクセスした際に「SELECTしてからUPDATEするまでの間に別のリクエストが割り込む」ため、数値のカウント漏れが発生し、ロックの保持時間も長くなってしまいます。
⭕ 最適解:SQLレベルでアトミック(原子的)に更新する
メタデータが存在することが確定している頻繁な更新では、`$wpdb` を直接使って1クエリで加算処理を完結させます。
/
- 記事の閲覧数を安全にインクリメント(+1)する関数
- @param int $post_id 投稿ID
- @return bool 成功したかどうか
/
function safe_increment_post_views( int $post_id ): bool {
global $wpdb;
// 1. 初回のみ update_post_meta で安全にキーを作成(存在チェック兼ね)
if ( ! metadata_exists( ‘post’, $post_id, ‘views_count’ ) ) {
// add_post_meta の第4引数を true (unique) にして初期化
return (bool) add_post_meta( $post_id, ‘views_count’, 1, true );
}
// 2. 既存レコードに対して、アトミックな UPDATE クエリを発行
// WHERE 条件に post_id と meta_key を指定し、最小限の行ロックで一瞬で終わらせる
$result = $wpdb->query(
$wpdb->prepare(
“UPDATE {$wpdb->postmeta}
SET meta_value = meta_value + 1
WHERE post_id = %d AND meta_key = %s”,
$post_id,
‘views_count’
)
);
// 3. WordPressの内部オブジェクトキャッシュ(Object Cache)を破棄・同期
clean_post_cache( $post_id );
return $result !== false;
}
コードのポイント解説
1. `SET meta_value = meta_value + 1`
MySQL内部で読み込みと書き込みを同一ステップで行うため、トランザクションの競合が起きません。
2. `clean_post_cache( $post_id )`
`$wpdb->query()` で直接DBを更新した場合、WordPressのインメモリキャッシュ(Redis / Memcached や WP内部キャッシュ)とDBの値にズレが生じます。キャッシュクリア関数を呼ぶのを忘れないようにしましょう。
—
5. まとめ:堅牢なWordPressデータベースをマスターしよう
今回のポイントをおさらいしましょう!
1. `wp_postmeta` は構造上、行ロック・ギャップロックの競合が起きやすい
2. MySQLの分離レベルを `READ COMMITTED` にすることで、デッドロックの主因である不要なギャップロックを回避できる
3. 頻繁に更新されるメタデータは、`SELECT` → `UPDATE` ではなくアトミックな1クエリで更新し、キャッシュの破棄もセットで行う
「WordPressの標準関数だから安心」と盲信せず、その裏でデータベースがどんなSQLを発行し、どうロックを取得しているかに目を向けることができるようになると、エンジニアとして一気にステップアップできます。
高トラフィックな案件でも慌てないよう、ぜひ今回の知識を武器に日々の開発を進めてみてくださいね。応援しています!