Лаборатория кеша: промахи и устаревший ответ
TTL ограничивает возраст, но не предотвращает гонку. Два промаха одного ключа могут одновременно нагрузить источник, а запрос до инвалидирования — записать старый ответ после него. Разберём оба случая без сети и случайных задержек. Это локальная модель одного процесса; Next.js и Redis имеют собственные механизмы.
Управляемые Promise
Сохраните блок как cache.mjs, запустите Node.js 24. deferred позволяет выбрать
порядок завершения явно. Для одного ключа совпадают контекст доступа и параметры;
в реальной системе tenant/user/locale входят в ключ, если влияют на ответ.
import assert from "node:assert/strict";
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
let version = 0;
let cached = null;
let inFlight = null;
let loads = 0;
const pending = [];
function load() {
loads++;
const request = deferred();
pending.push(request);
return request.promise;
}
function invalidate() {
version++;
cached = null;
inFlight = null;
}
function get() {
if (cached !== null) return Promise.resolve(cached);
if (inFlight) return inFlight.promise;
const generation = version;
const entry = { promise: null };
entry.promise = load()
.then((value) => {
if (generation === version) cached = value;
return value;
})
.finally(() => {
if (inFlight === entry) inFlight = null;
});
inFlight = entry;
return entry.promise;
}
const first = get();
const joined = get();
assert.equal(loads, 1);
invalidate();
const fresh = get();
assert.equal(loads, 2);
pending[1].resolve("new");
assert.equal(await fresh, "new");
pending[0].resolve("old");
assert.equal(await first, "old");
assert.equal(await joined, "old");
assert.equal(await get(), "new");
invalidate();
const failed = get();
pending[2].reject(new Error("source down"));
await assert.rejects(failed, /source down/);
const recovered = get();
pending[3].resolve("recovered");
assert.equal(await recovered, "recovered");
assert.equal(loads, 4);
console.log("Joined miss, stale write blocked, retry recovered");
Один in-flight Promise объединяет одновременные промахи (singleflight). Поколение блокирует старую запись в кеш, но первый клиент всё ещё получает old: отмена доставки устаревшего результата — отдельная политика. В UI можно сравнивать номер запроса перед setState, как в эффектах. Проверка identity в finally не позволяет старому запросу стереть новый inFlight. После reject ссылка очищается, иначе ошибка станет вечным результатом.
Исходник схемы
sequenceDiagram participant A as Первый запрос participant C as Кеш participant S as Источник A->>C: get, поколение 0 C->>S: load old Note over C: invalidate, поколение 1 C->>S: load new S-->>C: new S-->>A: old Note over C: old не записывается в поколение 1
Цена координации
В нескольких процессах локальный inFlight не объединит запросы других экземпляров. Распределённый lock требует срока аренды, проверки владельца при освобождении и политики на случай зависшего вычисления; истёкший lock не отменяет старую работу. Нельзя использовать отсутствие lock как доказательство актуальности ответа. Практические альтернативы: разнести истечения TTL, ограничить параллелизм, обновлять заранее, временно отдавать stale там, где это допустимо.
Опыт: уберите проверку generation — final get вернёт old. Уберите identity в finally и завершите old пока new ещё ожидает: третий get начнёт лишнюю загрузку. Добавьте другой ключ и убедитесь, что независимые запросы не объединяются. Запишите требования к максимальному размеру Map, timeout и очистке. Этот короткий пример не реализует LRU, TTL, cancellation и distributed lock.
Приёмка: точный порядок завершений, число обращений к источнику, результат каждого клиента и итог кеша. Отдельно объясните поведение при отказе, повторе и инвалидировании. В проекте используйте существующие теги CMS; учебный Map не предлагается как замена кешам Next.js.