Pull to refresh
VK
Building the Internet

ElasticSearch 1.0 — новые возможности аналитики

Reading time 9 min
Views 32K
Многие слышали о высокоуровневом поисковом сервере ElasticSearch, но не все знают, что многие используют его не совсем по прямому назначению. Речь идет о реалтайм-аналитике различных структурированных и не очень данных.

Эта статья также назрела ввиду того, что многие крупные интернет-проекты рунета в 2014 году получили письма счастья от Google Analytics с предложением заплатить $150 000 за возможность использовать их продукт. Я лично считаю, что ничего плохого в том, чтобы оплатить труд программистов и администраторов нет. Но при этом это довольно серьезные инвестиции, и, может, вложения в собственную инфраструктуру и специалистов, даст большую гибкость в дальнейшем.

Аналитика в ElasticSearch основана на полнотекстовом поиске и фасетах. Фасеты в поиске — это некая агрегация по определенному признаку. Вы часто сталкивались с фасетами-фильтрами в интернет-магазинах: в левой или правой колонке есть уточняющие галочки. Ниже пример тестового фасетного поиска у нас на главной странице http://indexisto.com/.



Буквально неделю назад вышла стабильная версия поискового сервера ElasticSearch 1.0, в которой разработчики настолько серьезно поработали над фасетами, что даже назвали их Aggregation.

Так как тема еще не освещалась на Хабре, я хочу рассказать, что из себя представляют аггрегации в ElasticSearch, какие возможности открываются и есть ли жизнь без Hadoop.

Во-первых, я хотел бы привести статистику из Google Trends, которая наглядно показывает, насколько велик интерес к ElasticSearch:


Дело здесь, конечно, не в том, что это поисковый сервер с морфологией и прочими плюшками. Основное — это легкая масштабируемость на больших объемах данных. Данные сами разъезжаются по шардам (логическое разбиение индекса), а шарды по нодам (серверам). Потом шарды еще и реплицируются по другим нодам, чтобы пережить падение серверов и для быстродействия запросов. Думать приходится, в основном, во время проработки схемы индексов.

Распределенные вычисления


Запрос исполняется на кластере параллельно, на тех шардах, в кототорых лежит интересующий индекс. Шарды — это, по сути, старые добрые Lucene-индексы. Все, что делает ElasticSearch, это принимает запрос, определяет ноды, где лежат шарды индекса, к которому пришел запрос, рассылает запрос на эти шарды, шарды считают и отсылают результаты ноде-инициатору. Нода-инициатор уже делает свертку и отсылает клиенту. По потокам данных это похоже на map reduce, когда индексы Lucene — это mapper'ы, а нода-инициатор — это reducer.

Некоторые запросы могут выполниться в один проход, некоторые в несколько. Например, чтобы посчитать стандартную текстовую релевантность tf/idf, основанную на частоте терма в документе (tf) и в коллекции документов (idf), очевидно, надо сначала получить idf с учетом всех шардов (первый запрос к шардам), и потом уже сделать основной запрос (второй). Хотя для скорости можно idf посчитать и локально на шарде.

Сделаем фасетный запрос «Сколько же было ошибок в логах по годам». Этот запрос ищет ERROR в документах логов, и делает фасеты по годам.
Вот так, примерно, выглядят потоки данных:



Нода, к которой пришел запрос, исполнила его сама (так как на ней есть один из шардов нужного индекса) и разослала на другие ноды, где содержатся другие шарды индекса. Каждый шард исполняет примерно такую последовательность в индексе:
  • определить максимальное и минимальное значение в поле date. Lucene не хранит внутри даты или числа — все в текстовом представлении.
  • подготовить нужные диапазоны дат в соответствии с тем, какое разбиение нужно пользователю. У нас это 1 год. Если логи есть за 2 года то получится 2 запроса
  • пройтись по инвертированному индексу и посчитать все документы (записи в лог), которые содержат ERROR и попадают в текущий диапазон. Сделать такой запрос для всех диапазонов.

Небольшая справка по Lucene. Range запросы по «числам» в текстовом представлении очень эффективны NumericRangeQuery:
Comparisons of the different types of RangeQueries on an index with about 500,000 docs showed that TermRangeQuery in boolean rewrite mode (with raised BooleanQuery clause count) took about 30-40 secs to complete, TermRangeQuery in constant score filter rewrite mode took 5 secs and executing this class took <100ms to complete


Переходим к практике. Подготовим данные


Так как сейчас мы просто посмотрим, что можно нааггрегировать в ElasticSearch, то не будем гнаться за объемами, а заведем индекс habr и добавим в него 20 постов.
Посты будут вот такой структуры:
{
        "user": "andrey",
        "title": "Android заголовок 1",
        "body": "Android тело 1",
        "postDate": "2011-02-15T11:12:12",
        "tags": ["взрыв"],
        "rank": 67,
        "comments": 21
}


Я создал 10 постов про Android и 10 постов про iPhone.
Посты могут иметь один или несколько тегов из набора
  • взрыв
  • обновление
  • приложение
  • баг

Годы постов от 2011 до 2014. Рейтинг от 0 до 100. Кол-во комментариев аналогично.

Вот что у нас в индексе в итоге:



Old-school-анализ (до версии ES 1.0)


Распределение тегов в постах про Android
Сразу на практике покажем, что можно проанализировать, сочетая полнотекстовый поиск и фасеты. Делаем поисковый запрос android к полю Title и фасетный запрос по полю tags:
{
  "query": {
    "wildcard": {
      "title": "Android*"
    }
  },
  "facets": {
    "tags": {
      "terms": {
        "field": "tags"
      }
    }
  }
}


В ответ получаем:
"facets": {
  "tags": {
    "_type": "terms",
    "missing": 0,
    "total": 18,
    "other": 0,
    "terms": [
      {
        "term": "обновление",
        "count": 5
      },
      {
        "term": "баг",
        "count": 5
      },
      {
        "term": "приложение",
        "count": 4
      },
      {
        "term": "взрыв",
        "count": 4
      }
    ]
  }
}


Ну и для сравнения то же самое, но если мы ищем посты про iPhone:
"facets": {
  "tags": {
    "_type": "terms",
    "missing": 0,
    "total": 16,
    "other": 0,
    "terms": [
      {
        "term": "взрыв",
        "count": 5
      },
      {
        "term": "баг",
        "count": 5
      },
      {
        "term": "приложение",
        "count": 4
      },
      {
        "term": "обновление",
        "count": 2
      }
    ]
  }
}


Как видно из аналитики, айфоны чаще взрываются, а андроиды обновляются.

Гистограмма распределения постов про iPhone по годам
Еще один пример, на котором мы рассмотрим, как менялся интерес к айфонам со временем. Запрос iPhone, фасеты по полю postDate:
{
  "query": {
    "wildcard": {
      "title": "iPhone"
    }
  },
  "facets": {
    "articles_over_time": {
      "date_histogram": {
        "field": "postDate",
        "interval": "year"
      }
    }
  }
}


Ответ:
"facets": {
  "articles_over_time": {
    "_type": "date_histogram",
    "entries": [
      {
        "time": 1293840000000,
        "count": 2
      },
      {
        "time": 1325376000000,
        "count": 4
      },
      {
        "time": 1356998400000,
        "count": 2
      },
      {
        "time": 1388534400000,
        "count": 2
      }
    ]
  }
}


Как видно, всплеск интереса пришелся на 2012 год (в ответе timestamp'ы).

Примерно понятно, как это работает. Основные типы фасетов в ElasticSearch
  • term — удобно на полях типа tags. Не очень рекомендуется строить term-фасет по терминам из «Войны и мира»
  • range — фасеты по диапазону значений числовых полей и дате. Можно сделать фасет: посчитай мне все товары с ценой от 0 до 100 и от 100 до 200
  • histogram — это те же самые range-запросы, только диапазоны задаются автоматически, указывается шаг разбиения. Работает по числовым полям и дате. Не стоит делать гистограмму с шагом в 1 миллисекунду за 100 лет

Сочетая фасеты и полнотекстовые запросы, можно получать множество разных выборок.

Аналитика в ES 1.0 — new school!


Как вы заметили, фасеты в ES до версии 1.0 просто возвращали число документов. Запросы были довольно плоскими (никакой вложенности). Но что если мы хотим получить посты про iPhone по годам, а потом посчитать средний рейтинг этих постов?
В ES 1.0 это стало возможно благодаря Aggregation-фреймворку.

Существуют два вида аггергаций:
Bucket — которые считают число найденных документов, а также складывают в «корзину» id найденных документов. Например, приведенные выше фасетные запросы типа term, range, histogram — это Bucket-агрегаты. Теперь они не просто считают количество документов, например, по термам в поле тегов, но и выдают списки id этих документов: 7 документов с тегом «баг», id этих документов 2,5,6,10,17,19,20
Calc — которые считают только число. Например, среднее значение числового поля, минимумы, максимумы, суммы.

Bucket-агрегаты не зря возвращают id найденных документов. Дело в том, что теперь можно построить цепочку аггрегатов. Например, первым аггрегатом мы разбиваем все документы по годам, потом находим распределение тэгов по годам.

Чтобы не ходить вокруг да около, вернемся к нашим тестовым данным. Давайте найдем самые скучные теги постов про айфон с разбивкой по годам. Другими словами найдем все статьи про Iphone, разобьем выборку по годам, и посмотрим какие теги у статей с самым низким рейтингом. Потом запретим авторам писать статьи по этой тематике. Запрос такой:
  • Ограничиваем выборку постами, которые содержат iPhone в заголовке
  • делаем histogram-bucklet-фасеты по годам
  • по полученным «корзинам» делаем term-bucklet-фасеты по тегам.
  • по полученным «корзинам» (с тегами) делаем Average-агрегацию по рейтингу
  • Так как нам нужны только самые скучные теги, то отсортируем их по значению, полученному во вложенной агрегации (средний рейтинг).


{
  "query": {
    "wildcard": {
      "title": "iphone"
    }
  },
  "aggs": {
    "post_over_time_agg": {
      "date_histogram": {
        "field": "postDate",
        "interval": "year"
      },
      "aggs": {
        "tags_agg": {
          "terms": {
            "field": "tags",
            "size": 1,
            "order": {
              "avg_rating_agg": "asc"
            }
          },
          "aggs": {
            "avg_rating_agg": {
              "avg": {
                "field": "rank"
              }
            }
          }
        }
      }
    }
  }
}


Ответ:
"aggregations": {
  "post_over_time_agg": {
    "buckets": [
      {
        "key": 1293840000000,
        "doc_count": 2,
        "tags_agg": {
          "buckets": [
            {
              "key": "взрыв",
              "doc_count": 1,
              "avg_rating_agg": {
                "value": 14.0
              }
            }
          ]
        }
      },
      {
        "key": 1325376000000,
        "doc_count": 4,
        "tags_agg": {
          "buckets": [
            {
              "key": "взрыв",
              "doc_count": 1,
              "avg_rating_agg": {
                "value": 34.0
              }
            }
          ]
        }
      },
      {
        "key": 1356998400000,
        "doc_count": 2,
        "tags_agg": {
          "buckets": [
            {
              "key": "приложение",
              "doc_count": 2,
              "avg_rating_agg": {
                "value": 41.0
              }
            }
          ]
        }
      },
      {
        "key": 1388534400000,
        "doc_count": 2,
        "tags_agg": {
          "buckets": [
            {
              "key": "баг",
              "doc_count": 1,
              "avg_rating_agg": {
                "value": 23.0
              }
            }
          ]
        }
      }
    ]
  }
}


Как видно, в 2011 и 2012 году людей достали посты про взрывы айфонов, в 2013 про новые приложения, а в 2014 про баги.

К реальным задачам


Стандартная задача интернет-магазина — посчитать эффективность рекламной кампании или проанализировать поведение группы пользователей. Представьте, что вы пушите все логи вашего http-сервера в ElasticSearch, при этом добавляя в документ еще немного своих данных, которые у вас хранятся, например в Битриксе
{
  "url": "http://myshop.com/purchase/confirmed",
  "date": "2014-02-15T11:12:12",
  "agent": "Chrome/32.0.1667.0",
  "geo": "Москва",
  "utm_source": "super_banner",
  "userRegisterDate": "2011-02-15T11:12:12",
  "orderPrice": 4000,
  "orderCategory": ["Сотовые телефоны","айфоны"]
}

При этом схема документа не фиксирована. Есть на этой странице заказ — добавили orderPrice, нет — не добавили.
Имея всего лишь такие логи, вы уже можете многое из того, что умеет Google Anallytics.

Пример 1: Сделать когортный анализ, к примеру, разбить пользователей по дате регистрации и посмотреть, сколько заказов было сделано ими за 2 месяца с момента регистрации, высчитать среднюю сумму заказа. Вот как логически выглядит такой запрос к ElasticSearch:
  • делаем текстовый запрос по url слова «purchase/confirmed»
  • делаем date_histogram-фасеты по дате регистрации пользователя
  • делаем date_range-фасет с начальным значением диапазона «дата регистрации пользователя» (к ней можно обратиться в запросе) и конечным + 2 месяца
  • делаем count и average по полю orderPrice в полученных bucklet


Пример 2: Отcлеживать цели и конверсии по каналам с разбивкой по дате. Например, цель — посещение урлы /purchase/confirmed
Запрос:
  • делаем текстовый запрос по url слова «purchase/confirmed»
  • делаем date_histogram-фасет по дате посещения урлы с разбивкой в 1 день
  • делаем term-фасет по utm_source
  • делаем count-агрегацию


Выводы


Сочетание полнотекстового поиска и цепочек аггрегаций (фасетов), гибкой схемы документов, автоматической масштабируемости и простоты в настройке и запуске делают ElasticSearch серьезным игроком на рынке аналитических систем.

Еще добавлю, что порог вхождения очень низкий. Установка за 5 минут, дальше можно играться хоть с curl, хоть плагином к Chrome который умеет генерировать http-запросы. У нас http://indexisto.com/ сейчас кластер из 7 машин показывает себя на удивление стабильно.

Все возможности аггрегаций описаны здесь https://github.com/elasticsearch/elasticsearch/issues/3300
Tags:
Hubs:
+69
Comments 19
Comments Comments 19

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен