障害対応 ·

Redis・Valkeyのキャッシュが落ちたとき — DBに倒すか、機能を落とすか

  • 障害対応
  • キャッシュ
  • Laravel

この記事で扱う問題

キャッシュは「なくても動くが、あると速い」ものとして入れるはずです。ところが、キャッシュの Redis(または Valkey)が止まると、例えば次のことが起こり得ます。

  • Cache::remember() が接続の例外を投げ、キャッシュを読むだけの画面がすべて 500 になる。DB は元気なのに、サービスが止まる
  • Redis が落ちたのではなく応答しなくなっただけのときは、各リクエストが接続や応答を長く待つ。PHP-FPM のプロセスが待ちで埋まり、キャッシュを使わない画面まで応答しなくなる
  • 復旧して Redis が空の状態で戻ると、すべてのリクエストが同時にキャッシュを外し、重い集計のクエリが一斉に DB に流れる。今度は DB が詰まる

3つ目は、キャッシュが落ちていなくても起きます。よく読まれるキーの期限が切れた瞬間に、同じ値を作るクエリが同時に何本も走る、いわゆるキャッシュスタンピードです。

さらに、Redis に置いているのはキャッシュだけとは限りません。セッション、ジョブのロック、レート制限、キューも同じ Redis に置いていると、「キャッシュの障害」が、ログインできない・ジョブが積めない障害として現れます。

判断

私は、次の順で決めます。

  1. Redis に置いているものを用途ごとに分け、キャッシュと、それ以外を分ける
  2. キャッシュへの接続と応答待ちに、短いタイムアウトを指定する
  3. キャッシュの値ごとに、使えないときに「DB に倒す」か「機能を落とす」かを決める
  4. 重い値はリクエストの中で作らず、作り直しを1か所に寄せる

Redis に置いているものを、用途ごとに分ける

置いているもの 落ちたときに起きること 判断
キャッシュ(DB から作り直せる値) 読み書きが例外になる DB に倒すか、機能を落とす(この記事の中心)
セッション セッションを読めず、web のルートが 500 になる 倒し先はない。キャッシュとは別のインスタンスに置き、キャッシュの障害に巻き込まない
ロック(ShouldBeUnique・WithoutOverlapping・Cache::lock()) ロックを取れずに例外になる。消えれば重複を通す 正しさは DB の一意制約で守り、ロックは無駄な実行を減らす最適化として扱う
レート制限 判定が例外になり、リクエストが 500 になる 判定できないときに通すか拒否するかを、制限の目的で決める
キュー ディスパッチが例外になる 消えると困る依頼は、同じトランザクションで DB に記録する

キャッシュ以外は、「落ちたら別の手段で代わりをする」ことがほとんどできません。セッションやキューは、落ちれば止まるものとして、止まっても正しさが崩れない作りにしておきます。ロックについてはキュージョブを冪等にする記事、キューについてはOutbox の記事で書いたとおりです。

分けておきたい理由はもう1つあります。メモリが上限に達したときに何を消すか(maxmemory-policy)は、インスタンス全体で1つの設定です。キャッシュは古いものから消えてよい(allkeys-lru など)一方、キューやセッションは黙って消えると困るので、消さない設定(noeviction)が合います。Laravel の既定の設定ファイルで Redis を使うと、キャッシュとキューは同じホストの別の DB 番号を使いますが、DB 番号を分けても、この設定も障害も分かれません。どうしてもインスタンスを分けられないなら、キャッシュのキーにだけ期限を付け、期限のあるキーだけを消す volatile-lru にする、という妥協もできます。

タイムアウトを短く指定する

Redis への接続(phpredis の場合)は、config/database.php の接続ごとにタイムアウトを指定できます。キャッシュ用の接続には、明示的に短い値を入れます。

'cache' => [
    'url' => env('REDIS_CACHE_URL'),   // url は host より優先される。default 接続と同じ REDIS_URL を使わない
    'host' => env('REDIS_CACHE_HOST', '127.0.0.1'),   // キャッシュ専用のインスタンスを指す
    'password' => env('REDIS_PASSWORD'),
    'port' => env('REDIS_PORT', '6379'),
    'database' => env('REDIS_CACHE_DB', '1'),
    'timeout' => 0.2,        // 接続までの上限(秒)
    'read_timeout' => 0.2,   // コマンドの応答を待つ上限(秒)
    'max_retries' => 1,      // 雛形の既定(3回、間に最大1秒のバックオフ)から減らす
],

指定しないと、phpredis や PHP の既定(default_socket_timeout など)に任されます。既定の設定によっては、数十秒待つことがあります。値の決め方は、外部 API のタイムアウトと同じで、画面の応答の予算と、普段の応答時間から決めます。同じネットワークの中の Redis なら、普段はミリ秒単位で返るので、100〜200 ミリ秒でも正常な応答を落とすことはまずありません。Laravel 12 の雛形の設定ファイルには、接続の再試行(max_retries と、試行の間のバックオフ)も入っています。1回のキャッシュ操作で待つ最長の時間は、おおよそ「タイムアウト × 試行の回数 + バックオフ」になり、接続が切れたときは例外を投げる前に接続し直す分も待ちます。キャッシュでは待つより早くあきらめたいので、上の例のように再試行を減らします。上の例は雛形の cache 接続を置き換えるものなので、残したい設定があれば消さないように気をつけます。

使えないときに、DB に倒すか、機能を落とすか

キャッシュへの読み書きは、アプリの中で1つのクラスにまとめ、使えないときの振る舞いをそこで決めます。Laravel の Cache を UseCase やクエリクラスから直接呼ばないのは、例外の扱いを呼び出し側ごとに書かせないためです。

use Illuminate\Contracts\Cache\Repository as Cache;
use Psr\Log\LoggerInterface;
use RedisClusterException;
use RedisException;

final class FailSafeCache
{
    public function __construct(
        private Cache $cache,   // config/cache.php の redis の store(cache 接続を使う)を束縛する(省略)
        private LoggerInterface $logger,
    ) {
    }

    /**
     * キャッシュにあればそれを返し、なければ $load の結果を保存して返す。
     * キャッシュを使えないときは、保存せずに $load の結果を返す(DB に倒す)。
     * null はキャッシュできない(null は「ない」と区別できないため、毎回 $load が呼ばれる)。
     *
     * @template T
     * @param callable(): T $load
     * @return T
     */
    public function remember(string $key, int $ttlSeconds, callable $load): mixed
    {
        try {
            $cached = $this->cache->get($key);
        } catch (RedisException | RedisClusterException $e) {   // 2つは別のクラス。predis なら Predis\Connection\ConnectionException
            $this->logger->warning('cache unavailable', ['key' => $key, 'exception' => $e]);

            return $load();
        }

        if ($cached !== null) {
            return $cached;
        }

        $value = $load();

        try {
            // 同時に作られたキーが同時に切れないよう、期限を少しずらす
            $this->cache->put($key, $value, $ttlSeconds + random_int(0, intdiv($ttlSeconds, 10)));
        } catch (RedisException | RedisClusterException $e) {
            $this->logger->warning('cache unavailable', ['key' => $key, 'exception' => $e]);
        }

        return $value;
    }

    /**
     * キャッシュにあればそれを返す。なければ、またはキャッシュを使えなければ $whenMissing を返す(機能を落とす)。
     * 値を作るのは、このクラスを使う側ではなく、作り直しのコマンドの役割。
     */
    public function getOr(string $key, mixed $whenMissing): mixed
    {
        try {
            return $this->cache->get($key) ?? $whenMissing;
        } catch (RedisException | RedisClusterException $e) {
            $this->logger->warning('cache unavailable', ['key' => $key, 'exception' => $e]);

            return $whenMissing;
        }
    }
}

どちらを使うかは、キャッシュがない状態で、その値を作るクエリを全リクエストが実行しても、DB が耐えられるかで決めます。

  • DB に倒す(remember()):主キーで1行読むだけの商品の詳細のように、元のクエリが軽い値。キャッシュがなくても遅くなるだけで、DB は耐えられる
  • 機能を落とす(getOr()):人気商品のランキングのように、元のクエリが集計で重い値。障害中に全リクエストが集計を実行すると、DB まで巻き込む。その間はランキングの欄を出さない
// 商品の詳細:キャッシュがなければ DB から読む
$product = $this->cache->remember(
    "product:{$sku}",
    600,
    fn (): array => $this->products->detailArray($sku),
);

// 人気商品のランキング:キャッシュがなければ欄を出さない。リクエストの中では集計しない
/** @var list<array{sku: string, quantity: int}> $ranking */
$ranking = $this->cache->getOr('popular-products:v1', []);

キャッシュに入れる値は、オブジェクトではなく配列にしています。オブジェクトをそのまま入れると、クラスの定義を変えたデプロイの直後に、古い形のオブジェクトを読み出すことがあるからです。形を変えるときは、キーの末尾の v1 を上げます。

重い値は、作り直しを1か所に寄せる

ランキングは、スケジューラから動くコマンドだけが作ります。

use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Factory as CacheFactory;
use Illuminate\Database\ConnectionInterface;

final class RebuildPopularProducts extends Command
{
    protected $signature = 'cache:rebuild-popular-products';

    public function handle(ConnectionInterface $db, CacheFactory $caches): int
    {
        // FailSafeCache と同じ store に書く。既定の store に書くと、読む場所とずれることがある
        $cache = $caches->store('redis');

        $ranking = $db->table('order_lines')
            ->where('created_at', '>=', now()->subDays(7))
            ->groupBy('sku')
            ->orderByDesc('total_quantity')
            ->limit(20)
            ->get(['sku', $db->raw('SUM(quantity) AS total_quantity')])
            ->map(fn (object $row): array => ['sku' => $row->sku, 'quantity' => (int) $row->total_quantity])
            ->all();

        // 5分ごとに作り直すが、期限は30分にする。何回か失敗しても、古いランキングを出し続けられる
        $cache->put('popular-products:v1', $ranking, 1800);

        return self::SUCCESS;
    }
}

スケジューラでは everyFiveMinutes()->withoutOverlapping() で動かします。Redis が落ちている間は、withoutOverlapping() の排他のロック(既定のキャッシュに置かれます)を取る時点か put() の時点で例外になり、コマンドが失敗しますが、それで構いません。次の実行で作り直されます。

こうしておくと、スタンピードはそもそも起きません。期限が切れても、Redis が空で戻っても、集計を実行するのはコマンドの1本だけで、リクエストの側は「あれば出す」だけです。

なぜそう判断したか

全部を DB に倒さない理由

「キャッシュが落ちたら DB から読む」は、すべての値に当てはめたくなります。ただ、キャッシュを入れた理由の多くは、DB だけでは負荷に耐えられないからです。その値を障害中に全部 DB に流すと、キャッシュの障害が DB の障害に変わり、今度はキャッシュを使わない更新の処理まで止まります。障害の範囲を広げないことを優先して、重い値は「出さない」を選びます。

逆に、すべてを「出さない」にすると、キャッシュの障害で画面の大部分が欠けます。軽い値まで落とす理由はないので、値ごとに分けています。

リクエストの中でロックを取って作り直す方法を選ばなかった理由

スタンピードへの対策としてよく挙がるのは、キャッシュを外したリクエストが Cache::lock() でロックを取り、取れた1つだけが値を作り、ほかは少し待ってから読み直す方法です。重い値をリクエストの中で作るしかないなら、有効な方法です。

ただ、ロックは同じ Redis に置くことになり、Redis の障害のときには役に立ちません。待っている間、リクエストは PHP-FPM のプロセスを持ったままです。重い値は、そもそもリクエストの中で作らないほうが、待ちも負荷も読みやすくなります。

期限の切れたキーへの同時アクセスだけが問題なら、Laravel 11 の途中で追加された Cache::flexible() も選べます。新しさの期限を過ぎてから一定の間は古い値を返しつつ、レスポンスを返した後に1つのリクエストだけが作り直します。ただし、キーが存在しない状態(初回や、Redis が空で戻ったとき)には効かず、全リクエストがその場で値を作ります。

Laravel の failover キャッシュを使わない理由

Laravel 12 には、あるストアが使えないときに別のストアへ切り替える failover キャッシュがあります。「DB に倒す」をフレームワークの設定で実現できるように見えますが、倒し先は別のキャッシュのストア(例えば database キャッシュ)で、元のクエリではありません。切り替わった直後は倒し先にも値がないので、重い値は各リクエストがその場で作り、スタンピードの問題は残ります。軽い値と重い値で振る舞いを変えたいので、私はアプリの側で決めています。

例外を握りつぶすのではなく、決めた振る舞いに変える理由

catch してログを出して続ける書き方は、レビューでは疑ってかかる書き方です。ここで許しているのは、キャッシュが「DB から作り直せる値の写し」だからです。キャッシュを使えないことで失われるのは速さだけで、正しさは失われません。セッションやロック、キューで同じことをすると、正しさが崩れます。そのため、この扱いは FailSafeCache を通る値に限り、ほかの用途には広げません。

障害中は、リクエストのたびに警告が出ます。ログの量が問題になるなら、件数をメトリクスとして数え、ログは間引きます。

どこから設計過剰になるか

  • キャッシュがなくても DB が耐える規模:キャッシュが少しの高速化のためだけにあるなら、タイムアウトを短くして、すべて remember() で DB に倒せば足ります。ランキングのような作り直しのコマンドは要りません
  • すべての Cache:: の呼び出しを置き換える:まずは、ログインの前後や商品の一覧のように、障害中も開いておきたい画面の経路から始めます。管理画面の集計などは、キャッシュの障害中に使えなくても困らないことが多いです
  • 障害中も毎回 Redis に問い合わせることが気になる場合:1つの画面で N 個のキーを読むと、障害中は N 回タイムアウトまで待ちます。まずは、FailSafeCache をリクエストごとに作られる形(scoped)で束縛し、「このリクエストで一度失敗したら、以降は Redis に問い合わせない」というフラグを持たせれば、待ちは1回分で済みます。リクエストをまたいで、失敗が続いたら一定時間 Redis に問い合わせない(サーキットブレーカー)、という仕組みもあります。ただ、PHP-FPM ではリクエストをまたいで状態を持てないので、「いま落ちている」という状態を APCu などに置く必要があります。私自身まだ深めている途中の領域で、短いタイムアウトとリクエストの中のフラグで待ちの上限が決まっていれば、まずはそれで足りると考えています
  • キャッシュを多段にする:サーバーごとのメモリ(APCu)と Redis の2段にすると、Redis の障害には強くなりますが、値を変えたときに古い値が消えるまでの時間がサーバーごとにばらつきます。値の鮮度に要件がある場合は、入れる前にその許容範囲を決めます

まとめ

  • Redis に置いているものを、キャッシュとそれ以外(セッション・ロック・レート制限・キュー)に分け、できればインスタンスも分ける
  • キャッシュの接続には短いタイムアウトを指定し、使えないときは、元のクエリが軽い値は DB に倒し、重い値は機能を落とす
  • 重い値はリクエストの中で作らず、スケジューラのコマンドで作り直す。スタンピードも、Redis が空で戻ったときの集中も起きなくなる

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。