Как-то раз, мне попалась интересная задача: выделить общую часть информации из нескольких документов, находящегося в Elasticsearch, в отдельный «фрагмент» с целью ее независимого и частого обновления по типу отношения «один ко многим». В данной статье я расскажу вам про join field type
.
О том как это сделать в просторах интернета довольно мало информации, но, скорее всего, я понимаю почему.
Причины так не делать
Кажется, что это довольно неэффективно, так как выделение части документа во фрагмент снизит скорость ваших запросов. Гораздо выгоднее дублировать повторяющиеся части в документах. Вообще, денормализация в Elasticsearch - это путь к хорошей производительности. Так сказала документация
Любые документы с помощью
join
(который в данной статье и будет описан) могут ссылаться только на документы в этом же индексе.Связанные ссылками документы должны храниться на одном шарде. Что в дальнейшем может ухудшить масштабируемость, если она вам нужна, особенно если связей будет достаточно много. И еще хуже, если у потомков будут свои потомки, что, естественно, тоже возможно.
Почему всё-таки это было внедрено
Кажется, идея массового частого
bulk update
на пачку документов из-за смены статуса в кусочке документа, малость оверхэдВ контексте использования в нашем проекте не требуется масштабирование, от слова совсем
Скорость запросов с созданными отношениями удовлетворила наши потребности
В связи с вышеперечисленными причинами, было принято решение, попробовать данное, весьма непопулярное, на мой взгляд, архитектурное решение. И теперь, я попробую описать свой опыт для тех, кому может необходимо сделать нечто подобное.
Что было бы без выделения части в отдельный кусочек документа:
При таком построении структуры документов, после обновлении статуса, необходимо было бы обновить его во всех документах.
Кейс
Я работаю работу в компании, по обслуживанию автомобилей. В индексе Elasticsearch у нас хранятся заказы. В заказе есть клиент и машина, для которой было проведено некоторое обслуживание, которое могло содержать в себе несколько услуг. Также, у нас собирается информация о машине и клиенте, степень собранности этих данных имеет свой статус, который может изменяться довольно часто, этот статус нам тоже хочется хранить в эластике и выдавать историю заказов по разным статусам.
Можно было бы сохранять эту информацию в каждом заказе, но для того, чтобы каждый раз не перезаписывать данную информацию у каждого заказа, эти сведения были нами выделены в отдельный фрагмент документа, то есть в другой документ.
Этот фрагмент имеет отношение один ко многим по отношению к заказам, поэтому мы будем называть его родительским, а заказы дочерними документами и именно так будем формировать свою связь. Дочерние документы будут иметь ссылку на родительской документ.
Реализация
Обновляем
mapping
Добавление фрагмента документа должно быть в том же индексе. Примерная структура создания мапинга документа в index
с вложенной структурой, нашим фрагментом (php):
$esClient = Elasticsearch\ClientBuilder::create()
->setHosts([
'host' => env('ES_SCHEME') . '://' . env('ES_HOST') . ':' . env('ES_PORT'),
])->build();
$index = env('ES_DB', ‘example);
if ($esClient->indices()->exists(['index' => $index])) {
$esClient->indices()->delete(['index' => $index]);
}
$esClient->indices()->create([
'index' => $index,
]);
$esClient->indices()->putMapping([
'index' => $index,
'body' => [
'properties' => [
'type' => [
'type' => 'keyword',
],
…
…
'car_id' => [
'type' => 'keyword',
],
'client_id' => [
'type' => 'keyword',
],
'state_block' => [
'type' => 'keyword',
],
'block_state_field' => [
'type' => 'join',
'relations' => [
'state' => 'intent',
],
],
],
],
]);
Тут мы в документ добавляем поле с названием 'block_state_field'
с типом поля 'join'
и в 'relations'
определяем связь между родительским и дочерними документами. Это поле и будет опраделять нашу связь "родительских" элементов и "детей".
Также, мы добавили поле 'state_block'
, которое мы, например, будем использовать только в документах-фрагментах, и в нем будем хранить наш статус.
Добавляем документ-фрагмент (родительский документ) в индекс. Пример:
Elasticsearch::index([
'index' => env('ES_DB', 'example'),
'id' => 'block_state.' . $blockState->id,
'body' => [
'id' => $blockState->id,
'type' => 'block_state',
'car_id' => $blockState->car_id,
'client_id' => $blockState->client_id,
'state_block' => $blockState->state_block,
'block_state_field' => [
'name' => 'state',
],
],
]);
Где $blockState
это объект, который содержит нужную нам дополнительную информацию о клиенте-машине-статусе собранности доп.информации.
Обратите внимание, что у нашего фрагмента имеется свой уникальный id
, который был назван как 'block_state.' . $blockState->id
и имеется поле body[‘block_state_field’][‘name’] = ‘state’
, который в mapping указывался слева в определении связи между документами
Добавляем документ с заказом, то есть дочерний документ, по отношению к нашему фрагменту, который создали ранее. Пример:
Elasticsearch::index([
'index' => env('ES_DB', 'example'),
'id' => 'order.' . $order->id,
'routing' => 'block_state.' . $order->blockStateId,
'body' => [
'id' => $order->id,
'type' => 'order',
'client_id' => $order->client_id,
'car_id' => $order->car_id,
'block_state_field' => [
'name' => 'intent',
'parent' => 'block_state.' . $order->blockStateId,
]
],
]);
Тут стоит обратить внимание на поле body['block_state_field']['name'] = 'intent'
, который в mapping указывался справа в определении связи документов, и указание id родительского документа body['block_state_field']['parent'] = 'block_state.' . $order->blockStateId
.
И еще тут появилось поле (на верхнем уровне документа, а не в самом body) 'routing'
указывающее на id документа, но уже не родителя, а самого корня верхнего «родителя». Но в нашем случае, это тоже ссылка на «родителя» и то же потому, что у нашего родительского документа нет своих родителей.
Родительский документ может быть только один, дочерних у «родителя» может быть много. Дочерний документ также может иметь и свои дочерние документы, если есть в этом необходимость. Но рекомендовано так не делать.
Это поле обязательное, так как родительские и дочерние (внучатые) документы должны быть проиндексированы на одном шарде, так сказала документация. И это поле как раз помогает определить что должно находиться на одном шарде.
Изменение/удаление документа с заказом (документа имеющего родителя)
Если при создании документа был установлен у документа routin
, то при изменении и удалении необходимо его указывать.
Если routing
в mapping
был установлен, как обязательное поле, то без указания в запросе верной маршрутизации удаления документа не произойдет и вернется RoutingMissingException
.
Указание маршрутизации как обязательного поля:
PUT my-index-000002
{
"mappings": {
"_routing": {
"required": true
}
}
}
Или, на моем примере, это будет так:
$esClient->indices()->putMapping([
'index' => $index,
'body' => [
'properties' => [
'_routing' => [
'required' => true
],
'type' => [
'type' => 'keyword',
],
'date' => [
'type' => 'date',
],
'car_id' => [
'type' => 'keyword',
],
'client_id' => [
'type' => 'keyword',
],
'state_block' => [
'type' => 'keyword',
],
'block_state_field' => [
'type' => 'join',
'relations' => [
'state' => 'intent',
],
],
],
],
]);
Запрашиваем заказы
Для примера, я выполню запрос одного заказа с нашим новым mapping
из консоли kibana
по родительскому id
:
GET _search
{
"query": {
"bool" : {
"filter": [
{
"terms": {
"type": ["order"]
}
},
{
"terms": {
"_routing": ["block_state.42244"]
}
}
]
}
},
"size": 1
}
Ответ:
{
"took" : 1,
"timed_out" : false,
"_shards" : {
…
},
"hits" : {
"total" : {
…
},
"max_score" : 0.0,
"hits" : [
{
"_index" : "example",
"_type" : "_doc",
"_id" : "order.3718",
"_score" : 0.0,
"_routing" : "block_state.42244",
"_source" : {
"id" : 3718,
"type" : "order",
"client_id" : 33,
"company_id" : 656,
"resource_id" : 1443,
"car_id" : 6783,
"block_state_field" : {
"name" : "intent",
"parent" : "block_state.42244"
}
}
}
]
}
}
А еще теперь можно сделать, например, такой интересный запрос:
Elasticsearch::search([
'index' => env('ES_DB', 'example'),
'body' => [
'query' => [
'bool' => [
'must' => [
[
'exists' => [
'field' => 'car_id',
],
],
],
'should' => [
[
'has_parent' => [
'parent_type' => 'state',
'query' => [
'terms' => [
'state_block' => ['fully'],
],
],
],
],
],
'minimum_should_match' => 1,
'filter' => [
"terms" => [
"type" => [
['order']
]
]
],
]
],
'sort' => [
'date' => [
'order' => 'desc',
],
],
'size' => 10,
'from' => 0,
'collapse' => ['field' => 'car_id'],
'aggs' => [
'total' => [
'cardinality' => [
'field' => 'car_id'
]
]
]
]
]);
Здесь я получаю заказы со статусом «полностью собран» для машины-клиента, и считаю, сколько уникальных полностью собранных данных по машинам у меня имеется.
Заключение
В данной публикации я делюсь своим личным опытом. На идеальность не претендую, если будут замечания и ошибки, обязательно пишите-исправлю. Надеюсь, он будет кому-то полезен и кому-нибудь стало понятнее, что там происходит с join
и как его использовать.
У меня elasticsearch:7.10.2
, в версиях выше могут немного отличаться запросы и возможно ответы, но настройки полей join
должны быть идентичны.
У кого еще есть любой опыт в выделении родительских-дочерних документов в индексе оставляйте комментарии и советы по улучшению.
grossws
Скажите, пожалуйста, а зачем вы в elastic запихиваете данные которые хорошо ложатся в реляционную модель? Или это просто настолько плохой пример? Я бы понял вариант запихивания в него/Apache Solr/Apache Lucene иерархической структуры при нормальном использовании FTS, point'ов и т.п. с дальнейшими структурными запросами с использованием
ToParentBlockJoinQuery
/ToChildBlockJoinQuery
, но этот вариант выглядит как странное аморфное нечто..Вообще современная тенденция пытаться использовать ES как РСУБД несколько удивляет, и не в хорошем смысле. Почему не использовать реляционную базу для данных и ES/Solr для поиска?
Menni Автор
Извините, но данный пример и структуры, и запросов очень сильно урезан, для того, чтобы стать максимально простым и понятным, и помочь продемонстрировать использование join field type и только лишь. Но, спасибо за замечание, действительно, во многих случаях в этом нет необходимости и есть масса других интересных и более выгодных решений.
Про современные тенденции некорректных попыток использования, к сожалению, не могу прокомментировать, не владею такой информацией