要点: 公開データは利用者の近くへ置けますが、非公開データは必ずテナント単位で分離し、更新ルールを明示する必要があります。
この記事の用語: テナント:同じ基盤を利用する一つの顧客。Invalidation:古いキャッシュを無効にする処理。
現場の課題
Redisを追加するだけでは安全なキャッシュにはなりません。キーの失効時に同じクエリがDBへ集中し、設定を一つ誤れば別テナントのデータを返す危険もあります。
単純な対策だけでは不十分な理由
キャッシュは性能改善であると同時に、別の場所へ置くデータの複製です。誰のデータか、どこまで古くてよいか、何を契機に更新するかを先に決めなければなりません。
処理フロー
端末側キャッシュ→CDN
公開レスポンスのみ→Redis
アプリ共有キャッシュ→データベース
正のデータ
アーキテクチャ上の判断
公開経路と非公開経路を分ける
共有CDNへ保存してよいのは、匿名で全利用者に同一となるレスポンスだけです。認証済みレスポンスは、関係するすべての識別条件をキャッシュキーに含めない限り、privateまたはno-storeにします。
キャッシュ Stampedeを防ぐ
高頻度なキーが期限切れになったら、再生成を短い分散ロックまたはSingle-flightで保護します。他のリクエストは制限内のStale値を返すか、短時間だけ待機します。避けるべきは、全リクエストが同時にDBを再構築してしまうことです。
バージョン方式の無効化を優先する
関連キーをすべて削除する方法は漏れやすくなります。代わりに、テナントや集約単位のデータバージョンをキーに含めます。Commit後にバージョンを進めれば、Redisを走査せずに旧キーを到達不能にできます。
さらに深く考える
整合性はProduct上の判断
TTLは単なるインフラstructure Parameterではありません。どのFieldをどれだけの時間まで古い状態で許容するかは、Product 担当者が決めることです。公開Catalogは数分許容できても、権限、課金状態、Feature 利用権限は即時Invalidation、あるいはキャッシュ不可が一般的です。キャッシュ対象のクエリごとにConsistency 種別を書き添えます。更新されうる業務上の集約では、業務変更とInvalidation イベントを同じDB トランザクション内で、Outbox経由でCommitします。するとRelayが、Redis更新とEdge Purgeの両方が成功するまで再試行できます。これにより、データだけ変わってキャッシュへ通知されないという危険な空白を防げます。
Hot キーとCardinalityを制御する
Filter、Pagination、Localeを無制限に含めると、テナント Scopeだけでも数百万キーに膨れ上がります。クエリ Parameterを正規化し、許可したDimensionだけをHashし、キャッシュ可能なパターン数に上限を設けます。大規模テナントは、ヒット率が良好でも一つのRedis ShardをHot キーで飽和させることがあります。計測結果が正当化するなら、不変値の複製、業務上の集約の分割、短いプロセス-local キャッシュの追加を検討します。あわせてEncoded Value SizeとEviction ポリシーも監視します。Memory不足のキャッシュは有用なキーを次々に追い出し、キャッシュなしより逆にDB負荷を増やすことがあるためです。
実装手順
- 保存先を決める前に、レスポンスを公開情報と非公開情報へ分類します。
- Single-flightを使い、キー失効時にキャッシュを再構築するリクエストを一つだけに制限します。
- キャッシュキーへテナントIDとデータバージョンを含め、関連データの変更時にバージョンを進めます。
コード例: テナント単位のstale-while-revalidate
key = `catalog:${tenantId}:v${catalogVersion}:${queryHash}`
cached = await redis.get(key)
if (cached && cached.age < freshFor) return cached.value
lock = await redis.set(`${key}:lock`, requestId, { NX: true, PX: 3000 })
if (!lock && cached && cached.age < staleFor) return cached.value
value = await db.catalog.findMany({ where: { tenantId } })
await redis.set(key, encode(value), { EX: staleFor })
return value本番実装では、Lockに担当者 トークンを持たせ、安全に解放します。あわせてTTLにJitterを加え、多数のキーが同時刻に失効しないようにします。
想定しておく障害
- テナントIDが欠落すると、全体で共有されるキーが生成されます。空値や既定値 テナントに置き換えず、リクエストを拒否します。
- DB トランザクションはCommitしたのにInvalidationが失敗する場合があります。イベントをOutbox経由で記録し、確実に再試行できるようにします。
- Redisが停止する場合もあります。ルートごとに、DBの同時実行数を制限してBypassするか、制御された縮退レスポンスを返すかを事前に決めておきます。
監視すべきこと
| 指標 | 何が分かるか |
|---|---|
| Route・テナント別のヒット率 | 全体平均だけでは、高負荷なテナントや、そもそもキャッシュに向かないRouteを見落とします。 |
| 再構築回数とLock待機時間 | 急増はStampede、TTL不足、キャッシュ生成クエリの遅延を示します。 |
| リクエスト当たりのDBクエリ数 | ヒットが下流処理を本当に減らしているか、それとも移しているだけかを確認します。 |
| Stale応答数とInvalidation遅延 | 可用性と読み取り速度のために支払っている整合性コストを示します。 |
設計の検証方法
- 2つのテナントへ同一リクエストを同時送信し、Body、ETag、キャッシュキーが越境しないことを確認します。
- 負荷中にHot キーを失効させ、DBの再構築が1回、または明示した上限内に収まることを確認します。
- Invalidation Relayを停止したまま書き込みをCommitし、再起動後に新バージョンが最終的に反映されることを検証します。
- Redisを停止し、DBの同時実行上限がFallback経路を遅延目標内に保てるかを計測します。
- Vary、Cookie、認可の挙動を含む、実際のCDN設定で公開レスポンスを試験します。
本番導入の進め方
まず読み取りが多くリスクの低いエンドポイントを一つ選び、比較だけを行う比較モードでヒット率、データの古さ、Redis遅延、DBクエリ数を測ります。結果が安定してから少数テナントへ有効化します。
本番前チェックリスト
- 高速化には、古いデータを許容する時間を明示することが必ず伴います。
- 正常系1件だけでなく、少なくとも2テナントでヘッダーとキーを検証します。
- ヒット率、再生成回数、DBクエリ数をまとめて監視します。
まとめ
良いキャッシュ設計は速いだけでなく、データの所有者、有効期間、更新条件を誰でも説明できます。
