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

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

Позвольте мне рассказать вам о трёх шаблонах кэширования, которые действительно работают в реальном мире, с кодом, который не заставит вашего старшего инженера морщиться во время code review.

Почему кэширование важно (и почему вы, вероятно, делаете это неправильно)

Прежде чем мы углубимся в шаблоны, давайте будем честными: кэширование — это то, где большинство разработчиков впервые сталкиваются с синдромом «работает на моей машине». Один промах кэша в 3 часа ночи может вызвать каскад запросов к базе данных, из-за которого ваша панель мониторинга будет выглядеть как крах фондового рынка.

Основная проблема заключается в том, что кэширование не является необязательным — оно необходимо для масштабирования. Но большинство людей относятся к кэшированию как к второстепенному. «Давайте кэшируем это позже», — говорят они, прямо перед тем, как время отклика их API достигнет 2 секунд в Чёрную пятницу.

graph LR A[Запрос клиента] --> B{Данные в кэше?} B -->|Да| C[Возврат кэшированных данных
Низкая задержка ⚡] B -->|Нет| D[Запрос к базе данных] D --> E[Сохранение в кэш] E --> F[Возврат данных
Более высокая задержка ⚠️] style C fill:#90EE90 style F fill:#FFB6C6

Реальность проста: каждый промах кэша — это обращение к базе данных. Каждое обращение к базе данных — это задержка, которую вы не сможете вернуть. Цель? Максимизировать попадания, минимизировать обращения.

Шаблон 1: Кэширование по требованию (Lazy Loading)

Давайте начнём с MVP шаблонов кэширования. Кэширование по требованию — это то, к чему вы обращаетесь, когда хотите максимальный контроль и минимальную сложность. Думайте об этом как о шаблоне, который говорит: «Приложение, ты главный».

Вот как это работает: ваше приложение сначала проверяет кэш. Если данные существуют (попадание в кэш), отлично — верните их немедленно. Если их нет (промах кэша), извлеките из базы данных, сохраните в кэше на следующий раз и верните пользователю.

Когда кэширование по требованию работает лучше всего

Этот шаблон отлично подходит для определённых сценариев:

  • Нагрузки с большим количеством операций чтения: большая часть вашего трафика — это операции чтения, поэтому кэширование чтений имеет смысл.
  • Непоredсказуемые шаблоны доступа: вы не знаете, какие данные будут запрашивать пользователи, поэтому кэшируете только то, что фактически используется.
  • Терпимость к последующей согласованности: устаревшие данные приемлемы, пока они не устаревают навсегда.
  • Потребность в гибкости: вы хотите полный контроль над тем, что кэшируется и когда.

Реализация в реальном мире на Node.js

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

const Redis = require('redis');
const client = Redis.createClient();
await client.connect();
class UserProfileCache {
  constructor(ttl = 3600) {
    this.ttl = ttl; // TTL в секундах (1 час)
  }
  cacheKey(userId) {
    return `user:profile:${userId}`;
  }
  async getUser(userId) {
    const key = this.cacheKey(userId);
    // Шаг 1: Сначала попробовать кэш
    const cached = await client.get(key);
    if (cached) {
      console.log(`✓ Попадание в кэш для пользователя ${userId}`);
      return JSON.parse(cached);
    }
    // Шаг 2: Промах кэша — запрос к базе данных
    console.log(`✗ Промах кэша для пользователя ${userId} — запрос к базе данных`);
    const user = await this.fetchFromDatabase(userId);
    // Шаг 3: Сохранение в кэш для будущих запросов
    if (user) {
      await client.setEx(key, this.ttl, JSON.stringify(user));
      console.log(`↻ Кэширован пользователь ${userId} на ${this.ttl} секунд`);
    }
    return user;
  }
  async fetchFromDatabase(userId) {
    // Имитация запроса к базе данных (на самом деле это был бы ваш клиент БД)
    return {
      id: userId,
      name: 'John Doe',
      email: '[email protected]',
      lastSeen: new Date()
    };
  }
}
// Использование
const userCache = new UserProfileCache(3600);
const user = await userCache.getUser('user123');
// Первый вызов: запрос к базе данных, кэширование результата
// Второй вызов в течение 1 часа: возврат из кэша мгновенно

Ловушка кэширования по требованию: «топовое» кэширование

Здесь всё становится интереснее. Представьте, что ваш кэш истекает для популярного профиля пользователя как раз в тот момент, когда одновременно поступают 50 запросов. Все 50 думают, что произошёл промах кэша, и одновременно обращаются к базе данных. Это называется «топовое» кэширование, и это не очень хорошо.

Решение? Кэшировать нулевые результаты с более коротким TTL:

async getUser(userId) {
  const key = this.cacheKey(userId);
  const cached = await client.get(key);
  if (cached !== null) {
    // Это охватывает как реальные данные, так и нулевые значения
    const data = cached === 'NULL' ? null : JSON.parse(cached);
    return data;
  }
  const user = await this.fetchFromDatabase(userId);
  // Кэшировать null на 5 минут, чтобы предотвратить «топовое» кэширование
  const valueToCache = user ? JSON.stringify(user) : 'NULL';
  await client.setEx(key, user ? 3600 : 300, valueToCache);
  return user;
}

Теперь несуществующие пользователи также кэшируются (на более короткий период), предотвращая обращение вашей базы данных к повторяющимся запросам для несуществующих пользователей.

Шаблон 2: Кэширование с записью через (Write-Through Caching)

Теперь давайте поговорим о записях. Кэширование по требованию прекрасно справляется с операциями чтения, но что насчёт обновлений? Кэширование с записью через обеспечивает синхронизацию вашего кэша и базы данных, записывая в оба одновременно.

Философия кэширования с записью через

При записи через каждая операция записи затрагивает два места: кэш и базу данных. Оба происходят вместе, оба успешны или оба терпят неудачу. Это синхронно, безопасно и медленнее — но в правильных сценариях компромисс стоит того.

graph TD A[Запрос на запись] --> B[Запись в базу данных] B --> C{Успешно?} C -->|Нет| D[Возврат ошибки] C -->|Да| E[Запись в кэш] E --> F{Успешно?} F -->|Нет| G[Запись ошибки
Кэш устарел] F -->|Да| H[Возврат успеха] style D fill:#FFB6C6 style G fill:#FFE4B5 style H fill:#90EE90

Когда использовать кэширование с записью через

Кэширование с записью через — это ваш шаблон, когда:

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

Реализация: обновление профиля пользователя

class WriteThroughUserCache {
  constructor(ttl = 3600) {
    this.ttl = ttl;
  }
  cacheKey(userId) {
    return `user:profile:${userId}`;
  }
  async updateUser(userId, userData) {
    const key = this.cacheKey(userId);
    try {
      // Шаг 1: Сначала запись в базу данных
      // Почему? Если БД терпит неудачу, мы не трогаем кэш
      await this.writeToDatabase(userId, userData);
      // Шаг 2: Только если БД успешна, обновить кэш
      await client.setEx(key, this.ttl, JSON