Хабрахабр

[Из песочницы] Распределённый чат на Node.JS и Redis

Результат пошуку зображень за запитом "голубиная почта"

Небольшой вопрос/ответ:

Для кого это? Людям, которые мало или вообще не сталкивались с распределенными системами, и которым интересно увидеть как они могут строится, какие существуют паттерны и решения.

Черпал информацию с разных источников, решил выложить в концентрированном виде, ибо в свое время сам хотел бы увидеть подобную работу. Зачем это? Самому стало интересно что и как. Также, наверняка будет много исправлений в комментариях от знающих людей, отчасти это и есть целью написания всего этого именно в виде статьи. По сути это текстовое изложение моих личных метаний и раздумий.

Постановка задачи

Это должно быть тривиальной задачей, наверное каждый второй бекендер пилил свой собственный, так же как игровые разработчики делают свои тетрисы/змейки и т. Как сделать чат? Я взялся за такой, но чтоб было интереснее он должен быть готов к захвату мира, чтоб мог выдерживать сотниллиарды активных пользователей и вообще был неимоверно крут. п. Заместо того чтоб просто сидеть и ждать на появление квантовых компьютеров я решительно взялся за изучение темы распределенных систем. Из этого исходит ясная потребность в распределенной архитектуре, потому что вместить все воображаемое количество клиентов на одной машине — пока нереально с нынешними мощностями.

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

%рандомная шутка про почту россии%

JS, он идеален для прототипирования. Использовать будем Node. IO. Для сокетов возьмем Socket. Писать на TypeScript.

И так, что вообще мы хотим:

  1. Чтоб пользователи могли слать друг-другу сообщения
  2. Знать кто онлайн/оффлайн

Как мы это хотим:

Сингл сервер

Объявим интерфейс сообщения: Тут нечего говорить особо, сразу к коду.

interface Message{ roomId: string,//В какую комнату пишем message: string,//Что мы туда пишем
}

На сервере:

io.on('connection', sock=>{ //Присоеденяемся в указанную комнату sock.on('join', (roomId:number)=> sock.join(roomId)) //Пишем в указанную комнату //Все кто к ней присоеденился ранее получат это сообщение sock.on('message', (data:Message)=> io.to(data.roomId).emit('message', data))
})

На клиенте что-то типа:

sock.on('connect', ()=> from ${data.roomId}`)) //Присоеденяемся к одной sock.emit('join', roomId) //И пишем в нее sock.emit('message', <Message>{roomId: roomId, message: 'Halo!'})
})

С онлайн статусом можно работать так:

io.on('connection', sock=>{ //При авторизации присоеденяем сокет в комнату с идентификатором пользователя //В будущем, если нужно будет послать сообщение конкретному пользователю - //можно его скинуть прямо в нее sock.on('auth', (uid:string)=> sock.join(uid)) //Теперь, чтоб узнать онлайн ли пользователь, //просто смотрим есть ли кто в комнате с его айдишником //и отправляем результат sock.on('isOnline', (uid:string, resp)=> resp(io.sockets.clients(uid).length > 0))
})

И на клиенте:

sock.on('connect', ()=> { const uid = 'im uid, rly' //Типо авторизуемся sock.emit('auth', uid) //Смотрим в онлайне ли мы sock.emit('isOnline', uid, (isOnline:boolean)=> console.log(`User online status is ${isOnline}`))
})

Примечание: код не запускал, пишу по памяти просто для примера

Просто как дрова, докручиваем сюды реальную авторизацию, менеджмент комнат (история сообщений, добавление/удаление участников) и профит.

Мы же собрались захватывать мир во всем мире, а значит не время останавливаться, стремительно движемся дальше: НО!

Node.JS кластер

IO на множестве нод есть прямо на официальном сайте. Примеры использования Socket. JS кластер, который мне показался неприменимым к моей задаче: он позволяет расширить наше приложение по всей машине, НО не за ее рамки, поэтому решительно пропускаем мимо. В том числе там есть и про родной Node. Нам нужно наконец-то выйти за границы одной железки!

Распределяй и велосипедь

Очевидно, нужно как-то связать наши инстансы, запущенные не только дома в подвале, но и в соседском подвале тоже. Как это сделать? Что первое приходит в голову: делаем некое промежуточное звено, которое послужит шиной между всеми нашими нодами:

1549140775997

Наша сеть готова! Когда нода хочет послать сообщение другой, она делает запрос к Bus, и уже он в свою очередь пересылает его куда надо, все просто.

FIN.

… но ведь не все так просто?)

Так давайте двинемся именно в эту сторону! При таком подходе мы упираемся в производительность этого промежуточного звена, да и вообще хотелось бы напрямую обращаться к нужным нодам, ведь что может быть быстрее общения на прямую?

Собственно, законнектить один инстанс к другому. Что нужно в первую очередь? Мы же хотим иметь их бесконечное количество, произвольно поднимать/убирать! Но как первому узнать об существовании второго? Нужен мастер-сервер, адрес которого заведомо известен, к нему все коннектятся, за счет чего он знает все существующие ноды в сети и этой информацией добросердечно делиться с всеми желающими.

1549048945334

В качестве мастера может выступать Consul или что-то подобное, но поскольку мы велосипедим то и мастер должен быть самопальный. Т е нода поднимается, говорит мастеру о своем пробуждении, он дает список других активных нод, мы к ним подключаемся и все, сеть готова.

Но текущая реализация чата в нем уже не пригодна. Отлично, теперь мы располагаем собственным скайнетом! Давайте, собственно, придумаем требования:

  1. Когда пользователь шлет сообщение, мы должны знать КОМУ он его шлет, т е иметь доступ к участникам комнаты.
  2. Когда мы получили участников мы должны доставить им сообщения.
  3. Мы должны знать какой пользователь в онлайне сейчас
  4. Для удобства — дать пользователям возможность подписываться на онлайн статус других пользователей, чтоб в реальном времени узнавать об его изменении

Например, можно сделать чтоб мастер знал к какой ноде какой юзерь подключен. Давайте разберемся с пользователями. Ситуация следующая:

1549237952673

Мастер знает об этом, ноды знают что мастер знает. Два пользователя подсоединены к разным нодам. Когда UserA захочет послать сообщение UserB то получится следующая картина: Когда UserB авторизуется, Node2 уведомляет об этом Master, который "запоминает" что UserB присоединен к Node2.

1549140491881

Это можно сделать, если они будут сообщать всем окружающим какие пользователи к ним подключены, каждая из них становится самодостаточным аналогом мастера, и сам мастер становится не нужным, т к список соотношения "User=>Node" дублируется у всех. В принципе все работает, но хотелось бы избежать лишнего раунд трипа в виде опрашивания мастера, экономнее было бы сразу обращаться напрямую к нужной ноде, ведь именно ради этого все и затеивалось. При старте ноде достаточно подключиться к любой уже запущенной, стянуть ее список себе и вуаля, она также готова к бою.

1549139768940

1549139882747

Да и вообще пилить такое самому — это явно попахивает велосипедостроительной промышленностью. Но качестве trade off мы получаем дублирование списка, который хоть и являет собой соотношение "user id -> [host connections]", но все же при достаточном количестве пользователей получится довольно большим по памяти. Пожалуй, заморозим этот вариант и глянем что уже есть из готового: Чем больше кода — тем больше потенциальных ошибок.

Брокеры сообщений

Его задача — получение и доставка сообщений. Сущность реализующая тот самый "Bus", "промежуточное звено" упомянутое выше. Все просто. Мы, как пользователи — можем подписаться на них и отсылать свои.

В их мире сообщение должно быть доставлено, несмотря ни на что. Есть зарекомендовавшие себя RabbitMQ и Kafka: они только и делают что доставляют сообщения — таково их предназначение, напичканы всем необходимым функционалом по горло.

Ему абсолютно плевать на сами сообщения, пропадут они, подвис ли подписчик — он выбросит его и возьмется за новое, будто ему в руки кидают раскаленную кочергу от которой хочется избавиться по быстрее. В то же время есть Redis и его pub/sub — тоже что и вышеупомянутые ребята но более дубово: он просто тупо принимает сообщение и доставляет подписчику, без всяких очередей и остальных оверхедов. Иными словами — ни о какой гарантии доставки речи не идет. Также, если он вдруг упадет — все сообщения также канут вместе с ним.

… и это то что нужно!

Не какой-то критический сервис по работе с деньгами или центр управления космическими полетами, а… просто чат. Ну действительно же, мы делаем просто чат. Тем более что в то же время можно вести историю сообщений в каком нибудь персистентном хранилище, а значит Петя таки увидит то самое пропущенное сообщение перезагрузив страницу/приложение. Риск что условному Пете раз в год не придет одно сообщение из тысячи — им можно пренебречь, если в замен получаем рост производительности а в месте с ним количества пользователей за те же деньки, trade off во всей красе. сайте. Именно по этому остановимся на Redis pub/sub, а точнее: посмотрим на существующий адаптер для SocketIO, который упоминается в статье на оф.

Так что это?

Redis adapter

https://github.com/socketio/socket.io-redis

Но как? С его помощью обычное приложение посредством нескольких строчек и минимальным количеством телодвижений превращается в настоящий распределенный чат! Если посмотреть внутрь — оказывается там всего один файл на пол сотни строк.

В случай, когда мы эмитим сообщение

io.emit("everyone", "hello")

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

1549232309776

Т е каждая нода принимает все сообщения и уже сама разбирается нужно ли оно ей. Сообщение разойдется по всех нодах даже если мы эмитим конкретному пользователю.

Например можно управлять сокетами удаленно, типо "кто находится в указанной комнате", "приказать сокету присоединиться к комнате", и т д. Также, там реализован простенький rpc (вызов удаленных процедур), позволяющий не только отсылать но и получать ответы.

Например, использовать идентификатор пользователя в качестве имени комнаты (user id == room id). Что с этим можно сделать? Еще, мы можем узнать онлайн ли юзер, элементарно глянув есть ли сокеты в указанной комнате. При авторизации джоинить сокет к ней, а когда мы захотим послать пользователю сообщение — просто шлем в нее.

В принципе на этом можно остановиться, но нам как всегда мало:

  1. Бутылочное горлышко в виде одного инстанса редиса
  2. Избыточность, хотелось бы чтоб ноды получали только нужные им сообщения

На счет пункта первого, глянем на такую штуку как:

Redis cluster

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

1549233023980

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

1549231953897

Нет, редис оставим дабы совсем не скатываться, но нужно что-то придумать с архитектурой ибо текущая никуда не годится. Я новичек, многого не знаю, иногда приходится возвращаться к вилесопедостроению, чем мы и займемся.

Поворот не туда

Повысить общую пропускную способность. Что нам нужно? Представим что socket.io-redis умеет подключаться к нескольким, при пуше сообщения он выбирает рандомный, а подписывается на все. Например попробуем тупо заспавнить еще один инстанс. Получится вот так:

1549239818663

В общем и целом, проблема решена, редис теперь не узкое место, можно спавнить сколь угодно экземпляров! Вуаля! Да-да, инстансы нашего чатика по прежнему переваривают ВСЕ сообщения, кому бы они не предназначались. Зато им стали ноды.

Можно наоборот: подписываться на один рандомный, что уменьшит нагрузку на ноды, а пушить в все:

1549239361416

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

И так, подключаем редис: Дабы прокачать нашу систему оставим пакет socket.io-redis в покое, он хоть и классный но нам нужно больше свободы.

//Отдельные каналы для:
const pub = new RedisClient({host: 'localhost', port: 6379})//Пуша сообщений
const sub = new RedisClient({host: 'localhost', port: 6379})//Подписок на них //Также вспоминаем этот интерфейс
interface Message{ roomId: string,//В какую комнату пишем message: string,//Что мы туда пишем
}

Настраиваем нашу систему сообщений:

//Отлавливаем все приходящие сообщения тут
sub.on('message', (channel:string, dataRaw:string)=> { const data = <Message>JSON.parse(dataRaw) io.to(data.roomId).emit('message', data))
}) //Подписываемся на канал
sub.subscribe("messagesChannel") //Присоеденяемся в указанную комнату
sock.on('join', (roomId:number)=> sock.join(roomId)) //Пишем в комнату
sock.on('message', (data:Message)=> { //Публикуем в канал pub.publish("messagesChannel", JSON.stringify(data))
})

Сейчас мы это исправим. На данный момент получается как в socket.io-redis: мы слушаем все сообщения.

Таким образом наши ноды будут получать только предназначенные им сообщения, а не слушать "весь эфир". Организуем подписки следующим образом: вспоминаем концепцию с "user id == room id", и при появлении пользователя подписываемся на одноименный канал в редисе.

//Отлавливаем все приходящие сообщения тут
sub.on('message', (channel:string, message:string)=> { io.to(channel).emit('message', message))
}) let UID:string|null = null;
sock.on('auth', (uid:string)=> { UID = uid //Когда пользователь авторизируется - подписываемся на //одноименный нашему UID канал sub.subscribe(UID) //И соответствующую комнату sock.join(UID)
})
sock.on('writeYourself', (message:string)=> { //Пишем сами себе, т е публикуем сообщение в канал одноименный UID if (UID) pub.publish(UID, message)
})

Следует заметить, однако, что самих подписок теперь намного, намного больше, а значит будет кушать память ой йой йой, + больше операций подписки/отписки, которые сравнительно дорогие. Офигенно, теперь мы уверены что ноды получают только предназначенные им сообщения, ничего лишнего! Например, ноды могут подписываться на один из нескольких инстансов редиса, а при пуше — отсылать сообщение в все инстансы: Но в любом случае это придает нам некоторой гибкости, даже можно на этом моменте остановиться и пересмотреть заново все предыдущие варианты, уже с учетом нашего нового свойства нод в виде более выборочного, целомудренного получения сообщений.

1550174595491

В один момент в голову пришла следующая схема: а что если инстансы редисов поделить на группы, скажем А и В по два инстанса в каждой. … но, как ни крути, они все равно не дают бесконечной расширяемости при разумных накладных расходах, нужно рожать иные варианты. Ноды при подписке подписываются по одному инстансу от каждой группы, а при пуше отсылают сообщение в все инстансы какой-то одной рандомной группы.

1550174092066

1550174943313

Таким образом мы получаем действующую структуру с бесконечным потенциалом расширяемости в реальном времени, нагрузка на отдельный узел в любой точке не зависит от размера системы, ибо:

  1. Общая пропускная способность делится между группами, т. е. при увеличении пользователей/активности просто сравним дополнительные группы.
  2. Менеджмент пользователями (подписками) делится внутри самих групп, т е при увеличении пользователей/подписок просто наращиваем количество инстансов внутри групп.

… и как всегда есть одно "НО": чем больше оно все становится, тем больше ресурсов нужно для следующего прироста, это мне кажется непомерным trade off.

Ну ведь действительно, имея мы эту информацию, могли б пушить сообщения сразу куда надо, без лишнего дублирования. Вообще, если подумать — вышеупомянутые затыки исходят из незнания, на какой ноде какой пользователь. Пытались сделать систему бесконечно масштабируемой, при этом не имея механизма четкой адресации, из за чего неизбежно упарывались либо в тупик, либо в неоправданную избыточность. Что мы пытались сделать все это время? Например можно вспомнить об мастере, выполняющий роль "адресной книги":

1550233610561

Что-то похожее рассказывает этот чувак:

Кажется мы копаем в не совсем правильную сторону, нужно что-то иное... Чтобы получить местонахождение пользователя мы проделываем дополнительный раундтрип, что в принципе О'К, но не в нашем случае.

Сила хэша

У него есть некоторый конечный диапазон значений. Есть такая штука как хэш. А что если разделить этот диапазон между инстансами редиса? Получить его можно из любых данных. Т е мы заранее не знаем где какой юзерь существует, но получив его айди можем с уверенностью сказать что он именно в n инстансе, инфа 100. Ну вот берем мы идентификатор пользователя, производим хэш, и в зависимости от диапазона в котором он оказался подписываемся/пушим в один определенный инстанс. Теперь то же самое, но с кодом:

function hash(val:string):number{/**/}//Наша хэш-функция, возвращающая число
const clients:RedisClient[] = []//Массив клиентов редиса
const uid = "some uid"//Идентификатор пользователя //Теперь, такой не хитрой манипуляцией мы получаем всегда один и тот же
//клиент из множества для данного пользователя
const selectedClient = clients[hash(uid) % clients.length]

Теперь мы не зависим от количества инстансов от слова вообще, можем скалироваться сколь угодно без оверхедов! Вуаля! Есть такая штука как Стандартное кольцо и Партиционное кольцо, позволяющие побороть это, но они не применимы в условиях системы обмена сообщениями. Ну серьезно, это же гениальный вариант, единственный минус которого: нужда в полном перезапуске системы при обновлении количества редис-инстансов. Да и в нашем случае downtime вполне допустимый tradeoff. Ну т е сделать логику миграции подписок между инстансами можно, но это стоит еще дополнительный кусок кода непонятного размера, а как мы знаем — чем больше кода, тем больше багов, нам этого не надо, спасибо.

В принципе можно взять его и спать спокойно но, если кто шарит в его тюнинге дабы вывести в realtime режим оставив только лишь фичу с хэш рингом. Еще можно посмотреть на RabbitMQ с его плагином, позволяющий вытворять то же что и мы, и + обеспечивает миграцию подписок (как я говорил выше — он обвязан функционалом с ног до головы).

Залил репозиторий на гитхаб.

Помимо всего, там есть дополнительная логика по работе с комнатами (диалогами). В нем реализован тот финальный вариант к которому мы пришли.

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

Итого

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

Мы начали с полного незнания как могут работать распределенные системы к более менее осязаемым конкретным паттернам, и это хорошо.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»