Высоконагруженные системы: решение основных проблем

  • Tutorial
Привет, Хабр!

Сегодня я хочу рассказать о некоторых решениях проблем, которые возникают во время использования высоконагруженных систем. Все, о чем пойдет речь в этом материале, проверено на собственном опыте: я – Social Games Server Team Lead в компании Plarium, которая занимается разработкой социальных, мобильных, браузерных игр.

Для начала немного статистики. Plarium занимается разработкой игр с 2009 года. На данный момент наши проекты запущены во всех наиболее популярных социальных сетях («Вконтакте», «Мой мир», «Одноклассники», Facebook), несколько игр интегрированы в крупные игровые порталы: games.mail.ru, Kabam. Отдельно существует браузерная и мобильная (iOS) версии стратегии «Правила войны». В базах числятся более 80 миллионов пользователей (5 игр, локализация на 7 языках, 3 миллиона уникальных игроков в день), в итоге все наши серверы получают в среднем около 6500 запросов в секунду и 561 миллион запросов в сутки.

В качестве аппаратной платформы на боевых серверах в основном используются два серверных CPU с 4 ядрами (x2 HT), 32-64 GB RAM, 1-2 TB HDD. Серверы работают на базе Windows Server 2008 R2. Контент раздается через CDN с пропускной способностью до 5 Gbps.
Разработка ведется под .NET Framework 4.5 на языке программирования C#.

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


NoSQL vs. Relational

В этом сражении чистый NoSQL показал себя слабым бойцом: существовавшие на тот момент решения не поддерживали вменяемую консистентность данных и не обладали достаточной устойчивостью к падениям, что давало о себе знать в процессе работы. Хотя в итоге выбор пал на реляционные СУБД, которые позволяли использовать транзакционность в необходимых местах, в целом в качестве основного подхода используется NoSQL. В частности, таблицы зачастую имеют очень простую структуру типа ключ-значение, где данные представлены в виде JSON, который хранится в упакованном виде в колонке BLOB. В результате схема остается простой и стабильной, при этом структура поля данных может легко расширяться и изменяться. Как ни странно, это дает очень хороший результат – в нашем решении мы объединили преимущества обоих миров.

ORM vs. ADO.NET

Учитывая тот факт, что чистый ADO.NET имеет минимальный оверхед, а все запросы созданы вручную, знакомы и греют душу, он отправляет любые ORM в глубокий нокаут. А всё потому, что объектно-реляционное отображение имеет в нашем случае ряд минусов, таких как низкая производительность и низкий контроль запросов (или его отсутствие). При использовании многих решений ORM приходится долго и часто бороться с библиотекой и терять главное – скорость. А уж если речь заходит о хитром флаге для правильной обработки тайм-аутов клиентской библиотеки или о чем-то аналогичном, то попытки представить установку такого флага с использованием ORM окончательно расстраивают.

Distributed transactions vs. Own Eventual Consistency

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

SOAP, WCF, etc. vs. JSON over HTTP

При использовании готовых решений в стиле SOAP (стандартные веб-сервисы .NET, WCF, Web API и т.д.) обнаружилась недостаточная гибкость, возникали сложности с настройкой и поддержкой разными клиентскими технологиями, появлялся лишний инфраструктурный посредник. Для работы с данными мы выбрали передачу JSON по HTTP, не только из-за максимальной простоты, но и потому, что с использованием такого протокола было очень легко диагностировать и устранять проблемы. Также эта простая комбинация наиболее широко охватывает клиентские технологии.

MVC.NET, Spring.NET vs. Naked ASP.NET

Опираясь на опыт работы, могу сказать, что MVC.NET, Spring.NET и им подобные фреймворки создают лишние промежуточные конструкции, которые мешают выжимать максимальную производительность. Наше решение построено на самых базовых возможностях, предоставляемых ASP.NET. Фактически точкой входа являются несколько обычных хендлеров. Мы не используем ни одного стандартного модуля, в приложении нет ни одной активной сессии ASP.NET. Всё понятно и просто.

Немного о велосипедах

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

Чуть больше трети используемого нами времени CPU тратится на сериализацию/десериализацию больших объемов данных в формате JSON, поэтому вопрос эффективности данной задачи является очень важным в контексте производительности системы в целом.
Изначально в работе мы использовали Newtonsoft JSON.NET, но в определенный момент пришли к выводу, что его скорости недостаточно, и мы можем реализовать нужное нам подможество фич более быстрым способом, без необходимости поддержки слишком большого количества вариантов десериализации и «замечательных» фич вроде валидации схем JSON, десериализации в JObject и т.д.

Поэтому мы самостоятельно написали сериализацию с учетом специфики своих данных. При этом на тестах получившееся решение оказалось в 10 раз быстрее JSON.Net и в 3 раза быстрее fastJSON.

Критически важной для нас была совместимость с уже существующими данными, сериализованными с помощью Newtonsoft. Чтобы убедиться в совместимости, перед включением нашей сериализации в продакшн мы провели тестирование на нескольких крупных базах: вычитывали данные в формате JSON, десериализовали с помощью нашей библиотеки, снова выполняли сериализацию и проверяли исходный и конечный JSON на равенство.

Память

Из-за нашего подхода к организации данных мы получили отрицательный эффект в виде слишком большого размера кучи больших объектов (large object heap). Для сравнения, ее размер в среднем составлял порядка 8 гигабайт против 400—500 мегабайт в объектах второго поколения. В итоге эту проблему решили путем разбивки больших блоков данных на блоки меньшего размера с использованием пула ранее выделенных блоков. Благодаря такой схеме куча больших объектов значительно уменьшилась, сборки мусора стали происходить реже и легче. Пользователи довольны, а это главное.

Работая с памятью, мы используем несколько кэшей разных размеров с различными политиками устаревания и обновления, при этом конструкция некоторых кэшей предельно проста, без всяческих излишеств. В результате показатель эффективности всех кэшей – не ниже 90—95%.

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

Дополнительный инструментарий

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



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



Среди положительных сторон:

— счетчики всегда включены;
— минимальные издержки (менее 0,5% ресурса используемого CPU);
— простой и гибкий подход к указанию профилируемых участков;
— автоматическая генерация счетчиков для точек входа (сетевых запросов, методов);
— возможность просмотра и агрегации по принципу parent—child;
— можно оценивать не только real-time данные, но и сохранять значения измерений счетчиков по времени с возможностью дальнейшего просмотра и анализа.

• Логирование
Зачастую это единственный способ диагностики ошибок. В работе мы используем два формата: human readable и JSON, при этом пишем всё, что можно писать, пока хватает места на диске. Собираем логи с серверов и используем для анализа. Всё сделано на базе log4net, поэтому не используется ничего лишнего, решения максимально просты.

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

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

Выводы

Для того чтобы добиться скорости при высокой нагрузке на серверы, необходимо использовать более простой и тонкий стек технологий, все инструменты должны быть знакомы и предсказуемы. Конструкции должны быть одновременно простыми, достаточными для решения текущих проблем и иметь запас прочности. Оптимально использовать горизонтальное масштабирование, производить кэш-контроль производительности. Логирование и мониторинг состояния системы – must have для жизнеобеспечения любого серьезного проекта. А система развертывания значительно упростит жизнь, поможет сэкономить нервы и время.
Plarium
100,79
Разработчик мобильных и браузерных игр
Поделиться публикацией

Комментарии 25

    0
    Добрый день!
    У меня один вопрос, как вы общаетесь с клиентом? Он у вас запрашивает данные или же он подписывается и сервер ему отправляет что-то (дуплекс)?
      +1
      Добрый! С клиентом общаемся по HTTP, запрос-ответ + Long Polling.
        +1
        Спасибо, я так и предполагал, еще бы привести код PerfWatch, интересно решение.
      +1
      Добрый день, а не покажете код PerfWatch?
        +2
        Добрый! Нет, к сожалению, весь код показать нет возможности, но сами замеры построены на функциях QueryPerformanceCounter и GetThreadTimes
          0
          Спасибо. QueryPerformanceCounter — возвращает текущее значение счётчика таймера.
          QueryPerformanceFrequency — возвращает частоту таймера (число тиков в секунду).
          Все понятно.
        0
        Какой у вас используется балансировщик нагрузки между серверами?
        Как будете делать масштабирование, когда в этом возникнет необходимость?
          +1
          Мы используем подход сходный с shards. Данные разделяются на разные базы и хостятся на разных физических серверах. С каждой базой связан игровой сервис (как правило, размещаются на одном физическом сервер, что позволяет достичь минимальных задаржек при работе). Пару база и сервис мы называем сегментом. Пользователи при регистрации распределяются по сегментам и в дальнейшем запросы пользователя идут к нужному сегменту. Для управления сегментами сейчас используем простой подход. Создаем два сегмента на одном физическом сервере и когда нагрузка доходит до определенного нами лимита, мы разносим сегменты на два физических сервера, при этом добавляя по одному новому на каждый. Это не обеспечивает идеальной балансировки, но позволяет достичь более чем достаточного качества и мы пока не сталкивались с проблемами. Если столкнемся, будем рассматривать вариант более умного разделения на сегменты с перебалансировкой пользователей с учетом их активности.
            0
            не возникает ли проблемы с растущим объемом взаимодействия между сегментами?
              0
              Спасибо за подробный ответ
            0
            Не планируете выложить свою либу для сепиализации json?
              0
              Пока не планировали.
              0
              Спасибо за статью, многие мои догадки и знания из личного опыта оптимизиции производительности получили подтверждение )
                0
                Вопрос по логированию — log4cxx на порядок убивает производительность (по CPU), пробовали отключать log4net?
                  0
                  У себя мы не обнаружили такого влияния на прозводительность, о котором Вы говорите. Наоборот, эта библиотека показала себя очень хорошо, она достаточно простая и стабильная.
                  0
                  чистый ADO.NET имеет минимальный оверхед, а все запросы созданы вручную, знакомы и греют душу, он отправляет любые ORM в глубокий нокаут

                  Наверное имелось в виду использование DataReader, Connection, Adapter, т.е. по-сути .NET Framework Data Providers? Потому как под ADO.NET обычно подразумевают его высокуровневую часть, т.е. DataSet, который внутри еще и XML-based и чудовищно неповоротлив, особенно в сравнении с современными ORM.
                    0
                    Да, верно — мы ограничились использованием Connection, Command и DataReader.
                    0
                    А какие ORM тестировали?
                    Поговаривают, что, например, у BlToolkit и аналогов оверхед, довольно невысокий.
                      0
                      На тот момент наибольший опыт был в использовании NHibernate и Entity Framework. В целом, мы достаточно легко приняли решение отказаться от использования этих и других ORM. Возможно, современные реализации преуспели в снижении накладных расходов, но у нас до сих пор не возникло желания переходить на их использование.
                        0
                        Спасибо за статью, весьма познавательно.

                        Расскажите пожалуйста подробнее про зависимости ваших dal/bll/ui уровней. Используются ли в большинстве своём общие сущности или, отказываясь от ORM Вам выгоднее стало использовать уникальные структуры под каждый запрос? Используете конвертеры между уровнями или, может протягиваете dal объекты напрямую до ui? Кэш у вас используется на уровне сервера или вынесен на отдельный или и то и другое? Собираете ли отдельную статистику для профилирования на более высоком уровне(контроллеров допустим) или же стараетесь замерить это при разработке, а в лайве уже мониторите лишь общие цифры? Польуетесь ли async'ами при io операциях? Чем отдаёте статику?

                        Извиняюсь за сумбур, но действительно интересно.
                        0
                        Сейчас есть уже LINQ2DB, BLToolkit плавно уйдет с арены.
                        Действительно, производительность там прилично выше, но, это легковесные ORM.
                          0
                          А почему уйдут?
                          Легковестность в нагруженных проектах — это же преимущество, а не недостаток.
                            0
                            Потому что LINQ2DB занимается Игорь, это пересмотр BLToolkit. В следующий раз почитайте, прежде чем вставить умный комментарий:)
                            Кстати, уйдут и уйдет разные вещи.
                              0
                              Да, наверно, прочитал ваш комментарий :)
                        0
                        если столько времени тратится на json, может логичней делать бинарную сериализацию? или с вашей библиотекой это уже стало неактуально?

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

                        Самое читаемое