Engenharia

Introdução aos campos de tempo de execução, a implementação do esquema na leitura da Elastic

Historicamente, o Elasticsearch sempre dependeu de uma abordagem de esquema na gravação para tornar a busca de dados rápida. Agora estamos adicionando recursos de esquema na leitura ao Elasticsearch para que os usuários tenham a flexibilidade de alterar o esquema de um documento após a ingestão e também gerar campos que existem apenas como parte da consulta de busca. Juntos, o esquema na leitura e o esquema na gravação fornecem aos usuários a opção de equilibrar desempenho e flexibilidade com base em suas necessidades.

Nossa solução para o esquema na leitura são os campos de tempo de execução, que são avaliados apenas no momento da consulta. Eles são definidos no mapeamento de índice ou na consulta e, uma vez definidos, ficam imediatamente disponíveis para solicitações de busca, agregações, filtragem e classificação. Como os campos de tempo de execução não são indexados, adicionar um campo de tempo de execução não aumenta o tamanho do índice. Eles podem, na verdade, reduzir os custos de armazenamento e aumentar a velocidade de ingestão.

No entanto, existem compensações a considerar. As consultas em campos de tempo de execução podem ser caras; portanto, os dados que você normalmente busca ou filtra ainda devem ser mapeados para campos indexados. Os campos de tempo de execução também podem diminuir a velocidade da busca, embora o tamanho do índice seja menor. Recomendamos o uso de campos de tempo de execução em conjunto com campos indexados para encontrar o equilíbrio certo entre velocidade de ingestão, tamanho do índice, flexibilidade e desempenho da busca para seus casos de uso.

É fácil adicionar campos de tempo de execução

A maneira mais fácil de definir um campo de tempo de execução é na consulta. Por exemplo, se tivermos o seguinte índice:

 PUT my_index
 {
   "mappings": {
     "properties": {
       "address": {
         "type": "ip"},
       "port": {
         "type": "long"
       }
     }
   } 
 }

E carregarmos alguns documentos nele:

 POST my_index/_bulk
 {"index":{"_id":"1"}}
 {"address":"1.2.3.4","port":"80"}
 {"index":{"_id":"2"}}
 {"address":"1.2.3.4","port":"8080"}
 {"index":{"_id":"3"}}
 {"address":"2.4.8.16","port":"80"}

Poderemos criar a concatenação de dois campos com uma string estática da seguinte maneira:

 GET my_index/_search
 {
   "runtime_mappings": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     }
   },
   "fields": [
     "socket"
   ],
   "query": {
     "match": {
       "socket": "1.2.3.4:8080"
     }
   }
 }

Produzindo a seguinte resposta:

…
     "hits" : [
       {
         "_index" : "my_index",
         "_type" : "_doc",
         "_id" : "2",
         "_score" : 1.0,
         "_source" : {
           "address" : "1.2.3.4",
           "port" : "8080"
         },
         "fields" : {
           "socket" : [
             "1.2.3.4:8080"
           ]
         }
       }
     ]

Definimos o socket do campo na seção runtime_mappings. Usamos um pequeno script Painless que define como o valor do socket será calculado por documento (usando + para indicar a concatenação do valor do campo de endereço com a string estática ‘:’ e o valor do campo da porta). Em seguida, usamos o socket do campo na consulta. O socket do campo é um campo de tempo de execução efêmero que existe apenas para essa consulta e é calculado quando a consulta é executada. Ao definir um script Painless para usar com campos de tempo de execução, você deve incluir emit para retornar os valores calculados.

Se constatamos que o socket é um campo que queremos usar em várias consultas sem ter de defini-lo por consulta, podemos simplesmente adicioná-lo ao mapeamento fazendo a chamada:

 PUT my_index/_mapping
 {
   "runtime": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     } 
   } 
 }

Então, a consulta não precisa incluir a definição do campo; por exemplo:

 GET my_index/_search
 {
   "fields": [
     "socket"
  ],
   "query": {
     "match": {
       "socket": "1.2.3.4:8080"
     }
   }
 }

A instrução "fields": ["socket"] só será necessária se você quiser exibir o valor do campo do socket. Embora o socket do campo agora esteja disponível para qualquer consulta, ele não existe no índice e não aumenta o tamanho do índice. O socket é calculado apenas quando uma consulta o exige e para os documentos para os quais é necessário.

Consumido como qualquer campo

Como os campos de tempo de execução são expostos por meio da mesma API que os campos indexados, uma consulta pode se referir a alguns índices em que o campo é de tempo de execução e a outros em que ele é um campo indexado. Você tem a flexibilidade de escolher quais campos indexar e quais manter como campos de tempo de execução. Essa separação entre geração e consumo do campo facilita o trabalho, com um código mais organizado, mais fácil de criar e manter.

Você define os campos de tempo de execução no mapeamento de índice ou na solicitação de busca. Esse recurso inerente fornece flexibilidade na maneira como você usa campos de tempo de execução em conjunto com campos indexados. 

Substitua valores de campo no momento da consulta

Muitas vezes, você percebe erros nos seus dados de produção quando já é tarde demais.  Embora seja fácil corrigir as instruções de ingestão para documentos que você vai ingerir no futuro, é muito mais desafiador corrigir os dados que já foram ingeridos e indexados. Usando campos de tempo de execução, você pode corrigir erros nos seus dados indexados substituindo os valores no momento da consulta. Os campos de tempo de execução podem ocultar campos indexados com o mesmo nome para que você possa corrigir erros nos seus dados indexados.  

Aqui está um exemplo simples para tornar isso mais concreto. Digamos que temos um índice com um campo de mensagem e um campo de endereço:

 PUT my_raw_index 
{
  "mappings": {
    "properties": {
      "raw_message": {
        "type": "keyword"
      },
      "address": {
        "type": "ip"
      }
    }
  }
}

E vamos carregar um documento nele:

 POST my_raw_index/_doc/1
{
  "raw_message": "199.72.81.55 - - [01/Jul/1995:00:00:01 -0400] GET /history/apollo/ HTTP/1.0 200 6245",
  "address": "1.2.3.4"
}

Infelizmente, o documento contém um endereço IP incorreto no campo de endereço. O endereço IP correto existe na mensagem, mas de alguma forma o endereço errado foi analisado no documento que foi enviado para ser inserido no Elasticsearch e indexado. Para um único documento, isso não é um problema, mas e se descobrirmos depois de um mês que 10% dos nossos documentos contêm um endereço errado? Consertar isso para novos documentos não é um grande problema, mas reindexar os documentos que já foram ingeridos costuma ser operacionalmente complexo. Com os campos de tempo de execução, o problema pode ser corrigido imediatamente, ocultando o campo indexado com um campo de tempo de execução. Aqui está como você faria isso em uma consulta:

GET my_raw_index/_search
{
  "runtime_mappings": {
    "address": {
      "type": "ip",
      "script": "Matcher m = /\\d+\\.\\d+\\.\\d+\\.\\d+/.matcher(doc[\"raw_message\"].value);if (m.find()) emit(m.group());"
    }
  },
  "fields": [ 
    "address"
  ]
}

Você também pode fazer a alteração no mapeamento para que fique disponível para todas as consultas. Observe que o uso de regex agora está habilitado por padrão por meio de script Painless.

Equilibre desempenho e flexibilidade

Com os campos indexados, você faz todas as preparações durante a ingestão e mantém as estruturas de dados sofisticadas para fornecer um desempenho ideal. Mas consultar campos de tempo de execução é mais lento do que consultar campos indexados. E se as suas consultas ficarem lentas depois que você começar a usar campos de tempo de execução?

Recomendamos o uso de busca assíncrona ao recuperar um campo de tempo de execução. O conjunto de resultados completo é retornado exatamente como em uma busca síncrona, desde que a consulta seja concluída dentro de um determinado limite de tempo. No entanto, mesmo se a consulta não for concluída nesse tempo, você ainda obterá um conjunto de resultados parcial, e o Elasticsearch continuará a buscar até que o conjunto de resultados completo seja retornado. Esse mecanismo é particularmente útil ao gerenciar um ciclo de vida de índice porque os resultados mais recentes geralmente retornam primeiro e também costumam ser mais importantes para os usuários.

Para fornecer um desempenho ideal, contamos com os campos indexados para fazer o trabalho pesado da consulta, de modo que os valores dos campos de tempo de execução sejam calculados apenas para um subconjunto dos documentos.

Mudar um campo de tempo de execução para indexado

Os campos de tempo de execução permitem que os usuários alterem com flexibilidade seu mapeamento e análise enquanto trabalham nos dados em um ambiente ativo. Como um campo de tempo de execução não consome recursos, e o script que o define pode ser alterado, os usuários podem fazer experimentos até atingir o mapeamento ideal. Quando um campo de tempo de execução é considerado útil no longo prazo, é possível pré-calcular seu valor no momento do índice simplesmente definindo esse campo no modelo como um campo indexado e garantindo que o documento ingerido o inclua. O campo será indexado a partir do próximo rollover de índice e fornecerá melhor desempenho. As consultas que usam o campo não precisam ser alteradas. 

Esse cenário é particularmente útil com mapeamento dinâmico. Por um lado, é muito útil permitir que novos documentos gerem novos campos, porque dessa forma os dados neles podem ser usados imediatamente (a estrutura das entradas muda frequentemente, por exemplo, devido a uma alteração no software que gera o log). Por outro, o mapeamento dinâmico vem com o risco de sobrecarregar o índice e até mesmo criar uma explosão de mapeamento, porque nunca se sabe se algum documento pode surpreender você com dois mil novos campos. Os campos de tempo de execução podem fornecer uma solução para esse cenário. Os novos campos podem ser criados automaticamente como campos de tempo de execução para não sobrecarregar o índice (uma vez que não existem no índice) e não são contados no index.mapping.total_fields.limit. Esses campos de tempo de execução criados automaticamente são consultáveis, embora com desempenho inferior, para que os usuários possam usá-los e, se necessário, decidir mudá-los para campos indexados no próximo rollover.   

Recomendamos o uso de campos de tempo de execução inicialmente para fazer experimentos com a sua estrutura de dados. Depois de trabalhar com seus dados, você pode decidir indexar um campo de tempo de execução para ter um melhor desempenho de busca. Você pode criar um novo índice e, em seguida, adicionar a definição do campo ao mapeamento do índice, adicionar o campo a _source e se certificar de que o novo campo seja incluído nos documentos ingeridos. Se estiver usando fluxos de dados, você poderá atualizar o seu modelo de índice para que, quando os índices forem criados a partir desse modelo, o Elasticsearch saiba como indexar esse campo. Em uma versão futura, planejamos tornar o processo de mudança de um campo de tempo de execução para um campo indexado tão simples quanto mover o campo da seção de tempo de execução do mapeamento para a seção de propriedades. 

A solicitação a seguir cria um mapeamento de índice simples com um campo de registro de data/hora. A inclusão de "dynamic": "runtime" instrui o Elasticsearch a criar campos adicionais dinamicamente nesse índice como campos de tempo de execução. Se um campo de tempo de execução incluir um script Painless, o valor do campo será calculado com base nesse script. Se um campo de tempo de execução for criado sem um script, conforme mostrado na solicitação a seguir, o sistema procurará um campo em _source que tenha o mesmo nome do campo de tempo de execução e usará seu valor como valor do campo de tempo de execução.

PUT my_index-1
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

Vamos indexar um documento para ver as vantagens dessas configurações:

POST my_index-1/_doc/1
{
  "timestamp": "2021-01-01",
  "message": "my message",
  "voltage": "12"
}

Agora que temos um campo de registro de data/hora indexado e dois campos de tempo de execução (mensagem e tensão), podemos ver o mapeamento do índice:

GET my_index-1/_mapping

A seção de tempo de execução inclui mensagem e tensão. Esses campos não são indexados, mas ainda podemos consultá-los exatamente como se fossem campos indexados.

{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "keyword"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}

Criaremos uma solicitação de busca simples que consulta o campo de mensagem:

GET my_index-1/_search
{
  "query": {
    "match": {
      "message": "my message"
    }
  }
}

A resposta inclui os seguintes acertos:

... 
"hits" : [
      {
        "_index" : "my_index-1", 
        "_type" : "_doc", 
        "_id" : "1", 
        "_score" : 1.0, 
        "_source" : { 
          "timestamp" : "2021-01-01", 
          "message" : "my message", 
          "voltage" : "12" 
        } 
      } 
    ]
…

Olhando para essa resposta, notamos um problema: não especificamos que a tensão é um número! Como a tensão é um campo de tempo de execução, isso é fácil de corrigir, atualizando a definição do campo na seção de tempo de execução do mapeamento:

PUT my_index-1/_mapping
{
  "runtime":{
    "voltage":{
      "type": "long"
    }
  }
}

A solicitação anterior altera a tensão para um tipo "long", que tem efeito imediato para documentos que já foram indexados. Para testar esse comportamento, construímos uma consulta simples para todos os documentos com uma tensão entre 11 e 13:

GET my_index-1/_search
{
  "query": {
    "range": {
      "voltage": {
        "gt": 11,
        "lt": 13
      }
    }
  }
}

Como nossa tensão era 12, a consulta retorna nosso documento em my_index-1. Se visualizarmos o mapeamento novamente, veremos que a tensão agora é um campo de tempo de execução de tipo "long", mesmo para documentos que foram ingeridos no Elasticsearch antes de atualizarmos o tipo de campo no mapeamento:

...
{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "long"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}
…

Posteriormente, poderemos decidir que a tensão é útil em agregações e vamos querer indexá-la no próximo índice criado em um fluxo de dados. Criamos um novo índice (my_index-2) que corresponde ao modelo de índice para o fluxo de dados e definimos a tensão como um inteiro, sabendo qual tipo de dados queremos após fazer experimentos com os campos de tempo de execução.

O ideal seria atualizar o próprio modelo de índice para que as alterações entrassem em vigor no próximo rollover. Você pode executar consultas no campo de tensão em qualquer índice que corresponda ao padrão my_index*, mesmo que o campo seja um campo de tempo de execução em um índice e um campo indexado em outro.

PUT my_index-2
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      },
      "voltage":
      {
        "type": "integer"
      }
    }
  }
}

Com os campos de tempo de execução, introduzimos, portanto, um novo fluxo de trabalho de ciclo de vida do campo. Nesse fluxo de trabalho, um campo pode ser gerado automaticamente como um campo de tempo de execução sem afetar o consumo de recursos e sem correr o risco de explosão do mapeamento, permitindo que os usuários comecem a trabalhar imediatamente com os dados. O mapeamento do campo pode ser refinado em dados reais enquanto ainda é um campo de tempo de execução e, devido à flexibilidade dos campos de tempo de execução, as alterações têm efeito em documentos que já foram ingeridos no Elasticsearch. Quando está claro que o campo é útil, o modelo pode ser alterado para que nos índices que serão criados a partir desse ponto (após o próximo rollover), o campo seja indexado para desempenho ideal.

Resumo

Para a grande maioria dos casos, e em particular, se você conhece seus dados e o que deseja fazer com eles, os campos indexados são o caminho a percorrer devido à sua vantagem no desempenho. Por outro lado, quando há necessidade de flexibilidade na análise do documento e na estrutura do esquema, os campos de tempo de execução agora dão a resposta.

Os campos de tempo de execução e os campos indexados são recursos complementares — eles formam uma simbiose. Os campos de tempo de execução oferecem flexibilidade, mas nunca seriam capazes de ter um bom desempenho em um ambiente de alta escala sem o suporte do índice. A estrutura poderosa e rígida do índice fornece um ambiente protegido no qual a flexibilidade dos campos de tempo de execução pode revelar todas as suas vantagens, de uma forma não muito diferente de como as algas encontram abrigo em um coral. Todos se beneficiam dessa simbiose.

Comece hoje mesmo

Para começar a trabalhar com campos de tempo de execução, prepare um cluster no Elasticsearch Service ou instale a versão mais recente do Elastic Stack. Já tem o Elasticsearch em execução? Basta atualizar seus clusters para a versão 7.11 e experimentar. Para saber mais sobre campos de tempo de execução e seus benefícios, leia o post do blog Runtime fields: Schema on read for Elastic (Campos de tempo de execução: esquema na leitura para a Elastic. Além disso, também gravamos quatro vídeos para ajudar você a começar a usar os campos de tempo de execução.