Убей в себе государство (slonik_v_domene) wrote,
Убей в себе государство
slonik_v_domene

Category:

Архитектура игры

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



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


Написать серверную часть игры для ВКонтакта/Одноклассников. Количество игроков онлайн - 10 000, общее - 500 000. Предусмотреть возможность PVP между игроками. Время ответа критично и не может составлять более 50 мс, а в среднем должно быть меньше 5 мс. Также необходимо обеспечить отказоустойчивость сервиса, или по крайней мере - добиться быстрого (меньше 5 минут) восстановления после падения. Следует учитывать, что игроки могут находиться за прокси-серверами, поэтому использование неизвестных наукеофисным криворуким админам протоколов обмена исключается.

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

Известные проблемы


Из неудачного опыта разработки предыдущей игры (Вова, мы о тебе помним, привееееет!) мы знали, что никакая СУБД ни на каком разумном железе не справляется с синхронной записью игровых событий на диск. Даже когда одна транзакция - одна запись на диск, это все равно очень дорого если требуется вразнобой параллельно обрабатывать несолько тысяч транзакций в секунду.

Кроме того, при таких скоростях обмена сериализациия в/из текстовых протоколов обходится дорого не только по CPU, но и по времени. Причем эта проблема не решается наращиванием числа вычислительных ядер: вы можете купить второй (третий, двадцатый) сервер, но он вам поможет лишь увеличить количество единовременно обслуживаемых клиентов, а не скорость обрабоки запроса каждого из них.

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

Зная обо всем этом мы приступили к проектированию рабочей системы. И начали с хранилища данных.

Хранилище


Mail.ru, Яндекс и прочие вышедшие на IPO компании могут позволить себе годами писать собственные СУБД под свои нужды. Мы же не такие богатые, а результат нужен, как водится, вчера, максимум - сегодня. Поэтому в качестве СУБД был выбран PostgreSQL, а для обеспечения приемлемой скорости работы написан специальный прокси-сервер, основная задача которого - копить запросы к БД и исполнять их пакетно. Такая схема позволяет складывать пришедшие запросы на изменение состояния в очередь и затем писать их по несколько штук в одной транзакции, а запросы на чтение исполнять в параллельном подключении к БД максимально быстро.

При этом встает задача обучить прокси-сервер следить за правильным порядком сброса данных в БД и успевать корректно обрабатывать запросы на изменение перед запросами на чтение. Иными словами: если мы несколько раз изменили уровень игрока, положили эти запросы в очередь, а потом пришел запрос получить уровень, нам необходимо сначала сбросить очередь, дождаться исполнения транзакции и только потом прочитать данные из БД. Так как запросов на чтение относитльео мало (БД используется только как постоянное хранилище, данные читаются только при логине пользователя), эта схема позволяет равномерно распределять нагрузку по времени суток, сдвигая исполнение запросов из дневных пиков на ночь.

Само собой, к хранилищу необходим быстрый и удобный доступ. Для этого пришлось разработать собственный протокол обмена данными.

Протокол


При по-настоящему большом количестве запросов встает проблема минимизации трафика. Кроме того, необходимо уметь быстро и без дополнительных затрат, а самое главное - переносимо сериализовывать и десериализовывать данные. По вышеозвученным причинам протокол - двоичный, а порядок байт в нем - little-endian. Отличительной особенностью протокола является то что без дополнительных затрат на (де)сериализацию он инкапсулируется в POST data HTTP(s).

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

Поэтому для сериализации структур данных пришлось написать два класса с предсказуемыми названиями Serializer и Deserializer и перегруженными методами для POD-типов и основных типов STL (string, vector, *map, *set). Для всех тех структур которые предполагается передавать по сети используются перегруженные операторы << и >>.

Пример сериализатора:
Serializer & operator << (Serializer  & oSerializer, const Unit  & oData)
{
    oSerializer << oData.unit_id
                << oData.type
                << oData.suit_id
                << oData.skills
                << oData.hire_currency
                << oData.iprops
                << oData.iprops_client
                << oData.cprops
                << oData.upgrade_type
                << oData.spell;

return oSerializer;
}

Десериализатор отличается только направлением острых скобок, приводить пример лишено смысла. Дополнительный плюс такого подхода - возможность выборочной (де)сериализации полей передаваемых структур. Вишенка на торте: в отличие от Protobuf здесь можно наследоваться от любых типов данных, поэтому при желании можно очень быстро вносить практическии любые изменения в протокол.

Лирическое отступление


Хранилище, сериализация и протокол - замечательные вещи. Но все это было бы бесполезно без высокопроизводительного TCP-сервера. Поэтому следующей задачей было построение универсальной высокопроизводительной платформы для разработки серверов игры.

Изначальный вариант, а именно - написать каждому программисту много-много простых сервисов на основе libev, оказался провальным. По факту, получась куча турдукен-кода, где одни и те же вещи решались множеством различных способов, отладка сетевой части стоила кучи нервов и времени, а ошибки лезли с завидной регулярностью. К счастью, у меня был практически доведенный до продакшена код, доработка которого обошлась относительно недорого. Результатом стал универсальный TCP-сервер, на основе которого построено все сетевое взаимодействие в проекте. Исходники сервера, кстати, доступны всем желающим.

Сервер


Сервер (рабочее название - Iris) построен как мультипоточное приложение с мультиплексированием сетевых соединений. При старте сервера создается N потоков по количеству процессоров, каждый привязывается к конкретному процессору. В этих потоках происходит вся работа с сетью: подключение новых клиентов, чтение и запись данных в сокеты, обработка таймаутов. Бизнес-логика игры обсчитывается более сложным путем: если на обработку запроса не требуется много времени (микросекунды), он исполняется в том же сетевом потоке где сосредоточена работа с сокетом. При этом никакого копирования данных между потоками не происходит и все выполняется настолько быстро, насколько это возможно. Если же запрос требует длительной обработки, он складывается в специальную очередь и отправляется в отдельный пул потоков занимающихся обработкой запросов такого рода.



Каждый сервис (а на сейчас их три: прокси БД, логин и геймсервер) оформлен в виде загружаемого плагина к этому серверу.

Общая архитектура


Собственно, вот что имеем на выходе:


Nginx, как обычно, занимается балансировкой, раздачей статики и проксированием запросов внутрь кластера (об этом - ниже). Логинсервер отвечает за хранение пользовательских сессий и балансировку игр между геймсерверами. На геймсерверах играют пользователи. Вся работа с базой данных происходит через специальный прокси-сервер.

Когда пользователь открывает приложение ВКонтакте (Facebook и Одноклассники работают по той же схеме), первый запрос приходит на логинсервер. Логинсервер проверяет, существует ли пользователь с таким Id Вконтакте, и если нет - регистрирует нового пользователя. Если пользователь существует, но не залогинен ни на одном из геймсерверов, для него создается сессия на произвольно выбранном геймсервере. Здесь и только здесь отправляется единственный запрос в базу данных на выборку сохраненного ранее состояния игры. Если пользователь залогинен, логинсервер знает об этом и сразу отдает клиенту предыдущую сессию.

В случае, если произошло отключение одного из геймсерверов, система про попытке что-либо отослать получает таймаут и приложение перезапрашивает логинсервер. Пользователь логинится на другом геймсервере и продолжает играть, все состояние игры поднимается из БД как при обычном логине. Если отключается логинсервер, все уже залогиненные пользователи продолжают игру, но новые регистрации становятся невозможны до рестарта логинсервера. Предполагается, что логинсервер без крайней на то необходимости не перезагружается. В дальнейших планах - написать репликацию между несколькими логинсерверами и убрать проблему выхода из строя непродублированного сервиса.

Прокси БД работает на той же машине что и PostgreSQL master, поэтому при выходе этого сервера из строя потребуется переключение PostgreSQL slave в режим master. На PostgreSQL slave заранее поднят запасной экземпляр прокси БД.

PVP


Реализация PVP (Игрок против игрока). При такой архитектуре возможна ситуация, когда играющие друг против друга залогинены на разных геймсерверах. Чтобы уменьшить сетевой трафик и ускорить работу сервера при заходе в PVP мы прозрачно для пользователя мигрируем защищающегося игрока на сервер нападающего. При этом полностью решается проблема синхронизации состояний боя обоих пользователей - все изменения происходят атомарно на одном сервере за одну команду.

Двухфазная фиксация


Как мы видим, многие команды исполняются на разных серверах. Вполне вероятна ситуация, когда один или несколько серверов станут недоступны во время исполнения команды. Чтобы изменения вступали в силу либо везде, либо нигде, используется механизм двухфазной фиксации. Этот же механизм позволяет не терять данные при отказе прокси БД. Подробнее об этом можно почитать здесь.

Кластер


Площадка построена по принципу горизонтальной масштабируемости. Критические сервисы дублируются на серверах. Недублируемый сервис (PostgreSQL) реплицируется по схеме master-slave. В случае необходимости переключение с основной БД на реплику можно произвести вручную.

Операционная система - Ubuntu 12.04 LTS, amd64. Типовая конфигурация серверов: 16-24 Гбайт оперативной памяти, 2 процессора по 8 ядер каждый, диски в RAID1. На серверах БД для PostgreSQL выделены отдельные разделы также собранные в RAID1. Tablespace-ы для данных и индексов разнесены на разные диски.

Для авторизации используются PAM и NSS LDAP. В качестве сервера LDAP установлен OpenLDAP. Отказоустойчивость обеспечивается установкой двух серверов LDAP и настройкой репликации между ними. Модули PAM и NSS подключаются к одному из
серверов; в случае недоступности какого-либо из них происходит переподключение к другому.

Все сервисы имеют свои собственные имена в специально выделенной доменной зоне, это сильно упрощает мигрирацию сервисов между серверами.

Балансировка осуществляется посредством:
- round-robin DNS
- механизма апстримов Nginx

Round-robin DNS осуществляет первоначальную балансировку пользователей на сервера-фронтэнды. Механизм апстримов Nginx балансирует нагрузку на сервера, и кроме того, перехватывает ошибки и исправляет их по мере возможности. Так, если один сервер выдает ответ HTTP 502, nginx автоматически перезапросит второй. В частности, такой подход позволяет проводить обновления кода без остановки всего кластера; достаточно лишь последовательно обновить сервера.

Для обеспечения приемлемого уровня безопасности а также для упрощения проведения обновлений, подсистема PHP вынесена на отдельные виртуальные сервера. Балансировка осуществляется также через nginx.

Результаты


Самочувствие типичного геймсервера можно увидеть на графиках ниже:



Загрузка CPU - не фейк; правильно написанный проект сначала упирается в сеть, и только потом - в диск, память или процессор.

Тайминги


Command MOVE_BUILDING (0x00000302): Request: 0.001615 { ReadCommand: 0.000009, Execute: 0.001288 }
Command GRAB_MINE (0x00000308): Request: 0.001644 { ReadCommand: 0.000006, Execute: 0.001347 }
Command START_COMBAT (0x00000400): Request: 0.001711 { ReadCommand: 0.000005, Execute: 0.001416 }
Command LOG_ACTIVITY (0x00001501): Request: 0.002389 { ReadCommand: 0.000007, Execute: 0.002043 }
Command GET_U_INFO (0x00000202): Request: 0.000890 { ReadCommand: 0.000007, Execute: 0.000586 }
Command GET_U_BAG (0x00000200): Request: 0.000463 { ReadCommand: 0.000002, Execute: 0.000457 }
Command GET_U_INFO (0x00000202): Request: 0.000557 { ReadCommand: 0.000002, Execute: 0.000552 }
Command LOG_ACTIVITY (0x00001501): Request: 0.001019 { ReadCommand: 0.000002, Execute: 0.001013 }
Command LOG_ACTIVITY (0x00001501): Request: 0.001101 { ReadCommand: 0.000003, Execute: 0.001095 }
Command GET_U_STATS (0x00000210): Request: 0.000588 { ReadCommand: 0.000002, Execute: 0.000582 }
Command GET_COMMON_STATIC (0x00000001): Request: 0.003434 { ReadCommand: 0.000002, Execute: 0.003429 }
Command GET_CITY_STATIC (0x00000003): Request: 0.001983 { ReadCommand: 0.000002, Execute: 0.001977 }
Command GET_UNIT_STATIC (0x00000002): Request: 0.003449 { ReadCommand: 0.000002, Execute: 0.003443 }
Command GET_COMBAT_STATIC (0x00000004): Request: 0.001916 { ReadCommand: 0.000002, Execute: 0.001911 }


Выводы


1. Высокопосещаемые отказоустойчивые проекты требуют определенного уровня технологий. Попытки строить решения без предварительной глубокой проработки архитектуры, как правило, приводят к неудаче и дальнейшему неоднократному переписыванию проекта под нагрузкой.

2. Решения "из коробки", особенно в части СУБД плохо подходят для высокопосещаемых проектов, тем не менее, все не так плохо как кажется на первый взгляд.

3. Если в вашем проекте за исполнение одного запроса отвечает более одного сервиса, вам так или иначе придется использовать распределенные транзакции. Лучше не экспериментировать и сразу разрабатывать архитектуру с поддержкой двухфазной фиксации.

4. Чтобы разработка не стала сверхзадачей, требуется максимальная унификация кодовой базы. Хорошим примером является использование единого фреймфорка Iris для построения сетевых сервисов.
Tags: рабочее
Subscribe

  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 50 comments