Родители и дети. Связываем документы в Elasticsearch

от автора

Как-то раз, мне попалась интересная задача: выделить общую часть информации из нескольких документов, находящегося в Elasticsearch, в отдельный «фрагмент» с целью ее независимого и частого обновления по типу отношения «один ко многим». В данной статье я расскажу вам про join field type.

О том как это сделать в просторах интернета довольно мало информации, но, скорее всего, я понимаю почему.

Причины так не делать

  1. Кажется, что это довольно неэффективно, так как выделение части документа во фрагмент снизит скорость ваших запросов. Гораздо выгоднее дублировать повторяющиеся части в документах. Вообще, денормализация в Elasticsearch — это путь к хорошей производительности. Так сказала документация

  2. Любые документы с помощью join (который в данной статье и будет описан) могут ссылаться только на документы в этом же индексе.

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

Почему всё-таки это было внедрено

  1. Кажется, идея массового частого bulk update на пачку документов из-за смены статуса в кусочке документа, малость оверхэд

  2. В контексте использования в нашем проекте не требуется масштабирование, от слова совсем

  3. Скорость запросов с созданными отношениями удовлетворила наши потребности

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

Что было бы без выделения части в отдельный кусочек документа:

При таком построении структуры документов, после обновлении статуса, необходимо было бы обновить его во всех документах.

Кейс

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

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

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

Реализация

  1. Обновляем 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', которое мы, например, будем  использовать только в документах-фрагментах, и в нем будем хранить наш статус.

  1. Добавляем документ-фрагмент (родительский документ) в индекс. Пример:

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 указывался слева в определении связи между документами 

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

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 документа, но уже не родителя, а самого корня верхнего «родителя». Но в нашем случае, это тоже ссылка на «родителя» и то же потому, что у нашего родительского документа нет своих родителей. 

Родительский документ может быть только один, дочерних у «родителя» может быть много. Дочерний документ также может иметь и свои дочерние документы, если есть в этом необходимость. Но рекомендовано так не делать.

Это поле обязательное, так как родительские и дочерние (внучатые) документы должны быть проиндексированы на одном шарде, так сказала документация. И это поле как раз помогает определить что должно находиться на одном шарде.

  1. Изменение/удаление документа с заказом (документа имеющего родителя)

Если при создании документа был установлен у документа 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',                 ],             ],         ],     ], ]);
  1. Запрашиваем заказы

Для примера, я выполню запрос одного заказа с нашим новым 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *