Это база: нюансы работы с Redis. Часть 1
Привет! Меня зовут Петр и мы в компании Nixys очень любим Redis. Эта база используется если не на каждом нашем проекте, то на подавляющем большинстве. Мы работали как с разными инсталляциями Redis, так и с разными версиями, вплоть до самых дремучих, вроде 2.2. Несмотря на то, что в Интернете очень много статей и докладов по этой БД, мы в своей практике достаточно часто встречаемся с непониманием некоторых основных концепций Redis и со стороны разработчиков, и со стороны системных администраторов.
В серии статей я попытаюсь осветить неочевидные нюансы при работе с Redis и сегодня начну с основных концепций и понятий. А еще в конце статьи приведу небольшой чек-лист, который может помочь вам в оптимизации этого NoSQL решения.
Прежде чем начать, приглашаем вас подписаться на наш блог Хабр, TG-канал DevOps FM, интернет-издание VC и познакомиться с YouTube — мы всегда рады новым друзьям :) Теперь ближе к делу.
Что такое Redis?
Redis — это база данных, которая размещается в памяти и хорошо подходит для следующих целей:
Кэширование данных: этот сценарий подходит когда есть ключ, по которому вы можете прочитать или записать кэш. Частый случай: использование Redis как кэша перед другой базой-крепышом, вроде MySQL.
Хранение сессий: этот сценарий подходит при росте проекта, когда мы получаем несколько инстансов приложения на разных машинах за балансировщиком и возникает потребность в хранении пользовательских сессий в хранилище, которое доступно для каждого сервера приложения.
Pub/Sub: помимо хранилища данных Redis можно использовать как брокер сообщений. В этом случае издатель может опубликовать сообщения в именованном канале для любого числа подписчиков. Когда клиент публикует сообщение в канале, Redis доставляет это сообщение всем клиентам, подписанным на этот канал, что обеспечивает обмен информацией между отдельными компонентами приложения в реальном времени. Однако, стоит помнить, что это сообщение опубликовывается по паттерну Fire & Forget — отправитель отправляет сообщение, не ожидая явного подтверждения от получателя о получении сообщения. То есть, отсутствует гарантия доставки и если часть подписчиков потеряет соединение, то после возвращения они не получат пропущенных сообщений. А если получателей не существует вовсе, то сообщение пропадает без возможности восстановления.
Мы не будем останавливаться на остальных возможных применениях Redis, а также на базовых моментах и перейдем к особенностям его работы, которые непосредственно влияют на конфигурирование и эффективность:
Однопоточность
Это одновременно как и плюс, так и минус: из-за последовательного выполнения команд мы по определению не получаем проблем с concurrency и локами. Всегда в один момент времени выполняется только одна команда, все остальные выстраиваются в очередь и выполняются в том порядке, в котором пришли, в одном потоке. Однако, если в базу придет неоптимальный запрос, который займет много времени, например сопряженный с полным перебором ключей, Redis фактически блокируется: до завершения тяжелого запроса все остальные запросы выстраиваются в очередь и, как правило, по таймауту возвращают лучшие результаты, накопленные на данный момент, или ошибку — в зависимости от политики, установленной с помощью ON_TIMEOUT
.
Политики вытеснения
В Redis существует механизм удаления устаревших ключей при добавлении новых данных, который называется вытеснением. Он реализуется через параметр maxmemory, который задает предел, после которого начинается процесс удаления ключей. В общем случае последовательность действий внутри Redis выглядит следующим образом:
Клиент запускает команду для добавления новых данных;
Перед выполнением команды Redis проверяет, превышает ли использование памяти параметр
maxmemory
;Если использование памяти превышает параметр
maxmemory
, то происходит удаление группы ключей. Для удаления используется политика вытеснения, указанная в параметреmaxmemory-policy
(об этом чуть ниже);После удаления старых ключей, команда выполняется и добавляются новые ключи.
Если установить значение maxmemory
равным 0, то для Redis не будет указано ограничение памяти. В 64-битных системах значение установлено как 0 по умолчанию, а для 32-битных архитектур система неявно использует максимальный предел памяти в 3Gb.
Политика вытеснения устанавливает правила того, как Redis удаляет ключ при достижении предела памяти, указанного в параметре maxmemory
. Если ни один ключ не соответствует критериям политики или параметр maxmemory-policy
установлен как noeviction
, то ни один ключ не будет вытеснен. Вместо этого сервер Redis будет отдавать ошибки на все операции записи, и отвечать только на команды чтения, такие как GET, фактически перейдя в read only. С основными политиками вытеснения мы можете познакомиться на официальном сайте Redis.
Важно понимать, что вытеснение — это тоже процесс, который выполняется в основном потоке вместе с обработкой клиентских запросов. Когда Redis достигает своего maxmemory
порога, он освобождает память, удаляя ключи, чтобы вернуться вновь под значение maxmemory
. Если в ваш Redis одной транзакцией прилетит количество ключей, объем которых превышает maxmemory
, то они все равно попадут в Redis, а уже после завершения транзакции начнется процесс вытеснения. В примере ниже нами было загружено 100 000 000, ключей по 10 байт каждый при параметре maxmemory = 10485760
:
Процесс удаления ключей в процессе вытеснения
При этом потребление памяти вышло за установленные пределы maxmemory
и вытеснение началось только после завершения транзакции:
Потребление памяти в процессе вытеснения
Примечание: упомянутые выше транзакции в Redis работают не так, как например в ACID базах данных. После получения последовательности атомарных команд, Redis стартует их выполнение в созданной ранее очереди в том порядке, в котором они попали в очередь, на время выполнения транзакция занимает основной поток. Ошибка при выполнении любой из команд в транзакции прерывает транзакцию, но не вызывает откат: результат выполнения предыдущих команд в рамках транзакции не отменяется.
Из графика выше также заметна еще одна деталь: процесс вытеснения не одна сплошная операция, а ряд итеративных, при этом ограниченных одной временной рамкой. Продолжительность одного вытеснения определяется параметром maxmemory-eviction-tenacity. По умолчанию он равен 10, что составляет примерно 50 микросекунд. При увеличении этого параметра продолжительность вытеснения увеличивается на 15% по сравнению с предыдущим значением, а при установке maxmemory-eviction-tenacity
на значение 100 ограничение итерации по времени фактически отсутствует. Иногда может быть полезно уменьшить этот параметр, чтобы операция вытеснения занимала больше фактического времени, но меньше времени основного потока, за счет увеличения количества итераций.
Также стоит учитывать, что удаление ключей при вытеснении — это блокирующая операция, то есть Redis останавливает обработку новых команд, чтобы синхронно освободить всю память, связанную с удаляемым объектом. Однако, мы можем настроить вытеснение неблокирующим асинхронным способом, через включения параметра lazyfree-lazy-eviction:
lazyfree-lazy-eviction yes
При включенном параметре освобождение памяти во время вытеснения идет в отдельном потоке и фактически ключи отсоединяются от пространства ключей, а непосредственно их удаление происходит позже, в асинхронном режиме.
Для вытеснения Redis использует 2 основных алгоритма: аппроксимированные алгоритмы LRU и LFU. Алгоритм LRU основан на предположении, что если к ключу недавно обращались, существует более высокая вероятность повторного доступа к нему в ближайшем будущем, поскольку обычно паттерны доступа к ключам не меняются резко. В этом контексте алгоритм LRU пытается исключить из кэша Redis наименее использованные ключи. Для реализации этой политики Redis использует поле (в дальнейшем — поле lru), размером в 24 бита (3 октета), которое отслеживает, когда ключ использовался в последний раз. В них хранятся младшие биты текущего unix-времени в секундах и требуют 194 дня для переполнения — метаданные ключей обновляются часто, поэтому этого времени более чем достаточно. Для очистки памяти Redis использует пул вытеснения, который представляет из себя связанный список, размер пула жестко задан на уровне кода и равен 16 ключам. Этот пул заполняется выборкой из N ключей, первый ключ в пуле имеет наименьшее время с момента использования, а последний — наибольшее. После заполнения кандидатов на вытеснения, Redis выберет наиболее подходящий ключ из конца пула, удалит его и будет повторять процесс до момента, пока память не будет высвобождена. Если элементов с одинаковой временной меткой несколько, вытеснение происходит по принципу FIFO — удаляется тот, который попал в пул раньше. Последующие ключи, попадающие в пул вытеснения, будут вставлены в нужную позицию в соответствии со своей временной меткой, новые ключи попадают в пул только в том случае, если время их простоя больше, чем у ключа из пула или при наличии свободного места в пуле. Количество N ключей в выборке для проверки (а, соответственно, и точность алгоритма) каждого вытеснения можно настроить с помощью параметра maxmemory-samples (об этом параметре чуть дальше).
LFU (Least Frequently Used) — второй основной алгоритм вытеснения Redis, в основе которого лежит мысль о том, что ключи, которые имеют максимальную вероятность доступа в будущем — это ключи, к которым наиболее часто обращаются в целом, а не те, к которым последний раз обращались. У нас есть 24 бита общего пространства в каждом объекте, поскольку для этой цели мы повторно используем поле lru. Эти 24 бита разделяются на два поля, первое — это логарифмический счетчик размером в 8 бит, который дает представление о частоте доступа к элементу. Это функция, которая увеличивает счетчик при доступе к ключам, но чем больше значение счетчика, тем менее вероятно, что счетчик действительно будет увеличиваться в дальнейшем. На стандартных настройках мы получим примерно следующее:
После 100 обращений к ключу значение счетчика будет равно 10;
После 1к — 18;
После 100к — 142;
После 1 миллиона обращений значение счетчика достигает предела в 255 и больше не увеличивается;
Однако, если в один момент времени произошло много обращений к ключу, что фактически накрутило его счетчик, то есть шанс того, что то, что раньше было часто используемым ключом, останется таким навсегда, а мы хотим, чтобы алгоритм адаптировался к этому. Для этого оставшиеся 16 бит используются для хранения «времени декремента» — времени Unix, преобразованного в минуты. Когда Redis выполняет случайную выборку для вытеснения, все встречающиеся ключи проверяются на время последнего декремента. Если последний декремент был выполнен более N минут назад (N настраивается параметром lfu-decay-time), то в зависимости от того, во сколько раз прошедшее время больше, чем N, счетчик уменьшается вдвое и время декремента уменьшается или происходит только уменьшение времени декремента. То есть, с течением времени происходит уменьшение счетчиков. Чтобы у новых ключей была возможность остаться, начальное значение LFU счетчика у них равно 5, что дает им некоторое время для накопления обращений.
Так как алгоритмы LRU и LFU в Redis являются не точными, а приближенными, их можно настроить как на скорость, так и на точность через параметр maxmemory-samples, который уже упоминался ранее. По умолчанию он равен 5, увеличение его до 10 пропорционально увеличивает точность алгоритма, но увеличивает нагрузку на CPU. Соответственно, понижение увеличивает скорость, но снижает точность попадания.
Типы данных
В Redis используется много типов данных, однако хотелось бы осветить несколько. Но прежде стоит отметить один, на первый взгляд, очевидный момент, про который, по нашим наблюдениям, раз за разом разработчики забывают: Redis работает быстро, пока используются команды со временной сложностью O (1) и O (log_N). Как только вы решаете использовать команду со сложностью O (N), то будьте готовы к скачкам нагрузки, если операция будет задействовать большое количество ключей.
Строки — самый простой тип данных. Значения могут быть строками любого вида, например, можно хранить изображение в формате JPEG внутри значения. Размер строки не может быть больше 512Mb. Большинство строковых операций выполняются за O (1). Вроде бы все просто, но есть нюанс: строки в Redis реализованы через библиотеку SDS, в которой структура состоит из заголовка, который хранит актуальный размер и свободное место в уже выделенной памяти, непосредственно данные строки и завершающее нулевое значение, который добавляет сам Redis.
+--------+-------------------------------+-----------+
| Header | Binary safe C alike string... | Null term |
+--------+-------------------------------+-----------+
|
-> Pointer returned to the user.
То есть, для хранения строки нам необходимо:
целое число для обозначения длины;
целое число для обозначения количества оставшихся свободных байт;
строка;
нулевой символ.
Это подводит нас к важному выводу: нас будут очень интересовать затраты на служебные данные, чтобы не тратить лишние байты. В часто приводимом примере, если строка имеет служебные данные размером около 90 байт, то при выполнении команды set foo bar
будет использовано около 96 байт, из которых 90 байт будут являться служебными. Из этого мы можем извлечь, что для максимальной утилизации ресурсов, короткие строки, вроде имен, адресов электронных почт, номеров телефонов и т.д. лучше класть в хэши, но подробнее об этом дальше.
Списки (lists) — коллекции строк, упорядоченные в порядке добавления. Redis использует внутри связанные списки — каждый элемент содержит указатели на предыдущий и следующий элементы, а также указатель на строку внутри элемента. Это означает, что вне зависимости от количества элементов внутри списка, операция добавления нового элемента в начало или конец списка выполняется за O (1), так как для вставки необходимо только изменить ссылки. Также не стоит забывать, что команды, которые манипулируют элементами в списке (вроде LSET), обычно имеют сложность O (n), что может вызвать проблемы с производительностью при большом количестве элементов. Максимальная длина списка Redis — 4 294 967 295 элементов.
Наборы (sets) — это неупорядоченная коллекция уникальных элементов, которая эффективно подходит для отслеживания уникальных компонентов, а также для операций над множествами. Максимальный размер — 4 294 967 295 элементов, большинство операций над множествами выполняются за O (1).
Хэши (hashes) — это записи, структурированные как коллекции пар поле-значение. Каждый хэш может хранить до 4 294 967 295 пар поле-значение, большинство хэш-команд Redis имеют сложность O (1) и самое главное: хэши можно очень эффективно закодировать в памяти (но об этом чуть ниже). Одна из самых из самых популярных рекомендаций по экономии памяти при использовании Redis — использовать хэши вместо простых строк. Если мы возьмем 1 000 000 ключей вида »123456789», то в виде строк они будут занимать около 56Mb:
127.0.0.1:6379> flushall
OK
127.0.0.1:6379> info memory
used_memory:2441696
used_memory_human:2.33M
127.0.0.1:6379> eval "for i=0,1000000,1 do redis.call('set', i, 123456789) end" 0
127.0.0.1:6379> info memory
used_memory:58830624
used_memory_human:56.11M
В то же время, при сохранении данных в хэш таблицу потребление памяти составило около 14Mb:
127.0.0.1:6379> flushall
OK
127.0.0.1:6379> info memory
# Memory
used_memory:2442112
used_memory_human:2.33M
127.0.0.1:6379> eval "for i=0,1000000,1 do local bucket=math.floor(i/500); redis.call('hset', bucket, i, 123456789) end" 0
(nil)
(3.02s)
127.0.0.1:6379> info memory
# Memory
used_memory:14801296
used_memory_human:14.12M
Разница в 42Mb, неплохо. Ситуация становится более наглядной, когда количество целочисленных ключей вырастает с 1 000 000 до 100 000 000:
127.0.0.1:6379> eval "for i=0,100000000,1 do redis.call('set', i, 123456789) end" 0
(nil)
(313.34s)
127.0.0.1:6379> info memory
# Memory
used_memory:6668210336
used_memory_human:6.21G
127.0.0.1:6379> eval "for i=0,100000000,1 do local bucket=math.floor(i/500); redis.call('hset', bucket, i, 123456789) end" 0
(nil)
(307.42s)
127.0.0.1:6379> info memory
# Memory
used_memory:1242900056
used_memory_human:1.16G
Откуда такая разница в памяти? Все из-за Ziplist — специально закодированного двусвязного списка, который предназначен для того, чтобы быть крайне эффективным с точки зрения использования памяти. Это достигается за счет последовательной компоновки элементов, при которой элементы хранятся один за другим. Если размер данных небольшой (в нем мало элементов с небольшими значениями и они меньше порога, который задан *-max-listpack-*
параметром), то Redis кодирует его в Ziplist, что делает его очень эффективным по потреблению ОЗУ, однако отражается на потреблении CPU.
В Redis представлено 6 параметров работы с Ziplist (параметры с Redis >= 7.0), пока рассмотрим 2:
list-max-ziplist-entries (по умолчанию — 512) — эффективно кодирует список, если его размер не превышает установленное значение. В противном случае Redis автоматически преобразует его в обычную кодировку.
list-max-ziplist-value (по умолчанию — 64) — эффективно кодирует список, если размер самого большого элемента в списке не превышает установленное значение, иначе Redis автоматически преобразует его в обычную кодировку и сохраняет как стандартный тип данных.
Вернемся к структуре списка. Нетрудно заметить, что в ней для одного элемента есть необходимость в:
трех указателях (предыдущее значение, следующее значение, строка внутри элемента списка);
двух целых числах (длина и оставшиеся байты);
строке;
дополнительном байте;
Что вызывает служебные расходы. Ziplist представляет элементы в виде:
размера предыдущей записи (Prevlen) для поиска предыдущего элемента в памяти;
кодировки, которая хранит тип данных текущей записи (целое число или строка) и если это строка, то к хранимым данным добавляется длина полезной нагрузки строки (Entrylen или encoding);
строки, в которой хранятся данные (Content).
Иногда Entrylen представляет собой саму запись, например, для небольших целых чисел и тогда поле Content отсутствует за ненадобностью. Размер поля Prevlen может быть представлен только в виде двух значений: 1 байт в виде беззнакового 8-битного целого числа, если длина предыдущей записи меньше 254 байт и 5 байт, если длина больше или равна 254. В этом случае, первый байт устанавливается в значение 254 (FE), чтобы указать, что за ним следует большее значение. Оставшиеся 4 байта принимают в качестве значения длину предыдущей записи.
Содержимое поля Entrylen определяется типом данных содержимого элемента: если запись представляет собой строку, то первые 2 бита первого байта Entrylen будут содержать тип кодировки, используемой для хранения длины строки, а затем фактическую длину строки. Если запись является целым числом, то первые два бита устанавливаются в 1. Следующие два бита используются для указания того, какое целое число будет храниться после этого заголовка.
И что, спросите вы, в Ziplist нет расходов на служебные записи? Есть, но они фиксированы и крайне малы:
zlbytes — беззнаковое 32-битное целое число, содержащее количество байт, которые занимает ziplist. Позволяет менять размер всей структуры без обхода всей структуры;
zltail — беззнаковое 32-битное целое число, содержащее смещение к последней записи в списке. Позволяет выполнять операцию pop в дальней части списка без полного обхода;
zllen — беззнаковое 16-битное целое число, содержащее количество записей;
zlend — один байт, равный 255, сигнализирующий о конце ziplist. Ни одна другая запись не начинается с байта, установленного в значение 255.
Схематически все вышесказанное можно изобразить примерно так:
Структура Ziplist
Это и позволяет ziplist на порядок сокращать потребление памяти. Аналогичные настройки существуют и для других типов данных:
hash-max-listpack-entries 512
hash-max-listpack-value 64
zset-max-listpack-entries 128
zset-max-listpack-value 64
В приведенном выше примере, мы использовали стандартные параметры hash-max-listpack-entries = 512
и hash-max-listpack-value = 64
. Если мы изменим их на 0, то получим следующее потребление памяти при аналогичных ключах:
127.0.0.1:6379> flushall
OK
127.0.0.1:6379> eval "for i=0,100000000,1 do local bucket=math.floor(i/500); redis.call('hset', bucket, i, 123456789) end" 0
(nil)
(91.96s)
127.0.0.1:6379> info memory
# Memory
used_memory:6435185176
used_memory_human:5.99G
Как видно, скорость записи ключей возросла из-за отсутствия процесса кодирования, однако объем занятого ключами места в памяти вырос с 1.16Gb до 5.99Gb.
Сохранение данных
Принципиальное отличие Redis от, например, Memcached состоит в способности Redis сохранять информацию на диск, что защищает их от потери при экстренной перезагрузке инстанса или другой непредвиденной ситуации. Зачем это нужно? Скажем, если вы используете In-memory кэш, то при падении базы придется перезаливать данные и этот процесс может занимать достаточно большое количество времени. Redis предлагает 2 основных варианта:
RDB представляет из себя снэпшот данных в момент времени. Это компактный бэкап, который можно переносить не только между инстансами, но и, как правило, между разными версиями Redis. Однако, RDB предоставляет снимок данных в момент времени и не учитывает последующие изменения в системе после его создания;
AOF последовательно регистрирует каждую операцию, полученную сервером, в файл. По сравнению с RDB, AOF сохраняет все изменения. Также приятный момент состоит в том, что журнал AOF предназначен только для записи и Redis не изменяет уже записанные данные, а лишь дописывает их в конец, что с другой стороны сказывается на размере AOF журнала.
Мы не будем подробно расписывать преимущества и недостатки каждого из методов сохранения данных, потому как на просторах Интернета масса информации на эту тему, но осветим пару нюансов:
Очень частая ошибка новичка при переносе или копировании инстанса Redis — это загрузка RDB дампа со старого инстанса в директорию /var/lib/redis до выключения нового инстанса. В результате, Redis после перезапуска автоматически сохраняет данные из ОЗУ на диск, затирая загруженный ранее RDB файл. Этот момент может показаться запредельно очевидным, однако, за время работы, мы часто встречали озадаченность со стороны клиентов, которые решали самостоятельно восстановиться из дампа и раз за разом после перезагрузки Redis получали старый набор данных.
Если размер вашего RDB дампа больше 10Gb, вы можете столкнуться с тем, что Redis бесконечно поднимается с него. При чуть более дотошном анализе вы заметите, что после загрузки определенного количества данных в ОЗУ, Redis перезагружается и процесс загрузки данных начинается заново. Проблема в том, что по умолчанию в unit-файле Redis не выставлен явно параметр
TimeoutStartSec
, отвечающий за время ожидания старта. Если демон не сигнализирует о завершения запуска в течение настроенного времени, старт будет считаться неудавшимся и демон будет выключен. По умолчанию TimeoutStartSec равен 90 секундам и если Redis не успеет за это время загрузить данные в ОЗУ, то Redis будет перезагружен. Решение — установить параметрTimeoutStartSec=infinity
.Старайтесь снимать RDB дампы с боевой базы как можно реже, если объем базы более нескольких гигабайт. Для создания RDB используется операция fork в основном потоке, что может вызвать заметную задержку в работе инстанса Redis. Лучшее решение — поднятие мастер — слейв репликации и снятие бэкапа со слейва.
Параметры
appendonly yes
+appendfsync always
могут вызвать заметное замедление работы Redis из-за того, что fsync будет выполняться на каждую операцию. Лучше использоватьappendonly yes
+appendfsync everysec
.
Из всего вышесказанного можно вывести следующие рекомендации по настройке Redis:
Избегайте использования маленьких строк, это неэффективно;
Используйте хэши;
Следите за размерами ключей;
Если суммарный размер ваших данных в Redis менее 3Gb, рассмотрите вариант использования 32-битной версии Redis;
Не используйте ключи с неограниченным TTL, желательно ограничить размер TTL минимально необходимым сроком жизни ключа;
Используйте политику вытеснения чтобы не засорять Redis и подбирайте параметр maxmemory-policy в зависимости от структуры базы и частоты обращения к ключам;
Найдите для параметров *-max-listpack-entries и *-max-listpack-value значения, при которых большинство элементов преобразуются в zip формат. Однако, не устанавливайте слишком большое значение (больше 1000), чтобы не вызывать нагрузку на CPU;
Уменьшите параметр maxmemory-eviction-tenacity, чтобы сократить время использования основного потока процессом вытеснения;
Включите параметр lazyfree-lazy-eviction для неблокируемого вытеснения ключей;
Увеличьте/уменьшите значение параметра maxmemory-samples в зависимости от требований к точности/скорости;
Не используйте maxmemory = 0 на 64 битных сборках Redis и всегда ограничивайте потребление памяти;
Для удобного контроля или анализа содержимого Redis используйте утилиту RedisInsight, а также обратите в ней внимание на вкладку ANALYSE/Memory Analysis/Recommendations. В ней указаны рекомендации по экономии памяти для повышения производительность приложения, анализ использования памяти идет в автономном режиме и не влияет на производительность Redis.
Воспользовавшись этими советами, мы смогли сократить использование ОЗУ на проекте с ~30Gb до ~15Gb:
Потребление памяти в период проведения оптимизации
Без удаления пропорционального этой разнице количества ключей:
Потребление памяти и количество ключей до проведения оптимизации
Потребление памяти и количество ключей после проведения оптимизации
На этом мы подходим к концу. В следующих частях мы разберем нюансы в работе основных отказоустойчивых инсталляций Redis (в том числе и в рамках Kubernetes), проведем серию тестов на разных версиях Redis, а также расскажем, почему не всем следует переезжать на KeyDB. Увидимся!