2児のパパ系Webエンジニア
の開発日記
A Dad-of-Two Engineer's Development Diary

Laravel 13.38のPreventStaleDatabaseReadsで保存直後の古いデータ表示を防ぐ!stickyとの違いも

Laravel 13.38のPreventStaleDatabaseReadsで保存直後の古いデータ表示を防ぐ!stickyとの違いも

🙌 結論から

リードレプリカを使っている Laravel で「保存したのに一覧に出ない、リロードすると出る」が起きているなら、Laravel 13.38.0 の PreventStaleDatabaseReads を web グループに足すだけで、本人の画面ではほぼ起きなくなります!

これ、10月20日に出たばかりのミドルウェアです(^o^)

昔からある sticky は「同じリクエストの中」しか面倒を見てくれません🤔

保存 → リダイレクト → 一覧、のようにリクエストをまたぐと、次のリクエストはまたレプリカから読むので、レプリケーションが追いつく前だと古いデータが出てしまうんですよね(・_・;)

新しいミドルウェアは、書き込みがあった時刻をセッションに入れておき、その後の数秒(標準は2秒)だけ読み込みも書き込み側の接続に向けてくれます🙌

ただしセッション頼みなので、API やキューのジョブには届きません。

そこは同じリリースで入った usingWritePdo() で補う、というのが今回の結論です👍

👀 stickyだけでは防げないリダイレクト後の古いデータ

仕事で関わっている Laravel のシステムでも、読み込みはレプリカ、書き込みはプライマリに分けています。

設定はおなじみのこの形です。

// config/database.php
'mysql' => [
    'driver' => 'mysql',
    'read' => [
        'host' => [env('DB_READ_HOST')],
    ],
    'write' => [
        'host' => [env('DB_WRITE_HOST')],
    ],
    'sticky' => true,
    // ...
],

sticky => true にしておくと、そのリクエストの中で書き込みが1回でもあれば、以降の読み込みは書き込み側の接続を使います。

なので「保存して、同じリクエストの中で読み直す」は問題ありません。

困るのは、管理画面でよくあるこの流れです。

  • フォームを POST して保存する(ここはプライマリ)
  • redirect()->route('products.index') で一覧へ飛ばす
  • 一覧の GET は新しいリクエストなので、sticky の効果は切れていてレプリカから読む

レプリケーションは非同期なので、この一瞬のすき間で古い一覧が返ることがあります。

この手の不具合は「さっき登録した商品が出てないです」という問い合わせになりがちで、こちらで開くと普通に出ている。。

再現しないので、正直かなりモヤモヤするタイプの不具合です(´・ω・`)

Laracasts でも、Aurora 構成でリダイレクト先の一覧に更新が出ないという相談が昔からあって、自分だけじゃないんだな・・・と思いました( .. )

これまではサードパーティの mpyw/laravel-cached-database-stickiness(同じユーザーの連続したリクエストをまたいで、既定5秒だけ書き込み側に寄せるパッケージ)を入れるか、自前のミドルウェアを書くしかなかったんです。

💡 PreventStaleDatabaseReadsがやっていること

公式ドキュメントの「Read and Write Connections」に、sticky の説明のすぐ下へ「Preventing Stale Database Reads」という項目が増えています✨

ソースも短いので読んでみました😎

やっていることはシンプルで、だいたい次の2つです。

  • リクエストの最後に、そのリクエストで書き込みがあったら現在時刻をセッションの _database_modified_at に入れる
  • 次のリクエストの最初に、その時刻から指定の秒数(標準2秒)以内なら useWriteConnectionWhenReading(true) を呼んで、読み込みも書き込み側の接続に向ける

秒数が過ぎれば、何もしなくてもレプリカに戻ります!

ずっとプライマリに張りつくわけではないので、レプリカを置いた意味がなくならないのがありがたいです(^^)

一方で、ドキュメントと PR に書かれている制約もはっきりしています。

  • 対象はデフォルトのDB接続だけ(別の接続名で読んでいるクエリは対象外)
  • 守ってくれるのは書き込んだ本人のセッションだけ。他のユーザーは今までどおりレプリカから読む
  • セッションを使うので StartSession より後に動かす必要がある。PR では、セッションを使わない API には向かないと書かれている

PR のコメント欄では「長く走るリクエストがセッションを上書きして、_database_modified_at が消えることがあるかも」という指摘も出ていました(・∀・)

作者も「まれで深刻ではない」という受け止めだったので、私は完全な保証ではなく、体感の不具合を減らすものとして使うつもりです。

🔧 私の入れ方と確認チェックリスト

まず laravel/framework を 13.38.0 以上に上げます。

composer require laravel/framework:^13.38
php artisan --version   # Laravel Framework 13.38.x になっていればOK

bootstrap/app.php はこう書いています。

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        // 標準は2秒。レプリカの遅れに合わせて秒数を変えられる
        PreventStaleDatabaseReads::for(5),
    ]);
})

web グループには最初から StartSession が入っているので、append で後ろに足せば順番の条件は満たせます🙌

管理画面だけに絞りたいなら、ルートのグループに付ける書き方もできます💡

Route::middleware(PreventStaleDatabaseReads::class)->group(function () {
    Route::resource('products', ProductController::class);
});

入れる前に、私はこの4つを確認するようにしています。

  • sticky は残す: 新しいミドルウェアは「次のリクエスト」担当、sticky は「同じリクエスト」担当。役割が違うので両方オンのままで大丈夫です
  • 秒数はレプリカの遅れを見て決める: MySQL なら SHOW REPLICA STATUS の Seconds_Behind_Source を見ておく(公式マニュアルいわく短い間でも0と大きい値を行き来することがあるので、1回だけで決めない)。長くしすぎるとプライマリに読み込みが寄るので、余裕を少し足すくらいにしています
  • デフォルト以外の接続を使っていないか: 集計用に別の接続名でレプリカを読んでいる画面は、このミドルウェアの対象外です
  • API ルートで頼らない: トークン認証の API はセッションがないので、下の usingWritePdo() で個別に寄せる

一番迷うのは秒数ですね🤔

レプリカの遅れが普段 1 秒未満の環境なら標準の2秒で足りるはずなので、私はピークの時間帯に遅れを見てから決めるようにしています。

🤔 ミドルウェアが届かないところはusingWritePdoで

セッションがない場所、たとえばキューのジョブでも同じ問題は起きます。

Larastan の記事で afterCommit の付け忘れを直した話を書きましたが、コミット後にジョブを投げても、ジョブがレプリカから読めば反映前のデータを拾うことがあります(T_T)

ここで使えるのが、13.38.0 で一緒に入った Connection::usingWritePdo() です。

use Illuminate\Support\Facades\DB;

public function handle(): void
{
    $order = DB::connection()->usingWritePdo(fn () => Order::with('items')->findOrFail($this->orderId));

    // ここから先の通知・集計は、取れた $order を使う
}

コールバックの中の読み込みは全部書き込み側の接続になり、終わったら元の設定に戻ります。

例外が出ても元に戻るとドキュメントに書いてあるので、try / finally を自分で書かなくていいのは楽・・!

今までも Order::onWriteConnection() や、クエリビルダの useWritePdo() はありました。

ただ PR の説明によると、これらはそのクエリだけの指定なので、あとから読むリレーション($user->posts() など)はレプリカに行ってしまうんです・・!

「親は取れたのに子が空」というかなり分かりにくい形で出るので、リレーションまでまとめて読むなら usingWritePdo() で包んだほうが安心です👀

🔗 関連記事と使い分け

  • Larastan 3.12 のキュー用ルール: トランザクション中にジョブが走る問題は afterCommit、コミット後もレプリカが遅れる問題は今回の usingWritePdo()、と分けて考えています
  • Redis の API キャッシュ: 古いデータが出る原因がキャッシュ側なら、こちらの「キャッシュの消し方」を先に疑います。レプリカの遅れとキャッシュの消し忘れは症状がそっくりなので、まずどっちかを切り分けるのが大事です

パパ系Webエンジニア〜愛用ガジェット一覧 🛍️

この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌

👍 まとめ

リードレプリカ構成で「保存直後に古いデータ」が気になっているなら、PreventStaleDatabaseReads は1行足すだけなので入れて損はないと思います。

sticky と役割が違うので、両方オンのままでOKです(`・ω・´)

ただ、守れるのは書き込んだ本人のセッションとデフォルト接続だけです。

ジョブや API は usingWritePdo() で個別に寄せる、と割り切るのがちょうどいいかな、と思っています(^^)

今までパッケージか自前のミドルウェアで埋めるしかなかった「書き込み後の数秒だけプライマリ」が本体に入ったのは、マジでうれしい・・!

リダイレクト後の古い一覧、みんなはどうやって防いでました?

※ バージョン番号・料金・機能は今後も変わる可能性があります。最新情報は Laravel 公式ドキュメント をご確認ください👀

参考になったらシェアしてもらえるとうれしいです🙌

Xでポスト Threads はてブ LINE