Schiffstreffen in Millisekunden erkennen mit Geohash-Zellen und Neo4j
Das Problem, jedes Schiffspaar zu prüfen
Die Erkennung von Schiffstreffen klingt nach einem einfachen Geometrieproblem, bis man sich den Maßstab ansieht. Eine Engine zur Erkennung maritimer Anomalien, die wir dafür gebaut haben, verarbeitet AIS-Positionsmeldungen von Tausenden Schiffen, die laufend über Kafka eintreffen. Jedes Schiffspaar gegen jedes andere zu prüfen, ist eine quadratische Operation, und bei diesem Volumen läuft das nicht annähernd im Millisekundenbereich. Weder die naive Abfrage noch das naive Schema skaliert.
Geohash-Zellen statt Rohkoordinaten
Statt rohe Breiten- und Längengrade zu vergleichen, ordnen wir jede Positionsmeldung einer Geohash-Zelle der Präzisionsstufe 6 zu, was Zellen von rund 1,2 Kilometern Kantenlänge ergibt. Zwei Schiffe können nur relevant nah sein, wenn sie eine Zelle teilen oder in benachbarten Zellen liegen. Der Suchraum schrumpft von jedem Schiff gegen jedes Schiff auf jedes Schiff gegen die Handvoll Schiffe in Nachbarzellen. Das ist eine Designentscheidung über die Granularität, die diese Erkennungsaufgabe braucht, keine Aussage über GPS-Genauigkeit, und es ist die eine Änderung, die das Problem bei unserem Ingest-Volumen handhabbar gemacht hat.
Warum ein Graph, nicht nur räumliches SQL
Wir speichern Schiffe und ihre Näherungsereignisse als Graph in Neo4j statt als Zeilen in einer relationalen Tabelle. Ein Treffen ist nicht zwei Schiffe, die sich einmal nahekommen. Es ist eine Beziehung, die anhält, sich wiederholt oder eskaliert, und diese Art von Beziehung gehört natürlich in einen Graphen. Teilen sich zwei Schiffe lange genug eine Geohash-Zelle, um als Treffen zu zählen, schreiben wir eine vorberechnete ENCOUNTERED-Kante zwischen ihren :Vessel-Knoten, indiziert nach beiden Schiffskennungen und dem Zeitstempel. Spätere Fragen, ob sich dieses Paar schon einmal getroffen hat, wie oft, in der Nähe welches Hafens, laufen als Cypher-Traversierung über indizierte Knoten statt als Join gegen eine wachsende Historientabelle.
Den Datenstrom konsistent halten
- Backpressure: Der Kafka-Consumer verarbeitet AIS-Nachrichten in dem Tempo, das die Geohash- und Graph-Writer tatsächlich mithalten können, statt unbegrenzt Nachrichten im Speicher zu puffern, wenn ein nachgelagerter Schreibvorgang langsamer wird.
- Idempotente Projektion: AIS-Feeds senden Nachrichten erneut und doppelt, daher wird jedes Positionsupdate mit Schiffskennung und Nachrichtenzeitstempel als Schlüssel geschrieben, und ein Duplikat aktualisiert dieselbe Zeile, statt eine neue zu erzeugen.
- Outbox: Wird ein Treffen erkannt, wird das Ereignis, das nachgelagerte Consumer abonnieren, in derselben Transaktion wie die Graph-Kante in eine Outbox-Tabelle geschrieben und danach asynchron veröffentlicht, sodass ein Consumer nie ein Ereignis zu einer Kante sieht, die ein späteres Rollback entfernt hat.
Was uns Spring WebFlux hier bringt
Der Ingest-Pfad läuft auf Spring WebFlux mit Java 21, weil die Last vom Warten auf I/O dominiert wird, dem Kafka-Consumer, den Neo4j-Schreibvorgängen, nicht von CPU-gebundener Berechnung. Eine reaktive Pipeline lässt eine kleine Anzahl von Threads eine große Zahl gleichzeitiger Positionsströme verarbeiten, ohne dass ein langsamer Schreibvorgang die nächste Nachricht in der Warteschlange blockiert. Das zählt mehr als die Framework-Wahl für sich genommen, denn erst dadurch hält die oben beschriebene Backpressure-Behandlung unter echter Last, nicht nur auf dem Diagramm.
Was wir einem Team raten würden, das etwas Ähnliches baut
Legen Sie Ihre räumliche Granularität als bewusste Designentscheidung fest, bevor Sie das Schema anfassen, greifen Sie nicht standardmäßig zu Rohkoordinaten, nur weil sie präziser wirken. Modellieren Sie Beziehungen, die anhalten oder sich wiederholen, als Graph, nicht als Zeilen in einer Tabelle, die Sie ständig gegen sich selbst joinen. Und behandeln Sie Nachrichtenduplikate und Backpressure in jeder Streaming-Pipeline als Gewissheit, nicht als Randfall, denn bei echten Ingest-Volumen zeigen sie sich in der ersten Woche, nicht im ersten Jahr.