Хабрахабр

Практика использования модели акторов в бэкэнд-платформе игры Quake Champions

Продолжаю выкладывать доклады с Pixonic DevGAMM Talks — нашего сентябрьского митапа для разработчиков высоконагруженных систем. Много делились опытом и кейсами, и сегодня публикую расшифровку выступления backend-разработчика из Saber Interactive Романа Рогозина. Он рассказывал про практику применения акторной модели на примере управления игроками и их состояниями (другие доклады можно посмотреть в в конце статьи, список дополняется).

Наша команда работает над backend'ом для игры Quake Champions, и я расскажу о том, что такое акторная модель, и как она используется в проекте.

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

Там есть несколько очень интересных примитивов, от которых мы не хотим отказываться, такие как Table Storage и Cosmos DB (но стараемся сильно на них не завязываться ради кроссплатформенности проекта). На данный момент мы хостим наши сервисы в Azure.

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

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

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

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

Акторы друг от друга изолированы, общаются посредством асинхронных вызовов, и внутри самих себя акторы потокобезопасны.

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

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

Опять же, если необходимо сделать downscale (к примеру, игроки вышли) — тоже нет никакой проблемы вывести эти акторы из системы. Таким образом, актор, во-первых, выполняет роль кэша, а во-вторых, это а-ля «умный кэш», который умеет обрабатывать какие-то сообщения, выполнять бизнес-логику.

В чем отличие — я сейчас постараюсь рассказать. Мы в backend’e используем не классическую акторную модель, а на основе Orleans-фреймворк.

В отличие от классической акторной модели, где какой-либо сервис отвечает за то, чтобы создать этот актор и поместить его на какой-то из серверов, Orleans берёт эту работу на себя. Во-первых, Orleans вводит понятие virtual-актор или, как он еще называется, грейн (grain). если некий user service запрашивает некий грейн, то Orleans поймет, какой из серверов сейчас менее загружен, сам разместит там актора и вернет результат в user service. Т.е.

Для грейна важно знать только тип актора, допустим user states, и ID. Пример. Orleans же внутри себя хранит пути всех акторов очень хитрым образом. Допустим, ID пользователя 777, мы получаем грейн данного юзера и не задумываемся о том, как этот грейн хранить, мы не управляем жизненным циклом грейна. Если актора нет, он их создает, если актор живой, он его возвращает, и для юзер-сервисов все выглядит так, что все акторы всегда живые.

Во-первых, прозрачную балансировку нагрузки за счет того, что программист не нуждается в том, чтобы самому управлять расположением актора. Какие преимущества это нам дает? Он просто говорит Orleans, который развёрнут на нескольких серверах: дай мне из своих серверов такого-то актора.

Опять же, можно сделать и в обратную сторону upscale. При желании можно сделать downscale, если нагрузка на процессор и память небольшая. Таким образом, Orleans берет на себя инфраструктурную заботу за жизненный цикл грейнов. Но сервис ничего об этом не знает, он просит грейн, и Orleans дает ему этот грейн.

Во-вторых, Orleans обрабатывает падение серверов.

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

Чтобы стало чуть более понятно, разберем небольшой пример, как пользователь читает некоторый свой state.

Для того, чтобы получить эти стейты, он обращается в PublicUserService, который и обращается к Orleans за стейтом. Стейтом может быть его экономическое состояние, которое хранит доспехи, оружие, валюту или чемпионов этого пользователя. грейна) еще нет, он его создает на свободном сервере, и грейн читает свое состояние из некоторого Persistence-хранилища. Что происходит: Orleans видит, что такого актора (т.е.

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

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

Когда клиент захочет обновить какой-то стейт, он передаст эту ответственность на грейн, т.е. Такой же флоу на обновлении. И далее идет обновление кэша грейна и, при желании, сохранение в Persistence. скажет ему: «дай этому пользователю 10 золота», и грейн поднимается, в нем происходит обработка этого стейта с какой-то бизнес-логикой внутри грейна.

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

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

Небольшой пример того, как это выглядит.

И у нас есть интерфейс, который мы реализуем, т.е. Как я уже говорил, грейн состоит из типа и какого-то ключа (в данном случае тип это IPlayerState, ключ — IGrainWithGuidKey, что означает, что это Guid). Методы Orleans возвращают Task. GetStates возвращает какой-то список стейтов и ApplyState, который какой-то стейт применяет. Также у нас есть какой-то PlayerState, который мы получаем с помощью GrainFactory. Что это значит: Task — это promise, который говорит нам о том, что, когда состояние вернется, promise будет в состоянии resolved. здесь мы получаем ссылку, и ничего не знаем о физическом расположении этого грейна. Т.е. При вызове GetStates Orleans поднимет наш грейн, прочитает state из Persistence-хранилища себе в память, а при ApplyState применит новый стейт, а также обновит этот стейт и у себя в памяти, и в Persistence.

Хотелось бы еще разобрать чуть более сложный пример на High level архитектуре нашего UserStates сервиса.

У нас есть GameConfigurationService, ответственный за экономическую модель какой-то группы юзеров, в данном случае и нашего пользователя. У нас есть какой-то игровой клиент, который получает свои стейты через OfferSevice. В соответствии с ней пользователь запрашивает OfferSevice для получения своих стейтов. И у нас есть оператор, который меняет эту экономическую модель. А OfferSevice уже обращается к UserOrleans сервису, который состоит из этих грейнов, поднимает это состояние пользователя у себя в памяти, возможно, выполняет какую-то бизнес-логику, и возвращает данные обратно пользователю через OfferService.

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

Здесь я бы хотел разобрать некоторые подводные камни этой модели.

Поскольку все вызовы в грейне проходят потокобезопасно, друг за другом, и если у нас есть какая-то жирная логика на грейне, нам придется слишком долго ждать. Первое — это слишком большой грейн. Здесь нет точного алгоритма того, какой размер грейна должен быть, потому что слишком мелкий грейн это тоже плохо. Опять же, слишком много памяти выделяется под один такой грейн. Точно не скажу какой, это уже решает сам программист. Здесь скорее нужно исходить из оптимальной величины.

Когда пользователь поднимает какой-то грейн, а тот в свою очередь может неявно поднять другие грейны в системе. Вторая проблема не так очевидна — это так называемая цепная реакция. Таким образом, вся система держит все свои грейны в памяти и если у нас 1000 пользователей, и у каждого 100 друзей, то 100 000 грейнов могут быть активны просто так. Как это происходит: пользователь получает свои состояния, а у пользователя есть друзья и он получает состояния своих друзей. Такого случая тоже нужно избегать — как-то хранить стейты друзей в какой-то общей памяти.

Самая, пожалуй, известная — это Akka, которая пришла к нам с Java. Ну и какие технологии существуют для реализации модели акторов. NET для . Есть ее форк, называется Akka. Есть Orleans, который open-source и есть в других языках, как реализация. NET. Есть Azure-примитивы, такие как Service Fabric Actor — технологий очень много.

Вопросы из зала

— Как вы решаете классические проблемы, как CICD, обновление этих акторов, используете ли Докер и нужен ли он вообще?

Вообще, разверткой занимается DevOps, они разворачивают наши сервисы в клауд-сервисе Azure. — Докер пока ещё не используем.

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

Обычно у нас это решается через наши примитивы под названием топология. — Если речь идет о том, чтобы обновить весь сервис целиком, и если мы обновили какую-то бизнес-логику актора, мы можем выкатить под него новый Orleans сервис. Акторов не будет в системе вообще, но при следующем запросе пользователя эти актора уже будут созданы. Мы выкатили какой-то новый сервис Orleans, который еще пока что, предположим, пустой, и без актора, выводим старый сервис и подменяем его новым. В таких случаях обычно обновление проходит утром, так как утром у нас наименьшее число игроков. Будет, возможно, какой-то спайк в начале.

Вот ты рассказывал, что он быстренько акторов перекидывает на другой сервер... — Как Orleans понимает, что сервак упал?

— У него есть пингатор, который периодически понимает, какие из серваков живые.

— Он пингует конкретно актор или сервер?

— Конкретно сервер.

А вот произошла ошибка и что происходит с актором? — Такой вопрос: внутри актора произошла ошибка, ты говоришь он идет шаг за шагом, каждая инструкция. Актора просто умирает? Допустим такая ошибка, которая не обработана.

NET. — Нет, Orleans кидает exception в стандартной схеме .

У игрока я не знаю, как это будет выглядеть, но что потом происходит? — Смотри, мы не обработали exception, актор видимо сдох. Вы пытаетесь как-то этот актор перезапустить или еще что-то сделать в этом духе?

Например retriable или не retriable. — Смотря какой кейс, зависит от того, какое поведение.

это все конфигурируется? — Т.е.

Какие-то исключения мы обрабатываем. — Скорее программируется. явно видим, что такой код ошибки, а какие-то, как необработанные исключения, уже дальше прокидываются. Т.е.

— У вас несколько Persistence’ов — это типа базы данных?

— Persistence, да, база данных с постоянным хранилищем.

Что происходит, если актор не может до нее достучаться? — Допустим, легла база данных, в которой (условно) игровые деньги. Это вы как обрабатываете?

На данный момент у нас используется Azure Table Storage и такие проблемы на самом деле случаются — Storage падает. — Во-первых, это Storage. Обычно в этом случае приходится его переконфигурировать.

У него просто этих денег нет или у него игра сразу закрывается? — Если актор не смог получить что-то в Storage, у игрока это как выглядит?

Поскольку каждый сервис имеет свою severity, в данном случае, то юзер сервис это состояние terminal, и клиент просто вылетает. — Это критичные изменения для пользователя.

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

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

— А как это на игроке отразится?

А если менее критично — то подождет. — Поскольку юзер-сервис обращается к актору, ему бросят исключение таймаут exception и, если это «критичный» сервис, то клиент выкинет ошибку и закроется.

у вас есть угроза DDoS? — Т.е. Допустим, кто-то быстро начнет приглашать друзей и т.д. Большое количество мелкий действий может положить игрока?

— Нет, там стоит request-лимитер, который не позволит слишком часто обращаться к сервисам.

Допустим, у нас есть два юзера, надо у одного что-то забрать, а другому что-то начислить, и чтобы это было транзакционно. — Как вы справляетесь с консистентностью данных?

Во-первых, Orleans 2. — Хороший вопрос. Более точно нужно уже про экономику рассказывать. 0 поддерживает Distributed Actor Transaction — это первый выход. А как самый простой способ — в последнем Orleans транзакции между акторами без проблем реализуются.

оно уже умеет гарантировать, что в персистентность данные уйдут целостно? — Т.е.

— Да.

Еще доклады с Pixonic DevGAMM Talks

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»