Введение
Кэширование ― одна из важнейших практик в проектировании современных высоконагруженных ИТ-систем. Статья позволит почерпнуть практический опыт проектирования механизма кэширования и будет интересна системным аналитикам, проектировщикам систем и архитекторам высоконагруженных систем.
В этой статье:
что такое кэширование и для чего оно нужно;
какие бывают подходы к кэшированию;
пример проектирования приложения с кэшем;
с какими проблемами можно столкнуться при реализации кэширования и как их решить.
Что такое кэширование и для чего оно нужно
Представим, что мы ― стартап и у нас есть небольшой новостной сайт. Система пока маленькая: бэкенд-приложение развёрнуто на одном сервере, есть одна база данных. Помимо новостного сервиса есть несколько сторонних бэкендов: внешние бэкенды (например, сервис погоды) и бэкенды смежных команд (например, сервис подсчета лайков к новостям).
Со временем приложение растёт и количество пользователей увеличивается. Чтобы справиться с возрастающей нагрузкой, мы решаем масштабироваться горизонтально: разворачиваем дополнительные экземпляры приложения и баз данных.
Такая архитектура хорошо работает, пока трафик растёт равномерно и мы успеваем масштабировать сервера. В какой-то момент мы выпускаем эксклюзивную резонансную новость. Все наши пользователи одновременно открывают страницу приложения с этой новостью, комментируют, ставят лайки. Система не справляется с такой нагрузкой: фронтенд шлёт очень много запросов на бэкенд, страницы не открываются, потому что сервисы не успевают возвращать ответы на большое количество одновременных запросов.
Один из паттернов, который можно использовать для эффективной работы в таких кейсах ― это кэширование.
Кэширование (caching) ― механизм оптимизации производительности ИТ-систем. Суть кэширования заключается в добавлении буфера между приложением и базой данных или приложением и сервисом для хранения часто используемых данных. Ключевая идея механизма состоит в том, чтобы иметь возможность получать данные намного быстрее, чем из основного источника данных. Кэш обычно гораздо меньше по размеру, чем основной источник, поэтому данные в кэше актуальны в пределах ограниченного периода времени.
Классификация подходов к кэшированию
Кэширование ― одна из самых сложных и объёмных тем в разработке ИТ-систем. Существует большое количество подходов к реализации кэширования в зависимости от деталей вашей задачи. Способов их классификации очень много, поэтому рассмотрим основные классификации подходов к кэшированию и наиболее известные виды этих подходов.
По порядку размещения кэша
В браузере (browser cache). Кэш будет сохраняться на уровне фронтенда и храниться на устройстве пользователя.
В прокси (proxy cache). Прокси может быть размещен между фронтендом и приложением или между приложением и бэкендом. Прокси будет фильтровать запросы: часть направлять по исходному пути, а на часть отвечать кэшированными данными.
В базе данных (database cache). Может быть реализован с помощью плагинов или in-memory индексов некоторых СУБД.
В памяти приложения (in-memory cache). Такой кэш сохраняется только в рамках выполняемого процесса и одного сервера. При перезапуске приложения кэш будет утерян. Если экземпляры приложения развёрнуты на нескольких серверах, на каждом сервере будет свой кэш.
Распределённый кэш (distributed cache). Распределённый кэш является общим для нескольких экземпляров приложения. Часто реализуется внешним сервисом, например, с помощью Redis или Memcached, но также может быть представлен библиотекой внутри приложения.
По порядку вытеснения кэша
Вытеснение давно неиспользуемых записей кэша (LRU, Least Recently Used). Из кэша вытесняются те записи, которые давно не использовались.
Вытеснение редко используемых записей кэша (LFU, Least Frequently Used). Из кэша вытесняются те записи, которые используются реже всего.
Вытеснение недавно использованных записей кэша (MRU, Most Recently Used). Из кэша вытесняются последние использованные записи.
По порядку взаимодействия с кэшем
Кэширование на стороне (Cache-Aside). Подход, при котором приложение обращается в кэш, и если данные есть в кэше, то используются кэшированные данные. Если данных в кэше нет ― приложение делает запрос к основному хранилищу.
Сквозная запись (Write-Through). Подход, при котором данные записываются одновременно в кэш и в основное хранилище данных.
Write-Behind. Подход, при котором сначала обновляется кэш, а потом ― основное хранилище данных.
Сквозное чтение (Read-Through). Подход, на первый взгляд похожий на кэширование на стороне: если данных в кэше нет, то данные берутся из базы данных. Важное отличие: координация запросов в базу происходит на стороне кэша, а не приложения.
Упреждающее чтение (Read-Ahead). Подход, при котором данные помещаются в кэш на основе предположения о том, какие данные будут запрошены следующими.
Пример приложения с кэшем
Архитектура приложения
Рассмотрим, как можно спроектировать кэш на уровне приложения на примере нашего новостного сайта.
Шаг 1. Добавляем кэш в памяти приложения (in-memory cache)
Самое простое решение ― реализовать хранение кэша в памяти приложения. При запросе с фронтенда приложение будет обращаться сначала в хранилище кэша. Если кэша в хранилище нет, то приложение будет обращаться в основную базу данных и сохранять данные в кэше.
Шаг 2. Добавляем распределенный кэш
Наш новостной сайт имеет одну важную особенность: новости для всех пользователей отображаются одинаковые. Это позволяет нам реализовать распределённый кэш (например в Redis). Так одна нода приложения сможет записывать новости в кэш, а другие ― читать.
В каком виде данные хранятся в кэше
Каждая запись в кэше содержит три поля.
Ключ (Key). Обычно ключом является кортеж (структура данных) из параметров запроса или параметров базы данных.
Значение (Value). Это сами данные, например, содержимое новости.
Время жизни (Time To Live). Это «срок годности» записи ― время, через которое запись нужно обновить или удалить.
Как оценить эффективность внедрения кэширования
Реализовать кэширование само по себе недостаточно. Необходимо также оценить, насколько кэширование эффективно работает. Для этого можно реализовать мониторинг.
Для мониторинга эффективности кэша можно использовать метрики.
Процент попадания (hitrate или hit ratio). Метрика говорит о том, сколько запросов в процентном выражении получают ответы из кэша и какой ― из базы данных. Чем большее количество запросов получают данные из кэша, тем лучше.
Процент попадания (hitrate) = Количество попаданий в кэш / (Количество попаданий в кэш + количество промахов)Сравнение количества элементов в кэше с количеством данных в исходном хранилище. Например, если количество пользователей увеличилось на 10%, а кэш пользователей увеличился в 2 раза ― это может быть показателем того, что используется некорректный ключ кэширования.
Размер записи кэша в байтах (средний размер, расчет перцентилей размера). Метрика позволяет определить ситуации, когда запись содержит больше данных чем нужно.
Количество записей, вытесняемых из кэша в секунду (evictions). Высокое количество может говорить о том, что размер кэша слишком маленький или о том, что выбран неэффективный ключ кэширования.
Задержка получения данных из кэша (latency) ― время, нужное на получение данных из кэша. Задержка получения данных из кэша должна быть меньше задержки получения данных из оригинального источника.
Количество ошибок получения данных из кэша в секунду. Метрика позволяет детектировать сбои на системах кэширования.
С какими проблемами можно столкнуться при реализации кэширования и как их решить
Важно понимать, что хотя кэширование ускоряет работу приложения, оно также усложняет его и может вызывать различные проблемы и даже ухудшать производительности приложения при неправильном подходе.
Рассмотрим некоторые проблемы, с которыми можно столкнуться при реализации кэширования.
Дублирование запросов в полете
Такая проблема возникает, когда:
с минимальной разницей во времени поступают одинаковые запросы;
нужных данных нет в кэше.
Так одинаковые запросы одновременно начинают нагружать базу данных.
Для решения проблемы можно использовать паттерн singleflight. Паттерн предполагает реализацию дополнительной функции, которая будет по некоторому набору параметров дедуплицировать запросы (устранять дубли).
Паттерн может применяться как к HTTP-запросам, так и к запросам к базе данных.
При применении паттерна уже перед началом запроса сохраняется факт того, что запрос по какому-то ключу находится в полете. Так дублирующие запросы в кэш не будут запускать новые запросы в основной источник, а будут дожидаться ответа на самый первый запрос.
Так как на нашем сайте все новости одинаковые, то ожидается много параллельных одинаковых запросов на открытие страницы с новостью. При таких условиях применение паттерна singleflight может давать до 50% hitrate, то есть снизить нагрузку в 2 раза.
Недоступность данных во время сбоя
Такая проблема возникает, когда:
свежих данных для запроса нет в кэше;
основной источник данных по каким-то причинам недоступен и не отвечает на запросы.
Пользователь увидит в приложении ошибку. Во многих случаях пользователю не обязательно видеть самые свежие данные, можно показать устаревшие с пояснением. Например, на нашем новостном сайте тексты новостей меняются не часто, лучше показать новость без последних правок, чем ошибку. Для таких случаев бывает полезно иметь альтернативный источник данных. Этот паттерн называется fallback.
Часто для реализации паттерна fallback используется подход fallback cache. Fallback cache ― это дополнительный кэш, в котором последний успешный ответ от основного источника хранятся намного дольше, чем в основном кэше.
Из этого дополнительного кэша данные берутся только в случаях, когда недоступны и свежий кэш и основной источник данных. Важно, что сохранение данных во второй кэш происходит при каждом получении свежих данных от бэкенда.
Паттерн fallback cache можно реализовать и другим способом: объединить два кэша в один.
Вместо одного TTL (Time To Live) можно хранить два значения. Первое ― время, в течение которого данные кэша валидны для обычного использования, второе ― время, в течение которого кэш можно использовать как дополнительный, когда не отвечает бэкенд.
Таймауты мешают наполнению кэша
Иногда таймауты могут мешать наполнению кэша. Проблема возникает, когда:
при обращении приложения за кэшем, данных в кэше нет;
бэкенд ответил таймаутом на запрос, но через время ответил данными;
кэш не дождался ответа от бэкенда.
Со временем это может привести к тому, что все данные кэша перестанут быть актуальными. Это вызовет повышение нагрузки на бэкенд и только усугубит сбой, который привел к первоначальному повышению времени ответа.
Часто эту проблему решают простым увеличением таймаута. Но бывают ситуации, в которых предпочтительнее не показать какую-то отдельную информацию, чем замедлить загрузку приложения полностью. В таких ситуациях решить проблему можно добавлением еще одного таймаута между приложением и кэшем.
Таймаут между приложением и кэшем должен быть маленьким, чтобы приложение не ждало данные слишком долго. Но между кэшем и бэкендом необходимо установить увеличенный таймаут, чтобы механизм кэширования дожидался данных максимально долго и кэш мог наполняться в фоне.
Объединяем паттерны
Все описанные паттерны можно объединить в общий механизм.
Как будет работать такой механизм:
Приложение обращается в кэш за данными.
Если данные есть в кэше, то приложение отображает данные кэша.
Если запрос сейчас в полёте, дожидаемся ответа от бэкенда (паттерн singleflight). Если данные получены из бэкенда ― то отдаём данные в приложение. Если получена ошибка, то пытаемся получить дополнительный кэш или откатиться на предыдущую версию кэша (паттерн fallback cache).
Если данных нет, то отправляем запрос в бэкенд и отмечаем, что запрос сейчас в полёте. Если данные получены, то сохраняем данные в кэш и отдаём в приложение. Если получена ошибка ― то пытаемся получить дополнительный кэш или откатиться на предыдущую версию элемента кэша (паттерн fallback cache).
Важно, что приведенная схема будет эффективно работать только при нагрузке определённого характера. В каждой системе кэширование необходимо проектировать с нуля, учитывая особенности пользовательского поведения и структуры данных.
Резюме
Кэширование ― важная практика в проектировании высоконагруженных ИТ-систем. Кэширование позволяет увеличить производительность веб-приложений и выдерживать пиковые нагрузки.
Кэширование ― одна из сложнейших задач в разработке ИТ-систем. Подходов к кэшированию очень много, подбирать необходимо исходя из задачи, пользовательского поведения, структуры данных.
Оценка эффективности кэширования строится на мониторинге метрики hitrate, задержки получения данных, объёма записей кэша и других.
Кэширование может принести не только пользу, но и проблемы. Для разрешения проблем кэширования можно применять различные паттерны и подходы.
Автор статьи — Дмитрий Пахомов
ИТ-архитектор с опытом работы более 10 лет.
Работает с высоконагруженными системами в финтехе.