MAATRIX / Блог / Предел кеша: с какого объёма попадания перестают расти, а память всё ещё тратится

Предел кеша: с какого объёма попадания перестают расти, а память всё ещё тратится

MAATRIX

Когда hit ratio кеша упирается в потолок, первая реакция — выделить ещё памяти. Иногда это работает, иногда нет: если запросы к данным распределены неравномерно, кеш уже держит почти всё, что реально запрашивают повторно, а лишние гигабайты просто лежат мёртвым грузом. Ниже — как измерить свою кривую «hit ratio от размера кеша», найти точку, после которой рост бессмысленен, и настроить TTL с eviction policy так, чтобы не тратить память на данные, которые никто второй раз не спросит.

Почему рост кеша даёт всё меньше отдачи

Обращения к данным почти никогда не распределены равномерно. В типичном вебе, API или базе действует что-то близкое к закону Ципфа или распределению Парето: небольшая доля ключей (товары на главной, горячие профили, топовые статьи) получает основную массу запросов, а длинный хвост — миллионы редких ключей — запрашивается один-два раза и больше никогда.

Из этого прямо следует форма кривой hit ratio. Если отсортировать ключи по частоте обращений и посчитать долю запросов, которую покрывают первые N% самых горячих ключей, кривая растёт быстро в начале и почти горизонтально в конце. Кеш на 200 МБ, вмещающий только «горячую голову», может закрывать 70-80% трафика. Кеш на 2 ГБ — 92-95%. Кеш на 20 ГБ, пытающийся вместить весь хвост, — может добавить ещё 2-3 процентных пункта, потому что каждый дополнительный ключ в хвосте запрашивается настолько редко, что просто не успевает быть повторно прочитанным до вытеснения или истечения TTL.

Отсюда и экономика: после точки насыщения каждый следующий гигабайт памяти покупает вам исчезающе малый прирост hit ratio, а стоит он ровно столько же, сколько и первый гигабайт. Разница в том, что первый гигабайт убирал реальную нагрузку с базы, а десятый просто держит холодные данные, которые проще один раз вычислить заново.

Это не теоретическая абстракция — форму своей кривой можно и нужно измерить, а не полагаться на общий принцип Парето: у конкретного приложения хвост может быть длиннее или короче, чем «стандартные» 80/20.

Как снять свою кривую hit ratio от размера кеша

Самый надёжный способ — прогнать реальный или близкий к реальному трафик через кеш при нескольких значениях лимита памяти и зафиксировать hit ratio на каждом шаге. Для Redis это делается через maxmemory и статистику INFO.

# текущий hit ratio без изменений конфигурации
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses'

Hit ratio считается просто:

HITS=$(redis-cli INFO stats | awk -F: '/keyspace_hits/{print $2}' | tr -d '\r')
MISSES=$(redis-cli INFO stats | awk -F: '/keyspace_misses/{print $2}' | tr -d '\r')
echo "scale=4; $HITS / ($HITS + $MISSES)" | bc

Чтобы снять кривую, а не одну точку, нужно сбросить счётчики (CONFIG RESETSTAT), выставить конкретный maxmemory, дать системе поработать под реальной нагрузкой хотя бы один полный бизнес-цикл (для интернет-магазина — сутки с пиком, для B2B-сервиса — рабочую неделю), затем снять hit ratio и повторить с другим лимитом:

redis-cli CONFIG SET maxmemory 512mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG RESETSTAT
# ждём представительный период нагрузки
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'

Дальше — 1 ГБ, 2 ГБ, 4 ГБ и так далее. На каждом шаге, кроме hit ratio, снимайте evicted_keys (сколько ключей вытеснено из-за нехватки памяти) и used_memory относительно maxmemory. Если evicted_keys близок к нулю ещё до достижения лимита — кеш уже вмещает весь рабочий набор, и дальнейшее увеличение памяти точно не даст эффекта.

Для Memcached логика та же, только статистику снимают через stats:

echo -e "stats\r\nquit\r" | nc localhost 11211 | grep -E 'get_hits|get_misses|evictions'

Если гонять эксперимент на проде рискованно, снимите zкривую офлайн: возьмите лог обращений (access log, лог запросов к БД перед кешем) за представительный период, прогоните его через симулятор LRU/LFU нужного размера — библиотеки вроде cachelib или простой скрипт на Python с collections.OrderedDict для LRU вполне достаточно для оценки формы кривой без нагрузки на боевой Redis.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как найти точку насыщения на кривой

Когда точки собраны (размер кеша → hit ratio), ищите не абсолютный максимум, а точку, где предельная отдача становится меньше стоимости памяти. Практическое правило: считайте прирост hit ratio на каждое удвоение объёма.

Размер кешаHit ratio (иллюстративно)Прирост за удвоение
256 МБ68%
512 МБ82%+14 п.п.
1 ГБ91%+9 п.п.
2 ГБ95%+4 п.п.
4 ГБ96,5%+1,5 п.п.
8 ГБ97%+0,5 п.п.

Это не результат замера, а иллюстрация формы кривой — на своих данных получите свои цифры, они могут отличаться в разы в зависимости от того, насколько «длинный хвост» у вашего трафика. Но сама форма — быстрый рост, потом почти плато — устойчиво повторяется для распределений, близких к Ципфу.

Точка насыщения — это не строгий математический экстремум, а инженерное решение: где прирост в один-два процентных пункта hit ratio перестаёт оправдывать удвоение стоимости памяти. Для большинства проектов это тот шаг, на котором прирост падает ниже 2-3 п.п. за удвоение объёма. Дальше эффективнее не расширять кеш, а разбираться, почему хвост такой длинный — возможно, часть «холодных» запросов вообще не должна идти через тот же кеш (например, стоит завести отдельный, меньший кеш под конкретный горячий тип данных вместо одного общего пула).

Второй сигнал точки насыщения — соотношение used_memory и maxmemory при близком к нулю evicted_keys: если кеш не использует весь выделенный лимит и почти ничего не вытесняет, значит, рабочий набор уже полностью помещается, и запас памяти сверху — чистые накладные расходы.

Роль TTL: почему бесконечный кеш не значит эффективный кеш

TTL напрямую определяет, сколько «холодных» ключей физически может накопиться в кеше и тем самым — насколько быстро вы дойдёте до плато. Слишком долгий TTL (или его отсутствие) позволяет кешу заполняться данными, которые были прочитаны один раз и больше не понадобятся: они просто занимают память до истечения срока или до вытеснения, разбавляя полезную «горячую» часть.

Слишком короткий TTL — другая крайность: даже действительно горячие ключи успевают устаревать и выбывать из кеша чаще, чем к ним успевают обратиться повторно, что тоже снижает hit ratio, но уже не из-за нехватки памяти, а из-за преждевременной инвалидации. Здесь есть отдельный нюанс с равномерностью применения TTL, который разбирается в материале о том, почему TTL кеша в 60 секунд может работать хуже, чем в пять — слишком грубая гранулярность обновления иногда вредит больше, чем короткий срок жизни сам по себе.

Практический подход: TTL должен примерно соответствовать характерному времени между повторными обращениями к типичному горячему ключу, а не к среднему по всем ключам. Если 90% повторных чтений товара на витрине происходят в течение 10 минут после первого, TTL в 5 минут будет резать часть полезных попаданий, а TTL в сутки — просто копить в памяти товары, которые больше никто не откроет. Разные типы данных стоит разводить по разным TTL и, если позволяет архитектура, по разным логическим кешам с собственными лимитами памяти — тогда «шумный» тип данных с длинным хвостом не вытесняет действительно горячие записи другого типа.

LRU и LFU: какая политика вытеснения выгоднее при ограниченном объёме

Eviction policy определяет, что именно останется в кеше, когда память исчерпана, — а значит, напрямую влияет на то, где именно окажется точка насыщения. Redis поддерживает несколько режимов через maxmemory-policy:

redis-cli CONFIG SET maxmemory-policy allkeys-lru
# варианты: noeviction, allkeys-lru, volatile-lru,
# allkeys-lfu, volatile-lfu, allkeys-random, volatile-ttl

LRU (Least Recently Used) вытесняет то, к чему дольше всего не обращались. Это дёшево по вычислениям и хорошо работает, когда «горячесть» ключа примерно совпадает с недавностью обращения. Слабое место LRU — он уязвим к разовым сканированиям: один batch-job, прошедшийся по миллиону редких ключей, вымывает из кеша всё действительно горячее, потому что LRU видит только «недавно/давно», а не «часто/редко». Этот сценарий и его последствия подробно разобраны в статье о том, как один запрос вымывает весь кеш при LRU.

LFU (Least Frequently Used, доступен в Redis начиная с ветки 4.x) вытесняет по частоте обращений, а не по недавности, и потому устойчивее к разовым всплескам холодного трафика — редкий ключ, даже прочитанный только что, не вытеснит по-настоящему горячий. Плата за это — чуть больше накладных расходов на подсчёт частоты и то, что LFU медленнее реагирует на смену паттерна доступа: если вчерашние хиты сегодня стали нерелевантны, LFU будет ещё какое-то время держать их в приоритете.

Практический вывод для темы насыщения: при одинаковом объёме памяти LFU обычно достигает более высокого hit ratio на трафике с выраженным длинным хвостом и периодическими сканированиями, а значит — точка насыщения при LFU может быть достигнута на меньшем объёме памяти, чем при LRU. Если после смены allkeys-lru на allkeys-lfu кривая hit ratio заметно сдвинулась вверх при том же лимите — это симптом, что часть предыдущего «бесполезного» роста кеша на самом деле компенсировала не размер, а неподходящую политику вытеснения. Отдельно стоит проверить maxmemory-policy, если после рестарта Redis память внезапно снова начинает бесконтрольно расти — типичная причина разобрана в статье про то, как забытый maxmemory-policy приводит к росту памяти до перезапуска.

Практический алгоритм: сколько памяти реально нужно под кеш

Пошагово, без гадания:

  1. Соберите базовую метрику. Текущий hit ratio, used_memory, maxmemory, evicted_keys за представительный период — минимум сутки для трафика с суточной цикличностью.
  2. Снимите 4-5 точек кривой. Уменьшите и увеличьте maxmemory относительно текущего значения в 2-4 раза в обе стороны, на каждом шаге дайте системе отработать полный цикл нагрузки и зафиксируйте hit ratio.
  3. Постройте прирост за удвоение. Как в таблице выше — где прирост падает ниже вашего порога окупаемости (обычно 2-3 процентных пункта на удвоение), это и есть рабочая точка насыщения.
  4. Проверьте TTL отдельно от объёма. Если hit ratio низкий даже при большом лимите памяти и низком evicted_keys, дело не в объёме — проверьте, не истекают ли ключи раньше повторного обращения (expired_keys в INFO stats).
  5. Сравните LRU и LFU на одном объёме. Если LFU даёт заметно лучший hit ratio при том же maxmemory, переключайтесь — это чистый выигрыш без затрат памяти.
  6. Заложите запас 20-30% сверх точки насыщения, а не «на всякий случай в разы». Запас нужен на рост данных и сезонные пики, а не на иллюзию, что «больше памяти — всегда лучше»: миф о том, что рост кеша сам по себе решает проблему производительности, разобран отдельно в материале «кеш решит проблему производительности» — там же про случаи, где кеш не поможет вообще, независимо от объёма.
  7. Мониторьте кривую периодически, а не один раз. Профиль обращений меняется — новый раздел каталога, сезонный товар, изменение логики приложения — и точка насыщения вместе с ним. Разовый замер устаревает за месяцы, а не годы.

Если после всех шагов оказывается, что рабочий набор в разы больше доступной памяти на текущем сервере — это уже вопрос не настройки, а объёма RAM в инстансе: выбор между вертикальным масштабированием памяти и переходом на кластер Redis с шардированием стоит делать исходя из абсолютного размера горячих данных, а не из желания «на всякий случай» держать кеш побольше.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Кеш на 100% занял выделенную память — это нормально?

Да, для кешей это ожидаемое поведение: они стремятся заполнить весь maxmemory, вытесняя старое новым. Само по себе заполнение не значит, что память используется эффективно — смотрите на evicted_keys и hit ratio, а не на процент заполнения.

Можно ли ориентироваться на общее правило 80/20 без собственных замеров?

Как ориентир для первой прикидки — можно, но конкретная форма хвоста у вас может быть другой: у каталога с миллионом SKU хвост часто длиннее, чем у сервиса с несколькими сотнями «карточек». Решения по бюджету памяти лучше принимать по своей измеренной кривой.

Что делать, если evicted_keys высокий, а объём памяти увеличить нельзя?

Сначала пересмотрите TTL по типам данных и разделите один общий кеш на несколько с разными политиками — это часто даёт больше эффекта, чем добавление памяти, особенно если один «шумный» тип данных вытесняет остальные.

LFU всегда лучше LRU?

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

Как часто нужно пересматривать размер кеша?

Ориентировочно — при заметном изменении профиля нагрузки (новый функционал, сезонность, рост каталога) или раз в квартал как плановая проверка, даже если ничего явно не менялось.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →