Наш доклад на «Стачке»: «Как избавиться от persistent database зависимости»
10–11 апреля наша команда приняла участие в крупнейшей в регионах России ИТ-конференции «Стачка», которая прошла в четвертый раз в Ульяновске. ИТ-компании представили свои стенды, где можно было познакомиться с их продуктами, узнать про вакансии, принять участие в конкурсах.В этом году, XIMAD также решил представить стенд со своими продуктами на конференции в связи с развитием мобильной секции. Это профильное для нас направление. Мы прослушали доклады, обменялись опытом с коллегами и ответили на множество вопросов о технологиях, используемых в наших играх.За два дня на 8 площадках выступили 130 экспертов и в общей сложности прозвучало 150 актуальных докладов по различным направлениям. Наш разработчик Алексей Ключников представил свой доклад на тему «Делаем интерактив в мобильных играх или как избавиться от persistent database зависимости» на примере нашего флагмана Magic Jigsaw Puzzles (2M MAU, 600K DAU и до 50K онлайн-игроков c интерактивным взаимодействием).Ниже приведен текст его выступления: В чем основная проблема с серверной частью геймдева? А то, что это hiload! Это не hiload только в одном случае, если проект не будет доведен до конца. Если игра выйдет на маркет, пойдет реклама, пойдет поток игроков и первые несколько дней это будет, пусть небольшой, но вполне настоящий hiload. А если вас настигнет успех…
Посетив на конференции доклады, посвященные высоким нагрузкам, стало ясно, что все работают примерно в одном направлении. Первое, во что упирается любой высоконагруженный проект — это данные, и большинство докладчиков повествовали о том, как реплицировать, шардить, денормализировать и т. д. Не избежали этой участи и мы, но путь выбран несколько другой. Предлагается минимизировать работу с БД. По сути, избавится от нее. Как это сделать? А очень просто.
Идея.Пишем сервер, в который игрок будет логиниться, для игрока запускается выделенный процесс, в него загружается из БД профиль и далее все действия игрока происходят в этом процессе. Как игрок не проявляет активности некоторое время, пусть 10 минут, сохраняем профиль в базу и завершаем процесс. В результате мы имеем одно чтение из БД при логине и одну запись в БД при логауте и все!
Чего мы хотим? Мы получили одно чтение и одну запись на одну игровую сессию. А значит можем рассчитывать и прогнозировать, сколько игроков наше решение потянет. Думаю, многие могут прикинуть, сколько может выдать простейшая ключ/значение табличка, например в mysql, операций чтения записи в секунду. И сколько игроков протянет такая база в сутки. Число получится внушительное и вот на это число мы себя огородили от проблем с БД. Что может быть лучше?
Реализация.Для реализации берем Erlang, так как в нем хорошо работать с процессами и… и все.Что нам дает Erlang: процессы из коробки, может их стартовать, останавливать и посылать между ними сообщения. Это значит, что один процесс игрока может послать сообщение другому процессу другого игрока. И по тому же принципу взаимодействовать с процессами, обеспечивающими игровую логику. Интерактивность в таком случае так же получается практически из коробки.Разберемся по порядку с нюансами.
Адресация Тут все канонично, каждому игроку при регистрации назначается уникальный идентификатор и по нему в дальнейшем осуществляется вся адресация. Иногда может возникнуть искушение использовать для адресации дополнительные ключи, но этого лучше избегать по следующим соображениям: адресация используется для передачи сообщений, когда сообщение передается offline игроку, мы должны стартовать процесс, загрузить в него профиль этого игрока и только потом передавать в него сообщение. Но если мы будем пользоваться разными ключами при адресации, у нас есть шанс попасть в трудно отслеживаемую коллизию, когда для одного игрока стартует 2 и более процессов.Регистратор процессов Встроенный в Erlang регистратор имеет серьезные недостатки, не позволяющие его использовать для динамически стартующих и завершающихся процессов, поэтому берем регистратор gproc. От регистратора требуется регистрировать процессы и выдавать по запросу их Pid. А при завершении процессов или при их падении производить их «разрегистрацию».Старт процессов Как уже говорилось выше, когда приходит сообщение игроку, мы обращаемся к регистратору с вопросом, на какой Pid слать сообщение, если для такого игрока нет процесса — нужно его запустить. Каждая операция занимает пусть небольшое, но время и возможна ситуация, когда к одному и тому же игроку придет два сообщения, в примерно одно время оба они получат отрицательный ответ от регистратора и попробуют стартовать процессы. В результате один из них стартует первым, а второй получит exception и сообщение будет потеряно. Мы не можем стартовать процессы асинхронно и должны организовать очередь для их старта. Для этого заводим процесс, в который будем направлять все наши обращения к регистратору и который будет стартовать процессы. Но получим узкое место, поэтому нужен не один такой «process_starting_worker», а пулл, например из 100 вокеров, между которыми распределить все обращения к id пользователей любым удобным алгоритмом, хоть остатком от деления.Остановка процессов Остановка процессов не менее интересное дело. Когда пришла пора завершать процесс, мы должны выполнить ряд действий, такие как сохранить профиль в базу, выписаться из регистратора, отправить всем друзьям прощальное сообщение и собственно завершить процесс. Все эти действия нельзя делать друг за другом, так как игрок может вдруг ожить, или ему просто может прийти сообщение пока мы занимаемся сохранением профиля. Поэтому после каждой операции нужно вычитывать очередь сообщений, и если в ней что-то обнаружится — то обрабатывать, и в случае до выписки из регистратора — возвращать процесс в нормальное состояние, а после выписки с регистратора — честно отвечать отправителю, что процесс разрегистрирован, на что отправитель должен будет перепослать сообщение.Кеширование и дальнейшая денормализация Как видим, наш регистратор используется при каждом сообщении и это делает его узким местом, поэтому имеет смысл кешировать Pid`ы в процессах. После первого обмена сообщениями между процессами, каждый из процессов запоминает Pid оппонента, и в дальнейшим они общаются без обращения к регистратору. Именно поэтому при завершении процесса добавляется действие по оповещению всех Pid`ов из кеша о своем завершении, чтобы все могли вычистить свои кеши от завершаемого процесса.Второе, над чем можно подумать — это оптимизация на чтение. Должен ли игрок видеть, как играют его друзья? И как он должен получать эту информацию? Каждый ли раз опрашивать всех своих друзей, или же каждый друг при достижении результата должен «хвастаться» всем своим друзьям, которые пропишут этот результат у себя в профиле и для отображения результатов друзей не будут генерить никаких запросов, а отдадут его сразу из своего профиля. Какой подход выбрать зависит от характера использования данных. Если чтение будет чаще записи, то имеет смысл пойти по этому пути.Мысли о масштабировании Во-первых, можно прикинуть нашу нагрузку и получить, что сервер под БД + сервер под наш код протянет несколько сотен тысяч игроков в сутки. Erlang достаточно честно использует память, поэтому если использовать в среднем 100 кб под профиль игрока, то чтобы обслужить 50 тыс игроков понадобится 5 гб оперативной памяти. Иными словами, берем сервер на 32–64 гб и с большой вероятностью забываем о необходимости масштабирования, до оглушительного успеха проекта.Во-вторых, если оглушительный успех все-таки настал, то ничего не мешает «расшардить» БД по id игрока и распределить игроков при помощи ДНС по разным нодам Erlang`а. Проблема здесь лишь с нашим регистратором, он должен уметь работать в кластерном режиме. Gproc умеет, но как показали тесты — не до конца. Все что нужно — это немного его пропатчить или взять другой регистратор, но это отдельная тема, возможно для отдельной статьи.Вывод.Решение оказалось не таким простым, каким могло бы показаться. Осталась еще масса вопросов про обмен сообщениями, как гарантировать их доставку, как откатывать, например, цепочки сообщений, какие сообщения передавать синхронно какие асинхронно и т.д. Но самый главный вывод из применения такой архитектуры, что мы впихнули невпихиваемое и получили работоспособный сервис. Что было бы невозможно, используя классическую реализацию каждого чиха игрока с запросом в БД.