Vai al contenuto
jsonbeautifiers
Italiano

Cosa si rompe davvero quando i file JSON diventano grandi

Ogni soglia di dimensione negli strumenti JSON deriva da un limite preciso, e quello di cui ti avvertono di solito è quello sbagliato.

Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.

L’esportazione arriva come un unico file .json da 40 MB. L’evidenziatore di sintassi dell’editor si arrende, e incollarlo in un formattatore web lascia la scheda bianca per parecchi secondi. Qualcuno suggerisce lo streaming, qualcun altro dice che la ricorsione farà esplodere lo stack, ed entrambi i consigli puntano a problemi che non hai ancora.

La domanda utile non è «questo file è grande» ma «quale limite sto per colpire». Ce ne sono circa quattro, e arrivano in un ordine fisso.

La scala delle dimensioni

Dimensione Cosa succede
1 MB Niente. L’analisi nativa impiega pochi millisecondi, l’albero di oggetti sta su decine di MB. Funziona ogni strumento, compresi quelli scritti male.
10 MB Il JSON.parse nativo impiega circa 158 ms. Un tokenizzatore scritto in JavaScript, come quello di questo sito, impiega circa 780 ms per formattare lo stesso input. Costruirci sopra un albero di nodi navigabile costa circa 294 MB di heap. Tutto funziona ancora, ma un’analisi sincrona dura ormai abbastanza da sembrare un blocco.
100 MB Estrapola gli stessi numeri: diversi secondi di analisi in un tokenizzatore JS e un albero misurato in gigabyte. È qui che le schede del browser cominciano a morire per memoria anziché per tempo. Lato server resta ordinaria amministrazione.
Oltre 500 MB V8 limita una singola stringa a 536.870.888 caratteri, circa 512 MB di ASCII. In Chrome e Node il file non può proprio essere letto in una stringa, quindi nessuno strumento costruito su quel passaggio può toccarlo, comunque sia scritto.

Le prime tre righe sono misurate sugli strumenti di questo sito; il tuo parser darà numeri diversi. La quarta non è un dato di prestazioni, è un tetto rigido del motore.

Il muro dei 512 MB

Ogni strumento JSON da browser segue lo stesso percorso: leggere il file in una stringa, passare la stringa a un parser. È in quel primo passaggio che un file molto grande muore. La lunghezza massima di stringa del motore è una costante fissa, e FileReader.readAsText o Response.text() su qualcosa che la supera sollevano un errore prima che il tuo codice giri.

La costante dipende dal motore. V8 si ferma a 536.870.888 caratteri (require('buffer').constants.MAX_STRING_LENGTH su Node a 64 bit), SpiderMonkey a 1.073.741.822 e JavaScriptCore a 2.147.483.647. Firefox e Safari sopravvivono quindi a file che Chrome rifiuta, ma tutti e tre hanno un tetto, perciò uno strumento che deve funzionare ovunque si progetta contro quello di V8.

Un parser a blocchi su un ReadableStream si spinge un po’ più in là, perché non materializza mai il documento come un’unica stringa. Questo risolve il tetto sulla stringa e non il problema successivo, cioè che anche il risultato analizzato deve stare in memoria. Tutto ciò che supera qualche centinaio di MB non è un problema da browser: spostalo in una shell, in un runtime di linguaggio o in un database.

Perché l’albero analizzato è così più grande del file

Che 10 MB di testo diventino circa 294 MB di heap sorprende, ma l’aritmetica non è misteriosa. Prendi {"id":1,"ok":true}, cioè 18 byte su disco. In memoria è:

  • Un oggetto con un’intestazione e un puntatore alla sua forma o mappa delle proprietà.
  • Uno slot largo quanto un puntatore per ogni proprietà prima dei valori, quattro byte in una build a 64 bit con compressione dei puntatori e otto senza.
  • Ogni valore stringa che porta la propria intestazione, il campo lunghezza e i dati dei caratteri, e ogni chiave stringa che fa lo stesso, a meno che il motore non l’abbia internata.
  • I valori che non sono piccoli interi memorizzati come celle di heap separate con le proprie intestazioni, raggiunte tramite un altro puntatore.

Il sovraccarico è per nodo, non per byte, quindi il rapporto peggiora man mano che i dati si fanno più strutturati: 10 MB di un’unica stringa lunga costano poco, 10 MB di ottantamila oggetti piccoli con otto chiavi ciascuno no. Il risultato di un semplice JSON.parse è più leggero di un albero da visualizzatore che porta metadati per nodo, ma resta un multiplo della sorgente. Metti in conto un ordine di grandezza, poi misura la tua forma.

Il mito della ricorsione, corretto

L’avvertimento classico è che il JSON molto annidato fa esplodere lo stack in fase di analisi. In un browser, su un motore attuale, non è più vero. V8 ha sostituito il parser JSON ricorsivo con uno iterativo nella v7.6, e legge un milione di livelli di annidamento senza fiatare. Misurato su Node v24.15.0 con V8 13.6.233.17, l’analisi andava bene a profondità che un tempo erano fatali.

Il fallimento si è spostato dall’altra parte. JSON.stringify continua a ricorrere e solleva un RangeError qualche migliaio di livelli più in basso, sulla stessa build intorno a 4.800:

const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // benissimo, un milione di livelli

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

Quel numero non è una costante. Si sposta con la dimensione dello stack con cui il runtime è partito e con qualsiasi altra cosa ci sia sullo stack al momento della chiamata, quindi non è una cifra su cui progettare.

Un servizio può dunque accettare un payload ostile, analizzarlo senza lamentarsi, memorizzarlo, e poi cadere quando prova a registrarlo a log o a riemetterlo. I limiti di profondità continuano a stare sul confine d’ingresso, anche se è proprio il lato d’ingresso a sopravvivere.

Altri runtime sono meno indulgenti in entrambe le direzioni. Il modulo json di CPython ricorre sia in decodifica sia in codifica, quindi un input molto annidato solleva RecursionError già in entrata. Quanto in profondità arrivi prima dipende dalla build: lo scanner in C di CPython 3.14 ha ceduto qui verso i 14.000 livelli, ben al di sotto di ciò che V8 accetta. Se attraversi confini di linguaggio, la profondità che il tuo servizio tollera è quella del suo passaggio più severo.

Streaming, con le parti che si sbagliano

Streaming significa non tenere mai l’intero documento. Ogni linguaggio diffuso ha un parser pull per questo.

ijson di Python restituisce i valori che corrispondono a un percorso prefisso. Il prefisso records.item significa «ogni elemento dell’array sotto la chiave di primo livello records», e item è il token letterale per un elemento di array, non un segnaposto per un nome di campo. È il dettaglio che si sbaglia al primo utilizzo:

import ijson

total = 0
with open("events.json", "rb") as f:            # modalità binaria, non testo
    for record in ijson.items(f, "records.item"):
        if record["status"] == "failed":
            total += 1

print(total)

ijson sceglie il backend più veloce disponibile al momento dell’import, e un backend in C è molto più rapido del ripiego in puro Python. Controlla quale ti è toccato prima di concludere che lo streaming è lento.

encoding/json di Go fa la stessa cosa con Decoder, e lì la trappola è diversa. Chiamare Decode una volta su un array di primo livello decodifica l’intero array in un unico slice, cioè esattamente ciò che volevi evitare. Devi prima consumare la parentesi di apertura come token, poi decodificare elemento per elemento:

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) } // legge la '['

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

In Node, stream-json (con Pick per selezionare un sottoalbero e StreamArray per emettere gli elementi) o il più vecchio JSONStream fanno l’equivalente, e il JsonParser di Jackson ti dà lo stesso ciclo di token sulla JVM. Tutti comprano un profilo di memoria costante rinunciando a qualsiasi cosa richieda l’intero documento in una volta.

Il problema era il formato

Fare streaming di un array JSON gigantesco è lavoro che stai facendo perché quel file non sarebbe mai dovuto essere un unico array. NDJSON, un valore JSON completo per riga, elimina l’intera categoria di problemi: leggi una riga, la analizzi, la lasci andare, e la memoria è limitata dal tuo record singolo più grande. Si spezza con split, si greppa come testo, si accoda senza riscrivere, e sopravvive a una scrittura troncata perdendo un record invece del file.

Se oggi sei bloccato con un array e domani vuoi le righe, NDJSON in JSON converte in entrambe le direzioni, e il visualizzatore JSON apre l’uno e l’altro.

Perché 780 ms sono una scheda rotta

Un’analisi sincrona trattiene il thread principale. Non si disegna nulla e nessun clic viene registrato. Oltre i 100 ms circa un’interazione smette di sembrare istantanea, e oltre il secondo la pagina si legge come congelata e l’utente allunga la mano verso il ricaricamento. Ricaricare fa ripartire l’analisi.

Il rimedio non è un parser più veloce, è togliere il lavoro dal thread che disegna. Il beautifier di questo sito analizza e formatta in un Web Worker, così la scheda continua a disegnare e lo stato di avanzamento è reale anziché una bugia pubblicata appena prima di una chiamata bloccante. Quella scelta strutturale conta più di qualsiasi micro-ottimizzazione nel tokenizzatore.

Quando la risposta non è uno strumento

Alcune cose vale la pena farle prima di ricorrere a tutto quanto sopra.

Filtra prima con jq, così ciò che carichi è piccolo:

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

Nota che jq liscio legge l’intero documento in memoria. Per file più grandi della RAM, jq --stream è la modalità che non lo fa, al prezzo di una sintassi a eventi assai più strana.

Per una fetta che vuoi soltanto guardare, lo strumento di filtro fa la stessa selezione nel browser, e il minificatore toglie gli spazi di formattazione, che su un export indentato sono una frazione reale dei byte.

E a volte la risposta onesta è che questo non è un problema di file. Se stai greppando ripetutamente un export da 2 GB, scriverlo una volta in SQLite o DuckDB e interrogarlo lì rende economica ogni domanda successiva. Un file che continui a rianalizzare ti ha già detto che vuole essere una tabella.