Ко мне обратился один руководитель стартапа социальной игры с просьбой увеличить производительность своего проекта. На этом этапе был сделан и запущен прототип проекта. И надо отдать должное разработчикам, что проект работал и даже приносил какую-то прибыль. Но, запускать рекламную компанию не имело смысло, так как проект не выдерживал ни каких нагрузок. Валился 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_QUEUES, UPDATE_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 пользователей.
