Ctrl K
Урок

Кэш: где и как

Интуиция: холодильник на кухне

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

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

Уровни кэша: от пользователя к базе

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

Уровень Что хранит Задержка Как инвалидировать
Браузер статика, ответы с Cache-Control ~0 мс почти никак, только ждать TTL или менять URL (app.v42.js)
CDN картинки, JS, иногда публичные API-ответы 10–30 мс purge через API провайдера
Кэш в памяти процесса горячие мелкие данные, конфиги наносекунды у каждого инстанса своя копия, они расходятся
Кэш приложения (Redis/Memcached) профили, ленты, сессии ~1 мс DEL key, общий для всех инстансов
Кэш базы buffer pool, страницы в RAM внутри БД управляет сама БД

На собесе пройдись по уровням сверху вниз одной фразой: «Статику отдаю через CDN с долгим TTL и версионированными URL, горячие объекты держу в Redis, для совсем горячих конфигов подойдёт локальный in-memory кэш с коротким TTL». Это сразу показывает зрелость.

Cache-aside: стратегия по умолчанию

Самая частая схема. Приложение само управляет кэшем:

  1. Пришёл запрос, ищем ключ в Redis.
  2. Hit: сразу отдаём ответ.
  3. Miss: читаем из PostgreSQL, кладём в Redis с TTL, отдаём ответ.
  4. При записи обновляем базу и удаляем ключ из кэша. Следующее чтение подтянет свежие данные.
HTTPS1. GET key2. при промахе SELECT3. SET key TTL / DELUPDATEКлиентклиентБалансировщикLBAPI #1сервисAPI #2сервисRedis (кэш)кэшPostgreSQLБД
Cache-aside: приложение само читает кэш, при промахе идёт в БД и заполняет кэш

Почему ключ при записи удаляют, а не обновляют? Два параллельных апдейта могут записать в кэш значения в обратном порядке. Удаление безопаснее.

Посчитаем, зачем это всё

Пусть 10 000 чтений в секунду. Redis отвечает за 1 мс, БД за 10 мс, hit rate 90%.

Метрика Расчёт Итог
Запросов в БД 10 000 × 0.1 1 000 RPS вместо 10 000
Средняя задержка 0.9 × 1 + 0.1 × (1 + 10) ≈ 2 мс вместо 10
При hit rate 99% 10 000 × 0.01 100 RPS в БД

Каждая «девятка» hit rate снимает с базы ещё порядок нагрузки. Скажи это вслух на собесе, это сильный аргумент.

Четыре стратегии: сравнение

Стратегия Как работает Плюсы Минусы
Cache-aside приложение читает кэш, при промахе идёт в БД и кладёт результат в кэш просто; падение кэша не роняет систему; в кэше только нужные данные первый запрос медленный; логика кэша размазана по коду; возможна устаревшая выдача
Read-through при промахе кэш сам ходит в БД (через библиотеку или провайдер) код приложения чище кэш обязан уметь читать БД; первый промах всё равно медленный
Write-through запись идёт в кэш, кэш синхронно пишет в БД кэш всегда свежий запись медленнее (две операции); в кэш попадают данные, которые никто не читает
Write-back (write-behind) запись только в кэш, в БД уходит пачками асинхронно очень быстрая запись; меньше нагрузки на БД риск потерять данные, если кэш упал до сброса; сложнее консистентность

Правило выбора: по умолчанию бери cache-aside. Write-through нужен, когда важна свежесть при чтении сразу после записи. Write-back подходит для счётчиков, лайков и метрик, где потеря пары секунд данных некритична.

TTL: срок годности

TTL (time to live) задаёт, сколько живёт ключ. Это страховка: даже если ты забыл инвалидировать ключ, через TTL он сам исчезнет.

  • Короткий TTL (секунды): данные свежее, но промахов больше.
  • Длинный TTL (часы): hit rate выше, но пользователь дольше видит старое.
  • Добавляй джиттер: TTL = 300 ± random(30). Иначе тысячи ключей истекут одновременно, и вся нагрузка разом упадёт на БД.

Вытеснение: что выкинуть из полного холодильника

Память конечна. Когда она кончается, срабатывает политика вытеснения. Самая популярная из них LRU (Least Recently Used): выкидываем то, к чему дольше всего не обращались.

user:7→user:3→feed:12→user:1→cfg:main→null
LRU: слева самые свежие обращения. При нехватке памяти вытесняется cfg:main

При каждом чтении ключ переезжает в голову списка. Есть ещё LFU (выкидываем редко используемое). Она лучше, когда популярность стабильна. В Redis это настройка maxmemory-policy allkeys-lru.

Прогрев: холодный старт

После деплоя или рестарта Redis кэш пуст, hit rate равен 0%, и все 10 000 RPS бьют в базу. Прогрев означает, что кэш заранее наполняют горячими ключами: топ товаров, популярные профили, данные с прошлого дня. Ещё помогают постепенный ввод трафика и защита от stampede: когда горячий ключ истёк, в БД за ним идёт только один запрос (лок или single-flight), остальные ждут.

Как это звучит на собесе

«Нагрузка read-heavy, 100:1, поэтому ставлю Redis по схеме cache-aside. TTL 5 минут с джиттером, при записи делаю DEL ключа. Ожидаю hit rate около 95%, это снимет с БД в 20 раз нагрузки. Если Redis упадёт, система деградирует, но продолжит работать через БД. Для статики CDN с версионированными URL».

Частые ошибки

  • Кэшировать без TTL. Устаревшие данные будут жить вечно.
  • Не назвать цену. Кэш всегда даёт eventual consistency, это нужно проговорить.
  • Забыть про холодный старт и stampede. Интервьюер почти наверняка спросит: «А что будет, если кэш упал?»
  • Write-back для денег. Платежи нельзя терять, тут только надёжная запись в БД.
  • Локальный кэш без оговорок. Объясни, что копии на разных инстансах расходятся, поэтому TTL должен быть коротким.

Ты теперь умеешь ответить на вопросы «где», «как», «сколько живёт» и «что выкинуть». Это и есть весь каркас ответа про кэш.