[Из песочницы] Как мы делали аналитику для высоконагруженного сайта

imageНедавно на хабре была публикация о том, как реализована аналитика на 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–188×190–57×50–349×251–83×0–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.Структура базы данных:

b991a1d3be9f41e3bb3d36f166c28f04.jpg

Структура одного документа (один документ соответствует одному просмотру):

8611a534979c409bacfd1bc56fa87c10.jpg

Данные за один день весят довольно прилично — около 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

© Habrahabr.ru