Кэш за 16 минут: проектируем эффективное кэширование

Кэширование – одна из важнейших практик в проектировании современных высоконагруженных IT-систем.

Что такое кэширование и для чего оно нужно?

Представим стартап с небольшим новостным сайтом. Система пока маленькая: приложение развернуто на одном сервере, есть одна база данных. Помимо новостного сервиса, есть несколько сторонних backend-ов (сервис погоды, сервис подсчета лайков к новостям).

Со временем приложение растет, количество пользователей увеличивается. Для справки с возрастающей нагрузкой, мы масштабируемся горизонтально (развертываем дополнительные экземпляры приложения и баз данных). Такая архитектура хорошо работает, пока трафик растет равномерно. Однако, при выходе эксклюзивной новости, все пользователи одновременно открывают страницу, комментируют и ставят лайки. Система не справляется: фронтенд шлет много запросов, страницы не открываются, сервисы не успевают отвечать. Один из паттернов, позволяющих эффективно работать в таких случаях – кэширование (caching).

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

Подходы к кэшированию

Кэширование – обширная тема. Рассмотрим основные подходы к его реализации, классифицируя их по нескольким признакам:

Расположение кэша:

  1. Браузерный кэш: хранится на уровне фронтенда, на устройстве пользователя.
  2. Прокси-кеш: размещается между фронтендом и приложением или между приложением и backend-ом. Фильтрует запросы, часть направляет по исходному пути, на часть отвечает кэшированными данными.
  3. Кэш в базе данных: реализуется с помощью плагинов или in-memory индексов некоторых СУБД.
  4. Кэш в памяти приложения: сохраняется в рамках выполняемого процесса и одного сервера. При перезапуске приложения кэш теряется. На нескольких серверах – на каждом свой кэш.
  5. Распределённый кэш: общий для нескольких экземпляров приложения. Часто реализуется внешним сервисом (Redis, Memcached) или библиотекой внутри приложения.

Алгоритм вытеснения:

  1. LRU (Least Recently Used): вытесняются давно неиспользуемые записи.
  2. LFU (Least Frequently Used): вытесняются редко используемые записи.
  3. MRU (Most Recently Used): вытесняются недавно использованные записи.

Порядок взаимодействия:

  1. Кэширование на стороне приложения: приложение обращается к кэшу. Если данные есть – используются, иначе – запрос к основному хранилищу.
  2. Сквозная запись (Write-through): данные записываются одновременно в кэш и в основное хранилище.
  3. Запись через кэш (Write-back/Write-behind): сначала обновляется кэш, потом основное хранилище.
  4. Сквозное чтение (Read-through): похоже на кэширование на стороне приложения, но координация запросов к базе происходит на стороне кэша.
  5. Упреждающее чтение (Read-ahead): данные помещаются в кэш на основе предположения о будущих запросах.

Пример приложения с кэшем

Рассмотрим проектирование кэша на уровне приложения на примере новостного сайта:

  1. Кэш в памяти приложения: простейшее решение. При запросе приложение сначала обращается к кэшу, затем – к базе данных и сохраняет данные в кэш.
  2. Распределённый кэш (например, Redis): новости для всех пользователей одинаковы, поэтому одна нода приложения записывает новости в кэш, другие – считывают.

Хранение данных в кэше

Каждая запись в кэше содержит три поля:

  • Ключ: обычно кортеж параметров запроса или базы данных.
  • Значение: сами данные (например, содержимое новости).
  • Время жизни (Time to Live): срок годности записи.

Оценка эффективности кэширования

Для оценки эффективности используются метрики:

  1. Hit Rate (процент попадания): количество попаданий в кэш / (количество попаданий + количество промахов).
  2. Сравнение количества элементов в кэше с количеством данных в исходном хранилище: помогает выявить некорректный ключ кэширования.
  3. Размер записи кэша: определяет ситуации, когда запись содержит больше данных, чем нужно.
  4. Количество вытесняемых записей в секунду (Evictions): высокое значение может указывать на слишком маленький размер кэша или неэффективный ключ.
  5. Задержка получения данных из кэша: должна быть меньше задержки получения данных из оригинального источника.
  6. Количество ошибок получения данных из кэша: детектирует сбои в системе кэширования.

Проблемы и решения при реализации кэширования

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

Дублирование запросов

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

Недоступность данных

Проблема возникает, когда свежих данных нет в кэше, а основной источник недоступен. Решение: паттерн Fallback cache. Дополнительный кэш хранит последний успешный ответ от основного источника дольше, чем основной кэш. Данные из него берутся только при недоступности основного кэша и основного источника. Можно реализовать, храня два значения TTL: время жизни данных и время жизни как fallback.

Тайм-ауты

Проблема возникает, когда кэш не дождался ответа от backend-а из-за тайм-аута. Решение: увеличение тайм-аута между кэшем и backend-ом, добавление тайм-аута между приложением и кэшем (маленького, чтобы приложение не ждало долго).

Объединение паттернов

Все рассмотренные паттерны можно объединить в один механизм:

  1. Приложение обращается к кэшу.
  2. Если данные есть – отображаются.
  3. Если запрос в полёте – ждём ответа (Single Flight).
  4. При получении данных из backend-а – отдаём данные приложению.
  5. При ошибке – пытаемся получить из fallback-кэша или откатываемся к предыдущей версии (Fallback cache).
  6. Если данных нет – отправляем запрос в backend.
  7. При получении данных – сохраняем в кэш и отдаём приложению.
  8. При ошибке – пытаемся получить из fallback-кэша или откатываемся к предыдущей версии (Fallback cache).

Кэширование – важная практика в проектировании высоконагруженных IT-систем. Оно увеличивает производительность и позволяет выдерживать пиковые нагрузки. Существует множество подходов к кэшированию, выбор зависит от задачи, пользовательского поведения и структуры данных. Оценка эффективности осуществляется через мониторинг метрик. Кэширование может принести не только пользу, но и проблемы, для решения которых применяются различные паттерны и подходы. Каждую систему кэширования необходимо проектировать с нуля, учитывая особенности пользовательского поведения и структуры данных.

Что будем искать? Например,программа