はじめに
Webアプリケーションの開発で、複数のプロセスが同時に同じデータを操作する際の競合状態(Race Condition)に遭遇しました。この問題をUPSERTで解決した実装について紹介します。
問題の背景
従来の実装の問題点
// 競合状態が発生する可能性のあるコード
$existing = DB::table('users')
->where('email', $email)
->first();
if ($existing) {
// この間に他のプロセスが同じレコードを変更する可能性
DB::table('users')
->where('id', $existing->id)
->update($data);
} else {
DB::table('users')->insert($data);
}
具体的な競合シナリオ
- プロセスA: 既存レコードを取得(SELECT)
- プロセスB: 同じレコードを取得(SELECT)
- プロセスA: レコードを更新(UPDATE)
- プロセスB: レコードを更新(UPDATE)← プロセスAの変更が上書きされる
UPSERTによる解決
UPSERTとは
UPSERT = UPDATE + INSERT の組み合わせで、以下の動作を1回のSQL文で実行:
- レコードが存在しない場合 → INSERT
- レコードが存在する場合 → UPDATE
メリット
1. 競合状態の完全回避
- 1回のSQL文で処理完了
- SELECT → UPDATE の間に他のプロセスが介入する余地なし
2. データ整合性の保証
- 常に最新の情報が保持される
- 古いデータで新しいデータを上書きすることを防ぐ
3. パフォーマンス向上
- ネットワーク往復回数の削減
- バルク処理による効率化
重要なポイント
ユニーク制約が必須
// マイグレーション
Schema::create('users', function (Blueprint $table) {
$table->id();
// upsertするにはユニーク制約が必要
$table->string('email')->unique();
$table->string('name');
$table->timestamps();
});
-
ON DUPLICATE KEY UPDATEはユニーク制約違反をトリガーとする - 制約がないとUPSERTは動作しない
実装コード
private function saveUser(array $userData): int
{
$insert_sql = "INSERT INTO users (email, name, updated_at)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
updated_at = VALUES(updated_at)";
DB::statement($insert_sql, [
$userData['email'],
$userData['name'],
now()
]);
}
その他工夫したこと
1. 条件付き更新
以下のように古いデータで新しいデータを上書きすることを防ぎたい場合には更新条件を設定することができます。
private function saveUser(array $userData): int
{
$insert_sql = "INSERT INTO users (email, name, updated_at)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
name = IF(VALUES(updated_at) > updated_at, VALUES(name), name),
updated_at = IF(VALUES(updated_at) > updated_at, VALUES(updated_at), updated_at)";
DB::statement($insert_sql, [
$userData['email'],
$userData['name'],
now()
]);
}
2. バルク処理
バルク処理とは、複数のデータを一度にまとめて処理することです!
100回のSQL → 1回のSQLで実行しN+1問題を回避し
メモリ使用量を抑えることが出来ます。
// 複数レコードを一度にUPSERT
$values = [];
$bindings = [];
foreach ($users as $user) {
$values[] = "(?, ?, ?)";
$bindings[] = $user['email'];
$bindings[] = $user['name'];
$bindings[] = now();
}
// 生成されるSQL例
$insert_sql = "INSERT INTO users (email, name, updated_at)
VALUES " . implode(', ', $values) . "
ON DUPLICATE KEY UPDATE
name = IF(VALUES(updated_at) > updated_at, VALUES(name), name),
updated_at = IF(VALUES(updated_at) > updated_at, VALUES(updated_at), updated_at)";
DB::statement($insert_sql, $bindings);
まとめ
UPSERTを活用することで、競合状態を根本的に解決し、データの整合性とパフォーマンスを両立できました。特に複数のプロセスが同時に同じデータを操作するシステムでは、UPSERTは強力な解決策となります。