Всем привет, меня зовут Алексей Жуков, я старший программист в студии IT Territory. В этой статье я расскажу, как мы строили игровой сервер для Rush Royale. Объясню, почему создание сервера в геймдеве — это не так просто, как кажется на первый взгляд, а также опишу плюсы и минусы реактивного подхода, который мы использовали в нашей работе.  Когда пользователь запускает игру, возникает новая игровая сессия — происходит авторизация на Account Server, подтверждается аккаунт игрока, а потом его пропускают на игровой сервер (Game Server). В дальнейшем клиент взаимодействует только с Game Server. Дополнительно Game Server пишет свои данные в базу, а также логирует игровые события в Kafka.  На протяжении всего времени игрок совершает разные манипуляции: покупает, меняет, улучшает игровые предметы и так далее — для каждого действия важно зафиксировать время. Мы отправляем все эти события в персистентную очередь Kafka. Дальше поток данных распадается на два — один в Hadoop, другой в базу Gametool Server.  После того, как данные попадают в Hadoop, аналитики изучают их при помощи инструментов Big Data и оценивают состояние игры — стала ли она лучше, хуже, определяют возможные точки роста. Но обновление данных в Hadoop происходит (по независящим от нас причинам) раз в день, а нам хотелось бы получать данные сразу же. Это помогло бы во многих ситуациях, чтобы решить проблемы с игроками, пришедшими в поддержку. К примеру, если игрок купил предмет, но он почему-то не добавился в инвентарь, то мы хотим сразу же узнать об этом. С помощью нашего ETL Server мы параллельно с Hadoop выкачиваем из Kafka данные, а затем сервер кладет их в свою базу Gametool. Чтобы потом добраться к этим данным, мы используем Gametool Web Server, который предоставляет интерфейс для анализа игровых событий, а также может модифицировать модель игрока. Если игрок пришел с упомянутой проблемой, то модераторы идут на Gametool Web Server, проверяют, действительно ли игрок что-то недополучил. Если проблема реально есть, то они выдают предмет и вносят какие-то исправления, если что-то сломалось. То есть с помощью этого инструмента можно не только смотреть логи, но и взаимодействовать с игровым сервером со стороны поддержки. Когда вы запускаете мобильную игру, вы видите интерфейс, локации, персонажей и так далее. Во время игры можете совершать различные действия: выполнять квесты, тратить валюту, участвовать в событиях. Многое из этого привязано к конкретному игроку, к его игровой модели — назовем это механиками игрока.  Кроме того, есть глобальные механики, которые без сервера было бы сложно реализовать, потому что они объединяют несколько сущностей. Например, игроки могут собираться в кланы, общаться в чате, добавлять других пользователей в друзья, попадать в таблицы лидеров и так далее.  Все предметы в интерфейсе игрока хранятся на сервере и передаются на игровой клиент по запросу. Подобное происходит и с колодами, и с любыми другими объектами и ресурсами игрока.  Вот как гипотетически происходит процесс покупки предмета. Пользователь посылает запрос. Мы читаем модель из базы, запускаем механику, которая списывает валюту и дает игроку предмет. Когда модификация модели завершится, а начисление и списание пройдут успешно, произойдет следующее: модель запишется в базу, а пользователь получит ответ.  Все это выглядит не слишком сложно и строится по принципу: запрос — ответ. Мы тоже так думали, когда в 2017 году начали делать свой первый Game Server. Первый прототип сервера был написан на C++, но в какой-то момент прототип стал продакшен-решением. Тем не менее мы запустили на этот сервер реальных игроков и оказалось, что он падал каждый раз, когда на него заходило более 2 тысяч пользователей.  Было два варианта: либо чинить этот сервер, либо переписывать все заново. В тот момент в студии освободилась команда джавистов, поэтому мы решили делать и то, и то: немного починить старый сервер на С++ и одновременно делать новый на Java.  К тому моменту мы уже отлично понимали, каким должен быть наш новый сервер. Во-первых, тогда игра уже стала достаточно успешной, и мы прогнозировали еще больший рост количества игроков. Поэтому нам нужен был сервер, который выдержит десятки тысяч игроков одновременно.  Во-вторых, сервер должен был выдерживать тысячу запросов в секунду.  В-третьих, задержка ответа не должна была превышать несколько десятков миллисекунд. Так как у нас мобильная игра, каждое увеличение задержки увеличивает негативный фидбек от пользователей.  Чтобы сервер соответствовал этим критериям, мы решили применить реактивный подход, основанный на четырех принципах.  Отзывчивость. Система отвечает в пределах своих возможностей.  Устойчивость. Если в системе из нескольких компонентов выходит из строя какая-то одна часть, то остальные продолжают работать. Масштабируемость. Мы можем масштабировать наши системы в зависимости от нагрузки, можем увеличивать и уменьшать количество объектов нашей системы.  Наличие системы, основанной на обмене сообщений. Этот принцип перекликается со вторым и позволяет уменьшить зависимость компонентов друг от друга. И когда один выходит из строя, это не слишком плохо сказывается на остальных, потому что между ними нет жесткой кодовой связи.  Чтобы все это реализовать, мы выбрали фреймворк Vert.x, у которого есть несколько важных преимуществ.  Большой набор инструментов. Внутри Vert.x можно написать полноценный микросервис — от работы с базой до создания своего Web Server. Это фреймворк c ядром, к которому можно подключать модули.  Возможность использовать неблокирующее асинхронное API.  Мультиязычность. Мы выбрали Java, но изначально допускали, что можем писать на Kotlin. Также он поддерживает многие JVM-языки.  Шина сообщений. Нам хотелось, чтобы взаимодействие на сервере происходило с помощью сообщений. Поэтому наличие этого решения из коробки тоже нас подкупило.  Кластеризация, с помощью которой можно масштабировать систему в зависимости от загрузки.  Vert.x представляет собой реализацию шаблона мульти-реактор. Чтобы понять, что это такое, разберем реактор, который является частным случаем мульти-реактора. При работе с Vert.x у вас всегда есть как минимум один поток Event Loop — каждый поток закреплен за одним ядром процессора и управляет всеми событиями, которые ему приходят.  Есть клиенты, которые посылают запрос. Event Loop их разбирает и отправляет воркерам на исполнение, а те их обрабатывают. В процессе работы Event Loop отслеживает готовность результата и оповещает об этом клиентов. Мульти-реактор позволяет создавать много таких Event Loop. Вы можете масштабироваться в рамках количества ядер вашей системы. И это без учета дальнейшей кластеризации.  Verticles — это однопоточные сервисы, которые занимаются обработкой. Мы выделяем игровые механики и помещаем обработку как раз в эти самые вертиклы. Затем собираем их в группу, и получается, что они существуют на нашем сервере в нужном нам объеме. В нашей терминологии одну JVM будем назвать нодой (Node) игрового сервера. Нам важно, чтобы Verticles умели общаться. Для этого есть Eventbus — встроенная шина, которая позволяет асинхронно обмениваться сообщениями. И компоненты нашей системы в этом случае становятся менее связанными друг с другом.  Шина сообщений предоставляет различные методы отправки сообщений. Например, publish/subscribe — когда один публикует, а другой подписывается, и request/response. Сообщения могут отправляться как одному адресату, так и нескольким. В целом это стандартная классическая шина сообщений. И самое главное, что это все работает прозрачно как в рамках JVM, так и всего кластера.  Соответственно, с Eventbus наши Verticles могут общаться, поэтому мы можем все запросы с клиента перенаправлять прямо на них. В Verticles происходит обработка запроса и далее данные в качестве ответа возвращаются игровому клиенту.  Но и этого для нас недостаточно. У нас добавляются еще распределенные в RAM кэши, в которых мы храним модели игроков, чтобы при каждом запросе с игрового клиента не вычитывать модель из БД.  Наша база достаточно нагруженная, и у нас нет прямого шардирования PostgreSQL. Мы думали, как уменьшить нагрузку на базу, и в итоге решили добавить распределенные кэши (Vert.x, кстати, предоставляет API для распределенных данных). Есть распределенные мапы (хеш-таблицы имплементирующие интерфейс Map), которые можно использовать как в рамках одной JVM, так и в рамках кластера. Мы кладем модель игрока, с которой работаем, в этот кэш, и, соответственно, вычитаем.  Но нужно помнить, что модель игрока могут менять параллельно. Это как в классической операции снятия-начисления денег: одному начисляешь, у другого снимаешь. Так и здесь — если кто-то запустит параллельное изменение модели, то может произойти ее порча. Чтобы этого не допустить, мы используем распределенные локи для блокировки модели. Мы применяем их не на базе, а именно в распределенном кластере данных, что также уберегает от дополнительной нагрузки на базу.  В итоге получается структура, в которой Verticles обращаются для расчетов к распределенному кэшу напрямую. А потом — в базу, если надо сохранить что-то по итогу.  Теперь вернемся к ситуации с покупкой предмета, но будем учитывать, что у нас появились кэши и локи. Теперь мы блокируем модель перед тем, как начать ее читать. Читаем ее, модифицируем, сохраняем в базу, дальше в кэш и, наконец, снимаем блокировку. Соответственно, мы можем писать код, который работает параллельно, и не бояться, что нашу модель кто-то поломает в этот момент.  Тут подразумевается, что всегда есть запись в базу и кэш. Иногда есть запросы, которые просто нужно прочитать из базы. Например, игрок запросил состояние какой-то части модели. Тогда можно просто запросить данные из кэша и вернуть данные игроку. При этом не нужно будет использовать чтение из базы, блокировки и так далее. В этом случае использование кэша тоже убирает нагрузку на базу.  Чтобы собрать все это в кластер, в библиотеке Vert.x есть поддержка четырех кластер-менеджеров — Hazelcast, Ignite, ZooKeeper, Infinispan. Сначала мы пользовались Hazelcast. Со временем на одном проекте мы все же решили попробовать Ignite, но вернулись к Hazelcast по нескольким причинам. Во-первых, оказалось, что он достаточно чувствителен к небольшим сетевым проблемам. Во-вторых, при создании пустой мапы, она занимала порядка 40 Мб, что для нас оказалось критично, так как мы хотели создавать их сотнями, но при этом получали оверхерд по памяти. В такой же ситуации в Hazelcast практически не требовалось дополнительной памяти. Итак, после выбора кластер-менеджера, можно объединять ноды в один большой кластер, у которого есть общий Eventbus с распределенным кэшем. Соответственно, в этом кластере можно брать распределенные локи, а в зависимости от загрузки увеличивать количество нод. Тем самым можно масштабировать и увеличивать пропускную способность нашего кластера.  Предположим, что мы только выпустили нашу игру, она доступна игрокам под версией 1.0. Но так как мы не стоим на месте, через некоторое время мы готовы выдать игрокам новую версию 2.0. Совокупностью нод одной версии будем назвать шард (Shard). Когда выходит новая версия игры, мы должны добавить в наш кластер шард игрового сервера версии 2.0, который будут взаимодействовать с новым клиентом. То есть старый сервер взаимодействует со старым клиентом, новый — с новым. За то, чтобы игровой клиент попал на шард нужной версии, как раз и отвечает Account Server.  Когда мы запускаем новый сервер, он начинает работать параллельно со старым в рамках одного кластера. Если ранее нам было необходимо поддерживать совместимость только на уровне базы, то теперь мы должны также поддерживать совместимость в кэшах и данных, передающихся по шине сообщений. А это требует от разработки дополнительных накладных расходов. Хотя в этом есть свои плюсы — при такой схеме мы можем реализовать взаимодействие двух версий сервера, например, создать механику чата, в котором игроки со старой и новой версий могут общаться друг с другом. Подытожим. У этого подхода, при котором мы использовали Vert.x в качестве основного фреймворка, есть свои достоинства и недостатки.  Плюсы Мультиязычность. Изначально мы писали на Java, но в итоге начали переходить на Kotlin — он позволяет писать код без callbacks и асинхронности. Это обуславливается тем, что Vert.x поддерживает корутины и suspendable функции.  Неблокирующее API менее ресурсозатратно, оно не требует такого количества потоков для ожидания ответа. И за счет этого все потоки (кроме накладных) — это Event Loop.  Поддержка обмена сообщениями. В рамках кластера мы можем обмениваться асинхронными сообщениями. Наши компоненты становятся независимыми и слабо связанными.  Масштабируемость. С базой PostgreSQL это достаточно сложно, но мы можем увеличивать количество нод игрового сервера, тем самым увеличивая пропускную способность по железу в рамках кластера.  Уменьшение нагрузки на базу за счет использования распределенных кэшей.  Минусы Неблокирующее асинхронное API сложнее для понимания. С Kotlin все хорошо (есть корутины), а вот на Java callback hell не всегда приятный.  Vert.x — это большой фреймворк, поэтому его тяжело обновлять, если он затрагивает много компонентов вашей системы. Нужно синхронизировать кэши с базой — они должны существовать параллельно. Затраты на эту поддержку являются платой за наличие кэша.  Кэши потребляют дополнительные ресурсы.  Поддержка кроссверсионных сервисов, у которых тоже нужно поддерживать совместимость в разных версиях.  Советы Старайтесь не использовать Singleton Verticle. Первая причина заключается в том, что если выйдет из строя нода с таким вертиклом, то часть функционала окажется попросту недоступной. Вторая причина, это потенциальная возможность появления узкого места в вашей системе при наличии такого вертикла.  По возможности выносите кроссшардовые сервисы в микросервисы. Это нужно для того, чтобы упростить межверсионную совместимость — это сложно, но оно того стоит.