Engineering

Erste Schritte mit Laufzeitfeldern, der Elastic-Umsetzung von Schema-on-Read.

Zur Beschleunigung der Suche hat Elasticsearch seit jeher auf den „Schema-on-Write“-Ansatz gesetzt. Neu in Elasticsearch ist jetzt, dass wir auch „Schema-on-Read“-Funktionen anbieten. So erhalten die Nutzer die Chance, das Schema eines Dokuments nach dem Ingestieren zu ändern und auch Felder zu generieren, die nur als Teil der Suchabfrage existieren. Durch die Möglichkeit, entweder Schema-on-Read oder Schema-on-Write zu nutzen, können die Nutzer ganz nach Bedarf den Fokus entweder auf Geschwindigkeit oder auf Flexibilität legen.

Schema-on-Read haben wir in Form von Laufzeitfeldern umgesetzt, die erst zum Zeitpunkt der Abfrage ausgewertet werden. Diese sind im Index-Mapping oder in der Abfrage definiert und stehen nach dem Definieren unverzüglich für Suchanfragen, Aggregationen, das Filtern und das Sortieren zur Verfügung. Da Laufzeitfelder nicht indexiert werden, haben sie keine Auswirkung auf die Größe des Index. Mehr noch: Sie können sogar die Speicherkosten reduzieren und das Ingestieren beschleunigen.

Wie so häufig, gibt es aber auch Haken an der Sache. Abfragen von Laufzeitfeldern können kostspielig sein. Daher empfiehlt es sich, bei häufig genutzten oder gefilterten Daten mit Index-Mapping zu arbeiten. Trotz kleinerer Indexgröße können Laufzeitfelder auch Abfragen langsamer machen. Wir empfehlen, eine Mischung aus Laufzeitfeldern und indexierten Feldern zu verwenden, um so ein ausgewogenes Verhältnis zwischen Ingestionstempo, Indexgröße, Flexibilität und Suchgeschwindigkeit für Ihre Anwendungsfälle zu finden.

Das Hinzufügen von Laufzeitfeldern ist einfach

Am einfachsten lässt sich ein Laufzeitfeld in der Abfrage selbst definieren. Nehmen wir an, wir haben den folgenden Index:

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

und laden ein paar Dokumente in diesen Index:

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

Mit einer statischen Zeichenfolge können wir zwei Felder miteinander verketten:

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

Als Antwort wird Folgendes zurückgegeben:

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

Im Abschnitt „runtime_mappings“ haben wir das Feld „socket“ definiert. Die Berechnung des „socket“-Wertes für die einzelnen Dokumente wird durch ein kurzes Painless-Skript gesteuert (wobei ein Pluszeichen + die Verkettung des „address“-Wertes mit der statischen Zeichenfolge „':'“ und dem Wert des Feldes „port“ angibt). Dann haben wir das Feld „socket“ für die Abfrage verwendet. Das Feld „socket“ ist ein kurzlebiges Laufzeitfeld, das nur für diese Abfrage existiert und zum Zeitpunkt der Abfrageausführung berechnet wird. Beim Definieren eines Painless-Skripts für die Verwendung mit Laufzeitfeldern muss „emit“ angegeben werden, damit berechnete Werte zurückgegeben werden.

Wenn wir später feststellen, dass wir das Feld „socket“ in mehreren Abfragen verwenden möchten, ohne es für jede Abfrage neu definieren zu müssen, können wir es einfach dem Mapping hinzufügen:

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

So muss die Abfrage nicht unbedingt die Definition des Feldes enthalten, z. B.:

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

Die Anweisung „"fields": ["socket"]“ wird nur benötigt, wenn es darum geht, den Wert des Feldes „socket“ anzuzeigen. Das Feld „socket“ ist jetzt für jede Abfrage verfügbar, existiert aber nicht im Index und erhöht auch nicht dessen Größe. „socket“ wird nur dann berechnet, wenn das Feld von einer Abfrage benötigt wird, und dies auch nur für die Dokumente, für die es erforderlich ist.

Gleiches Verhalten beim Konsumieren wie jedes andere Feld auch

Da Laufzeitfelder über dieselbe API wie indexierte Felder exponiert werden, kann sich eine Abfrage sowohl auf Indizes beziehen, in denen das Feld ein Laufzeitfeld ist, als auch auf Indizes, in denen das Feld ein indexiertes Feld ist. Wir können flexibel wählen, welche Felder indexiert werden sollen und welche weiter als Laufzeitfelder bestehen sollen. Durch diese Trennung zwischen dem Erstellen und dem Konsumieren von Feldern können wir für einen organisierten Code sorgen, der sich einfacher erstellen und pflegen lässt.

Laufzeitfelder können im Index-Mapping oder in der Suchanfrage definiert werden. Diese Wesenseigenschaft bietet ein hohes Maß an Flexibilität bei der Nutzung von Laufzeitfeldern zusammen mit indexierten Feldern. 

Überschreiben von Feldwerten zur Abfragezeit

Häufig kommt es vor, dass Fehler in Produktionsdaten erst erkannt werden, wenn es schon zu spät ist.  Bei Dokumenten, die erst noch ingestiert werden sollen, ist es kein Problem, die Ingestionsanweisungen zu ändern; bei Daten, die bereits ingestiert und indexiert wurden, sieht das jedoch anders aus. Mithilfe von Laufzeitfeldern lassen sich Fehler in indexierten Daten korrigieren, indem die bestehenden Werte zum Zeitpunkt der Abfrageausführung überschrieben werden. Laufzeitfelder können als Schatten für indexierte Felder mit demselben Namen agieren und ermöglichen so das Korrigieren von Fehlern in indexierten Daten.  

Das folgende einfache Beispiel illustriert dies. Nehmen wir an, wir haben einen Index mit einem Nachrichtenfeld („raw_message“) und einem Adressfeld („address“):

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

Wir laden ein Dokument in den Index:

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

Leider enthält das Dokument im Adressfeld eine falsche IP-Adresse. In der Nachricht ist zwar die richtige IP-Adresse angegeben, aber irgendwie wurde im Dokument, das für das Ingestieren in Elasticsearch und das Indexieren gesendet wurde, die falsche Adresse herausgefiltert. Bei einem einzelnen Dokument ist das zwar kein Problem, aber was, wenn wir nach einem Monat feststellen, dass 10 Prozent unserer Dokumente eine falsche Adresse enthalten? Während es bei neuen Dokumenten ein Klacks ist, diesen Fehler zu korrigieren, ist das Neuindexieren der bereits ingestierten Dokumente aus operativer Sicht häufig sehr komplex. Bei Laufzeitfeldern kann der Fehler auf der Stelle korrigiert werden, indem das indexierte Feld von einem Laufzeitfeld als Schatten begleitet wird. In einer Abfrage könnte das wie folgt aussehen:

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

Sie können die Änderung auch im Mapping vornehmen, damit sie für alle Abfragen bereitsteht. Über das Painless-Skript ist die Verwendung regulärer Ausdrücke jetzt standardmäßig aktiviert.

Gleichgewicht zwischen Performance und Flexibilität

Bei indexierten Feldern finden sämtliche Vorbereitungen in der Phase des Ingestierens und Pflegens der komplexen Datenstrukturen statt, um eine optimale Performance zu ermöglichen. Die Abfragezeiten sind bei Laufzeitfeldern aber länger als bei indexierten Feldern. Was also tun, wenn Abfragen nach der Einführung von Laufzeitfeldern plötzlich zu lange dauern?

Wir empfehlen, für das Abrufen von Laufzeitfeldern die asynchrone Suche zu verwenden. Dabei werden wie bei einer synchronen Suche alle Suchergebnisse vollständig zurückgegeben, sofern die Abfrage innerhalb einer bestimmten Zeit abgeschlossen wird. Aber auch dann, wenn die Abfrage nicht in dieser Zeit abgeschlossen wird, erhalten wir schon die ersten Teilergebnisse; gleichzeitig setzt Elasticsearch sein „Polling“ im Hintergrund so lange fort, bis der komplette Ergebnissatz zurückgegeben worden ist. Dieser Mechanismus ist vor allem beim Verwalten eines Index-Lebenszyklus nützlich, da neuere Ergebnisse in der Regel zuerst zurückgegeben werden und für die Nutzer zumeist auch wichtiger sind.

Zur Sicherstellung einer bestmöglichen Performance übernehmen die indexierten Felder den Großteil der Verarbeitungsarbeit und die Laufzeitfeldwerte werden nur für einen Teil der Dokumente berechnet.

Ändern des Typs eines Feldes von Laufzeitfeld zu indexiertes Feld

Mit Laufzeitfeldern können Nutzer ihr Mapping und Parsing flexibel ändern, während sie in einer Live-Umgebung an Daten arbeiten. Weil ein Laufzeitfeld keine Ressourcen belegt und weil das Skript, das das Feld definiert, geändert werden kann, können Nutzer so lange experimentieren, bis sie das optimale Mapping erreicht haben. Wenn der Nutzer feststellt, dass ein als Laufzeitfeld definiertes Feld auch längerfristig sinnvoll ist und sein Wert schon beim Indexieren vorausberechnet werden sollte, reicht es, das Feld einfach in der Vorlage als indexiertes Feld zu definieren und dafür zu sorgen, dass das ingestierte Dokument das Feld enthält. Ab dem nächsten Index-Rollover wird das Feld dann indexiert und kann damit schneller abgefragt werden. Auf die Abfragen, die das Feld nutzen, hat dies keinerlei Auswirkungen. 

Dieses Szenario eignet sich besonders für das dynamische Mapping. Einerseits ist es sehr hilfreich, neuen Dokumenten das Erstellen neuer Felder zu erlauben, weil so die Daten in ihnen sofort genutzt werden können (die Struktur der Einträge ändert sich häufig, z. B. wegen Änderungen bei der Software, die die Logdaten generiert). Andererseits birgt das dynamische Mapping das Risiko, das der Index überlastet wird und es sogar zu einer Mapping-Explosion kommt, weil es immer möglich ist, dass plötzlich ein Dokument mit 2.000 neuen Feldern daherkommt. Laufzeitfelder können eine Lösung für dieses Szenario sein. Es kann festgelegt werden, dass die neuen Felder automatisch als Laufzeitfelder erstellt werden, um den Index nicht zu überlasten (denn diese Felder sind im Index nicht vertreten); sie werden auch nicht auf die in index.mapping.total_fields.limit. festgelegte Obergrenze für die Zahl der Felder angerechnet. Diese automatisch erstellten Laufzeitfelder sind – mit gewissen Performance-Einbußen – abfragbar. Das heißt, Nutzer können sie erst einmal verwenden und sie dann bei Bedarf beim nächsten Rollover in indexierte Felder umwandeln.   

Wir empfehlen, neue Felder zunächst als Laufzeitfelder einzurichten, damit Sie mit Ihrer Datenstruktur experimentieren können. Nachdem Sie genug mit Ihren Daten gearbeitet haben, können Sie überlegen, ob Sie das Feld zugunsten einer besseren Such-Performance indexieren. Sie können einen neuen Index erstellen und dann das Index-Mapping um die Felddefinition ergänzen, das Feld zu _source hinzufügen und sicherstellen, dass das neue Feld in den ingestierten Dokumenten enthalten ist. Falls Sie Datenstreams nutzen, können Sie Ihre Indexvorlage aktualisieren und so dafür sorgen, dass Elasticsearch beim Erstellen von Indizes anhand dieser Vorlage weiß, dass dieses Feld indexiert werden soll. Für die Zukunft ist geplant, den Prozess des Umwandelns eines Laufzeitfeldes in ein indexiertes Feld so weit zu vereinfachen, dass das Feld nur noch aus dem Mapping-Abschnitt „runtime“ in den Abschnitt „properties“ verschoben werden muss. 

Die folgende Anfrage erstellt ein einfaches Index-Mapping mit einem Zeitstempel-Feld („timestamp“). Mit „"dynamic": "runtime"“ wird Elasticsearch angewiesen, dynamisch zusätzliche Felder in diesem Index zu erstellen und sie als Laufzeitfelder einzurichten. Wenn ein Laufzeitfeld ein Painless-Skript enthält, wird der Wert des Feldes anhand des Painless-Skripts berechnet. Wird ein Laufzeitfeld ohne Skript erstellt, wie in der folgenden Anfrage gezeigt, sucht das System in „in _source“ nach einem Feld, das denselben Namen wie das Laufzeitfeld hat, und nutzt dessen Wert für das Laufzeitfeld:

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

Lassen Sie uns ein Dokument indexieren, damit wir sehen können, was diese Einstellungen bringen:

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

Wir haben jetzt ein indexiertes Zeitstempel-Feld („timestamp“) und zwei Laufzeitfelder („message“ und „voltage“) und können uns das Index-Mapping ansehen:

GET my_index-1/_mapping

Der Abschnitt „runtime“ enthält die Felder „message“ und „voltage“. Diese Felder sind zwar nicht indexiert, aber wir können sie auf die gleiche Art und Weise abfragen wie indexierte Felder.

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

Wir erstellen eine einfache Suchanfrage, die den Wert des Feldes „message“ abfragt:

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

Die Antwort enthält die folgenden Treffer:

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

Wenn wir uns diese Antwort ansehen, bemerken wir ein Problem: Wir haben nirgends angegeben, dass „voltage“ eine Zahl ist. Aber „voltage“ ist ein Laufzeitfeld, sodass sich dieses Problem einfach beheben lässt: Wir müssen nur zum Abschnitt „runtime“ des Mappings gehen und die Felddefinition aktualisieren, und zwar so:

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

Mit dieser Anfrage ändern wir den Typ des Feldes „voltage“ zu „long“, was sich sofort auf bereits indexierte Dokumente auswirkt. Dies können wir testen, indem wir eine einfache Abfrage nach allen Dokumenten erstellen, bei denen der „voltage“-Wert größer als 11 und kleiner als 13 ist:

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

Da der Wert von „voltage“ bei 12 lag, gibt die Abfrage unser Dokument in „my_index-1“ zurück. Wenn wir uns das Mapping noch einmal ansehen, sehen wir, dass „voltage“ jetzt ein Laufzeitfeld des Typs „long“ ist; dies gilt auch für Dokumente, die vor der Änderung des Feldtyps im Mapping in Elasticsearch ingestiert worden sind:

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

Es kann sein, dass wir später feststellen, dass „voltage“ in Aggregationen nützlich sein könnte und wir dieses Feld doch gern in den nächsten Index indexieren möchten, der in einem Datenstream erstellt wird. Dazu erstellen wir unter Zuhilfenahme der Indexvorlage für den Datenstream einen neuen Index („my_index-2“) und legen für „voltage“ den Feldtyp „integer“ fest, weil wir nach dem Experimentieren mit Laufzeitfeldern inzwischen wissen, welchen Datentyp wir haben möchten.

Ideal wäre es, wenn wir die Indexvorlage selbst aktualisieren, damit die Änderungen beim nächsten Rollover wirksam werden. Wir können Abfragen für das Feld „voltage“ in jedem Index durchführen, der dem Muster „my_index*“ entspricht, selbst wenn das Feld in einem Index ein Laufzeitfeld und in einem anderen Index ein indexiertes Feld ist.

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

Wegen der Einführung von Laufzeitfeldern haben wir daher einen neuen Feld-Lebenszyklus-Workflow eingeführt. In diesem Workflow kann festgelegt werden, dass ein Feld automatisch als Laufzeitfeld erstellt wird, ohne dass dies Auswirkungen auf den Ressourcenverbrauch hat oder das Risiko einer Mapping-Explosion birgt. So können die Nutzer auf der Stelle damit beginnen, mit ihren Daten zu arbeiten. Das Mapping des Feldes kann noch während das Feld als Laufzeitfeld definiert ist für reale Daten angepasst werden, und dank der Flexibilität von Laufzeitfeldern werden die Änderungen bei Dokumenten, die bereits in Elasticsearch ingestiert wurden, sofort wirksam. Wenn klar geworden ist, dass das Feld nützlich ist, kann die Vorlage so geändert werden, dass in den Indizes, die ab diesem Zeitpunkt (nach dem nächsten Rollover) erstellt werden, das Feld indexiert wird und damit schneller abgerufen werden kann.

Zusammenfassung

In der überwiegenden Mehrheit der Fälle und speziell dann, wenn Sie Ihre Daten kennen und wissen, was Sie mit ihnen anstellen möchten, empfiehlt sich die Verwendung indexierter Felder, weil sie einfach eine bessere Performance aufweisen. Aber in den Fällen, in denen für das Parsen von Dokumenten und die Schemastruktur Flexibilität benötigt wird, sind ab sofort Laufzeitfelder die richtige Wahl.

Laufzeitfelder und indexierte Felder ergänzen sich gegenseitig – sie bilden eine Art Symbiose. Laufzeitfelder bieten Flexibilität, können aber ohne Unterstützung des Index nicht mit den Performance-Anforderungen großer Umgebungen mithalten. Die leistungsfähige, eher starre Struktur des Index bietet eine geschützte Umgebung, in der die Flexibilität von Laufzeitfeldern ihre wahren Stärken zeigen kann – ganz so, wie Algen Schutz in einer Koralle finden. Von einer solchen Symbiose profitieren beide Seiten.

Jetzt loslegen

Wenn Sie mit Laufzeitfeldern loslegen möchten, richten Sie in Elasticsearch Service einen Cluster ein oder installieren Sie die neueste Version des Elastic Stack. Sollte Elasticsearch bereits bei Ihnen laufen, upgraden Sie Ihre Cluster einfach auf 7.11 und probieren Sie die neuen Funktionen aus. Einen Überblick über das Thema Laufzeitfelder und ihre Vorteile finden Sie im Blogpost „Runtime fields: Schema on read for Elastic“. Außerdem haben wir auch vier Videos für Sie, die Ihnen bei Ihren ersten Schritten mit Laufzeitfeldern helfen: