Оперативная память — одновременно источник скорости Redis и его самое жёсткое ограничение. Диск можно докупить почти незаметно, память — нет, и когда датасет упирается в потолок, Redis приходится решать: что удалить, кому отказать, как не упасть. Поведение под давлением памяти определяется не одним переключателем, а связкой из аллокатора, фрагментации, лимита maxmemory и выбранной политики вытеснения. Неверная настройка здесь — классическая причина внезапных ошибок записи в проде.
Это пятая статья серии «Redis: глубокое погружение». Она напрямую продолжает нить «кэш vs источник истины»: в роли кэша вытеснение — норма и благо (данные восстановимы), в роли источника истины вытеснение недопустимо. Но главный сюжет статьи другой и неожиданный: самая красивая цифра в таблице вытеснения означает не то, что кажется.
В статье
maxmemory-samples=5: почему LRU и LFU — приближённые- Методика: горячие, холодные и заполнитель
- Выживаемость по политикам: живые числа
- Почему колонка
cold— не рейтинг политик noeviction: сервер не падает, он отказывает- Фрагментация, которой нет, и
MEMORY DOCTOR, который ошибается - Что из этого следует на практике
- Что дальше
- Источники
maxmemory-samples=5: почему LRU и LFU — приближённые
Начать нужно с параметра, без которого все числа ниже читаются неверно.
LRU и LFU в Redis/Valkey приближённые. Когда память упирается в maxmemory, сервер не ищет глобально худший ключ — он берёт случайную выборку из maxmemory-samples ключей (по умолчанию 5) и вытесняет худший в ней. Это осознанное решение: точный LRU требовал бы поддерживать упорядоченную структуру по времени доступа для всего keyspace, что стоило бы памяти и времени на каждой операции.
Практическое следствие важнее теоретического: результат вытеснения зависит не только от «заслуг» ключа, но и от того, как часто его класс вообще попадает в выборку — то есть от доли класса в keyspace. Это то, что превращает часть чисел ниже в ловушку.
Методика: горячие, холодные и заполнитель
Стенд: свежий инстанс с --maxmemory 64mb и заданной политикой, три класса ключей.
| Класс | Объём | Обращения |
|---|---|---|
hot:N |
200 × 1 КиБ | GET по всем каждые 20 раундов |
cold:N |
2000 × 1 КиБ | одна запись, больше не трогаются |
filler:N |
4 КиБ × тысячи | непрерывный долив — источник давления |
Конфигурация одинакова на обоих образах: maxmemory = 67108864 (64 МиБ), maxmemory-samples = 5, lfu-log-factor = 10, lfu-decay-time = 1 минута, аллокатор jemalloc-5.3.0.
TTL ставится только при volatile-ttl, и намеренно длинный — 300–420 секунд при прогоне в десятки секунд. Так истечение по часам не может подмешаться к вытеснению: единственный способ ключу исчезнуть — быть вытесненным. Проверяется это явно: expired_keys во всех 16 прогонах равен нулю, и сценарий падает с ошибкой, если это не так. Причина строгости в том, что число вытесненных cold считается вычитанием выживших из исходных — истёкший по TTL ключ молча приплюсовался бы к «вытесненным» и завысил бы мнимую разборчивость политики.
Что вытеснение реально сработало, тоже проверяется, а не предполагается. Для allkeys-lru на Redis, прогон №4: used_memory на финале 67044040 из 67108864 — 99.9% лимита; used_memory_peak 67128192 — упёрлось; evicted_keys = 651. Сценарий падает, если под вытесняющей политикой evicted_keys=0, если пик не дотянул до 90% лимита или если на noeviction не случилось OOM. «Ноль вытеснено» здесь не может выглядеть как находка.
Выживаемость по политикам: живые числа
Сначала — как читать колонку hot, иначе таблица соврёт.
Горячие ключи пишутся всегда без TTL. Поэтому под allkeys-lru и allkeys-lfu их стопроцентная выживаемость — настоящее наблюдение: ключи были вытесняемы и не вытеснены. А под volatile-ttl они вообще не входят в volatile-множество, то есть невытесняемы по построению сценария; под noeviction не вытесняется ничего. В этих двух ячейках стоит «н/п» — их нельзя цитировать как «политика сберегла горячие ключи».
redis:8.8 (четыре независимых прогона матрицы, volatile-ttl — три):
| Policy | hot выжило | cold выжило (все прогоны) | evicted hot/cold/filler (один прогон) | OOM |
|---|---|---|---|---|
allkeys-lru |
200/200 (100%) — наблюдение | 1542–1653/2000 (77.1–82.7%) | 0 / 347 / 304 (Σ651) | нет |
allkeys-lfu |
200/200 (100%) — наблюдение | 1817–1964/2000 (90.8–98.2%) | 0 / 183 / 354 (Σ537) | нет |
volatile-ttl |
н/п (hot без TTL → невытесняемы) |
1923–1932/2000 (96.2–96.6%) | 0 / 68 / 345 (Σ413) | нет |
noeviction |
н/п (не вытесняется ничего) | 2000/2000 (100%) | 0 / 0 / 0 | filler:12120 (3/3) |
valkey/valkey:8.1:
| Policy | hot выжило | cold выжило (все прогоны) | evicted hot/cold/filler (один прогон) | OOM |
|---|---|---|---|---|
allkeys-lru |
200/200 (100%) — наблюдение | 1625–1673/2000 (81.2–83.7%) | 0 / 375 / 228 (Σ603) | нет |
allkeys-lfu |
200/200 (100%) — наблюдение | 1861–1883/2000 (93.0–94.2%) | 0 / 138 / 288 (Σ426) | нет |
volatile-ttl |
н/п | 1930–1939/2000 (96.5–97.0%) | 0 / 61 / 349 (Σ410) | нет |
noeviction |
н/п | 2000/2000 (100%) | 0 / 0 / 0 | 12196 / 12196 / 12163 |
Эти диапазоны нельзя сужать — и вот почему это не формальность
На двух-трёх прогонах эти диапазоны выглядели заметно уже: LFU на Redis держался в 97.5–98.2%. Четвёртый прогон вышел за границу обоих: LRU дал 82.7%, LFU — 90.8%. В таблицах выше диапазоны по всем наблюдениям, но и они не «истинные границы» — это лишь то, что успело попасться на трёх-четырёх прогонах.
Практический вывод здесь заслужен и стоит дороже самих чисел: выживаемость под сэмплирующим вытеснением заметно шумит от прогона к прогону — у Redis под LFU разброс достигает 7.4 процентных пункта при неизменных конфиге и нагрузке. Любые сравнения «на пару процентов» между политиками или образами на таких выборках не значат ничего.
Из этого же следует и то, чего в статье не будет. На первых прогонах Redis и Valkey выглядели устойчиво разошедшимися по выживаемости cold — интервалы не пересекались, и вывод напрашивался сам. Он не устоял: четвёртый прогон увёл значения за прежние границы, и интервалы перекрылись — у LRU на 81.2–82.7%, а у LFU интервал Valkey целиком лёг внутрь интервала Redis. Разброс внутри одного образа перекрывает всю наблюдавшуюся «разницу» между образами. Никакого расхождения между движками этот стенд не показал; прежний вывод был артефактом малой выборки.
Почему колонка cold — не рейтинг политик
Теперь самое интересное. Смотрим на колонку выживаемости cold: у LRU 77.1–82.7%, у LFU 90.8–98.2%, у volatile-ttl 96.2–96.6%. Напрашивается вывод: «LFU бережёт холодные данные лучше LRU, а volatile-ttl — лучше всех». Он неверен, и это главное содержание статьи.
Что LRU действительно различает
У LRU перекос вытеснений в сторону cold настоящий, и знак перекоса одинаков во всех прогонах на обоих образах:
| Прогон | cold в вытеснениях |
доля cold в keyspace (финал) |
перевес |
|---|---|---|---|
| redis, №4 | 347 из 651 = 53.3% | 11.8% | ×4.5 |
| redis, ранний | 458 из 741 = 61.8% | 11.0% | ×5.6 |
| valkey, №3 | 375 из 603 = 62.2% | 11.5% | ×5.4 |
Возраст работает как дискриминатор: cold никто не трогал с момента записи, их idle монотонно растёт, и в выборке они систематически худшие. Оговорка про базу: доля cold в keyspace непостоянна — на старте вытеснения около 14%, к финалу проседает до 11–12% (сами вытеснения её и уменьшают). От финальной доли перевес выходит ×4.5–5.6, от стартовых 14% — ×3.8–4.4. Честный диапазон с учётом обеих баз — примерно ×4–5.6; одного точного числа тут нет.
Что LFU не различает вообще
А вот у LFU перевес кратно меньше — и причина измерена, а не выведена из исходников. Команда OBJECT FREQ показывает счётчик частоты:
| Класс | OBJECT FREQ min / медиана / max (redis) |
(valkey) |
|---|---|---|
hot |
12 / 16 / 21 | 11 / 16 / 24 |
cold |
4 / 4 / 4 | 5 / 5 / 5 |
filler |
4 / 4 / 4 | 5 / 5 / 5 |
Вот оно. cold и filler записаны по одному разу каждый, и счётчик у них совпадает до единицы, без разброса вообще. Для LFU это буквально неразличимые ключи, и выбор между ними вырождается в случайный.
Отсюда и «хорошая» выживаемость cold при LFU: LFU её не защищал — он не мог отличить cold от filler, а filler численно доминирует (около 85% keyspace) и потому забирает большинство вытеснений. Высокая цифра — следствие неразличимости плюс арифметики, а не заслуга политики.
Абсолютное значение счётчика гуляет между прогонами — 4 или 5, — и это тоже измерение: lfu-decay-time равен минуте, и в более медленных прогонах счётчик успевает просесть. Но проседает он у cold и filler одновременно, так что различать их это не помогает: в каждом отдельном прогоне они всё равно равны друг другу. Совпадение сохраняется на обоих образах и во всех прогонах — это самый устойчивый результат стенда.
volatile-ttl — ровно та же история
96–97% выживаемости cold под volatile-ttl выглядят ещё убедительнее. Причина ровно та же. volatile-ttl вытесняет ключ с ближайшим истечением, а в сценарии:
| Класс | TTL |
|---|---|
cold:i |
случайный в [300,420) с |
filler:round |
случайный в том же [300,420) с |
hot:i |
нет TTL |
Распределения TTL у cold и filler совпадают. Значит «ближайшее истечение» не может служить дискриминатором между ними — как не мог счётчик LFU. И 96% объясняются тем же самым численным доминированием filler.
Слабый перекос в сторону cold всё же есть — ×1.10–1.35 по обоим образам против примерно ×4–5.6 у LRU, то есть почти пропорционально доле в keyspace. Причина не в разборчивости политики, а в том, что TTL у cold проставлены на десятки секунд раньше (они пишутся в начале прогона, filler — по ходу), поэтому истекают чуть раньше при том же номинале. Артефакт порядка заливки.
Вывод: из четырёх ячеек содержательно различает cold только allkeys-lru. У allkeys-lfu и volatile-ttl высокая выживаемость cold — следствие неразличимости плюс численного доминирования заполнителя, а не защиты. Колонку cold выжило нельзя читать как ранжирование политик.
Практический вывод при этом формулируется осторожно: LFU выигрывает у LRU там, где у ключей разная частота обращений. Там, где всё записано по разу и больше не читалось, LFU вырождается — разделить «старое, записанное однажды» и «новое, записанное однажды» ему нечем. У LRU в этом же сценарии дискриминатор есть: возраст.
И про 100% у горячих ключей — тоже осторожно
Результат под allkeys-* чистый, и именно поэтому его легко переоценить. Горячие ключи защищены тремя механизмами, и стенд их не разделяет: сигналом обращения (свежий idle, счётчик LFU 16 против 4–5 у остальных); редкостью в выборке — hot составляют 1.4% keyspace, и вероятность, что случайная выборка из пяти ключей содержит хоть один горячий, около 7%; и полной невытесняемостью под volatile-*. Вклад первого механизма против второго стендом не измерен. Утверждать «LRU надёжно защищает горячие ключи» на основании этих 100% нельзя: при другой доле горячего в keyspace — скажем, 60% вместо 1.4% — картина может быть иной.
noeviction: сервер не падает, он отказывает
| Образ | OOM на записи, по прогонам | evicted_keys |
hot+cold целы |
|---|---|---|---|
redis:8.8 |
12120 / 12120 / 12120 — совпало 3 из 3 |
0 | 2200/2200 |
valkey/valkey:8.1 |
12196 / 12196 / 12163 |
0 | 2200/2200 |
Здесь тоже напрашивалось более сильное утверждение — «точка отказа воспроизводима до записи на обоих образах»: у каждого образа два прогона совпали в точности. Третий прогон Valkey дал 12163 вместо 12196: расхождение в 33 записи. У Redis совпадение держится (3/3), у Valkey — нет. Честная формулировка: заполнение почти детерминировано, точка отказа воспроизводится с точностью лучше 0.3%, но побайтовую повторяемость утверждать нельзя. Чем вызван сдвиг у Valkey — не установлено.
Разница между образами (12120 против 12163–12196) правдоподобно объясняется накладными расходами на ключ и структуры, а не тем, что «у Valkey больше памяти». Но и это сравнение стоит брать с оговоркой — с учётом урока предыдущего раздела: собственный дрейф Valkey между прогонами (33 записи) того же порядка, что и сам межпродуктовый разрыв (43–76). Диапазоны пока не пересекаются, и в пользу разрыва играет отсутствие сэмплирования и нулевой разброс у Redis, — но при трёх прогонах на образ запас прочности невелик. Направление подтверждено всеми прогонами; величину цитировать как точную не стоит.
Что здесь по-настоящему важно: под noeviction чтения продолжают работать, отказывают только записи, увеличивающие потребление. Все 2200 горячих и холодных ключей остались на месте — сервер не «упал», он перестал принимать новое.
Политика настроена — не значит вытеснение работает
Отдельный сценарий: политика volatile-ttl, но ни у одного ключа нет TTL.
| Образ | Результат |
|---|---|
redis:8.8 |
"OOM command not allowed" на filler:12128, evicted_keys=0 |
valkey/valkey:8.1 |
"OOM command not allowed" на filler:12169, evicted_keys=0 |
Вытеснять нечего: volatile-множество пусто, а volatile-* политики не трогают ключи без TTL. Сервер с настроенным maxmemory-policy и заданным лимитом ведёт себя ровно как noeviction — начинает отказывать в записи. Это тот случай, когда «политика вытеснения настроена» в конфиге и «вытеснение работает» — разные утверждения.
Фрагментация, которой нет, и MEMORY DOCTOR, который ошибается
| Метрика | redis:8.8 |
valkey/valkey:8.1 |
|---|---|---|
mem_fragmentation_ratio |
1.40–1.42 | 1.27–1.28 |
allocator_frag_ratio |
1.00–1.01 | 1.00–1.01 |
rss_overhead_ratio |
1.19–1.24 | 1.13–1.17 |
used_memory_rss |
~94.3 МБ | ~85.5 МБ |
Оговорка, без которой эту таблицу читать нельзя. Прогон идёт на Windows-хосте через Docker Desktop (WSL2), и used_memory_rss с rss_overhead_ratio в такой среде завышены относительно нативного Linux — переносить их на прод нельзя. Это важно вдвойне, потому что вся разница вердиктов MEMORY DOCTOR ниже держится ровно на этих метриках: они подходят к своим порогам вплотную, и в другой среде обе могли бы лечь по другую сторону. Ни строку про RSS, ни разницу образов по нему нельзя цитировать как свойство Redis или Valkey.
mem_fragmentation_ratio 1.41 — это не «фрагментация 41%». allocator_frag_ratio равен 1.00–1.01, то есть jemalloc не фрагментирует практически совсем. Весь разрыв даёт rss_overhead_ratio — RSS процесса против того, что держит аллокатор: код, стеки, буферы, накладные расходы среды. Читать mem_fragmentation_ratio как «пора запускать activedefrag» — ошибка; разделять эти вещи умеет allocator_frag_ratio.
Вердикты MEMORY DOCTOR (снимались с хоста, независимо от клиента). Доктор выдаёт список пунктов, и считать надо каждый пункт отдельно, а не «вердикт образа» целиком:
| Пункт | Порог | redis:8.8 |
valkey/valkey:8.1 |
|---|---|---|---|
High total RSS |
mem_fragmentation_ratio > 1.4 |
5 из 5 ячеек | 0 из 5 |
High process RSS overhead |
rss_overhead_ratio > 1.1 |
5 из 5 ячеек | 5 из 5 |
Диагноз Valkey — строгое подмножество диагноза Redis, а не «другой вердикт». Разницу создаёт ровно один порог: mem_fragmentation_ratio у Redis 1.40–1.42 — за границей 1.4, у Valkey 1.27–1.28 — не доходит. Второй порог переступают оба.
Отсюда два вывода, и путать их нельзя:
High total RSSу Redis — ложноположительный для этой нагрузки. Доктор сам оговаривается: если проблема в большом пике памяти, то проблемы нет, — а пик здесь упирается вmaxmemoryпо построению сценария.High process RSS overheadс формулировкой «может быть вызвано Lua-скриптами или модулями» печатают оба образа — при том, что ни Lua-скриптов, ни модулей на стенде нет вообще. Это не причуда Valkey.
Есть и нестабильность между прогонами: в одной из ранних матриц ячейка allkeys-lfu на Valkey дала «не вижу проблем с памятью» — то есть пункт про RSS overhead не сработал. В последней матрице это не воспроизвелось. Валкеевский rss_overhead_ratio (1.13–1.17) идёт вплотную к порогу 1.1, так что перекидывание вердикта от прогона к прогону ожидаемо.
Вывод про MEMORY DOCTOR: оба пункта, которые он выдал на этой нагрузке, указывают на RSS-накладные расходы, а не на проблему аллокатора, и один из них у Redis прямо ложноположительный. Что именно загорится — определяется парой порогов, к которым обе метрики подходят вплотную, поэтому вердикт меняется и между образами, и между прогонами одного образа. Как сигнал тревоги брать нельзя без проверки allocator_frag_ratio. И это не различие движков по существу: набор пунктов у Valkey беднее только потому, что его mem_fragmentation_ratio не дотянул до порога, — из-за значения метрики, а не из-за другой логики доктора.
Что из этого следует на практике
maxmemoryбез политики — мина. Дефолтnoevictionозначает, что при достижении лимита записи начнут отказывать. Для кэша это почти всегда не то, что вы хотели.volatile-*без TTL — та же мина в профиль. Политика настроена, вытеснение не работает, поведение вырождается вnoeviction. Проверять надо не конфиг, аevicted_keysпод нагрузкой.- LFU не «лучше» LRU. Он лучше там, где частота обращений действительно различается. Там, где данные записаны и не читаются, LFU слепнет — и его красивая цифра выживаемости означает не защиту, а неразличимость.
- Высокий
mem_fragmentation_ratioсам по себе ничего не значит. Смотреть надо наallocator_frag_ratio; если он около единицы — аллокатор ни при чём, дело в RSS-накладных. - Сравнивать проценты выживаемости на паре прогонов бессмысленно. Сэмплирующее вытеснение шумит достаточно, чтобы нарисовать разницу, которой нет, — на этом стенде именно так и произошло.
Для нити «кэш или источник истины» вывод простой. Как кэш Redis/Valkey под давлением памяти ведёт себя предсказуемо и управляемо — надо лишь понимать, что политика выбирает жертву приближённо, а не оптимально. Как источник истины вытеснение недопустимо в принципе, а значит остаётся noeviction — и вместе с ним обязанность следить за приближением к лимиту, потому что отказ в записи придёт молча и внезапно.
Что дальше
Кодировки из первой статьи прямо влияют на то, сколько ключей поместится в лимит: структура, переключившаяся с компактной кодировки на полную, занимает кратно больше — а значит, раньше упрётся в maxmemory. Персистентность добавляет к этому форк под снимок и всплеск памяти на copy-on-write, который тоже надо укладывать в бюджет.
Дальше в серии — streams и Lua, где однопоточность из второй статьи превращается в практическую атомарность. Завершает серию эксплуатация и карта выбора: там evicted_keys, mem_fragmentation_ratio и прочие метрики этой статьи занимают своё место в наборе того, за чем следить, — и там же обнаружится, что MEMORY DOCTOR не единственный инструмент диагностики, который врёт.
Про Redis и Valkey в этом разделе честно сказать нечего: единственное расхождение, которое казалось устойчивым, пришлось снять после четвёртого прогона. Контекст форка — в отдельной статье про ValkeyСкоро.
Источники
- Официальная документация: redis.io, раздел redis.io/docs.
- Политики вытеснения и
maxmemory: redis.io/docs — Key eviction. - Оптимизация и диагностика памяти: redis.io/docs — Memory optimization.
- Справочник команд (
OBJECT FREQ,MEMORY DOCTOR,INFO memory): redis.io/commands. - Смежное на сайте: модель данных и кодировки (что занимает память), персистентность (форк и copy-on-write в бюджете памяти).
- Стенд: digital-cookbook/databases/redis/deep-dive (
redis:8.8,valkey/valkey:8.1, модульmemory-eviction: сценарииfill-until-oom,volatile-ttl-degenerate; оркестрацияops/eviction-demo.sh).
Комментарии