
Как-то раз, мне попалась интересная задача: выделить общую часть информации из нескольких документов, находящегося в 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 должны быть идентичны.
У кого еще есть любой опыт в выделении родительских-дочерних документов в индексе оставляйте комментарии и советы по улучшению.
ссылка на оригинал статьи https://habr.com/ru/post/702134/
Добавить комментарий