Как-то раз, мне попалась интересная задача: выделить общую часть информации из нескольких документов, находящегося в 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 должны быть идентичны.

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

Комментарии (2)


  1. grossws
    29.11.2022 03:13
    +1

    Скажите, пожалуйста, а зачем вы в elastic запихиваете данные которые хорошо ложатся в реляционную модель? Или это просто настолько плохой пример? Я бы понял вариант запихивания в него/Apache Solr/Apache Lucene иерархической структуры при нормальном использовании FTS, point'ов и т.п. с дальнейшими структурными запросами с использованием ToParentBlockJoinQuery/ToChildBlockJoinQuery, но этот вариант выглядит как странное аморфное нечто..

    Вообще современная тенденция пытаться использовать ES как РСУБД несколько удивляет, и не в хорошем смысле. Почему не использовать реляционную базу для данных и ES/Solr для поиска?


    1. Menni Автор
      29.11.2022 17:22

      Извините, но данный пример и структуры, и запросов очень сильно урезан, для того, чтобы стать максимально простым и понятным, и помочь продемонстрировать использование join field type и только лишь. Но, спасибо за замечание, действительно, во многих случаях в этом нет необходимости и есть масса других интересных и более выгодных решений.
      Про современные тенденции некорректных попыток использования, к сожалению, не могу прокомментировать, не владею такой информацией