Учебник веб-разработки
Разделы учебника
На этой странице

VI. HTTP, Next.js и кеширование

Лаборатория кеша: промахи и устаревший ответ

Оглавление · Уровни кеша

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.