· 8 Min. Lesezeit

Wie wir eine lokale SQL-Engine für DynamoDB gebaut haben (eine Datenbank ohne JOINs)

DynamoDB kann eine 100-GB-Tabelle exportieren, aber es kann keine aggregieren. Es gibt kein JOIN, kein GROUP BY, kein COUNT(*) — keine langsamen Versionen davon, gar keine — und selbst , AWS' eigene SQL-artige Oberfläche, fügt sie nicht hinzu. Also schreibt am Ende jedes Team dasselbe Wegwerf-Skript: einen paginieren, Zeilen in eine Map falten, die Antwort ausgeben, das Skript löschen.

DynoTables Workbench ist unsere Antwort auf dieses Skript: ein Tab, in dem du ein einziges echtes SELECT schreibst — tabellenübergreifendes JOIN … ON, WHERE, GROUP BY, HAVING, DISTINCT, CASE, Aggregate — gegen Tabellen, die nichts davon unterstützen. Dieser Beitrag ist, wie es unter der Haube funktioniert, der Parser, der uns stillschweigend belog, und warum wir später das Speichermodell der Engine herausgerissen haben.

Baue keine Query-Engine

Die Kernentscheidung war, es abzulehnen, einen relationalen Executor zu schreiben. DynamoDB-Seiten streamen in eine eingebettete SQLite-Datenbank, und SQLite — eine der am meisten getesteten Query-Engines der Welt — erledigt die relationale Arbeit:

which tables,which join keysrewritten SQLYour SQLCompilersql-parser-cstDynamoDBpage streamLocal SQLiteon diskResult grid

Jedes DynamoDB-Item landet in SQLite als sein rohes, typisiertes Envelope ({platform: {S: "ios"}}) in einer einzigen item-Spalte. Eine kleine benutzerdefinierte Funktion packt Attribute zur Query-Zeit aus, sodass dein SELECT platform zu einem attr(item, 'platform', …)-Aufruf kompiliert. Ein Detail in dieser Funktion ist tragend: Sie umschließt ihr JSON-Parsing mit einem Catch, weil die SQLite-Bindung den Parse-Fehler einer einzigen fehlerhaften Zeile in einen Abbruch des gesamten Prepared Statements verwandelt. Ohne den Catch würde eine einzige korrupte Zeile die Query killen.

Die ehrliche Einschränkung, die wir beibehalten haben: Die Zugriffsmuster-Physik von DynamoDB gilt weiterhin. Die To-Seite eines JOIN muss ein Primärschlüssel oder ein -Partition-Key sein — die Engine löst Joins per Key-Lookup gegen den Stream auf, nie per Kreuzprodukt-Scan. Wenn du beliebige Joins auf Nicht-Schlüssel-Attributen brauchst, ist das ein Modellierungsgespräch, kein Feature der Query-Engine.

Die Workbench: ein tabellenübergreifender SQL-JOIN, mit dem gejointen Ergebnis-Grid darunter.
Die Workbench: ein tabellenübergreifender SQL-JOIN, mit dem gejointen Ergebnis-Grid darunter.

Der Parser, der uns belog

Die erste Version nutzte einen beliebten SQL-Parser von der Stange. Über ein paar Wochen ließen sich vier unabhängige Korrektheits-Bugs auf ihn zurückführen:

  • SELECT DISTINCT wurde stillschweigend fallengelassen. Der Parser akzeptierte es und der Emitter ignorierte es — jede doppelte Zeile kam zurück. Der Bug-Report eines Nutzers lautete: „zeigt eine Tabelle aller Werte, keine Zähler.“
  • Alles Unbehandelte wurde zum literalen SQL-NULL. CASE, CAST, skalare Subqueries — der Fallback des Emitters für einen unbekannten Syntaxknoten war return 'NULL'. Queries liefen, gaben selbstbewusst falsche Antworten zurück und lösten keinen Fehler aus.
  • Die Groß-/Kleinschreibung von Bezeichnern verhielt sich nicht wie SQL. SELECT PLATFORM FROM t gab Nulls zurück, wenn das Attribut platform hieß — das Gegenteil von dem, worauf dich jede SQL-Datenbank trainiert.
  • Die Autovervollständigung widersprach dem Resolver und schlug Namen vor, die der Compiler dann nicht auflösen konnte.

Der Ersatz, sql-parser-cst, gewann bei einem Primitiv, das die Marktschau nirgendwo sonst fand: Sein Baum bewahrt sowohl den Rohtext als auch den normalisierten Namen jedes Bezeichners — sodass der Compiler platform von "PLATFORM" unterscheiden kann. Diese eine Unterscheidung lässt eine einzige Grammatik saubere ANSI-Semantik tragen: Nicht in Anführungszeichen gesetzte Bezeichner werden case-insensitiv aufgelöst, in Anführungszeichen gesetzte sind die case-sensitive Notausstiegsluke, genau wie bei Postgres oder SQLite. Er trägt außerdem eine Source-Range an jedem Knoten, sodass eine Diagnose das beanstandete Konstrukt unterstreicht statt des ganzen Statements.

Und wir ersetzten den return 'NULL'-Fallback durch eine Regel, die wir jetzt als Richtlinie behandeln: niemals ein stilles NULL. Der Default-Zweig des Emitters ist ein Fehler mit präziser Range. Window-Funktionen erhalten eine Nachricht, die sagt, warum, nicht nur nein:

Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.

Der Kompromiss, den wir eingegangen sind: eine Pre-1.0-Abhängigkeit, auf die exakte Version gepinnt, nur von Hand aktualisiert mit der Test-Suite als Gate. Wir haben in Betracht gezogen, den alten Parser zu patchen (er brauchte einen parallelen Pre-Tokenizer — jede Eingabe zweimal parsen), den Syntaxbaum des Editors zu nutzen (Token-Ebene, keine Klausel-Struktur) und selbst zu bauen (ein Großteil eines SQLite-Parsers, unbegrenzte Wartung). Ein vierfaches Korrektheitsversagen wiegt schwerer als all diese Bedenken.

Wissen, was nicht zu blockieren ist

Eine Diagnose stoppt dich bewusst nicht. Wenn ein Filter ein Attribut referenziert, dessen Schreibweise das Schema nicht bestätigen kann, können wir den kanonischen Fall nicht kennen — die serverseitigen Filter von DynamoDB sind case-sensitiv und sein Schema deklariert nur Schlüsselattribute. Das gibt eine Warnung aus, und Warnungen deaktivieren Run nie. Diese Art von Lektion haben wir bei unserer Validator-Arbeit auf die harte Tour gelernt: Ein Gate, das die häufigste legitime Query-Form ablehnt, ist schlimmer als gar kein Gate.

Das Limit hatte die falsche Form

Die erste Aggregations-Engine pufferte gescannte Zeilen in ein In-Memory-SQLite mit einer Größenobergrenze — 64 MB hier, 250 MB dort, und ein Pfad ohne Obergrenze, der die App bei einer großen Tabelle per OOM abschießen konnte. Erreichte man die Obergrenze, bekam man eine partielle Antwort mit einem Overflow-Flag.

Das Redesign begann mit einer Neurahmung: Der Konkurrent ist das Wegwerf-Skript, das ein Entwickler stattdessen schreiben würde. Und ein kompetentes Skript puffert-dann-begrenzt nicht — es streamt und faltet jede Seite in eine laufende Summe mit beschränktem Speicher. Die In-Memory-Obergrenze war nicht zu klein; sie hatte die falsche Form. Schlimmer noch: Ein partielles Aggregat ist nicht „unvollständig“ — es ist falsch. Der Moment, der das konkret machte: Unsere eigene KI-Demo erzählte selbstbewusst ein partielles „Top-Spender“-Ranking als Fakt.

Die neu gebaute Compute-Engine arbeitet so, wie es das Skript tut, plus alles, worum sich das Skript nie kümmert:

  • Ein Planer klassifiziert jede Query in eine von drei Bahnen: Stream-Fold (einfache algebraische Aggregate — COUNT/SUM/AVG/ MIN/MAX — Seite für Seite außerhalb des Hauptprozesses gefaltet; die Größe blockiert eine faltbare Query nie, sie dauert nur länger), Materialize (Sortierungen, schlüsselbegrenzte Joins — auf plattengestütztes SQLite ausgelagert, keine Speicherobergrenze) oder eine ehrliche Ablehnung, die das richtige schwergewichtige Tool (DynamoDBs S3-Export plus Athena) für den Rest empfiehlt, den wir nicht bedienen können.
  • Die Fold-Klassifikation ist bewusst eng. Ein HAVING, ein ORDER BY, eine nackte Spalte neben dem Aggregat — jedes davon erzwingt die Materialize-Bahn. Ein Fold, der eine Klausel ignorierte und trotzdem „exakt“ meldete, wäre genau der falsch-aber-selbstbewusste Bug, zu dessen Verhinderung dieser Planer existiert.
  • Die Exaktheit ist Teil des Ergebnisses. Aggregat-Spalten zeigen ein Partial-Badge, solange noch Seiten streamen, und lassen es erst fallen, wenn der Scan leergelaufen ist. Selbst die arithmetische Ehrlichkeit wird verfolgt: Ganzzahlsummen bleiben über 2⁵³ hinaus exakt (als 64-Bit-Integer gespeichert), während eine gebrochene Summe, die die Float-Mantisse übersteigt, sich selbst als partial: precision markiert, statt still zu runden.
  • Sicherungen ersetzen Popups für Maschinen. Die interaktive App fragt vor einem Scan über fünf Millionen Items nach; der KI-Assistent und MCP-Aufrufer bekommen stattdessen Scan-Budgets und Abbrüche an Seitengrenzen. Und die Ergebnis-Hülle, die das Modell sieht, ist bewusst gestutzt — Kostenklasse und Größenschätzungen werden zurückgehalten, weil das Offenlegen einer veralteten Schätzung das Modell einlädt, sie als Fakt zu erzählen.

Eine Kostennotiz, die intern überraschte: Für ein einmaliges Aggregat ist ein Live-Scan grob 6× günstiger als das Generieren eines DynamoDB→S3-Exports — weshalb die skriptförmige Bahn der Standard ist und das Warehouse die Empfehlung für wiederholte schwere Analytik, nicht der Reflex. (Der Preis-Rechner berechnet den Preis für einen kompletten Durchlauf deiner eigenen Tabelle.)

Ist es sicher, SQL auf die Produktion zu richten?

Die Workbench ist strukturell schreibgeschützt. Das finale SQL, das die lokale Engine erreicht, durchläuft ein Defense-in-Depth-Gate, das alles, was kein einzelnes SELECT ist, tokenisiert und ablehnt — als Vertragsverletzung, nicht als Nutzerfehler, weil der Compiler es niemals hätte erzeugen dürfen. Das SQLite-Substrat läuft mit deaktivierten gefährlichen Built-ins, und Schreibvorgänge nach DynamoDB haben in der gesamten App genau einen Pfad: den überprüfbaren Staging-Bereich, den SQL nicht erreichen kann.

Was übertragbar ist, wenn du selbst eine baust

  • Schreibe keine Query-Engine; schreibe einen getreuen Compiler auf eine, die bereits existiert. Deine Bugs werden in den Nahtstellen leben (Bezeichner-Schreibweise, Typ-Envelopes, korrupte Zeilen), nicht im Join-Algorithmus.
  • Verlange Quote-Provenienz von deinem Parser. Wenn er name nicht von "NAME" unterscheiden kann, kannst du die Case-Semantik von SQL nicht implementieren, und deine Nutzer finden es vor dir heraus.
  • Mach „unbehandelte Syntax“ zu einem lauten Fehler, nie zu einem Default-Wert. Stilles NULL ist, wie selbstbewusst falsche Antworten ausgeliefert werden.
  • Benchmarke dein Design gegen das Wegwerf-Skript, das dein Nutzer schreiben würde. Wenn das Skript streamt und du pufferst, hast du die falsche Form gebaut.
  • Behandle partielle Aggregate als falsche Antworten, die eine Kennzeichnung brauchen, nicht als Teilpunkte.

Die Workbench-Doku behandelt die alltägliche Feature-Oberfläche, und SQL für DynamoDB kartiert, was im gesamten Ökosystem funktioniert. Oder lade einfach DynoTable herunter, öffne einen Workbench-Tab mit ⌘⌥Q und führe ein JOIN gegen die Datenbank aus, die keins hat.

Mit DynamoDB ohne die Console arbeiten

DynoTable ist ein schneller Desktop-Client für DynamoDB — durchsuche Tabellen, führe SQL-artige Queries aus und bearbeite Items lokal.