MAATRIX / Блог / Redis рос до самого перезапуска: забыли про maxmemory-policy

Redis рос до самого перезапуска: забыли про maxmemory-policy

MAATRIX

Redis на проде вёл себя странно уже вторую неделю: сервер работал нормально почти сутки, а потом всё чаще подтормаживал, память утекала куда-то в своп, а под утро приходил алерт «high memory usage» — и сам же гас через полчаса. Никто целенаправленно ничего не перезапускал, поэтому все решили, что «само прошло». На деле само ничего не проходило: просто в 4:10 по расписанию срабатывала другая задача, которая заодно перезапускала redis, и это каждый раз сбрасывало счётчик до нуля. Разбор инцидента ниже — как мы месяц гонялись за призраком и в итоге упёрлись в одну забытую строчку в конфиге.

Как это выглядело со стороны

Сервис — обычный API на Node.js с Redis в роли кеша ответов, хранилища сессий и счётчиков rate-limiter. Redis крутился на выделенном сервере с 8 ГБ RAM, отдельно от приложения. Первые жалобы пришли не от разработчиков, а от поддержки: часть пользователей стала жаловаться на то, что API отвечает за 3-5 секунд вместо привычных десятков миллисекунд, причём проблема то есть, то её нет.

Паттерн вырисовался не сразу. top на сервере Redis показывал, что процесс redis-server постепенно наращивает RSS — сначала 2 ГБ, через несколько часов 4 ГБ, к вечеру 6-7 ГБ. При приближении к физическому потолку памяти сервер начинал уходить в своп (мы туда специально заглянули, вспомнив, что своп вместо памяти — это уже отдельная категория проблем, а не решение), задержки по всем командам росли кратно, а через какое-то время память снова падала до 2 ГБ — сама, без вмешательства человека. Выглядело так, будто кто-то каждую ночь чистит Redis вручную.

Первая версия — «у нас где-to утечка памяти в самом Redis» — быстро провалилась при первой же проверке redis-cli --version: версия была стабильная, LTS, без открытых issue на этот счёт. Вторая версия — «это OOM killer выкашивает процесс» — тоже не подтвердилась: в dmesg не было ни одной записи про Out of memory: Killed process, а именно это первым делом смотрят, когда подозревают убийцу (мы недавно как раз разбирали, как ядро выбирает жертву OOM killer, и точно знали, что там должна остаться запись в логе). Process id redis-server вообще не менялся сутками — значит, никакого краша и рестарта самого процесса не было. Память падала, а сам Redis не перезапускался. Это меняло всю картину розыска.

Что показывали логи и метрики

Мы включили более частый опрос INFO memory и INFO stats — раз в минуту вместо раза в пять минут — и стали смотреть на конкретные поля, а не только на суммарный RSS из top.

redis-cli INFO memory | grep -E "used_memory:|used_memory_rss:|maxmemory:|maxmemory_policy:|mem_fragmentation_ratio:"

Результат в разные моменты суток выглядел примерно так:

# днём, после свежего старта
used_memory:2147483648
used_memory_rss:2202009600
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.03

# вечером, перед деградацией
used_memory:7025459200
used_memory_rss:7180328960
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.02

Два поля сразу бросились в глаза. Во-первых, mem_fragmentation_ratio держался около 1.0 — то есть физическая память, которую реально ест процесс, почти точно равна логическому объёму данных внутри Redis. Это исключало версию про фрагментацию аллокатора: если бы дело было в ней, коэффициент рос бы вместе с памятью, а тут он был стабилен. Во-вторых, maxmemory:0 — лимит памяти не был выставлен вообще, Redis мог расти, пока не съест всю доступную RAM хоста.

Дальше посмотрели INFO stats, конкретно на evicted_keys и expired_keys:

redis-cli INFO stats | grep -E "evicted_keys:|expired_keys:|keyspace_hits:|keyspace_misses:"
evicted_keys:0
expired_keys:184213
keyspace_hits:9820441
keyspace_misses:512033

evicted_keys был нулём всё время наблюдения. Это логично при maxmemory:0 — эвикшену просто неоткуда взяться, лимита нет, вытеснять нечего. А вот expired_keys рос, но не так быстро, как хотелось бы: часть ключей истекала по TTL нормально, но общий объём данных внутри всё равно тянулся вверх день ото дня. Это указывало на то, что либо в систему постоянно льётся больше ключей, чем истекает, либо какая-то часть ключей вообще не имеет TTL и оседает навсегда.

Проверили через redis-cli --scan с выборочным TTL по ключам разных префиксов:

redis-cli --scan --pattern 'ratelimit:*' | head -20 | while read key; do
  echo "$key -> $(redis-cli TTL "$key")"
done

По ключам session:* и cache:* TTL стоял почти у всех — это ожидаемо, их выставляет код явно через SETEX. А вот у части ключей ratelimit:* TTL был -1, то есть «живёт вечно». Уже теплее.

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

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

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

Гипотезы, которые мы отбросили

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

  • Утечка памяти в самом Redis. Отбросили сразу — mem_fragmentation_ratio около 1.0 и совпадение used_memory с used_memory_rss говорят о том, что память тратится на реальные данные, а не теряется в аллокаторе.
  • Один гигантский ключ, который распух. Прогнали redis-cli --bigkeys и выборочно MEMORY USAGE по топовым ключам — самый крупный ключ занимал около 40 КБ, ничего похожего на многогигабайтный объект не нашли. Рост шёл не за счёт размера, а за счёт количества ключей.
  • Утечка на стороне приложения (клиент не закрывает соединения, копится буфер). Проверили число подключений через CLIENT LIST | wc -l — держалось стабильно в районе пула соединений из конфига приложения, скачков не было. Значит, дело не в клиентах.
  • Баг в TTL для сессий. Часть кода действительно не выставляла TTL для сессий в редком edge-case (когда пользователь логинился через "вход по ссылке из письма"), но при подсчёте объёма эти сессии давали от силы пару сотен мегабайт за сутки — заметно меньше, чем реальный рост в несколько гигабайт. Это была настоящая проблема, но не главная.
  • Плановые фоновые задачи, которые копят данные в Redis "на всякий случай". Проверили cron-джобы — нашли одну, которая писала промежуточные агрегаты в Redis, но с EXPIRE на 1 час, и они действительно истекали вовремя.

Каждая из версий давала объяснение части картины, но ни одна не объясняла главного: почему процесс мог расти без всякого потолка. Ответ лежал не в коде и не в данных, а в конфигурации самого Redis.

Реальная причина: maxmemory-policy и потерянный лимит

Когда полгода назад Redis разворачивали, лимит памяти был выставлен осознанно:

maxmemory 6gb
maxmemory-policy allkeys-lru

Логика была понятной: держим кеш в разумных границах, а если места не хватает — вытесняем наименее используемые ключи по LRU. Всё работало нормально до планового обновления Redis на минорную версию с applied security patch. Обновление накатывалось через Ansible-роль, которая на предыдущей итерации была написана с шаблоном конфига, беря redis.conf из upstream-пакета и накладывая поверх только несколько строк (bind-адрес, пароль, порт). Лимит памяти и политику вытеснения туда добавили вручную один раз, руками, через redis-cli CONFIG SET, и забыли зафиксировать это в самом шаблоне конфигурации — то есть настройка жила только в runtime-состоянии процесса, а не в файле.

Когда роль обновляла пакет, она делала штатный systemctl restart redis, чтобы подхватить новый бинарник. Redis перечитывал redis.conf с диска — тот самый шаблонный файл без maxmemory и maxmemory-policy. После рестарта эти параметры вернулись к значениям по умолчанию:

redis-cli CONFIG GET maxmemory
1) "maxmemory"
2) "0"

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"

maxmemory 0 означает «без ограничения» — Redis будет расти, пока не упрётся в физическую память хоста или в лимиты cgroup, если они заданы. А noeviction — это политика по умолчанию в самом Redis: даже если бы лимит остался, но кто-то один раз явно не прописал allkeys-lru или volatile-lru, при достижении потолка Redis не вытесняет ключи, а просто отклоняет новые команды записи ошибкой OOM command not allowed. В нашем случае лимита не было вовсе, поэтому даже эта защита не срабатывала — процесс просто рос без остановки, набирая ключи ratelimit:* без TTL и раздувающийся объём сессий из edge-case, пока не начинал давить на память хоста и уходить в своп.

А что с "ночным перезапуском", который всё это маскировал? Расследование привело к отдельному cron-заданию на этом же сервере — резервному копированию через BGSAVE с последующей ротацией AOF-файла, которое кто-то в прошлом дополнил строчкой systemctl restart redis "на всякий случай", чтобы гарантированно освобождать память после бэкапа. Формально это работало: рестарт сбрасывал всю накопленную за сутки память в ноль, RSS падал обратно к 2 ГБ, алерт по памяти гас сам собой. Именно поэтому проблема выглядела так, будто "сама себя лечит" — просто у неё был скрытый костыль, который включался раз в сутки и заметал следы.

Как мы это подтвердили

Чтобы не полагаться на догадки, воспроизвели сценарий на отдельном тестовом Redis с той же версией и урезанным конфигом (без maxmemory и maxmemory-policy, то есть значениями по умолчанию). Написали простой скрипт, имитирующий трафик rate-limiter без TTL:

import redis
import time

r = redis.Redis(host="localhost", port=6379)

i = 0
while True:
    r.incr(f"ratelimit:user:{i}")   # без EXPIRE - воспроизводим баг
    i += 1
    if i % 10000 == 0:
        info = r.info("memory")
        print(f"keys={i} used_memory={info['used_memory']}")
    time.sleep(0.001)

За несколько часов работы used_memory рос линейно, evicted_keys оставался нулём, а mem_fragmentation_ratio держался около 1.0 — та же картина, что и на проде. После добавления maxmemory 512mb и maxmemory-policy allkeys-lru в тот же тест поведение изменилось сразу: как только used_memory подходил к лимиту, счётчик evicted_keys начинал расти, а used_memory переставал увеличиваться дальше потолка. Это подтвердило причину: дело было именно в отсутствии лимита и политики вытеснения, а не в постороннем факторе.

Что изменили после инцидента

Правки разбили на три слоя: сама конфигурация Redis, процесс её деплоя и мониторинг, чтобы в следующий раз не потребовался ещё один детектив.

В redis.conf вернули явные значения и, что важнее, зафиксировали их в самом шаблоне деплоя, а не только в runtime:

maxmemory 6gb
maxmemory-policy allkeys-lru
maxmemory-samples 10

Значение maxmemory посчитали от объёма RAM хоста с запасом под саму ОС и файловый кеш, а не "впритык" — грубое правило, которым пользуемся: не больше 70-75% физической памяти сервера, остальное — операционной системе и странице кеша (мы отдельно разбирали, куда девается память, которая выглядит "свободной" из-за page cache, и почему не стоит выжимать лимит под ноль). Политику выбрали allkeys-lru, а не volatile-lru, сознательно: у нас есть ключи без TTL по дизайну (агрегаты rate-limiter, которые мы теперь сами очищаем логикой приложения), и хотелось, чтобы вытеснение работало по всей базе данных, а не только по ключам с истечением.

Отдельно закрыли причину, по которой конфиг вообще потерялся при обновлении: в Ansible-роль добавили проверку, которая после применения конфигурации сверяет фактическое состояние через redis-cli CONFIG GET с ожидаемыми значениями и падает с ошибкой, если что-то разошлось:

- name: Verify maxmemory settings after restart
  ansible.builtin.command: redis-cli CONFIG GET maxmemory-policy
  register: policy_check
  failed_when: "'allkeys-lru' not in policy_check.stdout"

Так любые расхождения конфига с ожидаемым состоянием видны сразу после деплоя, а не через полгода на проде.

Убрали и скрытый костыль с ночным systemctl restart redis из бэкап-джобы — вместо него оставили только BGSAVE, а память теперь должна регулироваться самим Redis через maxmemory, а не человеческим "перезапустить и не думать". Заодно завели алерты в Prometheus именно на соотношение used_memory/maxmemory и на evicted_keys per rate — если вытеснение внезапно начинает расти резко, это сигнал, что приложение льёт в кеш больше, чем ожидалось, и стоит разбираться заранее, а не ждать деградации (общий подход к тому, как строить такие алерты, мы описывали в материале про мониторинг баз данных через Grafana). И исправили тот самый edge-case с логином по ссылке из письма, где сессия создавалась без EXPIRE — раньше он терялся в фоне лимита noeviction, а без потолка стал одной из причин лишнего роста.

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

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

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

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

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

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

Почему Redis по умолчанию не ограничивает память сам?

Так исторически устроен сам процесс — по умолчанию maxmemory равен 0, что означает "без лимита", а maxmemory-policy стоит в `noeviction". Разработчики Redis исходят из того, что администратор сам решает, сколько памяти выделить конкретному инстансу под конкретную задачу, а не гадает за него — но это же значит, что если лимит забыть выставить, потолка не будет вообще.

В чём разница между allkeys-lru и volatile-lru?

allkeys-lru вытесняет по принципу "давно не использовался" среди всех ключей в базе, включая те, у которых не задан TTL. volatile-lru трогает только ключи с выставленным TTL, а ключи без срока жизни считает "неприкосновенными" и никогда не вытесняет — если у вас есть данные без TTL, которые не критичны для сохранности, и вы хотите защититься именно от такого сценария роста, allkeys-lru безопаснее.

Как быстро проверить, стоит ли у меня эта проблема прямо сейчас?

Одна команда: redis-cli CONFIG GET maxmemory и redis-cli CONFIG GET maxmemory-policy. Если первое значение "0", а второе — "noeviction", лимита памяти у вас фактически нет, и стоит решить осознанно, какое значение выставить под конкретный сервер, а не оставлять как есть.

Что делать, если maxmemory уже настроен, но Redis всё равно упирается в OOM?

Стоит проверить mem_fragmentation_ratio из INFO memory — если он заметно больше 1.0-1.2, дело может быть в фрагментации аллокатора, а не в отсутствии лимита; это уже отдельная история с настройкой activedefrag и параметров jemalloc, а не с политикой вытеснения.

Можно ли выставить maxmemory в проценте от RAM автоматически при старте?

Сам Redis такого не умеет, но это несложно сделать обвязкой в systemd unit или в скрипте запуска, который считает нужное значение от /proc/meminfo и передаёт его через --maxmemory при старте процесса или прописывает в сгенерированный redis.conf перед запуском.

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

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

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