2026年8月11日
2026年8月11日
MySQLのON DUPLICATE KEY UPDATEの使い方と注意点|WordPressでの活用例
ON DUPLICATE KEY UPDATE は、INSERT しようとした行が主キーかユニークキーで既存の行とぶつかったときに、エラーにせず既存の行を更新する MySQL の構文です。「なければ追加、あれば更新」(いわゆるアップサート)を1文で書けるので、閲覧数カウンターや日別集計のようなテーブルに向いています。この記事では、基本構文、MySQL 8.0 で変わった書き方、REPLACE や INSERT IGNORE との違い、つまずきやすい落とし穴、WordPress の独自テーブルで安全に使う書き方までを順に説明します。
基本構文と動く条件
まずは記事ごとの日別閲覧数を記録するテーブルを例にします。
CREATE TABLE page_views (
post_id BIGINT UNSIGNED NOT NULL,
view_date DATE NOT NULL,
views INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (post_id, view_date)
);
INSERT INTO page_views (post_id, view_date, views)
VALUES (123, '2026-10-18', 1)
ON DUPLICATE KEY UPDATE views = views + 1;
その日の最初の1回は行が追加され、2回目以降は (post_id, view_date) の組み合わせが主キーと重複するため、既存行の views が1ずつ増えます。UPDATE 句の中で列名だけを書くと、それは既存行の値を指します。
重複の判定に使われるのは PRIMARY KEY と UNIQUE インデックスだけです。通常の INDEX しか張っていない列では重複とみなされず、行がひたすら増えていきます。「更新されずに同じ行が何件もできる」という相談の多くはこれが原因です。対象テーブルのインデックスは次のように確認できます。
SHOW INDEX FROM page_views;
結果の Non_unique が 0 になっているインデックスが、重複判定に使われるキーです。
挿入しようとした値を UPDATE 句で使う
「既存の値に、今回送った値を足したい」「今回の値で上書きしたい」という場面では、挿入予定の値を参照する必要があります。書き方はデータベースとバージョンで異なります。
-- MySQL 8.0.19 以降:行エイリアスを使う書き方
INSERT INTO page_views (post_id, view_date, views)
VALUES (123, '2026-10-18', 5) AS new
ON DUPLICATE KEY UPDATE views = page_views.views + new.views;
-- 従来の書き方:VALUES() 関数
INSERT INTO page_views (post_id, view_date, views)
VALUES (123, '2026-10-18', 5)
ON DUPLICATE KEY UPDATE views = views + VALUES(views);
MySQL では 8.0.20 以降、UPDATE 句の中の VALUES() は非推奨となり、実行すると警告が出ます。すぐに動かなくなるわけではありませんが、新しく書くコードは行エイリアスに寄せておくと将来の移行が楽です。一方 MariaDB は(2026年10月現在)この行エイリアス構文には対応しておらず、VALUES() が引き続き使われています。共有サーバーでは MySQL ではなく MariaDB が動いていることも多いため、まず SELECT VERSION(); で実際のサーバーを確認してから書き方を選んでください。
複数行をまとめて送る場合も同じ構文で書けます。VALUES の後ろに行を並べれば、行ごとに追加か更新かが判定されます。
実行結果の「影響を受けた行数」の読み方
この構文では、影響を受けた行数(affected rows)が通常の INSERT と違う意味を持ちます。
- 1:新しい行が追加された
- 2:既存の行が更新された
- 0:既存の行があったが、更新後の値が元と同じで変化しなかった
0 になる挙動は、接続時に CLIENT_FOUND_ROWS フラグを付けると 1 に変わります。アプリケーション側で「新規だったか更新だったか」を分岐させたい場合は、この数値を見るのが手軽です。後で紹介する WordPress の $wpdb->query() も、この値をそのまま返します。
REPLACE・INSERT IGNORE との違い
似た目的で使われる構文が2つあります。動きがかなり違うので、比較して選んでください。
| 構文 | 重複したときの動き | 注意点 |
|---|---|---|
| INSERT ... ON DUPLICATE KEY UPDATE | 既存行の指定した列だけを更新 | 指定しなかった列はそのまま残る |
| REPLACE INTO | 既存行を削除してから新しく挿入 | 指定しなかった列は初期値に戻る。AUTO_INCREMENT の ID が変わり、削除扱いのため外部キーの ON DELETE CASCADE や DELETE トリガーも動く |
| INSERT IGNORE | 重複した行を捨てて何もしない | 重複以外のエラー(型や長さの不一致など)も警告に格下げされ、気づきにくい |
一部の列だけを更新したいならほぼ ON DUPLICATE KEY UPDATE 一択です。REPLACE は「行を丸ごと差し替える」動作なので、関連テーブルを持つ行に使うと、意図せず子テーブルの行まで消えることがあります。
つまずきやすい注意点
ユニークキーが複数あるテーブル
主キーのほかに UNIQUE インデックスが別にあるテーブルでは、挿入しようとした行が「主キーでは行A、ユニークキーでは行B」と複数の行にぶつかることがあります。この場合に更新されるのは1行だけで、どちらが選ばれるかを前提にしたロジックは危険です。MySQL の公式マニュアルも、複数のユニークキーを持つテーブルではこの構文を避けるよう勧めています。また、ステートメントベースのバイナリログでは安全でない文として扱われ、レプリケーション環境では警告の対象になります。
AUTO_INCREMENT の欠番
InnoDB では、結果的に更新になった場合でも AUTO_INCREMENT の値が先に確保され、欠番が生まれることがあります(innodb_autoinc_lock_mode の設定によって挙動が変わります)。更新が頻繁なテーブルで INT の ID を使っていると、想定より早く上限に近づくことがあります。カウンター用途なら、上の例のように意味のある列の組み合わせを主キーにするか、ID を BIGINT にしておくと安心です。
更新になった行の ID を LAST_INSERT_ID() で取りたい場合は、UPDATE 句に id = LAST_INSERT_ID(id) を加えるという、マニュアルにも載っている書き方があります。
同時実行時のロック
同じキー付近に対して大量のアップサートが同時に走ると、InnoDB の行ロックやギャップロックの影響でデッドロック(エラー 1213)が起きることがあります。トランザクションを短く保つ、アプリケーション側で数回まで再試行する、更新を一定間隔でまとめて書き込む、といった対策が現実的です。
トリガーの発火順
BEFORE INSERT トリガーは毎回動き、その後に「AFTER INSERT」か「BEFORE UPDATE と AFTER UPDATE」のどちらかが動きます。INSERT 用のトリガーだけで集計しているテーブルに使うと、更新時に集計漏れが出る点に気をつけてください。
WordPress の独自テーブルで使う
WordPress のコアテーブルに対して、この構文を直接書くのは避けるのが基本です。たとえば wp_postmeta は「同じ投稿に同じ meta_key を複数持てる」設計で、post_id と meta_key の組み合わせにユニークキーがありません。そのためアップサートが成立せず、update_post_meta() を使うのが正解です。wp_options は option_name にユニークキーがあり、コアの add_option() の内部でもこの構文が使われていますが、オブジェクトキャッシュとの整合性のため、プラグインやテーマからは update_option() を使ってください。
一方、プラグインで独自テーブルを作る場合には便利に使えます。以下は有効化時にテーブルを作り、閲覧数を記録する例です。テーブルを追加する前にはデータベースのバックアップを取り、ステージング環境で動作を確かめてください。
<?php
// プラグインのメインファイルに記述する例
// 有効化時にテーブルを作成する
function myplugin_create_views_table() {
global $wpdb;
$table = $wpdb->prefix . 'myplugin_views';
$charset = $wpdb->get_charset_collate();
// dbDelta は書式に厳しいので、1列1行・PRIMARY KEY の後ろは半角スペース2つで書く
$sql = "CREATE TABLE {$table} (
post_id bigint(20) unsigned NOT NULL,
view_date date NOT NULL,
views int(10) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (post_id,view_date)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
register_activation_hook( __FILE__, 'myplugin_create_views_table' );
// 閲覧数を1増やす(その日の行がなければ1で作成)
function myplugin_count_view( $post_id ) {
global $wpdb;
$post_id = absint( $post_id );
// 公開済みの投稿だけを数える
if ( ! $post_id || 'publish' !== get_post_status( $post_id ) ) {
return false;
}
$table = $wpdb->prefix . 'myplugin_views';
$result = $wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table} (post_id, view_date, views)
VALUES (%d, %s, 1)
ON DUPLICATE KEY UPDATE views = views + 1",
$post_id,
current_time( 'Y-m-d' )
)
);
// false は SQL エラー。1 は新規作成、2 は更新
if ( false === $result ) {
error_log( 'myplugin_count_view: ' . $wpdb->last_error );
}
return $result;
}
ポイントは3つです。テーブル名は信頼できる $wpdb->prefix から組み立て、外から来る値は $wpdb->prepare() のプレースホルダー(%d、%s)で渡すこと。値をそのまま SQL 文字列に埋め込むと SQL インジェクションの原因になります。次に、$wpdb には insert() や replace() はあってもアップサート専用のメソッドはないため、このように query() で書くこと。replace() は前述のとおり削除と挿入なので、カウンターには向きません。最後に、Ajax や REST API から呼び出す場合は、値を整数化して存在する公開記事かを確かめ、連打やボットへの対策も考えておくことです。
アクセスのたびに書き込みが発生するため、ページキャッシュを使っているサイトや大量アクセスのあるサイトでは、データベースへの負荷も確認してください。負荷が気になる場合は、一定時間ごとにまとめて書き込む方式のほうが向いています。
まとめ
- ON DUPLICATE KEY UPDATE は、主キーか UNIQUE インデックスで重複したときだけ既存行を更新する。通常の INDEX では重複とみなされない
- 挿入予定の値の参照は、MySQL 8.0.19 以降は行エイリアス、MariaDB や古い MySQL では VALUES() を使う
- 影響行数は 1 が新規、2 が更新、0 が変化なし
- REPLACE は削除してから挿入するため、ID の変化や関連行の削除に注意する
- ユニークキーが複数あるテーブル、AUTO_INCREMENT の欠番、同時実行時のデッドロックに気をつける
- WordPress ではコアテーブルには API を使い、独自テーブルでは $wpdb->prepare() と組み合わせて使う