В серии из нескольких статей я приведу свой адаптированный перевод раздела Redis Best Practices с официального сайта Redis Labs.
Redis можно использовать бесчисленным количеством способов, однако, есть несколько паттернов, с помощью которых можно решать часто возникающие проблемы. Мы собрали коллекцию общих паттернов, которые мы считаем best practices для решения этих проблем. Эта коллекция не является исчерпывающей и не представляется как набор единственных способов использования Redis, но мы надеемся, она послужит отправной точкой для решения проблем с помощью Redis.
Это руководство по best practices мы разделили на главы и подглавы по мере необходимости (Прим. переводчика: некоторые подглавы короткие, поэтому я объединю их в одну):
- в главе «Паттерны индексирования» мы рассмотрим способы выйти за рамки обычного доступа «ключ-значение» с Redis. Туда входят способы умного использования паттернов на ключах с использованием различных типов данных Redis, чтобы помочь не только в поиске данных, но и снизить сложность доступа;
- глава «Паттерны взаимодействия» сфокусирована на паттерны Redis, которые перемещают данные по инфраструктуре. В этом случае Redis выступает не как хранилище, а скорей как направляющая для данных;
- глава «Паттерны хранения данных» описывают методы как сохранять сложные представления данных в Redis. Мы вычислим сложные сценарии документов, которые могут быть обобщены на простые и сложные способы;
- паттерны, касающиеся временно хранимых данных, описаны в главе «Паттерны временных рядов»;
- ограничение скорости часто используется в Redis. В главе «Основные паттерны ограничения скорости» мы перейдём к основам вариантов его использования;
- фильтр Блума давно встречается в Redis, и в главе «Паттерны с фильтром Блума» мы рассмотрим вероятностные структуры данных и чем они отличаются от их невероятностных аналогов;
- счётчик – удивительно глубокий приём. В отдельной главе мы исследуем, как высчитать активность и уникальные элементы эффективными вычислительными способами;
- наконец мы поговорим о том, как задействовать Lua, чтобы заставить Redis делать больше с меньшими затратами.
Это руководство непоследовательно, поэтому можете начинать его с любой главы. Также можете пользоваться навигацией в начале каждого поста, чтобы найти что-то подходящее.
Паттерны индексирования
Концептуально Redis – база данных, базирующаяся на парадигме «ключ/значение», когда каждая порция данных ассоциируется с неким ключом. Если вы хотите получить данные по чему-то кроме ключа, вам нужно будет реализовать индекс, который использует один из многих типов данных, доступных в Redis.
Индексирование в Redis довольно сильно отличается от того, что представлено в других базах данных, поэтому ваши собственные сценарии использования и данные определят лучшую стратегию для индексирования. В этой главе мы рассмотрим некоторые общие стратегии поиска данных помимо простого получения по «ключу/значению»:
- сортированные множества как индексы;
- лексикографические индексы;
- геопространственные индексы;
- IP-геолокация;
- полнотекстовый поиск;
- партиционированные индексы.
Сортированные множества как индексы
Сортированные множества (ZSETs) — стандартный тип данных в Redis, который представляет множество уникальных объектов (повторения не сохраняются), где каждый объект закреплён за числом (называемое «счёт»), которое выступает как естественный механизм сортировки. И хотя объекты не могут повторяться, любые несколько объектов могут иметь одинаковый счёт. При относительно невысокой временной сложности на добавление, удаление и получение диапазона значений (по рангу или счёту) сортированные множества вполне пригодны для того, чтобы быть индексами. В качестве примера возьмём страны мира, ранжированные по населению:
> ZADD countries-by-pop 1409517397 china
> ZADD countries-by-pop 146573899 russia
> ZADD countries-by-pop 81456724 germany
> ZADD countries-by-pop 333016381 usa
> ZADD countries-by-pop 1 mars
> ZADD countries-by-pop 37290812 afghanistan
> ZADD countries-by-pop 1388350202 india
Получить топ-5 стран будет просто:
> ZRANGE countries-by-pop 0 4
1) "mars"
2) "afghanistan"
3) "germany"
4) "russia"
5) "india"
А получение стран с населением между 10000000 и 1000000000:
> ZRANGEBYSCORE countries-by-pop 10000000 1000000000
1) "afghanistan"
2) "germany"
3) "russia"
Можно создать несколько индексов, чтобы продемонстрировать разные способы сортировки данных. В нашем примере мы могли бы использовать те же объекты, но вместо количества людей взять плотность населения, географические размеры, количество пользователей Интернет и т.д. Это создаст высокопроизводительные индексы для разных аспектов. Кроме того, деля имя объекта с данными о нём, хранящимися либо в Redis (в Hash, например), либо в другом хранилище данных, вторичный процесс мог бы получить дополнительную информацию о каждом элементе по мере необходимости.
Лексикографические индексы
Сортированные множества (ZSETs) с ранжированием по счёту имеют одно интересное свойство, которое может быть использовано для создания механизма грубой алфавитной сортировки. Свойство заключается в том, что объекты с одинаковым счётом могут быть возвращены в лексикографическом порядке и по граничным значениям. Возьмём следующие данные:
> ZADD animal-list 0 bison 0 boa 0 dog 0 emu 0 falcon 0 alligator 0 chipmunk
Эта команда добавит нескольких животных к ключу animal-list. У каждый объекта счёт 0. Выполнив команду ZRANGE с аргументами 0 и -1, увидим любопытный порядок:
> ZRANGE animal-list 0 -1
1) "alligator"
2) "bison"
3) "boa"
4) "chipmunk"
5) "dog"
6) "emu"
7) "falcon"
Несмотря на то, что элементы были добавлены не в алфавитном порядке, они оказались возвращены отсортированными по алфавиту. Такой порядок является результатом двоичного сравнения строк, побайтового. Это значит, что ASCII-символы вернутся в алфавитном порядке. Это говорит о следующем:
- символы в нижнем и верхнем регистрах не будут распознаны как одинаковые;
- многобайтовые символы будут отсортированы не как ожидается.
Redis также предоставляет некоторые продвинутые возможности для дальнейшего сужения лексикографического поиска. Например, мы хотим вернуть животных, начинающихся с b и заканчивающихся на e. Мы можем использовать следующую команду:
> ZRANGEBYLEX animal-list [b (f
1) "bison"
2) "boa"
3) "chipmunk"
4) "dog"
5) "emu"
Аргумент (f может немного смутить. Это важный момент, потому что Redis не имеет никакого понятия о буквальном понимании букв алфавита. Это значит, что мы должны учитывать, что всё начинающееся с e всегда будет стоять до всего начинающегося с f, независимо от последующих букв. Другое примечание заключается в том, что квадратная скобка указывает на поиск с включением, а круглая на поиск без включения. В нашем случае, если мы запрашиваем с b, это будет включено в список, тогда как f не появится в выборке. Если вам нужны все элементы до конца, используйте закодированный последний символ (255 или 0xFF):
> ZRANGEBYLEX animal-list [c "[xff"
1) "chipmunk"
2) "dog"
3) "emu"
4) "falcon"
Эту команду можно также ограничить, обеспечив тем самым постраничный вывод:
> ZRANGEBYLEX animal-list [b (f LIMIT 0 2
1) "bison"
2) "boa"
> ZRANGEBYLEX animal-list [b (f LIMIT 2 2
1) "chipmunk"
2) "dog"
Единственный подводный камень, что временная сложность будет расти по мере увеличения отступа (первый аргумент после LIMIT). Поэтому, если у вас 1 миллион объектов и вы пытаетесь получить последние два, это потребует обхода всего миллиона.
Геопространственные индексы
У Redis есть несколько команд, связанных с геопространственным индексированием (GEO команды), но, в отличие от других команд, у этих команд нет собственных типов данных. Эти команды на самом деле дополняют тип сортированного множества. Это достигается кодированием широты и долготы в счёт (score) сортированного множества, используя алгоритм геохэша.
Добавить элементы к геоиндексу легко. Предположим, вы отслеживаете группу автомобилей, которые едут по дороге. Назовём это множество машин просто «cars». Скажем, что ваша особая машина может быть идентифицирована как объект «my-car» (мы используем термин «объект», потому что геоиндекс просто форма множества). Чтобы добавить машину ко множеству, мы можем выполнить команду:
> GEOADD cars -115.17087 36.12306 my-car
Первый аргумент – множество, к которому добавляем, второй – широта, третий – долгота и четвёртый – имя объекта.
Чтобы обновить местоположение машины, нужно просто выполнить команду снова с новыми координатами. Это работает, потому что геоиндекс – это просто множество, где повторяющиеся элементы непозволительны.
> GEOADD cars -115.17172 36.12196 my-car
Добавим вторую машину к «cars». На этот раз ведёт её Володя:
> GEOADD cars -115.171971 36.120609 volodias-car
Взглянув на координаты, вы можете сказать, что это машины довольно недалеко друг от друга, но насколько? Вы можете определить это командой GEODIST:
> GEODIST cars my-car volodias-car
"151.9653"
Это значит, что два транспортных средства примерно в 151 метре друг от друга. Можно посчитать также в других единицах измерения:
> GEODIST cars my-car robins-car ft
"498.5737"
Это вернуло то же самое расстояние в шагах. Вы также можете использовать мили (ml) или километры (km).
Сейчас давайте посмотрим, кто есть в радиусе определённой точки:
> GEORADIUS cars -115.17258 36.11996 100 m
1) "volodias-car"
Это вернуло всех в радиусе 100 метров вокруг указанной точки. Можно также запросить всех в радиусе какого-либо объекта из множества:
> GEORADIUSBYMEMBER cars volodias-car 152 m
1) "volodias-car"
2) "my-car"
Мы можем также включить дистанцию, добавив необязательный аргумент WITHDIST (это работает для GEORADIUS или GEORADIUSBYMEMBER):
> GEORADIUSBYMEMBER cars volodias-car 152 m WITHDIST
1) 1) "volodias-car"
2) "0.0000"
2) 1) "my-car"
2) "151.9653"
Другой необязательный аргумент для GEORADIUS и GEORADIUSBYMEMBER – WITHCOORD, который возвращает координаты каждого объекта. WITHDIST и WITHCOORD можно использовать вместе или по отдельности:
> GEORADIUSBYMEMBER cars volodias-car 152 m WITHDIST WITHCOORD
1) 1) "volodias-car"
2) "0.0000"
3) 1) "-115.17197102308273315"
2) "36.12060917648089031"
2) 1) "my-car"
2) "151.9653"
3) 1) "-115.17171889543533325"
2) "36.12196018285882104"
Так как геопространственные индексы – это просто альтернатива сортированным множествам, можно пользоваться некоторыми операторами последних. Если мы хотим удалить «my-car» из множества «cars», можно использовать команду сортированного множества ZREM:
> ZREM cars my-car
Redis предоставляет богатый набор инструментов для работы с геопространством, и в этом разделе мы рассмотрели только базовые из них.
IP-геолокация
Нахождение фактического местоположения подключённого сервиса может быть очень полезным. Таблицы IP-геолокации, как правило, довольно велики и ими сложно эффективно управлять. Мы можем использовать сортированные множества, чтобы реализовать быстрые и эффективные сервисы IP-геолокации.
На IPv4 люди чаще всего ссылаются в десятичной нотации (74.125.43.99, например). Однако сетевые сервисы видят этот же самый адрес как 32-битное число, причём каждый байт представляет одно из четырёх чисел в десятичной форме. Пример выше будет 0x4A7D2B63 в шестнадцатеричном виде или 1249717091 в десятичном.
Наборы данных IP-геолокации широко доступны и обычно имеют форму простой таблицы с тремя колонками (начало, конец, местоположение). Начало и конец – десятичное представление IPv4. В Redis мы можем адаптировать сортированные множества под этот формат, потому что в диапазонах IP отсутствуют «дыры», следовательно, можно с уверенностью допустить, что конец одного диапазона – это начало другого.
Для простого примера добавим несколько диапазонов в сортированные множества:
> ZADD ip-loc 1249716479 us:1
> ZADD ip-loc 1249716735 taiwan:1
> ZADD ip-loc 1249717759 us:2
> ZADD ip-loc 1249718015 finland:1
Первый аргумент – ключ нашего множества, второй – десятичное предствление конца IP-диапазона, и последний – собственно, сам объект. Обратите внимание, что объект множества имеет число после двоеточия. Это просто для облегчения примера. В реальных IP-таблицах для каждого диапазона свои уникальные идентификаторы (и больше дополнительной информации, чем просто название страны).
Чтобы запросить таблицу к данному IP-адресу, мы можем использовать команду ZRANGEBYSCORE с несколькими дополнительными аргументами. Возьмём IP-адрес и конвертируем его в десятичный вид. Это можно сделать средствами вашего языка программирования. Для начала используем адрес из исходного примера 74.125.43.99 (1249717091). Если мы возьмём это число в качестве начало отсчёта и не укажем максимум, а затем ограничим результат только до первого объекта, мы найдём его геоположение:
> ZRANGEBYSCORE ip-loc 1249717091 +inf LIMIT 0 1
1) "us:2"
Первый аргумент – ключ нашего сортированного множества, второй – десятичное представление IP-адреса, третий (+inf) говорит Redis запрашивать без верхней границы, и последние три аргумента просто указывают, что мы хотим получить только самый первый результат.
Полнотекстовый поиск
До появления модулей полнотекстовый поиск был реализован с использованием собственных команд Redis. Модуль RedisSearch намного более производительный, чем этот паттерн, однако, в некоторых окружениях он недоступен. Кроме того, этот паттерн очень интересен и может быть обобщён для других рабочих нагрузок, в которых RedisSearch будет не идеален.
Допустим, у нас есть несколько текстовых документов, по которым нужно произвести поиск. Это может быть неочевидный use-case для Redis, так как он осуществляет доступ по ключам, но, с другой стороны, Redis может быть использован как совершенно новый полнотекстовый поисковый движок.
Сначала возьмём несколько примерных текстов в документах:
“Redis is very fast”
“Cheetahs are fast”
“Cheetahs have spots”
Разделим их на наборы слов, разделённых пробелом для простоты:
> SADD ex1 redis is very fast
> SADD ex2 cheetahs are very fast
> SADD ex3 cheetahs have spots
Обратите внимание, что мы помещаем каждую строку в своё собственное множество. Может показаться, что мы просто добавляем всю строку – SADD является вариативным и принимает сразу несколько элементов в качестве аргументов. Мы также перевели все слова в нижний регистр.
Затем нам нужно инвертировать этот индекс и показать, какое слово в каком документе содержится. Для этого мы сделаем множество для каждого слова и поместим имя документа в качестве объекта:
> SADD redis ex1
> SADD is ex1
> SADD very ex1 ex2
> SADD fast ex1 ex2
> SADD cheetahs ex2 ex3
> SADD have ex3
> SADD spots ex3
Для ясности мы разделили это на разные команды, но все команды обычно выполняются атомарно в блоке MULTI/EXEC.
Чтобы запросить наш крошечный полнотекстовый индекс, мы используем команду SINTER (пересечение множеств). Найти документы с «very» и «fast»:
> SINTER very fast
1) "ex2"
2) "ex1"
В случае, когда нет документов, соответствующих запросу, мы получим пустой результат:
> SINTER cheetahs redis
(empty list or set)
Для логичности лучше использовать SUNION вместо SINTER:
> SUNION cheetahs redis
1) "ex2"
2) "ex1"
3) "ex3"
Удаление объекта из индекса немного сложнее. Сначала получим индексированные слова из документа, затем удалим идентификатор документа от каждого слова:
> SMEMBERS ex3
1) "spots"
2) "have"
3) "cheetahs"
> SREM have ex3
> SREM cheetahs ex3
> SREM spots ex3
В Redis нет отдельного оператора, чтобы выполнить все эти шаги одной командой, поэтому сначала придётся запрашивать командой SMEMBERS, затем последовательно удалять каждый объект при помощи SREM.
Конечно, это очень упрощённый полнотекстовый поиск. Можно сделать более продвинутый, используя сортированные множества вместо обычных. В этом случае, если в документе слово встречается больше одного раза, вы можете ранжировать его выше, чем документ, в котором оно встречается единожды. Описанные выше паттерны более-менее одинаковы, за исключением используемых типов множеств.
Партиционированные индексы
Единственный экземпляр (или шард) Redis очень жизнеспособный, но есть обстоятельства, когда вам может понадобиться индекс, распределённый по нескольким экземплярам. Например, чтобы повысить пропускную способность распараллеливанием индексов, размер которых превышает свободное пространство экземпляра. Скажем, вы хотите выполнить операцию над несколькими ключами. Эффективный способ разделить (партиционировать) эти ключи – обеспечить равномерное распределение ключей по каждой партиции, выполнять любые операции в каждой партиции параллельно и затем объединять результаты по окончанию.
Чтобы достичь равномерного распределения ключей, мы будем использовать некриптографический алгоритм хеширования. Подойдёт любая быстрая функция хеширования, но мы используем известную CRC-32 для примера. В большинстве случаев эти алгоритмы возвращают результат в шестнадцатеричном виде (для «my-cool-document» CRC-32 выдаст F9FDB2C9). Шестнадцатеричное представление проще для машины, но это просто другое представление десятичных целых чисел, которое означает, что можно выполнять вычисления на этих значениях.
Далее нужно определить число партиций – это должно быть по крайней мере х2 от количества экземпляров. В дальнейшем это способствует масштабированию.
Допустим, у нас есть 3 экземпляра и 6 партиций. Вычислить партицию, на которую отправить документ, можно следующей операцией:
CRC32(“my-cool-document”) = F9FDB2C9 (16) или 4194153161 (10)
4194153161 mod 6 = 5
В Redis Enterprise вы можете контролировать, к какой партиции относится ключ, используя либо предопределённые регулярные выражения, либо оформив часть ключа фигурными скобками. Так для нашего примера мы можем установить ключ для документа следующим образом:
idx:my-cool-document{5}
Затем у нас есть другой документ, который выдаёт партицию с номером 3, следовательно, ключ будет выглядеть так:
idx:my-other-document{3}
Если у вас есть дополнительные вспомогательные ключи, с которыми вам нужно будет работать, и которые связаны с этим документом, вам нужно, чтобы они находились на той же партиции так, чтобы вы могли выполнять операции с обоими ключами одновременно, не сталкиваясь с кучей ошибок. Для этого вам надо добавить к ключу тот же самый номер партиции, что и у документа.
Отдалённо просматривая ваши данные, вы увидите, что ваш индекс довольно равномерно распределён по партициям. Вы можете распараллелить задачу, которую необходимо выполнить для каждой партиции. Когда у вас есть задача, которую нужно сделать через весь индекс, вашему приложению нужно будет выполнить ту же логику для каждой партиции, вернуть результат и объединить так, как требуется в приложении.
На этом первая статья подходит к концу. В следующей будет перевод подглав «Паттерны взаимодействия» и «Паттерны хранения данных».
Автор: megaentwickler