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

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

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

Но мир многопользовательских игр устроен немного иначе. Пространственная сложность в нём устремляется к бесконечности, а временну́ю мы стремимся сократить до минимума. Мы работаем со множеством строго детерминированных модулей, которые можем легко обслуживать и развёртывать параллельно. И рано или поздно мы сталкиваемся со злом, которое кроется за кулисами любой MMO – проблемами ввода-вывода в базе данных.

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

И здесь мы столкнёмся с когнитивным диссонансом в его чистейшей форме, поскольку решения, которые применяются в MMO-системах, представляют явный нонсенс для корпоративной среды (e-commerce?).

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

Источником истины игрового мира является НЕ база данных.

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

В таких случаях мы рассматриваем БД как средство хранения информации, а не источник истины, который на самом деле всегда находится в памяти.

«Что за инакомыслие!», подумают некоторые. Но это не совсем так. Давайте немного разберём этот принцип, используя простейшую математику.

Предположим, у нас есть MMORPG вроде World of Warcraft с 1000 игроков. Её мир поделён на зоны, а значит, мы можем реализовать шардинг, но самое интересное в том, что все игроки живут вместе в одной игровой среде.

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

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

И это только для отслеживания позиций игроков в мире! А ещё надо добавить получение опыта, атаки оружием, сообщения чата и так далее…

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


Удачи!

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

Кэш, много кэша

Когда я сказал, что источник истины состояния игрового мира находится в памяти, то это и имел в виду, хотя не в самом прямом смысле.

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

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

Чтобы выполнить все эти требования, мы обычно используем шаблон «брокер данных». Мы создаём сервис с прямым подключением к базе данных, который будет отслеживать всё состояние в памяти и подключаться к сервисам игрового мира через RPC (Remote Procedure Call, удалённый вызов процедур). При этом он выступает в роли своеобразного кэша базы данных.



Но как это помогает выполнить требования? Разберём всё по частям.

Прямое соединение с низкой задержкой между игровыми сервисами и состоянием мира


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

И здесь возникает важный момент, который хоть и выходит за тему статьи, но упоминания стоит.
Эти команды RPC должны быть специфичными для нашей игровой логики. Никаких SQL или непонятных запросов, связанных с долговременным хранением или самим понятием «данные».

grantPlayerExperience, playerChangingZone и так далее.

За реализацию этого API отвечает сервис данных, чтобы при получении команды grantPlayerExperience он добавлял N очков опыта игроку X.

Таким образом, мы сохраним наши слои раздельными. Мы отделим API данных от реализации, изолировав тем самым логику игры.

Нужно сохранять состояние мира для его воссоздания или восстановления после ошибок/перебоев работы


Понятно, что нужно сохранять, но «Что?» и «Когда?» Эта больше философская работа, которая относится скорее к дизайну продукта, чем к проектированию архитектуры, но всё же давайте попробуем этот нюанс разобрать.

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

Возьмём, например, игру League of Legends. Что бы мы в ней сохраняли и когда?

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

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

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

Нет, не сможем.

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

Зачем же нам тогда сохранять все эти изменения в базе данных?

Как я писал выше, вся суть в том, Что сохранять и Когда.

В случае нашего примера, что мы сохраняем? Собственно, характеристики персонажа в конце игры и немного других данных. Когда? В конце игры.

В более сложном случае вроде World of Warcraft мы можем сохранять, например, инвентарь, когда предмет сменяет владельца или используется; позицию персонажа через каждый определённый промежуток времени (возможно, 30 секунд?), новую зону при каждом переходе в неё и так далее.

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

Всё остальное мы сохраняем в памяти в сервисе игрового состояния.

Нужна масштабируемость


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

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

Относительно простым решением может стать использование системы Redis «издатель-подписчик» для синхронизации сервисов состояния.

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

Нужно избегать состояний гонки


Это, пожалуй, самый простой пункт, так как если мы поддерживаем в нашем сервисе данных однопоточную архитектуру, то практически гарантированно не получим ощутимых состояний гонки.

Тем не менее работая с распределёнными данными или в многопоточных средах у нас, есть отличный друг.

▍ CAS


Каждую операцию записи для наших сервисов данных можно выполнять с помощью инструкции CAS (Compare and Swap, сравнение и перестановка), чтобы эти записи точно выполнялись асинхронно.

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

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


Простая система контроля версий

А что происходит, если CAS даёт сбой? Делаем повтор N раз (сколько сочтёте нужным), и если выполнить её так и не получится, возвращаем ошибку.

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

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

Типичным решением, принимаемым в индустрии MMO, как правило, являются стратегии, которые для тех, кто работал в сфере корпоративных архитектур, могут показаться не интуитивными или неестественными. Хотя и они имеют свои проблемы и слабости.

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

Узнавайте о новых акциях и промокодах первыми из нашего Telegram-канала ????

Комментарии (3)


  1. saboteur_kiev
    29.10.2023 22:42
    +7

    Возьмём, например, игру League of Legends. Что бы мы в ней сохраняли и когда? Лично я бы сохранял финальное состояние игры после её завершения (в этом примере мы игнорируем переигрывания, возможность наблюдения и прочее). И причина тому проста. В подобных системах сохранение нам нужно для того, чтобы иметь возможность восстановить их на случай проблем, а не в качестве бизнес-аспекта. Предположим, что мы сохраняем каждое изменение в процессе игры, и наш инстанс взрывается, отключается от сети или поглощается космической бездной. Сможем ли мы восстановиться после этой ошибки? Сможем восстановить последнее сохранённое состояние? Нет, не сможем. Скорее всего, к моменту, когда мы восстановим состояние, половина игроков уже не будут подключены.

    Не совсем корректное сравнение.
    Для начала League of Legends это не MMO, это MOBA, со всеми выходящими. Типа игра 5 на 5, без какой либо связи с остальными миллионами игроков. А значит что горизонтальное масштабирование тут делается не просто легко а очень легко, и вообще учитывая требования игры к отклику, переход из лобби в игровой режим может даже просчитать координаты игроков и скинуть наилучший для них игровой сервер.

    И зачем вы сразу игнорируете переигрывание - то есть самое интересное и сложное?

    Да, оно реализовано не для всех, а только для турниров, поскольку требует множества данных для сохранения немедленно, а поскольку принцип MOBA игры заключен в том, что одна партия мало на что влияет в целом (в отличие от ММОРПГ, где, например, при случайной смерти с тебя может выпасть предмет, на который ты потратил полтора года фарма, и если эта смерть из-за рестарта сервера, то явно это проблема).

    Также тот момент, что в ЛОЛ существует возможность проиграть реплей, обозначает что игра может записать все ключевые моменты, позволяющие идентично воспроизвести 40-60 минутный матч за счет маленького реплей файла.

    Сможем ли мы восстановиться после этой ошибки? Сможем восстановить последнее сохранённое состояние? Нет, не сможем.

    Для турнирной игры - да, сможем.

    В более сложном случае вроде World of Warcraft мы можем сохранять, например, инвентарь, когда предмет сменяет владельца или используется; позицию персонажа через каждый определённый промежуток времени (возможно, 30 секунд?), новую зону при каждом переходе в неё и так далее.

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

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

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

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

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


  1. izogfif
    29.10.2023 22:42

    Мне кажется, что было бы полезнее рассматривать реальные подходы, использующиеся в реальных серверах MMO(RPG). Пиратские сервера с исходниками World of Warcraft были доступны уже лет 10 с лишним назад. И вроде неплохо работали при падении сервера. И с обычной MySQL-базой. С тысячами одновременных игроков (10% которых сидели в Даларане).


    1. saboteur_kiev
      29.10.2023 22:42

      Я думаю тут основной момент, что автор оригинала работал в среднестатистическом ентерпрайзе, где в коде постоянно выполняются различные запросы в базу
      А в геймдеве из базы разок все объекты загружаются в память и работа идет с ними. А в базу сохраняется изредка. И это нормальная практика за все время, что существуют игры.
      Задолго до World Of Warcraft и даже Lineage2 (там mssql)