MAATRIX / Блог / Миллион ключей в Redis против миллиона файлов на диске: где предел наступит раньше

Миллион ключей в Redis против миллиона файлов на диске: где предел наступит раньше

MAATRIX

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

Два разных предела: что вообще сравниваем

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

У Redis предел — оперативная память. Каждый ключ занимает не только байты значения, но и служебную структуру: запись в хеш-таблице базы, объект-обёртку значения, при необходимости — отдельные структуры для сложных типов (list, hash, set, zset). При миллионе ключей служебная часть перестаёт быть мелочью и начинает конкурировать по объёму с самими данными, особенно если значения короткие.

У файловой системы предел двойной. Первый — число inode, которое на многих ФС фиксируется на этапе форматирования и не резервируется под рост: кончились inode — получите No space left on device, даже если гигабайты диска свободны. Второй — не место, а время: операции над каталогом с миллионом записей (создание, поиск, листинг) деградируют нелинейно, если структура каталога к этому не готова.

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

Сколько весит один ключ в Redis: методика подсчёта

Не гадайте на глаз — Redis даёт инструменты для прямого измерения. Первый шаг — усреднённая оценка по всей базе:

redis-cli INFO memory | grep -E 'used_memory_human|used_memory_rss_human'
redis-cli DBSIZE

Разделив used_memory (в байтах, не human-версию) на DBSIZE, вы получите средний расход памяти на ключ по факту — с учётом всех накладных расходов вашей конкретной базы: структуры данных, длины ключей, длины значений, фрагментации. Это честнее любой теоретической оценки, потому что учитывает именно ваш паттерн данных.

Второй инструмент — точечная оценка одного ключа:

redis-cli MEMORY USAGE mykey
redis-cli MEMORY USAGE mykey SAMPLES 0

SAMPLES 0 заставляет Redis пройти по всей структуре целиком (актуально для больших hash/set/zset), а не оценивать по выборке — это медленнее, но точнее для крупных коллекций.

Из чего складывается память на ключ концептуально: запись в хеш-таблице базы (указатель на ключ, указатель на значение, служебные поля), обёртка значения (объект Redis с типом и метаданными), строка ключа с собственным заголовком, и — для строковых значений — заголовок SDS (Simple Dynamic String), в котором Redis хранит длину и капасити строки отдельно от C-строк. При коротких значениях (счётчик, флаг, короткий токен) вся эта служебная часть может оказаться сопоставима по размеру с самим значением или больше него. Точные цифры зависят от версии Redis, maxmemory-policy, длины ключей и архитектуры — не подставляйте чужие числа из статей, измеряйте на своих данных через MEMORY USAGE и INFO memory.

Отдельный момент — рост хеш-таблицы базы данных. Redis хранит ключи в динамической хеш-таблице, которая расширяется по мере роста их числа и периодически проходит через rehashing (постепенное перекладывание в таблицу большего размера). Во время rehashing одновременно существуют старая и новая таблицы — на пике возможен временный дополнительный расход памяти сверх «стабильного» состояния. Если вы вплотную подошли к лимиту maxmemory, миллион новых ключей, вставленных одной волной, может спровоцировать OOM именно в момент rehashing.

Практическая методика оценки перед ростом до миллиона:

  1. Возьмите текущую базу (или тестовый набор, максимально похожий на прод по длине ключей и типу значений).
  2. Снимите used_memory и DBSIZE, посчитайте среднее байт/ключ.
  3. Умножьте на целевое число ключей — получите грубую оценку итоговой памяти.
  4. Добавьте запас 20-40% на rehashing, фрагментацию и рост значений — это не измеренная цифра, а инженерный запас, конкретный процент подбирайте по критичности сервиса.
  5. Сравните с реальным объёмом RAM на сервере и с maxmemory, если он установлен.

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

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

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

Что происходит при персистентности: RDB и AOF под миллионом ключей

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

RDB-снапшот Redis делает через fork(): дочерний процесс получает copy-on-write копию адресного пространства родителя и пишет консистентный снимок на диск, пока родитель продолжает обслуживать команды. Сам fork на большом резидентном объёме памяти не бесплатен, а на инстансах с уже плотно занятой памятью и активной записью может дать заметный кратковременный скачок потребления — страницы копируются (copy-on-write срабатывает) быстрее, чем снапшот успевает их прочитать. Подробно механика разобрана в статье про RDB-снапшот и когда этот фокус не срабатывает — если персистентность настроена на прод с большой базой, стоит понимать это заранее.

AOF (Append Only File) — журнал команд, который растёт вместе с числом операций, а не только с числом уникальных ключей: при активной перезаписи значений AOF-файл может стать заметно больше самого датасета в памяти. Redis периодически делает BGREWRITEAOF, компактируя журнал до минимального набора команд — но перезапись снова использует fork и требует свободных ресурсов на момент запуска.

Практический вывод: при планировании памяти под миллион ключей закладывайте не только «данные плюс служебные структуры», но и пиковый расход в момент RDB-снапшота или AOF-перезаписи. Если maxmemory стоит впритык к физической RAM без запаса — именно момент персистентности статистически самый вероятный триггер OOM-killer или падения в своп.

Файловая система: inode, каталоги и цена одного файла

Теперь вторая сторона. У файловой системы миллион файлов бьёт по двум независимым ресурсам: числу inode и производительности операций над каталогом.

Каждый файл — это как минимум один inode: структура с метаданными (владелец, права, время изменения, указатели на блоки данных). Число inode на многих файловых системах (в первую очередь ext4) фиксируется при форматировании и не меняется автоматически при росте свободного места. Посмотреть текущее состояние:

df -i /path/to/mount

Колонки IUsed и IFree покажут, сколько inode занято и сколько осталось — независимо от того, сколько гигабайт свободно в df -h. Если IFree близко к нулю, создание нового файла упадёт с No space left on device, даже если места на диске полно. Это классическая ловушка, разобрана подробнее в статьях про то, что такое inode и почему место есть, а файл не создаётся и про нехватку inode при свободном месте.

Число inode задаётся при mkfs параметром отношения байт-на-inode (у ext4 значение по умолчанию зависит от версии утилит и профиля форматирования, проверяйте через mkfs.ext4 -n или tune2fs -l). Для множества мелких файлов стандартное соотношение может оказаться недостаточным; для относительно немногочисленных крупных файлов запаса хватит с большим избытком. Проблема возникает при смешении: расчёт делался под «средний» файл, а по факту система хранит миллионы файлов по несколько байт (флаги, блокировки, кэш-объекты на диске).

Второй предел — не место, а скорость операций над самим каталогом. Классический ext2/ext3 без индекса каталога искал файл по имени линейным перебором записей — на директории с сотнями тысяч файлов это ощутимо деградирует. Современный ext4 по умолчанию включает dir_index (htree-индекс на основе хеша имени), что заметно улучшает ситуацию, но у больших плоских каталогов остаётся узкое место: листинг (ls, readdir) всё равно обязан пройти все записи каталога, даже если поиск конкретного имени стал быстрым. Поэтому ls на каталоге с миллионом файлов может заметно подвисать, хотя открытие одного конкретного файла по имени — нет.

Где на практике упирается диск: листинг, бэкап, фрагментация

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

Первый симптом — листинг и обход. Команды вроде ls -la, find, du -sh вынуждены обойти метаданные каждого файла. На каталоге с миллионом записей du без специальных флагов может идти минутами там, где для сотен файлов уходили доли секунды — это не чтение содержимого, а чтение метаданных по каждой записи. Подробный разбор — в статье про миллион мелких файлов и почему это убивает диск.

Второй симптом — бэкап и синхронизация. Инструменты вроде rsync или tar тратят время не столько на передачу байт, сколько на построение списка файлов и обработку метаданных каждого — при миллионе файлов сам этот пересчёт становится заметной частью общего времени операции, независимо от объёма передаваемых данных.

Третий симптом — фрагментация, актуальнее на HDD, но частично проявляется и на SSD. Создание и удаление множества мелких файлов вперемешку с крупными приводит к тому, что свободное пространство дробится на мелкие несмежные куски, а новые файлы раскладываются экстентами по всему разделу. На HDD это просадка из-за позиционирования головки; на SSD физического позиционирования нет, но растут накладные расходы контроллера на управление свободным пространством.

Практический вывод для файловой стороны: если заведомо будут миллионы мелких сущностей — не кладите их плоским списком в одну директорию. Разносите по поддиректориям (например, по первым символам хеша имени — классическая схема шардирования каталогов), либо переходите на хранилище, спроектированное под мелкие объекты, а не эксплуатируйте файловую систему как key-value store, для которого она не проектировалась.

Прямое сравнение: таблица и на что смотреть

КритерийRedis (миллион ключей)Файловая система (миллион файлов)
Основной ресурс, в который упираетесьОперативная памятьЧисло inode и время операций над каталогом
Что проверить перед ростомINFO memory, DBSIZE, MEMORY USAGEdf -i, структура каталогов, тип ФС
Пиковая нагрузкаМомент RDB-снапшота / AOF-rewrite (fork, copy-on-write)Массовое создание/удаление файлов, полный листинг каталога
Деградация при ростеПлавная (память кончается предсказуемо) до внезапного OOM у лимитаНе по объёму данных, а по числу файлов; листинг/бэкап замедляются нелинейно
Что решает проблемуБольше RAM, TTL, сжатие значений, шардирование по инстансамШардирование каталогов, смена ФС/профиля inode, переход на другое хранилище
Восстановление после сбояИз RDB/AOF, зависит от настроек fsyncfsck на большом числе inode может идти заметно дольше обычного

Ключевое отличие в характере предела: у Redis потолок памяти известен заранее и растёт предсказуемо линейно с числом ключей — его можно спрогнозировать по формуле из раздела про методику. У файловой системы предел двойной и менее очевидный: можно упереться в inode при формально свободном месте, а можно не упереться в inode вообще, но получить деградацию по времени операций, которую заранее посчитать сложнее — она зависит от типа ФС, версии ядра и настроек индексации каталога.

Что выбрать для конкретной задачи: кэш, очередь, метаданные

Единого правильного ответа нет — выбор зависит от того, что вы храните и как к этому обращаетесь.

Кэш. Здесь Redis почти всегда предпочтительнее. Кэш подразумевает частое обновление, TTL, атомарные операции инкремента/декремента и вытеснение по политике (maxmemory-policy). ФС не даёт TTL из коробки, а частое создание и удаление временных файлов — прямой путь к фрагментации и исчерпанию inode. Файлы под кэш оправданы, только если объём заведомо больше доступной RAM и вы сознательно платите задержкой диска за объём (например, кэш превью изображений).

Очередь. Смотрите на durability и объём. Лёгкая очередь с терпимостью к потере части сообщений при падении — кандидат для структур Redis (list, stream), при этом стоит продумать AOF с разумным appendfsync, иначе при падении сервера очередь исчезнет. Если сообщения должны переживать любой сбой и копиться месяцами — это задача для дискового брокера с журналом на диске, спроектированного под такой паттерн, а не для сырых файлов на ФС и не для Redis без осознанной персистентности.

Метаданные. Чаще всего ошибаются в сторону «один файл — один объект метаданных» (JSON-файл на пользователя, файл-флаг на задачу). При росте до сотен тысяч это прямой путь к проблеме inode и медленному листингу. Практичнее держать метаданные в одной структуре: в Redis (hash на объект или один hash-ключ на группу, чтобы не плодить миллион top-level ключей), в лёгкой встраиваемой базе (SQLite и подобные) или в полноценной СУБД, если нужны сложные запросы. Файлы оправданы, только если метаданные должны существовать как отдельные файлы по внешним причинам — например, их читает стороннее ПО.

Простой ориентир: сущностей будут сотни тысяч и больше, а операции — точечный доступ по ключу с частым обновлением? Redis, с расчётом памяти по методике выше. Сущностей много, но обращения редкие, объём на сущность крупный, а persistence нужна «из коробки» на уровне ФС? Файлы, но с продуманной структурой каталогов и заранее рассчитанным числом inode.

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

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

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

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

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

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

Как быстро понять, что Redis скоро упрётся в память?

Смотрите used_memory относительно maxmemory (если он задан) или относительно физической RAM сервера, и держите в уме, что пиковое потребление в момент RDB-снапшота или AOF-rewrite выше «спокойного» уровня — не ставьте лимит впритык к физической памяти.

Можно ли увеличить число inode на уже отформатированном разделе ext4?

Штатно нет — число inode ext4 фиксируется при mkfs. Надёжный путь — пересоздать ФС с другим соотношением байт-на-inode и перенести данные, либо перейти на ФС без такого жёсткого лимита.

Что произойдёт раньше — Redis упрётся в память или ФС в inode?

Зависит от размера значений/файлов и объёма RAM/диска. Мелкие значения при небольшой RAM — Redis упрётся раньше; крупный диск с некорректно рассчитанным числом inode под мелкие файлы — ФС упрётся раньше. Прогоните обе методики на своих реальных данных.

Стоит ли класть файлы в Redis как blob-хранилище, чтобы обойти проблему inode?

Для небольших и не очень многочисленных бинарных объектов — рабочий вариант, но Redis не заменяет файловое или объектное хранилище: вся база должна помещаться в RAM.

Как избежать проблемы миллиона файлов, если архитектура требует именно файлов?

Шардируйте по поддиректориям (например, по первым символам хеша имени), заранее считайте нужное число inode при форматировании под этот профиль нагрузки, и рассмотрите объектное хранилище как альтернативу плоской структуре.

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

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

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