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 есть короткое окно, когда чтение, конкурирующее с инвалидацией, может получить устаревшие данные. Для многих данных это допустимо, для балансов или прав доступа — нет.
- Больше движущихся частей. Кэш нужно мониторить, и он может упасть. Эндпоинт должен продолжать работать, пусть и медленнее, если кэш недоступен.
Выводы
Сначала план запроса, потом исправление
План запроса обычно прямо указывает на проблему: нет составного индекса, документы отдаются целиком, мелкие запросы идут в цикле. Хитрый код для этого не нужен.
Кэшировать быстрый путь, а не медленный
Кэш поверх медленного запроса улучшает среднее и не трогает худшие случаи. Кэш поверх быстрого запроса делает хороший путь ещё лучше, а промах перестаёт быть проблемой.
Контракт — тоже часть работы над производительностью
Именно неизменный ответ делает такое изменение безопасным для выкладки. Сравнить старые и новые ответы на одинаковых входных данных несложно, а главный риск это снимает.
Инвалидация живёт рядом с записью
Построение ключа и инвалидацию стоит держать рядом с кодом записи — так их трудно забыть. Полезно добавить тест, который падает, если путь записи меняет данные и не удаляет соответствующий ключ.