AGPsource Game Platform — эксперименты с игровым движком, РНР <> JSON <> JDBC

16 января 2009

logo3

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

И так, доступ к базе данных. Изначально мы имеем игру, которая написана на РНР, соответственно, если отбросить промежуточные библиотеки, используется либо mysqli или же PDO, который, в свою очередь, опирается теперь на новый драйвер Mysqlnd. К сожалению, на этом уровне внедрить какие-то более продвинутые методики работы с базой сложно.

Единственный пока выход - использование специальных прокси между РНР и непосредственно базой данных, к примеру, это MySQL Proxy. Такое решение, в принципе, рабочее, хотя и ограниченное возможностями настройки самого прокси. Плюсом является то, что на стороне РНР-приложения никаких изменений не будет, все сокрыто за слоем абстракции на уровне сетевых протоколов. Вторым вариантом решения является, в принципе, полный отказ от прямой работы с встроенным функционалом базы данных (я имею ввиду, отказ от работы с функциями mysqli/pdo) и использование других решений. Для примера, как то я писал о разработке программистов с NY Times под названием DBSlayer, некоторым аналогом является и система mod_ndb, работающая как модуль Apache и связывающая кластер MySQL  с приложениями. Но однажды я задался вопросом, а как связать РНР приложение и JDBC, то есть систему работу с базами данных в мире Java. Тогда на этот вопрос так и не был дан однозначный ответ, но было предложено несколько вариантов. Во многих случаях лучшим будет перенос всех РНР-скриптов на платформу Quercus PHP, однако, как показало мое недавнее исследование, это не всегда возможно. Что делать в таком случае?

Имея на руках код и опыт нескольких онлайновых игр, которые я разрабатывал, стало интересным узнать, а как же в основном используется база данных? Большинство запросов укладывается в рамки самых простых - выбрать данные с одной или нескольких таблиц (при этом, часть таблиц статичны, то есть, просто справочники), вставить или обновить данные (гораздо меньше запросов), редко когда удаление. Отдельно стоит оформить статистические и аналитические функции, которые применяются, например, в админ-системах, это отдельная часть запросов, часто очень громоздких, и они могут не учитываться в общей статистике. Как правило, не используется ничего, кроме нескольких встроенные функций (в основном - работа с временем и датой), очень редко - агрегатные функции и сортировка/группировка. Обычно никаких объединений, вложенных подзапросов и остальных "извращений" - практика показала, что все достаточно хорошо делается и без них, компенсируя чуть усложненной логикой или же денормализацией данных. Практически, кроме нескольких случаев, я не заметил необходимости в таких вещах, в тех же моментах обычное решение с двумя запросами работало не медленнее, а выигрыш в скорости был ничтожен (если запрос будет всего пару раз в сутки, то сколько он будет работать, не так важно). В итоге - почти все запросы простые и быстрые, число результатов (строк) в них редко (почти никогда) не превышает 2 0 - 50 (честно, всегда столько, даже в админ-части, например, в модуле контроля над аккаунтами, идет разбиение и запрос возвращает фиксированное число строк, обычно 25), результат запроса же обычно или используется как ассоциативный массив, реже - интересует просто факт выполнения запроса (для тех, которые ничего не возвращают).

С такими посылами я приступил к проектированию модуля. Архитектура следующая. Непосредственно сам доступ к базе мы строим на основе Java-приложения, распределяющего работу с несколькими серверами через HA-JDBC (почитать краткий обзор можно здесь). Таким образом мы получаем отказоустойчивое и масштабируемое соединение и репликацию/кластеризацию. Далее стоит пул коннектов, который обслуживает все запросы к базе. Это позволяет практически свести потери производительности при создании новых запросов, впрочем, это давно применяется и известно.

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

Теперь о РНР. Напрямую связать Java и PHP достаточно нетривиальная задача, поэтому я выбрал вариант работы через сокеты, в случае cURL и, к примеру, REST-сервера, мне кажется, слишком большой оверхед. В таком же виде связь происходит через локальное сокет-соединение или каналы (в будущих версиях). То есть, открытие соединения с базой данных в обычном режиме я заменяю на открытие сокета к моему же Java-серверу. А далее все просто - запрос пишется в сокет, получается строка JSON, она обрабатывается через встроенную функцию json_decode, которая достаточно быстрая. А полученный массив используется в приложении дальше.

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

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

Используя EHCache, я реализовал на стороне сервера кеш, в который складываю JSON-данные для всех уникальных запросов. После получения нового запроса, сервер проверяет, существует ли в кеше объект с ключём, равным MD5-хешу запроса. Если да, все остальные шаги пропускаются, из кеша извлекается строка и отдается клиенту. Это особенно полезно в случае выборок с таблиц-справочников или просто статичных данных, мы даже не обращаемся к базе данных. Кеширующая система в EHCache очень мощная, к тому же допускает различные варианты, например, кеш в памяти, постоянный кеш с репликацией и т.п. Пока я использую простой кеш, в котором фиксированное количество элементов хранится только в памяти. При этом срок жизни достаточно небольшой, около часа. Здесь есть некоторые подводные камни вроде того, что кеш работает в адресном пространстве JVM, соответственно, занимает выделенную ей память, однако наш кеш не очень большой, под него достаточно выделить 5 - 50 Мб (если учесть, что средний размер ответа в формате JSON не будет превышать 5 - 10 Кб, что достаточно много, но это зависит от данных, если у вас много строковых данных, то следует увеличить размер кеша).Кстати, для убыстрения работы, я не использую постоянный кеш и механизма сброса данных на диск - нем не особо важно его сохранность, а работа с диском все же уменьшит производительность. На всякий случай проверка присутствия ведется только для элементов в памяти (в EHCache для этого отдельные механизмы предусмотрены).

Если запроса нет в кеше, он поступает в дальнейшую обработку. В качестве пула коннектов сейчас используется Proxool, который далее работает напрямую или с драйвером базы данных, в моем случае, MySQL JDBC Connector, или же через HA-JDBC (это пока не реализовано).

Возвращенный результат запроса, в виде объекта ResultSet, обрабатывается - с него выбираем названия полей, а потом значения всех строк, формируя JSON. Оказалось, что эта обработка достаточно нетривиальная, как и само преобразование в JSON - странно, что столь развитая платформа не имеет ничего встроенного для такой обработки. В тестовом примере я вручную обрабатываю все, выбирая значения каждой строки и занося в результат, строя сразу строку JSON, но первым делом переведу на использование стандартных возможностей, в частности, модуля JSON (взят отсюда).

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

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

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

Первым делом открыт вопрос сетевой платформы. Сейчас все работает на стандартных средствах, но следующим этапом будет перенос сервера на более серьезные платформы, в частности, использование NIO. Пока рассматриваю несколько популярных и интересных фреймворков, которые позволят потом реализовать и веб-сервис поверх непосредственно сокетного сервера - Grizzle Project, xSockets и Netty, который подпроект JBoss.org.

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

Компонент преобразования результата обработки запроса в JSON-пакет - пожалуй единственное, что придется реализовывать самостоятельно и без привлечения внешних фреймворков, кроме компонента работы с JSON-форматом. У меня возникли некоторые сложности с обработкой и извлечением данных с запроса, вернее - метаинформации о структуре, но это решаемо вполне. Предупрежу сразу комментарии по поводу Hibernate и ORM - нет, это не используется и вряд ли будет использоваться, для тех проектов, для которых разрабатывается эта система, применение таких средств неоправданно, и как усложнение, и как фактор существенного снижения производительности.

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

Вариантов пула соединений базы данных также не очень много. Apache Jakarta DBCP хорошо, используется внутри серьезных продакшин решений уровня Tomcat, однако, на мой взгляд, слабо развивается и очень плохо документирован. Остаются C3P0 и Primrose, однако и там не все так гладко, особенно в части поддержки и развития. Поэтому, если в результате тестов не будет обнаружено чего-то серьезного, останется текущий вариант с Proxool, он отлично интегрируется в существующую систему, максимально прозрачно работая с существующим кодом.

Остался один архитектурный момент, который требует самого серьезного подхода и исследования. Мы пока не рассматривали различные способы соединения между РНР скриптом и Java-сервером. Сейчас используется TCP-сокет, как нейтральный и универсальный соединитель, однако я думаю, можно найти и более масштабируемое решение. Варианты протоколов, основанные на XML отбрасываются сразу - здесь они слишком тяжеловесны, да и нет необходимости в столь серьезных средствах. Можно рассмотреть и бинарные форматы, например, Hessian (а особенно его вторую версию), тем более, для него есть модули как для РНР, так и для Java. HTTP-транспорт также отпадает по причине того, что у нас преимущественно внутренние взаимодействия, все работает на одном и том же сервере, либо, как максимум, внутри кластера. И данные, которые передаются по соединению, также максимально простые -  по сути, только строки. Исходя из требований, чтобы клиентские библиотеки были для обоих языков (а лучше, для множества), остаются Hessian (хотя он все равно работает поверх HTTP) и Google Protocol Buffer. Я даже больше склоняюсь к выбору Hessian-а. Но не стоит сбрасывать со счетов и Messages Queue, например, Stomp, однако остается открытым вопрос производительности - мне кажется такое решение больше подойдет для меж-серверного общения, чем внутри одной системы.

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

agpdbsystem

Как всегда, буду рад получить отзывы и ответить на все вопросы, может кто сможет подсказать более эффективные решения (кроме переписать все на Java/Erlang/C++/Perl и т.д.).

  • Я бы все-таки на высокой нагрузке отказался от MySQL и обратил внимание на PostgreSQL, исходя из моего опыта он намного (на порядки) лучше держит нагрузку. Плюс наличие громадного количества нативных или сторонних утилит для партиционирования, балансинга, кеширования, очередей (те же plProxy, PgBouncer, PgQ от Skype) дают неограниченные возможности по кластеризации и _полную_ прозрачность для приложения. ИМХО, JDBC — костыль, и серьезную нагрузку просто не выдержит.

  • Fenix, у нее тоже проблем очень много, да и она все же медленее, тем более, со множеством фич, надобности в которых в таких проектахх нет. Я, в принципе, ориентируюсь на Drizzle, который, надеюсь, весной уже будет доступен.
    А JDBC это годами проверенная технология корпоративного уровня, и позволяет сделать все то же и намного больше 🙂
    Если уже отказываться от базы, так от базы вообще, заменив ее чем-то вроде CouchDB или другой вариацией хранилищ, да хоть бы тем самым EHCache, смирившись с возможностью некоторых потерь и перестроив полностью концепцию работы приложения. Но это слишком кардинально 🙂

  • Очень интересная статья!
    У меня вопрос: какую примерно нагрузку будет создавать игра? Скажем, на 100 игроков?

  • Владимир, эти цифры сильно разнятся от проекта к проекту. Я сейчас не имею под рукой точных данных анализа, как закончу проект, попробую, но типичная картина следующая:
    — периодические запросы по таймеру, два запроса, каждые 3 и 6 секунд, каждый обработчик, хоть и оптимизирован, но требует в среднем 4-х запросов.
    — действия пользователя, пусть раз в 3 секунды, часто требуют исполнения нескольких обработчиков логики, поэтому число запросов варьируется от 3 -4 для простых, до десятка-полтора, в среднем 7 — 8 запросов.
    Исходя из этого получим что один игрок в течении каждых 10 секунд делает 12 — 15, пусть 20 запросов. Значит 2 запроса на игрока в секунду. Но этот вопрос стоит просмотреть по живому проекту, мне самому стало интересно (цифры выше — по моему последнему проекту, seahopes.com, там почти все на AJAX построено).

  • Кстати, Владимир, читаю с интересом ваш блог, очень нравится, особенное спасибо за материал по Phing!

  • >> особенное спасибо за материал по Phing

    не за что 🙂 Кстати, если вы используете PHP вместе с Java, то, наверное, удобнее будет использовать Ant вместо Phing (большинство задач совпадают один в один, но в Ant есть специфичные для Java задачи).

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

  • Вот Феникс дело говорит…. MySQL не будет держать такие нагрузки…

    • То есть, Вы, Станислав, утверждаете, что другие нагруженные проекты, которые держат и намного большие нагрузки, и работают на MySQL, говорят неправду? к примеру, Facebook и еще множество других. База не панацея. Если проект ориентирован только на поток селектов и апдейтов/инсертов в простые таблицы, то MySQL прекрасно работает.

Developers.org.ua