Из говнокода в Highload. Используем ТАРАНtool. 5 рецептов повышения производительности

в 22:49, , рубрики: highload, nosql, performance optimization, php, tarantool, высокая производительность, рефакторинг, социальные игры, метки: , , , , , ,

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

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

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

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

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

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

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

При профилировании запросов вылезает, что мы выполнили пять одинаковых «селектов»: SELECT * FRON 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 — решение memoru 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 в foregraund

Как мы уже говорили, перенеся данные пользовательского профиля в 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 ));
Необходимо отметить, что тип переменной, номер пространства (константа 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 2:
$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 * FRAO 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. уже третий час ночи, возможны ачапотки… плиииз в приват — сразу поправлю. Заранее благодарен.

Автор: akalend

Источник

Поделиться

* - обязательные к заполнению поля