45
41

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

LaravelAdvent Calendar 2021

Day 16

Laravel の firstOrNew() firstOrCreate() をレースコンディション考慮で実用的に使う

Last updated at Posted at 2021-12-15

2023-08-30 更新: Laravel v10.29.0 の変更によって Laravel 本体に改修が入りました。レースコンディションを考慮した Model::createOrFirst()Model::firstOrCreate() Model::updateOrCreate() の内部で使われるようになりました。今後は安心してこれらを使用することができます。

2023-08-31 更新: Zenn に詳細な解説を書きました
[Laravel] createOrFirst の登場から激変した firstOrCreate, updateOrCreate に迫る!

TL;DR

  • Laravel の firstOrNew() firstOrCreate() updateOrCreate() 等はレースコンディションが全く考慮されていない
    • 普通はユニークキー制約エラーを拾ってリトライするだけで十分
    • updateOrCreate() 相当の処理で厳格にロストアップデートを回避する場合,悲観ロックまたは楽観ロックのいずれかの適用が必要。 Laravel 8.10 以降は upsert() という選択肢もある
  • ライブラリ を作ったので使ってね
    • Laravel 8.10 の upsert() は,使われ方が全く違うので差別化はできている

ご挨拶

Qiita ではご無沙汰しております。最近は専ら走り書きが多く,ネット上で技術にフォーカスした記事らしいものを書くのはかなり久しぶりです。以下の転職エントリが多分最後だと思います。

今回は数年前からずっと業務で使用している, firstOrCreate() 等を実用的に使うためのちょっとした工夫と,現在の職場で使用するために OSS 化したライブラリについて紹介します。

導入

メソッドの動作

まず,このようなメソッド群が何をするものなのか,再確認してみましょう。

大雑把にまとめると,以下のような用途となります。

メソッド名 動作
firstOrNew SELECT して存在したら返す,
無ければ新規インスタンスを返す
firstOrCreate SELECT して存在したら返す,
無ければ新規インスタンスを INSERT して返す
updateOrCreate SELECT して存在したら UPDATE して返す,
無ければ新規インスタンスを INSERT して返す

どのメソッドも,第1引数が 一意な検索条件 $attributes,第2引数が作成または更新時に追加で埋めたいその他の値 $values です。

実装の確認

せっかくなので,バージョン 8.73.0 時点でのソースコードを確認してみましょう。

いつも Model::firstOrCreate() のように Eloquent Model 上で使っているメソッドなので,てっきりそこに生えているかと思いきや,実は Eloquent Builder 上の実装なんです。これは知らなかった人も一定数いらっしゃるかもしれませんね。

処理委譲の仕組み

以前こちらの記事で触れましたが, Model::firstOrCreate()Model::__call() を通じて下位にある Builder::firstOrCreate() に実行を委譲しています。しかし,委譲しているにも関わらず,結局 Builder::newModelInstance() で Model にフォーカスが戻っているので,位置づけとしては特殊な部類になるかもしれません。

Laravel 9.x で,一部のメソッドが interface + trait 定義となり,__call() 依存から外れる変更が予定されています。

問題: レースコンディションの発生

さて,本題です。

public function firstOrCreate(array $attributes = [], array $values = [])
{
    if (! is_null($instance = $this->where($attributes)->first())) {
        return $instance;
    }

    return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) {
        $instance->save();
    });
}

上記で引用したコードを見る限り,このメソッドの SQL 実行は2回に分かれています。最初の first() で SELECT クエリが実行され,その後に条件つきで新規インスタンスについて save() によって INSERT クエリが実行されます。これによって何が問題になるでしょうか?

それは,レースコンディションの発生です。カタカナにピンと来ない方には,

「2人以上が2つ以上のクエリを実行する時,絶妙なタイミングでそれらが噛み合って不整合な状態を作ってしまう」

とでも言っておきましょうか。以下の表に,2人が2つのクエリを実行する際,レースコンディションが発生している状況の時系列を示してみます。

他ユーザ あなた
SELECT
(結果なし)
SELECT
(結果なし)
INSERT
(成功)
INSERT
(エラー!キー重複)

このように,あなたの SELECT が他ユーザの SELECT と INSERT の間に実行されてしまった場合,あなたはユニークキー制約で確実にエラーになる INSERT を実行してしまうでしょう。
(あるいはユニークキー制約が DB 上に無い場合,重複したレコードを作成してしまうでしょう)

原則的に,一意性を求める部分には必ずユニークキー制約を設定してください。アプリケーション側での担保よりも,データベース側での担保のほうが遥かに信頼性が高いからです。

以下では,ユニークキー制約が存在する前提で話を進めます。

firstOrCreate() firstOrNew() に関する対策

リトライ

ユニークキー制約エラーになったら,リトライしましょう。殆どの場合において,これで十分要件は満たせるでしょう。

他ユーザ あなた
SELECT
(結果なし)
SELECT
(結果なし)
INSERT
(成功)
INSERT
(エラー!キー重複)
リトライ!
SELECT
(結果あり)
try {
    $user = User::firstOrCreate(['email' => 'example@example.com']);
} catch (QueryException $e) {
    // 実際には $e でエラーコード判定
    $user = User::firstOrCreate(['email' => 'example@example.com']);
}

updateOrCreate() に関する対策

リトライ…?

updateOrCreate() の場合も,エラーを回避したいだけであればリトライのみで十分です。 updateOrCreate() 特有の問題としては,更新処理が競合することによるロストアップデートがあります。文字通り,更新が失われることです。

他ユーザ あなた
SELECT
(結果あり)
SELECT
(結果あり)
UPDATE
(成功)
(ここで本来はもう1回
SELECT したい)
UPDATE
(成功…していいの?)

他ユーザが更新したはずのものを,知らず知らずのうちにあなたが上書きしてしまうかもしれません。

但し, Eloquent Model の save()変更した差分のみを UPDATE 対象にするので,あなたが変更していない部分で他ユーザの変更を上書きしてしまうことは回避できます。このため問題は限定的です。

「自分が変更する前に他のユーザが一切変更を入れていないことを保証したい」と厳密性が求められる場合,以下に挙げる対応を実装する必要があるかもしれません。

それぞれの使い分けに関しては,上記の記事を参照してください。

悲観ロック

データベース上の該当レコードを実際にロックして,自分が作業中には他の人に一切触らせなくしてしまう方法です。悲観ロックには **「共有ロック」「排他ロック」**の2種類がありますが,今回は更新が目的なので「排他ロック」を使うことになります。

種類 他ユーザの SELECT 他ユーザの INSERT/UPDATE/DELETE
共有ロック
排他ロック

Laravel においては,以下のように lockForUpdate() を使用して実現します。 Query Builder 上の実装ですが,例によって __call() の委譲により Model や Eloquent Builder からの呼び出しもできます。

DB::transaction(function () {
    // トランザクション内でロックを取得
    $user = User::lockForUpdate()->findOrFail(1);
    $user->email = 'new@example.com';
    $user->save();
});
// トランザクションを抜けると自動的にロックを開放

楽観ロック

データベースのロック機能は使用せず,アプリケーション側で担保する方法です。トランザクションも一切不要で,競合可能性が低い場合はリクエストをまたぐロックもできてしまう点に長所があります。例えば GitHub の Issue 本文, Qiita の記事本文といった場所で活躍しそうです。

その場でロック開始から終了まで行う場合
// 取得時点からのロック開始
$user = User::findOrFail(1);
$user->email = 'new@example.com';

// MySQL は新旧値に差分が無い場合に編集競合という誤判定をしてしまうためこの確認が必須
// (他の RDBMS の場合は省略可能であるが,クエリ数を減らせるメリットはある)
if ($user->isDirty()) {
    // 古い値での WHERE 条件を追加して UPDATE を実行
    if (!$user
        ->newQueryForRestoration()
        ->where('email', $user->getOriginal('email'))
        ->update($user->getDirty())) {
        throw new ConflictHttpException('編集が競合しました');
    }

    // 本来 save() を使ったときに設定される内容を再現
    $user->syncOriginal();
}


前のリクエストでロック開始して今回のリクエストで終了する場合
// フォームを表示した時点での値をフロント側から受け取る
$oldEmail = $request->input('old_email');

$user = User::findOrFail(1);

// 取得した時点で変化していたら確実に競合している
if ($user->email !== $oldEmail) {
    throw new ConflictHttpException('編集が競合しました');
}

$user->email = $request->input('new_email'); // new@example.com

if ($user->isDirty()) {
    // こちらでも同様に競合の判定を行う
    if (!$user
        ->newQueryForRestoration()
        ->where('email', $oldEmail)
        ->update($user->getDirty())) {
        throw new ConflictHttpException('編集が競合しました');
    }

    $user->syncOriginal();
}

この update() メソッドは, Model ではなく Eloquent Builder のものです。それゆえ,もとの User オブジェクトの内容に更新は反映されません。更新成功後, syncOriginal() を実行してようやく完了となります。また, Eloquent イベントは発火されません。

MySQL は,「新しい値と古い値の間に差があったときだけ」その行が影響を受けた,という扱いにしてしまいます。これは他の多くの RDBMS の「WHERE で絞り込まれた」行を計上する,という挙動と大きく異なります。

実際のエラーコード判定ロジック

泥臭い処理を隠蔽したものをライブラリ化しましたので,これをお使いください!

$user = DB::retryOnDuplicateKey(function () {
    return User::firstOrCreate(['email' => 'example.com']);
});

具体的な判定ロジックに関しては,上記のライブラリが内部で使用している下記のライブラリを参照いただければと思います。

その他

Rails での例

言語は違えど, Ruby on Rails でも最終的にみんなこの解決策に落ち着くようですね…

Laravel 8.10 で実装された upsert() との違いについて

Eloquent Builder 上に実装されている upsert() については,

  • MySQL: INSERT ... ON DUPLICATE KEY UPDATE 構文
  • Postgres: INSERT INTO ... ON CONFLICT DO UPDATE 構文

を使用して1クエリだけを実行することにフォーカスを置いているものですが,明確に用途が違う部分があります。

  • firstOrCreate() については,**「基本的に既存レコードを SELECT するが,初回だけ INSERT したい」**という用途である場合,殆どの場合において 1 クエリで済むこちらに優位性があります。
    • upsert() を使用する場合,その後に SELECT を結局実行する必要があるので,2 クエリの実行が確定してしまいます。
  • firstOrCreate() updateOrCreate() ともに,オートインクリメントの番号を節約できるメリットがあります。
    • upsert() は結果的に起こる処理が UPDATE であったとしてもオートインクリメント番号を必ず1個消費してしまい,スペースの浪費や欠番を生む原因になります。クエリの実行頻度が著しく多い場合に問題となるかもしれません。
  • 更に updateOrCreate() については, RDBMS によって考え方が異なる部分があります。
    • MySQL 以外の RDBMS においては,自動的にリードレプリカとマスターへの振り分けを行う sticky オプションによる最適化が無駄になってしまう可能性があるため,更新時に差分が発生していることが確定的でない場合は updateOrCreate() をリトライさせるコードを書いたほうがよい場面もあるでしょう。
    • MySQL においては,この制約事項はありません。

upsert() メソッドは, Model ではなく Eloquent Builder のものです。 Eloquent イベントは発火されません。

upsert() メソッドは,バルクインサートに対応しています。

まとめ

  • Laravel の firstOrNew() firstOrCreate() updateOrCreate() 等はレースコンディションが全く考慮されていない
    • 普通はユニークキー制約エラーを拾ってリトライするだけで十分
    • updateOrCreate() 相当の処理で厳格にロストアップデートを回避する場合,悲観ロックまたは楽観ロックのいずれかの適用が必要。 Laravel 8.10 以降は upsert() という選択肢もある
  • ライブラリ を作ったので使ってね
    • Laravel 8.10 の upsert() は,使われ方が全く違うので差別化はできている
45
41
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
41

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?