Статьи

Покидая реляционное мышление — деревья RavenDB

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

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

Внутри ворона категории были смоделированы как:

{ // categories/1
"ParentId": null,
"Name": "Welcome ..."
}

{ // categories/2
"ParentId": "categories/1",
"Name": "Chapter 2..."
}

У них было еще несколько свойств, но ни один, который действительно интересует нас для этого поста. Оригинальный код был довольно наивным и сделал что-то вроде этого:

public IEnumerable<TreeNode> GetNodesForLevel(string level)
{
var categories = from cat in session.Query<Category>()
where cat.ParentId == level
select cat;

foreach(var category in categories)
{
var childrenQuery = from cat in session.Query<Category>()
where cat.ParentId == category.Id
select cat;

yield return new TreeNode
{
Name = category.Name,
HasChildren = childrenQuery.Count() > 0
};
}
}

 

Как вы можете себе представить, это вызвало некоторые проблемы, потому что у нас есть классический Select N + 1 здесь.

Теперь, если бы мы использовали SQL, мы могли бы сделать что-то вроде:

select *, (select count(*) from Categories child where child.ParentId = parent.Id)
from Categories parent
where parent.ParentId = @val

 

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

То, что мы сделали в Raven, это определение индекса карты / сокращения, чтобы сделать всю работу за нас. Это элегантно, но требует некоторого сдвига в мышлении, поэтому позвольте мне представить эту часть за раз:

 

from cat in docs.Categories 
let ids = new []
{
new { cat.Id, Count = 0, cat.ParentId },
new { Id = cat.ParentId, Count = 1, ParentId = (string)null }
}
from id in ids
select id

 

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

Используя категории, показанные выше, мы получаем следующий вывод:

    { "Id": "categories/1", "Count" = 0, ParentId: null }
{ "Id": null, "Count" = 1, ParentId: null }

{ "Id": "categories/2", "Count" = 0, ParentId: "categories/1" }
{ "Id": "categories/1", "Count" = 1, ParentId: null }

 

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

Следующим шагом является написание уменьшающей части, которая обрабатывает результаты запроса карты:

    from result in results
group result by result.Id into g
let parent = g.FirstOrDefault(x=>x.ParentId != null)
select new
{
Id = g.Key,
Count = g.Sum(x=>x.Count),
ParentId = parent == null ? null : parent.ParentId
}

 

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

  • Идентификатор является нулевым
  • Идентификатор «категории / 1»
  • Идентификатор «категории / 2»

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

В результате мы будем индексировать следующие элементы:

{ "Id": null, "Count": 1, "ParentId": null }
{ "Id": "categories/1", "Count": 1, "ParentId": null }
{ "Id": "categories/2", "Count": 0, "ParentId": "categories/1" }

 

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

Как это решение сравнивается с написанием коррелированного подзапроса в SQL? Ну, есть два основных преимущества:

  • Вы делаете запросы поверх предварительно вычисленного индекса, это означает, что вам не нужно беспокоиться о таких вещах, как блокировки строк, количество запросов и т. Д. Ваши запросы будут работать быстро, потому что в вычислениях не участвуют генерировать ответ.
  • Если вы используете режим HTTP, вы получаете кеширование по умолчанию. Да, это правильно, вам не нужно ничего делать, и вам не нужно беспокоиться об управлении кэшем или принятии решения о том, когда истечет срок действия, вы можете просто воспользоваться преимуществами собственной системы кэширования RavenDB, которая будет обрабатывать все этого для вас.

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