サーバーレス環境でのスキーマキャッシュ戦略を示す図解。使い捨てのDBクライアントでWeakMapキャッシュが機能しない問題と、モジュールレベル変数を用いた効率的な単一イントロスペクションクエリによる解決策を対比しています。

はじめに

Cloudflare Workers のように、リクエストごとに実行環境(isolate)が使い回されつつも、DBクライアントのようなオブジェクトはリクエストのたびに新しく生成し直す構成があります。こうした環境でよくあるのが、「DBのスキーマ情報(カラムの有無など)を毎回問い合わせてしまい、1リクエストあたり複数本の introspection クエリが直列のRTTとして積み上がる」という問題です。

あるサーバーレス環境のプロジェクトでこの問題に向き合った際、原因は2種類ありました。1つはそもそもキャッシュ自体が実装されていなかったケース。もう1つは WeakMap でキャッシュしていたつもりが、実行環境の性質上まったく機能していなかったケースです。後者は気づきにくいバグパターンなので、この記事では特にそこを掘り下げます。

こんな人におすすめ

  • Cloudflare Workers や AWS Lambda など、リクエストごとにクライアントインスタンスを生成し直す環境で開発している方
  • DBスキーマや設定値のキャッシュを実装しているが、本当に効いているか自信がない方
  • WeakMap をキャッシュ用途で使っていて、ヒット率に違和感がある方
  • モジュールレベルの状態管理と「リクエスト状態のリーク」のトレードオフに悩んだことがある方

罠1: WeakMapキャッシュはインスタンスが使い捨てだと効かない

WeakMap はメモリリークを避けつつオブジェクトをキーにできるため、「接続インスタンスごとにキャッシュしたい」場面で選ばれがちです。

const schemaCache = new WeakMap()

export async function getTableSchema(db: DbClient): Promise {
  const cached = schemaCache.get(db)
  if (cached !== undefined) return cached

  const rows = await introspectColumns(db, 'PRAGMA table_info(target_table)')
  const schema = buildSchemaFromRows(rows)
  schemaCache.set(db, schema)
  return schema
}

一見正しく動きそうに見えますが、WeakMap のキーはオブジェクト参照そのものです。DBクライアントがリクエストのたびに new される設計だと、キーが毎回変わるためキャッシュはヒットしません。実質的には「同一リクエスト内での重複排除」にしかなっておらず、リクエストをまたいだキャッシュとしては機能していなかった、というのがこのケースの正体でした。

切り分けの軸: リクエスト状態 vs デプロイスコープの環境情報

このバグを直す前に整理しておきたいのが、「その値はリクエストごとに変わってよいものか、デプロイが変わるまで不変なものか」という切り分けです。

  • ユーザー入力やセッション状態のような リクエスト状態 は、リクエスト間で共有してはいけません
  • DBスキーマやフィーチャーフラグの静的定義のような デプロイスコープの環境情報 は、デプロイが変わるまで基本的に不変です

この軸で見ると、DBスキーマは後者に属します。デプロイのたびに実行環境(isolate やプロセス)が入れ替わるなら、「デプロイ後にスキーマが変わって古いキャッシュが残り続ける」という staleness のリスクは、実行環境のライフサイクルと紐づけて考えると実質ゼロに近いと判断できます。この整理があると、モジュールレベルの mutable state を使ってよい範囲が明確になります。

解決策: モジュールレベル変数 + テスト用リセットフックのセット

切り分けができたので、WeakMap をやめてモジュールレベルの単一キャッシュ変数に統一しました。

let cachedSchema: TableSchema | undefined

export function resetCachedTableSchema() {
  cachedSchema = undefined
}

export async function getTableSchema(db: DbClient): Promise {
  if (cachedSchema !== undefined) return cachedSchema

  try {
    const rows = await introspectColumns(db, 'PRAGMA table_info(target_table)')
    cachedSchema = buildSchemaFromRows(rows)
    return cachedSchema
  } catch {
    return buildFallbackSchema() // 未 migration 環境向けのフォールバック
  }
}

ポイントは、キャッシュ変数を用意すると同時に resetCachedXxx() のようなリセット用フックを必ずセットで用意することです。これがないと、テストケース間でグローバル変数が汚染され、実行順序によって green/red が入れ替わるような不安定なテストになりがちです。

describe('getTableSchema', () => {
  beforeEach(() => {
    resetCachedTableSchema()
    mockIntrospect.mockReset()
  })

  it('does not re-probe introspection across different db instances in the same isolate', async () => {
    const db1 = {} as DbClient
    const db2 = {} as DbClient
    mockIntrospect.mockResolvedValueOnce([{ name: 'some_column' }])

    await getTableSchema(db1)
    await getTableSchema(db2)

    expect(mockIntrospect).toHaveBeenCalledTimes(1)
  })
})

ローカルの疑似環境での計測では、対象モジュール合計でキャッシュ未ヒット時に5本発行されていた introspection クエリが、2リクエスト目以降は同一実行環境内で0本になったという結果が観察されました。あくまで目安としての数値ですが、直列RTTが積み上がるタイプの問題では、こうした「2回目以降ゼロ」の効果が体感しやすい傾向にあります。

フォールバック分岐は消さないでおく理由

新スキーマと旧スキーマの両方に対応するフォールバック分岐は、キャッシュ実装をきれいにする過程で消したくなりますが、全環境でmigrationの適用が保証されるまでは残しておくのが安全です。テスト環境(インメモリDBなど)では新スキーマのみが通っていても、本番では migration 未適用のインスタンスが存在しうるためです。分岐の削除は、キャッシュ修正とは別タスクとして切り出しておくと判断がぶれません。

つまづきやすいポイント

  • WeakMap を使っているからといって「キャッシュが効いている」とは限らない。キーとなるインスタンスの生存期間を必ず確認する
  • 過去に WeakMap 化した経緯がある場合、その決定を覆すには根拠が要る。「リクエスト状態のリークを警戒する」という元の意図自体は正しいことが多いため、対象が本当にリクエスト状態なのか、それともデプロイスコープの環境情報なのかを再評価し、PRの説明などに残しておくと後から議論が蒸し返されにくい
  • モジュールレベルキャッシュを入れるなら、リセットフックとテストの beforeEach はセットで用意する
  • フォールバック分岐の削除は、キャッシュ修正のスコープに含めず別タスクにする

まとめ

サーバーレス環境でのキャッシュ実装は、「オブジェクトの生存期間」と「値のスコープ(リクエストかデプロイか)」を切り分けて考えると設計判断がぶれにくくなります。WeakMap は便利な道具ですが、キーとなるインスタンスが使い捨てになる環境では狙った効果を発揮しません。デプロイが変わるまで不変な値であれば、モジュールレベルの単一変数とテスト用リセットフックの組み合わせがシンプルで扱いやすい選択肢になります。