💨

Laravel キャッシュをDB変更時にクリアする問題

2021/09/05に公開

Laravelで検索結果を一時的にキャッシュに保存して、同じ検索をする時にキャッシュから取り出すことで、DBへのアクセスを減らせるし、レスポンススピードも向上できます。ただ、保存するだけでなく、DBのデータが更新される時に、キャッシュデータをクリアしたい時もあります。ここでLaravelのオブザーバーを利用するやり方をまとめてみました。

キャッシングのドライバー

実際にキャッシュの使い方について、いくつかのドライバーがあり、それぞれの特徴があります。

各種のキャッシュドライバーのパフォーマンス差についてこちらの調査があります。結論をまとめると:

  • APCu:スピードが最も早く、データの量に関わらず安定している。一度エクステンションをインストールする必要がある。
  • ファイルシステム:読みに関しては多少遅れるが、書きはメモリーDB系に近いスピード。設定変更などが不要、デフォルトのキャッシングドライバー。
  • データベース:最も遅い、元々DBヒットを減らしたいのもあり、あまりおすすめできない。実装が簡単だが一度マイグレーションが必要。
  • Redis/Memcache:APCuに続いて早い。ただ別途サービスを立ち上げる必要があり、これらの中で一番手間がかかる。Dockerを使えばセットアップが簡単になるので、こちらを使うならDockerがおすすめ。

各ドライバーの詳しい設定の仕方、使い方についてこちらにご参照ください。今回はデフォルトのファイルシステムをドライバーに起用します。

検索条件と結果を保存

一般的にはコントローラでキャッシング操作していると思いますが、レポジトリーを作ってより優雅なやり方もあります。ここはコントローラで例をあげます。

仮にフロントエンドからGETリクエストでフォームデータが提出されます。その検索条件をキャッシュのキー、結果をバリューとして保存します。

// とあるコントローラ

    public function search(Request $request)
    {
        // query stringを配列として取得
        $qs = $request->query();
        if (empty($qs)) {
            return back()->with('warning','検索条件を入力してください');
        }
	// 配列を文字列に変換し、キーとして利用
        $key = $this->json_encode($qs);
	
	// キャッシュにキーが存在すればそのまま使用
        if (Cache::has($key)) {
            $items = Cache::get($key);
        } else {
            // query stringに基づいて検索
            $items = $this->getItems($qs);

            // マッチする結果がない
            if ($items->isEmpty()) {
                return back()->with('error','見つかりませんでした');
            }
	    
	  // 結果をキャッシュに追加
            Cache::put($key, $items, $seconds = 600);
        }

        return view('Item.search', compact('items'));
    }

オブザーバーについて

キャッシュを保存するのは良いですが、DBのデータが更新されるときにキャッシュをクリアしたい、というケースもあるでしょう。さもないと、キャッシュの保存時間内に毎回同じ結果しか返ってこなく、最新のデータが反映されません。

ここの一つの解決法として、observerを利用することにしました。observerは、モデルと紐付いて、モデルイベントが発生するたびに処理を行うことができます。

まずはモデルと関連するオブザーバークラスを作ります。

php artisan make:observer UserObserver --model=User

これでApp/Observersにファイルが作られるので、中身はいくつかのモデルイベントが発生するときに処理する関数のテンプレートとなります。

<?php

namespace App\Observers;

use App\Models\User;

class UserObserver
{
    public function created(User $user)
    {
        //
    }

    public function updated(User $user)
    {
        //
    }

    public function deleted(User $user)
    {
        //
    }

    public function forceDeleted(User $user)
    {
        //
    }
}

トランザクションを使うときにこちらの変数を追加すれば、コミットされた後にイベントを処理するようになります:

public $afterCommit = true;

最後に、このオブザーバーを有効化するために、サービスプロバイダーApp\Providers\EventServiceProvider に登録します。このステップを忘れるとオブザーバーが作動しませんのでご注意ください。

use App\Models\User;
use App\Observers\UserObserver;

public function boot()
{
    User::observe(UserObserver::class);
}

また、上記のオブザーバーのテンプレートコードのイベント以外にも、他に色々とライフサイクルイベントがあります。イベントが発生するタイミングについては以下となります。

  • retrieved, 一個のインスタンスが取得された時、例えば:User::find(1)
  • creating/created, モデルインスタンスが作られる前/後に、例えば:User::create([...])
  • updating/updated, モデルインスタンスが更新される前/後に、例えば:User::find(1)->update([...])、$user->save()。属性データが変わっている時に起こります。
  • saving/saved, $user->save()だけでなく、$user->update([...])$user->create([...])時にも起こります。インスタンスの属性データが変わっていなくても起こります。
  • deleting/deleted, $user->delete()の前/後に、例えば:$user->delete(), $user->forceDelete()。物理削除、論理削除に関係なく、削除であれば起こります。
  • restoring/restored, 論理削除から回復される前/後に、例えば:User::withTrashed()->find(1)->restore();
  • replicating、インスタンスをコピーする前に、例えば:User::find(1)->replicate()

キャッシュクリア時の注意点

上記のオブザーバーとモデルイベントを元に、キャッシュをクリアするタイミングが大体イメージできたでしょう。基本的に、created, updateddeletedの時にクリアすれば良い。

class UserObserver
{
    public function created(User $user)
    {
        Cache::flush();
    }

    public function updated(User $user)
    {
        Cache::flush();
    }

    public function deleted(User $user)
    {
        Cache::flush();
    }
}

ただし、ここで注意しないといけないのは、モデルオブザーバーは、シングルのモデルインスタンスのみを対象にしているため、一回で複数のレコードを作ったり、更新したり、削除したりする(bulk insert/update/delete)ことは、これらのモデルイベントが発生できません!!

そのため、こちらのコードを比較してみると:

User::find(1)->update([...]); // OK
User::find(1)->delete([...]); // OK
User::where('is_active', false)->first()->delete(); // OK

User::where('id',1)->update([...]); // NG
User::where('is_active', false)->delete(); // NG

もし一括更新、削除などがある場合、for-loopとかで一個ずつですると、モデルイベントが起こります。ここは特にハマりやすい落とし穴なので、もし「オブザーバーが効いていない」とかがあれば、一度複数のレコードを操作しているのではないかとチェックしてみましょう。

もし一度大量のデータを処理しないといけない時、for-loopで一個ずつ処理すると同じQueryをいっぱい作ってしまうので逆にマイナスとなります。ケースには限られていますが、今回のように、幾つかのレコードと関係なく、とにかく何か変更があればキャッシュをクリアしたい、であれば、bulk処理と別途一つのインスタンスだけをトリガーとして処理するのも良いでしょう。

DB::beginTransaction();
try {
    $q = User::where('is_active', false);
    $q->first()->delete();
    $q->delete();
    DB::commit();
} catch (\Error $e) {
    DB::rollBack();
    Log::error(__FILE__ . " (" . __LINE__ . ")" . PHP_EOL . $e->getMessage());
}

この場合はトランザクション後イベント処理する変数、$afterCommit = trueをオブザーバーに設定することを忘れずに。

以上です!

Discussion