Вы находитесь на странице: 1из 14

10.04.

2024, 22:15 Редактирование статьи / Хабр

КАК СТАТЬ АВТОРОМ DevOps в России: о боли, найме, открытом к…

Редактирование статьи
EugeneUshakov 15 марта в 00:34
Использование Redis почти как SQL БД:
Реализация чата с кешированием сообщений
Начту с описания проблемы.
Допустим, вы хотите создать чат и хранить сообщения для него.
Вполне возможно, вы можете добавить для этого простую базу
данных (БД), такую как MySQL или даже NoSQL.
Однако постоянно извлекать сообщения из БД может быть накладно и
долго. Особенно, если в чате есть большое количество
неавторизованных пользователей или пользователей
с определенными ролями, для которых так же, как и
для неавторизованных, нежелательно расходовать ресурсы БД
сервера. Кроме того, логично кешировать все сообщения
пользователей в чате, где‑то помимо основной базы данных, так
как это самая востребованная для получения информация. Логично
использовать Redis для кеширования. Мне понравилось видео,
которое за 100 секунд объясняет, что такое Redis — Redis in
100 Seconds.
Обычно многие используют Redis как key‑value (dictionary) хранилище.
Кстати, видео вскользь объясняет, что Redis — это несколько большее,
чем key‑value, как многие привыкли думать.

https://habr.com/ru/article/edit/800379/ 1/14
10.04.2024, 22:15 Редактирование статьи / Хабр

Подпись к изображению
Задача у нас несколько сложнее, не просто достать сообщения
из Redis по ключу, как обычно. Мы еще хотим и доставать сообщения
разными гибкими настраиваемыми и кастомными запросами,
в зависимости от разных входящих параметров и условий,
фильтровать и сортировать... В общем, сделать запросы к Redis почти
так же, как мы привыкли взаимодействовать с SQL БД. Логично
продублировать работу MySQL сервера для функционала выше и
добавить Redis как кэш для сообщений в чате.
Только есть проблема: Redis — NoSQL кэш БД и с довольно
урезанным функционалом.
Вы скажете... Мы же можем достать сообщения и их уже
отфильтровать на стороне сервера в коде по любой нужной логике. А
если сообщений десятки или сотни тысяч?! Это крайне
неэффективно.
Гораздо эффективнее было бы уже при запросе к Redis делать так,
чтобы он фильтровал, сортировал и выдавал результат, как обычная
SQL БД.
Удивлены или сомневаетесь, что такое возможно? Возможно!
Добро пожаловать под кат!

https://habr.com/ru/article/edit/800379/ 2/14
10.04.2024, 22:15 Редактирование статьи / Хабр

Подпись к изображению
Но... Давайте сначала все равно посмотрим на более полное
описание задачи.
При каждой отправке логично отсылать и доставлять сообщение
адресатам, например, по веб‑сокету. Также заносить это сообщение
в базу данных, чтобы всё это где‑то хранилось. Этого кода не будет
в примере, но это важно для общего понимания.
Мы намереваемся разработать на сервере API, которое
по нескольким разным входным параметрам выдает разный
результат. Если нужно, оно обратится в реальную БД MySQL, а если
надо — в кеш в Redis, имитируя Where-подобные запросы,
как для SQL. При этом мы избежим перебора, при котором сложность
может быть O(n), чего бы очень не хотелось.
К счастью, в Redis есть дополнительный модуль для этого —
RediSearch. Изначально он нужен для полнотекстового поиска.
Но обо всем по порядку.
https://habr.com/ru/article/edit/800379/ 3/14
Поехали...
10.04.2024, 22:15 Редактирование статьи / Хабр

В конце статьи будет пример использования транзакций в Redis


для batch update. Это важно для понимания всей проблематики.
Пример заготовки используемого в статье кода для получения данных
доступен на github.
Оговорюсь: я не хочу доказать точку зрения, что хорошо
использовать NoSQL кэш в SQL и реляционной парадигме, не думая
о модели данных, которая реально подходит для каждого случая и то,
что это можно сделать полноценно. Ведь зачем мешать кислое со
сладким? Но иногда бывает так, что часть логики SQL модели данных
надо повторить в NoSQL для удобства, а также для эффективности
работы бизнеса, и этого может требовать кейс, описаный в самом
начале.
Здесь и далее я буду использовать JS псевдокод-синтаксис: JS знают
все, и этот код можно легко переписать на любые другие языки.
При создании сообщения на сервере напишем код для добавления
записи в Redis.
Далее — псевдокод, где {value} — значение для параметра множества
возможных значений HSET в Redis.
1 redisClient.hSet('messages:'+ {id}, {
2 id: {id},
3 chatId: {chatId},
4 message: {message},
5 userId: {userId},
6 createdAt: {createdAtDt},
7 data: {dataJson},
8 read: 0
9 });

Получается набор записей хэшей, которые сгенерируют вызовы hset,


где ключ 'messages:'+{id} — это id сообщения. Значение id может
быть id созданного сообщения из БД, ведь оно может появиться
до записи сообщения в Redis.
Значения полей объектов в хэшах, которые соответствуют
ключам выше, дублируют значения колонок в таблице messages в SQL
https://habr.com/ru/article/edit/800379/ 4/14
БД.
10.04.2024, 22:15 Редактирование статьи / Хабр

Большинство полей в самом объекте должно быть понятно, однако я


объясню наименее очевидное.
read — логический флаг. Означает, прочитано ли уже сообщение.
По умолчанию считается, что сообщение пока никто не прочитал.
Далее напишем функцию подготовки универсального фильтра
для поиска как для SQL, так и для Redis. Объект данных, которые
возвращает эта функция, может использоваться как заготовка
для Where запроса к SQL БД и для подготовки Where
запроса‑близнеца к Redis.
1 static prepareSearchCriteria = (id, limit, onlyOwn, userId, currentUser) =>
2 ...
3 let searchCriteria;
4 let whereCriteria = {
5 chatId: id
6 };
7
8 if (onlyOwn) {
9 whereCriteria['userId'] = currentUser.id;
10 }
11 else if (userId !== null && userId !== undefined) {
12 whereCriteria['userId'] = userId;
13 }
14
15 searchCriteria = {
16 where: whereCriteria,
17 limit: limit,
18 order: [['createdAt', 'DESC']]
19 }
20 return searchCriteria;
21 }

Объясню неочевидное.
Строка 8. Если onlyOwn == true, то этот параметр имеет более
высокий приоритет над остальными для выборки, и сообщения
для себя надо достать в первую очередь.
Строка 15. Формируем объект searchCriteria, на основе которого
вырастет дальнейший запрос для SQL и Redis.
https://habr.com/ru/article/edit/800379/ 5/14
Прежде чем перейдем непосредственно к получению сообщений, я
10.04.2024, 22:15 Редактирование статьи / Хабр

настоятельно рекомендую ознакомиться с RediSearch, хотя бы


поверхностно, если вы не знакомы — здесь и здесь.
Забегая вперед: почему RediSearch, а не другой модуль, например
RedisJSON? RediSearch может делать полнотекстовый поиск. Также
RediSearch может обрабатывать JSON-структуры.
Получение данных с RediSearch ощутимо быстрее, чем с помощью
команды Redis — SCAN и тем более KEYS. KEYS вообще не годится
для продакшена. Так как Redis однопоточный, KEYS просто
заблокирует сервер. График сравнения с redis SCAN в примерах выше
можно посмотреть здесь. Один поиск у SCAN занял 10 секунд,
а RediSearch — 40 мс. Есть еще ZSET, но здесь речь больше идет о
тонкой настройке для поиска в дальнейшем. Например, для поиска по
тексту или части сообщения.

Подпись к изображению
Данные достаются по индексу, возможна высокооптимизированная
выдача и никаких O(n):) А при определенных форматах структур
данных для поиска (в том числе и наш случай) и шаблона для scan
нельзя гарантировать, что удастся избежать перебора.
Теперь настроим RediSearch так, чтоб он справлялся с запросом,
который мы сформировали выше. Сначала надо в RediSearch создать
индекс для поиска на нужные поля и их типы командой FT.CREATE.
1 const { SchemaFieldTypes } = require('redis');
2
3 static async initialize() {
4 await redisClient.connect();
5 try {

https://habr.com/ru/article/edit/800379/ 6/14
10.04.2024, 22:15 Редактирование статьи / Хабр

6 await redisClient.ft.create('idx:messages', {
7 userId: SchemaFieldTypes.NUMERIC,
8 chatId: SchemaFieldTypes.NUMERIC,
9 userId: SchemaFieldTypes.NUMERIC,
10 message: SchemaFieldTypes.TEXT,
11 ...
12 },
13 {
14 ON: 'HASH',
15 PREFIX: 'messages'
16 });
17 } catch (e) {
18 if (e.message === 'Index already exists') {
19 console.log('Index exists already, skipped creation.');
20 } else {
21 // Something went wrong, perhaps RediSearch isn't installed...
22 console.error(e);
23 process.exit(1);
24 }
25 }
26 }

Команда JS redisClient.ft.create вызывает команду FT.CREATE из Redis,


а команда redisClient.ft.search — команду FT.SEARCH соответственно.
Перейдем непосредственно к самому получению сообщений
из комнат чата.
Далее приведена развилка кода, чтобы было понятно, насколько
близко и почти одинаково идет получение по разным веткам для SQL
БД и для Redis.
Давайте допустим, что функция messages() завязана на REST запрос:
POST /chat/{id}/messages
1 Body
2 {
3 limit: { Limit сообщений – опционально },
4 onlyOwn: {Логический флаг, только ли сообщения для текущего пользовател
5 userId: {id пользователя для которого достать сообщения – опционально}
6 }

Здесь не GET, а POST, потому запрос может менять состояние данных


в БД и Redis, это увидим далее.
https://habr.com/ru/article/edit/800379/ 7/14
В примере приведена верхнеуровневая функция получения
10.04.2024, 22:15 Редактирование статьи / Хабр

сообщений, которая подготавливает запрос, если случай для SQL БД


и для Redis. Опустим подробности, их можно посмотреть в примере
на github. Скажу лишь, что в примере мы используем Redis для всех
ролей, которые не принадлежит конкретному типу.
1 static messages = async (req, res) => {
2 ...
3 let searchCriteria = ChatController.prepareSearchCriteria(id, limit, only
4 ...
5 let messages;
6 if (currentUser.typeId == RolesEnum.API) {
7 //code for fetching from SQL DB
8 ...
9 messages = ...;
10 }
11 else {
12 //code for fetching from Redis
13 ...
14 messages = ...;
15 }
16 ....
17 }

Теперь рассмотрим Redis; функционал более интересный.


1 static handleRedisReadMessages = async (searchCriteria, currentUser) => {
2 let whereCriteria = searchCriteria.where;
3 let redisArrParams = [];
4 let redisStrParams = "";
5 redisArrParams = ChatController.prepareRedisSearchParams(whereCriteria);
6 redisStrParams = redisArrParams.join(" ");
7 let respMessages = await redisClient.ft.search('idx:messages', redisStrPa
8 LIMIT: {
9 from: 0,
10 size: searchCriteria.limit
11 },
12 SORTBY: {
13 BY: searchCriteria.order[0][0],
14 DIRECTION: searchCriteria.order[0][1]
15 }
16 });
17 let filteredMessages = ChatController.filterAndMapMessages(respMessages)
18
19 //transaction part
20 let importMulti = redisClient.multi();
21 let shouldRedisUpdate = ChatController.isShouldUpdateMessagesInTransactio
https://habr.com/ru/article/edit/800379/ 8/14
10.04.2024, 22:15 Редактирование статьи / Хабр

22 ChatController.execTransactionMessagesUpdate(shouldRedisUpdate, importMul
23
24 return filteredMessages;
25 }

Смотрим строку 5 и видим


ChatController.prepareRedisSearchParams(whereCriteria), где
формируется запрос к Redis из универсального запроса, пригодного
как для SQL, так и для Redis. Функцию prepareRedisSearchParams
можно увидеть ниже.
Строка 6. redisStrParams = redisArrParams.join(" ") — склеиваем то, что
получилось, чтобы отправить сообщения для запроса в Redis.
Строка 7. Вызываем уже ft.search, передав ей то, что получилось для
универсального запроса, заодно задаем сортировку, в этом примере
будет по полю createdAt.
Методы ChatController.isShouldUpdateMessagesInTransaction и
ChatController.execTransactionMessagesUpdate относятся к работе с
транзакциями, о них поговорим далее отдельно.
Рассмотрим непосредственно саму подготовку параметров для
поиска в Redis:
1 static prepareRedisSearchParams = (whereCriteria) => {
2 let redisSearchParams = "";
3 redisSearchParams = Object.entries(whereCriteria).map(([key, value]
4 let resParam = null;
5 if (typeof value == "boolean") {
6 let bval = value == true ? 1 : 0;
7 resParam = `@${key}: [${bval} ${bval}]`;
8 }
9 else {
10 resParam = `@${key}: [${value} ${value}]`;
11 }
12
13 return resParam;
14 });
15 return redisSearchParams;
16 }

Строка 1.
https://habr.com/ru/article/edit/800379/ 9/14
whereCriteria — Мы уже видели этот универсальный объект с
10.04.2024, 22:15 Редактирование статьи / Хабр

параметрами для поиска.


Object.entries(whereCriteria).map(([key, value])
Методично достаем параметры для поиска и формируем из них
строки формата @{key}: [{value} {value}]
Два раза написанный value — это не опечатка. Такой синтаксис
запросов, даже если точное соответствие.
Для логических типов написана отдельная ветка с кодом. Если бы в
запросе были и другие типы, которые требуют особой обработки, то
их тоже пришлось бы написать.
Делаем маппинг для удобного отображения возвращаемых
сообщений пользователю.
Пример подготовки запроса для RediSearch.
При вызове функции static messages = async (req, res) => {}
Передали набор параметров.
Limit – 5, onlyOwn – false, userId= 7, chatId = 1.
Где: userId – id пользователя, для которого надо делать запрос в БД,
chatId — id чата.
Команда, которая отправится в Redis и результат работы вызова
функции redisClient.ft.search - FT.SEARCH "idx:messages"
"@chatThreadId: [1 1] @userId : [7 7]" LIMIT 0 5 SORTBY createdAt desc.
LIMIT задает смещение 0 и 5 это само количество сообщений.
Подбирая нужное смещение, можно реализовать пагинацию для чата.
Транзакции в Redis
Также стоит сказать про транзакции в Redis.
Например, после запроса на получение сообщений мы хотим также и
отмечать, какие пользователи еще выбраны, и добавить для этого
отдельную колонку в таблице messages, назвав её, например,
firstFetchedByUserId. Для простоты этот параметр не введен,
https://habr.com/ru/article/edit/800379/ 10/14
а добавлена колонка — прочитано сообщение или нет — read. Хоть это
10.04.2024, 22:15 Редактирование статьи / Хабр

и убогий, ограниченный подход, но он выглядит как подобие работы


с реляционными данными:).
Для случая SQL это сделать просто — для ORM Sequalize массовый
апдейт по критерию, берем id тех сообщений, что уже прочитали.
1 await Message.update({
2 read: true
3 }, {
4 where: {
5 id: idsRead,
6 },
7 });

Для случая Redis такой апдейт тоже делается несложно. Но первое,


что предлагает поиск Google для такого решения, — это LUA скрипты.
Можно было бы их рассматривать как отдаленных родственников
хранимых процедур и функций SQL БД. Только это решение
непригодно для случая с репликацией и любых случаев, когда узлов
БД больше чем 1, поэтому оно не годится.
Тут на помощь и приходят транзакции Redis.
Сначала определяем, нужно ли обновить сообщения, если хоть
одно было прочитано не тем пользователем, который его создал.
1 static isShouldUpdateMessagesInTransaction = (respMessages, importMulti, cu
2 let isUpdate = false;
3 respMessages.documents.forEach(mes => {
4 if ( parseInt(mes.value.userId) != currentUser.id ) {
5 if (!mes.value.read) {
6 importMulti.hSet(mes.id, {
7 "read": 1,
8 });
9 isUpdate = true;
10 }
11 }
12 });
13 return isUpdate;
14 }

https://habr.com/ru/article/edit/800379/ 11/14
Далее просто выполняем транзакцию, где записи обновляются
10.04.2024, 22:15 Редактирование статьи / Хабр

массово.
1 static execTransactionMessagesUpdate = (redisUpdate, importMulti) => {
2 if (redisUpdate) {
3 importMulti.exec(function(err,results){
4 if (err) { throw err; } else {
5 console.log(results);
6 client.quit();
7 }
8 });
9 }
10 }

Заключение.
Это основное, что я хотел показать; возможно, внимательный
читатель заметит, что есть еще и другие модули, вроде RedisJson, но
почему я не использовал его для подобной задачи, написал выше.
Еще в RediSearch полно и других команд вроде FT.AGGREGATE...
Цель же статьи — показать, насколько мощны модули, особенно
RediSearch, и то, что можно получить видимые профиты, используя их.
Например, существенно сэкономить машинные ресурсы, используя
правильный подход. Что было и сделано в конкретной бизнес-задаче.
Экономия ресурсов была существенной, но еще более
существенной была экономия нервных клеток заказчика
по сравнению с традиционным способом получения данных из Redis
(без использования модулей).
Сам же официальный список модулей с их кратким описанием можно
взять на официальном сайте Redis — здесь.
Нажмите "/" для вызова меню

Далее к настройкам
ХАБР ИЩЕТ АВТОРОВ

https://habr.com/ru/article/edit/800379/ 12/14
10.04.2024, 22:15 Редактирование статьи / Хабр

Мы ищем авторов в контент-студию — помогать компаниям делать крутые статьи


для техноблогов и мегапроекты.
Хотите писать не только на Хабр, но и для Хабра? Давайте познакомимся!

НОВЫЙ РЕДАКТОР

Это новый WYSIWYG-редактор, в котором по умолчанию будут создаваться все


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

ПАМЯТКА АВТОРУ

Соблюдайте правила сайта


Следуйте советам и заботливо оформляйте публикации
Используйте хаб «Я пиарюсь» для рекламных публикаций
Загружайте картинки меньше 8МБ для тела публикации и меньше 1МБ для
обложки публикации

Ваш аккаунт Разделы Информация Услуги


Профиль Статьи Устройство сайта Корпоративный
Трекер Новости Для авторов блог
Диалоги Хабы Для компаний Медийная
Настройки Компании Документы реклама
https://habr.com/ru/article/edit/800379/ 13/14
10.04.2024, 22:15 Редактирование статьи / Хабр

ППА Авторы Соглашение Нативные


Песочница Конфиденциальность проекты
Образовательные
программы
Стартапам

Настройка языка
Техническая поддержка
© 2006–2024, Habr

https://habr.com/ru/article/edit/800379/ 14/14

Вам также может понравиться