5 мин чтения

Сначала запрос, потом кэш: как я сократил время ответа критичного эндпоинта примерно на 60%

Заметки о сервисе на Node.js и MongoDB: почему сначала нужно исправить запрос и только потом добавлять кэш, как устроен cache-aside и как при этом не сломать контракт ответа.

В Junzi Tech я работал с сервисом на Node.js / Express и MongoDB, фронтенд был на React. Переписав запросы одного критичного эндпоинта и добавив кэширование, я снизил его среднее время ответа примерно на 60%, не меняя контракт ответа. В этой статье — подход и логика, которая за ним стоит. Код принадлежит компании, поэтому примеры упрощённые и условные: они показывают технику, а не продакшен-код, а детали описывают подход, которым я пользуюсь, а не устройство конкретной системы.

Контекст

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

В такой задаче все решения определяют два ограничения:

  • Контракт ответа менять нельзя. Если фронтенд завязан на точную структуру JSON, быстрый эндпоинт, который отдаёт чуть-чуть другие данные, — это регрессия, а не оптимизация.
  • Данные не статичны. Часть из них читается гораздо чаще, чем пишется, но всё же меняется, поэтому вариант «закэшируем всё на час» не подходит.

Варианты

Вариант 1: поставить кэш и забыть

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

Вариант 2: масштабировать базу или сервис

Дополнительные ресурсы немного помогут, но если запрос делает больше работы, чем нужно, дело не в мощности. Железо лишь на время сделает эту работу дешевле.

Вариант 3: сначала исправить запрос, потом добавить кэш

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

Подход

Я выбрал третий вариант: сначала рефакторинг запросов, затем кэширование. Изменение запроса стоит выкатывать отдельно — так его эффект виден в чистом виде, пока кэш не смазал картину.

Шаг 1: измерить и прочитать план запроса

Прежде чем что-то менять, нужно посмотреть, что на самом деле делает база. В MongoDB для этого есть explain("executionStats"): сравниваем число просмотренных документов с числом возвращённых. Если база читает намного больше, чем отдаёт, подходящего индекса для этого фильтра нет.

Типичные исправления просты:

  • составной индекс, в котором сначала идут поля с условием на равенство, а затем поле сортировки;
  • проекция, чтобы база возвращала только нужные ответу поля, а не документы целиком;
  • отказ от запросов в цикле на каждый элемент (проблема N+1) в пользу одного пакетного запроса.
// Упрощённый условный пример: фильтр и сортировка обслуживаются одним составным индексом,
// проекция ограничена полями, которые реально нужны в ответе.
await db.collection("items").createIndex({ accountId: 1, status: 1, updatedAt: -1 });

const items = await db
  .collection("items")
  .find(
    { accountId, status: "active" },
    { projection: { _id: 1, title: 1, status: 1, updatedAt: 1, ownerId: 1 } }
  )
  .sort({ updatedAt: -1 })
  .limit(50)
  .toArray();

// Один пакетный запрос вместо запроса на каждый элемент.
const ownerIds = [...new Set(items.map((i) => String(i.ownerId)))];
const owners = await db
  .collection("owners")
  .find({ _id: { $in: ownerIds.map(toObjectId) } }, { projection: { name: 1 } })
  .toArray();

Шаг 2: защитить контракт ответа

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

Шаг 3: добавить cache-aside

Кэш имеет смысл только после того, как некэшированный путь стал быстрым, и только для данных, которые читаются намного чаще, чем меняются. Я обычно использую паттерн cache-aside: читаем из кэша, при промахе идём в базу, записываем результат с TTL и удаляем ключ, когда исходные данные меняются.

// Упрощённая условная обёртка cache-aside с TTL и явной инвалидацией.
async function cached(key, ttlSeconds, load) {
  const hit = await cache.get(key);
  if (hit !== null) return JSON.parse(hit);

  const value = await load();
  await cache.set(key, JSON.stringify(value), { EX: ttlSeconds });
  return value;
}

const listKey = (accountId) => `items:list:${accountId}`;

// Чтение (значение TTL здесь произвольное)
const payload = await cached(listKey(accountId), 60, () => buildItemsResponse(accountId));

// Запись: инвалидируем после успешной записи в базу
await db.collection("items").updateOne({ _id }, { $set: changes });
await cache.del(listKey(accountId));

TTL здесь — страховка, а не основная стратегия инвалидации. Свежесть данных обеспечивает явное удаление ключа при записи, а TTL ограничивает ущерб, если какой-то путь записи забудет это сделать.

Компромиссы

  • Сроки. Исправить запрос дольше, чем добавить кэш. Цена оправдана: быстрым становится и путь при промахе.
  • Стоимость индекса. Каждый индекс ускоряет часть чтений, замедляет каждую запись в коллекцию и занимает память. Добавлять стоит тот индекс, которого требует план запроса, а не по индексу на каждое поле.
  • Согласованность кэша. В cache-aside есть короткое окно, когда чтение, конкурирующее с инвалидацией, может получить устаревшие данные. Для многих данных это допустимо, для балансов или прав доступа — нет.
  • Больше движущихся частей. Кэш нужно мониторить, и он может упасть. Эндпоинт должен продолжать работать, пусть и медленнее, если кэш недоступен.

Выводы

Сначала план запроса, потом исправление

План запроса обычно прямо указывает на проблему: нет составного индекса, документы отдаются целиком, мелкие запросы идут в цикле. Хитрый код для этого не нужен.

Кэшировать быстрый путь, а не медленный

Кэш поверх медленного запроса улучшает среднее и не трогает худшие случаи. Кэш поверх быстрого запроса делает хороший путь ещё лучше, а промах перестаёт быть проблемой.

Контракт — тоже часть работы над производительностью

Именно неизменный ответ делает такое изменение безопасным для выкладки. Сравнить старые и новые ответы на одинаковых входных данных несложно, а главный риск это снимает.

Инвалидация живёт рядом с записью

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

Альхассан Альфарран.

© 2026 · Спроектировал и сделал сам. Стек сайта: Next.js, Tailwind и Framer Motion.

Моё местное время: · Москва

Заметки
Как устроен этот сайт

Стек

Next.js (App Router) и React, стили на Tailwind CSS, анимации на Framer Motion. Форма обратной связи отправляет письма через Resend, сайт размещён на Vercel.

Три языка, одна вёрстка

У английской, русской и арабской версий свои адреса (/en, /ru, /ar), а собраны они из одних и тех же компонентов. В вёрстке используются логические CSS-свойства (start/end вместо left/right), поэтому арабская версия отражается справа налево без отдельных стилей. Сервер отдаёт каждую страницу сразу с нужным языком и направлением текста, так что после загрузки ничего не перескакивает, а арабский шрифт загружается, только когда на экране есть арабский текст.

Скорость и доступность

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

Исходный код на GitHub