Engineering

Elasticsearch: Datenintegrität mit externen Datenspeichern überprüfen

Elasticsearch wird manchmal gemeinsam mit anderen Datenbanken genutzt. In solchen Szenarien ist es oft schwierig, Lösungen zu implementieren, die Two-Phase-Commits unterstützen, was hauptsächlich an einer fehlenden Transaktionsunterstützung über alle Systeme hinweg liegt. Abhängig von deinem individuellen Fall kann es notwendig sein, die Daten, die sich in beiden Datenspeichern befinden, zu verifizieren, wobei einer als die sogenannte „Source of Truth“ fungiert.

Als Support-Engineer bei Elastic erhalten wir regelmäßig Anfragen, die sich um Aspekte wie die beste Vorgehensweise bei der Strukturierung von Daten für die Verifizierung oder auch einfach um ein effizientes Strukturieren an sich drehen. Dieser Blogbeitrag wird dich durch einige Beispiele führen, die wir selbst erlebt und dementsprechend bearbeitet haben. Die Beispiele reichen von einfach bis fortgeschritten und befassen sich mit der Verifizierung, dass Elasticsearch alle notwendigen Daten aus einer Datenbank wie beispielsweise PostgreSQL enthält.

Modellierung der Daten für die Verifizierung

Die Art und Weise, wie deine Daten gespeichert sind, sowohl in als auch außerhalb von Elasticsearch, macht einen großen Unterschied in Bezug auf die Verifizierung. Zunächst musst du dich entscheiden, wie viel tatsächlich verifiziert werden muss:

  • Ist die bloße Existenz eines Dokuments bereits ausreichend?
  • Oder macht ein interner Prozess die Verifizierung ganzer Dokumente nötig?

Diese Entscheidung wird Auswirkungen auf den für die Verifizierung nötigen Arbeitsumfang haben.

Die Evolution der Existenzverifizierung

Glücklicherweise geht es hier nicht um eine tiefgründige philosophische Fragestellung. Stattdessen ist es einfach die Frage: „Existieren sämtliche Dokumente in Elasticsearch?“

So lange du wirklich verstehst, was genau passiert, bietet Elasticsearch viele Möglichkeiten der Existenzverifizierung. Denke daran, dass Elasticsearch nahezu in Echtzeit sucht, dabei jedoch Dokumente direkt und in Echtzeit abrufen kann. Dies bedeutet, dass ein Dokument direkt nach seiner Indexierung im Rahmen von Suchanfragen nicht sichtbar ist, jedoch per Direktabfrage (Get Request) verfügbar sein wird.

Eins nach dem anderen

Für Entwicklungen in kleinerem Umfang ist der einfachste Ansatz, Head-Anfragen (HEAD) auf einzelne Dokumente auszuführen und dann sicherzustellen, dass der HTTP-Antwortcode keine 404-Meldung ist (Seite – oder in diesem Fall Dokument – nicht gefunden).

HEAD /my_index/my_type/my_id1

Eine beispielhafte Antwort wäre, dass ausschließlich Header im Erfolgsfall übertragen werden:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 0

Bei der Nicht-Existenz sähe es praktisch genauso aus, abgesehen vom Antwortcode:

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=UTF-8
Content-Length: 0

Dies erfordert, dass du N Anfragen gegen einen Index ausführst, der erwartungsgemäß N Dokumente enthalten sollte. Wie du dir vielleicht vorstellen kannst, lässt sich dieser Ansatz nicht wirklich gut skalieren.

Batch-Processing

Im weiteren Verlauf entscheidest du dich vielleicht, das Ganze über Batch-Requests mittels der Multi-GET-API durchzuführen: _mget.

GET /my_index/my_type/_mget
{
  "ids": [ "my_id1", "my_id2" ]
}

Ein weiterer vergleichbarer Ansatz hierfür ist, einfach nach den IDs zu suchen und die erwartete Nummer zurückzugeben.

GET /my_index/_refresh
GET /my_index/my_type/_search
{
  "size": 2,
  "query": {
    "ids": {
      "values": [ "my_id1", "my_id2" ]
    }
  }
}

Zuerst wird der Endpunkt _refresh aufgerufen, um sicherzustellen, dass alle indizierten Inhalte auch durchsuchbar sind. Dies beseitigt den erwähnten „Fast-Echtzeit-Aspekt“ der Suche. Unter normalen Suchbedingungen solltest du dies nicht tun – in diesem Fall macht es jedoch absolut Sinn. Vermutlich wird die Verarbeitung deiner Verifizierung komplett auf einmal durchgeführt. Ist das der Fall, ist der Aufruf von _refresh einmalig und zu Beginn eines Jobs für alles Folgende ausreichend. Dies gilt jedoch nur, sofern du nicht auf neue Daten prüfst, die nach dem Starten des Prozesses hinzugefügt wurden.

Dieses Vorgehen bietet ein besseres Request-Handling, vermeidet jedoch nicht die Arbeit im Hintergrund, die hier unverändert bleibt. Auch bedeutet es, dass jedes Dokument ebenfalls zurückgegeben wird, was einen entsprechenden zusätzlichen Mehraufwand verursacht.

Suchen, dann batchen

Ausgehend vom Batch-Processing, gehen viele Anwender dazu über, das Problem durch eine Unterteilung lösen zu wollen. Zuerst führen sie dabei eine Suche durch, um festzustellen, was genau fehlt. Erst danach beginnen sie mit der Suche nach dem fehlenden Dokument.

GET /my_index/_refresh
GET /my_index/my_type/_search
{
  "size": 0,
  "query": {
    "ids": {
      "values": [ "my_id1", "my_id2" ]
    }
  }
}

Wenn du den Wert hits.total der Antwort prüfst, dann sollte dieser der von dir erwarteten Zahl entsprechen. In diesem vereinfachten Beispiel sollte es 2 sein. Falls es nicht 2 ist, musst du einen Gang hochschalten und entweder:

  1. Nach sämtlichen IDs suchen, sie durch die Angabe einer entsprechenden Größe zurückgeben und dann die fehlende(n) Nadel(n) im Heuhaufen finden.
  2. Auf das Beispiel mit der Funktion _mget von oben zurückgreifen und das Gleiche tun: Bestimmen, welche ID fehlt.

Es muss einen besseren Weg geben

Die langwierige oder zweistufige Verarbeitung kann umständlich zu skalieren sein und unterstellt, dass du eine teure Frage stellst, aber auch, dass du eventuell einfach die falsche Frage stellst.

Was wäre, wenn du die Anfrage oder die allgemeine Frage auf den Kopf stellen würdest; wenn du anstatt der Anfrage „Finde alles, was existiert“ eher die Frage „Was fehlt?“ stellen würdest? Es ist schwierig, Dinge abzufragen, die nicht vorhanden sind. Tatsächlich ist es regelrecht unmöglich, da die Daten nicht existieren. Mit Hilfe von Aggregationen lässt sich die Frage trotzdem beantworten – dies jedoch nur, sofern du Daten passend strukturieren kannst.

Die meisten Fallbeispiele im Bereich Verifizierung kommen aus der SQL-Welt, in der ganzzahlige Schlüssel für Primär- und Fremdschlüssel sehr häufig sind. Auch wenn du diese numerischen IDs nicht als Elasticsearch _id nutzt, solltest du sie als ganzzahlige Werte mit aktivierten Doc-Values indizieren (standardmäßig eingestellt in ES2.x und aktueller). Zum Beispiel:

POST /my_index/my_type/my_id1
{
  "id": 1,
  …
}

Der Name des Feldes ist irrelevant. Beachte jedoch, dass es nicht _id,ist, was in Elasticsearch ein reserviertes Metadaten-Feld ist. Sobald du mit der Indizierung dieses Wertes beginnst, wird das Auffinden fehlender Daten mit Hilfe von Histogrammen sehr einfach.

GET /my_index/my_type/_search
{
  "size": 0,
  "aggs": {
    "find_missing_ids": {
      "histogram": {
        "field": "id",
        "interval": 1,
        "min_doc_count": 0
      },
      "aggs": {
        "remove_existing_bucket_selector": {
          "bucket_selector": {
            "buckets_path": {
              "count": "_count"
            },
            "script": {
              "inline": "count == 0",
              "lang": "expression"
            }
          }
        }
      }
    }
  }
}

Dies sorgt für zwei Dinge:

  1. Es wird ein Histogramm über das numerische Feld id erstellt. Dabei wird ein Schritt von 1 zwischen jedem Wert verwendet, was bedeutet, dass für alle id s sämtliche dazwischenliegenden und ganzzahlig-basierten Werte gefunden werden (z. B. 1–5 würden als ein Histogramm aus 1, 2, 3, 4, 5 dargestellt werden).
  2. Alle Histogramm-Buckets, die tatsächlich Daten enthalten, werden durch die Anwendung eines Bucket-Selektors entfernt. Dieser ist jedoch ausschließlich in Elasticsearch 2.0 und neueren Versionen verfügbar.

Indem wir entfernen, was tatsächlich existiert, bleibt nur das übrig, was dementsprechend nicht existiert. Dies kann dann folgendermaßen aussehen:

{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 5,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "find_missing_ids": {
      "buckets": [
        {
          "key": 4,
          "doc_count": 0
        }
      ]
    }
  }
}

Aus einem winzigen Index mit fünf Dokumenten, gefunden mit der Funktion hits.total, zeigt uns dies, dass das Dokument mit der id 4 fehlt.

Das ist auch schon alles: Wenn du ein Histogramm aus dem spezifischen Schlüssel des Dokuments erstellen kannst, dann kannst du auch eines erstellen, das nur fehlende Daten anzeigt. Du kannst dies jedoch nicht mit Strings durchführen. Das bedeutet, dass dieser auf Aggregationen basierende Ansatz für Elemente wie „my_id“ und UUIDs (z. B. ein Globally Unique Identifier bzw. GUID) nicht angewendet werden kann! Für diese Art der Prüfung muss man auf die Strukturierung von Daten zurückgreifen.

Vorsicht Lücke

Denjenigen, die nicht sehr vertraut mit sequentiellen Identifikatoren sind, sei gesagt, dass es in Systemen (z. B. SQL-Datenbanken) durchaus zu einem „Prefetch“ ganzer Batches an Identifikatoren kommen kann. Innerhalb dieser Batches kann es aus verschiedenen Gründen dazu kommen, dass bestimmte Werte übersprungen werden. Dazu zählen fehlgeschlagene Transaktionen oder sogar einfachere Gründe wie zum Beispiel nachträglich gelöschte Einträge.

In solchen Fällen musst du dir bewusst sein, dass diese Werte gemäß dem Histogramm als fehlende Dokumente erscheinen. Um dies zu vermeiden, kannst du Elasticsearch als Client auffordern, diese Werte ausdrücklich zu ignorieren. Dafür füge einen sekundären Bucket-Selektor hinzu, der explizit genau solche ids eliminiert oder sie clientseitig ignoriert.

Verifizierung ganzer Dokumente

Die Verifizierung ganzer Dokumente ist eine Herausforderung jenseits der bloßen Existenzprüfung. In diesem Szenario müssen ganze Dokumente verifiziert werden, um sicherzustellen, dass die darin enthaltenen Daten auch die erwarteten Daten sind. Aus der Perspektive von Elasticsearch ist es genau genommen sogar einfacher. Die Arbeit auf deiner Seite ist jedoch komplizierter.

Durch Daten scrollen

Der einfachste Weg, diese Überprüfung durchzuführen, ist, sich durchzuscrollen. Glücklicherweise bedeutet das nicht, dass du deine gesamten Daten in eine HTML-Tabelle übertragen und dann mit dem Mausrad alles doppelt prüfen musst. Vielmehr geht es hier um die Nutzung der _scroll API, um durch all deine Daten zu gehen (_doc, wie unten genutzt, ist in dem Link beschrieben). Wie jede Such-API funktioniert dies auf Basis desselben Fast-Echtzeit-Prinzips wie weiter oben angemerkt.

GET /my_index/my_type/_search?scroll=1m
{
  "sort": [
    "_doc"
  ]
}

Die Scroll-Zeit ist die Zeit, die du in der Rolle als Client-Software zur Bearbeitung eines Batches benötigst, bevor der nächste Batch abgefragt wird. Stelle sicher, dass sie lang genug ist, um die nachfolgende Antwort durchzugehen und lies die oben verlinkte Dokumentation durch!

Indem du durch die Daten scrollst, kannst du dich Dokument für Dokument vergewissern, dass alle Daten entsprechend deiner Vorgaben korrekt sind.

Kontrolle über die Versionierung übernehmen

Elasticsearch unterstützt Optimistic Concurrency Control (OCC), was nichts anderes als die Versionierung beschreibt.

Du kannst die vollständige Kontrolle über die Versionierung übernehmen, indem du deine eigene Versionsnummer verwendest. Wenn du dies tust, kannst du durch die Verifizierung von Versionsnummern möglicherweise eine vollständige Dokumentenprüfung vermeiden. Um Versionsnummern im Kontext von Suchantworten zu aktivieren, musst du das Versions-Flag setzen:

GET /my_index/my_type/_search
{
  "version": true
}

Verifizierung umgehen

Es ist möglich, die Verifizierung komplett zu umgehen, vorausgesetzt, dass du sicher sein kannst, dass die Daten von einem vertrauenswürdigen Nutzer bereitgestellt und sämtliche Ingestion-Fehler angemessen behoben wurden (z. B. falls Elasticsearch zum Zeitpunkt außer Betrieb war, als Dokument 15123 indiziert werden sollte, dann muss noch immer etwas hinzugefügt werden). In diesem Szenario kann eine Verifizierung überflüssig werden.

X-Pack Security in der Version 5.0 und Shield in früheren Releases können für die nötige Sicherheit sorgen, indem vertrauenswürdigen Nutzern Zugriff gewährt wird und nicht-vertrauenswürdige Nutzer blockiert werden. Jedoch liegt es letztendlich bei dir, wie du mit Ingestion-Fehlern umgehen möchtest, da dies gänzlich davon abhängt, durch was die Datenaufnahme vorgenommen wird.

Fazit

Du hast es geschafft! Du hast dich durch meinen langen Blogbeitrag gearbeitet.

Ich hoffe, dass dir die Lektüre zum Thema Datenverifizierung und der Frage, wie die Strukturierung deiner Daten zu drastisch vereinfachten Ansätzen beitragen kann, gefallen hat. Wenn du über diese Art der Herangehensweise nachdenkst, beginne zu verstehen, wie auch andere Probleme durch eine alternative Betrachtung von Anfragen oder Daten und mit Hilfe reichhaltiger Features wie Aggregationen oder X-Pack-Security gelöst werden können.

Wir sind ständig auf der Suche nach kreativen Wegen, um interessante Probleme zu lösen. Dieses Thema ist dabei keine Ausnahme. Wie immer ermutigen wir unsere User diese Themen in unserem Forum zu diskutieren und GitHub-Issues zu allen sich ergebenen Problemen zu eröffnen. Zudem helfen wir auch gerne über Twitter (@pickypg – das bin ich) und IRC weiter.