この記事は 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、またはキューイングを検討します。分散ロックは「取得できなければ待つ」だけでなく、所有者の停止・期限切れ・結果の整合性まで説明できる必要があります。
面接での説明順
実装を見せる前に、次の順で説明すると設計意図が伝わりやすくなります。
- キャッシュミス集中では、同じ高コスト処理が並列に走る
- キーごとに in-flight Promise を 1 個だけ持つ
- 最初の呼び出しが作り、後続は同じ Promise を返す
- 成功・失敗の両方で削除し、次の波を受け入れる
- 単一プロセスの解法であり、分散時は別の同期機構が必要
この 5 点まで言えれば、単に Map を書けるだけでなく、負荷・障害・スコープの境界を意識していることも示せます。
