Interrogare le blockchain con SQL · Lezione 6 di 6
Prestazioni, costi e riproducibilità
10 min di lettura
Le tabelle blockchain con la cronologia completa arrivano a miliardi di righe e la stessa domanda può costare pochi centesimi e richiedere pochi secondi, oppure costare cifre considerevoli e richiedere un'ora, a seconda di come è scritta la query. Le abitudini che mantengono le query veloci ed economiche (ambiti ristretti, intervalli fissati, versioni esplicite) sono anche quelle che rendono riproducibili i risultati, trasformando così l'esito di una query in una prova. Questa lezione tratta entrambi gli aspetti e si conclude spiegando come verificare un risultato rispetto a un nodo.
Come i motori di analisi leggono i dati
I data warehouse e i motori per data lake archiviano i dati per colonna, non per riga. Una query legge soltanto le colonne che nomina, quindi SELECT * su una tabella delle transazioni legge anche la voluminosa colonna input contenente i dati di chiamata, anche quando servono solo tre colonne più piccole. Nei servizi con tariffazione a consumo per scansione, il costo è proporzionale ai byte letti: indicare le colonne è quindi il primo modo per controllare i costi.
Il secondo è il partizionamento. Le tabelle di grandi dimensioni sono suddivise in partizioni, di solito per giorno del timestamp del blocco o per intervallo di blocchi, archiviate come file o segmenti separati. Quando un filtro sulla colonna di partizionamento esclude una partizione, il motore la salta completamente: si tratta dell'eliminazione delle partizioni. All'interno delle partizioni, i dati possono essere raggruppati (ordinati) per una colonna come il numero del blocco o l'indirizzo; i motori memorizzano i valori minimo e massimo per ogni segmento, così da poter saltare quelli che non possono soddisfare la condizione.
Alcune intuizioni da correggere:
- Nella maggior parte dei data warehouse,
LIMIT 10non rende meno costosa una scansione: il filtro e la scansione avvengono prima del limite. - Un filtro su una colonna non usata per il partizionamento (come
to_address) non elimina partizioni, anche se il raggruppamento per quella colonna può essere utile. - La maggior parte dei servizi offre un'esecuzione di prova o
EXPLAINche indica i byte o le partizioni che una query leggerà. Usali prima di eseguire query sull'intera cronologia.
La scala aiuta a stimare i costi. Ethereum produce un blocco ogni slot di 12 secondi, quindi circa 7.200 blocchi al giorno; Bitcoin circa 144. Un intervallo di una settimana su Ethereum corrisponde a circa 50.000 blocchi, una piccola frazione dei più di 20 milioni presenti nella cronologia della blockchain.
Filtri per partizione e intervallo di blocchi
Gli analisti ragionano in termini di altezze dei blocchi, mentre le tabelle sono partizionate per tempo. Il metodo affidabile consiste nel convertire prima l'intervallo di blocchi in una finestra temporale, quindi applicare entrambi i filtri.
-- Passaggio 1: trova la finestra temporale che copre l'intervallo di blocchi
SELECT MIN(block_timestamp) AS t0, MAX(block_timestamp) AS t1
FROM blocks
WHERE number BETWEEN 21100000 AND 21107199;
-- Passaggio 2: filtra la colonna di partizionamento usando valori letterali, poi i blocchi
SELECT transaction_hash, from_address, to_address, amount_raw
FROM token_transfers
WHERE block_timestamp >= TIMESTAMP '2024-11-01 00:00:00' -- passaggio 1, intervallo ampliato
AND block_timestamp < TIMESTAMP '2024-11-05 00:00:00' -- fino a comprendere giorni interi
AND block_number BETWEEN 21100000 AND 21107199 -- intervallo esatto
AND token_address = '0x7a11…0001';
Il passaggio 1 legge la piccola tabella blocks. Il passaggio 2 usa il filtro temporale affinché il motore legga solo alcune partizioni giornaliere, e il filtro sui blocchi per restituire esattamente le righe desiderate. Ampliare la finestra fino a comprendere giorni interi costa poco e protegge dai casi limite dei timestamp.
Due avvertenze. Molti motori eliminano le partizioni solo quando il filtro di partizionamento confronta la colonna con costanti; una join o una sottoquery che fornisce la finestra potrebbe quindi eseguire una scansione completa. Passa i valori come valori letterali o parametri di query. Inoltre, racchiudere la colonna di partizionamento in una funzione, ad esempio CAST(block_timestamp AS DATE) = …, può impedire l'eliminazione delle partizioni in alcuni motori; è preferibile confrontare la colonna non modificata con un intervallo.
Un ulteriore risparmio si ottiene estraendo i dati una volta e svolgendo le iterazioni in locale. Un'indagine prevede in genere decine di varianti della stessa domanda sugli stessi indirizzi e blocchi. Invece di scansionare ogni volta le tabelle con la cronologia completa, esegui una query ben filtrata che recuperi tutti i trasferimenti che riguardano gli indirizzi del caso nell'intervallo di blocchi del caso, salva i dati in una piccola tabella o in un file Parquet e svolgi lì l'analisi, per esempio con un motore integrato su un portatile. La scansione costosa viene eseguita una sola volta, le iterazioni sono quasi gratuite e l'estrazione salvata funge anche da istantanea fissa delle prove, favorendo la riproducibilità oltre al risparmio.
Campionamento
Il campionamento è utile per esplorare una tabella sconosciuta, verificare la logica di una query e stimare distribuzioni come i livelli tipici delle commissioni. Non è adatto per i totali, il tracciamento o le analisi di eventi rari. Il valore on-chain è fortemente concentrato: una manciata di trasferimenti può rappresentare la maggior parte del volume giornaliero e un campione dell'1% di solito non li include.
Esistono due tipi di campione con effetti diversi:
- Campionamento dello storage.
TABLESAMPLE SYSTEM (1 PERCENT)(supportato in qualche forma da BigQuery, PostgreSQL, Trino e DuckDB) legge un sottoinsieme casuale dei blocchi di storage. Riduce i byte letti, ma le righe sono raggruppate anziché indipendenti e, in alcuni motori, una nuova esecuzione restituisce un campione diverso. - Campionamento deterministico. Un filtro basato su un valore derivato da un hash restituisce sempre le stesse righe.
-- Campione ripetibile: i txid sono hash uniformi, quindi '%00' conserva circa 1 riga su 256
SELECT COUNT(*) AS sampled
FROM transactions
WHERE block_timestamp >= TIMESTAMP '2024-11-01 00:00:00'
AND block_timestamp < TIMESTAMP '2024-12-01 00:00:00'
AND hash LIKE '%00';
Poiché gli hash delle transazioni sono di fatto casuali, conservare quelli che terminano in 00 seleziona circa 1 transazione su 256 (0,39%), sempre la stessa su ogni motore e a ogni esecuzione. Il filtro continua a leggere la colonna hash per l'intero intervallo, quindi rende i risultati più piccoli e ripetibili, ma non riduce il costo della scansione. Abbinalo a un filtro di partizionamento ristretto.
Modelli incrementali
I processi di monitoraggio (una lista di controllo di indirizzi, i flussi giornalieri verso gli exchange) non dovrebbero ricalcolare la cronologia a ogni esecuzione. Un modello incrementale elabora soltanto i nuovi blocchi e li aggiunge a una tabella derivata. Il rischio riguarda la testa della blockchain: i blocchi recenti possono essere riorganizzati, quindi un processo che si limita ad aggiungere righe conserverà quelle relative a blocchi che non esistono più (vedi la lezione Blocchi e intestazioni come dati).
-- Aggiornamento incrementale con finestra retrospettiva per le riorganizzazioni; sicuro da rieseguire
-- :from_block = blocco più alto già caricato meno 64
-- :to_block = blocco finalizzato più recente
DELETE FROM watched_transfers
WHERE block_number >= :from_block;
INSERT INTO watched_transfers
SELECT tt.*
FROM token_transfers AS tt
WHERE tt.block_number BETWEEN :from_block AND :to_block
AND EXISTS (SELECT 1 FROM watchlist AS w
WHERE w.address IN (tt.from_address, tt.to_address));
A ogni esecuzione viene eliminato l'intervallo retrospettivo e poi ricaricato, quindi l'esecuzione del processo due volte produce la stessa tabella: il processo è idempotente. Caricare i dati solo fino al blocco finalizzato (su Ethereum, la finalità si raggiunge normalmente dopo circa due epoche, all'incirca 13 minuti) evita la maggior parte dei problemi di riorganizzazione; su Bitcoin, una finestra retrospettiva di almeno sei blocchi è una convenzione comune. Si usa EXISTS invece di una join affinché un trasferimento tra due indirizzi monitorati venga inserito una sola volta, non due. Esegui entrambe le istruzioni in un'unica transazione del database, così i lettori non vedono mai un intervallo caricato a metà. La sintassi dei parametri varia: :name in molti client, @name in BigQuery, $1 in PostgreSQL, ? in JDBC. Strumenti open source come dbt implementano questo schema con i «modelli incrementali» e una ricostruzione completa periodica intercetta le divergenze sfuggite agli incrementi.
Query con versione e riproducibili
Un risultato è riproducibile quando un'altra persona, o tu stesso a distanza di un anno, può eseguire la stessa query sugli stessi dati e ottenere le stesse righe. A tal fine occorrono:
- Ambito fissato. Usa intervalli di blocchi, mai
CURRENT_DATEo «ultimi 30 giorni». - Controllo delle versioni. Conserva SQL in un repository e cita nel rapporto il commit; niente copie modificate a mano.
- Output deterministico. Ordina in base a una chiave completa (numero del blocco, poi indice del log); senza
ORDER BY, l'ordine delle righe può cambiare tra un'esecuzione e l'altra. Evita le funzioni approssimative nei valori riportati. - Versioni dei dati. Registra il dataset, la sua istantanea o la data di estrazione e la versione degli input modificabili, come le tabelle di etichette e prezzi.
- Un record dell'esecuzione salvato accanto al risultato:
{"query": "flows_to_exchanges.sql", "commit": "9c41e2a",
"dataset": "eth_transfers_parquet", "snapshot": "2026-10-01",
"blocks": [21100000, 21107199], "labels_version": "v14",
"engine": "duckdb 1.x", "run_utc": "2026-10-28T14:05:00Z",
"rows": 1832, "result_sha256": "5be1…a7d0"}
L'hash del risultato esportato consente a un revisore di verificare che siano state riprodotte esattamente le stesse righe. La lezione Scrivere il rapporto analitico spiega come inserire questi record nell'appendice.
Convalidare i risultati rispetto a un nodo
I dataset sono creati da software che può non funzionare correttamente. Prima di inserire un dato in un rapporto, confrontane un campione con la fonte (vedi la lezione Qualità, normalizzazione e provenienza dei dati):
- Saldi. Confronta un saldo calcolato in SQL con
eth_getBalanceper l'indirizzo allo stesso blocco oppure conbalanceOf(selettore0x70a08231), chiamato tramiteeth_calla quel blocco. - Blocchi. Confronta gli hash dei blocchi memorizzati con quelli restituiti dal nodo (
getblockhashsu Bitcoin,eth_getBlockByNumbersulle blockchain EVM) per rilevare righe provenienti da blocchi orfani. - Conteggi. Confronta il
transaction_countdi ciascun blocco con il numero effettivo di righe caricate intransactions. - Continuità. Verifica che i numeri dei blocchi non presentino lacune e che l'hash padre di ciascun blocco corrisponda all'hash del blocco precedente.
-- Verifica della continuità: lacune nei numeri dei blocchi e collegamenti ai blocchi padre non validi
SELECT number, prev_number, parent_hash, prev_hash
FROM (SELECT number, parent_hash,
LAG(number) OVER (ORDER BY number) AS prev_number,
LAG(hash) OVER (ORDER BY number) AS prev_hash
FROM blocks
WHERE number BETWEEN 21100000 AND 21107199) AS s
WHERE number <> prev_number + 1 -- un blocco mancante
OR parent_hash <> prev_hash; -- un blocco appartenente a un altro fork
Ogni riga restituita segnala un problema: un blocco mancante oppure un blocco memorizzato che non è collegato al suo predecessore. Un risultato vuoto nell'intervallo di blocchi del caso, insieme a controlli a campione con esito positivo, consente di scrivere che i dati sono stati convalidati.
Focus sull'esame: perché i motori colonnari addebitano i costi in base alle colonne lette e perchéSELECT *eLIMITpossono trarre in inganno; l'eliminazione delle partizioni e il motivo per cui il filtro di partizionamento dovrebbe usare intervalli letterali sulla colonna non modificata; la conversione degli intervalli di blocchi in finestre temporali; quando il campionamento è accettabile e la differenza tra campionamento dello storage e campionamento deterministico basato su hash; i modelli incrementali con una finestra retrospettiva per le riorganizzazioni, teste finalizzate e operazioni idempotenti di eliminazione e inserimento; gli elementi di una query riproducibile (intervallo di blocchi fissato, controllo delle versioni, ordinamento deterministico, versioni dei dati, record dell'esecuzione); la convalida rispetto a un nodo tramite controlli di saldo, hash del blocco, conteggio e continuità.