- PVSM.RU - https://www.pvsm.ru -
Недавно на хабре была публикация [1] о том, как реализована аналитика на ivi.ru. После прочтения захотелось рассказать об аналитике, которую мы делали для одного крупного сайта. Заказчик, к сожалению, не разрешил публиковать в статье ссылку на сайт. Если верить Alexa Rank, то трафик на сайте, для которого мы делали аналитику, раз в 10 больше, чем на ivi.ru.
Из-за большого количества посещений сайта в какой-то момент пришло письмо от Google с просьбой перестать пользоваться сервисом или уменьшить количество запросов к нему, также некоторые данные невозможно было получить через Google Analytics.
Информация, которую мы собирали о пользователях:
Около 70% просмотров приходилось на страницы с видеоплеером, основной задачей было собрать информацию с этих страниц. Нужно было получить информацию об active/passive — сколько секунд пользователь был активен на странице, а сколько секунд она была неактивна — открыта как вкладка. Также интересна была информация о буферизации (тормозит видео или нет и как долго оно загружается у пользователя), информация о количестве перемоток и с какой секунды на какую перематывают пользователи. Для этого на все страницы размещался javascript код, который отстукивал каждые 30 секунд на сервер информацию с открытой в браузере страницы.
Скрипт довольной простой, он дергает две одно-пиксельные картинки с сервера аналитики, параметры предает в урле этих картинок. Почему так? На наш взгляд, самое надежное решение будет работать абсолютно в любых браузерах и платформах. Если бы использовали AJAX, пришлось бы решать вопросы с кроссдоменностью и работоспособностью в различных браузерах. Есть две картинки stat.gif и p.gif, первая используется при загрузке страницы и передает основную информацию о пользователе, вторая дергается каждые 15 секунд и передают ту информацию, которая может измениться с течением времени (active/passive, буферизация, перемотки).
Эта картинка дергается при первом открытии страницы:
/stat.gif?pid=p0oGejy139055323022216801050bny0&l=http%3A%2F%2Fsite.ru%2F8637994&r=http%3A%2F%2Fsite.ru%2F&w=1680&h=1050&a=Mozilla%2F5.0%20(Windows%20NT%206.1%3B%20rv%3A26.0)%20Gecko%2F20100101%20Firefox%2F26.0&k=1390553230222&i=30000&vr=3.0
Эта картинка дергается каждые 30 секунд:
/p.gif?pid=p0oGejy139055323022216801050bny0&rand=6752416&b=1&time=2-188x190-57x50-349x251-83x0-235x&pl=29&fpl=46&ld=552&efsc=true&tfsc=19&tac=89&tpas=70&vr=3.0
Названия параметров сокращены для уменьшения трафика. PID — уникальный идентификатор просмотра страницы, служит для того, чтобы сопоставить данные, которые пришли из stat.gif и p.gif.
С базой данных мы сразу определились, решено было использовать MongoDB (быстрая вставка, данные хранятся в документах, нереляционная структура). Первую реализацию написали на php, первые же тесты под большой нагрузкой показал серьезные проблемы:
Стало очевидно, что данные из stat.gif и p.gif нужно агрегировать и вставлять в монгу только после того, как запросы перестали приходить на p.gif. Это позволило на порядок сократить количество обращений к MongoDB и сами обращения стали только на insert (без Update). На PHP не могу решить задачу, поэтому встал вопрос о выборе новой платформы. Нужна была возможность обрабатывать запросы на уровне web-сервера, поэтому довольно быстро наш выбор пал на NodeJS. Причины: асинхронность, перспективность, знакомый синтаксис (большой опыт JavaScript), относительная простота написания кода. Большое влияние на выбор в пользу NodeJS дала публикация «Миллион одновременных соединений на Node.js» [2] за авторством ashtuchkin [3] — мы у себя на сервере повторили описанный эксперимент.
Немного о трафике и характере запросов: на каждой открытой странице располагается такой скрипт и отстукивает каждые 15 секунд данные на сервер. У одного пользователя может быть открыто сразу несколько таких страниц и все они будут отправлять данные вне зависимости от того, пользователь на этой странице сейчас или нет. И это все при примерно ~ 40 миллионах просмотров в сутки!
Сначала для теста сделали однопоточную версию сервера. Скрипт очень простой, в нем request принимал запросы на картинки stat.gif и p.gif и записывали эти данные в массив.
Array
(
[PID] => Array
(
[stat] => данные переданные картинкой stat.gif при первой загрузке страницы
[pgif] => последние данные переданные картинкой p.gif (отправляются каждые 15 секунд)
[time] => ЮНИКС метка времени, дата последнего обновление данных по этому PID
)
)
Дальше по таймеру запускается обработчик, который перебирает весь массив с PID и проверят время последнего изменения данных по этому PID (Array[PID][time]). Если с момента последнего изменения прошло более 90 секунд (раз данные не приходят от юзера каждые 15 секунд, значит, он закрыл страницу или пропал интернет), то запись вставляется в MongoDB и удаляется из самого массива. Протестировав однопоточную версию, решено было реализовать многопоточную версию (чтобы по максимуму использовать все возможности процессора).
В NodeJS многопоточность реализуется очень легко благодаря замечательному модулю Cluster [4]. В рамках этой статьи не буду вдаваться в детали работы многопоточного кода (об этом и так много написано), скажу только, что этот модуль позволяет запустить кусок кода в нескольких экземплярах на разных потоках и дает инструмент для взаимодействия дочерних потоков с головным при помощи сообщений.
Логика однопоточного приложения была разделена между головным и дочерними потоками:
Дочерние потоки принимали http запрос отдавали в ответ одно пиксельную картинку, а данные, полученные с картинкой в get-запросе, передавали в головной поток.
Пример кода worker- а (дочернего потока):
//Часть кода в которой происходит непосредственно разбор запросов
server.on('request', function(req, res) { - Обработка GET запроса к серверу
var url_parts = url.parse(req.url, true);
var query = url_parts.query;
var url_string = url_parts.pathname.slice(1);
var cookies = {};
switch(url_string){ // Все очень примитивно потому что нужно обрабатывать только в урла /p.gif и /stat.gif
case 'p.gif':
process.send({ routeType: 'p.gif', params: url_parts.query}); // отправляем данные в головной поток
if(image == undefined){ // если после запуска сервера картинка не считывалась и ее нет в памяти то считываем записываем в память и отдаем -- однопиксельная картинка
fs.stat('p.gif', function(err, stat) {
if (!err){
image = fs.readFileSync('p.gif');
res.end(image);
}
else
res.end();
});
}else
res.end(image);
break;
case 'stat.gif':
url_parts.query.ip = req.connection.remoteAddress;
process.send({ routeType: 'stat.gif', params: url_parts.query}); // отправляем данные в головной поток
if(image == undefined){ // если после запуска сервера картинка не считывалась и ее нет в памяти то считываем записываем в память и отдаем -- однопиксельная картинка
fs.stat('p.gif', function(err, stat) {
if (!err){
image = fs.readFileSync('p.gif');
res.end(image);
}
else
res.end();
});
}else
res.end(image);
break;
default: //
res.end('No file');
break;
}
});
Данные в головной поток отправляются с помощью process.send({}) [5].
В головном потоке данные из дочерних потоков принимаются с помощью
worker.on('message', function(data) {}) [6] и записываются в массив.
Пример кода головного потока:
Часть кода, вешаем событие на сообщение для каждого дочернего процесса
worker.on('message', function(data) {
switch(data.routeType){
case 'p.gif':
counter++;
if(data.params.pid != undefined && dataObject[data.params.pid] != undefined){ //Проверяем что передан PID, а также что юзер уже существует в объекте
dataObject[data.params.pid]['pgif'] = data.params; //Записываем параметры во второй, перезаписываемый индекс
dataObject[data.params.pid]['time'] = Math.ceil(new Date().getTime()/1000); //Записываем последнюю дату перезаписи
}
break;
case 'stat.gif':
counter++;
if(data.params.pid != undefined){
if(dataObject[data.params.pid] == undefined) //Если массив не существует, создаём его
dataObject[data.params.pid] = [];
dataObject[data.params.pid]['stat'] = data.params; //Записываем параметры в первый индекс
dataObject[data.params.pid]['time'] = Math.ceil(new Date().getTime()/1000); //Записываем дату когда была сделана первая запись, для вычисления случаев, когда юзер закрыл страницу раньше, чем был второй запрос
}
break;
default:
break;
}
});
Также в головном потоке запускается таймер, который анализирует записи в массиве и вставляет в базу MongoDB те, по которым не было изменения более чем 90 секунд.
С хранение данных также есть свои нюансы, в ходе различных экспериментов пришли к выводу, что хранить все данные в одной коллекции (аналог таблицы в MySQL) — плохая идея. Решено было на каждый день создавать новую коллекцию — благо в MongoDB это делается легко: если коллекция не существует и вы пытаетесь в нее что-то записать, она создается автоматически. Получается, что в ходе своей работы серверная часть пишет данные в коллекции с датой в имени: stat20141102, stat20141103, stat20141104.
Структура базы данных:
Структура одного документа (один документ соответствует одному просмотру):
Данные за один день весят довольно прилично — около 500 мегабайт это при сэмплирование 1/10 (только на 10 % посетителей срабатывает статистика), соответственно, если бы запускали без сэмплирования, то коллекция за один день весила бы 5 Гигабайт. Коллекции с сырыми данными хранятся всего 5 дней, затем удаляются за ненадобностью, потому что есть скрипты-агрегаторы, которые запускаются по крону, обрабатывают сырые данные и записывают их уже в более компактном обсчитанном виде в другие коллекции – которые используются для построения графиков, отчетов.
Изначально отчеты строились с помощью find() и Map-Reduce [7]. Метод collection.find() использовался для простых выборок, а более сложные строились с помощью Map-Reduce. Второй способ наиболее сложный и требовал полного понимания механизмов распределенных вычислений и практического опыта. Задачи, которые в MySQL решались операторами AVG,SUM, ORDER BY, требовали определенных ухищрений с Map-Reduce для того, чтобы получить результат. Хорошим подарком для нас в тот момент стал выход стабильной версии MongoDB 2.2, в ней появился Aggregation Framework [8], он позволял очень легко и быстро строить сложные выборки из базы, не прибегаю к Map-Reduce.
Пример запроса через aggregate (группирует данные по id видео и суммирует| получает среднее по показателям):
db.stat20141103.aggregate([
{ $match : { $nor : [{ ap : {$gt: 20}, loaded :0 }]} } ,
{ $group: {
_id:"$video_id",
sum:{$sum:1},
active:{$sum:"$active" },
passive:{$sum:"$passive" },
buffer:{$sum:"$buffer" },
rewind:{$avg:"$rewindn" },
played:{$sum:"$played" }
}
}
]);
Чтобы все это хорошо работало под высокой нагрузкой, необходимо немного настроить операционную систему и базу данных:
#/etc/security/limits.conf
# Увеличиваем лимит дескрипторов файлов (на каждое соединение нужно по одному).
* - nofile 1048576
В других Linux-системах настройки из файла /etc/sysctl.conf.
/etc/mongodb.conf
journal:
enabled: false
Автор: ISINK
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/node-js/73584
Ссылки в тексте:
[1] публикация: http://habrahabr.ru/company/ivi/blog/236253/
[2] «Миллион одновременных соединений на Node.js»: http://habrahabr.ru/post/123154/
[3] ashtuchkin: http://habrahabr.ru/users/ashtuchkin/
[4] Cluster: http://nodejs.org/api/cluster.html#cluster_cluster
[5] process.send({}): http://nodejs.org/api/child_process.html#child_process_example_sending_server_object
[6] worker.on('message', function(data) {}): http://nodejs.org/api/cluster.html#cluster_event_message
[7] Map-Reduce: http://docs.mongodb.org/manual/core/map-reduce/
[8] Aggregation Framework: http://docs.mongodb.org/master/reference/command/aggregate/#dbcmd.aggregate
[9] Источник: http://habrahabr.ru/post/242369/
Нажмите здесь для печати.