Перейти к содержанию

Кеш каталога и источник данных

Оглавление серии

Фоновый импорт обычно начинается с чтения уже существующих данных. В проекте «Импортёр» это каталог вымышленных товаров с остатками: лампа, блокнот и позднее карандаш. Сначала рассмотрим кеш чтения, а затем постепенно добавим постановку CSV-пакета в очередь и работу отдельного воркера. Все состояния используют одну предметную модель, поэтому изменение количества товара будет заметно и в кеше, и в результате задачи.

Кеш — сохранённая копия результата, которую можно прочитать повторно вместо обращения к основным данным. Он не должен становиться единственным местом хранения товара только потому, что доступен быстро. В первом снимке основной каталог находится в Redis hash, а JSON-файл задаёт начальное учебное заполнение через HSETNX. Отдельные string-ключи кеша содержат копию всего списка. Пример not_run: Redis, приложения и зависимости не запускались.

Источник и копия имеют разные обязанности

Для локального проекта выбираем Node.js 24 LTS, Express 5.2.1 и ioredis 5.11.1. Redis предполагается отдельный учебный экземпляр 7.2 или новее без важных данных. Клиент ioredis здесь выбран ради согласования с дальнейшими примерами BullMQ; официальный проект также рекомендует node-redis для новых общих приложений. Наш выбор относится к конкретной серии, а не к сравнительной оценке всех клиентов Redis.

Используйте полный каталог stages/01 из архива. Файл fixtures/catalog.json содержит два товара: p-1 с остатком 8 и p-2 с остатком 3. Функция readCatalog() добавляет начальные значения только для отсутствующих полей, затем читает основной hash. Повторное чтение не возвращает исходные остатки поверх уже изменённых, поскольку HSETNX не заменяет существующее поле.

Полный файл catalog.js показывает, где находится источник. Сортировка по sku создаёт устойчивый порядок ответа и не зависит от порядка полей, возвращённых hash.

import { readFile } from 'node:fs/promises';

export const productsKey = 'importer:products';
export const generationKey = 'importer:generation';

export async function readCatalog(redis) {
  const text = await readFile(new URL('./fixtures/catalog.json', import.meta.url), 'utf8');
  const initial = JSON.parse(text);
  for (const product of initial) {
    await redis.hsetnx(productsKey, product.sku, JSON.stringify(product));
  }
  const stored = await redis.hgetall(productsKey);
  return Object.values(stored).map(value => JSON.parse(value))
    .sort((left, right) => left.sku.localeCompare(right.sku));
}

Обратите внимание на первичное заполнение. Файл fixture не является постоянно обновляемой базой, которую мы каждый раз копируем безусловно. После первого добавления источником текущего количества становится hash importer:products. Если удалить основной hash, следующее чтение снова заполнит его начальными вымышленными данными. Это специальная договорённость локального примера; реальное приложение не должно незаметно восстанавливать потерянные записи такими начальными значениями.

Чтение через кеш

Функция cachedCatalog() сначала получает значение string-ключа. Если значение есть, JSON разбирается и возвращается с source: "cache". При отсутствии читаются основные данные, сериализуются и сохраняются, а ответ получает source: "primary". Поле source помогает понять механизм во время урока. В обычном продукте оно редко нужно пользователю; это учебная диагностическая часть API, а не обязательный параметр каталога.

Полный файл cache.js первого снимка пока не задаёт срок жизни. Этот намеренно простой вариант позволит следующему уроку объяснить проблему устаревшей копии.

import { readCatalog, generationKey } from './catalog.js';

export async function cachedCatalog(redis) {
  const generation = '0';
  const key = `importer:cache:catalog:${generation}`;
  const cached = await redis.get(key);
  if (cached) return { source: 'cache', items: JSON.parse(cached) };
  const items = await readCatalog(redis);
  await redis.set(key, JSON.stringify(items));
  return { source: 'primary', items };
}

Ключ называется importer:cache:catalog:0. Префикс отделяет данные примера от других задач учебного Redis, а завершающий номер позднее станет поколением каталога. Пока он постоянный и не меняется. Наличие понятного префикса не является изоляцией безопасности: разные приложения с доступом к той же базе всё равно могут прочитать или удалить чужие ключи. Поэтому серия требует отдельного локального экземпляра, а не общей production-базы.

На первом GET /api/catalog ожидается source primary и оба товара. На втором запросе без удаления ключа ожидается source cache с теми же остатками. Это ожидаемая последовательность договора, не фактические ответы выполненных запросов. Если Redis недоступен, HTTP-обработчик возвращает ошибку сервиса; в этой серии кеш и основной hash находятся на одной зависимости, поэтому fallback к другой полноценной базе не реализован.

{
  "source": "cache",
  "items": [
    {"sku":"p-1","title":"Лампа","quantity":8},
    {"sku":"p-2","title":"Блокнот","quantity":3}
  ]
}

Копия может устареть

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

Нельзя использовать один и тот же ключ одновременно как основное хранилище и кеш. Если бизнес-данные считаются потеряемой копией, политика вытеснения или очистка кеша может уничтожить остатки. В нашей структуре основные поля, cache-ключи, очередь и отметки идемпотентности имеют разные префиксы и разные сроки хранения. Такое разделение не гарантирует долговечность Redis, но делает обязанности данных видимыми в коде и документации.

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

Параллельные промахи кеша могут одновременно прочитать основные данные и записать одинаковую копию. Для нашего маленького локального источника это допустимо. При дорогом внешнем запросе такое поведение называется всплеском заполнения и требует отдельного решения: объединения вычислений, ограниченного lock или допуска временно старой копии. Сам GET/SET не делает заполнение единственным на весь сервер и не устраняет параллельные вычисления автоматически.

Политика подключения в config.js ограничивает повторы producer-запроса и отключает ожидание команд в offline-очереди. HTTP-клиент не должен бесконечно ждать Redis. Фоновый воркер позднее получит другой режим, потому что у него другой жизненный цикл. Уже на первом шаге полезно различать запрос человека с ограниченным временем ответа и фоновый процесс, который может дольше ждать восстановления зависимости.

Команды чтения и SET сверяются с Redis SET и ioredis 5.11.1. После первого снимка источник и кеш разнесены, а промах имеет ясный путь заполнения. Дальше ограничим срок жизни копии, сохраняя основные остатки независимо от срока кеша.