Если вы знаете, почему простая строка `strings` в Redis займёт в оперативной памяти 56 байт — вам, думаю, статья не будет интересна. Всем остальным я попробую рассказать, что такое строки в Redis и почему использующему эту базу данных разработчику важно понимать, как они устроены и работают. Это знание особенно важно, если вы пытаетесь рассчитать фактическое потребление памяти вашим приложением или планируете строить высоко нагруженные системы статистики или учёта данных. Или, как часто бывает, пытаетесь срочно понять, почему вдруг ваш экземпляр redis стал потреблять неожиданно много памяти.

О чём буду рассказывать — как хранятся строки в Redis, какие внутренние структуры используются для хранения строк, какие виды оптимизаций использует Redis под капотом. Как эффективно хранить большие структуры и в каких ситуациях не стоит использовать строки или структуры, построенные на их основе. Строки — ключевая структура Redis, HSET/ZSET/LIST построены на их базе добавляя небольшой оверхед на представление своих внутренних структур. Зачем мне эта статья — более года я читаю и активно отвечаю на stackoverflow в тэге redis. На протяжение этого времени я постоянно вижу не прекращающийся поток вопросов так или иначе связанных с тем, что разработчики не понимают особенностей работы Redis с оперативной памятью и того, чем приходится расплачиваться за высокое быстродействие.

Ответ на вопрос сколько памяти будет использовано на самом деле зависит от операционной системы, компилятора, типа вашего процесса и используемого аллокатора(в redis по умолчанию jemalloc). Все дальнейшие расчёты я привожу для redis 3.0.5 собранном на 64 битном сервере под управлением centos 7.
Мне кажется, что тут совершенно необходима небольшая интерлюдия для тех, кто не пишет на с/с++ или не очень хорошо знаком с тем как всё работает на низком уровне. Давайте чудовищно сильно упростим несколько понятий, чтобы вам проще было понимать расчёты. Когда в программе на с/с++ вы объявляете структуру, и в ней у вас есть unsigned int поля (без знаковые целые на 4 байта) компилятор бережно выровняет их размер до 8 байт в реальной оперативной памяти (для х64 архитектуры). В это статье будет периодически говорится об аллокаторе памяти — эта такая штука которая выделяет память «по умному». Например, jemalloc старается оптимизировать для вас скорость поиска новых блоков памяти, делая ставку на выравнивание выделяемых фрагментов. Стратегия выделения и выравнивания памяти в jemalloс хорошо описана, однако я думаю, что нам стоит использовать упрощение, что любой размер выделенного фрагмента будет округлён до ближайшей степени 2. Вы просите 24 байт — выделят 32. Просите 61 — выделят 64. Я сильно упрощаю и надеюсь вам будет немного понятнее. Это те вещи, которые в интерпретируемых языках вас, по логике, волновать не должны, однако тут очень прошу вас обратить на них своё внимание.

Концепция и реализация строк за авторством Сальваторе Санфилиппо (aka antirez) лежит в одном из под проектов редиса под названием SDS (Simple Dynamic String, github.com/antirez/sds):
+--------+-------------------------------+-----------+
| Header | Binary safe C alike string... | Null term |
+--------+-------------------------------+-----------+
         |
         `-> Pointer returned to the user.


Это простая структура на `с`, заголовок которой хранит актуальный размер и свободное место в уже выделенной памяти, саму строку и обязательный завершающий ноль, который добавляет сам редис. В sds строках нас больше всего будет интересовать расходы на заголовок, стратегия изменения их размера и пенальти на выравнивание структур при выделении памяти.

4 июля 2015 года завершилась длинная история с оптимизацией строк, которой должна попасть в редис 3.1. Эта оптимизация привнесёт большую экономию памяти в заголовках строк (от 16% до 200% на синтетических тестах). Снимет ограничение в 512Мб на максимальную длину строки в редисе. Всё это станет возможным благодаря динамическому изменения длины заголовка при изменении длины строки. Так заголовок будет занимать всего 3 байта для строк с длиной до 256 байт, 5 байт для строк менее 65 кб, 9 байт (как сейчас) для строк до 512 мб и 17 байт для строк, чей размер «влазит» в uint64_t (64 битное без знаковое целое). К слову, с этим изменением наша реальная ферма экономит порядка 19,3% памяти (~42 гб). Однако, в последний на текущей момент 3.0.х с заголовком всё просто — 8 байт + 1 байт на завершающий ноль. Давайте прикинем, сколько памяти займет строка «strings»: 16 (заголовок) + 7 (длина строки) + 1(завершающий ноль) = 24 байта (16 байт на заголовок, т.к. компилятор выровняет для вас 2 unsigned int). При этом jemalloc выделит для вас 32 байта. Давайте пока это опустим (надеюсь позже будет понятно почему).

Что происходит, когда строка меняет размер? Всякий раз когда вы увеличиваете размер строки и уже выделенной памяти не хватает, редис сверяет новую длину с константой SDS_MAX_PREALLOC (определена в sds.h и составляет 1,048,576 байт). Если новая длина меньше этой величины, будет выделена память вдвое больше запрашиваемой. Если длина строки уже превышает SDS_MAX_PREALLOC — к новой запрашиваемой длине будет добавлено значение этой константы. Эту особенность будет важна в истории «про исчезающую память при использование bitmap». К слову сказать, при выделении памяти под bitmap всегда будет выделено в 2 раза больше запрошенного, из-за особенностей реализации команды setbit (смотри setbitCommand в bitops.c).

Теперь можно было бы сказать, что наша строка займёт в оперативной памяти 32 байта (с учетом выравнивания). Те, кто читал советы ребят из hashedin.com (redis memory optimization guide) могут вспомнить, что они настоятельно рекомендуют не использовать строки длиной менее 100 байт, т.к. для хранения короткой строки, скажем при использовании команды `set foo bar` вы потратите ~96 байт из которых 90 байт это оверхед (на 64 битной машине). Лукавят? Давайте разбираться дальше.

Все значения в редис хранятся в структуре типа redisObject. Это позволяет редису знать тип значения, его внутреннее представление (в редисе это называется кодировкой), данные для LRU, количество ссылающихся на значение объектов и непосредственно само значение:
+------+----------+-----+----------+-------------------------+
| Type | Encoding | LRU | RefCount | Pointer to  data (ptr*) |
+------+----------+-----+----------+-------------------------+


Чуть позже посчитаем её размер для нашей строки с учётом выравнивания компилятора и особенностей jemalloc. В разрезе строк нам очень важно знать, какие кодировки используются для хранения строк. Прямо сейчас редис использует три различные стратегии хранения:

  1. REDIS_ENCODING_INT достаточно прост. Строки могут хранится в таком виде, если значение приведённое к long значение находится в диапазоне LONG_MIN, LONG_MAX. Так, строка «dict» будет хранится именно в виде этой кодировки и будет представлять собой число 1952672100 (0x74636964). Эта же кодировка используется для предварительно выделенного диапазона специальных значений в диапазоне REDIS_SHARED_INTEGERS (определён в redis.h и равен по умолчанию 10000). Значения их этого диапазона выделяются сразу при старте редиса.
  2. REDIS_ENCODING_EMBSTR используется для строк с длиной до 39 байт (значение константы REDIS_ENCODING_EMBSTR_SIZE_LIMIT из object.c). Это означает, что redisObject и структура с sds строкой будут размещена в единой области памяти выделенной аллокатором. Помня это, мы правильно сможем посчитать выравнивание. Впрочем, это не менее важно для понимания проблемы фрагментации памяти в редисе и то, как с этим жить.
  3. REDIS_ENCODING_RAW используется для всех строк, чья длина превышает REDIS_ENCODING_EMBSTR_SIZE_LIMIT. В этом случае наш ptr* хранит обычный указатель на область памяти с sds строкой.

EMBSTR появились в 2012 году и привнесли 60-70% увеличение производительности при работе с короткими строками, однако серьезных изысканий на тему влияния на память и её фрагментацию нет до сих пор.

Длина нашей строки «strings» всего 7 байт, т.е. тип её внутреннего представления — EMBSTR. Созданная таким образом строка размещена в памяти вот так:
+--------------+--------------+------------+--------+----+
| robj data... | robj->ptr    | sds header | string | \0 |
+--------------+-----+--------+------------+--------+----+
                     |                       ^
                     +-----------------------+

Теперь мы готовы посчитать сколько оперативной памяти потребуется redis для хранения нашей строки «strings».
(4 + 4)* + 8(encoding) + 8 (lru) + 8 (refcount) + 8 (ptr) + 16 (sds header) + 7(сама строка) + 1 (завершающий ноль) = 56 байт.

Тип и значение в redisObject используют только 4 младших и старших бита одного числа, поэтому эти два поля после выравнивания займут 8 байт.

Давайте проверим, что я не вожу вас за нос. Посмотрим кодировку и значение. Воспользуемся одной мало известной командой для отладки строк — DEBUG SDSLEN. К слову, команды нет в официальной документации, она была добавлена в redis 2.6 и бывает очень полезна:
set key strings
+OK
debug object key
+Value at:0x7fa037c35dc0 refcount:1 encoding:embstr serializedlength:8 lru:3802212 lru_seconds_idle:14
debug sdslen key
+key_sds_len:3, key_sds_avail:0, val_sds_len:7, val_sds_avail:0

Используемая кодировка — embstr, длина строки 7 байт (val_sds_len). Что насчёт тех 96 байт, про которые говорили парни из hashedin.com? В моём понимании, они немного ошиблись, их пример с `set foo bar` потребует выделение 112 байт оперативной памяти (56 байт на значение и столько же на ключ), из которых 106 — оверхед.

Чуть выше я обещал историю про исчезающую память при использование BITMAP. Особенность о которой я хочу рассказать, постоянно утекает из внимания части разработчиков, её использующих. Парни, на этом регулярно зарабатывают консультанты по оптимизации памяти. Такие как redis-labs или datadog. Семейство команда «Bit and byte level operations» появились в redis 2.2 и сразу позиционировались как палочка выручалочка для счётчиков реального времени (например, статья от Spool), которые позволяют экономить память. В официальном гайде по оптимизации памяти тоже есть рекламный слоган об использовании этого семейства данных для хранения онлайна «Для 100 миллионов пользователей эта данные займут всего 12 мегабайт оперативной памяти». В описании SETBIT и SETRANGE предупреждают о возможных лагах работы сервера при выделении памяти, опуская при этом важный, как мне кажется, раздел «Когда вам не стоит использовать BITMAP» или «Когда лучше использовать SET вместо BITMAP».

Вооружившись пониманием того, как растут строки в редисе можно заметить, что bitmap:
  • не стоит использовать для разреженных данных.
  • понимать отношение полезной и реальной нагрузки (об этом на примере ниже).
  • учитывать динамику заполнения вашего bitmap.

Рассмотрим на примере. Предположим, что у вас зарегистрировано до 10 млн человек и ваш десяти миллионный пользователь вышел онлайн:
setbit online 10000000 1
:0
debug sdslen online
+key_sds_len:6, key_sds_avail:0, val_sds_len:1250001, val_sds_avail:1048576

Ваш фактический расход памяти составил 2,298,577 байт, при «полезной» для вас 1,250,001 байтах. Хранение одного вашего пользователя обошлось вам в ~2,3 мб. Используя SET вам потребовалось бы ~64 байта (при 4 байтах полезной нагрузки). Нужно правильно выбирать интервалы агрегации так, чтобы снижать разреженность данных и стараться, чтобы заполнение bitmap лежало в диапазоне от 30% — в этом случае вы на самом деле будете эффективно использовать память под эту структуру данных. Я говорю это к тому, что если у вас многомиллионная аудитория, а часовой онлайн скажем 10,000 — 100,000 человек то использовать для этой цели bitmap может быть накладным по памяти.

Наконец, изменение размеров строк в редисе — это постоянное перераспределение блоков памяти. Фрагментации памяти ещё одна специфика редис, о котором разработчики мало задумываются.
info memory
$222
# Memory
used_memory:506920
used_memory_human:495.04K
used_memory_rss:7565312
used_memory_peak:2810024
used_memory_peak_human:2.68M
used_memory_lua:36864
mem_fragmentation_ratio:14.92
mem_allocator:jemalloc-3.6.0

Метрика mem_fragmentation_ratio показывает отношение выделенной операционной системой памятью (used_memory_rss) и памятью, используемой редисом (used_memory). При этом used_memory и used_memory_rss уже включат в себя как сами данные так и затраты на хранение внутренних структур редиса для их хранения и представления. Редис рассматривает RSS (Resident Set Size) как выделенное операционной системой количество памяти, в котором помимо пользовательских данных (и расходов на их внутреннее представление)учитываются расходы на фрагментацию при физическом выделение памяти самой операционной системой.

Как понимать mem_fragmentation_ratio? Значение 2,1 говорит нам что мы используем на 210% больше памяти под хранение данных, чем нам нужно. А значения меньше 1 говорит о том, что память кончилась и операционная система свапится.

На практике, если значения для mem_fragmentation_ratio выпадают за границы 1 — 1,5 говорит о том, что у вас что-то не так. Попробуйте:
  • Перезагрузить ваш редис. Чем дольше редис, в который вы активно пишите работал без перезагрузки, тем выше у вас будет mem_fragmentation_ratio. Во многом «благодаря» особенности аллокатора. В том числе, это гарантировано поможет, если у вас большая разница между used_memory и used_memory_peak. Последний показатель говорит какой максимальный объем памяти который когда либо требовался вашему экземпляру редиса с момента его старта.
  • Посмотреть, что за данные и сколько вы планируете хранить. Так, если для хранения ваших данных достаточно 4 гб — используйте 32 битные сборки редиса. По крайне мере, если вы используете 64 битную сборку хотя бы попробуйте развернуть ваш дам на 32 битной версии (rdb не зависит от битности редиса и вы легко можете запустить rdb созданный 64 битным экземпляром на 32 битном). Практически гарантировано это снижает фрагментацию (и объем использованной памяти) на ~7% (за счёт экономии на выравнивании).
  • Если вы понимаете разницу и особенности, попробуйте сменить аллокатор. Редис можно собрать с glibс malloc, jemalloc (почитайте что думают об этом инженеры facebook), tcmalloc.


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

Пользователь pansa верно замечает, что в ситуации со свапом редис не пересчитает значение used_memory_rss после того как операционная система вернёт процессу часть оперативной памяти. Редис пересчитает это значение уже при обращении к данным.

Оглавление:

Дополнительные материалы для ознакомления:
Стоит написать о том, как устроены под капотом другие структуры редиса?

Проголосовало 244 человека. Воздержалось 26 человек.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Комментарии (3)


  1. sl4mmer
    23.11.2015 18:39
    +6

    Предельно годная статья. Побольше бы таких на хабре

    Про jemalloc есть
    интересная заметка в Facebook Engineering


  1. pansa
    24.11.2015 00:09
    +3

    >А значения меньше 1 говорит о том, что память кончилась и операционная система свапится.

    Вероятно, всё не так однозначно. Например, это может говорить о том, что системе _ранее_ не хватало памяти и она свапилась?

    Посмотрел один из свох боевых редисок:

    # Memory
    used_memory:56794944
    used_memory_human:54.16M
    used_memory_rss:36392960
    used_memory_peak:120547320
    used_memory_peak_human:114.96M
    used_memory_lua:31744
    mem_fragmentation_ratio:0.64
    mem_allocator:jemalloc-3.2.0

    Казалось бы, явно должно свапиться? Но vmstat за полчаса не показал ни одного намека на чтение или запись в свап. При этом работа кипит. Хотя в «Understanding the Top 5 Redis Performance Metrics» пишут то же. Что-то тут не однозначно…


    1. pansa
      24.11.2015 00:24
      +3

      Отвечу сам себе =)
      Я верно предположил — редису когда-то ранее не хватало памяти и часть выгрузилась в свап. Позже свободная память в ОС появилась, но к выгруженным в свап ключам не было обращений и они продолжали лежать там.
      При этом не наблюдается проблем с производительностью — для новых ключей памяти достаточно, никаких проблем. Хотя в info имеем
      mem_fragmentation_ratio:0.64

      Теперь берем redis-cli и пробегаемся по всем данным (у меня это несколько больших set-ов). Во время чтения параллельно запущенный vmstat действительно показал чтения из свапа. После экзекуции имеем:

      mem_fragmentation_ratio:1.09

      Т.е всё чисто-красиво.
      Это я к тому, что не стоит сразу пугаться цифр mem_fragmentation_ratio. Возможно, стоит добавить в статью.