Политики вытеснения и TTL
Кэш конечен, поэтому обязан что-то выбрасывать. Политика вытеснения (LRU, LFU, FIFO, random) решает, что уходит при заполнении памяти; TTL решает, когда значение устаревает. Оба держат рабочий набор горячим — а число, которое важно, это hit ratio, а не размер кэша.
Инженер настроил кэш вытеснять сначала самую старую запись (FIFO) и ушёл домой довольным. В кэше лежали главная страница, читаемая миллион раз в час, и ночной отчёт, который никто не трогает до 9 утра. Поскольку главная грузилась при старте — она была самой старой записью — FIFO выкидывал её в момент заполнения, потом перезагружал на следующем чтении, потом снова выкидывал. Кэш усердно гонял свой единственный самый ценный элемент, преданно храня отчёт. Починка была одним словом в конфиге — сменить политику с FIFO на LRU — но она вскрыла настоящий урок: конечный кэш определяется не столько размером, сколько тем, что он выбирает забыть.
Почему кэш обязан что-то выбрасывать
После этого урока ты сможешь диагностировать кэш, который работает корректно, но тихо разрушает производительность базы — и знать однострочную починку конфига, которую большинство команд пропускает.
Кэш — это ограниченный объём быстрой памяти перед намного большим медленным хранилищем. Хранилище держит всё; кэш держит крошечное горячее подмножество. Два механизма решают, что в этом подмножестве. Вытеснение отвечает «память полна, нужно место — что удалить?» TTL (time to live) отвечает «это значение тут уже какое-то время — когда перестать ему доверять?» Они решают разные задачи: вытеснение про место (давление ёмкости), TTL про время (устаревание). Боевой кэш почти всегда использует оба.
Важность этого — в рабочем наборе (working set): множестве ключей, реально читаемых в данном окне. Если кэш не меньше рабочего набора, почти каждое чтение — попадание, и вытеснение срабатывает редко. Если кэш меньше рабочего набора, начинается трэшинг: каждое чтение вытесняет то, что скоро понадобится, и hit ratio обрушивается. Поэтому первый вопрос никогда не «какая политика?» — он «достаточно ли кэш велик, чтобы держать рабочий набор, или я пихаю слона в коробку из-под обуви?»
Политики вытеснения и что каждая предполагает
Какую запись пожертвовать, когда память кончилась? Ответ неочевиден — неверный выбор тихо убивает самые ценные данные. Каждая политика — это ставка на будущее, закодированная правилом о прошлом:
- LRU (Least Recently Used) вытесняет запись, нетронутую дольше всех. Ставка: недавность предсказывает повторное использование — что недавно использовал, используешь снова. Это соответствует большинству реальных паттернов доступа (временна́я локальность) и это разумный дефолт.
- LFU (Least Frequently Used) вытесняет запись с наименьшим числом обращений. Ставка: популярность предсказывает повторное использование. LFU блистает, когда стабильный набор горячих ключей должен пережить поток одноразовых чтений — разовый скан не может вытеснить вечно-горячие ключи так, как под LRU.
- FIFO (First In, First Out) вытесняет самую старую вставленную запись независимо от использования — ровно баг из вступления, ведь самый ценный элемент часто вставлен рано и остаётся ценным. FIFO полностью игнорирует доступ; для кэша это редко верный выбор.
- Random вытесняет произвольную запись. Звучит глупо, но дёшево (без учёта) и удивительно устойчиво — её не сломать патологическим сканом, как строгий LRU.
allkeys-randomв Redis существует ровно для этого.
▸Почему это работает
Почему реальные кэши приближают LRU/LFU вместо точной реализации? Потому что идеальный LRU значит вести точный связный список, упорядоченный по доступу, на каждом чтении — лишние записи и блокировки на самом горячем пути. Redis вместо этого семплирует: при вытеснении он берёт несколько случайных ключей (по умолчанию 5) и вытесняет лучший по метрике политики — приближение, очень близкое к истинному LRU/LFU за долю стоимости и без глобальной структуры порядка. Senior-мысль: точность вытеснения сама по себе компромисс против скорости вытеснения — и на кэше с миллионом чтений в секунду цена учёта «идеала» может превысить выгоду лучшего выбора. Приближённый LRU — дефолт именно потому, что приближение дёшево и почти так же хорошо.
TTL: ограничение устаревания и ловушка джиттера
TTL вешает на ключ срок истечения: через N секунд он считается отсутствующим, форсируя перезагрузку из источника. Как показал прошлый урок, TTL — это то, чем cache-aside ограничивает своё устаревание: пол под «насколько неверным может стать это значение». Короткие TTL — свежее данные и больше промахов (больше нагрузки на БД); длинные TTL — меньше промахов и старее данные. TTL настраивают поклассово: секунды для быстрой ленты, часы для редко меняющегося конфига.
Но единый TTL прячет острый край. Если прогреть 10 000 ключей при старте все с ttl=3600, они все истекут в одну секунду через час — и в этот миг каждый из них промахивается одновременно, штурмуя базу 10 000 параллельных перезагрузок. Починка — джиттер TTL: добавь небольшой случайный разброс (например, 3600 ± 300с), чтобы истечения рассеялись по окну, а не сдетонировали разом. Джиттер — одна строка кода, и он предотвращает самонанесённый thundering-herd, который иначе отлаживаешь в 3 ночи.
Hit ratio — это метрика, а промах стоит дороже, чем кажется
Число, что говорит, работает ли кэш — это hit ratio: попадания ÷ всего чтений. Кэш на 99% попаданий и на 90% звучат близко, но они не близки: промахи выросли с 1% до 10% — это 10× рост нагрузки на базу за кэшем. Поскольку бэкенд-хранилище размерено в расчёте, что кэш поглощает большинство чтений, падение hit ratio с 99% до 90% может перевести базу из комфорта в пожар. Это асимметрия, делающая кэширование опасным: кэш скрывает, насколько хрупка система под ним, и регрессия hit ratio (плохой деплой, штормы вытеснения) бьёт по базе в 10× сильнее, чем подсказывает изменение процента.
Промах не бесплатен и по задержке: как отмечает AWS про lazy loading, каждый промах — три похода (проверить кэш, прочесть БД, записать кэш), так что пользователь на промахе ждёт весь бэкенд-путь плюс походы в кэш. Hit ratio, штраф промаха и соответствие рабочему набору — три числа; размер кэша в ГБ важен лишь постольку, поскольку меняет их.
▸Частая ошибка
Опасная misconfiguration — это maxmemory-policy. Redis по умолчанию ставит noeviction: когда память полна, записи начинают падать, а не вытеснять что-либо — что верно для надёжного хранилища, но катастрофа для кэша, где ты хотел, чтобы он сбрасывал холодные ключи и продолжал отдавать. Команды разворачивают Redis как кэш, оставляют дефолт, и однажды каждый SET падает под давлением памяти. Для чистого кэша нужен allkeys-lru (или allkeys-lfu), чтобы любой ключ мог быть вытеснен; volatile-lru вытесняет только ключи с TTL, что тихо заполняет память твоими ключами без TTL, пока вытеснять станет нечего. Выбирай политику вытеснения осознанно и никогда не держи кэш на noeviction.
Hit ratio твоего кэша тихо падает с 99% до 90% после деплоя. Задержка кэша выглядит норм. Почему дежурного будят из-за плавящейся базы?
При старте ты прогреваешь 50 000 ключей в Redis, каждый ровно с ttl=600. Сервис работает норм десять минут, затем база сильно скачет каждые десять минут. В чём причина и однострочная починка?
Дефолтная политика вытеснения в реальных кэшах приближает _______ — вытеснить запись, нетронутую дольше всех — потому что ставит на то, что недавность предсказывает повторное использование, что соответствует временно́й локальности большинства паттернов доступа, и потому что семплирование пары ключей дёшево приближает её на горячем пути чтения.
- 01Отличи вытеснение от TTL и свяжи оба с рабочим набором.
- 02На что ставит каждая политика вытеснения и почему LRU — дефолт?
- 03Почему hit ratio — метрика, и каковы ловушки джиттера TTL и maxmemory-policy?
Кэш конечен, поэтому обязан забывать — двумя независимыми механизмами. Вытеснение держит давление места (память полна, удали что-то): LRU ставит, что недавность предсказывает повтор, и это дефолт, LFU ставит на популярность и защищает горячий набор от одноразовых потоков, FIFO игнорирует доступ и может вытеснить твой самый ценный рано-вставленный элемент (баг из вступления), а random дёшев и устойчив к сканам. Реальные кэши приближают LRU/LFU семплированием вместо ведения идеального порядка, меняя точность на скорость горячего пути. TTL держит время (ограничивает устаревание, что допускают ленивые паттерны), но единый TTL детонирует все прогретые ключи разом — поэтому ты его джиттеришь. Число, что реально важно — это hit ratio, а не размер кэша в ГБ: падение с 99% до 90% попаданий — это 10× рост нагрузки базы, ведь бэкенд-хранилище размерено в расчёте, что кэш поглощает почти всё. А misconfiguration, что роняет кэш — это maxmemory-policy: дефолт Redis noeviction валит записи под давлением, что неверно для кэша; выбирай allkeys-lru/lfu осознанно. Всё тут существует, чтобы держать рабочий набор на быстром пути; следующий урок масштабирует это за пределы одного узла. Теперь, когда встретишь периодический всплеск нагрузки на базу, точно совпадающий с интервалом прогрева кэша, — ты сразу узнаешь: синхронное истечение TTL, лечится одной строкой джиттера.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.