Недавно на хабре была публикация о том, как реализована аналитика на ivi.ru. После прочтения захотелось рассказать об аналитике, которую мы делали для одного крупного сайта. Заказчик, к сожалению, не разрешил публиковать в статье ссылку на сайт. Если верить Alexa Rank, то трафик на сайте, для которого мы делали аналитику, раз в 10 больше, чем на ivi.ru.
Причины и цели создания аналитики
Из-за большого количества посещений сайта в какой-то момент пришло письмо от Google с просьбой перестать пользоваться сервисом или уменьшить количество запросов к нему, также некоторые данные невозможно было получить через Google Analytics.
Информация, которую мы собирали о пользователях:
- просмотры страниц (refer,IP,UAgent, размеры экрана);
- active/passive;
- буферизация;
- перемотки.
Около 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, первые же тесты под большой нагрузкой показал серьезные проблемы:
- сам по себе php-fpm в связке с nginx потреблял очень много ресурсов на обработку запроса;
- при загрузке stat.gif в MongoDB делалась вставка нового документа, далее каждые 15 секунд в него апдейтились данные, которые приходят с картинкой p.gif.
Стало очевидно, что данные из stat.gif и p.gif нужно агрегировать и вставлять в монгу только после того, как запросы перестали приходить на p.gif. Это позволило на порядок сократить количество обращений к MongoDB и сами обращения стали только на insert (без Update). На PHP не могу решить задачу, поэтому встал вопрос о выборе новой платформы. Нужна была возможность обрабатывать запросы на уровне web-сервера, поэтому довольно быстро наш выбор пал на NodeJS. Причины: асинхронность, перспективность, знакомый синтаксис (большой опыт JavaScript), относительная простота написания кода. Большое влияние на выбор в пользу NodeJS дала публикация «Миллион одновременных соединений на Node.js» за авторством ashtuchkin — мы у себя на сервере повторили описанный эксперимент.
Немного о трафике и характере запросов: на каждой открытой странице располагается такой скрипт и отстукивает каждые 15 секунд данные на сервер. У одного пользователя может быть открыто сразу несколько таких страниц и все они будут отправлять данные вне зависимости от того, пользователь на этой странице сейчас или нет. И это все при примерно ~ 40 миллионах просмотров в сутки!
Устройство сервера на NodeJS
Сначала для теста сделали однопоточную версию сервера. Скрипт очень простой, в нем 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. В рамках этой статьи не буду вдаваться в детали работы многопоточного кода (об этом и так много написано), скажу только, что этот модуль позволяет запустить кусок кода в нескольких экземплярах на разных потоках и дает инструмент для взаимодействия дочерних потоков с головным при помощи сообщений.
Логика однопоточного приложения была разделена между головным и дочерними потоками:
Дочерние потоки принимали 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({}).
В головном потоке данные из дочерних потоков принимаются с помощью
worker.on('message', function(data) {}) и записываются в массив.
Пример кода головного потока:
Часть кода, вешаем событие на сообщение для каждого дочернего процесса
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. Метод collection.find() использовался для простых выборок, а более сложные строились с помощью Map-Reduce. Второй способ наиболее сложный и требовал полного понимания механизмов распределенных вычислений и практического опыта. Задачи, которые в MySQL решались операторами AVG,SUM, ORDER BY, требовали определенных ухищрений с Map-Reduce для того, чтобы получить результат. Хорошим подарком для нас в тот момент стал выход стабильной версии MongoDB 2.2, в ней появился Aggregation Framework, он позволял очень легко и быстро строить сложные выборки из базы, не прибегаю к 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" }
}
}
]);
Деплой и Отладка
Чтобы все это хорошо работало под высокой нагрузкой, необходимо немного настроить операционную систему и базу данных:
- В самой OS необходимо было увеличить количество дескрипторов. В случае с Ubuntu это:
#/etc/security/limits.conf # Увеличиваем лимит дескрипторов файлов (на каждое соединение нужно по одному). * - nofile 1048576
В других Linux-системах настройки из файла /etc/sysctl.conf.
- Для ускорения работы MongoDB файлы базы данных разместили на SSD диске. Также потребовалось поправить конфиг базы: отключили Journalin, и поигрались со временем сброса и информации на диск (storage.syncPeriodSecs — этот параметр указывает, как часто MongoDB выгружает данные из оперативной памяти на диск).
/etc/mongodb.conf journal: enabled: false
Автор: ISINK