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

Код проекта… В общем у меня осталось впечатление, что писал его недоученный студент… И это, немотря на то, что уже был сделан частичный рефакторинг другим программистом. Единственное, что радовало, то это то, что не использовался какой-либо фреймворк. Конечно, это вечно флеймовый вопрос: Иисус или Магомед? Быть или не Быть? Unix или Windows? Использовать или не Использовать? ИМХО, Моё мнение: фреймворки заточены под узкий круг типовых задач. Социальный проект — задача, как правило, не типовая… Но, в целом, мне проект показался интересным и я решил взяться за улучшение. На этом вступление можно закончить…

Наверно, про повышение производительности и тему highload не писал только ленивый WEB разработчик, знающий хоть что-то в этой области. Принципиально, что-то нового, в данной статье вы не найдёте. Основные идеи разработки highload проектов, были мною изложены в цикле статей HighLoad. Три кита.. Если вам интересно, как я увеличил производительность PHP проекта, используя NoSQL хранилище tarantool, то Добро пожаловать под кат.

Хотя, принципиально можно использовать другое, подходящее под данный круг задач, key/value хранилище, и реализация серверной логики может быть на любом другом скриптовом языке.

Рецепт 1. Анализируем код

Всё до ужаса банально. Про это писали сотни раз до меня, и будет написано еще сотни статей… Однако, «мы самые умные» и упорно наступаем на одни и теже грабли. Я не открою Америки, если скажу, что самое узкое место в 99% всех WEB проектов — это БД. А какой из этого следует сделать вывод?
Правильно, — необходимо минимизировать количество запросов.. А как это сделать, если по ходу логики пять раз встречается код:

$user = new User();
$userData = $user->getById($uid);

При профилировании запросов вылезает, что мы выполнили пять одинаковых «селектов»: SELECT * FROM users WHERE id=$uid;
А реализуется это довольно просто: используем внутреннee (private) статическое поле или свойство объекта User:
    class User {
 
        private static $userData = NULL;
 
        private function getById_loc($uid) {
        // некоторый код обращения к БД. 
        }
 
        public function getById($uid) {
           if (self::$userData) return self::$userData;
           self::$userData = $this->getById_loc($uid); 
           return self::$userData;
        }
    }


Второе, что сразу бросается в глаза. это когда рядом стоят два метода:
$User->updateBalance($sum);
$User->updateRating($rating);
 

что приводит к выполнению двух запросов к одной таблице подряд, один за другим:
UPDATE users  SET balance = balance + $sum WHERE id = $uid;
UPDATE users  SET rating = $rating  WHERE id = $uid;
хотя, если чуть-чуть пошевелить мозгами, то мы вполне могли бы сформировать один запрос:
UPDATE users SET 
  balance = balance + $sum,
  rating = $rating  
WHERE id = $uid;
 

Это можно сделать двумя способами: либо мы пишем еще один метод $user->updateBalanceAndRating($sum, $rating), либо мы реализуем нечто универсальное, как говорится, на все случае жизни:
$user->updateFieldAdd('balance', $sum);        // запоминаем поле, операцию add - сложение, операнд
$user->updateFieldAssign('rating', $rating);   // запоминаем поле, операцию assign - присваивание, операнд
$user->execUpdate();                           // формируем и выполняемзапрос 


Только внедрение этих двух простых методов позволило уменьшить количество запросов к БД с 10-12 до 3-5. Но если честно, существующий код предстоит еще рефакторить и рефакторить. Конечно, большинство Хаброюзеров далеко не лузеры, но профилирование и анализ всегда «Бог нам в помощь».

Рецепт 2. Кеширование

Что такое Кеширование, надеюсь разъеснять не нужно. Как писал в своей статье про разработку highload проекта, Дима Котеров: «надо кешировать всё, что можно». В данном проекте, были робкие попытки какого-то кеширования. Однако, все вышло по Черномырдину: хотели как лучше, а закешировали, не то, что наиболее часто используется ;).

Как было отмеченоно выше, чтоб снизить нагрузку на БД, надо снизить количество запросов. А как их снизить? Да очень просто — часть неизменных данных (справочники по юнитам, трибутам и оружию ) просто вынести из БД, например в конфиги. Конфиги могут быть либо в XML, и правится девочками из команды геймплея в любом XML редакторе, либо в уже готовом виде: в РНР массиве — если разработчик геймплея и кода — один человек. ПарсингXML на лету — вещь тяжелая, по этому я делаю предварительное XSLT преобразование непосредственно в PHP код (в ввиде ассоциативного массива, который и грузится вместе с основным кодом). Однако, после каждого изменения в XML конфиг-файле, необходимо запустить скрипт XSLT- преобразования или консольную утилиту. Да, это не кеширование, это небольшое улучшение, и его не стоит выделять в отдельный рецепт, но и забывать про него не стоит.

Таким образом, запихнув все справочники в конфиги, мы освобождаемся еще от пары-тройки запросов. Ну, что — стало легче?.. По крайней мере, после применения рецептов 1 и 2 база перестала падать. Ну хоть какой-то результат…

Рецепт 3. Анализ данных

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

В нашем проекте, наиболее часто запрашиваемой информацией был игровой профиль пользователя, подарки и награды. Все эти данные были помещены в NoSQL хранилище, а все прочие данные, особенно связанные с платежами, остались в MySQL. В качестве NoSQL хранилища был выбран tarantool.

А все же — почему ТАРАНtool ?

На Конференции Highload++ 2011 Руководителя разработки Tarantool Костю Осипова спросили:
— А почему у Вас название такое ядовитое?
— Ну, можете рассматривать название, как таран и tools, т.е. как средство (тулзу) тарана для ваших проектов.

Итак, факторами, влияющими на выбор NoSQL хранилища были:
— Моё личное знакомство с тимлидом проекта Костей Осиповым, который обещал поддержку и консультацию
— Опыт внедрения данного хранилища в предыдущем проекте. К сожалению проект не взлетел :(, но было интересно.
— Изучение новых возможностей tarantool, прошло почти два года с момента его предыдущего использования
— Высокая производительность данного NoSQL хранилища и высокая доступность данных.
— Персистентность данных, при падении на диске остается актуальная копия, которую всегда можно поднять.
— ну, и если быть не очень скромным, то сам я являюсь автором первой версии PHP расширения для Tarantool, так что при необходимости смогу что-то пропатчить или исправить багу.

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

Анализ данных (продолжение)

Рассмотрим профиль пользователя, таблица users. В ней есть изменяемые и не изменяемые данные. К изменяемым данным относится: баланс, рейтинг, pvp-очки, юниты, шаги туториала и пр.
К не изменяемым данным относятся social_id, логин, url аватара, личные коды и пр… Среди изменяемых данных есть часто меняемые и редко меняемые. Однако, не изменяемые данные могут запрашиваться часто.

Выделяем часто запрашиваемые данные. Именно их мы и будем кешировать в tarantool. Теперь немного про само NoSQL хранилище…

ТАРАНtool. Краткий обзор

Tarantool — это, как уже было выше сказано, высокопроизводительное key/value NoSQL хранилище. Все данные находятся в оперативной памяти, и представлены в виде кортежей, по этому их извлечение по скорости не уступает redis или чуть медленнее (на 6-7 милисекунд на 1000 операций) memcached.

И все-таки, отметим, что Tarantool — это хранилище данных, а не система кеширования в памяти, типа memcache. Все данные находятся в оперативной памяти, но постоянно сохраняются (синкуются от системного вызова sync) в файлы (снапах 0000..01.snap ). В отличие от традиционного memcached & redis, tarantool имеет дополнительные возможности:
— возможноссть наложения вторичных индексов на данные
— индексы могут быть составными
— индексы могут иметь тип HASH, TREE или BITSET. Планируется внедрение GEO индекса.
— осуществление выборок по одному или нескольким индексам одновременно.
— изъятие данных частями (аналог LIMIT/OFFSET).

Tarantool оперирует данными, которые объединены в пространства (space). Пространство — это аналог таблицы в MySQL. В tarantool используется цифровая нумерация пространств (0, 1, 2, 3 ...). В обозримом будущем планируется использовать именные пространства (аналог имен таблиц в MySQL.).

На каждое пространство может накладываеться индекс. Индексы могут накладываться, как на числовое (int32 или int64), так и на символьное поле. Так же, как и с пространствами, в tarantool определена цифровая нумерация индексов.

Обмен межд�� клиентом и сервером происходит кортежами. Кортеж- это аналог строки в таблице MySQL. В математике понятие кортеж — это упорядоченный конечный набор длины n. Каждый элемент кортежа, представляет собой элемент данных или поле. Принципиально, тарантул не различает типы данных полей. Для него — это набор байт. Но если мы используем индекс, т.е. накладываем индекс на это поле, то его тип должен соответствовать типу поля. Существует еще другое наименование кортежа: тапл (tuple).

Все индексы прописываются в конфиге, который человеко- восприним: YAML. Пример части конфига:
space[0].enabled = 1 
space[0].index[0].type = "HASH"
space[0].index[0].unique = 1
space[0].index[0].key_field[0].fieldno = 0
space[0].index[0].key_field[0].type = "NUM"
В конфиге описываем первичный и вторичные индексы для каждого пространства. Есл�� мы делаем выборку только по PRIMARY KEY, то вполне достаточно описания только первичного индекса (см. пример выше). Если мы хотим среди наших пользователей выбрать лучших по рейтингу или pvp-боям, то на эти поля накладываем вторичный индекс. Пусть индексируется второе поле (fieldno = 1, отсчет от ноля) int32_t — рейтинг:

space[0].index[1].type = "TREE"                 // делает тип TREE, что позволяет делать выборки на операции больше и меньше
space[0].index[1].unique = 0                       // убираем иникальность
space[0].index[1].key_field[0].fieldno = 1   // указываем номер индексируемого поля
space[0].index[1].key_field[0].type = "NUM" // тип int32_t

Так как у нас проект Социальной игры, то первичный ключ будет соответствовать social_id. Для большинства соцсетей — это 64-ключ. Тип индекса будет HASH, а тип данных STR. В идеале, хотелось бы NUM64, но к сожалению, PHP плохо работает с типом long long. Драйвер не распознает тип и размер первичного ключа используемого пространства. В настоящий момент, если использовать 64-х битный ключ, пока по нему нельзя искать используя 32хбитное значение. Его надо запаковать в пакет как 64-битный ключ. Сейчас драйвер это делает только если значение превосходит 32-хбитный диапазон. По этому, надежнее работать с типом STRING.

Расчет памяти

Необходимо помнить, что tarantool — решение memory only, по этому важно рассчитать предполагаемый объем занимаемой оперативной памяти. Расчет производится слудующим образом:

Перед каждым кортежом будет храниться переменная типа varint (аналог perl'ового 'w' в pack) и 12 байт из заголовка на каждую tuple. Конкретно, про за данные, можно ознакомиться, изучив протокол или прочитав статью Tarantool Данные и Протокол.

Дополнительно около 15 процентов занимает данные для аллокаторов. Если мы, например, имеем 10 полей и размер данных пользователя укладываются в 256 байт, то для 1.5M приблизительно будет следующий расчет:
( 10 * 1 + 256 + 12 ) * 1.15 * 1 500 000 = 921150000 ~= 440 Мб на днные

Так же, все индексы находятся в памяти, которая занимает:
— для одной ноды в дереве хранит 68 байт служебной информации
— для одной ноды в хеше хранит 56 байт служебной информации

Для хранения индекса на 1.5M пользователей достаточно чуть более 80Mb, итого вместе, для хранения 1,5 M профилей потребуется чуть более половины гигабайта. Если мы добавим еще один ключ (тип TREE), то это еще дополнительно 90М оперативной памяти.

Кому как, но по нынешним меркам — не совсем уж много.

Рецепт 4. Избавляемся от MySQL в foreground

Как мы уже говорили, перенеся данные пользовательского профиля в tarantool, мы хотим иметь их актуальные копии в MySQL. По этому, все операции, связанные с UPDATE, приходится выполнять. В результате, сделав кеширование, мы достигли не много. Но, основного эффекта все же достигли: MySQL перестал падать. Так, как же всё-таки ускорить работу скрипта в несколько раз? Это возможно, если избавиться от MySQL запросов вообще. А как это возможно? Нужно передать информацию об изменении в БД в какой-то другой, бэграунд скрипт, который будет осуществлять операции INSERT/UPDATE.

Данная информация передаётся через очередь. Есть несколько промышленных решений, которые позволяют запускать удаленные задачи: Gaerman, Celery, а так же можно приспособить RabbitMQ, ZMQ, redis и прочие сервера очередей. Но зачем вводить в проект какую-то новую сущность, если в качестве сервера очередей можно использовать tarantool.

В тарантул есть реализация очереди github.com/mailru/tntlua/blob/master/queue.lua и рекомендую её к использованию.
Однако, было реализовано чуть-чуть проще. Создаем новое пространство:

space[1].enabled = 1 
space[1].index[0].type = "TREE"
space[1].index[0].unique = 1
space[1].index[0].key_field[0].fieldno = 0
space[1].index[0].key_field[0].type = "NUM"
 

В данное пространство будем писать следующие поля:
— id, автоинкрементное поле. Должно быть проиндексировано. Накладывается первичный индекс типа TREE.
— type — тип операции, некоторая числовая константа, по которой определяется номер шаблона SQL оператора.
— data — некоторые данные для вставки / обновления.

В foreground скрипте будет следующий код:
define( 'UPDATE_SPIN_COUNT',   1);
define( 'UPDATE_USER_BALANCE'2);

$res = $tnt->call( 'box.auto_increment' , array( (string)TBL_QUEUESUPDATE_SPIN_COUNT, $spinCount, $uid ));
Хранимая процедура box.auto_increment является встроенной, она вставляет данные tuple значение первичного ключа — max + 1. Параметры:
— номер пространства, куда будет осуществлена вставка данных
— сами данные
— необязательный параметр флаг, по умолчанию выставлен «возвращать новый ключ»
Необходимо отметить, что тип переменной, номер пространства (константа TBL_QUEUES) должен быть приведен к типу STRING. Данный скрипт, вызывает lua процедуру, которая записывает данные в FIFO очередь ( автоинкрементный номер, тип выполняемой задачи и сами данные).

Далее, background скрипт, который может выполняться даже на другой удаленной машине, вынимает из очереди данные и выполняет SQL:

define( 'UPDATE_SPIN_COUNT',   1);
define( 'UPDATE_USER_BALANCE'2);

$res = $this->callProc('my_pop', array((string)TBL_QUEUES));
 
/*
если пусто то вернет: 
array(2) {
  ["count"]=>  int(0)
  ["tuples_list"]=>  array(0) {}
}
 
*/
if (!$res['count']) return;
 
$tuple = $res['tuples_list'][0];
switch ($tuple[1]) {
case UPDATE_SPIN_COUNT:
$sql = "UPDATE users SET spinCount ={$tuple[2]}  WHERE uid ={$tuple[3]}";
break;
 
case UPDATE_USER_BALANCE:
$sql = "UPDATE users SET money = money + {$tuple[2]}  WHERE uid ={$tuple[3]}";
break;
 
default:
throw new Exception ('unknow task type');
break;
}
 
$this->execSQL($sql);


В результате, наш фронт скрипт работает только с быстрым тарантулом, а бэдграунд-скрипт, висит в качестве демона или запускается по крону и сохра��яет данные в MySQL на отдельном сервере, не тратя ресурсов WEB сервера. В результате, можно выиграть в производительности более 30%. Тема бэкграундовских скриптов достойна отдельной статьи.

Однако, это не всё. Чтоб запустить lua процедуру my_pop, её надо проинициализировать. Для этого, нижеследующий код необходимо поместить в файл init.lua, который должен находиться в work_dir или script_dir.

function my_pop(spaceno)
    spaceno = tonumber(spaceno)
    local min_tuple = box.space[spaceno].index[0].idx:min()
    local min = 0
    if min_tuple ~= nil then
        min = box.unpack('i', min_tuple[0])
    else
        return 
    end
 
    local ret = box.select(spaceno, 0,min)
    box.delete(spaceno, min)
 
    return ret
 
end


Значение work_dir указывается в tarantool.conf.

Рецепт 5. Кеширование только тех профилей, кто активно играет

Как мы уже реализовали ранее, все наши профили хранятся в tarantool, а все изменения бэкграундовским скриптом заносятся в MySQL. Мы всегда имеем актуальные, в соответствии с CAP теоремой, с незначительным опозданием, данные.

А что, если наш проект набрал не 1.5 млн, а три или 5 млн пользователей? Пользователь зашел, игра не понравилась — ушел. А данные в тарантул остались, занимают память и не используются… По этому, для более эффективного использования, да и просто для более быстрого извлечения данных — имеет смысл хранить только тех пользователей, которые постоянно играют.

Иными словами, тех пользователей, которые не играют, т.е. не заходили в игру, например более недели, можно удалить из оперативного кеша. Так как у нас есть актуальная копия в БД, то мы всегда её можем восстановить в оперативном кеше. Код классов, использующих оперативный кеш построен по стандартному типу кеширования:

class User extends DbModel {
 
  public function getByUid($uid) {
 
        $result = this->getFromCache($uid);
 
        if (!is_null($result)) {
            return $result;
        }
 
        $result = $this->execSQL( "SELECT * FROM users WHERE uid=$uid");
        $this->setToCache($result);
 
        return $result;
  }
....
}


Чистку можно выполнить несколькими способами:
— выбрать крон скриптом список всех «просроченных» записей из БД и удалить их в тарантуле
— настроить в тарантуле брокера чистки (сам этого не делал) github.com/mailru/tarantool/wiki/Brokers-ru
— написать хранимую процедуру на lua по удалению всех «просроченных» записей и запускать ее вызов по крону.

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

Вместо заключения

Всё вышеописанное можно реализовать на любом из языков, на котором реализован ваш социальный проект (PHP, Perl, Python, Ruby, Java).
В качестве NoSQL хранилища данных оперативного кеша может подойти любое key/value хранилище из следующего многообразия:
— memcached, отсутствует персистентность и придется немного поломать голову над реализацией очередей, но это можно решить исполдьзуя операцию APPEND
— membase, не очень удачная разработка и вроде как уже перестал поддерживаться своими создателями
— связка memcacheDb & memcacheQ
— редис, принципиально есть все, чтоб реализовать данный функционал
— TokyoTyrant, KyotoTyrant — принципиально очереди можно реализовать на lua процедурах
— LevelDb Daemon, достойная разработка ребят из мамбы. Небольшой допил и очереди у вас уже в кармане.
— предлагаем в комментах что-то своё

Ну и в заключении немного о грушах или чуть-чуть пиара.
Про демоны, а конкретно их возможные задачи, взаимодействие и тонкости их реализации, я планирую рассказать на DevConf 2013 "РНР-демоны в социальных играх". А так же освещу некоторые фичи, которые мне позволили поднять производительность реализованных проектов. Если интересно, то затрону тему тарантула (для этого я воспользовался голосованием) Так-что, до встречи наDevConf 2013.

PS. уже третий час ночи, возможны ачапотки… плиииз в приват — сразу поправлю. Заранее благодарен.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Данная тема актуальна?
48.84%да, много нового169
20.81%принципиально нет ничего нового72
29.77%принципиально всё знал, но кое-что новое узнал про тарантул103
0.58%свое мнение в комментах2
Проголосовали 346 пользователей. Воздержались 89 пользователей.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
собираюсь ли я использовать тарантул в свои проектах
28.29%да, стоит попробовать99
23.14%нет, использую альтернативы (написать в комментах)81
14%нет, пока еще боюсь49
22.57%нет, мне MySQL достаточно79
10.57%нет, продукт еще сырой37
1.43%свое в комментах5
Проголосовали 350 пользователей. Воздержались 106 пользователей.