Zum Inhalt springen
jsonbeautifiers
Deutsch

Was wirklich kaputtgeht, wenn JSON-Dateien groß werden

Jede Größenschwelle im JSON-Werkzeugkasten stammt von einer bestimmten Grenze, und die Grenze, vor der man Sie warnt, ist meist die falsche.

Jede Aussage auf dieser Seite ist entweder gemessen oder belegt. Wo sie keines von beidem ist, steht das dabei.

Der Export landet als einzelne 40-MB-Datei .json. Die Syntaxhervorhebung des Editors gibt auf, und wenn man den Inhalt in einen Web-Formatierer einfügt, wird der Tab mehrere Sekunden lang weiß. Jemand schlägt Streaming vor, jemand anderes sagt, die Rekursion sprenge den Stack, und beide Ratschläge zielen auf Probleme, die Sie noch gar nicht haben.

Die nützliche Frage lautet nicht „ist diese Datei groß“, sondern „an welche Grenze stoße ich gleich“. Es gibt ungefähr vier davon, und sie kommen in einer festen Reihenfolge.

Die Größenleiter

Größe Was passiert
1 MB Nichts. Natives Parsen dauert ein paar Millisekunden, der Objektbaum liegt bei zweistelligen MB. Jedes Werkzeug funktioniert, auch die schlecht geschriebenen.
10 MB Natives JSON.parse braucht etwa 158 ms. Ein in JavaScript geschriebener Tokenizer wie der dieser Seite braucht rund 780 ms, um dieselbe Eingabe zu formatieren. Einen navigierbaren Knotenbaum dafür zu bauen kostet etwa 294 MB Heap. Alles funktioniert noch, aber ein synchroner Parse dauert nun lange genug, um wie ein Absturz auszusehen.
100 MB Rechnen Sie dieselben Zahlen hoch: mehrere Sekunden Parsen in einem JS-Tokenizer und ein Baum in Gigabyte. Hier beginnen Browser-Tabs an Speicher statt an Zeit zu sterben. Serverseitig ist das weiterhin Routine.
500 MB und mehr V8 begrenzt eine einzelne Zeichenkette auf 536.870.888 Zeichen, etwa 512 MB ASCII. In Chrome und Node lässt sich die Datei überhaupt nicht in eine Zeichenkette einlesen, also kann kein Werkzeug, das auf diesem Schritt aufbaut, sie anfassen, egal wie es geschrieben ist.

Die ersten drei Zeilen sind am Werkzeug dieser Seite gemessen; Ihr Parser wird abweichen. Die vierte ist keine Leistungszahl, sondern eine harte Decke in der Engine.

Die 512-MB-Wand

Jedes Browser-JSON-Werkzeug geht denselben Weg: die Datei in eine Zeichenkette lesen, die Zeichenkette einem Parser übergeben. An diesem ersten Schritt stirbt eine sehr große Datei. Die maximale Zeichenkettenlänge der Engine ist eine feste Konstante, und FileReader.readAsText oder Response.text() werfen bei allem darüber einen Fehler, bevor Ihr Code läuft.

Die Konstante hängt an der Engine. V8 hört bei 536.870.888 Zeichen auf (require('buffer').constants.MAX_STRING_LENGTH auf 64-Bit-Node), SpiderMonkey bei 1.073.741.822 und JavaScriptCore bei 2.147.483.647. Firefox und Safari überstehen daher Dateien, die Chrome ablehnt, aber alle drei haben eine Decke, also wird ein Werkzeug, das überall laufen soll, gegen die von V8 entworfen.

Ein blockweiser Parser über einen ReadableStream kommt etwas weiter, weil er das Dokument nie als eine Zeichenkette materialisiert. Das löst die Zeichenkettengrenze und nicht das nächste Problem, nämlich dass auch das geparste Ergebnis in den Speicher passen muss. Alles jenseits von ein paar hundert MB ist kein Browser-Problem: Verlagern Sie es in eine Shell, eine Sprachlaufzeit oder eine Datenbank.

Warum der geparste Baum so viel größer ist als die Datei

Dass aus 10 MB Text etwa 294 MB Heap werden, überrascht, aber die Rechnung ist kein Rätsel. Nehmen Sie {"id":1,"ok":true}, 18 Byte auf der Platte. Im Speicher ist das:

  • Ein Objekt mit einem Header und einem Zeiger auf seine Form oder Eigenschaftskarte.
  • Ein Slot in Zeigerbreite je Eigenschaft vor den Werten, vier Byte bei einem 64-Bit-Build mit Zeigerkompression und acht ohne.
  • Jeder Zeichenkettenwert mit eigenem Header, Längenfeld und Zeichendaten, und jeder Zeichenkettenschlüssel ebenso, sofern die Engine ihn nicht interniert hat.
  • Werte, die keine kleinen Ganzzahlen sind, als eigene Heap-Zellen mit eigenen Headern gespeichert und über einen weiteren Zeiger erreicht.

Der Zusatzaufwand fällt je Knoten an, nicht je Byte, das Verhältnis wird also schlechter, je strukturierter die Daten sind: 10 MB aus einer langen Zeichenkette sind billig, 10 MB aus achtzigtausend kleinen Objekten mit je acht Schlüsseln nicht. Das Ergebnis eines schlichten JSON.parse ist leichter als ein Viewer-Baum mit Metadaten je Knoten, bleibt aber ein Vielfaches der Quelle. Kalkulieren Sie eine Größenordnung ein und messen Sie dann Ihre eigene Form.

Der Rekursionsmythos, korrigiert

Die Standardwarnung lautet, tief verschachteltes JSON sprenge beim Parsen den Stack. Im Browser, auf einer aktuellen Engine, stimmt das nicht mehr. V8 hat den rekursiven JSON-Parser in v7.6 durch einen iterativen ersetzt, und der liest eine Million Verschachtelungsebenen ohne Klage. Gemessen auf Node v24.15.0 mit V8 13.6.233.17 lief das Parsen in Tiefen glatt, die früher tödlich waren.

Der Fehlschlag ist auf die andere Seite gewandert. JSON.stringify rekursiert weiterhin und wirft ein paar tausend Ebenen tiefer einen RangeError, auf demselben Build irgendwo bei 4.800:

const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // gut, eine Million Ebenen

JSON.stringify(deep);              // RangeError: Maximum call stack size exceeded

Diese Zahl ist keine Konstante. Sie verschiebt sich mit der Stackgröße, mit der die Laufzeit gestartet ist, und mit allem anderen, was beim Aufruf auf dem Stack liegt; sie taugt also nicht als Entwurfsgröße.

Ein Dienst kann also ein feindliches Payload annehmen, klaglos parsen, speichern und dann umkippen, wenn er versucht, denselben Wert zu protokollieren oder wieder auszugeben. Tiefenbegrenzungen gehören weiterhin an die Eingabegrenze, obwohl ausgerechnet die Eingabeseite es übersteht.

Andere Laufzeiten sind in beide Richtungen weniger nachsichtig. Das json-Modul von CPython rekursiert beim Dekodieren wie beim Kodieren, tief verschachtelte Eingabe löst also schon beim Hereinkommen einen RecursionError aus. Wie tief Sie vorher kommen, hängt vom Build ab: Der C-Scanner in CPython 3.14 gab hier bei knapp 14.000 Ebenen auf, weit unter dem, was V8 akzeptiert. Wenn Sie Sprachgrenzen überqueren, ist die Tiefe, die Ihr Dienst verträgt, die Tiefe seines striktesten Zwischenschritts.

Streaming, mit den Stellen, die man falsch macht

Streaming heißt, das ganze Dokument nie zu halten. Jede verbreitete Sprache hat dafür einen Pull-Parser.

Pythons ijson liefert Werte, die zu einem Präfixpfad passen. Das Präfix records.item bedeutet „jedes Element des Arrays unter dem obersten Schlüssel records“, und item ist das wörtliche Token für ein Array-Element, kein Platzhalter für einen Feldnamen. Das ist das Detail, das man beim ersten Mal falsch macht:

import ijson

total = 0
with open("events.json", "rb") as f:            # Binärmodus, nicht Text
    for record in ijson.items(f, "records.item"):
        if record["status"] == "failed":
            total += 1

print(total)

ijson wählt beim Import das schnellste verfügbare Backend, und ein C-Backend ist weit schneller als der reine Python-Rückfall. Prüfen Sie, welches Sie bekommen haben, bevor Sie schließen, Streaming sei langsam.

Gos encoding/json macht dasselbe mit Decoder, und dort ist die Falle eine andere. Ein einziger Decode-Aufruf auf ein Array der obersten Ebene dekodiert das gesamte Array in einen Slice, genau das, was Sie vermeiden wollten. Sie müssen zuerst die öffnende Klammer als Token konsumieren und dann Element für Element dekodieren:

f, err := os.Open("events.json")
if err != nil { log.Fatal(err) }
defer f.Close()

dec := json.NewDecoder(f)
if _, err := dec.Token(); err != nil { log.Fatal(err) } // liest die '['

for dec.More() {
    var r Record
    if err := dec.Decode(&r); err != nil { log.Fatal(err) }
    process(r)
}

In Node erledigen stream-json (mit Pick zur Auswahl eines Teilbaums und StreamArray zur Ausgabe der Elemente) oder das ältere JSONStream das Äquivalent, und Jacksons JsonParser gibt Ihnen dieselbe Token-Schleife auf der JVM. Alle erkaufen ein konstantes Speicherprofil damit, auf alles zu verzichten, was das ganze Dokument auf einmal braucht.

Das Format war das Problem

Ein riesiges JSON-Array zu streamen ist Arbeit, die Sie leisten, weil die Datei nie ein einziges Array hätte sein dürfen. NDJSON, ein vollständiger JSON-Wert je Zeile, beseitigt die ganze Problemklasse: Sie lesen eine Zeile, parsen eine Zeile, lassen sie fallen, und der Speicher ist durch Ihren größten Einzeldatensatz begrenzt. Es lässt sich mit split zerteilen, wie Text grepen, ohne Neuschreiben anhängen und übersteht einen abgeschnittenen Schreibvorgang mit dem Verlust eines Datensatzes statt der Datei.

Wenn Sie heute mit einem Array festsitzen und morgen Zeilen wollen: NDJSON zu JSON konvertiert in beide Richtungen, und der JSON-Viewer öffnet beides.

Warum 780 ms ein kaputter Tab sind

Ein synchroner Parse hält den Hauptthread fest. Nichts wird gezeichnet, kein Klick registriert. Jenseits von etwa 100 ms fühlt sich eine Interaktion nicht mehr sofort an, und jenseits einer Sekunde liest sich die Seite als eingefroren und die Nutzerin greift zum Neuladen. Neuladen startet den Parse von vorn.

Die Lösung ist kein schnellerer Parser, sondern die Arbeit vom Thread wegzunehmen, der rendert. Der Beautifier dieser Seite parst und formatiert in einem Web Worker, der Tab zeichnet also weiter und der Fortschrittszustand ist echt statt eine Lüge, die kurz vor einem blockierenden Aufruf gepostet wurde. Diese strukturelle Entscheidung zählt mehr als jede Mikrooptimierung im Tokenizer.

Wenn die Antwort kein Werkzeug ist

Manches lohnt sich, bevor Sie zu irgendetwas davon greifen.

Filtern Sie zuerst mit jq, damit das, was Sie laden, klein ist:

jq -c '.records[] | select(.status == "failed")' events.json > failed.ndjson

Beachten Sie, dass schlichtes jq das ganze Dokument in den Speicher liest. Für Dateien, die größer als der Arbeitsspeicher sind, ist jq --stream der Modus, der das nicht tut, um den Preis einer deutlich fremdartigeren ereignisbasierten Syntax.

Für eine Scheibe, die Sie nur ansehen wollen, nimmt das Filterwerkzeug dieselbe Auswahl im Browser vor, und der Minifier entfernt den Formatierungs-Leerraum, der bei einem eingerückten Export einen echten Anteil der Bytes ausmacht.

Und manchmal ist die ehrliche Antwort, dass das kein Dateiproblem ist. Wenn Sie einen 2-GB-Export immer wieder durchsuchen, macht es jede spätere Frage billig, ihn einmal nach SQLite oder DuckDB zu schreiben und dort abzufragen. Eine Datei, die Sie immer wieder neu parsen, hat Ihnen bereits gesagt, dass sie eine Tabelle sein möchte.