Зачем?


На многих сайтах, в том числе и на наших, используется собственная система сбора статистики. Почему собственная а не Яндекс.Метрика или Google Analytics? Причин может быть много, рассмотрим три основные, наиболее популярные причины такого выбора:

  1. Метрика и Analytics не распознают пользователя в привязке к сессии: а нам важно знать, Вася Пупкин или же Иван Иванов посещал страницу.

  2. На сайте используются хитрые механизмы отображения контента в зависимости от критериев посещаемости, например нужно ограничить доступ к определенному разделу после того, как количество прочтений отдельных страниц превысит некий лимит (например выдать лайтбокс – «Нравится читать наши новости? Зарегистрируйтесь»).

  3. «Большой брат следит за мной» — никаких сторонних JavaScript на моем сайте!

Так или иначе, не важно в силу каких обстоятельств, у вас появилась собственная система сбора статистики. Сама система может собирать различные данные, вешать куки первом заходе, — в зависимости от бизнес-логики и фантазии разработчика, эти нюансы мы опустим, поскольку создание самой системы не входит в тему нашей сегодняшней статьи. Но в любом случае, будет не хватать очень важного и полезного компонента, который есть у Google и Yandex, — а именно подсчета количества человек на сайте онлайн.

Как?


В интернете можно встретить массу туториалов на тему создания таких счетчиков с использованием базы данных MySQL или же файлов, и такие решения, безусловно, подойдут для небольших проектов. Но такое решение отнюдь не самое эффективное, если посещаемость сайта серьезная — зачем создавать постоянную нагрузку на MySQL сотнями или тысячами инсертов в секунду, нагружая тем самым дисковую подсистему и расходую драгоценные иопсы.

Какие есть альтернативы? Для хранения данных, на вскидку приходят два варианта:

  • Использование не-реляционных хранилищ, таких как Memcached или Redis. Memcached выглядит более предпочтительно, так как в данном случае нет необходимости сохранять состояние, когда сервер выключен.

  • Использование монолитного серверного приложения (демона), который хранит данные в памяти, скажем в виде переменной, массива, коллекции, хэш-мапа. Будет аналогично memcached по скорости и типу используемого хранилища (оперативная память). Это же приложение будет принимать запросы по HTTP/HTTPS/WS и скорее всего будет написано на NodeJS или Java

Второй очевидный вопрос, который встает при реализации такого счетчика – это минимальный промежуток времени (тик), по которому счетчик будет каким-то образом опрашивать клиента на предмет того, находится ли клиент до сих пор онлайн (или наоборот, клиент будет сообщать серверу).

Идеальным решением выглядят вебсокеты – используем единый демон, написанный, скажем на NodeJS, отлавливаем события connect и disconnect, храним внутри в памяти одну переменную — и вуаля, наш счетчик готов. Но это в теории.

На практике – это потребует открытия отдельного порта на сервере, развертывания соответствующего серверного приложения, а также наличие клиентского JavaScript или Flash, взаимодействующего с сервером по WS. А в случае использования HTTPS задача еще усложняется в виду развертывания дополнительного слоя на серверном приложении.

Нам же нужно простое решение, которое можно было бы развернуть за 10 минут в полевых условиях, что называется «на коленке», и которое при этом решало бы поставленную задачу.

Реализация


Было решено построить следующую архитектуру:

  1. Где храним – Memcached;
  2. Что храним – IP-адреса или хеши IP-адресов подключенных пользователей;
  3. Как складываем данные в Memcached? – через PHP-скрипт;
  4. Как запрашиваем 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)


  1. nelson
    17.07.2015 22:21
    +3

    $mem->getAllKeys();
    Этот метод подходит только в том случае, когда данная система кэширования (ну или key-value хранилище) не используется ни для каких других целей. Например, на одном из моих проектов различных ключей в кэше настолько много, что попытка даже просто выполнить операцию getAllKeys() вешает сервак наглухо.

    Насчет секунд — следовало бы заложить на сервере время жизни чуть больше, чем период опрашивания — хотя бы потому что счетчики времени в JS совсем не точные (10000 — это может быть и 5 секунд и 20) — говорю исходя из опыта.


    1. IvanIDSolutions Автор
      17.07.2015 22:24

      Да, разумеется. Вообще весь этот пример предельно упрощенный и подразумевает, что Memcached ранее не использовался вообще, и будет полностью отведен под хранение данных счетчика.

      По поводу тайминга — интересно, надо учесть. В любом случае с таймингами всегда лучше играться индивидуально.


  1. nazarpc
    17.07.2015 22:43
    +2

    Метрика и Analytics не распознают пользователя в привязке к сессии: а нам важно знать, Вася Пупкин или же Иван Иванов посещал страницу.

    Да неужели?
    «Большой брат следит за мной» — никаких сторонних JavaScript на моем сайте!

    Piwik
    $_SERVER['REMOTE_ADDR']

    А потом в один прекрасный день вы подключаете CloudFlare либо просто reverse-proxy…


    1. IvanIDSolutions Автор
      17.07.2015 22:45

      А потом в один прекрасный день вы подключаете CloudFlare либо просто reverse-proxy…

      Читаем дальше по тексту:
      Разумеется, для определения IP-адреса клиента, $_SERVER['REMOTE_ADDR'] можно заменить на значение X_FORWARDED_FOR или другого заголовка, в зависимости от конфигурации вашего сервера.


      1. nazarpc
        17.07.2015 22:49
        +4

        Это не особо спасет ситуацию если честно. На самом деле я бы ещё понял, если бы статья была из песочницы, а так… Копипаст из документации как использовать memcached в качестве счётчика как-то не очень, и реализация сомнительна.


  1. Shvonder
    17.07.2015 22:49
    -1

    На практике – это потребует открытия отдельного порта на сервере, развертывания соответствующего серверного приложения, а также наличие клиентского JavaScript или Flash, взаимодействующего с сервером по WS. А в случае использования HTTPS задача еще усложняется в виду развертывания дополнительного слоя на серверном приложении.

    На это уйдет меньше времени чем на написание данной статьи. Счетчик на PHP+AJAX — это конечно круто :)


    1. IvanIDSolutions Автор
      17.07.2015 22:54

      Я всеми руками за Node+Socket.io, разворачивал и те и другие варианты решений. Вариант с сокетами более правильный с точки зрения хотя бы даже намного более низкой ланентности счетчика.


      1. Shvonder
        17.07.2015 23:07
        -2

        Данная статья ни в коем случае не должна быть руководством к действию, учитывая количество ошибок в вашем лаконичном коде.

        if($_GET['set'])
        

        так нельзя, потому что будет ошибка если $_GET['set'] не передан.

        $data = $mem->getAllKeys();
        echo count($data);
        

        так тоже нельзя, потому что ключи могут быть не только от счетчика. upd: не прочитал первый коммент, где про это написали.


        1. IvanIDSolutions Автор
          17.07.2015 23:17
          -3

          так нельзя, потому что будет ошибка если $_GET['set'] не передан.

          какая будет ошибка? ошибки не будет, просто ничего не произойдет если без GET-параметров вызывать — тупо подключение к серверу Memcached холостое откроется.


          1. 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: уже поправили благодаря комментарию внизу.


  1. yjurfdw
    17.07.2015 23:10
    -1

    я, конечно, понимаю, что это пример, но строки вида

    if($_GET['set']){...}
    

    пугают. Давайте все вместе использовать isset() :)


    1. IvanIDSolutions Автор
      17.07.2015 23:15
      -1

      done!


      1. zelenin
        18.07.2015 00:37
        -1

        уже поздно.


        1. annenkov
          18.07.2015 10:34

          ??


    1. gonzazoid
      18.07.2015 14:37
      -1

      да не, использовать надо санитаров, тогда все нормально будет,  и код такой пугать не будет. Правда автору стоило бы уточнить, что код работает с очищенными данными.


  1. Fedcomp
    17.07.2015 23:51
    +2

    > Метрика и Analytics не распознают пользователя в привязке к сессии: а нам важно знать, Вася Пупкин или же Иван Иванов посещал страницу.
    Вы можете назначать пользователям user_id и сообщать его analitycs
    habrahabr.ru/company/eastbanctech/blog/212197


    1. IvanIDSolutions Автор
      18.07.2015 12:14
      -2

      Честно скажу, не знал о том, что такая возможность появилась, раньше ее не было.
      К тому же, в нашем случае у нас уже отлично работала собственная система, были собраны данные за период больше года, которые активно используется для разной внутренней аналитики, и допилить счетчик онлайн-клиентов на мемкеше было намного быстрее, чем допиливать аналитикс по приведенным инструкциям.


    1. IvanIDSolutions Автор
      18.07.2015 13:43

      UPD: прочитал. Гугл не разрешает идентифицировать конкретных личностей. А нам нужно было идентифицировать именно людей, а не их принадлежность к группе, к тому же важно было понимать все историю конкретного человека, с момента его первого захода на сайт до регистрации и после регистрации (вешалась кука при первом посещении).


  1. gonzazoid
    18.07.2015 18:15

    >Почему собственная а не Яндекс.Метрика или Google Analytics?
    Это немножко заблуждение. Современные реалии таковы, что приходится использовать и метрику и аналитикс и свой велосипед и плюс ко всему свой агрегатор данных метрики и аналитикса. Ровно с того момента как гугль и яндекс начали шифровать реферер — вы не всегда знаете по каким запросам зашли к вам с гугля или яндекса, и если вам нужны запросы, по которым вас находят (а они нужны любому вменяемому продвиженцу) — то вас ждет полный набор — метрика, аналитикс и костыль.


  1. andrewnester
    18.07.2015 18:59

    Ситуация — в офисе сидит 100 человек и в Интернет они выходят под ним внешним айпи (как это часто бывает)
    что будет с вашим «простым до безобразия скриптом»?


    1. IvanIDSolutions Автор
      18.07.2015 21:30

      Это всего лишь упрощенный пример. В реальной ситуации — Cookies+Fingerprint.js по-хорошему. Хеш IP-адреса легко меняется на любой другой хеш.


      1. andrewnester
        20.07.2015 12:18

        ну а не лучше ли в таком случае показать максимально приближённый для использования в реальных условиях код?


  1. demimurych
    19.07.2015 08:09

    Хотел бы обратить внимание, что время жизни данных в мемкешед не является гарантией того что данные не будут удалены раньше. В некоторых случаях мемкешед может удалить их сразу же после того как Вы их туда поместили. О чем собственно пишется в документации к мемкешед.


    1. IvanIDSolutions Автор
      19.07.2015 19:35

      В случае переполнения мемкеша — да. Для хранения миллиона записей с кешами md5 длиной 32 байт в теории должно потребоваться 32 мб памяти. На практике больше, по поводу минимального размера блока, если таковой имеется, я ничего не нашел, погуглил, но пишут про коэффициент роста 1.25 при выделении следующекй ячейки: dev.mysql.com/doc/refman/5.0/en/ha-memcached-using-memory.html


      1. andrewnester
        20.07.2015 12:19

        кстати, я как-то и не понял обоснование использования Memcached, а не Redis, простите, может невнимательно читал и не понял сути, но для такой цели лично мне Redis нравится больше


  1. symbix
    19.07.2015 11:13

    getAllKeys() не стоит использовать в продакшене. Реализован он вот так — stackoverflow.com/a/19562199 — и годится только для отладки. На сервере с серьезной посещаемостью тормозить будет еще похуже, чем с базой данных (скажем, в mysql/myisam select count(*) моментален).

    Для этой задачи больше подходит Redis и его sorted sets, в качестве score использовать timestamp: ZADD (O(log N)) + ZRANGEBYSCORE (O(log N + M)). Ну и ZREMRANGEBYSCORE иногда, да


  1. delicious
    19.07.2015 19:21

    А пользователей вы игнорируете, если они шлют Do not track?


    1. IvanIDSolutions Автор
      19.07.2015 19:25
      -1

      Это будет зависеть от конкретной реализации, можно конечно и игнорировать, но нам же важно реальные цифры понимать.


  1. liderman
    19.07.2015 22:53

    Мне идея хранения данных в Memcache не очень нравится. Он же создан для кэширования, а не для хранения. Лучше использовать Redis для этих целей. Тем более для решения таких задач пригодятся сортированные множества, которые из коробки предоставляет Redis