Многие слышали о высокоуровневом поисковом сервере 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
Автор: Cher