Campos demais! Três maneiras de evitar a explosão de mapeamentos no Elasticsearch

blog-thumb-website-search.png

Diz-se que um sistema é “observável” quando tem três coisas: logs, métricas e traces. Embora as métricas e os traces tenham estruturas previsíveis, os logs (especialmente os logs de aplicações) geralmente são dados não estruturados que precisam ser coletados e analisados para serem realmente úteis. Portanto, manter seus logs sob controle é, sem dúvida, a parte mais difícil para se alcançar a observabilidade. 

Neste artigo, veremos três estratégias eficazes que os desenvolvedores podem usar para gerenciar logs com o Elasticsearch. Para uma visão ainda mais detalhada, confira o vídeo abaixo.

Video thumbnail

[Artigo relacionado: Utilização da Elastic para melhorar o gerenciamento de dados e a observabilidade na nuvem]

Colocando o Elasticsearch para trabalhar para seus dados

Às vezes, não temos controle sobre quais tipos de logs recebemos em nosso cluster. Pense em um provedor de analítica de logs que tem um orçamento específico para armazenar os logs de seus clientes e precisa manter o armazenamento sob controle (a Consultoria da Elastic lida com muitos casos como esse). 

Na maioria das vezes, os clientes indexam campos “por via das dúvidas”, caso precisem ser usados para busca. Se esse é o seu caso, as técnicas a seguir podem ser valiosas para ajudar a reduzir custos e usar o desempenho do cluster para o que realmente importa.

Vamos primeiro delinear o problema. Considere o seguinte documento JSON com três campos: message, transaction.user e transaction.amount:

{
 "message": "2023-06-01T01:02:03.000Z|TT|Bob|3.14|hello",
 "transaction": {
   "user": "bob",
   "amount": 3.14
 }
}

O mapeamento para um índice que conterá documentos como esses pode ser algo como o seguinte:

PUT dynamic-mapping-test
{
 "mappings": {
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

No entanto, o Elasticsearch nos permite indexar novos campos sem precisar necessariamente especificar um mapeamento de antemão, e isso é parte do que torna o Elasticsearch tão fácil de usar: podemos integrar novos dados facilmente. Portanto, não há problema em indexar algo que se desvie do mapeamento original, como:

POST dynamic-mapping-test/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field with arbitrary data"
 }
}

Um GET dynamic-mapping-test/_mapping nos mostrará o novo mapeamento resultante para o índice. Ele agora tem transaction.field3 como text e keyword — na verdade, dois novos campos.

{
 "dynamic-mapping-test" : {
   "mappings" : {
     "properties" : {
       "transaction" : {
         "properties" : {
           "user" : {
             "type" : "keyword"
           },
           "amount" : {
             "type" : "long"
           },
           "field3" : {
             "type" : "text",
             "fields" : {
               "keyword" : {
                 "type" : "keyword",
                 "ignore_above" : 256
               }
             }
           }
         }
       },
       "message" : {
         "type" : "text"
       }
     }
   }
 }
}

Ótimo, mas isso agora é parte do problema: quando não temos nenhum controle sobre o que está sendo enviado para o Elasticsearch, podemos nos deparar com um problema chamado explosão de mapeamentos. Nada impede que você crie subcampos e subsubcampos, que terão os mesmos dois tipos text e keyword, como:

POST dynamic-mapping-test/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field",
   "field4": {
     "sub_user": "a sub field",
     "sub_amount": "another sub field",
     "sub_field3": "yet another subfield",
     "sub_field4": "yet another subfield",
     "sub_field5": "yet another subfield",
     "sub_field6": "yet another subfield",
     "sub_field7": "yet another subfield",
     "sub_field8": "yet another subfield",
     "sub_field9": "yet another subfield"
   }
 }
}

Estaríamos desperdiçando RAM e espaço em disco para armazenar esses campos, pois estruturas de dados serão criadas para torná-los buscáveis e agregáveis. Pode ser que esses campos nunca sejam usados — eles estão lá “por via das dúvidas”, caso precisem ser usados para busca. 

Um dos primeiros passos que damos na consultoria quando nos pedem para otimizar um índice é inspecionar o uso de cada campo em um índice para ver quais são realmente buscados e quais estão apenas desperdiçando recursos.

Estratégia nº 1: ser rigoroso (strict)

Se quisermos ter controle total sobre a estrutura dos logs que armazenamos no Elasticsearch e como os armazenamos, podemos estabelecer uma definição de mapeamento clara para que aquilo que se desvie do que queremos simplesmente não seja armazenado. 

Ao usar dynamic: strict no nível superior ou em algum subcampo, rejeitamos documentos que não correspondem ao que está em nossa definição de mappings, forçando o remetente a cumprir o mapeamento predefinido:

PUT dynamic-mapping-test
{
 "mappings": {
   "dynamic": "strict",
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Então, quando tentamos indexar nosso documento com um campo extra…

POST dynamic-mapping-test/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field"
   }
 }
}

… a resposta que recebemos é esta:

{
 "error" : {
   "root_cause" : [
     {
       "type" : "strict_dynamic_mapping_exception",
       "reason" : "mapping set to strict, dynamic introduction of [field3] within [transaction] is not allowed"
     }
   ],
   "type" : "strict_dynamic_mapping_exception",
   "reason" : "mapping set to strict, dynamic introduction of [field3] within [transaction] is not allowed"
 },
 "status" : 400
}

Se você tiver certeza absoluta de que quer apenas armazenar o que está nos mapeamentos, essa estratégia forçará o remetente a cumprir o mapeamento predefinido.

Estratégia nº 2: não muito rigoroso (strict)

Podemos ser um pouco mais flexíveis e deixar os documentos passarem, mesmo que não sejam exatamente como esperamos, usando "dynamic": "false".

PUT dynamic-mapping-disabled
{
 "mappings": {
   "dynamic": "false",
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Ao usar essa estratégia, aceitamos todos os documentos que chegam até nós, mas indexamos apenas os campos especificados no mapeamento, tornando os campos extras simplesmente não buscáveis. Em outras palavras, não estamos desperdiçando RAM nos novos campos, apenas espaço em disco. Os campos ainda podem ficar visíveis nos acertos (hits) de uma busca, e isso inclui uma agregação top_hits. No entanto, não podemos fazer buscas ou agregações neles, pois nenhuma estrutura de dados é criada para manter seu conteúdo.

Não precisa ser tudo ou nada — a raiz pode ser strict e podemos ter um subcampo para aceitar novos campos sem indexá-los. Nossa documentação Setting dynamic on inner objects (Definir a dinâmica em objetos internos) cobre isso muito bem.

PUT dynamic-mapping-disabled
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "message": {
        "type": "text"
      },
      "transaction": {
        "dynamic": "false",
        "properties": {
          "user": {
            "type": "keyword"
          },
          "amount": {
            "type": "long"
          }
        }
      }
    }
  }
}

Estratégia nº 3: campos de tempo de execução

O Elasticsearch é compatível com esquema na leitura e esquema na gravação, cada um com suas ressalvas. Com dynamic:runtime, os novos campos serão adicionados ao mapeamento como campos de tempo de execução. Indexamos os campos especificados no mapeamento e tornamos os campos extras buscáveis/agregáveis apenas no momento da consulta. Em outras palavras, não desperdiçamos RAM antecipadamente nos novos campos, mas pagamos o preço de uma resposta de consulta mais lenta, pois as estruturas de dados serão construídas em tempo de execução.

PUT dynamic-mapping-runtime
{
 "mappings": {
   "dynamic": "runtime",
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Vamos indexar nosso documento grande:

POST dynamic-mapping-runtime/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field",
   "field4": {
     "sub_user": "a sub field",
     "sub_amount": "another sub field",
     "sub_field3": "yet another subfield",
     "sub_field4": "yet another subfield",
     "sub_field5": "yet another subfield",
     "sub_field6": "yet another subfield",
     "sub_field7": "yet another subfield",
     "sub_field8": "yet another subfield",
     "sub_field9": "yet another subfield"
   }
 }
}

Um GET dynamic-mapping-runtime/_mapping mostrará que nosso mapeamento é alterado na indexação do nosso documento grande:

{
 "dynamic-mapping-runtime" : {
   "mappings" : {
     "dynamic" : "runtime",
     "runtime" : {
       "transaction.field3" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_amount" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field3" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field4" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field5" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field6" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field7" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field8" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field9" : {
         "type" : "keyword"
       }
     },
     "properties" : {
       "transaction" : {
         "properties" : {
           "user" : {
             "type" : "keyword"
           },
           "amount" : {
             "type" : "long"
           }
         }
       },
       "message" : {
         "type" : "text"
       }
     }
   }
 }
}

Agora é possível fazer buscas nos novos campos como um campo de palavra-chave normal. Observe que o tipo de dados é adivinhado na indexação do primeiro documento, mas isso também pode ser controlado usando modelos dinâmicos.

GET dynamic-mapping-runtime/_search
{
 "query": {
   "wildcard": {
     "transaction.field4.sub_field6": "yet*"
   }
 }
}

Resultado:

{
…
 "hits" : {
   "total" : {
     "value" : 1,
     "relation" : "eq"
   },
   "hits" : [
     {
       "_source" : {
         "message" : "hello",
         "transaction" : {
           "user" : "hey",
           "amount" : 3.14,
           "field3" : "hey there, new field",
           "field4" : {
             "sub_user" : "a sub field",
             "sub_amount" : "another sub field",
             "sub_field3" : "yet another subfield",
             "sub_field4" : "yet another subfield",
             "sub_field5" : "yet another subfield",
             "sub_field6" : "yet another subfield",
             "sub_field7" : "yet another subfield",
             "sub_field8" : "yet another subfield",
             "sub_field9" : "yet another subfield"
           }
         }
       }
     }
   ]
 }
}

Ótimo! É fácil ver como essa estratégia pode ser útil quando você não sabe que tipo de documento vai ingerir; então, usar campos de tempo de execução parece ser uma abordagem conservadora com uma boa compensação entre desempenho e complexidade de mapeamento.

Observação sobre o uso do Kibana e de campos de tempo de execução

Lembre-se de que, se não especificarmos um campo ao fazer buscas no Kibana usando sua barra de busca (por exemplo, simplesmente digitando "hello" em vez de "message: hello", essa busca corresponderá a todos os campos, e isso inclui todos os campos de tempo de execução que declaramos. Você provavelmente não quer esse comportamento; então, nosso índice deve usar a configuração dinâmica index.query.default_field. Defina como todos ou alguns dos nossos campos mapeados e deixe os campos de tempo de execução para serem consultados explicitamente (por exemplo, "transaction.field3: hey").

Nosso mapeamento atualizado finalmente seria:

PUT dynamic-mapping-runtime
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "message": {
        "type": "text"
      },
      "transaction": {
        "properties": {
          "user": {
            "type": "keyword"
          },
          "amount": {
            "type": "long"
          }
        }
      }
    }
  },
  "settings": {
    "index": {
      "query": {
        "default_field": [
          "message",
          "transaction.user"
        ]
      }
    }
  }
}

Escolhendo a melhor estratégia

Cada estratégia tem suas próprias vantagens e desvantagens; portanto, a melhor estratégia dependerá, em última análise, do seu caso de uso específico. Veja abaixo um resumo para ajudar você a fazer a escolha certa para suas necessidades:

Estratégia

Prós

Contras

Nº 1 — strict

Os documentos armazenados têm garantia de conformidade com o mapeamento

Os documentos serão rejeitados se tiverem campos não declarados no mapeamento

Nº 2 — dynamic: false

Os documentos armazenados podem ter qualquer número de campos, mas apenas os campos mapeados usarão recursos

Os campos não mapeados não podem ser usados para buscas ou agregações

Nº 3 — campos de tempo de execução

Todas as vantagens do nº 2

Os campos de tempo de execução podem ser usados no Kibana como qualquer outro campo

Resposta de busca relativamente mais lenta ao consultar os campos de tempo de execução

É na observabilidade que o Elastic Stack realmente brilha. Seja para armazenar com segurança transações financeiras de vários anos enquanto rastreiam os sistemas afetados ou ingerem vários terabytes de métricas de rede diárias, nossos clientes contam com observabilidade dez vezes mais rápida por uma fração do custo. 

Quer começar a usar o Elastic Observability? A melhor maneira é na nuvem. Inicie sua avaliação gratuita do Elastic Cloud hoje mesmo!