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

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

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

  1. Собственно загрузка файла с CDN (порядка 3 МБ).
  2. Подключение к API социальной сети и получение необходимых данных.
  3. Авторизация на игровом сервере и загрузка ресурсов.

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

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

  • обработка большого количества одновременных запросов – нотификаций (notifications) и максимально быстрый ответ на запрос;
  • определение страны и региона пользователя по IP-адресу в реальном времени;
  • логирование различных данных, которые могут со временем расширяться или дополняться, и предоставление агрегированной статистики, в том числе в реальном времени.
  • предоставление статистики для многих одновременно подключенных клиентов в реальном времени.

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

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

При использовании технологии long polling клиент посылает на сервер Ajax-запрос и ждет, когда на сервере появятся новые данные. Получив данные, сервер отправляет их клиенту, после чего клиент посылает новый запрос и снова ждет. Возникает вопрос: а что будет, если несколько клиентов будут одновременно выполнять такие запросы и объем обрабатываемых данных по ним будет существенным? Очевидно, что загрузка сервера сильно возрастет. Зная, что в подавляющем большинстве случаев к серверу отправляются одни и те же запросы, мы хотели, чтобы сервер обрабатывал уникальный запрос один раз и уже сам рассылал данные пользователям. То есть использовался механизм push-уведомлений.

Выбор инструментов и технологий

Серверную часть приложения решено было реализовать на .net-архитектуре с использованием классических ASHX-обработчиков (handler) для входящих запросов со стороны игровых клиентов. Такие хендлеры являются базовым решением для обработки веб-запросов в .net-архитектуре, используются давно и работают быстро за счет того, что запрос быстро проходит по конвейеру IIS веб-сервера. Нам нужен всего один хендлер, который обрабатывает запросы с клиента, оправленные через «пиксел». Имеется в виду подход, когда на клиенте вставляется изображение размером 1х1, где URL сформирован так, чтобы передать необходимые данные на сервер. Таким образом осуществляется обычный GET-запрос. На стороне сервера на каждый такой запрос нужно определять геоинформацию по IP-адресу и сохранять данные в кэш и базу данных. Эти операции «дорогие», то есть требуют определенного времени. Поэтому мы используем асинхронные обработчики: при запросе происходит ожидание только валидации данных (выполняется быстро), после чего данные ставятся в очередь на асинхронную обработку, а клиенту сразу формируется ответ.

Для хранения данных мы выбрали базу данных NoSQL – MongoDB, позволяющую хранить сущности по принципу Code First, то есть структуру базы данных определяет сущность в коде. К тому же сущности можно динамически модифицировать без необходимости каждый раз менять структуру БД, что позволяет сохранять произвольные объекты, переданные со стороны клиента в формате JSON, и впоследствии делать по ним различные выборки. Кроме того, можно динамически создавать новые коллекции для новых типов данных, что отлично подходит для горизонтального масштабирования. Фактически каждый уникальный объект трекинга сохраняется в свою коллекцию.

Проанализировав запросы с клиента, мы поняли, что в подавляющем большинстве к серверу отправляются одни и те же запросы с разных клиентов, и только в специфических случаях они отличаются (например, при изменении критериев запроса). В качестве альтернативы lond polling мы решили использовать веб-сокеты (WebSocket), позволяющие организовывать двустороннее взаимодействие клиент–сервер и отправлять запросы только на открытие/закрытие канала и изменение состояний. Таким образом число запросов клиент–сервер сокращается. Этот подход позволяет обновлять данные на сервере единожды и рассылать push-уведомления всем подписчикам. Как результат – данные обновляются моментально, по срабатыванию события на стороне сервера, а количество обрабатываемых запросов на сервере сокращается.
Кроме того, можно организовать работу без использования непосредственно веб-сервера (например, в службе), а также отпадают некоторые тривиальные проблемы с кросс-доменными запросами и безопасностью.

Обработка уведомлений

Асинхронный хендлер является коробочным решением, ведь для того, чтобы написать свой асинхронный хендлер, достаточно унаследовать класс от IHttpAsyncHandler и реализовать необходимые методы и свойства. Мы реализовали обертку в качестве абстрактного класса (HttpTaskAsyncHandler), реализующего IHttpAsyncHandler. IAsyncResult был реализован через Tasks. Как результат – HttpTaskAsyncHandler содержит один обязательный для реализации метод, который принимает HttpContext и возвращает Task:

public abstract Task ProcessRequestAsync(HttpContext context);

Для реализации асинхронного ASHX-хендлера достаточно унаследовать его от HttpTaskAsyncHandler и реализовать ProcessRequestAsync:

 public class YourAsyncHandler : HttpTaskAsyncHandler
 {
  public override Task ProcessRequestAsync(HttpContext context)
  {
   return new Task(() =>
   {
    //Обработка запроса
   });
  }
 }

Собственно, сам Task создается через переопределенный ProcessRequestAsync в контексте:

 public IAsyncResult BeginProcessRequest(HttpContext context, AsyncCallback callback, object extraData)
  {
   Task task = ProcessRequestAsync(context);
   
   if (task == null)
    return null;
   
   var returnValue = new TaskWrapperAsyncResult(task, extraData);

   if (callback != null)
    task.ContinueWith(_ => callback(returnValue )); // выполняется асинхронно.

   return retVal;
  }

TaskWrapperAsyncResult – это класс-обертка, которая реализует IAsyncResult:

 public object AsyncState { get; private set; }

  public WaitHandle AsyncWaitHandle
  {
   get { return ((IAsyncResult)Task).AsyncWaitHandle; }
  }

  public bool CompletedSynchronously
  {
   get { return ((IAsyncResult)Task).CompletedSynchronously; }
  }

  public bool IsCompleted
  {
   get { return ((IAsyncResult)Task).IsCompleted; }
  }

Метод EndProcessRequest проверяет, что таск выполнен:

public void EndProcessRequest(IAsyncResult result)
  {
   if (result == null)
   {
    throw new ArgumentNullException();
   }

    var castResult = (TaskWrapperAsyncResult)result;
   castResult.Task.Wait();
  }

Сама асинхронная обработка происходит через вызов ContinueWith для нашей обертки. Так как ASHX-хендлеры – вещь далеко не новая, стандартные обращения к ним выглядят некрасиво: ../handlerName.ashx. Как результат, можно написать HandlerFactory, реализовав IHttpHandlerFactory, или написать роутинг, реализовав IRouteHandler:

public class HttpHandlerRoute<T> : IRouteHandler
 {
  private readonly String _virtualPath;

  public HttpHandlerRoute(String virtualPath)
  {
   _virtualPath = virtualPath;
  }

  public IHttpHandler GetHttpHandler(RequestContext requestContext)
  {
   var httpHandler = (IHttpHandler)BuildManager.CreateInstanceFromVirtualPath(_virtualPath, typeof(T));
   return httpHandler;
  }
 }

После чего при инициализации нужно задать роуты для хендлеров:

RouteTable.Routes.Add(new Route("notify", new HttpHandlerRoute<IHttpAsyncHandler>("~/Notify.ashx")));

При получении notification выполняется определение геоданных по IP-адресу (процесс определения будет описан позже), информация сохраняется в кэш и базу данных.

Кэширование данных

Кэш реализован на базе MemoryCache. Он цикличен и автоматически удаляет старые данные по истечении указанного периода жизни экземпляра или при превышении указанного объема в памяти. C кэшем удобно работать и конфигурировать, например, через .config:

<system.runtime.caching>
  <memoryCache>
   <namedCaches>
    <add name="NotificationsCache"
     cacheMemoryLimitMegabytes="3000"
     physicalMemoryLimitPercentage="95"
     pollingInterval="00:05:00" />
   </namedCaches>
  </memoryCache>
 </system.runtime.caching>

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



По сути, кэш представляет из себя коллекцию ключ–значение. Где ключ – временная метка (String), а значение – очередь событий (Queue).

События в кэше хранятся в очередях. У каждой очереди есть свой временной ключ, в нашем случае это одна минута. В результате ключ выглядит как [год_месяц_день_час_минута]. Пример формата: “ntnKey_{0:y_MM_dd_HH_mm}”.

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

Хранение данных в базе MongoDB

Параллельно с сохранением в кэше, notification сохраняются в MongoDB. О всех возможностях и плюшках этой базы данных можно почитать на официальном сайте. Сейчас существует много разных баз данных NoSQL, поэтому выбор необходимо делать, исходя из личных предпочтений и поставленных задач. От себя хочу сказать, что с Mongo легко и приятно работать, она динамично развивается (недавно вышла версия 3.0 с новым движком WiredTiger), есть .net-провайдеры для работы с ней, которые также оптимизируются и обновляются. Однако стоит отметить, что использование баз данных NoSQL подходит для решения определенных задач и применять их как замену реляционных БД стоит только после глубокого анализа. Сейчас всё чаще встречается комплексный подход – использование двух типов в крупных приложениях. Типичные сферы применения: замена механизма логирования, работа в связке с NodeJS без бэкэнда, хранение динамически изменяющихся данных и коллекций, проекты с архитектурой CQRS+Event Sourcing и так далее. Если нужно выбирать данные из нескольких коллекций и/или производить различные манипуляции c выборками, лучше использовать реляционные БД.



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

Для работы с БД был использован MongoDB C#/.NET Driver (на момент написания – версия 1.1, недавно вышла версия 2.0). В целом драйвер работает хорошо, но смущает громоздкость использования объектов типа BsonDocument при написании запросов.
Фрагмент:

 var countryCodeExistsMatch = new BsonDocument
   {
    {
     "$match", 
     new BsonDocument
     {
      {
       "CountryCode", new BsonDocument
       {
        {"$exists", true},
        {"$ne", BsonNull.Value}
       }
      }
     }
    }
   };


В версии 1.1 уже есть поддержка LINQ запросов:

Collection.AsQueryable().Where(s => s.Date > DateTime.UtcNow.AddMinutes(-1 * 10)    .OrderBy(d => d.Date);

Но, как выяснилось на практике при анализе через профайлер, такие запросы не преобразуются в нативные (BsonDocument) запросы. В результате загружается в память и перебирается вся коллекция, что не очень хорошо для производительности. Поэтому пришлось писать запросы через BsonDocument и директивы базы. Будем надеяться, что ситуация исправлена в недавно вышедшем .NET 2.0 Driver.

Mongo поддерживает Bulk-операции (вставка/изменение/удаление сразу нескольких экземпляров за одну операцию), что весьма полезно и позволяет хорошо справляться с пиковыми нагрузками. Мы просто сохраняем события в очереди и имеем рабочий background-процесс, который периодически достает N notifications и сохраняет через Bulk Insert в БД. Всё, что нужно сделать, это синхронизировать потоки или использовать Concurrent-коллекции.

База достаточно быстро работает в режиме чтения, но для повышения скорости рекомендуется использовать индексы. В результате правильного индексирования скорость выборки может увеличиваться примерно в 10 раз. Соответственно при индексировании возрастает и размер БД.

Еще одной особенностью Mongo является то, что она максимально загружается в оперативную память, то есть при запросах данные остаются в оперативной памяти и выгружаются по мере необходимости. Можно задать и максимальное значение оперативной памяти, доступной для БД.
Также есть интересный механизм, называемый Aggregation Pipeline. Он позволяет производить несколько операций с данными при запросе. Результаты как бы передаются по конвейеру и преобразуются на каждой стадии.
Примером может быть задача, когда нужно выбрать и сгруппировать данные и результаты представить в каком-то виде. Эту задачу можно представить в виде:

выборка группировка предоставление ($match, $group, $project и $sort).

Ниже приведен пример кода выборки событий с группировкой по коду страны:

var countryCodeExistsMatch = new BsonDocument
   {
    {
     "$match", 
     new BsonDocument
     {
      {
       "CountryCode", new BsonDocument
       {
        {"$exists", true},
        {"$ne", BsonNull.Value}
       }
      }
     }
    }
   };
   
   var groupping = new BsonDocument
   {
    {
     "$group",
     new BsonDocument
     {
      {
       "_id", new BsonDocument {{"CountryCode", "$CountryCode"}}
      },
      {
       "value", new BsonDocument {{"$sum", 1}}
      }
     }
    }
   };

   var project = new BsonDocument 
        { 
          { 
            "$project", 
            new BsonDocument 
              { 
                {"_id", 0}, 
                {"hc-key","$_id.CountryCode"}, 
                {"value", 1}, 
              } 
          } 
        };

   var sort = new BsonDocument { { "$sort", new BsonDocument { { "value", -1 } } } };
   var pipelineMathces = requestData.GetPipelineMathchesFromRequest(); //дополнительные условия выборки
   var pipeline = new List<BsonDocument>(pipelineMathces) { countryCodeExistsMatch, groupping, project, sort };
   var aggrArgs = new AggregateArgs { Pipeline = pipeline, OutputMode = AggregateOutputMode.Inline };

Результатом выборки будет IEnumerable, который при необходимости легко преобразуется в json:

 var result = Notifications.Aggregate(aggrArgs).ToJson(JsonWriterSettings);

Интересной особенностью работы с базой является возможность сохранения JSON-объекта непосредственно в BSON-документ. Таким образом можно организовать сохранение данных с клиента в базу, и .net-среде даже не нужно знать, какой объект она обрабатывает. Аналогично можно осуществлять и запросы к базе с клиентской стороны. Более детально про все возможности и особенности можно прочитать в официальной документации.

В следующей части статьи мы поговорим о сервисе GeoIP, который определяет геоданные по IP-адресу запроса, веб-сокетах, реализации polling сервера, AngularJS, Highcharts и проведем краткий анализ системы.

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


  1. lair
    24.09.2015 14:23

    А почему вы взяли IHttpHandler, а не OwinMiddleware?


    1. Plarium
      24.09.2015 16:53

      По сути, OwinMiddleware — это модуль, который реализует IHttpModule и результатом выполнения которого является объект типа Task, т.е. некая выполняемая задача. Наш асинхронный хендлер тоже возвращает объект типа Task. Но на наш взгляд, OwinMiddleware, как и модуль, больше подходит для промежуточного преобразования данных, т.е. врезаться в конвеер выполнения запроса. Кроме того, на момент написания трекера, а это было год назад, мы не рассматривали использование owin вообще.


      1. lair
        24.09.2015 16:56

        По сути, OwinMiddleware — это модуль, который реализует IHttpModule

        Нет.

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

        На самом деле, нет. Различие между промежуточными и терминальными модулями в Owin практически отсутствует (точнее, терминальных модулей просто нет, можно воткнуть функцию, если очень хочется). В норме пишется такая же мидлварь, которая просто возвращает нужные данные, не вызывая следующую. Так, например, WebAPI встраивается.

        Кроме того, на момент написания трекера, а это было год назад, мы не рассматривали использование owin вообще.

        Почему?


        1. Plarium
          24.09.2015 18:46

          Нет.

          Возможно, в детали не вникали.

          На самом деле, нет. Различие между промежуточными и терминальными модулями в Owin практически отсутствует (точнее, терминальных модулей просто нет, можно воткнуть функцию, если очень хочется). В норме пишется такая же мидлварь, которая просто возвращает нужные данные, не вызывая следующую. Так, например, WebAPI встраивается.


          Да, WebAPI имеено так, мы имеем пайплайн, где запрос проходит от Middleware к Middleware. Да, можно написать свою Middleware, которая будет обрабатывать запрос и возвращать ответ.

          Почему?


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