0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでSingleFlightを実装する:キャッシュミス集中時の重複ロードを防ぐ

0
Posted at

キャッシュミス集中時に複数リクエストを1つの実行へ集約する図

この記事は Qiita 向けに書き下ろした実装解説です。前提となる JavaScript の Map の仕様は MDN: Map を参照してください。

キャッシュの TTL が切れた直後に、同じキーへのリクエストが同時に来ることがあります。各リクエストが DB や外部 API を個別に叩くと、キャッシュがあるのに負荷の山を作ってしまいます。

この問題はシステム設計の面接でもよく問われます。単一プロセス内なら、キーごとに「実行中の Promise」を 1 個だけ保持する SingleFlight で解けます。

  • 同じキーの最初の呼び出しだけが実処理を開始します
  • 後続の呼び出しはその Promise を待ちます
  • 成功・失敗のどちらでも実行中一覧から必ず外します
  • 完了後の次の呼び出しは、新しい処理を開始できます

まず押さえる境界

SingleFlight はキャッシュそのものではありません。

仕組み 保持するもの 目的
キャッシュ 完了した値と TTL 同じ値を再利用する
SingleFlight 実行中の Promise 同時に重なった処理を 1 回にする

たとえば GET /profiles/42 では、先に通常のキャッシュを確認します。キャッシュミス時だけ SingleFlight を通し、その中で DB を読む構成にします。

実装

Map<string, Promise<V>> を使います。重要なのは、work が同期例外を投げても Promise の失敗として扱うことと、古い処理の完了が新しい処理の登録を消さないことです。

class SingleFlight<V> {
  private readonly flights = new Map<string, Promise<V>>();

  run(key: string, work: () => Promise<V>): Promise<V> {
    const existing = this.flights.get(key);
    if (existing) return existing;

    // 同期 throw も rejected Promise に正規化します。
    const created = Promise.resolve().then(work);
    this.flights.set(key, created);

    // finally() が返す Promise の未処理 rejection を作らないよう、
    // 成功・失敗の両方を then の第2引数で扱います。
    void created.then(
      () => this.forget(key, created),
      () => this.forget(key, created),
    );

    return created;
  }

  private forget(key: string, promise: Promise<V>) {
    // 将来の置き換えを誤って消さないための identity check です。
    if (this.flights.get(key) === promise) {
      this.flights.delete(key);
    }
  }
}

計算量は、Map の検索・登録・削除が平均 O(1) なので、run 自体も平均 O(1) です。共有されるのは処理結果ではなく Promise なので、メモリに残る期間は処理中だけです。

Bun で動作を検証する

以下は「5 件が同時に来ても loader は 1 回」「完了後は次の loader が動く」「失敗は保持されず再試行できる」を確認するテストです。

import assert from "node:assert/strict";

const delay = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

const singleFlight = new SingleFlight<string>();
let calls = 0;

const loadProfile = async () => {
  calls += 1;
  await delay(10);
  return `profile-v${calls}`;
};

const values = await Promise.all(
  Array.from({ length: 5 }, () =>
    singleFlight.run("profile:42", loadProfile),
  ),
);

assert.deepEqual(values, [
  "profile-v1",
  "profile-v1",
  "profile-v1",
  "profile-v1",
  "profile-v1",
]);
assert.equal(calls, 1);

assert.equal(
  await singleFlight.run("profile:42", loadProfile),
  "profile-v2",
);
assert.equal(calls, 2);

let failures = 0;
await assert.rejects(() =>
  singleFlight.run("broken", async () => {
    failures += 1;
    throw new Error("upstream unavailable");
  }),
);

assert.equal(
  await singleFlight.run("broken", async () => "recovered"),
  "recovered",
);
assert.equal(failures, 1);

実行結果は次のとおりです。

$ bun singleflight.test.ts
singleflight tests passed

失敗しやすい実装

失敗した Promise を残し続ける

失敗後に Map から消さないと、同じキーの全リクエストが過去の失敗を即座に再利用します。一時的な upstream 障害から回復できません。成功時だけでなく失敗時にも消す必要があります。

呼び出しごとに AbortSignal を共有する

最初の HTTP クライアントが切断したからといって、共有している loader を abort すると、待っている他のクライアントまで失敗します。リクエスト単位のタイムアウトは待機側に置き、共有 loader の期限は別に設計します。

分散環境でも Map だけで済ませる

この実装が防げるのは同一 Node.js プロセス内の重複だけです。複数 Pod / 複数 worker にまたがるキャッシュミス集中には、Redis などを使った分散ロック、lease、fencing token、またはキューイングを検討します。分散ロックは「取得できなければ待つ」だけでなく、所有者の停止・期限切れ・結果の整合性まで説明できる必要があります。

面接での説明順

実装を見せる前に、次の順で説明すると設計意図が伝わりやすくなります。

  1. キャッシュミス集中では、同じ高コスト処理が並列に走る
  2. キーごとに in-flight Promise を 1 個だけ持つ
  3. 最初の呼び出しが作り、後続は同じ Promise を返す
  4. 成功・失敗の両方で削除し、次の波を受け入れる
  5. 単一プロセスの解法であり、分散時は別の同期機構が必要

この 5 点まで言えれば、単に Map を書けるだけでなく、負荷・障害・スコープの境界を意識していることも示せます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?