Z-SOFT
アーキテクチャ · パフォーマンス

multi-tenant SaaS の三層キャッシュ:CDN、Redis、バージョンによる invalidation

Redis を追加すれば、キャッシュの課題が片付くわけではありません。この記事では、Z-EDU がキャッシュを三層に分けた方法を紹介します。エッジの Cache-Control、single-flight を備えた Redis、そして version による invalidation です。受験者の答案があやうく漏洩データになりかけた、キャッシュ設定のインシデントも振り返ります。

Z-SOFT 開発チーム 10分で読めます

Z-EDU は、教育機関向けに私たちが構築した LMS です。バックエンドは REST /api/v1 に NestJS 11、 PostgreSQL 上の Prisma、Redis、web と admin に Next.js 16 を使い、すべてを pnpm + Turborepo の monorepo に収め、 GitLab CI から Portainer Swarm へデプロイしています。トラフィックはかなり偏っています。大半は読み取りで、 教室紹介ページ、コース一覧、公開テストなどです。書き込みは少ないものの、答案や採点のように絶対に正しくなければ ならないものです。

最初に思いつくことは分かります。Redis を追加し、重いクエリをいくつか包み、それでキャッシュの課題は片付いた と考える。私たちもそう思っていました。三つの重要な教訓を得るまでは。そのうち一つは、あやうくセキュリティ インシデントになるところでした。この記事は、実際に動いている三層のキャッシュと、その層と層の間に潜むリスクの記録です。

1. なぜ Redis を足すだけでは足りないのか

Redis は高速です。しかし Redis のキャッシュに hit した request の経路をよく見てください。それでも CDN、 reverse proxy を通り、Node のプロセスに入り、middleware を走り、JWT を parse し、tenant のコンテキストを組み立て、 権限の guard を通り、そこでようやく controller に到達して Redis を読み、JSON を serialize して返します。 PostgreSQL の時間は節約できますが、Node 内の request ライフサイクルのコスト、つまり event loop、socket、 middleware、API プロセスのリソースは、ほぼそのまま払っています。

ある教室が募集キャンペーンを行い、数千人が一分間に紹介ページを開いたとき、最初に飽和するのは必ずしも PostgreSQL ではありません。API worker のほうがボトルネックになることもあります。request がアプリケーションの ライフサイクルを最後まで通るのであれば、Redis は database から API へ圧力を移しただけです。本当に負荷を下げたい なら、request が API に触れる前に止める必要があります。それが Cache-Control の仕事です。

最良のキャッシュ層とは、request がそもそもアプリケーションに到達しない層です。その内側のすべての層は、 すり抜けてきたトラフィックのための安全網にすぎません。

そこで私たちはキャッシュを外側から内側へと積み上げました。各層にはそれぞれの役割と、それぞれのトレードオフがあります。

何を止められるか払う代償
1. CDN/Traefik での Cache-Control request ライフサイクル全体:ネットワーク、Node、guard、DB 削除のタイミングを制御できない。プライベートなデータを誤って配信するリスク
2. Redis(CacheService.wrap DB クエリ。同じキーの重複した miss を一つにまとめる Node の request ライフサイクル一回分は依然かかる。インフラの依存が一つ増える
3. version key(bump admin が内容を編集した後に残る古いデータ TTL が切れるまでゴミが Redis に残る。version の読み取りが一回増える

2. 第一層 — エッジの Cache-Control、そして語り継ぐべきインシデント

Z-EDU では、エッジの層を一つの decorator、@HttpCache で制御しています。これは metadata を 付けるだけで、interceptor がその metadata を読み、response に Cache-Control ヘッダーを 設定します。decorator が無ければキャッシュも無い。デフォルトは no-store です。

// http-cache.decorator.ts
export interface HttpCacheOptions {
  maxAge: number;              // 秒 — ブラウザ向け
  sMaxAge?: number;            // 秒 — 共有 CDN/proxy 向け
  staleWhileRevalidate?: number;
  visibility?: 'public' | 'private';
  vary?: string[];
}

export const HTTP_CACHE = 'http_cache';
export const HttpCache = (opts: HttpCacheOptions) => SetMetadata(HTTP_CACHE, opts);

// 使い方:本当に公開で、かつ利用者によって変わらない route だけに opt-in する
@Public()
@HttpCache({ maxAge: 60, sMaxAge: 300, staleWhileRevalidate: 600, visibility: 'public', vary: ['Accept-Encoding'] })
@Get('landing/:tenantSlug')
getLanding(@Param('tenantSlug') slug: string) {
  return this.landingService.get(slug);
}

最初の実装はこうではありませんでした。もっと「賢く」しようとしたのです。グローバルな interceptor が @Public() の付いた route を見つけると、自動的に Cache-Control: public を付ける。 理屈はもっともらしく聞こえました。@Public() はログイン不要という意味だ。ログインが要らないなら 誰が見ても同じはずだ。ならば共有キャッシュに載せてよい、と。

この理屈は、一点で決定的に間違っています。Z-EDU には GET /exams/public/attempts/:id という route があります。受験者はアカウントを作らずに公開テストを受け、その受験に対する id を受け取り、 同じ id で自分の答案を読み返します。この route は JWT を要求しないので @Public() が 付いています。しかし response は、特定の一人のデータです。

グローバルな interceptor は @Public() を見て、response に Cache-Control: public, s-maxage=300 を付けました。共有 proxy が一人の受験者の答案を保持します。 同じ URL にアクセスした次の人が、その中身を受け取り得ます。これはもはやキャッシュによる表示の不具合ではなく、 データの漏洩です。

@Public() が答えるのは「呼び出すのに token が要るか?」という問いです。「この response は 誰にとっても同じか?」という問いには答えていません。二つは別の問いであり、共有キャッシュに載せてよいかを 決めるのは後者だけです。

この教訓は repo のルールになりました。エッジのキャッシュは route ごとに opt-in すること。 他の decorator から推論してはならない。route ごとに @HttpCache を一行足すコストは、 プライベートなデータを誤って配信するリスクよりはるかに安い。少しの重複を受け入れて、システムを デフォルトで安全にします。

public、private、そして Vary の役割

混同されやすい三つの概念を、はっきり区別する必要があります。Cache-Control: public は、CDN、 企業の proxy、ISP の proxy といった共有キャッシュがコピーを保存し、他人に配り直すことを許します。 private はユーザー自身のブラウザのキャッシュにだけ保存を許し、CDN は無視しなければなりません。 no-store はどこにも保存しないという意味です。attempts/:id の route では、 正しい選択は no-store です。これは一人だけのプライベートなデータだからです。

Vary は二つ目のピースです。response がどのヘッダーに依存するかをキャッシュに伝えます。 response が Accept-Language によって変わるのに Vary: Accept-Language を忘れると、 ベトナム語のユーザーが、前の訪問者の英語ページを受け取ることがあります。multi-tenant(複数の企業が一つの システムを共有し、データは完全に分離される)SaaS では、リスクはさらに大きくなります。tenant をヘッダーで 判別しているのにキャッシュがそのヘッダーで Vary していなければ、CDN が教室 A の内容を教室 B に 配信し得ます。より安全なのは、tenant をパスかホスト名に置くことです。そこは CDN が確実にキャッシュキーに 含める部分だからです。

3. 第二層 — Redis と cache stampede を防ぐ single-flight

すべての route をエッジでキャッシュできるわけではありません。受講者の進捗サマリーは個人データであり、 guard を通す必要があり、CDN に触らせてはいけません。それでいて重いクエリで、絶えず呼ばれます。 そこが第二層の領域です。薄い CacheService を挟んだ Redis です。

CacheService の核心は getset ではありません。重要なのは wrap、具体的には single-flight の能力です。ホットなキャッシュキー、たとえば大きな教室の コース一覧を思い浮かべてください。組み立て直すのに 0.5 秒ほどかかります。ピーク時に TTL がちょうど切れると、 数百の同時 request が cache miss を見ます。まとめる仕組みが無ければ、その数百すべてが重いクエリを DB に投げます。 PostgreSQL は負荷の塊を受け、connection pool が枯渇し、latency が上がり、やがて他のホットなキーも次々と 期限切れになっていきます。これが cache stampede、あるいは thundering herd です。

対処法は、同じキーの miss を一つにまとめることです。最初の request だけが DB に降り、残りの request は その promise そのものを待ちます。

@Injectable()
export class CacheService {
  // 「飛行中」のロードのマップ — プロセスのメモリ上に存在する
  private readonly inflight = new Map<string, Promise<unknown>>();

  async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T> {
    const cached = await this.redis.get(key).catch(() => null); // Redis のエラー -> miss とみなす
    if (cached) return JSON.parse(cached) as T;

    // 別の request がこのキーをロード中 -> それに相乗りし、DB には降りない
    const flying = this.inflight.get(key);
    if (flying) return flying as Promise<T>;

    const task = loader()
      .then(async (value) => {
        // set は best-effort:Redis が落ちても response を壊してはならない
        await this.redis
          .set(key, JSON.stringify(value), 'EX', ttlSec)
          .catch(() => undefined);
        return value;
      })
      .finally(() => this.inflight.delete(key));

    this.inflight.set(key, task);
    return task;
  }
}

限界ははっきり述べておきます。inflight はプロセスのメモリ上の Map なので、まとめられるのは 同一の API instance 内の cache miss だけです。replica が四つ動いていれば、キーが期限切れになったとき、 最悪でも一つではなく四つのクエリが DB に降ります。しかし四は四百とはまるで違います。私たちにとってその削減で 十分であり、代わりにシステムは単純になります。分散ロックは不要、deadlock のおそれも無く、ロックを持ったまま プロセスが死んで他のプロセスが永遠に待つ、という事態も起きません。Redis の distributed lock ならクエリを きっかり一つにまとめられますが、その代償として運用すべき複雑さの層が一つ増えます。私たちは単純な方を選び、 数字が要求したときにだけ引き上げます。

4. 第三層 — version による invalidation、そして KEYS/SCAN が罠である理由

課題はこうです。admin があるコースの説明を編集する。その教室の公開コンテンツに関わるキャッシュキーは 数十個ある。一覧、landing ページ、コース詳細、テスト一覧。それらをどうやってすべて消すのか。

多くの人が最初に思いつく答えは、パターンでの走査です。KEYS zedu:pub:* して削除する。やめてください。 KEYS は Redis のシングルスレッド上で走る O(N) のコマンドです。keyspace 全体を走査し、その間 Redis は 他のコマンドをほとんど処理できません。数百万のキーがあれば、admin がコース説明を一つ編集しただけで、他のコマンドが timeout し得ます。SCAN は複数回に分割するぶんましですが、それでも keyspace を走査し、多くの round-trip を消費し、明確な完了時点を与えてくれません。SCAN でキャッシュを消すことは、本来は即時の 操作を、信頼しにくいバックグラウンドのタスクに変えてしまいます。

私たちのやり方は、古いキーを直接消さないことです。代わりに、キャッシュキー自体に version 番号を埋め込みます。 ある「領域」全体を invalidate したくなったら、その番号を上げるだけです。古いキーはすべて即座に、 誰も問い合わせないキーになり、TTL が切れれば自然に消えます。

@Injectable()
export class CacheVersionService {
  // プロセス内の micro-cache:すべての request で Redis に 1 round-trip するのを避ける
  private readonly local = new Map<string, { v: number; exp: number }>();
  private static readonly MICRO_TTL_MS = 2000; // <= 2s

  async get(scope: string): Promise<number> {
    const now = Date.now();
    const hit = this.local.get(scope);
    if (hit && hit.exp > now) return hit.v;

    // Redis が落ちた -> v = 0。キャッシュは動き続け、bump を受け取れないだけ。
    const raw = await this.redis.get(`ver:${scope}`).catch(() => null);
    const v = Number(raw ?? 0) || 0;
    this.local.set(scope, { v, exp: now + CacheVersionService.MICRO_TTL_MS });
    return v;
  }

  async bump(scope: string): Promise<void> {
    await this.redis.incr(`ver:${scope}`).catch(() => undefined);
    this.local.delete(scope); // この instance は即座に見る。他の instance は <= 2s 後
  }
}

// キャッシュキーは領域 + tenant ごとの version を持つ
async function publicCourseList(tenantId: string) {
  const v = await this.versions.get(`pub:${tenantId}`);
  return this.cache.wrap(
    `zedu:v${v}:pub:${tenantId}:courses`,
    300,
    () => this.prisma.course.findMany({ where: { tenantId, published: true } }),
  );
}

// admin が公開コンテンツを編集 -> その tenant の pub 領域全体がゴミになる
async function onPublicContentChanged(tenantId: string) {
  await this.versions.bump(`pub:${tenantId}`);
}

いくつか述べておくべき点があります。第一に、version は tenant ごと、たとえば pub:${tenantId} に 分かれているので、ある教室の編集が他の教室のキャッシュを冷やすことはありません。multi-tenant SaaS では、 invalidation の領域がシステム全体を抱え込んでいると、一つの顧客が大量に編集するだけで、他の顧客の性能まで 引きずり下ろし得ます。

第二に、bumpINCR 一回です。O(1) で、ブロックせず、keyspace がどれだけ 大きくても関係ありません。invalidation のコストは、消すべきキーの数に依存しなくなります。それこそが 私たちがこの方式を選んだ理由です。

第三に micro-cache です。すべての request が Redis に「今の version はいくつか?」と尋ねるなら、ホットパスに round-trip を一つ足したことになります。キャッシュが取り除くために存在する、まさにそのものです。そこで version は プロセスのメモリに最大 2 秒だけ保持します。トレードオフは、admin が保存を押した後、別の instance が最大 2 秒間は 古い版を配信し得ることです。LMS の公開コンテンツにとって、これは許容できる遅れであり、明確な約束を与えてくれます。 編集は 2 秒以内に反映される、と。

最後に、version 方式の本当のコストです。古いキーはすぐには消えず、TTL が切れるまで Redis に残ります。 少しのメモリと引き換えに、keyspace を走査せず Redis を詰まらせない O(1) の invalidation を手に入れます。 maxmemory-policyallkeys-lru にしておけば、そのゴミはメモリが足りなくなったときに 自動で追い出されもします。私たちにとって、これは妥当なトレードオフです。

5. Redis が落ちたとき:fail-open

上のコード全体で繰り返し現れる細部に注目してください。Redis の呼び出しには必ずエラー処理の分岐があります。 これは雑さではなく、アーキテクチャ上の決定です。

Z-EDU の Redis はデータの本体ではありません。真実の源は PostgreSQL です。Redis は最適化の層にすぎません。 ですから Redis に障害が起きたときの正しい振る舞いは、ユーザーに 500 を返すことではなく、fail-open、 つまりすべてを直接 DB に降ろすことです。システムは遅くなり DB の負担は増えますが、結果は正しいままです。 受講者は教材を見られますし、試験も提出できます。同じ原則を rate limit にも適用しています。IP ごとのカウンターは Redis 上にあり、Redis が落ちたら rate limit はすべての request を拒否するのではなく fail-open します。

キャッシュは最適化であって、本当の source ではありません。キャッシュが死んだときにシステムが止まるなら、 あなたが持っているのはキャッシュではありません。バックアップの無いデータベースです。

この原則には、きわめて重要な前提条件があります。キャッシュが無くても DB が負荷に耐えられること。 fail-open が意味を持つのは、Redis が消えても PostgreSQL が、遅くなりつつも全トラフィックを受け止められる場合だけです。 DB がすでに過負荷だからキャッシュしているのなら、fail-open は Redis の障害を PostgreSQL の障害に変換するだけです。 テストは単純で、本番に強いられる前に自分で走らせるべきです。本番相当の負荷をかけた staging で Redis を落とし、 システムが立っていられるか見る。立っていられないなら、キャッシュは静かに必須のコンポーネントになっています。 その場合はまず DB を直すべきで、キャッシュを積み増すだけではいけません。

ここでも第一層が私たちを救ってくれます。Cache-Control が公開の読み取りトラフィックの大半を エッジへ押し出しているので、Redis も API も苦しんでいる最中でさえ、CDN はキャッシュ済みのコンテンツを 配信し続けます。三つの層が独立しているということは、独立して壊れるということです。すべてを一箇所に 押し込めず切り分ける、その深い理由がここにあります。

6. 得られたチェックリスト

結論

三つの層、三つの切り分けられた役割。Cache-Control は最大の読み取り負荷を引き受けますが、 一度送った response は取り戻せないため、最も慎重さを要する層でもあります。wrap を伴う Redis は PostgreSQL を守り、キャッシュが切れたときに多数の request が DB に一斉に降りるのを防ぎます。version key は keyspace を走査せずに、ほぼ即時の invalidation を可能にします。そのすべての根底にあるのは、たった一つの原則です。 PostgreSQL が真実の源であり、残りはシステムを速くするだけのものだ、ということです。

attempts/:id のインシデントは、私たちが新しいメンバーに今も語り継いでいる話です。キャッシュが 性能だけの問題ではないことを示しています。ヘッダーを一つ置き間違えれば、それはセキュリティの問題になります。 multi-tenant SaaS において、「速い」と「ある人のデータを別の人に配信する」の距離は、たった一語であることが あります。public です。

Z-SOFT はこのようなシステムを開発しています

本記事で紹介した技術は、すべて自社プロダクトの本番環境で実際に稼働しているものです。高負荷に耐え、セキュアで、 長期にわたって運用できるシステムをお考えでしたら、Z-SOFT の開発チームにぜひご相談ください。

関連記事