Зачем?
На многих сайтах, в том числе и на наших, используется собственная система сбора статистики. Почему собственная а не Яндекс.Метрика или Google Analytics? Причин может быть много, рассмотрим три основные, наиболее популярные причины такого выбора:
- Метрика и Analytics не распознают пользователя в привязке к сессии: а нам важно знать, Вася Пупкин или же Иван Иванов посещал страницу.
- На сайте используются хитрые механизмы отображения контента в зависимости от критериев посещаемости, например нужно ограничить доступ к определенному разделу после того, как количество прочтений отдельных страниц превысит некий лимит (например выдать лайтбокс – «Нравится читать наши новости? Зарегистрируйтесь»).
- «Большой брат следит за мной» — никаких сторонних JavaScript на моем сайте!
Как?
В интернете можно встретить массу туториалов на тему создания таких счетчиков с использованием базы данных MySQL или же файлов, и такие решения, безусловно, подойдут для небольших проектов. Но такое решение отнюдь не самое эффективное, если посещаемость сайта серьезная — зачем создавать постоянную нагрузку на MySQL сотнями или тысячами инсертов в секунду, нагружая тем самым дисковую подсистему и расходую драгоценные иопсы.
Какие есть альтернативы? Для хранения данных, на вскидку приходят два варианта:
- Использование не-реляционных хранилищ, таких как Memcached или Redis. Memcached выглядит более предпочтительно, так как в данном случае нет необходимости сохранять состояние, когда сервер выключен.
- Использование монолитного серверного приложения (демона), который хранит данные в памяти, скажем в виде переменной, массива, коллекции, хэш-мапа. Будет аналогично memcached по скорости и типу используемого хранилища (оперативная память). Это же приложение будет принимать запросы по HTTP/HTTPS/WS и скорее всего будет написано на NodeJS или Java
Второй очевидный вопрос, который встает при реализации такого счетчика – это минимальный промежуток времени (тик), по которому счетчик будет каким-то образом опрашивать клиента на предмет того, находится ли клиент до сих пор онлайн (или наоборот, клиент будет сообщать серверу).
Идеальным решением выглядят вебсокеты – используем единый демон, написанный, скажем на NodeJS, отлавливаем события connect и disconnect, храним внутри в памяти одну переменную — и вуаля, наш счетчик готов. Но это в теории.
На практике – это потребует открытия отдельного порта на сервере, развертывания соответствующего серверного приложения, а также наличие клиентского JavaScript или Flash, взаимодействующего с сервером по WS. А в случае использования HTTPS задача еще усложняется в виду развертывания дополнительного слоя на серверном приложении.
Нам же нужно простое решение, которое можно было бы развернуть за 10 минут в полевых условиях, что называется «на коленке», и которое при этом решало бы поставленную задачу.
Реализация
Было решено построить следующую архитектуру:
- Где храним – Memcached;
- Что храним – IP-адреса или хеши IP-адресов подключенных пользователей;
- Как складываем данные в Memcached? – через PHP-скрипт;
- Как запрашиваем PHP-скрипт? Через AJAX с заданным интервалом (раз в секунду/полминуты/минуту/и т.д.).
Memcached предполагает установку определенного срока годности (жизни) данных, то есть, когда мы кладем данные в Memcached, мы обязательно указываем, сколько секунд будут храниться данные. Этим свойством мы и воспользуемся – устанавливаем срок годности равный интервалу обращений к счетчику, и в случае если клиент уйдет в офлайн, запись о его IP-адресе автоматически удалится из Memcached по истечении срока жизни переменной в Memcached.
Установку самого Memcached оставим за кадром, благо в интернете есть сотни мануалов и туториалов на тему установки Memcached из пакетов для всех популярных систем, или же компиляции из исходников.
Перейдем непосредственно к части программной реализации.
Сам PHP-скрипт получился до безобразия простым и лаконичным:
$mem = new Memcached();
$mem->addServer('127.0.0.1',11211);
$hashname = md5($_SERVER['REMOTE_ADDR']);
if(isset($_GET['set'])){
$mem->add($hashname, 1 ,10); // срок жизни - 10 секунд
}
if(isset($_GET['count'])){
$data = $mem->getAllKeys();
echo count($data);
}
Скрипт будет принимать GET-параметр — set/get для добавления записи об IP-адресе или получения количества пользователей онлайн соответственно. Разумеется, для определения IP-адреса клиента, $_SERVER['REMOTE_ADDR'] можно заменить на значение X_FORWARDED_FOR или другого заголовка, в зависимости от конфигурации вашего сервера.
IP-адреса по желанию можно и не хешировать, мы всегда хешируем все, что не нужно хранить в открытом виде исходя из бизнес-логики. Нам ведь важно понять количество уникальных пользователей, и совершенно не нужно хранить в Memcached сами IP-адреса.
Остается написать клиентский JavaScript-код, который будет запрашивать наш счетчик с интервалом в 5 секунд. С использованием jQuery это также всего лишь несколько строчек:
setInterval(function(){
$.get("/counter.php?set");
}, 5000);
Запускаем – работает. Для получения большей точности и динамичности обновления данных счетчика, вам нужно будет настроить тайминги индивидуально (в общем виде рекомендуется ставить время обращения меньше времени жизни данных в Memcached).
Проверить работу счетчика и получить количество пользователей, которые находятся на сайте можно будет запросив URI:
/counter.php?get
Просто, быстро, надежно, практично.
Вместо заключения
Рассмотренный подход можно дополнять и видоизменять по вкусу в зависимости от конкретных задач и применяемых серверных технологий. Этот подход, безусловно, не нов и основан на самых простых и элементарных принципах и функциональных особенностях используемого ПО (Memcached). Но мы надеемся, что он пригодится как новичкам, так и возможно опытным разработчикам, и позволит сэкономить как свое, так и серверное время.
Если будет интересно, как реализовать счетчик посещаемости в виде NodeJS-приложения с взаимодействием через WebSocket, мы можем рассказать об этом в отдельной статье – вам нужно лишь обозначить интерес к тематике комментариях.
Комментарии (29)
nazarpc
17.07.2015 22:43+2Метрика и Analytics не распознают пользователя в привязке к сессии: а нам важно знать, Вася Пупкин или же Иван Иванов посещал страницу.
Да неужели?
«Большой брат следит за мной» — никаких сторонних JavaScript на моем сайте!
Piwik
$_SERVER['REMOTE_ADDR']
А потом в один прекрасный день вы подключаете CloudFlare либо просто reverse-proxy…IvanIDSolutions Автор
17.07.2015 22:45А потом в один прекрасный день вы подключаете CloudFlare либо просто reverse-proxy…
Читаем дальше по тексту:
Разумеется, для определения IP-адреса клиента, $_SERVER['REMOTE_ADDR'] можно заменить на значение X_FORWARDED_FOR или другого заголовка, в зависимости от конфигурации вашего сервера.
nazarpc
17.07.2015 22:49+4Это не особо спасет ситуацию если честно. На самом деле я бы ещё понял, если бы статья была из песочницы, а так… Копипаст из документации как использовать memcached в качестве счётчика как-то не очень, и реализация сомнительна.
Shvonder
17.07.2015 22:49-1На практике – это потребует открытия отдельного порта на сервере, развертывания соответствующего серверного приложения, а также наличие клиентского JavaScript или Flash, взаимодействующего с сервером по WS. А в случае использования HTTPS задача еще усложняется в виду развертывания дополнительного слоя на серверном приложении.
На это уйдет меньше времени чем на написание данной статьи. Счетчик на PHP+AJAX — это конечно круто :)IvanIDSolutions Автор
17.07.2015 22:54Я всеми руками за Node+Socket.io, разворачивал и те и другие варианты решений. Вариант с сокетами более правильный с точки зрения хотя бы даже намного более низкой ланентности счетчика.
Shvonder
17.07.2015 23:07-2Данная статья ни в коем случае не должна быть руководством к действию, учитывая количество ошибок в вашем лаконичном коде.
if($_GET['set'])
так нельзя, потому что будет ошибка если $_GET['set'] не передан.
$data = $mem->getAllKeys(); echo count($data);
так тоже нельзя, потому что ключи могут быть не только от счетчика. upd: не прочитал первый коммент, где про это написали.IvanIDSolutions Автор
17.07.2015 23:17-3так нельзя, потому что будет ошибка если $_GET['set'] не передан.
какая будет ошибка? ошибки не будет, просто ничего не произойдет если без GET-параметров вызывать — тупо подключение к серверу Memcached холостое откроется.Aiki
18.07.2015 08:39+1Поставьте error_reporting(E_ALL), и увидите.
Notice: Undefined index: set in \path\to\counter.php on line 10
Проверять переменные или ключа массива на существование следует функцией isset().
UPD: уже поправили благодаря комментарию внизу.
yjurfdw
17.07.2015 23:10-1я, конечно, понимаю, что это пример, но строки вида
if($_GET['set']){...}
пугают. Давайте все вместе использовать isset() :)gonzazoid
18.07.2015 14:37-1да не, использовать надо санитаров, тогда все нормально будет, и код такой пугать не будет. Правда автору стоило бы уточнить, что код работает с очищенными данными.
Fedcomp
17.07.2015 23:51+2> Метрика и Analytics не распознают пользователя в привязке к сессии: а нам важно знать, Вася Пупкин или же Иван Иванов посещал страницу.
Вы можете назначать пользователям user_id и сообщать его analitycs
habrahabr.ru/company/eastbanctech/blog/212197IvanIDSolutions Автор
18.07.2015 12:14-2Честно скажу, не знал о том, что такая возможность появилась, раньше ее не было.
К тому же, в нашем случае у нас уже отлично работала собственная система, были собраны данные за период больше года, которые активно используется для разной внутренней аналитики, и допилить счетчик онлайн-клиентов на мемкеше было намного быстрее, чем допиливать аналитикс по приведенным инструкциям.
IvanIDSolutions Автор
18.07.2015 13:43UPD: прочитал. Гугл не разрешает идентифицировать конкретных личностей. А нам нужно было идентифицировать именно людей, а не их принадлежность к группе, к тому же важно было понимать все историю конкретного человека, с момента его первого захода на сайт до регистрации и после регистрации (вешалась кука при первом посещении).
gonzazoid
18.07.2015 18:15>Почему собственная а не Яндекс.Метрика или Google Analytics?
Это немножко заблуждение. Современные реалии таковы, что приходится использовать и метрику и аналитикс и свой велосипед и плюс ко всему свой агрегатор данных метрики и аналитикса. Ровно с того момента как гугль и яндекс начали шифровать реферер — вы не всегда знаете по каким запросам зашли к вам с гугля или яндекса, и если вам нужны запросы, по которым вас находят (а они нужны любому вменяемому продвиженцу) — то вас ждет полный набор — метрика, аналитикс и костыль.
andrewnester
18.07.2015 18:59Ситуация — в офисе сидит 100 человек и в Интернет они выходят под ним внешним айпи (как это часто бывает)
что будет с вашим «простым до безобразия скриптом»?IvanIDSolutions Автор
18.07.2015 21:30Это всего лишь упрощенный пример. В реальной ситуации — Cookies+Fingerprint.js по-хорошему. Хеш IP-адреса легко меняется на любой другой хеш.
andrewnester
20.07.2015 12:18ну а не лучше ли в таком случае показать максимально приближённый для использования в реальных условиях код?
demimurych
19.07.2015 08:09Хотел бы обратить внимание, что время жизни данных в мемкешед не является гарантией того что данные не будут удалены раньше. В некоторых случаях мемкешед может удалить их сразу же после того как Вы их туда поместили. О чем собственно пишется в документации к мемкешед.
IvanIDSolutions Автор
19.07.2015 19:35В случае переполнения мемкеша — да. Для хранения миллиона записей с кешами md5 длиной 32 байт в теории должно потребоваться 32 мб памяти. На практике больше, по поводу минимального размера блока, если таковой имеется, я ничего не нашел, погуглил, но пишут про коэффициент роста 1.25 при выделении следующекй ячейки: dev.mysql.com/doc/refman/5.0/en/ha-memcached-using-memory.html
andrewnester
20.07.2015 12:19кстати, я как-то и не понял обоснование использования Memcached, а не Redis, простите, может невнимательно читал и не понял сути, но для такой цели лично мне Redis нравится больше
symbix
19.07.2015 11:13getAllKeys() не стоит использовать в продакшене. Реализован он вот так — stackoverflow.com/a/19562199 — и годится только для отладки. На сервере с серьезной посещаемостью тормозить будет еще похуже, чем с базой данных (скажем, в mysql/myisam select count(*) моментален).
Для этой задачи больше подходит Redis и его sorted sets, в качестве score использовать timestamp: ZADD (O(log N)) + ZRANGEBYSCORE (O(log N + M)). Ну и ZREMRANGEBYSCORE иногда, да
delicious
19.07.2015 19:21А пользователей вы игнорируете, если они шлют Do not track?
IvanIDSolutions Автор
19.07.2015 19:25-1Это будет зависеть от конкретной реализации, можно конечно и игнорировать, но нам же важно реальные цифры понимать.
liderman
19.07.2015 22:53Мне идея хранения данных в Memcache не очень нравится. Он же создан для кэширования, а не для хранения. Лучше использовать Redis для этих целей. Тем более для решения таких задач пригодятся сортированные множества, которые из коробки предоставляет Redis
nelson
Насчет секунд — следовало бы заложить на сервере время жизни чуть больше, чем период опрашивания — хотя бы потому что счетчики времени в JS совсем не точные (10000 — это может быть и 5 секунд и 20) — говорю исходя из опыта.
IvanIDSolutions Автор
Да, разумеется. Вообще весь этот пример предельно упрощенный и подразумевает, что Memcached ранее не использовался вообще, и будет полностью отведен под хранение данных счетчика.
По поводу тайминга — интересно, надо учесть. В любом случае с таймингами всегда лучше играться индивидуально.