Ctrl K
Урок

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

Почему про это спрашивают

Представь кафе с меловой доской «Сегодня в меню». Повар убрал сырники, а доску не стёр. Гости заказывают сырники, официант уходит на кухню и возвращается с извинениями. Кэш — это и есть такая доска: быстрая копия правды. Главный вопрос не «как её повесить», а «кто и когда её стирает».

Есть известная шутка: в программировании две сложные вещи — инвалидация кэша и придумывание имён. На собесе кэш предлагают почти все. Сильного кандидата отличает другое: он сам говорит, как кэш будет устаревать и ломаться.

Почему инвалидация — самое сложное

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

Классическая гонка при подходе «прочитал из БД → положил в кэш»:

A: промах кэша→A: читает из БД цену=100→B: пишет в БД цену=120→B: удаляет ключ из кэша→A: кладёт в кэш 100→null
Запрос A опоздал и записал в кэш старое значение — оно проживёт до конца TTL

Три основных способа инвалидации:

Способ Как работает Плюс Минус
TTL ключ сам истекает через N секунд просто, самоисцеляется до N секунд устаревших данных
Удаление при записи после UPDATE в БД делаем DEL key свежесть почти сразу гонки, забытые места записи
Версия в ключе user:42:v7, при изменении версия растёт старое просто перестаёт читаться нужно где-то хранить версию

💡 Правило: при записи удаляй ключ, а не обновляй его. Если два писателя обновляют кэш, порядок может перепутаться. После удаления следующий читатель возьмёт свежие данные из БД. И всегда добавляй TTL как страховку: даже если удаление потерялось, ошибка проживёт ограниченное время.

Устаревшие данные: как с ними жить

Идеальной свежести не бывает. Решай отдельно для каждого типа данных, сколько устаревания терпимо:

  • лента новостей, счётчик лайков: минуты, всем всё равно;
  • профиль пользователя: секунды, плюс удаление при записи;
  • баланс, остаток на складе при оплате: не кэшируем или сверяем с БД в момент транзакции.

Полезный приём — read-your-writes. Пользователь, который сам что-то поменял, должен сразу это увидеть. Для него можно читать мимо кэша несколько секунд после записи. Остальные спокойно подождут TTL.

Cache stampede: лавина при истечении

Популярный ключ истёк, и в ту же миллисекунду тысячи запросов получают промах и все идут в базу пересчитывать одно и то же.

missmissmissтяжёлый запростяжёлый запростяжёлый запрос10 000 RPS на ключклиентAPI #1сервисAPI #2сервисAPI #3сервисRedis: ключ истёккэшPostgreSQLБД
Все промахнулись одновременно и пошли в базу

Посчитаем масштаб:

Параметр Значение
Нагрузка на ключ 10 000 RPS
Время пересчёта в БД 200 мс
Запросов в БД за окно пересчёта 10 000 × 0,2 = 2 000 одинаковых запросов

База легла, запросы ждут, таймауты, ретраи, и стало ещё хуже.

Три защиты

1. Блокировка (single-flight). Пересчитывает только первый запрос. Он берёт лок SET lock:key 1 NX EX 5, остальные ждут или получают старое значение. В БД уходит 1 запрос вместо 2 000. Цена: лишняя задержка у ждущих и риск, что держатель лока упадёт. Поэтому у лока обязательно есть TTL.

2. Ранний перерасчёт. Обновляем ключ до истечения. Например, храним рядом «мягкий» срок. Когда он прошёл, один запрос обновляет значение в фоне, а все остальные продолжают получать текущее (stale-while-revalidate). Есть и вероятностный вариант: чем ближе к концу TTL, тем выше шанс, что очередной запрос запустит обновление. Цена: лишние пересчёты и усложнение логики.

3. Джиттер TTL. Если ты прогрел 100 000 ключей с одинаковым TTL=300, через 5 минут они истекут все разом. Добавь случайность, например TTL = 300 + random(0, 60), и истечения размажутся по минуте. Это дёшево и почти бесплатно, поэтому джиттер стоит упоминать всегда.

Горячие ключи

Даже без истечения один ключ (пост звезды, товар дня) может получать столько трафика, что один шард Redis упирается в CPU или сеть, хотя остальные шарды простаивают. Шардирование тут не помогает: ключ всё равно живёт на одном узле.

случайная копияКлиентыклиентAPI + локальный кэш 1–5 ссервисAPI + локальный кэш 1–5 ссервисRedis: post:1#0кэшRedis: post:1#1кэшRedis: post:1#2кэш
Локальный кэш + копии горячего ключа на разных шардах

Как с этим справиться:

  • локальный in-memory кэш в каждом инстансе API с коротким TTL поглощает основную массу запросов;
  • копии ключа key#0..N лежат на разных шардах, чтение идёт со случайной копии (минус: инвалидировать приходится все N копий);
  • реплики для чтения в Redis;
  • детекция: мониторинг топа ключей, чтобы горячие находить заранее.

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

Когда добавляешь кэш, проговори пять пунктов:

  1. Что кэшируем и зачем: «профили, read:write ≈ 100:1, снимем 90% нагрузки с БД».
  2. Стратегия: cache-aside, при записи DEL, плюс TTL как страховка.
  3. Допустимая устарелость: «для профиля до 60 секунд ок, баланс не кэшируем».
  4. Stampede: «для популярных ключей single-flight лок и джиттер TTL».
  5. Горячие ключи: «локальный кэш на пару секунд, при необходимости реплицируем ключ».

🎯 Фраза-маркер синьора: «Кэш — это компромисс между свежестью и нагрузкой. Давайте определим, сколько устаревания мы готовы терпеть». После неё интервьюер обычно кивает.

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

  • Предлагать кэш и молчать про инвалидацию.
  • Обновлять ключ при записи вместо удаления (гонки).
  • Ставить ключи без TTL («удалим вручную»).
  • Одинаковый TTL у массово прогретых ключей.
  • Кэшировать то, где устаревание недопустимо (деньги, остатки при покупке).
  • Думать, что шардирование решает проблему горячего ключа.

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