Память и вытеснение: как Redis живёт под давлением памяти

Redis держит данные в оперативной памяти — значит, память рано или поздно закончится. Разбираем, что происходит на границе: как устроен аллокатор и откуда берётся фрагментация, какие есть политики вытеснения (LRU/LFU/по TTL/noeviction), что значит upper bound через maxmemory и что делает Redis при OOM. Плюс инструменты диагностики: MEMORY DOCTOR, INFO memory.

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

Это пятая статья серии «Redis: глубокое погружение». Она напрямую продолжает нить «кэш vs источник истины»: в роли кэша вытеснение — норма и благо (данные восстановимы), в роли источника истины вытеснение недопустимо. Но главный сюжет статьи другой и неожиданный: самая красивая цифра в таблице вытеснения означает не то, что кажется.

Склад под потолок забит ящиками «filler», над ним табло «maxmemory 64 МиБ». Слева стеллаж «200 hot» со светящимися ящиками, справа «2000 cold» — заиндевевшие и в паутине. В центре инспектор вытеснения с завязанными глазами держит ровно пять случайных ящиков, рядом табличка «maxmemory-samples=5 — выборка, не глобальный поиск». Внизу слева инспектор LFU подносит к ящикам cold и filler два счётчика, оба показывают «4», под ним плашка «LFU не защищает cold — он его не отличает от filler». Рядом инспектор LRU с прибором возраста и плашкой «возраст различает — ×4–5.6». Дальше маскот volatile-ttl разводит руками над двумя одинаковыми песочными часами. Справа доктор со стетоскопом уверенно указывает вверх, под ним «фрагментации нет: allocator 1.00»

В статье

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Скоро.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии