Статьи

Выдержки из отчета рабочей группы RavenDB: аспекты информации, часть I

Грани раздражают. Главным образом потому, что они, как правило, используются относительно редко, но когда они используются, они используются много и многими интересными способами. Поскольку они довольно редко используются, позвольте мне объяснить, что они в первую очередь.

Мы хотим показать пользователям все последние телефоны. Мы можем сделать это, используя следующий запрос:

from phone in session.Query<Phone>() 
orderby phone.ReleaseDate descending 
select phone;

Все идет нормально. Но мы хотим добиться большего, чем просто показывать пользователю потенциально большой список. Итак, мы начинаем смотреть на результаты. Мы извлекаем некоторые интересные данные из результатов и позволяем пользователю быстро сузить детали. Типичные аспекты для телефонного запроса:

  • марка
  • Размер экрана
  • камера
  • Стоимость

Теперь мы, безусловно, можем дать возможность искать их по отдельности, но тогда вы получите что-то вроде этого:

Это не дружелюбно и не очень полезно. поэтому разработчики UX предложили эту идею:

образ

Это дает пользователю гораздо более простой способ просмотра больших наборов данных и позволяет быстро сузить поиск до того, что он хочет. Это также дает пользователю некоторое представление о реальных данных. Я вижу, как изменение выбора фасета, вероятно, изменит количество результатов, которые у меня есть. Это отличный способ предложить возможности исследования данных. И в любом виде хранилища данных это очень важно. Торговые сайты являются естественным выбором, но системы управления контентом / документами, рабочие места, информационные центры все чаще используют эту функцию.

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

Впервые мы получили эту функцию в сентябре 2011 года. Это был запрос от Мэтта Уоррена, основанный на некотором коде, который я добавил. Я намеренно иду туда, потому что это первый раз, когда мы видим этот код, и он был реализован самым простым способом, который мог бы работать, чтобы продемонстрировать эту функцию и убедиться, что она работает от начала до конца. Базовая реализация выглядела примерно так:

private void HandleTermsFacet(string index, Facet facet, IndexQuery indexQuery, IndexSearcher currentIndexSearcher, 
	Dictionary<string, IEnumerable<FacetValue>> results)
{
	var terms = database.ExecuteGetTermsQuery(index,
		facet.Name,null,
		database.Configuration.MaxPageSize);
	var termResults = new List<FacetValue>();
	foreach (var term in terms)
	{
		var baseQuery = database.IndexStorage.GetLuceneQuery(index, indexQuery);
		var termQuery = new TermQuery(new Term(facet.Name, term));

		var joinedQuery = new BooleanQuery();
		joinedQuery.Add(baseQuery, BooleanClause.Occur.MUST);
		joinedQuery.Add(termQuery, BooleanClause.Occur.MUST);

		var topDocs = currentIndexSearcher.Search(joinedQuery, 1);

		if(topDocs.totalHits > 0)
			termResults.Add(new FacetValue
			{
				Count = topDocs.totalHits,
				Range = term
			});
	}

	results[facet.Name] = termResults;
}

Другими словами, общая логика была примерно такой:

  • Получите граненый запрос со списком фасетов.
  • Для каждого аспекта:
    • Получить все условия для поля (если это операционная система, то это будет: Android, iOS, BlackBerry, Windows Phone)
    • Для каждого из этих терминов выполните исходный запрос и добавьте предложение, ограничивающее результаты текущим термином и значением.

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

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

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

Конечным результатом было то, что код фасета выглядел примерно так:

IndexedTerms.ReadEntriesForFields(currentState, fieldsToRead, allCollector.Documents,
    (term, docId) =>
    {
        List<Facet> facets;
        if (!sortedFacets.TryGetValue(term.Field, out facets))
            return;
        
        foreach (var facet in facets)
        {
            switch (facet.Mode)
            {
                case FacetMode.Default:
                    var facetValues = facetsByName.GetOrAdd(facet.DisplayName);
                    FacetValue existing;
                    if (facetValues.TryGetValue(term.Text, out existing) == false)
                    {
                        existing = new FacetValue
                        {
                            Range = GetRangeName(term)
                        };
                        facetValues[term.Text] = existing;
                    }
                    ApplyFacetValueHit(existing, facet, docId, null, currentState, fieldsCrc);
                    break;
                case FacetMode.Ranges:
                    // removed for brevity
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }

    });
});

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

И производительность была довольно хорошей для наших тестовых случаев, мы проверяли это у нескольких клиентов, которые были активными пользователями, и они увидели заметное улучшение производительности запросов. Но недавно мы получили клиента, который мимоходом упомянул, что фасетные запросы были дорогими. Например, диапазон 5-10 секунд на запрос. Это подняло некоторые красные флажки, мы считаем запросы, которые занимают более 100 мс, подозрительными, поэтому мы запросили дополнительную информацию, и мы поняли, что он использует аспекты немного иначе, чем большинство наших клиентов.

Обычно, фасеты используются для… ну, фасет данных в поиске. Типичные проблемы производительности с аспектами в прошлом были с большим количеством аспектов в результатах поиска (я не шутил с 70000 аспектов). Поскольку большая часть отзывов клиентов была именно о них, мы сосредоточились на этом вопросе в первую очередь, до такой степени, что такие вещи хорошо себя вели. Этот клиент, однако, использовал аспекты не для поиска, а как способ управления самими данными. Это означало, что они использовали много аспектов всего набора данных.

Таким образом, вместо большого количества фасетов в среднем количестве документов мы использовали небольшое количество фасетов, но для всего набора данных. Профилирование привело к тому, что несколько вызовов были выделены:

  • GetOrAdd
  • TryGetValue

Они сами по себе очень дешевые звонки, но этот фрагмент кода был вызван буквально десятки миллионов раз для этого конкретного сценария. Мы провели некоторое время с профилировщиком и начали играть с кодом, пытаясь понять, сможем ли мы удалить дорогостоящие вызовы и оптимизировать этот путь кода.

Ответы были … нет. На самом деле все, что мы делали, находилось в диапазоне 5-7% (в обоих направлениях, помните)

Это отстой, потому что, возвращаясь к пользователю и говоря: «так оно и есть», нам не понравилось. Мы собрались вместе, закрыли ставни, выключили свет, и исключительно при свете мониторов мы провели большую хакерскую сессию. Мы полностью изменили способ обработки фасетов, чтобы избежать не только проблемы с вызовами TryGetValue или GetOrAdd, но и изменить направление на гораздо более эффективный режим.

Этот пост становится довольно длинным, поэтому я буду обсуждать это в моем следующем посте. На данный момент, вы можете предложить варианты улучшения?