メインコンテンツまでスキップ

キャッシュ — 速くするために、正しさをどこまで諦めるか

ここまでの 2 章は「遅い処理を後ろへ回す」話でした。この章で扱うのは、そもそも処理をしない方法です。

商品一覧は、誰が見ても同じ結果を返します。1 秒間に 100 人が開けば、同じクエリを 100 回実行します。1 回だけ実行して、結果を覚えておけば済みます。

ただし、覚えておくということは古い値を返す可能性を受け入れるということです。この章は速さの話であると同時に、どこまで古くてよいかを決める話です。

何をキャッシュしてよいか

判断はひとつだけです。「少し古い値を返しても許されるか」

対象判断
商品一覧・カテゴリ一覧よい。数分前の情報でも実害が小さい
商品の在庫数危ない。古い値を見せると、買えない商品を買えると表示する
利用者ごとの注文履歴原則やらない。共有すると事故になる (後述)
権限・認可の判断やらない。権限を剥奪した人が古い判断で通る

在庫数が微妙な位置にいます。一覧に「残りわずか」と出す程度なら、多少ずれても問題ありません。注文処理で使う在庫数は、絶対にキャッシュしてはいけません。 第14章で行ロックと条件付き UPDATE を入れたのは、まさにその値を正確に扱うためでした。

速さのためにキャッシュを入れて、第14章の防御を無効化しないでください。

Cache::remember

基本形はこれだけです。

$value = Cache::remember('キー', 300, function () {
return /* 重い処理 */;
});

remember は、キーがあればその値を返し、無ければクロージャを実行して結果を保存してから返します1。第 2 引数は保持する秒数です。

第1章で「商品一覧はキャッシュの対象になる」と書きました。ところが、第8章で作った一覧 API にそのまま被せると、うまくいきません

第8章の一覧 API は、そのままではキャッシュできない

第8章の indexkeyword / sort / direction / per_page に加えてページ番号を受け取ります。結果を変える要素が 5 つあります。 キャッシュのキーには、そのすべてを含める必要があります。

// 「coffee で検索、価格の降順、1 ページ 50 件、3 ページ目」だけのキー
$key = "products.coffee.price.desc.50.3";

キーの組み合わせは、検索語の種類だけ無限に増えます。ほとんどのキーは 1 回しか読まれません。 保存する手間をかけて、再利用されずに期限を迎えます。速くならないどころか、書き込みのぶん遅くなります。

キャッシュが効くのは、少ないキーで多くのリクエストをまかなえるときだけです。

効くところだけに絞る

実際のアクセスは均等ではありません。検索も並び替えもしていない 1 ページ目に大きく偏ります。トップページから来た人が最初に見る画面だからです。

そこだけをキャッシュします。

app/Http/Controllers/ProductController.php
use Illuminate\Support\Facades\Cache;

public function index(Request $request)
{
$validated = $request->validate([
'keyword' => ['sometimes', 'string', 'max:100'],
'sort' => ['sometimes', 'in:id,name,price,created_at'],
'direction' => ['sometimes', 'in:asc,desc'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);

// 絞り込みも並び替えもない 1 ページ目だけをキャッシュする
if ($validated === [] && $request->integer('page', 1) === 1) {
$products = Cache::remember($this->listCacheKey(), 300, function () {
return Product::with('stock')->orderBy('id')->paginate(20);
});

return ProductResource::collection($products);
}

// ここから下は第8章のまま
$query = Product::query()->with('stock');

if (isset($validated['keyword'])) {
$query->where('name', 'like', '%' . $validated['keyword'] . '%');
}

$query->orderBy(
$validated['sort'] ?? 'id',
$validated['direction'] ?? 'asc',
);

return ProductResource::collection(
$query->paginate($validated['per_page'] ?? 20)
);
}

条件に合わないリクエストは、これまでどおりデータベースを見ます。 キャッシュは全体を速くする道具ではなく、偏りのある場所を狙って当てる道具です。

キャッシュする側にも orderBy('id') を書いています。 下の分岐が既定で id の昇順にするので、揃えないと 1 ページ目と 2 ページ目で並び順が変わります。同じ商品が両方に出たり、どちらにも出なかったりします。 第8章で「同点があるソートには id を足して順序を一意にする」と書いたのと同じ話が、キャッシュの分岐でも起きます。

どれくらい偏っているかは、アクセスログを見れば分かります。偏っていなければ、キャッシュを入れる理由がありません。

この実装は在庫数もキャッシュしています

冒頭の表で、在庫数を「危ない」に分類しました。それなのに with('stock') を含めてキャッシュしています。意図的です。理由を書きます。

paginate() が返すオブジェクトを保存すると、シリアライズされて復元されます。中の Eloquent モデルは保存した時点の値で固まります。 第16章の SerializesModels のように読み直してはくれません。第8章の ProductResourcestock_quantity を数値で返すので、在庫 1 個の商品が売り切れても、最大 5 分間は「残り 1 個」と表示され続けます

これを受け入れられるのは、注文処理がこの値を使わないからです。第14章で作った経路は、注文のたびに stocks の行を直接ロックして読みます。一覧に古い数字が出ても、買えない商品が買えてしまうことはありません。409 が返るだけです。

受け入れられないなら、選択肢は 3 つあります。TTL を短くする / 一覧では数値でなく「在庫あり」「残りわずか」の区分だけ返す / with('stock') を外して在庫を別に読む。2 つ目が、表示の要求と正確さの両方を満たしやすい形です。

「表で危ないと書いたものを、実装では入れている」という状態を、説明なしに残さないでください。 読んだ人が気づいたとき、判断なのか見落としなのかが分かりません。

ドライバを選ぶ

.envCACHE_STORE で決まります。Laravel 13 の .env.exampledatabase です。

ドライバ置き場所特徴
arrayPHP プロセスのメモリテスト用。プロセスが終われば消える
databasecache テーブル追加のミドルウェアが不要。タグが使えない
filestorage/framework/cache単一サーバー向け。タグが使えない
redisRedis速い。タグが使える。複数サーバーで共有できる

第3章でマイグレーションを流したとき、cache テーブルが一緒に作られていました。あのとき「第18章で使う」と書いたのがこれです。

複数のサーバーでアプリケーションを動かすなら、file は選べません。 サーバーごとに別のファイルを見るので、サーバー A で保存した値をサーバー B が読めません。「同じ商品一覧なのに、リロードすると内容が変わる」という現象になります。第17章のロックで扱ったのと同じ問題です。

キーの設計

キャッシュで最も危険なのは、キーの付け方を間違えて他人のデータを配ることです

❌ Bad: 利用者ごとに違う内容を共通のキーに入れる
$orders = Cache::remember('my.orders', 300, function () use ($request) {
return $request->user()->orders()->latest()->get();
});

最初にアクセスした人の注文履歴が my.orders に入ります。次にアクセスした別の利用者が、その履歴を受け取ります。 第12章で認可を丁寧に作っても、この 1 行で無意味になります。

キーには、結果を変える要素をすべて含めます。

✅ Good
$key = "orders.user.{$request->user()->id}.page.{$request->integer('page', 1)}";

「結果を変える要素」には、利用者 ID だけでなくページ番号・並び順・絞り込み条件も含まれます。第8章で作った一覧 API はこれらを受け取るので、キーに反映しないと別の条件の結果が返ります

そもそも利用者ごとのデータをキャッシュしない

キーを正しく作れば動きますが、利用者の数だけキーが増えます。1 万人が 10 ページずつ見れば 10 万個です。多くの場合、得られる速さに見合いません。

キャッシュが向くのは、少ないキーで多くのリクエストをまかなえるものです。全員が同じものを見る商品一覧は 1 個のキーで全リクエストに効きます。注文履歴は 1 人 1 個で、その人しか使いません。

キャッシュを検討する前に、まずインデックスとクエリを疑ってください。 第8章で扱った N+1 の解消のほうが、効果も安全性も上です。

いつ捨てるか

保存したものは、いつか正しくなくなります。捨て方は 2 つあります。

時間で捨てる

remember の第 2 引数がこれです。「最大でこれだけ古い値が返る」という宣言になります。

商品一覧を 300 秒でキャッシュするなら、商品を追加してから最大 5 分間は一覧に出ません。これが許容できるかを決めるのが設計です。許容できないなら、秒数を縮めるのではなく次の方法を使います。

変更したときに捨てる

第1章で「商品一覧のキャッシュと、商品更新時の失効」と書きました。ここで回収します。

素直にやるなら、商品を更新したときに対応するキーを消します。

Cache::forget('products.list'); // ← この方法は採りません

次のリクエストで作り直されるので、これでも動きます。 キャッシュしているキーが 1 つのうちは、これで足ります。

問題は、対象を広げた瞬間に破綻することです。 「よく見られる 2 ページ目も入れよう」「カテゴリごとにも入れよう」と増やすたびに、消す側にも 1 行ずつ足す必要があります。足し忘れても、エラーは出ません。古い値が返り続けるだけです。

Redis ならタグでまとめて消せますが、databasefile ではタグが使えません1。連載の既定は database なので、この方法も取れません。

そこで、消さずに済ませる形にします。 次の節がこの連載で採用する方法で、上に出てきた listCacheKey() の中身にあたります。

バージョンをキーに入れる

タグが無くても、キーそのものを変えてしまえば古い値には二度と到達しません

キーを組み立てるところを 1 つのメソッドにまとめます。

app/Http/Controllers/ProductController.php
private function listCacheKey(): string
{
$version = Cache::get('products.version', 0);

return "products.v{$version}.list";
}

商品が変わったら、バージョンを新しい値にします。

Cache::forever('products.version', (string) Str::uuid());

increment は使いません。 キーがまだ無いときの挙動がドライバによって変わり、「増えたつもりで増えていない」という分かりにくい失敗をします。

現在時刻も使いません。 now()->timestamp は秒単位なので、同じ秒に 2 回更新すると値が変わりません。手元では滅多に起きませんが、商品を一括で登録したときや、テストの中で連続して更新したときに踏みます。「たまに古い値が残る」という、最も追いにくい形の不具合になります。

毎回必ず違う値になるものを使ってください。UUID なら、キーが無い状態からでも、連続して呼んでも、必ず別の値になります。

キー全体が変わるので、将来キャッシュする範囲を広げても、無効化のコードは変えずに済みます。 古いキーは誰も読まなくなり、TTL が切れれば自動的に消えます。

無効化はどこに書くか

商品を更新する経路が 1 つとは限りません。 第4章で apiResourceindexshow だけに絞ったので、いまの API に商品を更新するエンドポイントはありません。次章で画像のアップロードが 1 本増えます。実際には管理画面・バッチ・手作業の SQL など、さらに複数の経路があります。

書き漏らしを防ぐには、モデル側で受けるのが確実です

app/Observers/ProductObserver.php
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class ProductObserver
{
public function saved(Product $product): void
{
Cache::forever('products.version', (string) Str::uuid());
}

public function deleted(Product $product): void
{
Cache::forever('products.version', (string) Str::uuid());
}
}
app/Models/Product.php
use App\Observers\ProductObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy(ProductObserver::class)]
class Product extends Model
{
// ...
}

#[ObservedBy] で紐付けます。 第3章の #[Fillable] や第17章の #[Tries] と同じ、属性で宣言する形です。

モデルイベントは一括操作では発火しません

第15章で扱ったとおり、一括更新・一括削除ではモデルイベントが発行されませんProduct::where(...)->update([...]) で価格を書き換えても、このオブザーバは呼ばれません。

一括操作を書く場所では、自分でバージョンを更新してください。「モデルに任せたから安心」とは言えない部分が残ります。

第15章では「業務上の出来事はモデルイベントで表さない」と書きました。ここでモデルイベントを使うのは、扱っているのが業務上の出来事ではなく「行が変わった」という事実そのものだからです。キャッシュが古くなる条件は、まさにそれと一致します。

同時に殺到したときに何が起きるか

キャッシュが切れた瞬間を考えます。

1 秒に 100 リクエスト来ているとして、商品一覧のキーの期限が切れます。次の 100 リクエストがすべて「キャッシュが無い」と判断し、100 個ともクエリを実行します。 キャッシュを入れる前より悪い状態になります。

これをスタンピード (雪崩) と呼びます。アクセスが多いほど深刻になるので、性能対策として入れたものが高負荷時に牙を剥きます。

古い値を返しながら裏で作り直す

Cache::flexible は、期限を 2 段階に分けます1

$products = Cache::flexible($this->listCacheKey(), [60, 300], function () {
return Product::with('stock')->paginate(20);
});
  • 60 秒まではそのまま返す
  • 60 秒から 300 秒のあいだは古い値をすぐ返し、裏で作り直す
  • 300 秒を過ぎたら作り直しを待ってから返す

「古くなってきたら、誰かのリクエストのついでに更新する」という形です。利用者を待たせずに更新できます。

古い値を返す期間が明示的に増えるので、どこまで古くてよいかを決めてから使ってください。

1 つだけ作らせる

Cache::lock を使うと、作り直す権利を 1 リクエストだけに与えられます1

$lock = Cache::lock('products.rebuild', 10);

if ($lock->get()) {
try {
// ここに来るのは 1 リクエストだけ
} finally {
$lock->release();
}
}

ロックを取れなかったリクエストは、待つか、古い値を返すか、諦めるかを選びます。

第17章のロックと同じ制約があります。 アトミックロックに対応したドライバが必要で、複数サーバーなら全員が同じ場所を見る必要があります。

database ドライバでは cache_locks テーブルを使います。 第3章のマイグレーションで cache と一緒に作られているので、追加の作業は要りません。

finally で必ず解放してください。 例外で抜けたときにロックが残ると、期限が切れるまで誰も作り直せません。

期限だけを延ばす

Laravel 13 で Cache::touch() が入りました2値を取り出して入れ直すことなく、期限だけを延ばせます。

Cache::touch($this->listCacheKey(), 300);

戻り値は bool で、キーが存在しなければ false です。作られていないものの期限は延ばせません。

大きな値を扱うときに効きます。従来は「読んで、そのまま書き戻す」必要があり、値の分だけ転送が発生していました。「まだ使われているから、もう少し置いておく」という操作が安く書けます。

テスト

第10章と第13章で予告したとおり、キャッシュはテスト間で残りますRefreshDatabase が戻すのはデータベースだけです。

tests/Feature/ProductCacheTest.php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;

pest()->use(RefreshDatabase::class);

beforeEach(function () {
Cache::clear();
});

beforeEach で消します。 第13章のレート制限のテストで同じことをしました。あちらは「カウンタが残る」でしたが、原因は同じです。

テスト環境の既定は array です

phpunit.xmlCACHE_STORE=array を設定しています。メモリ上にしか置かれないので、テストを実行し終えれば消えます。

それでも Cache::clear() が要るのは、1 つのテストプロセスの中で複数のテストが順に動くためです。前のテストが入れた値は、次のテストからも見えます。**「単体で実行すると通るが、全体で実行すると落ちる」**の典型がこれで、第10章で予告したとおりです。

キャッシュが効いていることを確認する

tests/Feature/ProductCacheTest.php
test('2回目のリクエストはキャッシュから返る', function () {
Product::factory()->create(['name' => '最初の商品']);

$this->getJson('/api/v1/products')
->assertJsonCount(1, 'data');

// オブザーバを通らない経路で 1 件足す
Product::query()->insert([
'name' => 'あとから足した商品',
'price' => 500,
'created_at' => now(),
'updated_at' => now(),
]);

$this->getJson('/api/v1/products')
->assertJsonCount(1, 'data');
});

「増えた商品が見えないこと」でキャッシュを確認します。 クエリの本数を数える方法もありますが、database ドライバではキャッシュ自身がクエリを出すので、本数だけでは判定できません。.env の設定に依存しないテストにしています。

insert() を使っているのがポイントです。 第15章で扱ったとおり、一括挿入ではモデルイベントが発火しません。オブザーバによる失効を通らないので、キャッシュが効いているかどうかだけを見られます。 Cache::remember を外すと 2 件返って red になります。

失効が効いていることを確認する

キャッシュのバグで多いのは「消えないこと」です。

tests/Feature/ProductCacheTest.php
test('商品を更新すると一覧に反映される', function () {
$product = Product::factory()->create(['name' => '古い名前']);

$this->getJson('/api/v1/products')
->assertJsonPath('data.0.name', '古い名前');

$product->update(['name' => '新しい名前']);

$this->getJson('/api/v1/products')
->assertJsonPath('data.0.name', '新しい名前');
});

失効の処理を入れ忘れると red になります。 1 回目のレスポンスがキャッシュされ、更新後も古い名前が返るためです。

このテストは、キャッシュを入れる前に書いておく価値があります。入れる前は当然 green で、入れた瞬間に red になれば、失効の実装が必要だと分かります。

本番で効く注意点

キャッシュが落ちたときに何が起きるか

Redis が落ちると、Cache::remember は例外を投げます。キャッシュのつもりで入れたものが、可用性の依存先になります。

商品一覧が少し遅くなるだけのはずが、サイト全体が 500 を返すことがあります。落ちたときに「遅くてもよいから動く」ようにするか、「落ちたら落ちる」を受け入れるかは、事前に決めておく判断です。

第8章で preventLazyLoading を本番で無効にした判断と同じ形です。性能の問題を可用性の問題に格上げしないという考え方です。

config:cache や route:cache とは別物です

名前は似ていますが、この章のキャッシュとは無関係です

config:cacheroute:cache は、第2章と第4章で扱った起動を速くする仕組みで、デプロイ時に生成してファイルに置きます。Cache:: ファサードが扱うのはアプリケーションのデータです。

php artisan cache:clear は前者を消しません。逆も同じです。「キャッシュを消したのに直らない」ときは、どちらのキャッシュの話かを確かめてください。

消し忘れは静かに壊れます

キャッシュのバグは、エラーを出しません。古い値が返り続けるだけです。テストも、キャッシュを消してから実行していれば通ります。

「更新したのに反映されない」という問い合わせで初めて気づくことになります。失効のテストを書いておくのは、そのためです。

何もかもキャッシュしない

キャッシュを 1 つ入れるたびに、「いつ古くなるか」を考える対象が 1 つ増えます

速くなったぶん、正しさの検討が要ります。まずクエリとインデックスを見直してください。 そのうえで足りない場合に、対象を絞って入れます。

まとめ

  • キャッシュは古い値を返す可能性を買って速さを得る取引。何を諦めるかを先に決める
  • 注文処理で使う在庫数はキャッシュしない。第14章の防御を無効化する
  • Cache::remember はキーが無ければクロージャを実行して保存する
  • キーには結果を変える要素をすべて含める。利用者 ID を忘れると他人のデータを配る
  • 絞り込みや並び替えを受ける一覧は、そのままではキャッシュに向かない。キーが増えるだけで再利用されない
  • 効くのは偏りのある場所。全体に被せず、当たる 1 か所に絞る
  • そもそも利用者ごとのデータはキャッシュに向かない
  • databasefile ではタグが使えない。バージョンをキーに含めれば、まとめて無効化できる
  • バージョンの更新に increment も現在時刻も使わない。前者はキーが無いとき、後者は同じ秒に 2 回更新したときに変わらない
  • 一部だけをキャッシュするなら、並び順を両方の経路で揃える。ずれるとページ間で商品が重複・欠落する
  • 無効化はオブザーバで受ける。ただし一括更新では発火しない
  • キャッシュ切れの瞬間に殺到する (スタンピード)。Cache::flexibleCache::lock で守る
  • Cache::touch() は値を読み書きせず期限だけ延ばす。キーが無ければ false
  • テストは beforeEachCache::clear()失効のテストを必ず書く
  • キャッシュのバグはエラーを出さない。古い値が返り続けるだけ

次に読む

ここまでで、重い処理を後ろへ回し、繰り返しの処理を減らしました。次章 ファイルストレージ — 商品画像と領収書 PDF からは、これまで扱ってこなかった種類のデータを相手にします。商品画像や領収書 PDF のような、データベースに入れないファイルです。アップロードの受け取り方、保存場所の選び方、そして公開してよいファイルとそうでないファイルの区別を扱います。

練習問題

次のコードには、利用者から見て深刻な問題があります。何ですか
public function index(Request $request)
{
return Cache::remember('orders.list', 600, function () use ($request) {
return OrderResource::collection(
$request->user()->orders()->latest()->paginate(20)
);
});
}

解答例

他人の注文履歴が返ります。

キーが orders.list で固定されています。最初にアクセスした利用者の注文が保存され、次にアクセスした別の利用者に、その内容がそのまま返ります

10 分間、全員が同じ人の注文履歴を見ることになります。氏名・メールアドレス・購入内容が含まれるので、個人情報の漏洩です

第12章で認可を作り、一覧ではリレーション経由で自分の注文だけを引くようにしました。その制約はクロージャの中では正しく効いています。問題はクロージャが 1 回しか実行されないことです。 認可は「誰の注文を取るか」を決めますが、キャッシュのキーはそれを見ていません。

直すなら、まずキーに利用者 ID を含めます。ページ番号も必要です。

ただし、そのうえでキャッシュそのものを外すことを検討してください。注文履歴は本人しか見ないので、キャッシュしても再利用されません。利用者の数だけキーが増え、メモリを使うだけです。速度が問題なら、user_id のインデックスを確認するほうが先です。

「TTL を 10 秒に縮めたので、商品更新時の失効処理は要らない」という判断にどう応じますか

「10 秒間は古い値が返ってよい」と決めたのなら、判断として成立します。 失効処理を書かない代わりに、最大 10 秒の遅れを受け入れる取引です。設計として筋は通っています。

確認したいことが 3 つあります。

1 つ目は、10 秒が誰の要求から来た数字かです。「短くすれば安全だろう」で決めた数字なら、根拠がありません。管理画面で商品を直した人が、一覧を再読み込みして確認する場面を考えると、10 秒でも「反映されていない」と見えます。

2 つ目は、キャッシュの効果が残っているかです。TTL を縮めるほど、キャッシュが当たる割合は下がります。10 秒ごとに作り直すなら、そのクエリが 1 日に 8,640 回走ります。元のクエリが十分速いなら、キャッシュを外すほうが単純です。 速くもならず、古い値を返す可能性だけが残るのは、いちばん悪い状態です。

3 つ目は、スタンピードです。 TTL が短いほど、期限切れの瞬間が頻繁に訪れます。アクセスが多ければ、10 秒ごとに殺到が起きます。Cache::flexible で古い値を返しながら裏で作り直す形のほうが、この構成には合っています。

「失効処理を書かない」こと自体は責められません。 ただ、それが判断なのか、面倒を避けただけなのかは、上の 3 つに答えられるかで分かります。


Footnotes

  1. 出典: Cache(Laravel 公式ドキュメント 13.x)。次の 5 点について。Cache::remember がキーの不在時にクロージャを実行して結果を保存すること。Cache::flexible が「新鮮」と「陳腐」の 2 段階の期間を取り、陳腐な期間は古い値を返しながら裏で再計算すること。Cache::lock がアトミックロックを作り get() / release() で扱えること。Cache::forgetCache::incrementキャッシュタグが file / dynamodb / database / storage ドライバでは使えないこと (同ページ「Cache Tags」節の警告)。 2 3 4

  2. 出典: Release Notes(Laravel 公式ドキュメント 13.x)「Cache TTL Extension」節。Cache::touch(...) が Laravel 13 で追加され、値を取得して保存し直すことなく既存のキャッシュ項目の TTL を延ばせること。戻り値の意味は Cache ページに拠る。