Как мы делали аналитику для высоконагруженного сайта

в 10:49, , рубрики: highload, mongodb, node.js, аналитика, высокая производительность

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-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" }
					} 
		 }
	]);

Деплой и Отладка

Чтобы все это хорошо работало под высокой нагрузкой, необходимо немного настроить операционную систему и базу данных:

  1. В самой OS необходимо было увеличить количество дескрипторов. В случае с Ubuntu это:
    #/etc/security/limits.conf 
    # Увеличиваем лимит дескрипторов файлов (на каждое соединение нужно по одному). 
    * - nofile 1048576
    

    В других Linux-системах настройки из файла /etc/sysctl.conf.

  2. Для ускорения работы MongoDB файлы базы данных разместили на SSD диске. Также потребовалось поправить конфиг базы: отключили Journalin, и поигрались со временем сброса и информации на диск (storage.syncPeriodSecs — этот параметр указывает, как часто MongoDB выгружает данные из оперативной памяти на диск).
    /etc/mongodb.conf
    journal:
          enabled: false
    

Автор: ISINK

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js