Основные понятия
Кэширование
Узнайте о кэшировании и когда использовать его на интервью по System Design
В задачах по System Design кэширование упоминается почти всегда, когда системе нужно выдерживать большой объем чтений. База данных становится узким местом, задержка начинает расти, и интервьюер ждет, когда мы скажем слово "кэш".
Чтение профиля пользователя из PostgreSQL может занимать 50 мс, а чтение из in-memory кэша, такого как Redis, - всего 1 мс. Это ускорение примерно в 50 раз. Базы данных хранят данные на диске, и запросы к ним требуют дисковых операций. А доступ к оперативной памяти гораздо менее затратен.
Кэши критически важны для масштабируемых систем. Они снижают нагрузку на базу данных и значительно уменьшают задержку. Но вместе с этим они создают новые проблемы, связанные с инвалидацией и обработкой сбоев.
В этой статье мы разберем основы кэширования, поговорим о том, когда и где его использовать, какие проблемы встречаются чаще всего и как правильно рассказать о кэшировании на собеседованиях.
Где кэшировать
Когда большинство инженеров слышит слово "кэширование", они сразу представляют Redis или Memcached между приложением и базой данных. Это самый распространенный тип кэша и именно о нем интервьюеры думают в первую очередь.
Но кэширование встречается на нескольких уровнях системы. Браузеры кэшируют. CDN кэшируют. Приложения кэшируют. Даже у баз данных есть встроенные слои кэширования.
Давайте посмотрим на основные места, где можно кэшировать данные, зачем нужен каждый вариант и когда он действительно уместен.
Внешний кэш
Внешний кэш - это отдельный сервис, с которым приложение общается по сети. Мы сохраняем часто используемые данные в Redis или Memcached, чтобы не обращаться к базе данных каждый раз.
Внешние кэши хорошо масштабируются, потому что все серверы приложения могут использовать один и тот же кэш. Они также поддерживают политики вытеснения, такие как LRU, и истечение срока жизни (Time to Live, TTL), поэтому объем используемой памяти остается под контролем.
На собеседованиях по системному дизайну внешний кэш с Redis - это ответ по умолчанию, когда речь заходит о стратегии кэширования. Интервьюеры ожидают, что мы упомянем его почти в любой высоконагруженной системе. Начинайте с него и только потом добавляйте другие виды кэширования, такие как CDN или кэш на стороне клиента, если этого требует задача.
CDN
CDN - это географически распределенная сеть серверов, которая кэширует контент ближе к пользователям. Вместо того чтобы каждый запрос шел к основному серверу, CDN хранит копии контента на edge-серверах по всему миру.
Современные CDN, такие как Cloudflare, Fastly и Yandex Cloud CDN, могут кэшировать гораздо больше, чем просто статические файлы. Они могут кэшировать публичные API-ответы, HTML-страницы и даже выполнять логику на edge-узлах для персонализации или проверок безопасности до того, как запрос дойдет до наших серверов. Но самый распространенный и самый полезный сценарий для CDN по-прежнему связан с доставкой медиафайлов.
Как это работает:
- Пользователь запрашивает изображение из нашего приложения.
- Запрос уходит на ближайший edge-сервер CDN.
- Если изображение уже есть в кэше, оно возвращается сразу.
- Если нет, CDN запрашивает его с основного сервера, сохраняет у себя и возвращает пользователю.
- Будущие пользователи в этом регионе получают изображение мгновенно с CDN.
Без CDN каждый запрос за изображением идет к основному серверу. Если сервер расположен в Санкт-Петербурге, а пользователь находится в Якутске, это может добавлять сотни миллисекунд задержки на каждый запрос. С CDN то же изображение отдается с ближайшего edge-сервера за 20-40 мс. Разница огромная.
Хотя современные CDN умеют кэшировать API-ответы и динамический контент, на собеседовании безопаснее всего упомянуть CDN, когда система отдает статические медиафайлы при масштабировании. Начните именно с этого сценария и расширяйте ответ только если того требует задача.
Кэш на стороне клиента
Кэш на стороне клиента хранит данные максимально близко к тому, кто их
запрашивает, чтобы избежать лишних сетевых вызовов. Обычно это устройство
пользователя: браузер с HTTP-кэшем и localStorage или мобильное приложение с
локальной памятью.
Но это может быть и кэш внутри клиентской библиотеки. Например, Redis-клиенты кэшируют метаданные кластера: какие узлы есть в кластере и какие слоты к ним привязаны. За счет этого клиент может сразу отправить запрос на нужный узел и не опрашивать кластер при каждой операции.
Когда речь идет о кэшировании для конечного пользователя, у бэкенда мало контроля над ситуацией. Данные могут устареть, а инвалидация становится сложнее. Приложение Strava хранит данные о тренировке на устройстве, пока пользователь офлайн, а потом синхронизирует их.
In-process кэш
Многие упускают из виду тот факт, что серверы работают на машинах с большим объемом памяти. Мы можем использовать эту память, чтобы кэшировать данные прямо внутри процесса приложения, а не каждый раз обращаться к Redis или к базе данных.
Идея проста: если сервис снова и снова запрашивает одни и те же небольшие куски данных, их стоит хранить в локальном кэше внутри процесса. Чтение из локальной памяти еще быстрее, чем чтение из Redis, потому что оно не требует сетевого вызова.
Такая легковесная форма кэширования особенно хорошо подходит для небольших наборов данных, которые запрашиваются часто:
- значения конфигурации;
- feature flags;
- небольшие справочные данные;
- горячие ключи;
- счетчики лимитов запросов (rate limiting);
- предварительно вычисленные значения.
In-process кэш очень быстрый, но у него есть очевидные ограничения. У каждого экземпляра бэкенда свой собственный кэш, поэтому данные не разделяются между серверами. Если один экземпляр обновил или инвалидировал значение в кэше, остальные об этом не узнают.
Используйте in-process кэш для небольших, часто запрашиваемых значений, которые редко меняются. Он отлично подходит как ускоряющий слой, но не заменяет Redis. На собеседованиях по System Design его лучше упоминать только как дополнительную оптимизацию, после того как вы уже предложили внешний кэш.
Архитектуры кэширования
Не все кэши работают одинаково. То, как мы читаем из кэша и пишем в него, влияет на производительность, согласованность и сложность системы. Ниже - четыре базовых паттерна кэширования, которые стоит знать для интервью по System Design.
Cache-Aside (ленивая загрузка)
Это самый распространенный паттерн кэширования и именно его стоит предлагать по умолчанию на собеседованиях.
Как это работает:
- Приложение сначала проверяет кэш.
- Если данные есть в кэше, оно сразу их возвращает.
- Если данных нет, приложение читает их из базы, кладет в кэш и возвращает результат.
Cache-aside кэширует данные только тогда, когда они действительно понадобились, поэтому кэш не разрастается без нужды. Минус в том, что промах кэша добавляет дополнительную задержку.
Если вы запомните только один паттерн кэширования для собеседований, пусть это будет cache-aside.
Write-Through кэширование
При write-through кэшировании приложение пишет только в кэш. После этого кэш синхронно записывает данные в базу данных и только потом возвращает ответ приложению. Операция записи считается завершенной, только когда обновлены и кэш, и база.
На практике для этого нужна реализация кэша, которая поддерживает write-through, например библиотека кэширования с плагином для работы с хранилищем. Когда мы пишем в кэш, библиотека сама вызывает логику записи в базу до того, как подтвердит операцию. Redis не поддерживает write-through нативно, поэтому этот паттерн обычно приходится реализовывать на уровне приложения или фреймворка.
Компромисс здесь в том, что записи становятся медленнее: приложению нужно ждать, пока обновятся и кэш, и база данных. Кроме того, write-through может засорять кэш данными, которые потом вообще никто не прочитает.
Write-through также не избавляет от проблемы двойной записи. Если обновление кэша успело пройти, а запись в базу завершилась с ошибкой, или наоборот, состояние системы оказывается несогласованным. Нужны механизмы повторных попыток, обработка ошибок или готовность признать, что без распределенных транзакций идеальной согласованности добиться трудно.
На интервью по System Design write-through встречается реже, чем cache-aside, потому что он требует более специализированной инфраструктуры кэширования и все равно оставляет пограничные случаи с согласованностью.
Используйте этот паттерн, когда чтения всегда должны возвращать свежие данные, а система может пережить немного более медленные записи.
Write-Behind (Write-Back) кэширование
При write-behind кэшировании приложение пишет только в кэш. Дальше кэш пакетирует изменения и асинхронно записывает их в базу данных в фоне.
Это делает операции записи очень быстрыми, но добавляет риск. Если кэш упадет до того, как успеет сбросить данные, часть записей может потеряться. Этот подход подходит для нагрузок, где редкая потеря данных допустима.
Используйте его, когда нужна высокая пропускная способность записи и согласованность в конечном счете приемлема. Это частый вариант для аналитики и конвейеров метрик.
Read-Through кэширование
При read-through кэшировании кэш становится "умным" прокси. Приложение вообще не общается с базой данных напрямую. Если в кэше нет нужного значения, сам кэш забирает его из базы, сохраняет и возвращает результат.
Это эквивалент write-through для чтений. В обоих случаях кэш выступает посредником, который берет на себя работу с базой данных. Read-through управляет чтениями, write-through - записями. На практике системы нередко совмещают оба паттерна.
Такой подход централизует логику кэширования, но добавляет сложности и обычно требует специализированной библиотеки или отдельного сервиса. В реальных системах он встречается реже, чем cache-aside.
CDN - это одна из форм read-through кэша. Когда CDN получает промах кэша, он забирает данные с основного сервера, кладет их в кэш и возвращает пользователю. Есть очень мало причин предлагать этот паттерн на интервью, если только вы не обсуждаете CDN или подобную инфраструктуру.
Политики вытеснения из кэша
Память кэша ограничена, поэтому системе нужна стратегия, которая определяет, какие записи удалять, когда кэш заполняется. Такие стратегии называют политиками вытеснения.
LRU (least recently used, наименее недавно использовавшиеся)
LRU вытесняет элемент, к которому дольше всего не обращались. Чтобы быстро находить такой элемент, система обычно отслеживает порядок обращений с помощью связного списка или кольцевого буфера.
Это вариант по умолчанию для многих систем, потому что он хорошо работает на типичных нагрузках, где недавно использованные данные с высокой вероятностью понадобятся снова.
LFU (least frequently used, наименее часто использовавшиеся)
LFU вытесняет элемент, к которому обращались реже всего. Для этого кэш хранит счетчик обращений для каждого ключа и удаляет запись с минимальной частотой. Некоторые реализации используют приближенный LFU, чтобы не платить высокую цену за точный учет частоты.
Этот подход хорошо работает, когда часть ключей долго остается стабильно популярной, например у трендовых видео или постов в соцсети.
FIFO (first in first out, первым пришел - первым ушел)
FIFO вытесняет самый старый элемент в кэше только по времени добавления. Такой вариант легко реализовать через обычную очередь, но он полностью игнорирует паттерны доступа.
Из-за этого FIFO может удалять элементы, которые все еще активно читаются, поэтому в реальных системах он используется редко и обычно только в самых простых слоях кэширования.
TTL (time to live, время жизни)
TTL сам по себе не является политикой вытеснения. Он просто задает срок жизни для каждого ключа и удаляет устаревшие записи. Чаще всего TTL комбинируют с LRU или LFU, чтобы одновременно контролировать свежесть данных и расход памяти.
TTL обязателен, когда данные со временем должны обновляться, например для ответов API или токенов сессии.
Типичные проблемы кэширования
Кэширование ускоряет систему, но вместе с этим создает новые режимы сбоев. Эти проблемы постоянно встречаются в реальных системах и интервьюеры часто используют их, чтобы проверить, понимаете ли вы не только пользу кэша, но и его компромиссы. Если вы предлагаете кэш на собеседовании, стоит сразу показать, что вы понимаете и эти граничные случаи.
Эффект толпы (Cache Stampede)
Эффект толпы возникает, когда популярная запись в кэше истекает и множество запросов одновременно пытается пересобрать ее заново. Возникает короткое окно, иногда всего на секунду, в котором каждый запрос промахивается мимо кэша и идет напрямую в базу данных. Вместо одного запроса база внезапно получает сотни или тысячи, и этого уже достаточно, чтобы ее перегрузить.
Представьте, что система кэширует главную ленту с TTL в 60 секунд. Ровно в 12:01:00 кэш истекает, и каждый запрос в этот момент идет мимо кэша и попадает в базу данных. Если трафик высокий, этот всплеск может перегрузить базу и спровоцировать каскадные сбои.
Как с этим работать:
- Объединение запросов (request coalescing / single flight): только один запрос пересобирает кэш, а остальные ждут результат. Это самый эффективный вариант.
- Прогрев кэша (cache warming): популярные ключи обновляются заранее, до истечения TTL. Это помогает только если мы используем TTL. Если кэш инвалидируется при записи, прогрев не защищает от эффекта толпы.
Согласованность кэша
Проблемы согласованности кэша - одна из самых частых тем на собеседованиях по System Design. Они возникают, когда кэш и база данных возвращают разные значения для одних и тех же данных. Обычно это происходит потому, что система читает из кэша, а пишет сначала в базу данных. В этот момент появляется окно, в котором в кэше все еще лежит устаревшее значение.
Например, если пользователь обновил фото профиля, новое значение уже записано в базу, но старое изображение все еще может оставаться в кэше. Другие пользователи будут видеть устаревшую картинку до тех пор, пока кэш не обновится.
Идеального решения здесь нет. Нужно выбирать стратегию, исходя из того, насколько свежими обязаны быть данные.
Как с этим работать:
- Инвалидация кэша при записи: после обновления базы удаляем запись из кэша, чтобы при следующем чтении она была заполнена свежими данными.
- Короткий TTL при допустимом устаревании: позволяем данным ненадолго устаревать, если согласованность в конечном счете приемлема.
- Принять согласованность в конечном счете: для лент, метрик и аналитики небольшая задержка обычно допустима.
Горячие ключи
Горячий ключ - это запись в кэше, которая получает несоразмерно большой объем трафика по сравнению со всеми остальными. Такой ключ может перегрузить отдельный узел кэша или один Redis-шард и стать узким местом во всей системе.
Например, если вы проектируете Twitter и все одновременно открывают профиль
Тейлор Свифт, ключ с ее пользовательскими данными, например user:taylorswift,
может получать миллионы запросов в секунду.
Как с этим работать:
- Реплицировать горячие ключи: хранить одно и то же значение на нескольких узлах кэша и балансировать чтения между ними.
- Добавить локальный кэш: держать чрезвычайно горячие значения внутри процесса, чтобы не перегружать Redis каждым запросом.
- Применить ограничение скорости (rate limiting): замедлять трафик для отдельных ключей.
Кэширование на собеседованиях по System Design
Кэширование всплывает почти на каждом собеседовании по системному дизайну, поэтому важно понимать, когда его добавлять и как обоснованно объяснять свое решение.
Когда стоит добавлять кэш
Не стоит начинать сразу с кэша. Сначала нужно показать, почему он вообще нужен.
Имеет смысл говорить о кэшировании, когда вы видите одну из следующих проблем:
Нагрузка смещена в чтения: "У нас 10 млн ежедневно активных пользователей, каждый делает по 20 запросов в день. Это 200 млн чтений, которые идут в базу. Даже с индексами мы получаем 20-50 мс на запрос. Кэш сокращает задержку до менее чем 2 мс и снимает большую часть нагрузки с базы".
Дорогостоящие запросы: "Персонализированная лента пользователя требует объединять данные о постах, подписчиках и лайках из нескольких таблиц. Такой запрос занимает 400 мс. Мы можем кэшировать готовую ленту на 60 секунд и отдавать ее из Redis за 1 мс".
Высокая загрузка CPU базы: "CPU базы уже загружен на 80% в пиковые часы только из-за чтений. Одни и те же запросы выполняются снова и снова. Кэширование горячих запросов может сократить нагрузку на базу на 70-80%".
Жесткие требования по задержке: "Нам нужен ответ API быстрее 10 мс. Запросы к базе занимают 20-50 мс. Значит, без кэша мы не уложимся".
Паттерн здесь простой: сначала нужно назвать проблему производительности, оценить ее хотя бы грубо числами и затем объяснить, как кэширование ее решает. Для ориентира можно использовать важные цифры.
Как добавлять кэш
После того как мы обосновали необходимость кэша, полезно пройтись по стратегии кэширования системно:
1. Определить узкое место
Сначала нужно назвать конкретную проблему, которую кэширование решит. Это нагрузка на базу? Большая задержка запросов? Дорогостоящие вычисления? Здесь важно быть конкретными и объяснить, что именно неэффективно и почему.
"Запросы на профиль пользователя идут в базу 500 раз в секунду в пиковые часы. Каждый запрос занимает 30 мс. Это и есть наше узкое место".
2. Решить, что именно кэшировать
Кэшировать стоит не все подряд. Лучше выбрать данные, которые часто читаются, редко меняются и дороги в вычислении или загрузке.
"Мы будем кэшировать профили пользователей, потому что они читаются на каждой загрузке страницы, но обновляются только когда пользователь меняет настройки. Мы также будем кэшировать ленту трендовых постов, потому что она строится из тяжелых запросов с агрегацией, но ей достаточно обновляться раз в минуту".
Нужно сразу продумать и ключи кэша. Как мы будем искать данные в кэше? Для
профиля пользователя ключ может быть user:123:profile. Для глобальной ленты
трендовых постов подойдет trending:posts:global.
3. Выбрать архитектуру кэша
Паттерн кэширования должен соответствовать требованиям к согласованности. Write-through имеет смысл, когда нужна сильная согласованность. Write-behind подходит для нагрузок с большим объемом записи, если система может терпеть риск потери части данных и некоторую задержку.
"Я бы выбрал cache-aside. На чтении мы сначала проверяем Redis. Если данные там есть, сразу возвращаем их. Если нет, читаем из базы, сохраняем результат в Redis и потом отдаем клиенту".
Если система работает со статическим контентом, таким как изображения или видео, уместно упомянуть CDN. Если есть экстремально горячие ключи, стоит добавить in-process кэш как еще один оптимизационный слой.
4. Задать политику вытеснения
Нужно объяснить, как кэш будет контролировать размер. LRU - безопасный ответ по умолчанию.
"Мы используем LRU в Redis и ставим TTL в 10 минут на профили пользователей. Это не даст кэшу неограниченно расти и в то же время не позволит профилям слишком сильно устаревать. Если пользователь обновляет профиль, мы сразу инвалидируем запись в кэше".
5. Обсудить недостатки
Кэширование добавляет сложности. Хороший ответ показывает, что мы понимаем эти компромиссы.
Инвалидация кэша: как мы будем держать данные свежими? Мы инвалидируем данные в кэше при записи в базу, полагаемся на TTL или принимаем согласованность в конечном счете?
"Когда пользователь обновляет профиль, мы удаляем соответствующий ключ из кэша, чтобы при следующем чтении получить свежее значение из базы".
Отказы кэша: что произойдет, если Redis недоступен? Как отреагирует база данных на внезапно вернувшийся трафик?
"Если Redis недоступен, запросы переключатся на базу данных. Мы добавим предохранитель (circuit breaker), чтобы избежать каскадного сбоя. В крайнем случае можно оставить небольшой in-process кэш как аварийный слой".
Эффект толпы: что будет, если TTL популярного ключа истечет и тысяча запросов одновременно попытаются заново загрузить его?
"Для особенно популярных ключей мы можем использовать объединение запросов (request coalescing) или вероятностное раннее истечение (probabilistic early expiration), чтобы только один запрос обращался к базе, а остальные ждали готовый результат".
Не нужно перечислять все возможные проблемы. Достаточно выбрать одну-две, которые действительно релевантны именно этой системе, и показать, как вы будете с ними работать. Для Staff+ уровня лучше делать упор на важные, но не самые очевидные сценарии, а не тратить время на тривиальные вещи.
Заключение
Кэширование - это то, что мы делаем, когда чтение из базы данных слишком медленное или слишком дорогое. Мы держим часто запрашиваемые данные в быстрой памяти и за счет этого можем вообще не ходить в базу для большинства чтений.
Базовый компромисс очень простой. Кэши ускоряют чтения и снижают нагрузку на компоненты за ними, но добавляют сложности вокруг устаревания данных и инвалидации. Кэш и база могут временно расходиться. Если кэш падает, база данных может не выдержать всплеск трафика. Горячие ключи могут стать узким местом даже в распределенном кэше.
На собеседованиях тему кэширования стоит поднимать после того, как вы нашли узкое место в базе данных. Дальше нужно пройтись по тому, что именно вы будете кэшировать, какой паттерн используете, как настроите вытеснение и что случится при сбоях. CDN хорошо подходит для статических медиафайлов, а in-process кэш может быть полезным дополнительным слоем для экстремально горячих ключей.
И самое важное: не нужно кэшировать все подряд. Покажите, что вы понимаете, когда кэш действительно стоит своей сложности, а когда достаточно просто хорошо настроенной базы данных с индексами.