Вторая часть цикла переводов «Redis Best Practices» от Redis Labs, и в ней рассмотрены паттерны взаимодействия и паттерны хранения данных.

Первая часть находится здесь.

Паттерны взаимодействия


Redis может функционировать не только как традиционная СУБД, но также его структуры и команды можно использовать для обмена сообщениями между микросервисами или процессами. Повсеместное использование клиентов Redis, скорость и эффективность сервера и протокола, а также встроенные классические структуры позволяют создавать собственные рабочие процессы и механизмы событий. В этой главе мы рассмотрим следующие темы:

  • очередь событий;
  • блокировка с Redlock;
  • Pub/Sub;
  • распределённые события.

Очередь событий


Списки в Redis – это упорядоченные списки строк, очень похожие на связные списки, с которыми вы можете быть знакомымы. Добавление значения в список (push) и удаление значения из списка (pop) очень легковесные операции. Как вы можете представить, это очень хорошая структура для управления очередью: добавлять элементы в начало и считывать их с конца (FIFO). Redis также предоставляет дополнительные возможности, которые делают этот паттерн более эффективным, надёжным и лёгким в использовании.

У списков есть подмножество команд, которые позволяют исполнять «блокирующее» поведение. Под термином «блокирующее» понимается соединение только с одним клиентом. В действительности эти команды не дают клиенту выполнять что-либо до тех пор, пока в списке не появится какое-то значение или пока не истечёт время ожидания. Это устраняет необходимость опрашивать Redis, ожидая результат. Поскольку клиент не может ничего выполнять пока ожидает значение, нам понадобится два открытых клиента, чтобы проиллюстрировать это:
# Клиент 1 Клиент 2
1
> BRPOP my-q 0
[ожидает значение]
2
> LPUSH my-q hello
(integer) 1
1) "my-q"
2) "hello"
[клиент разблокирован, готов принимать команды]
3
> BRPOP my-q 0
[ожидает значение]

В этом примере на шаге 1 мы видим, что заблокированный клиент ничего сразу не возвращает, так как в нём ничего не содержится. Последний аргумент – время ожидания. Здесь 0 означает вечное ожидание. На второй строке в my-q заносится значение, и первый клиент сразу же выходит из состояния блокировки. На третьей строке снова вызывается BRPOP (в приложении можно выполнять это в цикле), и клиент также ждёт очередное значение. Нажатием «Ctrl + C» можно прервать блокировку и выйти из клиента.

Давайте развернём пример в обратную сторону и посмотрим, как BRPOP работает с непустым списком:
# Клиент 1 Клиент 2
1
> LPUSH my-q hello
(integer) 1
2
> LPUSH my-q hej
(integer) 2
3
> LPUSH my-q bonjour
(integer) 3
4
> BRPOP my-q 0
1) "my-q"
2) "hello"
5
> BRPOP my-q 0
1) "my-q"
2) "hej"
6
> BRPOP my-q 0
1) "my-q"
2) "bonjour"
7
> BRPOP my-q 0
[ожидает значение]

На шагах 1-3 мы заносим 3 значения в список и видим, что ответ растёт, указывая количество элементов в списке. Шаг 4, несмотря на вызов BRPOP, возвращает значение немедленно. Всё потому, что блокирующее поведение возникает только когда в очереди нет значений. Мы можем видеть такой же мгновенный ответ на шагах 5-6, потому что это выполняется по каждому элементу в очереди. На шаге 7 BRPOP не находит ничего в очереди и блокирует клиента, пока что-то не будет добавлено.

Часто очереди представляют некоторую работу, которую необходимо выполнить в другом процессе (воркере). В таком типе рабочей нагрузки важно то, чтобы работа не пропала, если воркер по какой-то причине упадёт во время исполнения. Redis поддерживает такой тип очередей. Для этого вместо BRPOP используйте команду BRPOPLPUSH. Она ожидает значение в одном списке, и как только оно там появляется, заносит его в другой список. Это выполняется атомарно, поэтому невозможно двум воркерам изменить одно и то же значение. Посмотрим, как это работает:
# Клиент 1 Клиент 2
1
> LINDEX worker-q 0
(nil)
2 [Если результат не nil, как-то обработать его и перейти на шаг 4]
3
> LREM worker-q -1 [значение из шага 1]
(integer) 1
[вернуться к шагу 1]
4
> BRPOPLPUSH my-q worker-q 0
[ожидает значение]
5
> LPUSH my-q hello
"hello"
[клиент разблокирован, готов принимать команды]
6 [обработать «hello»]
7
> LREM worker-q -1 hello
(integer) 1
8 [вернуться к шагу 1]

На шагах 1-2 мы ничего не делаем, так как worker-q пуст. Если же что-то вернулось, то обрабатываем это и удаляем, и снова возвращаемся к шагу 1, чтобы проверить, не попало ли чего-нибудь в очередь. Тем самым мы сначала очищаем очередь воркера и выполняем существующую работу. На шаге 4 мы ждём, пока значение не появится в my-q, и когда появляется, оно атомарно переносится в worker-q. Затем мы как-то обрабатываем «hello», после этого удаляем его из worker-q и возвращаемся к шагу 1. Если процесс умирает на шаге 6, значение всё ещё остаётся в worker-q. После перезапуска процесса мы немедленно удалим всё, что не были удалено на шаге 7.

Этот паттерн сильно снижает вероятность потери работы, но только если воркер умирает между шагами 2 и 3 или 5 и 6, что маловероятно, но best practice будет учесть это обстоятельство в логике воркера.

Блокировка с Redlock


Иногда в системе необходимо заблокировать какой-нибудь ресурс. Это может понадобиться для того, чтобы применить важные изменения, которые не могут быть решены в условиях конкурентности. Цели блокировок:

  • позволить захватить ресурс одному и только одному воркеру;
  • иметь возможность надёжно освобождать объект блокировки;
  • не блокировать ресурс намертво (должен быть разблокирован через определённый промежуток времени).

Redis – хороший вариант для реализации блокировки, так как имеет простую модель данных, базирующуюся на ключах, а также каждый её шард однопоточный и довольно быстрый. Существует отличная реализация блокировки с использованием Redis, называемая Redlock.
Redlock-клиенты доступны почти для каждого языка, однако, важно знать, как работает Redlock, чтобы использовать его безопасно и эффективно.

Во-первых, нужно понимать, что Redlock спроектирован для работы как минимум на 3 машинах с независимыми экземплярами Redis. Это избавляет от единой точки отказа в вашем механизме блокировки, которая может привести к взаимной блокировке всех ресурсов. Другой момент для понимания, что, хотя часы на машинах не должны быть синхронизированы на 100%, функционировать они должны одинаково – время движется с одинаковой скоростью: одна секунда на машине А то же самое, что одна секунда на машине Б.

Установка объекта блокировки с Redlock начинается с получения метки времени с миллисекундной точностью. Также вы должны заранее указать время блокировки. Затем объект блокировки задаётся установкой (SET) ключа со случайным значением (только если этот ключ ещё не существует) и установкой времени ожидания на ключ. Это повторяется для каждого независимого экземпляра. Если экземпляр упал, то он немедленно пропускается. Если объект блокировки успешно установлен на большинстве экземпляров до истечения времени ожидания, тогда он считается захваченным. Время на установку или обновление объекта блокировки – это количество времени, необходимое для достижения состояния блокировки, минус предустановленное время блокирования. В случае ошибки или истечения времени ожидания разблокируйте все экземпляры и повторите снова.

Чтобы освободить объект блокировки, лучше использовать скрипт на Lua, который проверит, имеется ли во множестве ключей ожидаемое случайное значение. Если имеется, тогда можно удалить, иначе лучше оставить ключи, так как это могут быть более новые объекты блокировки.

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

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

Pub/Sub


Помимо хранилища данных Redis можно использовать как платформу «Pub/Sub» (издатель/подписчик). В этом паттерне издатель может выпустить сообщения любому числу подписчиков канала. Это сообщения по принципу «выстрелил и забыл», то бишь если сообщение выпущено, а подписчика не существует, то сообщение улетучивается без возможности восстановления.
Подписавшись на канал, клиент переводится в режим подписчика и больше не может вызывать команды – становится readonly. У издателя нет таких ограничений.

Можно подписаться более чем на один канал. Начнём с подписки на два канала «погода» и «спорт», используя команду SUBSCRIBE:

> SUBSCRIBE weather sports
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "weather"
3) (integer) 1
1) "subscribe"
2) "sports"
3) (integer) 2

В отдельном клиенте (другом окне терминала, например) мы можем публиковать сообщения в любом из этих каналов с помощью команды PUBLISH:

> PUBLISH sports oilers/7:leafs/1
(integer) 1

Первый аргумент – название канала, второй – сообщение. Сообщение может быть любым, в данном случае это закодированный счёт в игре. Команда возвращает количество клиентов, которым сообщение будет доставлено. В клиенте-подписчике мы сразу же видим сообщение:

1) "message"
2) "sports"
3) "oilers/7:leafs/1"

Ответ содержит три элемента: указание, что это сообщение, канал подписки и, собственно, сообщение. Клиент сразу же после получения возвращается к прослушиванию канала.

Возвращаясь к издателю, мы можем опубликовать другое сообщение:

> PUBLISH weather snow/-4c
(integer) 1

В подписчике мы увидим тот же формат, но с другим каналом с сообщением:

1) "message"
2) "weather"
3) "snow/-4c"

Давайте выпустим сообщение в канал, где нет подписчиков:

> PUBLISH currency CADUSD/0.787
(integer) 0

Так как никто не слушает канал currency, ответ будет 0. Это сообщение ушло, и клиенты, которые после подпишутся на этот канал, не получат уведомление об этом сообщении – оно было отправлено и забыто.

Кроме подписки на один канал, Redis позволяет подписку на каналы по маске. glob-style-маска передаётся в команду PSUBSCRIBE:

> PSUBSCRIBE sports:*

Клиент будет получать сообщения со всех каналов, начинающихся со sports:. В другом клиенте вызовем следующие команды:

> PUBLISH sports:hockey oilers/7:leafs/1
(integer) 1
> PUBLISH sports:basketball raptors/33:pacers/7
(integer) 1
> PUBLISH weather:edmonton snow/-4c
(integer) 0

Обратите внимание, что первые две команды возвращают 1, в то время как последняя возвращает 0. И хоть мы напрямую не подписаны на sports:hockey или sports:basketball, клиент получает сообщения благодаря подписке по маске. В окне клиента-подписчика мы можем увидеть, что там результаты только для каналов, соответствующих маске.

1) "pmessage"
2) "sports:*"
3) "sports:hockey"
4) "oilers/7:leafs/1"
1) "pmessage"
2) "sports:*"
3) "sports:basketball"
4) "raptors/33:pacers/7"

Этот вывод немного отличается от вывода команды SUBSCRIBE, поскольку он содержит саму маску, а также реальное имя канала.

Распределённые события


Схема обмена сообщениями Redis «Pub/Sub» может быть расширена, чтобы создать интересные распределённые события. Скажем, у нас есть структура, которая хранится в хэш-таблице, но мы хотим обновить клиентов, только когда отдельное поле превышает числовое значение, заданное подписчиком. Мы будем слушать каналы по маске и извлекать хэш в status. В этом примере нас интересует update_status со значениями 5-9.

> PSUBSCRIBE update_status:[5-9]
1) "psubscribe"
2) "update_status:[5-9]"
3) (integer) 1
...

Чтобы изменить значение status/error_level, нам понадобятся две команды, которые можно выполнить последовательно или в блоке MULTI/EXEC. Первая команда устанавливает уровень, а вторая публикует уведомление со значением, закодированным в самом канале.

> HSET status error_level 5
(integer) 1
> PUBLISH update_status:5 0
(integer) 1

В первом окне видим, что сообщение получено, и после этого можно переключиться на другого клиента и вызвать команду HGETALL:

...
1) "pmessage"
2) "update_status:[5-9]"
3) "update_status:5"
4) "0"

> HGETALL status
1) "error_level"
2) "5"

Мы также можем использовать этот способ, чтобы обновлять локальную переменную какого-нибудь длительного процесса. Это может позволить нескольким экземплярам одного и того же процесса обмениваться данными в реальном времени.

Чем этот паттерн лучше использования «Pub/Sub»? Когда процесс перезапускается, он может просто получить всё состояние и начать прослушивание. Изменения будут синхронизированы между любым количеством процессов.

Паттерны хранения данных


Существует несколько паттернов хранения структурированных данных в Redis. В этой главе мы рассмотрим следующие:

  • хранение данных в JSON;
  • хранение объектов.

Хранение данных в JSON


Существуют несколько вариантов хранения данных в формате JSON в Redis. Наиболее общая форма – сериализовать объект предварительно и сохранить под специальным ключом:

> SET car "{\"colour\":\"blue\",\"make\":\"saab\",\"model\":93,\"features\":[\"powerlocks\",\"moonroof\"]}"
OK
> GET car
"{\"colour\":\"blue\",\"make\":\"saab\",\"model\":93,\"features\":[\"powerlocks\",\"moonroof\"]}"

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

  • сериализация занимает клиентские вычислительные ресурсы на чтение и запись;
  • JSON-формат увеличивает размер данных;
  • Redis имеет только косвенный способ обращения с данными в JSON.

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

До Redis 4.0 единственным методом работы с JSON внутри Redis было использование Lua-скрипта в модуле cjson. Это частично решало проблему, хотя всё ещё оставалось узким местом и создавало дополнительную мороку с изучением Lua. Кроме того, многие приложения просто получали всю JSON-строку, десериализовали её, работали с данными, сериализовали обратно и снова сохраняли. Это антипаттерн. Есть большой риск потерять данные таким способом.

# Экземпляр приложения #1 Экземпляр приложения #2
1
> GET my-car
2 [десериализовать, изменить цвет машины и сериализовать снова]
> GET my-car
3
> SET my-car

[новое значение из экземпляра #1]
[десериализовать, изменить модель машины и сериализовать снова]
4
> SET my-car

[новое значение из экземпляра #2]
5
> GET my-car

Результат на строке 5 покажет изменения только экземпляра 2, и изменение цвета экземпляром 1 будет утеряно.

Redis версий 4.0 и выше имеет возможность использовать модули. ReJSON – это модуль, который предоставляет специальный тип данных и команды прямого взаимодействия с ним. ReJSON сохраняет данные в двоичном формате, что снижает размер хранимых данных, предоставляет более быстрый доступ к элементам без затрат времени на де-/сериализацию.

Чтобы использовать ReJSON, нужно установить его на Redis-сервер или включить в Redis Enterprise.

Предыдущий пример с использованием ReJSON будет выглядеть так:

# Экземпляр приложения #1 Экземпляр приложения #2
1
> JSON.SET car2 . '{"colour": "blue",  "make":"saab", "model":93,  "features": ["powerlocks",  "moonroof"]}‘
OK
2
> JSON.SET car2 colour '"red"'
OK
3
> JSON.SET car2 model '95'
OK
> JSON.GET car2 .
"{\"colour\":\"red",\"make\":\"saab\",\"model\":95,\"features\":[\"powerlocks\",\"moonroof\"]}"

ReJSON предоставляет более безопасный, более быстрый и более интуитивный способ работы с данными в формате JSON в Redis, особенно в тех случаях, где необходимы атомарные изменения вложенных элементов.

Хранение объектов


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

{
    "colour": "blue",
    "make": "saab",
    "model": {
        "trim": "aero",
        "name": 93
    },
    "features": ["powerlocks", "moonroof"]
}

Используя JSONPath (XPath для JSON), мы можем представить каждый элемент на одном уровне хэш-таблицы:

> HSET car3 colour blue
> HSET car3 make saab
> HSET car3 model.trim aero
> HSET car3 model.name 93
> HSET car3 features[0] powerlocks
> HSET car3 features[1] moonroof

Для ясности команды приведены по отдельности, но в HSET можно передавать множество параметров.

Теперь можно запросить весь объект или отдельное его поле:

> HGETALL car3
 1) "colour"
 2) "blue"
 3) "make"
 4) "saab"
 5) "model.trim"
 6) "aero"
 7) "model.name"
 8) "93"
 9) "features[0]"
10) "powerlocks"
11) "features[1]"
12) "moonroof"

> HGET car3 model.trim
"aero"

И хотя это предоставляет быстрый и полезный способ получения хранимого объекта в Redis, это имеет свои недостатки:

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

  • ненужный расход ресурсов в ключах JSONPath.

Этот паттерн в значительной степени совпадает с ReJSON. Если ReJSON доступен, то в большинстве случаев лучше использовать его. Однако, сохранение объектов способом выше имеет одно преимущество перед ReJSON: интеграция с командой Redis SORT. Однако, эта команда вычислительно сложна и является отдельной сложной темой за рамками этого паттерна.

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

P.S. Текст этих статей на «варварском» английском я постарался максимально доступно адаптировать на русский язык, но если вы считаете, что где-то мысль непонятна или некорректна, поправьте меня в комментариях.