[Из песочницы] Распределённый чат на Node.JS и Redis
Небольшой вопрос/ответ:
Для кого это? Людям, которые мало или вообще не сталкивались с распределенными системами, и которым интересно увидеть как они могут строится, какие существуют паттерны и решения.
Черпал информацию с разных источников, решил выложить в концентрированном виде, ибо в свое время сам хотел бы увидеть подобную работу. Зачем это? Самому стало интересно что и как. Также, наверняка будет много исправлений в комментариях от знающих людей, отчасти это и есть целью написания всего этого именно в виде статьи. По сути это текстовое изложение моих личных метаний и раздумий.
Постановка задачи
Это должно быть тривиальной задачей, наверное каждый второй бекендер пилил свой собственный, так же как игровые разработчики делают свои тетрисы/змейки и т. Как сделать чат? Я взялся за такой, но чтоб было интереснее он должен быть готов к захвату мира, чтоб мог выдерживать сотниллиарды активных пользователей и вообще был неимоверно крут. п. Заместо того чтоб просто сидеть и ждать на появление квантовых компьютеров я решительно взялся за изучение темы распределенных систем. Из этого исходит ясная потребность в распределенной архитектуре, потому что вместить все воображаемое количество клиентов на одной машине — пока нереально с нынешними мощностями.
а не доставка почты голубями. Стоит отметить что быстрый отклик очень важен, пресловутый realtime, ведь это же чат!
%рандомная шутка про почту россии%
JS, он идеален для прототипирования. Использовать будем Node. IO. Для сокетов возьмем Socket. Писать на TypeScript.
И так, что вообще мы хотим:
- Чтоб пользователи могли слать друг-другу сообщения
- Знать кто онлайн/оффлайн
Как мы это хотим:
Сингл сервер
Объявим интерфейс сообщения: Тут нечего говорить особо, сразу к коду.
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. Нам нужно наконец-то выйти за границы одной железки!
Распределяй и велосипедь
Очевидно, нужно как-то связать наши инстансы, запущенные не только дома в подвале, но и в соседском подвале тоже. Как это сделать? Что первое приходит в голову: делаем некое промежуточное звено, которое послужит шиной между всеми нашими нодами:
Наша сеть готова! Когда нода хочет послать сообщение другой, она делает запрос к Bus, и уже он в свою очередь пересылает его куда надо, все просто.
FIN.
… но ведь не все так просто?)
Так давайте двинемся именно в эту сторону! При таком подходе мы упираемся в производительность этого промежуточного звена, да и вообще хотелось бы напрямую обращаться к нужным нодам, ведь что может быть быстрее общения на прямую?
Собственно, законнектить один инстанс к другому. Что нужно в первую очередь? Мы же хотим иметь их бесконечное количество, произвольно поднимать/убирать! Но как первому узнать об существовании второго? Нужен мастер-сервер, адрес которого заведомо известен, к нему все коннектятся, за счет чего он знает все существующие ноды в сети и этой информацией добросердечно делиться с всеми желающими.
В качестве мастера может выступать Consul или что-то подобное, но поскольку мы велосипедим то и мастер должен быть самопальный. Т е нода поднимается, говорит мастеру о своем пробуждении, он дает список других активных нод, мы к ним подключаемся и все, сеть готова.
Но текущая реализация чата в нем уже не пригодна. Отлично, теперь мы располагаем собственным скайнетом! Давайте, собственно, придумаем требования:
- Когда пользователь шлет сообщение, мы должны знать КОМУ он его шлет, т е иметь доступ к участникам комнаты.
- Когда мы получили участников мы должны доставить им сообщения.
- Мы должны знать какой пользователь в онлайне сейчас
- Для удобства — дать пользователям возможность подписываться на онлайн статус других пользователей, чтоб в реальном времени узнавать об его изменении
Например, можно сделать чтоб мастер знал к какой ноде какой юзерь подключен. Давайте разберемся с пользователями. Ситуация следующая:
Мастер знает об этом, ноды знают что мастер знает. Два пользователя подсоединены к разным нодам. Когда UserA захочет послать сообщение UserB то получится следующая картина: Когда UserB авторизуется, Node2 уведомляет об этом Master, который "запоминает" что UserB присоединен к Node2.
Это можно сделать, если они будут сообщать всем окружающим какие пользователи к ним подключены, каждая из них становится самодостаточным аналогом мастера, и сам мастер становится не нужным, т к список соотношения "User=>Node" дублируется у всех. В принципе все работает, но хотелось бы избежать лишнего раунд трипа в виде опрашивания мастера, экономнее было бы сразу обращаться напрямую к нужной ноде, ведь именно ради этого все и затеивалось. При старте ноде достаточно подключиться к любой уже запущенной, стянуть ее список себе и вуаля, она также готова к бою.
Да и вообще пилить такое самому — это явно попахивает велосипедостроительной промышленностью. Но качестве 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")
оно пушится в редис, передается всем другим инстансам нашего чата, которые в свою очередь эмитят его у себя уже локально по сокетам
Т е каждая нода принимает все сообщения и уже сама разбирается нужно ли оно ей. Сообщение разойдется по всех нодах даже если мы эмитим конкретному пользователю.
Например можно управлять сокетами удаленно, типо "кто находится в указанной комнате", "приказать сокету присоединиться к комнате", и т д. Также, там реализован простенький rpc (вызов удаленных процедур), позволяющий не только отсылать но и получать ответы.
Например, использовать идентификатор пользователя в качестве имени комнаты (user id == room id). Что с этим можно сделать? Еще, мы можем узнать онлайн ли юзер, элементарно глянув есть ли сокеты в указанной комнате. При авторизации джоинить сокет к ней, а когда мы захотим послать пользователю сообщение — просто шлем в нее.
В принципе на этом можно остановиться, но нам как всегда мало:
- Бутылочное горлышко в виде одного инстанса редиса
- Избыточность, хотелось бы чтоб ноды получали только нужные им сообщения
На счет пункта первого, глянем на такую штуку как:
Redis cluster
Но как он это делает? Связывает между собой несколько инстансов редиса, после чего работают как единое целое. Да вот так:
Т е он предназначен не для прироста производительности, а для повышения надежности, что конечно хорошо и нужно, но для нашего кейса не имеет ценности и никак не спасает ситуацию с бутылочным горлышком, плюс в сумме это еще больше расходования ресурсов. … и видим что сообщение дублируется всем участникам кластера.
Нет, редис оставим дабы совсем не скатываться, но нужно что-то придумать с архитектурой ибо текущая никуда не годится. Я новичек, многого не знаю, иногда приходится возвращаться к вилесопедостроению, чем мы и займемся.
Поворот не туда
Повысить общую пропускную способность. Что нам нужно? Представим что socket.io-redis умеет подключаться к нескольким, при пуше сообщения он выбирает рандомный, а подписывается на все. Например попробуем тупо заспавнить еще один инстанс. Получится вот так:
В общем и целом, проблема решена, редис теперь не узкое место, можно спавнить сколь угодно экземпляров! Вуаля! Да-да, инстансы нашего чатика по прежнему переваривают ВСЕ сообщения, кому бы они не предназначались. Зато им стали ноды.
Можно наоборот: подписываться на один рандомный, что уменьшит нагрузку на ноды, а пушить в все:
Это тоже никуда не годится. Видим что стало наоборот: ноды чувствуют себя спокойнее, но на инстанс редиса нагрузка выросла. Нужно чутка повелосипедить.
И так, подключаем редис: Дабы прокачать нашу систему оставим пакет 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)
})
Следует заметить, однако, что самих подписок теперь намного, намного больше, а значит будет кушать память ой йой йой, + больше операций подписки/отписки, которые сравнительно дорогие. Офигенно, теперь мы уверены что ноды получают только предназначенные им сообщения, ничего лишнего! Например, ноды могут подписываться на один из нескольких инстансов редиса, а при пуше — отсылать сообщение в все инстансы: Но в любом случае это придает нам некоторой гибкости, даже можно на этом моменте остановиться и пересмотреть заново все предыдущие варианты, уже с учетом нашего нового свойства нод в виде более выборочного, целомудренного получения сообщений.
В один момент в голову пришла следующая схема: а что если инстансы редисов поделить на группы, скажем А и В по два инстанса в каждой. … но, как ни крути, они все равно не дают бесконечной расширяемости при разумных накладных расходах, нужно рожать иные варианты. Ноды при подписке подписываются по одному инстансу от каждой группы, а при пуше отсылают сообщение в все инстансы какой-то одной рандомной группы.
Таким образом мы получаем действующую структуру с бесконечным потенциалом расширяемости в реальном времени, нагрузка на отдельный узел в любой точке не зависит от размера системы, ибо:
- Общая пропускная способность делится между группами, т. е. при увеличении пользователей/активности просто сравним дополнительные группы.
- Менеджмент пользователями (подписками) делится внутри самих групп, т е при увеличении пользователей/подписок просто наращиваем количество инстансов внутри групп.
… и как всегда есть одно "НО": чем больше оно все становится, тем больше ресурсов нужно для следующего прироста, это мне кажется непомерным trade off.
Ну ведь действительно, имея мы эту информацию, могли б пушить сообщения сразу куда надо, без лишнего дублирования. Вообще, если подумать — вышеупомянутые затыки исходят из незнания, на какой ноде какой пользователь. Пытались сделать систему бесконечно масштабируемой, при этом не имея механизма четкой адресации, из за чего неизбежно упарывались либо в тупик, либо в неоправданную избыточность. Что мы пытались сделать все это время? Например можно вспомнить об мастере, выполняющий роль "адресной книги":
Что-то похожее рассказывает этот чувак:
Кажется мы копаем в не совсем правильную сторону, нужно что-то иное... Чтобы получить местонахождение пользователя мы проделываем дополнительный раундтрип, что в принципе О'К, но не в нашем случае.
Сила хэша
У него есть некоторый конечный диапазон значений. Есть такая штука как хэш. А что если разделить этот диапазон между инстансами редиса? Получить его можно из любых данных. Т е мы заранее не знаем где какой юзерь существует, но получив его айди можем с уверенностью сказать что он именно в 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 с его плагином, позволяющий вытворять то же что и мы, и + обеспечивает миграцию подписок (как я говорил выше — он обвязан функционалом с ног до головы).
Залил репозиторий на гитхаб.
Помимо всего, там есть дополнительная логика по работе с комнатами (диалогами). В нем реализован тот финальный вариант к которому мы пришли.
В общем, я доволен и можно закругляться.
Итого
Можно сделать что угодно, но есть такая штука как ресурсы, а они конечны, поэтому нужно извиваться.
Мы начали с полного незнания как могут работать распределенные системы к более менее осязаемым конкретным паттернам, и это хорошо.