Technique

Premiers pas avec les champs d'exécution, l'implémentation Elastic du schéma de lecture

Elasticsearch s'est toujours appuyé sur un schéma d'écriture pour permettre des recherches de données efficaces. Aujourd'hui, nous innovons en ajoutant un schéma de lecture. Le but : donner aux utilisateurs la possibilité de modifier le schéma d'un document après l'ingestion, ainsi que de générer des champs qui n'existent que dans le cadre de la requête de recherche. En combinant le schéma de lecture et le schéma d'écriture, les utilisateurs bénéficient d'un atout de taille : ils peuvent déterminer le bon équilibre entre performances et flexibilité selon leurs besoins.

Pour le schéma de lecture, nous proposons une solution innovante : des champs d'exécution. Ceux-ci servent uniquement au moment de la requête. Pour les utiliser, iI suffit de les définir dans le mapping d'index ou dans la requête. Les utilisateurs pourront s'en servir par la suite dans les requêtes de recherche, les agrégations, le filtrage et le tri. Un point intéressant qu'il convient de noter : les champs d'exécution ne sont pas indexés. Aussi, la taille de l'index n'augmente pas lorsqu'un utilisateur ajoute un champ d'exécution. De ce fait, ces champs permettent de réduire les coûts de stockage et d'accélérer l'ingestion.

Mais il y a des contreparties. Les requêtes exécutées sur les champs d'exécution peuvent demander beaucoup de ressources. Aussi, les données que vous utilisez régulièrement pour vos recherches ou vos filtres devraient rester mappées à des champs indexés. Les champs d'exécution peuvent aussi ralentir la recherche, même si votre index est de petite envergure. Nous recommandons donc de combiner champs d'exécution et champs indexés pour trouver le bon équilibre entre vitesse d'ingestion, taille d'index, flexibilité et performances de recherche pour vos cas d'utilisation.

Ajoutez facilement des champs d'exécution

Pour définir un champ d'exécution, rien de plus simple : faites-le directement dans la requête. Supposons que nous ayons l'index suivant :

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

Et que nous y ajoutions quelques documents :

 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"}

Nous pouvons créer la concaténation de deux champs avec une chaîne statique comme suit :

 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"
     }
   }
 }

Ce qui devrait renvoyer la réponse suivante :

…
     "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"
           ]
         }
       }
     ]

Nous avons défini le socket du champ dans la section runtime_mappings. Nous avons utilisé un script Painless court qui détermine la façon dont la valeur du socket sera calculée par document (à l'aide du signe + pour indiquer la concaténation de la valeur du champ d'adresse avec la chaîne statique ":" et la valeur du champ de port). Nous avons ensuite utilisé le socket du champ dans la requête. Mais qu'est-ce que le socket du champ ? Il s'agit d'un champ d'exécution éphémère qui existe uniquement pour cette requête et qui est calculé au moment où la requête est exécutée. Lorsque vous définissez le script Painless à utiliser avec les champs d'exécution, vous devez inclure "emit" pour renvoyer les valeurs calculées.

S'il s'avère par la suite que nous voulons réutiliser le socket dans plusieurs requêtes sans avoir à le définir à chaque fois, nous pouvons simplement l'ajouter au mapping en lançant l'appel :

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

Ainsi, la requête n'a pas besoin d'inclure la définition du champ, par exemple :

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

L'instruction "fields": ["socket"] est requise uniquement si vous souhaitez afficher la valeur du socket. Même si le socket du champ est désormais disponible pour n'importe quelle requête, il n'existe pas dans l'index, et de ce fait, il n'en augmente pas la taille. Le socket est calculé uniquement pour les besoins d'une requête et pour les documents qui le nécessitent.

Un champ utilisé comme les autres

Étant donné que les champs d'exécution sont présentés via la même API que les champs indexés, une requête peut se référer à plusieurs index où le champ est un champ d'exécution, ainsi qu'à d'autres index où le champ est un champ indexé. À vous de choisir quels champs vous souhaitez indexer, et quels champs vous souhaitez conserver en tant que champs d'exécution. Cette distinction entre la génération de champs et l'utilisation de champs permet de mieux organiser le code, qui est ainsi plus facile à créer et à gérer.

Les champs d'exécution se définissent dans le mapping d'index ou dans la requête de recherche. Cette capacité inhérente vous donne le choix dans la façon dont vous voulez utiliser les champs d'exécution et les champs indexés. 

Remplacement des valeurs de champ au moment de la requête

Zut ! Vous venez découvrir une erreur dans vos données de production, mais il est déjà trop tard.  En général, il est simple de rectifier les instructions d'ingestion des prochains documents à ingérer. Mais pour ce qui est des données déjà ingérées et indexées, c'est une autre paire de manches. C'est là que les champs d'exécution entrent en jeu. Vous pouvez rectifier les erreurs que contiennent vos données indexées en remplaçant leurs valeurs au moment de la requête. Les champs d'exécution peuvent faire écran aux champs indexés du même nom afin que vous puissiez corriger les erreurs se trouvant dans vos données indexées.  

Pour illustrer ce propos, prenons un exemple simple. Supposons que nous disposions d'un index avec un champ de message et un champ d'adresse :

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

Chargeons-y un document :

 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"
}

Malheureusement, l'adresse IP apparaissant dans le champ d'adresse du document est erronée. L'adresse IP correcte se trouve dans le message. Or, par un concours de circonstances, c'est la mauvaise adresse qui a été transmise dans le document à ingérer dans Elasticsearch et à indexer. Ici, il s'agit d'un seul document, donc ce n'est pas bien grave. Mais imaginons que nous découvrions au bout d'un mois que 10 % de nos documents contiennent une mauvaise adresse. Ce serait tout de suite plus embêtant. Pour rectifier cette adresse dans les nouveaux documents, c'est assez simple. Mais si nous devons procéder à la réindexation des documents déjà ingérés, les choses deviennent plus compliquées sur le plan opérationnel. Avec l'aide des champs d'exécution, nous pouvons immédiatement rectifier l'erreur en cachant un champ indexé. Voici comment procéder avec une requête :

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"
  ]
}

Vous pouvez aussi effectuer le changement dans le mapping pour qu'il soit appliqué à l'ensemble des requêtes. Notez que l'utilisation des expressions régulières est désormais activée par défaut par l'intermédiaire d'un script Painless.

Équilibre entre performances et flexibilité

Avec les champs indexés, vous effectuez toutes les préparations lors de l'ingestion et vous gérez des structures de données sophistiquées pour délivrer des performances optimales. Notez par ailleurs qu'il faut plus de temps pour interroger des champs d'exécution que des champs indexés. Alors, que faire si vos requêtes sont lentes lorsque vous utilisez des champs d'exécution ?

Nous vous recommandons d'utiliser la recherche asynchrone. Un ensemble complet de résultats est renvoyé comme lorsque vous utilisez une recherche synchrone, sous réserve que la requête s'exécute dans un certain laps de temps. Et même si ce n'est pas le cas, vous obtiendrez tout de même un ensemble de résultats partiel et Elasticsearch poursuivra sa tâche jusqu'à obtenir l'ensemble de résultats complet. C'est un mécanisme particulièrement utile lors de la gestion du cycle de vie d'un index, car ce sont généralement les résultats les plus récents qui sont renvoyés en premier, et ce sont souvent ces résultats qui ont le plus d'importance aux yeux des utilisateurs.

Pour que les performances soient optimales, nous comptons sur les champs indexés pour faire le gros du travail, afin que les valeurs des champs d'exécution soient calculées uniquement pour un sous-ensemble de documents.

Conversion d'un champ d'exécution en champ indexé

Avec les champs d'exécution, les utilisateurs peuvent modifier leur mapping et réaliser leurs analyses tout en travaillant sur des données dans un environnement en direct. Étant donné qu'un champ d'exécution n'utilise pas de ressources et que le script qui le définit peut être modifié, les utilisateurs peuvent faire des tests jusqu'à ce qu'ils obtiennent le mapping qui leur convient. Lorsqu'un champ d'exécution s'avère utile à long terme, il est possible d'en précalculer la valeur au moment de l'indexation tout simplement en le définissant dans le modèle comme champ indexé et en veillant à ce que le document ingéré l'inclue. Le champ sera indexé lors du prochain renouvellement d'index afin de fournir des performances optimales. Les requêtes qui utilisent le champ n'ont pas besoin d'être modifiées. 

Ce scénario s'avère extrêmement utile en cas de mapping dynamique. D'un côté, il est très avantageux d'autoriser de nouveaux documents à générer de nouveaux champs, car il est possible d'utiliser immédiatement les données qui s'y trouvent (la structure des entrées change fréquemment, par exemple, si une modification est apportée au logiciel qui génère le log). De l'autre, le mapping dynamique risque d'encombrer l'index, voire d'entraîner une explosion, car un document peut ajouter par exemple 2 000 nouveaux champs d'un coup. Pour vous, c'est une mauvaise surprise ! Les champs d'exécution apportent une solution à ce problème. Les nouveaux champs peuvent être automatiquement créés en tant que champs d'exécution pour ne pas encombrer l'index, comme ils n'existent pas dans l'index et qu'ils ne sont pas comptabilisés dans index.mapping.total_fields.limit. Les utilisateurs peuvent interroger ces champs d'exécution automatiquement créés, s'ils en ont besoin, même si leurs performances sont moins bonnes. Par la suite, ils peuvent décider de les convertir en champs indexés lors du prochain renouvellement.   

Nous vous recommandons d'utiliser ces champs d'exécution pour faire vos premières expériences avec la structure des données. Après avoir travaillé avec vos données, vous pouvez décider d'indexer un champ d'exécution pour bénéficier de meilleures performances de recherche. Vous pouvez créer un index, puis ajouter une définition de champ dans le mapping d'index, ajouter le champ à _source et vous assurer que le nouveau champ soit inclus dans les documents ingérés. Si vous utilisez des flux de données, vous pouvez mettre à jour votre modèle d'index. Ainsi, lorsque des index seront créés à partir de ce modèle, Elasticsearch saura qu'il faut indexer ce champ. Dans une prochaine version, nous prévoyons de simplifier le processus de conversion d'un champ d'exécution en champ indexé, par simple déplacement du champ depuis la section d'exécution du mapping à la section des propriétés. 

Avec la requête ci-dessous, un mapping d'index simple est créé avec un champ d'horodatage. L'instruction "dynamic": "runtime" indique à Elasticsearch qu'il doit créer des champs supplémentaires sous forme de champs d'exécution de manière dynamique dans cet index. Si un champ d'exécution inclut un script Painless, la valeur du champ sera calculée en fonction du script Painless. Si un champ d'exécution est créé sans script, comme dans la requête suivante, le système cherchera dans _source un champ ayant le même nom que le champ d'exécution et utilisera sa valeur comme valeur du champ d'exécution.

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

Indexons un document pour voir les avantages de ces paramètres :

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

Maintenant que nous disposons d'un champ d'horodatage indexé et de deux champs d'exécution ("message" et "voltage"), nous pouvons visualiser le mapping d'index :

GET my_index-1/_mapping

La section d'exécution inclut les champs "message" et "voltage". Même s'ils ne sont pas indexés, nous pouvons les interroger comme nous le ferions s'il s'agissait de champs indexés.

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

Nous allons créer une requête de recherche simple qui interroge le champ "message" :

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

La réponse renvoie les résultats suivants :

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

Penchons-nous sur la réponse. Celle-ci comporte un problème : nous n'avons jamais précisé que le champ "voltage" devait renvoyer une valeur numérique ! Étant donné qu'il s'agit d'un champ d'exécution, nous pouvons facilement le rectifier en mettant à jour la définition du champ dans la section d'exécution du mapping :

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

La requête ci-dessus change le type de "voltage" en "long", ce qui est immédiatement répercuté sur les documents déjà indexés. Pour tester ce comportement, nous construisons une requête simple pour tous les documents ayant un champ "voltage" compris entre 11 et 13 :

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

Comme notre champ "voltage" a pour valeur 12, la requête renvoie notre document dans my_index-1. Si nous étudions à nouveau le mapping, nous constaterons que le champ "voltage" est désormais un champ d'exécution de type "long", même pour les documents ayant été ingérés dans Elasticsearch avant que nous mettions à jour le type de champ dans le mapping :

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

Par la suite, nous pouvons décider que le champ "voltage" est utile dans les agrégations. Nous souhaitons alors l'indexer dans le nouvel index créé dans un flux de données. Nous créons un nouvel index (my_index-2) qui correspond au modèle d'index du flux de données et nous définissons "voltage" comme étant un nombre entier, car nous connaissons le type de données que nous voulons après avoir fait des tests avec les champs d'exécution.

Dans l'idéal, nous devrions mettre à jour le modèle d'index en tant que tel pour que les changements s'appliquent lors du prochain renouvellement. Vous pouvez exécuter des requêtes sur le champ "voltage" dans n'importe quel index correspondant au modèle my_index*, même si le champ est un champ d'exécution dans un index, et un champ indexé dans l'autre.

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

Avec les champs d'exécution, nous avons de fait introduit un nouveau workflow de cycle de vie des champs. Dans ce workflow, un champ peut être automatiquement généré en tant que champ d'exécution, sans effet sur la consommation des ressources et sans risque d'explosion du mapping. Résultat : les utilisateurs peuvent immédiatement commencer à travailler avec les données. Le mapping du champ peut être affiné en fonction des données réelles, tant que le champ reste un champ d'exécution. Et grâce à la flexibilité des champs d'exécution, les changements sont appliqués sur les documents ayant déjà été ingérés dans Elasticsearch. Lorsqu'il devient clair que le champ est utile, le modèle peut être modifié pour faire en sorte que le champ soit indexé dans les index créés par la suite (après le prochain renouvellement). L'indexation du champ permet de bénéficier de performances optimales.

Résumé

Dans la grande majorité des cas, et plus particulièrement si vous connaissez vos données et que vous savez ce que vous voulez en faire, les champs indexés sont l'approche à privilégier en raison de leurs meilleures performances. Toutefois, si vous avez besoin de flexibilité au niveau de l'analyse des documents et de la structure du schéma, optez pour les champs d'exécution.

Les champs d'exécution et les champs indexés sont des fonctionnalités complémentaires, qui forment une symbiose. Les champs d'exécution offrent de la flexibilité, mais ne seront pas performants dans un environnement à grande échelle sans l'aide des index. La structure puissante et rigide des index fournit un environnement protégé, dans lequel la flexibilité des champs d'exécution peut pleinement s'exprimer. Et cette symbiose profite à tout le monde.

Lancez-vous aujourd’hui

Envie de vous lancer avec les champs d'exécution ? Déployez un cluster sur Elasticsearch Service ou installez la dernière version de la Suite Elastic. Vous utilisez déjà Elasticsearch ? Il vous suffit de mettre vos clusters à niveau vers la version 7.11 pour en découvrir les avantages. Pour en savoir plus sur les champs d'exécution et leurs avantages, lisez l'article Les champs d'exécution et le schéma de lecture Elastic. Et pour aller encore plus loin sur le sujet, nous avons enregistré 4 vidéos qui vous aideront à vous lancer avec les champs d'exécution.