Как не вернуть удалённые данные из кеша

Удаление данных затрагивает и работу, которая началась раньше. Запрос уже мог получить копию записи и продолжить готовить результат после очистки кеша. Поэтому очистку нужно связать с проверкой результатов незавершённых запросов: устаревшая копия не должна снова попасть в кеш или в ответ посетителю.

Учебные примеры подготовлены в октябре 2026 года. В них использованы современные возможности JavaScript и платформы; архивная дата не относится к этим реализациям.

Возьмём список публикаций блога. Приложение готовит его асинхронно и сохраняет результат, чтобы не повторять работу для каждого посетителя. Если подготовка прочитала статью до удаления, а закончилась после очистки кеша, она снова положит туда список с удалённой статьёй. Следующие посетители увидят её, хотя в базе записи уже нет.

Список у всех одинаковый; персональных данных в этом кеше нет. Пример рассчитан на один процесс Node.js. Если экземпляров приложения несколько, каждому придётся сообщать об изменениях.

Очистили значение, но оставили вычисление

Для начала воспроизведём ошибку без базы и HTTP. В переменной source будет текст источника, а промис позволит остановить подготовку в нужном месте. Так нам не придётся подбирать задержку, за которую удаление успеет обогнать чтение.

import assert from 'node:assert/strict';

const ready = Promise.withResolvers();
const started = Promise.withResolvers();
let source = 'Удаляемая запись';
let cached;

async function load() {
  if (cached === undefined) {
    const body = source;
    started.resolve();
    await ready.promise;
    cached = body;
  }

  return cached;
}

const reading = load();
await started.promise;
source = '';
cached = undefined;
ready.resolve();

assert.equal(await reading, 'Удаляемая запись');
assert.equal(await load(), 'Удаляемая запись');
assert.equal(source, '');

Этот файл можно сохранить с расширением .mjs и запустить через node. Все утверждения выполнятся. Мы специально подтвердили ошибку: в источнике пусто, а повторное чтение отдаёт удалённый текст.

Promise.withResolvers() возвращает промис вместе с функциями его завершения. Здесь started сообщает, что чтение уже сохранило старый текст, а ready разрешает продолжить. Порядок действий задаёт сам пример, поэтому ошибка не зависит от скорости компьютера.

Момент Подготовка страницы Удаление
00 Сохраняет прежний текст в body
01 Ожидает продолжения Удаляет запись из источника
02 Продолжает ждать Очищает cached
03 Записывает прежний body в cached Уже закончено

Присваивание cached = undefined влияет на переменную кеша. Оно ничего не делает с локальной переменной body внутри приостановленного вызова. После await функция продолжает работу с тем значением, которое успела получить раньше.

Срок жизни кеша в пять секунд сам по себе не предотвратит показ удалённого текста. Если отсчитывать его от заполнения кеша, прежняя подготовка может закончиться уже после удаления и запустить эти пять секунд заново. Это не подходит для немедленного отзыва доступа: разрешение уже изменили, а приложение всё ещё выдаёт прежнее содержимое.

Отмена не определяет, какой результат ещё допустим

При очистке кеша хочется заодно остановить прежнюю подготовку. Если чтение или вычисление поддерживает AbortSignal, отмена поможет избежать лишней работы. Однако одной отмены недостаточно, чтобы исключить старый результат.

AbortController передаёт сигнал операции, которая умеет на него реагировать. Произвольную асинхронную функцию он не прерывает. Часть работы могла уже закончиться, а продолжение с полученным результатом ещё ждёт своей очереди. Наконец, подготовка может включать функцию, которой сигнал вообще не передали.

Я разделяю эти две задачи. Отмена помогает не делать ненужную работу. Проверка перед сохранением определяет, можно ли ещё показывать её результат.

Для этого перед подготовкой будем запоминать номер поколения кеша. При каждом изменении источника увеличиваем номер и убираем готовое значение. Если подготовка закончилась с прежним номером, результат больше не подходит.

const version = revision;
const body = await prepare();

if (version !== revision) {
  throw new Changed();
}

Здесь проверка стоит после асинхронной работы. Проверить поколение до вызова prepare() недостаточно: как раз во время ожидания источник и может измениться.

Ошибка Changed означает, что эту попытку чтения нельзя использовать. Она не сообщает, существует ли запись сейчас, и не заменяет ответ «не найдено». После удаления свежая подготовка может вернуть страницу без этой записи. Если источник временно недоступен, подготовка должна завершиться своей ошибкой, а не выдать отсутствие данных за пустой список.

Одна подготовка для нескольких запросов

Проверка поколения защищает результат, но ещё не решает, сколько одинаковых подготовок запустит приложение. Если десять запросов одновременно обнаружат пустой кеш, нет смысла десять раз собирать один документ. Сохраним общий промис в pending; все запросы дождутся его результата.

Ниже полный модуль cache.mjs. Он хранит один документ размером до одного мебибайта в кодировке UTF-8. В примере нет срока жизни: значение действует до явной инвалидации, то есть до вызова invalidate(). Все изменения источника обязаны доходить до этого метода.

import { Buffer } from 'node:buffer';

export class Changed extends Error {
  constructor() {
    super('Document changed');
    this.name = 'Changed';
  }
}

export function createCache(prepare) {
  let cached;
  let pending;
  let revision = 0;

  async function rebuild() {
    const version = revision;
    const body = await prepare();

    if (version !== revision) {
      throw new Changed();
    }

    if (typeof body !== 'string') {
      throw new TypeError('Expected a string');
    }

    if (Buffer.byteLength(body) > 1024 * 1024) {
      throw new RangeError('Document is too large');
    }

    cached = Object.freeze({ body });

    return cached;
  }

  function check(result) {
    if (!cached || result !== cached) {
      throw new Changed();
    }
  }

  return {
    async load() {
      if (cached) {
        return cached;
      }

      pending ??= Promise.resolve()
        .then(rebuild)
        .finally(() => {
          pending = undefined;
        });

      const result = await pending;
      check(result);

      return result;
    },
    check,
    invalidate() {
      revision++;
      cached = undefined;
    }
  };
}

В rebuild() проверяем поколение и только после этого присваиваем новое значение. Между проверкой и присваиванием нет await или вызова пользовательского кода. В этом процессе инвалидация не сможет вклиниться между ними обычным асинхронным продолжением.

Тело документа хранится строкой внутри замороженного объекта. Строку нельзя изменить на месте, а Object.freeze() не позволит вызывающему коду подменить body в общем объекте. Для дерева объектов одной поверхностной заморозки было бы недостаточно; здесь такого дерева нет. Пустая строка остаётся допустимым документом. Она отличается от отсутствия результата и от ошибки чтения.

Ограничение размера относится к хранимому документу. Оно не обещает, что подготовка использует не больше одного мебибайта памяти: прежде чем измерить строку, мы уже должны её получить. Если сама подготовка способна собрать огромный результат, ограничивать её нужно раньше, там, где читаются данные.

Отдельно стоит посмотреть на pending. Promise.resolve().then(rebuild) откладывает вызов подготовки, пока в pending не окажется общий промис. Исключение внутри rebuild() станет отклонением этого промиса. После успешного завершения или ошибки finally() освобождает pending, поэтому следующая попытка не застрянет на уже завершившейся работе.

При инвалидации я намеренно не очищаю pending. Если прежняя подготовка уже началась и ещё работает, новый запрос присоединится к ней. При её успешном завершении все ожидающие получат Changed, а при отказе источника — его ошибку. Только после завершения этой подготовки следующий запрос сможет начать новую.

Из-за этого после изменения источника часть запросов завершится ошибкой, хотя уже могла бы получить свежий документ. Зато несколько инвалидаций подряд не запустят несколько дорогих подготовок одновременно. Для небольшого списка публикаций я бы начал с такого поведения. Если ждать старую работу нельзя, придётся разрешить новую подготовку до её завершения, ограничить число одновременных запусков и решить, как отменять прежние. Одного pending = undefined для этого мало.

Зависшая prepare() задержит все ожидающие запросы, поэтому внешним вызовам внутри неё нужно ограничение времени. При этом таймер, который только перестаёт ждать ответ, не останавливает сам запрос. Если сразу разрешить новую подготовку, прежняя работа может продолжиться рядом с ней.

Устаревший результат уже вышел из кеша

Защитить присваивание недостаточно. Обработчик мог получить актуальный документ, затем начать ещё одну асинхронную операцию. Пока он ждёт, запись удалят. Локальная переменная обработчика по-прежнему содержит старый текст, хотя сам кеш уже пуст.

Например, после чтения документа приложение асинхронно готовит его окончательное оформление. Последнюю проверку ставим после этой работы, непосредственно перед передачей тела ответа. Сохраним обработчик в response.mjs:

export async function respond(cache, decorate, response) {
  const result = await cache.load();
  const body = await decorate(result.body);

  cache.check(result);
  response.end(body);
}

decorate() здесь работает с уже безопасным для своего контекста текстом. Если в оформление входят пользовательские значения, их экранирование остаётся обязанностью шаблонизатора. Кеш не делает произвольную строку безопасным HTML.

Метод check() сравнивает объекты по ссылке. После инвалидации старый объект больше не является текущим, даже если следующая подготовка случайно вернула совершенно такой же текст. Совпадения строк недостаточно: мы проверяем, можно ли использовать именно этот результат чтения.

Проверка внутри load() нужна по той же причине. Между завершением общей подготовки и продолжением ожидавшей функции может пройти инвалидация. Проверка перед response.end() учитывает изменения за время оформления страницы. Если после неё добавить новый await, придётся проверить результат ещё раз, уже после ожидания.

В этой модели response.end() синхронно принимает готовое тело. Проверка не даст передать ему старый текст после инвалидации в этом процессе. Но байты, отправленные раньше, она уже не отзовёт, как не изменит страницу, открытую у посетителя. При потоковой отправке, которая начинается до готовности всего документа, потребуется отдельно решить, что делать с удалением посреди ответа.

Проверяем порядок событий

Первый пример подтвердил ошибку очистки. Теперь остановим подготовку тем же способом, но используем кеш с поколением. Следующий блок можно сохранить рядом с cache.mjs и запустить как отдельный модуль.

import assert from 'node:assert/strict';
import { Changed, createCache } from './cache.mjs';

const ready = Promise.withResolvers();
const started = Promise.withResolvers();
let source = 'Удаляемая запись';

const cache = createCache(async () => {
  const body = source;
  started.resolve();
  await ready.promise;

  return body;
});

const reading = cache.load();
const rejected = assert.rejects(reading, Changed);
await started.promise;
source = '';
cache.invalidate();
ready.resolve();

await rejected;
assert.equal((await cache.load()).body, '');

Обработчик ожидаемого отказа подключён до продолжения подготовки. Старое чтение отклоняется, а новое возвращает пустой документ из уже изменённого источника. Мы не проверяем, сколько миллисекунд заняла операция, и не рассчитываем на удачное чередование таймеров.

Для второй гонки создадим отдельный файл и остановимся позже, уже внутри decorate(). Сам ответ заменим объектом, который записывает переданные тела в массив:

import assert from 'node:assert/strict';
import { Changed, createCache } from './cache.mjs';
import { respond } from './response.mjs';

const ready = Promise.withResolvers();
const started = Promise.withResolvers();
const sent = [];
const cache = createCache(() => 'Удаляемая запись');

async function decorate(body) {
  started.resolve();
  await ready.promise;

  return `<main>${body}</main>`;
}

const response = { end: body => sent.push(body) };
const rejected = assert.rejects(respond(cache, decorate, response), Changed);
await started.promise;
cache.invalidate();
ready.resolve();

await rejected;
assert.deepEqual(sent, []);

Строка внутри main задана самим примером. Проверка подтверждает конкретное свойство: старое содержимое не попало в end(). В приложении вызывающий обработчик может превратить Changed во временный ответ 503 с понятным сообщением. Бесконечно повторять подготовку внутри того же запроса не стоит: при частых изменениях она может каждый раз терять актуальность.

Кроме этих двух гонок, проверим совместное ожидание и повторную подготовку:

Ситуация Ожидаемое поведение
Два чтения начались до завершения подготовки prepare() вызвана один раз, оба чтения получили один объект
После начала подготовки кеш инвалидирован, затем пришёл ещё один запрос Оба чтения отклонены; одновременно вторая подготовка не запущена
Подготовка завершилась ошибкой источника Все ожидавшие получили ошибку, следующий запрос может повторить подготовку
Кеш инвалидирован и заново заполнен тем же текстом Проверка старого объекта всё равно отклоняет его
Источник вернул пустую строку Строка закеширована как допустимый результат
Подготовка вернула undefined Получена ошибка, пустой документ не подставлен

Именно эти последовательности определяют поведение кеша. Проверка вида «записали значение, затем прочитали то же самое» ничего не говорит о том, что случится с незавершённым чтением после удаления.

Когда сообщать кешу об изменении

В примерах изменение source и инвалидация выполняются рядом. В приложении приходится ждать подтверждения записи в базу. Сообщим кешу об изменении после него:

await removeRecord(id);
cache.invalidate();

removeRecord() должна завершаться успешно после подтверждённого удаления. Если очистить кеш только перед записью, во время ожидания другая подготовка может прочитать прежние данные и сохранить их уже с новым поколением. Когда удаление закончится, это значение останется действующим. Поэтому инвалидация нужна после подтверждения изменения; предварительная очистка её не заменяет.

При этом номер поколения ничего не знает о внутреннем моменте фиксации записи в базе. Пока подтверждение ещё не пришло и invalidate() не вызвана, старое значение в памяти может пройти проверку. Граница нашей гарантии проходит через инвалидацию в приложении. Если требуется запретить старые ответы с более раннего момента, чтения и запись нужно согласовать дополнительно, например приостанавливать выдачу этого документа на время изменения. У такого решения будет своя цена ожидания и своя обработка отказов.

Новая подготовка также должна читать источник, в котором подтверждённое удаление уже видно. Если она обращается к отстающей реплике, локальный счётчик не помешает закешировать старые данные под новым номером. Поколение защищает от устаревшей работы приложения, но не исправляет свежесть самого источника.

Ещё неприятнее ситуация, когда запись завершилась ошибкой связи и её результат неизвестен. Нельзя по этой ошибке решить, что база точно осталась прежней, и продолжить раздавать кеш. Для удаления или изменения доступа я бы прекратил выдачу прежнего значения до выяснения состояния. Сам кеш не может установить результат чужой операции; это задача слоя, который выполняет запись и умеет проверить её исход.

Старую копию можно вернуть не всегда

Если не удалось загрузить свежие фотографии для сайта, прежнюю подборку иногда можно оставить до следующей попытки. С удалённой публикацией так поступить нельзя: мы уже знаем, что её больше не нужно показывать. Поэтому разобранный кеш при ошибке подготовки не возвращает старое значение.

Если добавить такое поведение для подходящего документа, одной проверки «есть ли сохранённая копия» будет мало. Копия должна принадлежать всё ещё допустимому поколению, а причина обновления должна разрешать её использование. Истечение срока хранения и явное удаление нельзя бездумно сводить к одному флагу «устарело».

Для нескольких процессов этих проверок в памяти тоже недостаточно. Один экземпляр приложения может увеличить свой номер, пока другой ничего не знает об удалении. Здесь уже нужен способ сообщить об изменении каждому экземпляру или проверить общую версию состояния. Локальный кеш останется частью решения, но общей согласованности из него не получится.

Для нашего списка публикаций удаление заканчивается не на очистке переменной. Нужно ещё отбросить результат старой подготовки перед записью в кеш и проверить уже полученный документ перед отправкой. Когда эти места учитывают одну версию данных, удалённая статья не возвращается из прежнего запроса. Затем можно выбирать, как пережить ожидание свежего списка: повторить подготовку с ограничением числа попыток, подождать её в пределах таймаута или временно ответить ошибкой.