API Caching: стратегии кэширования для производительности

← Вернуться к статьям

Введение

Кэширование — один из самых эффективных способов улучшения производительности API. Правильно реализованное кэширование может снизить нагрузку на серверы на 70-90%, уменьшить время ответа в разы и значительно улучшить пользовательский опыт.

В этом руководстве мы рассмотрим различные стратегии кэширования API: HTTP caching с использованием ETag и Last-Modified, application-level caching, Redis кэширование, стратегии инвалидации кэша и выбор правильной стратегии для разных типов данных.

✅ Что вы узнаете:

💡 Зачем нужно кэширование API?

Кэширование снижает нагрузку на базу данных, уменьшает время ответа, экономит ресурсы сервера и улучшает масштабируемость приложения. Правильное кэширование может увеличить пропускную способность API в 10-100 раз.

📋 Содержание

1. HTTP Caching (ETag, Last-Modified) 🌐

HTTP caching использует стандартные HTTP заголовки для управления кэшированием на стороне клиента, прокси и CDN. Это самый простой и эффективный способ кэширования.

Cache-Control заголовок

// Express.js пример
app.get('/api/users', async (req, res) => {
  const users = await db.getUsers();

  // Устанавливаем Cache-Control заголовок
  res.set('Cache-Control', 'public, max-age=300'); // Кэш на 5 минут
  res.json(users);
});

// Различные директивы Cache-Control:
// - public: может кэшироваться везде
// - private: только в браузере пользователя
// - no-cache: нужно проверить свежесть
// - no-store: не кэшировать вообще
// - max-age=300: кэш на 300 секунд
// - must-revalidate: перепроверить при истечении

ETag (Entity Tag)

ETag — это уникальный идентификатор версии ресурса. Клиент отправляет ETag в заголовке If-None-Match, и сервер возвращает 304 Not Modified, если ресурс не изменился.

// Express.js с ETag
const crypto = require('crypto');

app.get('/api/users/:id', async (req, res) => {
  const user = await db.getUser(req.params.id);

  // Генерируем ETag на основе данных
  const etag = crypto
    .createHash('md5')
    .update(JSON.stringify(user))
    .digest('hex');

  res.set('ETag', `"${etag}"`);

  // Проверяем If-None-Match заголовок
  const clientEtag = req.headers['if-none-match'];
  if (clientEtag === `"${etag}"`) {
    return res.status(304).end(); // Not Modified
  }

  res.json(user);
});

// Более простой способ с встроенной поддержкой Express
app.get('/api/users/:id', async (req, res) => {
  const user = await db.getUser(req.params.id);
  res.json(user); // Express автоматически генерирует ETag
});

Last-Modified

// Использование Last-Modified
app.get('/api/users/:id', async (req, res) => {
  const user = await db.getUser(req.params.id);

  // Устанавливаем Last-Modified на основе updated_at
  const lastModified = new Date(user.updated_at);
  res.set('Last-Modified', lastModified.toUTCString());

  // Проверяем If-Modified-Since
  const ifModifiedSince = req.headers['if-modified-since'];
  if (ifModifiedSince) {
    const modifiedSince = new Date(ifModifiedSince);
    if (lastModified <= modifiedSince) {
      return res.status(304).end(); // Not Modified
    }
  }

  res.json(user);
});

Комбинация заголовков

// Оптимальная настройка HTTP кэширования
app.get('/api/users/:id', async (req, res) => {
  const user = await db.getUser(req.params.id);
  const lastModified = new Date(user.updated_at);

  // Проверка Last-Modified
  const ifModifiedSince = req.headers['if-modified-since'];
  if (ifModifiedSince && lastModified <= new Date(ifModifiedSince)) {
    res.status(304).end();
    return;
  }

  // Устанавливаем все заголовки для кэширования
  res.set({
    'Cache-Control': 'public, max-age=300, must-revalidate',
    'Last-Modified': lastModified.toUTCString(),
    'ETag': `"${generateETag(user)}"`
  });

  res.json(user);
});

✅ Преимущества HTTP Caching:

2. Application-level Caching 💾

Application-level кэширование — это кэширование на уровне приложения, где данные хранятся в памяти процесса или отдельном кэш-сервере.

In-Memory кэширование в Node.js

// Простой in-memory кэш
class SimpleCache {
  constructor() {
    this.cache = new Map();
    this.ttl = 5 * 60 * 1000; // 5 минут
  }

  set(key, value, ttl = this.ttl) {
    const expireAt = Date.now() + ttl;
    this.cache.set(key, { value, expireAt });
  }

  get(key) {
    const item = this.cache.get(key);
    if (!item) return null;

    if (Date.now() > item.expireAt) {
      this.cache.delete(key);
      return null;
    }

    return item.value;
  }

  delete(key) {
    this.cache.delete(key);
  }

  clear() {
    this.cache.clear();
  }
}

const cache = new SimpleCache();

// Использование в API
app.get('/api/users/:id', async (req, res) => {
  const { id } = req.params;
  const cacheKey = `user:${id}`;

  // Проверяем кэш
  const cached = cache.get(cacheKey);
  if (cached) {
    return res.json(cached);
  }

  // Загружаем из БД
  const user = await db.getUser(id);
  
  // Сохраняем в кэш
  cache.set(cacheKey, user);
  
  res.json(user);
});

LRU (Least Recently Used) кэш

// Установка: npm install lru-cache
const LRU = require('lru-cache');

const cache = new LRU({
  max: 500, // Максимум 500 записей
  maxAge: 1000 * 60 * 5 // TTL 5 минут
});

app.get('/api/users/:id', async (req, res) => {
  const { id } = req.params;
  const cacheKey = `user:${id}`;

  // Проверяем кэш
  const cached = cache.get(cacheKey);
  if (cached) {
    return res.json(cached);
  }

  // Загружаем из БД
  const user = await db.getUser(id);
  
  // Сохраняем в кэш
  cache.set(cacheKey, user);
  
  res.json(user);
});

Middleware для автоматического кэширования

// Express middleware для кэширования
const cacheMiddleware = (duration = 300) => {
  return async (req, res, next) => {
    // Только для GET запросов
    if (req.method !== 'GET') {
      return next();
    }

    const cacheKey = `${req.method}:${req.originalUrl}`;
    
    // Проверяем кэш
    const cached = cache.get(cacheKey);
    if (cached) {
      return res.json(cached);
    }

    // Сохраняем оригинальный res.json
    const originalJson = res.json.bind(res);
    
    // Переопределяем res.json для кэширования
    res.json = function(data) {
      cache.set(cacheKey, data, duration * 1000);
      return originalJson(data);
    };
    
    next();
  };
};

// Использование
app.get('/api/users', cacheMiddleware(300), getUserHandler);
app.get('/api/products', cacheMiddleware(600), getProductsHandler);

3. Redis кэширование 🔴

Redis — популярная система распределенного кэширования, которая позволяет использовать кэш в нескольких инстансах приложения.

Настройка Redis

// Установка: npm install redis
const redis = require('redis');

const client = redis.createClient({
  host: 'localhost',
  port: 6379,
  // password: 'your-password'
});

client.on('error', (err) => {
  console.error('Redis Client Error', err);
});

client.connect();

Базовые операции с Redis

// Простое кэширование
async function getCachedUser(userId) {
  const cacheKey = `user:${userId}`;
  
  // Проверяем кэш
  const cached = await client.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Загружаем из БД
  const user = await db.getUser(userId);
  
  // Сохраняем в кэш на 5 минут
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

// Кэширование с проверкой
async function getUsersWithCache() {
  const cacheKey = 'users:list';
  
  const cached = await client.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  const users = await db.getUsers();
  await client.setEx(cacheKey, 300, JSON.stringify(users));
  
  return users;
}

Продвинутые возможности Redis

// Hash для структурированных данных
async function cacheUser(user) {
  const cacheKey = `user:${user.id}`;
  await client.hSet(cacheKey, {
    id: user.id,
    name: user.name,
    email: user.email,
    updated_at: user.updated_at
  });
  await client.expire(cacheKey, 300);
}

// Получение из hash
async function getCachedUserFromHash(userId) {
  const cacheKey = `user:${userId}`;
  const user = await client.hGetAll(cacheKey);
  return Object.keys(user).length > 0 ? user : null;
}

// Список для кэширования коллекций
async function cacheUserList(users) {
  const cacheKey = 'users:ids';
  await client.del(cacheKey); // Очищаем старый список
  
  for (const user of users) {
    await client.lPush(cacheKey, JSON.stringify(user));
    await client.setEx(`user:${user.id}`, 300, JSON.stringify(user));
  }
  await client.expire(cacheKey, 300);
}

// Инкремент для счетчиков
async function incrementViewCount(postId) {
  const cacheKey = `post:${postId}:views`;
  return await client.incr(cacheKey);
}

Redis с Pub/Sub для инвалидации

// Publisher для инвалидации кэша
async function invalidateUserCache(userId) {
  await client.del(`user:${userId}`);
  await client.publish('cache-invalidation', JSON.stringify({
    type: 'user',
    id: userId
  }));
}

// Subscriber для синхронизации кэша
const subscriber = redis.createClient();
await subscriber.connect();

await subscriber.subscribe('cache-invalidation', (message) => {
  const { type, id } = JSON.parse(message);
  
  if (type === 'user') {
    // Инвалидируем кэш на всех инстансах
    client.del(`user:${id}`);
  }
});

✅ Преимущества Redis:

4. Cache Invalidation (Инвалидация кэша) 🔄

Инвалидация кэша — критически важный аспект кэширования. Неправильная инвалидация может привести к отображению устаревших данных.

TTL (Time To Live)

// Простая стратегия с TTL
async function getUsersWithTTL() {
  const cacheKey = 'users:list';
  const cached = await client.get(cacheKey);
  
  if (cached) {
    return JSON.parse(cached);
  }
  
  const users = await db.getUsers();
  // Кэш на 5 минут
  await client.setEx(cacheKey, 300, JSON.stringify(users));
  
  return users;
}

Ручная инвалидация

// Инвалидация при обновлении данных
app.put('/api/users/:id', async (req, res) => {
  const { id } = req.params;
  
  // Обновляем в БД
  const updatedUser = await db.updateUser(id, req.body);
  
  // Инвалидируем кэш
  await client.del(`user:${id}`);
  await client.del('users:list'); // Инвалидируем список
  
  res.json(updatedUser);
});

app.delete('/api/users/:id', async (req, res) => {
  const { id } = req.params;
  
  await db.deleteUser(id);
  
  // Инвалидируем все связанные кэши
  await client.del(`user:${id}`);
  await client.del('users:list');
  
  res.status(204).end();
});

Tag-based инвалидация

// Инвалидация по тегам
async function cacheWithTags(key, value, tags, ttl = 300) {
  // Сохраняем данные
  await client.setEx(key, ttl, JSON.stringify(value));
  
  // Сохраняем теги
  for (const tag of tags) {
    await client.sAdd(`tag:${tag}`, key);
  }
}

async function invalidateByTag(tag) {
  const keys = await client.sMembers(`tag:${tag}`);
  
  if (keys.length > 0) {
    await client.del(...keys);
  }
  
  await client.del(`tag:${tag}`);
}

// Использование
await cacheWithTags(
  'user:123',
  user,
  ['users', 'user:123', 'profile:456'],
  300
);

// Инвалидируем все пользователи
await invalidateByTag('users');

Event-based инвалидация

// Инвалидация через события
class CacheManager {
  constructor() {
    this.invalidationHandlers = new Map();
  }

  onInvalidation(event, handler) {
    if (!this.invalidationHandlers.has(event)) {
      this.invalidationHandlers.set(event, []);
    }
    this.invalidationHandlers.get(event).push(handler);
  }

  async invalidate(event, data) {
    const handlers = this.invalidationHandlers.get(event);
    if (handlers) {
      await Promise.all(handlers.map(handler => handler(data)));
    }
  }
}

const cacheManager = new CacheManager();

// Регистрируем обработчики
cacheManager.onInvalidation('user:updated', async ({ userId }) => {
  await client.del(`user:${userId}`);
  await client.del('users:list');
});

// Вызываем при обновлении
app.put('/api/users/:id', async (req, res) => {
  const updatedUser = await db.updateUser(req.params.id, req.body);
  
  await cacheManager.invalidate('user:updated', {
    userId: req.params.id
  });
  
  res.json(updatedUser);
});

5. Cache Patterns (Паттерны кэширования) 🎯

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

Cache-Aside (Lazy Loading)

// Cache-Aside паттерн
async function getUserCacheAside(userId) {
  const cacheKey = `user:${userId}`;
  
  // 1. Проверяем кэш
  const cached = await client.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // 2. Если нет в кэше, загружаем из БД
  const user = await db.getUser(userId);
  
  // 3. Сохраняем в кэш для следующих запросов
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

Cache-Aside:

Применение: Наиболее популярный паттерн. Приложение само управляет кэшем. Плюсы: Простота, гибкость, отказоустойчивость. Минусы: Возможна cache miss на первом запросе.

Write-Through

// Write-Through паттерн
async function createUserWriteThrough(userData) {
  // 1. Записываем в БД
  const user = await db.createUser(userData);
  
  // 2. Одновременно записываем в кэш
  const cacheKey = `user:${user.id}`;
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

async function updateUserWriteThrough(userId, userData) {
  // 1. Обновляем в БД
  const user = await db.updateUser(userId, userData);
  
  // 2. Обновляем в кэше
  const cacheKey = `user:${userId}`;
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

Write-Through:

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

Write-Back (Write-Behind)

// Write-Back паттерн
async function createUserWriteBack(userData) {
  const userId = generateId();
  const user = { id: userId, ...userData };
  
  // 1. Сначала записываем в кэш (быстро)
  const cacheKey = `user:${userId}`;
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  // 2. Асинхронно записываем в БД (в фоне)
  db.createUser(user).catch(console.error);
  
  return user;
}

// Периодическая синхронизация с БД
setInterval(async () => {
  const pendingWrites = await client.keys('pending:write:*');
  
  for (const key of pendingWrites) {
    const data = await client.get(key);
    await db.save(JSON.parse(data));
    await client.del(key);
  }
}, 60000); // Каждую минуту

Write-Back:

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

Refresh-Ahead

// Refresh-Ahead паттерн
async function getUserRefreshAhead(userId) {
  const cacheKey = `user:${userId}`;
  
  // Проверяем кэш
  const cached = await client.get(cacheKey);
  
  if (cached) {
    const data = JSON.parse(cached);
    
    // Проверяем TTL - если скоро истечет, обновляем заранее
    const ttl = await client.ttl(cacheKey);
    if (ttl < 60) { // Меньше минуты до истечения
      // Обновляем в фоне
      db.getUser(userId).then(user => {
        client.setEx(cacheKey, 300, JSON.stringify(user));
      });
    }
    
    return data;
  }
  
  // Если нет в кэше, загружаем из БД
  const user = await db.getUser(userId);
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

6. Стратегии по типам данных 📊

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

Тип данных TTL Стратегия Инвалидация
Статические данные 24 часа Write-Through При изменении
Пользовательские данные 5-15 минут Cache-Aside При обновлении
Списки/Каталоги 10-30 минут Cache-Aside Tag-based
Реальные данные Не кэшировать - -
Аналитика/Статистика 1 час Write-Back TTL
Поисковые результаты 15 минут Cache-Aside TTL

Примеры реализации

// Кэширование статических данных
async function getStaticConfig() {
  const cacheKey = 'config:static';
  const cached = await client.get(cacheKey);
  
  if (cached) {
    return JSON.parse(cached);
  }
  
  const config = await db.getStaticConfig();
  // Кэш на 24 часа
  await client.setEx(cacheKey, 86400, JSON.stringify(config));
  
  return config;
}

// Кэширование пользовательских данных
async function getUserData(userId) {
  const cacheKey = `user:${userId}`;
  const cached = await client.get(cacheKey);
  
  if (cached) {
    return JSON.parse(cached);
  }
  
  const user = await db.getUser(userId);
  // Кэш на 5 минут
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

// Кэширование списков с инвалидацией по тегам
async function getProductsList() {
  const cacheKey = 'products:list';
  const cached = await client.get(cacheKey);
  
  if (cached) {
    return JSON.parse(cached);
  }
  
  const products = await db.getProducts();
  await client.setEx(cacheKey, 1800, JSON.stringify(products));
  
  // Добавляем тег для инвалидации
  await client.sAdd('tag:products', cacheKey);
  
  return products;
}

Best Practices для кэширования 🌟

✅ 10 правил эффективного кэширования:

  1. Определите что кэшировать — частые запросы, тяжелые вычисления
  2. Выберите правильный TTL — баланс между свежестью и производительностью
  3. Используйте ключи кэша правильно — уникальные, понятные, версионированные
  4. Реализуйте инвалидацию — при изменении данных
  5. Мониторьте hit rate — процент попаданий в кэш
  6. Обрабатывайте cache miss — graceful degradation
  7. Используйте многоуровневое кэширование — HTTP + Application + Redis
  8. Кэшируйте результаты вычислений — не только данные из БД
  9. Тестируйте на production — измеряйте реальную эффективность
  10. Документируйте стратегию — для команды разработки

Мониторинг кэширования

// Метрики кэширования
class CacheMetrics {
  constructor() {
    this.hits = 0;
    this.misses = 0;
  }

  recordHit() {
    this.hits++;
  }

  recordMiss() {
    this.misses++;
  }

  getHitRate() {
    const total = this.hits + this.misses;
    return total > 0 ? (this.hits / total) * 100 : 0;
  }

  getStats() {
    return {
      hits: this.hits,
      misses: this.misses,
      hitRate: `${this.getHitRate().toFixed(2)}%`,
      total: this.hits + this.misses
    };
  }
}

const cacheMetrics = new CacheMetrics();

// Использование с метриками
async function getUserWithMetrics(userId) {
  const cacheKey = `user:${userId}`;
  const cached = await client.get(cacheKey);
  
  if (cached) {
    cacheMetrics.recordHit();
    return JSON.parse(cached);
  }
  
  cacheMetrics.recordMiss();
  const user = await db.getUser(userId);
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

// Endpoint для метрик
app.get('/api/cache/metrics', (req, res) => {
  res.json(cacheMetrics.getStats());
});

Чек-лист кэширования

☐ Определены данные для кэширования
☐ Выбраны правильные TTL для каждого типа данных
☐ Реализована инвалидация кэша
☐ Настроен мониторинг hit rate
☐ Обрабатываются ошибки кэша
☐ Используются понятные ключи кэша
☐ Реализовано логирование кэширования
☐ Протестировано на production данных
☐ Настроены алерты на низкий hit rate
☐ Документирована стратегия кэширования

Заключение

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

💡 Ключевые выводы:

⚠️ Частые ошибки:

Создайте Mock API за 2 минуты

Используйте Mock API для тестирования различных стратегий кэширования без воздействия на production системы. LightBox API позволяет быстро протестировать кэширование с разными TTL и сценариями.

Попробовать бесплатно →
← Вернуться к статьям