Dec 20th, 2021: [de] Runtime Fields — neue Möglichkeiten für Daten in Elasticsearch

Datenspeicher haben zwei Möglichkeiten, wie sie Informationen für Abfragen vorbereiten können — beispielsweise ein Muster durch eine Regular Expression zu extrahieren: Entweder bei der Indizierung / Speicherung ("Schema on Write") oder zur Laufzeit bei der Abfrage ("Schema on Read"). Dabei nimmt man unterschiedliche Vor- und Nachteile in Kauf:

  • Schema on Write: Schnellere Abfragen, da ein Teil der Arbeit bereits erledigt ist. Speziell wenn Daten oft gelesen aber nur selten aktualisiert werden, lohnt sich dieser Ansatz. Dafür wird zusätzlicher Speicherplatz benötigt, um die extrahierten Daten zu speichern.
  • Schema on Read: Mehr Flexibilität und schnellere Indizierung, da die Anforderungen erst bei der Abfrage bearbeitet werden müssen. Dafür können Abfragen deutlich langsamer sein.

Beispiel: Schema on Write vs Schema on Read

Ein gängiges Beispiel im Elastic Stack wäre das Speichern von Logs, die mit einem Grok-Pattern in ihre Einzelteile zerlegt werden. Beispielsweise [2021-12-20 09:00:00.000] INFO Publish advent blog post. in die folgende Struktur:

{
  "@timestamp": "2021-12-20 09:00:00.000",
  "log": {
    "level": "INFO"
  },
  "message": "Publish advent blog post."
}

Der Grok Debugger in Kibana kann die Arbeit dazu sehr erleichtern und das passende Grok-Pattern könnte beispielsweise sein ^\[%{TIMESTAMP_ISO8601:@timestamp}\]%{SPACE}%{LOGLEVEL:log.level}%{SPACE}%{GREEDYDATA:message}.

Elasticsearch hat von Anfang an auf den Ansatz bei der Indizierung gesetzt. Dadurch ergeben sich die Vorteile:

  • Schnelle Abfragen auf Log-Level oder eine bestimmte Zeitspanne, da nicht jedes Mal die gesamte Nachricht neu analysiert werden muss.
  • Simplere Abfragen, da man direkt auf die extrahierten Felder zugreifen kann.

Man darf allerdings auch nicht auf die Nachteile vergessen:

  • Man muss das Datenformat genau kennen, bevor man es korrekt strukturiert speichern kann.
  • Stellt sich später heraus, dass nicht alle Logeinträge diesem Format entsprechen oder man zusätzliche Informationen extrahieren möchte, müsste man sie mit einem aktualisierten Grok-Pattern neu indizieren, was Zeit und Ressourcen kostet.
  • Wenn man die Originalnachricht und die extrahierten teile speichert, benötigt man zusätzlichen Speicherplatz.

Um diesen Nachteilen entgegenzuwirken, unterstützt Elasticsearch seit Version 7.11 Schema on Read bzw. Runtime- oder Laufzeit-Felder. Dabei kann das Format eines solchen Feldes im Mapping des Index gespeichert (und aktualisiert) werden oder bei der Suchanfrage definiert werden.

Laufzeit-Felder, Painless und die Berechnung von Zeitspannen

Um nicht immer nur Log-Daten zu verwenden und weil ich kürzlich ein Problem mit Laufzeit-Feldern lösen musste, hier ein praktisches Beispiel: Mitarbeiter sind mit einem Hire Date und Termination Date, beide vom Datentyp date, gespeichert. Feldnamen mit Leerzeichen sind eher ungewöhnlich und ich würde sie auch nicht empfehlen; da die Anforderung allerdings genau so ausgesehen hat und man auch damit problemlos arbeiten kann, bleibe ich dabei.

Beispieldaten:

PUT test/_doc/1
{
  "Hire Date": "2020-02-01"
}
PUT test/_doc/2
{
  "Hire Date": "2020-01-01",
  "Termination Date": "2021-12-01"
}

Das dynamische Mapping, das Elasticsearch automatisch dafür anlegt, sieht folgendermaßen aus:

{
  "test" : {
    "mappings" : {
      "properties" : {
        "Hire Date" : {
          "type" : "date"
        },
        "Termination Date" : {
          "type" : "date"
        }
      }
    }
  }
}

Die Anforderung dafür ist, die Beschäftigungsdauer (oder Tenure) zu berechnen. Ein idealer Kandidat für Laufzeit-Felder.

Painless

Vor der eigentlichen Abfrage, ein kurzer Ausflug in Painless — die Scriptsprache in Elasticsearch. Leider kann die Sprache manchmal etwas schmerzhaft sein, speziell wenn man keine Java-Erfahrung hat, woran sie angelehnt ist. Der Name rührt allerdings von den chronischen Rückenschmerzen des Autors und bezieht sich nicht auf die Verwendung.

Die Grundidee der Berechnung ist simpel: Berechne die Differenz zwischen Start- und Enddatum. Falls noch kein Enddatum gesetzt ist (aufrechtes Arbeitsverhältnis), berechne die Differenz bis jetzt (new Date()):

Instant start = doc['Hire Date'].value.toInstant();
Instant end;
if(params._source['Termination Date'] == null){
  end = Instant.ofEpochMilli(new Date().getTime());
} else {
  end = doc['Termination Date'].value.toInstant();
}
emit(ChronoUnit.DAYS.between(start, end));

Instant ist der Java-Datentyp mit dem sich zeitliche Berechnungen leicht durchführen lassen. Mit emit() wird das Ergebnis zurückgegeben. Der Rest ist mehr oder weniger "nur" Javaprogrammierung, wobei mit doc['...'] auf ein gespeichertes Feld zugegriffen wird und über params._source['...'] lässt sich prüfen, ob ein Feld im Originaldokument existiert.

Laufzeit-Felder in einer Abfrage

Und jetzt zur Abfrage, sortiert nach absteigender Beschäftigungsdauer; wobei das Feld gleichermaßen für Suche oder Aggregationen zur Verfügung stehen würde:

GET test/_search
{
  "runtime_mappings": {
    "tenure": {
      "type": "long",
      "script": {
        "source": """
            Instant start = doc['Hire Date'].value.toInstant();
            Instant end;
            if(params._source['Termination Date'] == null){
              end = Instant.ofEpochMilli(new Date().getTime());
            } else {
              end = doc['Termination Date'].value.toInstant();
            }
            emit(ChronoUnit.DAYS.between(start, end));
          """
      }
    }
  },
  "fields": [
    "Hire Date",
    "Termination Date",
    "tenure"
  ],
  "sort": [
    {
      "tenure": {
        "order": "desc"
      }
    }
  ]
}

Wer Elasticsearch-Abfragen kennt, wird vom Ergebnis nicht zu überrascht sein:

{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : null,
        "_source" : {
          "Hire Date" : "2020-01-01",
          "Termination Date" : "2021-12-01"
        },
        "fields" : {
          "Termination Date" : [
            "2021-12-01T00:00:00.000Z"
          ],
          "tenure" : [
            700
          ],
          "Hire Date" : [
            "2020-01-01T00:00:00.000Z"
          ]
        },
        "sort" : [
          700
        ]
      },
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : null,
        "_source" : {
          "Hire Date" : "2020-02-01"
        },
        "fields" : {
          "tenure" : [
            688
          ],
          "Hire Date" : [
            "2020-02-01T00:00:00.000Z"
          ]
        },
        "sort" : [
          688
        ]
      }
    ]
  }
}

tenure ist nicht Teil des _source— es ist eben "nur" ein Laufzeit-Feld. Um es im Ergebnis trotzdem explizit gelistet zu haben, kann man auf die Liste an fields zurückgreifen.

Laufzeit-Felder im Mapping

Nun das selbe über das Mapping:

PUT test/_mapping
{
  "runtime": {
    "tenure": {
      "type": "long",
      "script": {
        "source": """
            Instant start = doc['Hire Date'].value.toInstant();
            Instant end;
            if(params._source['Termination Date'] == null){
              end = Instant.ofEpochMilli(new Date().getTime());
            } else {
              end = doc['Termination Date'].value.toInstant();
            }
            emit(ChronoUnit.DAYS.between(start, end));
          """
      }
    }
  }
}

Das sieht nach dem vorigen Beispiel schon recht vertraut aus. Und damit sieht auch die Abfrage wie eine reguläre Query aus — nur fields wäre dabei nicht notwendig:

GET test/_search
{
  "fields": [
    "Hire Date",
    "Termination Date",
    "tenure"
  ],
  "sort": [
    {
      "tenure": {
        "order": "desc"
      }
    }
  ]
}

Und das Ergebnis sieht wie zuvor aus:

{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : null,
        "_source" : {
          "Hire Date" : "2020-01-01",
          "Termination Date" : "2021-12-01"
        },
        "fields" : {
          "Termination Date" : [
            "2021-12-01T00:00:00.000Z"
          ],
          "tenure" : [
            700
          ],
          "Hire Date" : [
            "2020-01-01T00:00:00.000Z"
          ]
        },
        "sort" : [
          700
        ]
      },
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : null,
        "_source" : {
          "Hire Date" : "2020-02-01"
        },
        "fields" : {
          "tenure" : [
            688
          ],
          "Hire Date" : [
            "2020-02-01T00:00:00.000Z"
          ]
        },
        "sort" : [
          688
        ]
      }
    ]
  }
}

Abschluss

Und damit lassen wir es auch schon mit dem heuten Advent-Post bewenden. Ich hoffe, es ist nützlich.

Elasticsearch bleibt grundsätzlich dem Ansatz der Indizierung treu. Laufzeit-Felder erlauben aber zusätzliche Flexibilität für gewisse Anwendungsfälle:

  • Neue Anforderungen, wie im obigen Beispiel gezeigt, können ohne großen Aufwand hinzugefügt werden.
  • Informationen, die sehr selten durchsucht werden, könnten zu teuer sein für den Aufwand bei der Indizierung oder den zusätzlichen Festplattenspeicher.
  • Falsch indizierte Daten können ohne teure Reindizierung zur Laufzeit korrigiert werden.
1 Like

This topic was automatically closed 28 days after the last reply. New replies are no longer allowed.