Задача сделать чат кажется довольно тривиальной: даже если вам не приходилось его делать самостоятельно, то, наверняка, видели примеры, как делают чаты в учебных примерах для различных технологиях. Поэтому мы не будем фокусироваться на деталях, а попробуем спроектировать архитектуру системы и рассчитать нагрузку, которая позволит всему этому функционировать. Кто-то называет это гордо system design, но как по мне - это просто предварительный расчёт.
Гипотетическая игра
Давайте предположим что у нас есть некая "браузерная игра", в которой могут играть одновременно от 2 до 32 игроков против компьютера и для координации их действий требуется чат. Они могут отправлять только текстовые сообщения размером не более 1 килобайта. Лимита на отправку сообщений нет.
Сбор первичных требований
Работающий чат - это, всего лишь, вершина айсберга: техническая и технологическая составляющие чата, обычно, всегда остаются "за скобками", но сейчас мы это исправим.
Если есть игра, значит есть игроки. Игроки могут иметь, а могут и не иметь учётную запись, значит идентификатор пользователя может быть как временный (на игровую сессию), так и постоянный. Но это не наша проблема: пользователь, входящий в игру, автоматически получает свой идентификатор.
Интерфейс, из которого пользователи будут писать и куда будут получать сообщения предоставляется нами и будет встроен в игру.
Технические особенности: мы поддерживаем как старые, так и новые браузеры.
Популярность: игра обещает стать довольно популярной, однако по прогнозам наших аналитиков, единовременное количество игроков не будет превышать 5000 человек (а, скорее всего, это очень оптимистичное ожидание), однако и этот показатель будет достигаться постепенно и будет распределён по регионам.
Устойчивость: чат должен быть доступен во время игрового процесса, однако данные чата не являются критически важными; их не требуется хранить и восстанавливать в случае переподключения к сессии. Более того, не требуется обеспечить 100% гарантии доставки и достоверности данных.
Как будем делать
Для начала составим структуру всего. Для этого мы вместо громоздкого UML используем упрощённую нотацию C4: контекст, контейнер, компонент и код (последний раздел, чаще всего, не заполняется).
Контекст
На этом этапе просто составим схему, как всё должно, в теории, выглядеть.
Тут я допустил сознательное усложнение: наш чат должен быть встроен в уже существующую модель работы, поэтому существующий контекст также упоминается. Так, для ясности.
Вообще, может появиться мысль, о том, что этот блок можно было бы и пропустить, однако документацию читают не только технари, но и толстосумы-инвесторы; пусть тоже хоть что-нибудь будут понимать.
Оценка оборудования
Перед тем, как разделить наше приложение на контейнеры, которые будут обслуживать работу чата, давайте прикинем наше серверное оборудование; возьмём его в аренду выделенные сервера где-нибудь, где есть хорошее железо и быстрый интернет.
В интересах сохранения бюджета, а также, принимая во внимание географическую распределённость игроков и постепенный рост их числа - будем использовать самое дешёвое предложение по серверам. Я нашёл такое ОС: Linux, CPU: 1x2.2ГГц, RAM: 512 Мб, HDD: 10 Гб, Выделенный IP примерно за 140 рублей в месяц. Ну а ещё это способ заложить потенциал роста приложения.
Сеть
В ограничение сети мы не упрёмся, т.к. современные серверные сетевые карты могут обрабатывать чуть ли не до 500.000 соединений, если верить маркетинговым обещаниям (но даже показатели в 10-20 раз меньше нас устроит). У нас столько пользователей нет и не предвидится и мы, скорее, упрёмся в ограничение железа, нежели сетевой карты. Так что этот аспект можно считать несущественным.
Долговременная память
Хотя у нас и есть в доступе 10 Гб, но нам они практически не потребуются. Разве что для хранения журналов. Чаты наши не сохраняются и не должны переживать перезапуск сервера. Мы же экономим, вы помните?
Оперативная память
Тут нам надо определить, а сколько подключение одного пользователя потребляет процессорного времени и памяти? На наших серверах есть лимиты, которые мы не можем превзойти, ибо жадничаем, то есть 512 Мб оперативной памяти - это совсем не много. И немного это по двум причинам: часть этой памяти будет использоваться под хранение данных (например с помощью Redis, который тоже будет использовать не менее 10 Мб под свой процесс).
Итого по памяти мы имеем, что за вычетом расхода памяти на наше приложение (примерно 30 Мб) и базы данных, размещённой в памяти (ещё 10 Мб), у нас остаётся свободным 472 Мб, часть из которых стоит зарезервировать под неотложные нужны самой ОС и покуда неучтённый софт. Итого, давайте считать, что в нашем распоряжении всего 400 Мб памяти.
Одно сообщение от пользователя содержит не более 1 Кб данных + сопроводительная информация, которой можно пренебречь (маловероятно, что все пользователи будут слать сообщения предельной длины); далее, эти данные маршрутизируются и отправляются заинтересованным пользователям, то есть дальше не хранятся. Поэтому 200 Мб следует отдать под хранение данных, а 200 - под новые сообщения пользователей. Таким образом, нетрудно подсчитать, что один сервер (по памяти) способен обслужить примерно 2000 подключений. Это, безусловно, очень грубый подсчёт, но на него уже можно ориентироваться.
Теперь мы знаем, что нам понадобится не менее 3 серверов для обслуживания 5000 пользователей по памяти плюс один сервер - балансировщик нагрузки (но это позже).
Разумеется, можно и прибавить памяти - это выйдет куда дешевле, нежели добавлять ещё несколько серверов. Но тогда не получится использовать балансировщик нагрузок. А хочется.
Процессорное время
Это довольно непростой момент: сколько процессорного времени будет потреблять одно подключение? Сейчас это неизвестно. Но, если предположить, что нам нужно принять данные, передать в базу, перенаправить адресатам, отослать, а, затем удалить, это вряд ли займёт много времени (больше будут издержки на I/O).
Пусть будет 1 миллисекунда. Фактически, 1 миллисекунда для нашего процессора - это 2.2 миллиона простых операций минимум. Ну или до 1000 операций ввода-вывода. Должно хватить с запасом.
Таким образом, процессор может обрабатывать минимум 1000 подключений в секунду. Это вдвое меньше, чем по памяти. И требует 5 серверов плюс 1 балансировщик нагрузки.
Первые подсчёты
Итак, мы пришли к заключению, что нам потребуется минимум 6 серверов (минимальное количество по процессорному времени), чтобы обеспечить только работу нашего приложения.
Но это, безусловно, предварительные оценки: мы точно не знаем, сколько памяти и процессорного времени будет потреблять наше приложение. Дальше есть две возможные стратегии развития ситуации: просчёт в большую или меньшую сторону. Поэтому лучше добавить стоимость ещё пары серверов на всякий случай в бюджет. Не понадобится - отлично, понадобится - мы готовы. Ну а не хватит - будем искать пути выхода.
Итого мы получаем 8 серверов по 140 рублей в месяц - 1120 рублей в месяц или 13440 рублей в год.
Синхронизация данных
А вот это уже сложнее: представьте, что в игре 32 пользователя, но подключены они к разным серверам. Но чат-то у них один! То, что пользователь_1 отправил на сервер_А, должно быть доступно у пользователя_3 на сервере_Г. В этот момент вечер перестаёт быть томным и начинаются муки репликации.
Увы, это тоже съест процессорное время и оперативную память, поэтому первоначальные оценки могут быть пересмотрены.
Благо, мы уже выбрали себе базу данных/очередь, которая размещается в памяти и имеет модель подписок. Поэтому, все сервера могут синхронизироваться между собой через эту модель, хотя это и приведёт (потенциально) к дополнительным издержкам по времени, памяти и процессорному времени.
Все сервера должны подписываться на все входящие события всех серверов и получать обновления. При достаточно интенсивном обмене, трафик между серверами может составлять до половины всех данных, что сократит доступную оперативную память вдвое, но это не должно быть проблемой с учётом ограничений по процессорному времени. Но а если мы ещё сможем передавать между серверами не все данные, а только те, что необходимы - издержки упадут ещё сильнее.
Итого
Пока, мы остановились на 6 серверах плюс два "запасных" за 13440 рублей в год. Думаю, эти цифры можно хорошо оптимизировать, но это уже другая задача.
Контейнеры
Второй уровень в C4 - это контейнеры. Некоторые автономные сущности в рамках контекста, обеспечивающие работу приложения.
У нас есть клиентская и серверная части. Если клиентская часть относительно проста, то серверная окажется довольно разветвлённой. Более того, все сервера, имеющие публичный IP желательно объединить дополнительно некоторой внутренней сетью (что у облачных провайдеров совсем не редкость), дабы обмен данными между ними шёл через закрытые сети. Желательно, но совсем не обязательно.
Что мы имеем:
- Веб-приложение - отдельно и работает с серверами по API и веб-сокетам;
- Балансировщик нагрузки принимает подключение и передаёт соответствующему серверу; дальше - работа ведётся с ним;
- WS/API сервера выполняют две функции: запуск сессии чата (на этом этапе создаётся чат) и обеспечение работы чата (собрать сообщения, скомпоновать их и отправить подписчикам);
- База данных/очередь - позволяет хранить в памяти данные и быстро пересылать их пользователям, в том числе, подключённым к другим серверам.
Есть, правда, во всём этом несколько узких мест: как создаются чат-сессии, как туда добавляются пользователи, как сервер А узнаёт о том, что надо подписаться на сообщения сервера Б и так далее. Это всё вопросы реализации, но мы это, частично, рассмотрим далее.
Компоненты
Сами по себе контейнеры - это ведь, по сути, не обязательно удалённые сервера; это может быть и простая виртуальная машина, развёрнутая на домашнем ПК. Но если с контейнером, скажем, базы данных всё ясно, то вот WS/API сервер должен уметь довольно много. И мы это сейчас определим в виде компонентов:
Создание, удаление и актуализация чата
Добавление чата потребует распространения данных об этом среди остальных серверов. Это достигается подпиской на специальные события. Создание чата инициируется внешним источником (сервером игры), им же и завершается, либо же сессия завершается по истечению некоторого времени.
Управляющие сигналы внешнего источника ретранслируются сервером, получившим его всем остальным серверам.
Если что-то поменялось, внешний источник может отправить "уточняющий сигнал".
Подключение пользователей к чату
Это также выполняет внешний источник. Поскольку своих идентификаторов у нас нет, мы используем те, что предоставляются. Каждый пользователь подключается к своему серверу и оказывается в группе чата. Все сообщения, поступившие в эту группу - рассылаются всем подписантам.
После того, как чат создан и пользователи к нему подключены, сервера обмениваются между собой информацией о том, что готовы обслуживать чат. Те сервера, у которых нет подписантов на чат - удаляют группу чата; прочие - подписываются друг на дружку с целью репликации данных.
Обмен данными между пользователями чата
Все сервера подписаны друг на дружку. Это позволяет сразу переправлять сообщения между ними, хотя и с некоторой задержкой. Однако, переправляются только те сообщения, которыми надо поделиться.
Как, например, это работает:
- Создан чат (Ч1) и к нему подключились пользователи на сервере 1 (С1) и сервере 3 (С3);
- Пользователь, подключённый к С1 передаёт сообщение;
- С1 обрабатывает сообщение и пересылает его всем заинтересованным;
- С3 подключён к С1 и получает по подписке пакет данных, где есть сообщение для Ч1 (теоретически, он может в одном пакете получить сразу много сообщений для множества чатов);
- С3 берёт сообщения для Ч1 и отправляет их всем подписантам.
В принципе, идея проста. Да, она имеет несколько особенностей в реализации, но всё это вторично.
Вот, вроде, и сформировалось видение того, как всё это будет работать. Давайте нарисуем схему.
Код
Пришло время для четвёртого уровня нотации C4 - написание кода. Обычно, эта часть либо пропускается, либо делается очень "верхнеуровнево". Я, пожалуй, не буду добавлять эту часть просто потому, что в ней вряд ли найдётся нечто заслуживающее внимания, равно как и в том, как должны выглядеть компоненты браузерного веб-приложения.
Балансировщик нагрузки
Ранее, мы упоминали, что нам потребуется балансировщик нагрузки. Сделать его относительно несложно: берём nginx и настраиваем. Примерно так:
Всё, дело сделано или почти сделано. nginx будет самостоятельно проверять работоспособность сервера и перенаправлять на свободный. Однако, это не совсем то, что нам нужно: мы-то хотим перенаправлять в зависимости от географии. В этом нам поможет модуль GeoIP. Справку по нему можно прочитать тут, но если коротко, то он позволяет определять страну, регион или даже город, откуда пришёл запрос и по нему перенаправлять вызов.
На уровне контейнеров, на схеме, он присутствует.
Заключение
На этом этапе мы спроектировали (но не реализовали) приложение чата. Уверен, его можно было бы сделать и лучше. Также верно и то, что если добавить немного дополнительных условий, то схему, вероятно, придётся переделывать. Благо, это не так сложно как кажется: спасибо plantUml.