Elasticsearch: Relacionamentos não são tão complicados!

William Queiroz
CollabCode
Published in
9 min readJun 6, 2018

Fala galera! Este é o meu primeiro artigo sobre uma tecnologia que eu amo tanto e estava louco para escrever algo sobre. Passei dias pensando no que eu queria abordar que seria relevante para a comunidade e os desenvolvedores que queiram ter contato (ou já tenham) com o Elasticsearch e não fazem ideia de como manter seus dados consistentes e relacionados. Eis que tive a experiência de lidar com dados no seu modelo relacional para um não relacional, e PUF! tá aí um assunto muito interessante! No texto, falarei como utilizar os conceitos do Elasticsearch para trabalhar com os relacionamentos de documentos, seja em diferentes indexes ou desnormalizados num mesmo documento.

NOTA: não entrarei em detalhes sobre os conceitos básicos do Elasticsearch, como index, type, document, shards, nodes e etc, se você ainda não os conhece, recomendo ler diretamente na documentação do Elasticsearch aqui.

Paradigma Não-Relacional

Bom, se você está aqui, vou assumir que você já tenha esse conceito bem definido, e que saiba que o Elasticsearch trabalha com esse modelo. De qualquer maneira, um banco de dados não-relacional (ou NoSQL) foge do padrão tradicional (transacional) pois possui uma forma de armazenamento diferente de um banco relacional, surgiu com o propósito de oferecer performance e escalabilidade, não há necessidade de ter um esquema definido para se utilizá-lo pois os dados serão armazenados na maneira que forem fornecidos, sejam eles agrupados ou não. Veja mais sobre aqui.

Relacionamentos de dados no Elasticsearch

Uma das principais razões por eu gostar tanto do Elasticsearch e da elastic.co, seria pela sua qualidade nas documentações. Na página oficial do Elasticsearch há um guia completo de como lidar com os relacionamentos dos seus dados que você pode encontrá-lo aqui.

No guia, temos as principais sugestões para fazer a junção dos dados:

1 - Joins do lado da aplicação
2 - Desnormalização dos dados
3 - Objetos aninhados
4 - Relações pai/filho

Falarei sobre cada um delas. No mundo real, a manutenibilidade dos seus dados será a junção de algumas dessas opções.

Preparando o ambiente

Para conseguirmos visualizar melhor as diferentes abordagens, você precisará instalar:

1- O Docker, para abstrair a configuração manual do Elasticsearch.

CALMA! Não é necessária nenhuma experiência com ele, afinal, utilizaremos somente o comando abaixo para termos um instância local do Elasticsearch já configurado.

Quando finalizar a instalação, execute o seguinte comando:

Ao finalizar as configurações, o docker deixará uma instância local do Elasticsearch rodando em http://localhost:9200.

2 - O Postman, para executarmos as consultas e a indexação dos nossos dados utilizados nos exemplos.

1 - Joins do lado da aplicação

No Elasticsearch, é possível emular os join’s do lado da aplicação. E o que seria isso? Bom, vamos ver a seguir:

Imagine que temos alguns usuários que realizam suas postagens regularmente em um blog. Num banco relacional seria assim:

Execute as requisições no postman, informando o método, a url, e o corpo (JSON), não esqueça de definir em Body > raw > application/json:

PUT http://localhost:9200/users/_doc/1

Indexando o usuário “John Watson”

POST http://localhost:9200/posts/_doc

Indexando o post do usuário “John Watson”

Então, para obtermos todos os posts do usuário “John Watson”, com o ID, seria bem simples, com a seguinte consulta:

POST http://localhost:9200/posts/_search

No entanto, para procuramos todos os posts dos usuários chamados “John”, teríamos que primeiro buscar os ID’s desses usuários, e incluí-los numa segunda consulta:

POST http://localhost:9200/users/_search

Buscando por usuários chamados “John”

POST http://localhost:9200/posts/_search

Incluindo os IDs dos usuários numa outra consulta, na propriedade “user” poderia conter outros IDs encontrados na pesquisa anterior

A vantagem dessa abordagem seria que, os dados permanecem normalizados (cada um no seu quadrado) onde, por exemplo, o dado de usuário pode e DEVE ser alterado em um único lugar: no index user . A desvantagem seria que você deve precisa realizar consultas extras para ter seus dados unidos em tempo de pesquisa.
Nesse exemplo temos um único usuário chamado John, no mundo real com certeza teríamos mais ocorrências desse nome, incluir os ID’s de MILHÕES de usuários na segunda consulta, gera uma pesquisa com muitos termos para ser executada e analisada pela engine do Elasticsearch, o que resulta também numa perda considerada de performance.
Essa abordagem é ideal quando, a primeira entidade (no nosso caso user ) contém um número menor de documentos e eles raramente mudam. Isso porque o Elasticsearch, dependendo da query utilizada, cacheia e armazena os dados filtrados, evitando que a primeira consulta seja feita com mais frequência.

2 - Desnormalização dos dados

Para obtermos o melhor desempenho do Elasticsearch, a desnormalização dos dados em tempo de indexação é um ponto chave para uma melhor performance da engine. Já vou iniciar contando que a vantagem dessa abordagem seria não ter a necessidade de juntar os dados, uma vez que eles estão divididos em documentos, com cópias redundantes em cada um, logo, com apenas poucas consultas, você já irá obter seus dados prontinhos para serem pesquisados ou analisados. A desvantagem se deve a justamente a esse fato, pois como os dados estão “espalhados” em cada documento, há uma preocupação maior em mantê-los atualizados e persistentes, o que, dependendo da sua implementação, pode lhe causar uma leve dor de cabeça.

Veja mais sobre o conceito de desnormalização aqui

Antes de iniciar, exclua os indexes criados anteriormente com:
DELETE http://localhost:9200/users
DELETE http://localhost:9200/posts

Vamos a implementação dessa abordagem!

Primeiro, insira o nosso usuário chamado “John”:

POST http://localhost:9200/users/_doc/1

Indexando o usuário “John Watson”

Agora indexe o post desse usuário:

POST http://localhost:9200/posts/_doc

Exemplo de dados desnormalizados, o usuário com o ID 1, foi indexado dentro do documento “posts”

Então, para obtermos os posts que em seu título contenha “post 1” e sejam de usuários com o nome “John” seria necessário uma única query:

Consulta para obter os posts com determinado título publicados por um determinado usuário, no exemplo, “John”

É importante observar que nem todos os dados do usuário foram indexados junto com aquele documento “posts”, pois estamos pesquisando apenas pelo nome do usuário, porém, é possível pesquisarmos pelo ID e também pelo e-mail, isto é, para nós o created_atdo usuário nesse contexto não é relevante. Indexe apenas os dados que forem REALMENTE utilizados para sua pesquisa!

3 - Objetos aninhados

Bem, até aqui vimos que é possível indexar documentos num mesmo documento (utilizando a desnormalização de dados). Dado que o Elasticsearch trabalha com o modelo ACID em entidades únicas, é perfeitamente possível (e adequado, nesse contexto) armazenarmos entidades que se relacionam com o mesmo documento. Por exemplo, se quisermos indexar todos os comentários relacionados a um post, passamos um array de objetos para uma propriedade chamada comments:

PUT http://localhost:9200/posts/_doc

Então, com esse modo de indexação, não é necessário realizar nenhum “join” complexo para buscar, por exemplo, todos os posts que tiveram um comentário de alguém chamado Alice e que tenha 31 anos.
Os problemas que podem ocorrer nesse caso, é que, numa consulta como essa abaixo, o documento corresponderia com os critérios, mesmo não contendo os dados informados na query:

POST http://localhost:9200/posts/_search

Alice, no documento que indexamos, tem 31 anos e não 28!

O motivo dessa query trazer esse documento, é por conta do modo em que o Elasticsearch armazena a nossa matriz de objetos. Quando apenas enviamos a matriz (ou um objeto) numa propriedade ( comments ), esse dado é salvo como o tipo simples object, que é útil para guardar um único objeto. Já quando queremos indexar mais de um objeto, para a pesquisa, esse tipo torna os dados inúteis. Isto é pela maneira em que o Elasticsearch nivela os dados para a indexação “chave e valor”. O nosso JSON enviado, internamente, fica parecido com isso:

Perceba que a correlação de “John” e “Alice” foi perdida. Para contornar esse problema, para esse formato, é ideal utilizarmos o tipo nested para a propriedade comments antes de indexarmos os dados. Seria algo como:

Imagem capturada de: https://www.elastic.co/guide/en/elasticsearch/guide/current/nested-mapping.html

Com o tipo nested mapeado, os nossos dados depois de indexados, internamente, ficam armazenados da seguinte maneira:

Sendo possível então, manter cada objeto “pesquisável” e separado, o objeto se comporta de fato como um documento.

É importante observar que:

  • O tipo nested indexa cada objeto aninhado separadamente (mas ainda assim, o relacionamento dos objetos aninhados com o documento raiz é mantido).
  • É possível executar consultas em objetos aninhados que correspondam a um único documento.
  • Devido a indexação dos objetos aninhados, a junção desses objetos ao documento raiz no momento da consulta é extremamente rápida.
  • Na indexação de objetos aninhados, o ES cria objetos ocultos que não podem ser acessados diretamente.
  • Ao realizar qualquer transação num documento que contenha um ou mais objetos aninhados é necessário indexar todo o documento (existe uma maneira que eu acabei descobrindo para contornar isso, utilizando a junção de Script Query (com a linguagem de script Painless, que roda por trás da stack) e a API Update By Query.
  • A pesquisa num objeto aninhado retorna todo o documento e não só o objeto aninhado.

4 - Relações pai/filho

Este é o último modelo para representarmos os relacionamentos dos nossos dados. Este é bem parecido com a abordagem nested objects que vimos no tópico anterior, porém, a principal diferença (e talvez a melhor) é que, com os objetos aninhados, para manter o relacionamento, os documentos vivem dentro daqueles documentos no qual eles se relacionam, enquanto na abordagem parent/child são documentos “completamente” separados.

Veja que eu disse “completamente” separados. Pois se o documento A se relaciona com o documento B, para essa abordagem funcionar, é necessário que os dois documentos existam no mesmo index!

Com essa abordagem, é possível mapear documentos que se relacionam 1:N, ou sejam, um pai para muitos filhos.

Principais vantagens de parent/child comparado com nested objects:

  • O documento pai pode ser atualizado sem ser necessário indexar os filhos.
  • Outros documentos podem ser adicionados, alterados ou excluídos sem afetar o pai ou outros filhos. Isso é perfeito quando os documentos filhos são numerosos e precisam ser adicionados ou alterados com frequência.
  • Os documentos filhos podem ser retornados como os resultados de uma pesquisa.

Implentando!

O ponto fundamental, e essencial para essa abordagem funcionar, é mapearmos os dados antes de indexá-los.
Na versão 6.x (que é a que estamos usando nos exemplos) para possibilitar a junção dos documentos, temos um data type chamado join e é com ele que vamos mapear o nosso index.

Veja mais detalhes sobre esse data type aqui.

1 - Vamos começar mapeando o nosso index my_blog dessa maneira:

PUT http://localhost:9200/my_blog

2 - Em seguida, vamos indexar alguns usuários (no nosso exemplo, “users” são os documentos “pai” de “posts”):

POST http://localhost:9200/my_blog/_doc/1?refresh

POST http://localhost:9200/my_blog/_doc/2?refresh

3 - Agora vamos indexar os documentos filhos “posts” especificando na sua criação qual é o documento pai:

POST http://localhost:9200/my_blog/_doc?routing=1&refresh

O routing=1 é o roteador para o documento pai, ou seja, o usuário com o ID 1,
o routing também é OBRIGATÓRIO, pois os documentos “users” e “posts” são indexados no mesmo fragmento/index. Veja mais como funciona o campo routing aqui.

POST http://localhost:9200/my_blog/_doc?routing=1&refresh

Por fim, indexaremos um post do usuário “John Lenon”

POST http://localhost:9200/my_blog/_doc?routing=2&refresh

PRONTO! Os nossos documentos já estão indexados e prontos para a pesquisa. Podemos então construir consultas utilizando has_parent e has_child para mais informações, acesse os links disponibilizados.
O campo com o tipo join mapeado pode ser acessado de diversas maneiras, como algumas dessas abaixo:

Exemplo extraído de: https://www.elastic.co/guide/en/elasticsearch/reference/master/parent-join.html#_parent_join_queries_and_aggregations

Meu foco agora é realizar algumas consultas simples com essa abordagem, caso necessite de algo mais complexo, sugiro que dedique um tempo lendo a documentação ou me encaminhe a sua dúvida, tentarei ajudar como puder :D

Let’s BORA!:

Digamos que, precisamos obter todos os posts indexados ou todos os posts de usuários com o sobrenome “watson”.
Isso é possível com as consultas:

Restrições no uso de parent/child

Esse é um do pontos que é importante saber antes de aderir a essa abordagem, que são algumas restrições que existem no modelo:

  • Apenas um campo do tipo join é permitido por índice.
  • Documentos pai e filho devem ser indexados no mesmo fragmento. Isso significa que o valor de routing precisa ser fornecido ao obter , excluir ou atualizar um documento filho.
  • Um documento pode ter vários filhos, mas apenas um pai.
  • É possível adicionar uma nova relação a um campojoin existente.
  • Também é possível adicionar um filho a um documento existente, mas apenas se o documento já for um pai.

Performance de parent/child

Outro ponto importante, é a performance nessa abordagem. Eu, sinceramente, não tive a oportunidade de testar a performance com muitos documentos indexados, porém, vale a pena levar em consideração que a galera do Elasticsearch sempre recomenda desnormalizar os dados, isso porque a stack trabalha melhor assim. Também recomendam o uso de parent/child “se os dados contiverem um relacionamento 1:N (um-para-muitos), em que uma entidade ultrapassa significativamente a outra entidade”. Então o meu conselho quanto a isso é: teste. Experimente modelar seus dados em cada uma das maneiras que vimos, cabe a você decidir qual será a melhor.

Finalizando…

Então é isso! Chegamos ao fim do meu primeiro artigo/post aqui no medium. E eu quero agradecer a você que chegou até aqui, e queria lhe pedir também para encaminhar-me as suas dúvidas, comentários, críticas, correções ou sugestões sobre a postagem.

Gostei muito de escrever sobre algo que eu gosto tanto 💚. Muito obrigado e até a próxima! :D

--

--