Consultar cadenas de bloques con SQL · Lección 6 de 6
Rendimiento, costo y reproducibilidad
10 min de lectura
Las tablas de blockchain con todo el historial pueden tener miles de millones de filas, y responder la misma pregunta puede costar unos centavos y tomar segundos, o costar mucho dinero y tardar una hora, según cómo se escriba la consulta. Los hábitos que mantienen las consultas rápidas y económicas (alcances acotados, rangos fijados y versiones explícitas) también hacen que los resultados sean reproducibles, lo que convierte el resultado de una consulta en evidencia. Esta lección cubre ambos aspectos y termina con cómo comprobar un resultado frente a un nodo.
Cómo leen los datos los motores analíticos
Los almacenes de datos y los motores de lago almacenan los datos por columna, no por fila. Una consulta lee solo las columnas que nombra, así que SELECT * en una tabla de transacciones también lee la voluminosa columna input de datos de llamada, aunque necesites solo tres columnas pequeñas. En los servicios de pago por escaneo, el costo es proporcional a los bytes leídos, por lo que nombrar las columnas es el primer control de costos.
El segundo es la partición. Las tablas grandes se dividen en particiones, normalmente por día de la marca de tiempo del bloque o por rango de bloques, y se almacenan como archivos o segmentos separados. Cuando un filtro sobre la columna de partición descarta una partición, el motor la omite por completo: esto se llama poda de particiones. Dentro de las particiones, los datos pueden estar agrupados (ordenados) por una columna, como el número de bloque o la dirección, y los motores mantienen valores mínimos y máximos por fragmento para poder omitir los fragmentos que no pueden coincidir.
Algunas intuiciones que conviene corregir:
LIMIT 10no abarata un escaneo en la mayoría de los almacenes de datos; el filtro y el escaneo ocurren antes del límite.- Un filtro sobre una columna que no es de partición (como
to_address) no poda particiones, aunque la agrupación por esa columna puede ayudar. - La mayoría de los servicios ofrecen una ejecución de prueba o
EXPLAINque informa los bytes o las particiones que leerá una consulta. Úsala antes de ejecutar algo sobre todo el historial.
La escala ayuda a estimar el costo. Ethereum produce un bloque por intervalo de 12 segundos, es decir, unos 7.200 bloques al día; Bitcoin, unos 144. Un alcance de una semana en Ethereum equivale aproximadamente a 50.000 bloques, una pequeña fracción de los más de 20 millones que hay en el historial de la cadena.
Filtros por partición y rango de bloques
Los analistas piensan en alturas de bloque; las tablas se particionan por tiempo. El método fiable consiste en convertir primero el rango de bloques en una ventana de tiempo y después filtrar por ambos.
-- Paso 1: encontrar la ventana de tiempo que cubre el rango de bloques
SELECT MIN(block_timestamp) AS t0, MAX(block_timestamp) AS t1
FROM blocks
WHERE number BETWEEN 21100000 AND 21107199;
-- Paso 2: filtrar por la columna de partición con valores literales y luego por los bloques
SELECT transaction_hash, from_address, to_address, amount_raw
FROM token_transfers
WHERE block_timestamp >= TIMESTAMP '2024-11-01 00:00:00' -- paso 1, ampliado
AND block_timestamp < TIMESTAMP '2024-11-05 00:00:00' -- a días completos
AND block_number BETWEEN 21100000 AND 21107199 -- alcance exacto
AND token_address = '0x7a11…0001';
El paso 1 lee la pequeña tabla blocks. El paso 2 usa el filtro de tiempo para que el motor lea solo unas pocas particiones diarias, y el filtro de bloques para devolver exactamente las filas previstas. Ampliar la ventana a días completos cuesta poco y protege contra casos límite de las marcas de tiempo.
Hay dos precauciones. Muchos motores podan solo cuando el filtro de partición compara la columna con constantes, por lo que una unión o subconsulta que proporcione la ventana podría escanearlo todo; pasa los valores como literales o parámetros de consulta. Además, envolver la columna de partición en una función, como CAST(block_timestamp AS DATE) = …, puede desactivar la poda en algunos motores; en su lugar, compara la columna sin modificar con un rango.
Se puede ahorrar aún más extrayendo una vez e iterando localmente. Una investigación suele ejecutar decenas de variantes de la misma pregunta sobre las mismas direcciones y bloques. En lugar de escanear tablas con todo el historial cada vez, ejecuta una consulta bien filtrada que extraiga todas las transferencias que involucren las direcciones del caso dentro de su rango de bloques, guárdalas como una tabla pequeña o un archivo Parquet y explóralas allí, por ejemplo, con un motor integrado en una computadora portátil. El escaneo costoso ocurre una sola vez, las iteraciones son casi gratuitas y la extracción guardada también sirve como una instantánea fija de la evidencia, lo que ayuda tanto a la reproducibilidad como al control de costos.
Muestreo
El muestreo es útil para explorar una tabla desconocida, probar la lógica de una consulta y estimar distribuciones, como los niveles de comisión habituales. No sirve para obtener totales, hacer rastreos ni analizar eventos poco frecuentes. El valor en cadena está muy concentrado: un puñado de transferencias puede representar la mayor parte del volumen de un día, y una muestra del 1 % normalmente no las incluirá.
Hay dos tipos de muestras con efectos diferentes:
- Muestreo del almacenamiento.
TABLESAMPLE SYSTEM (1 PERCENT)(compatible de alguna forma con BigQuery, PostgreSQL, Trino y DuckDB) lee un subconjunto aleatorio de los bloques de almacenamiento. Reduce los bytes leídos, pero las filas quedan agrupadas en lugar de ser independientes, y en algunos motores una nueva ejecución devuelve una muestra diferente. - Muestreo determinista. Un filtro sobre un valor derivado de un hash devuelve las mismas filas cada vez.
-- Muestra repetible: los txid son hashes uniformes, por lo que '%00' conserva aproximadamente 1 de cada 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';
Como los hashes de transacción son, en la práctica, aleatorios, conservar los que terminan en 00 selecciona aproximadamente 1 de cada 256 (0,39 %), los mismos en todos los motores y ejecuciones. El filtro sigue leyendo la columna hash en todo el rango, así que reduce el tamaño de los resultados y los hace repetibles, pero no abarata el escaneo. Combínalo con un filtro de partición acotado.
Modelos incrementales
Los trabajos de monitoreo (una lista de seguimiento de direcciones, flujos diarios hacia exchanges) no deberían recalcular el historial en cada ejecución. Un modelo incremental procesa solo los bloques nuevos y los agrega a una tabla derivada. El problema está en la cabeza de la cadena: los bloques recientes pueden reorganizarse, por lo que un trabajo que solo agrega datos conservará filas de bloques que ya no existen (consulta la lección Bloques y encabezados como datos).
-- Actualización incremental con ventana retrospectiva para reorganizaciones; se puede volver a ejecutar sin riesgo
-- :from_block = bloque más alto ya cargado menos 64
-- :to_block = último bloque finalizado
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));
Cada ejecución elimina el rango retrospectivo y vuelve a cargarlo, de modo que ejecutar el trabajo dos veces produce la misma tabla: el trabajo es idempotente. Cargar solo hasta el bloque finalizado (en Ethereum, la finalidad normalmente llega después de unas dos épocas, aproximadamente 13 minutos) evita la mayoría de los problemas de reorganización; en Bitcoin, una ventana retrospectiva de al menos seis bloques es una convención habitual. Se usa EXISTS en lugar de una unión para que una transferencia entre dos direcciones de la lista se inserte una sola vez, no dos. Ejecuta ambas instrucciones en una misma transacción de base de datos para que los lectores nunca vean un rango cargado a medias. La sintaxis de los parámetros varía: :name en muchos clientes, @name en BigQuery, $1 en PostgreSQL, ? en JDBC. Herramientas de código abierto como dbt implementan este patrón como «modelos incrementales», y una reconstrucción completa periódica detecta las diferencias que no captaron las actualizaciones incrementales.
Consultas versionadas y reproducibles
Un resultado es reproducible cuando otra persona, o tú dentro de un año, puede ejecutar la misma consulta sobre los mismos datos y obtener las mismas filas. Para ello se necesita:
- Alcance fijado. Usa rangos de bloques, nunca
CURRENT_DATEni «los últimos 30 días». - Control de versiones. Guarda el SQL en un repositorio y cita el commit en el informe; no uses copias editadas manualmente.
- Salida determinista. Ordena por una clave completa (número de bloque y luego índice del registro); sin
ORDER BY, el orden de las filas puede cambiar entre ejecuciones. Evita las funciones aproximadas en las cifras del informe. - Versiones de los datos. Registra el conjunto de datos, su instantánea o fecha de extracción y la versión de las entradas mutables, como las tablas de etiquetas y precios.
- Un registro de ejecución guardado junto al resultado:
{"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"}
El hash del resultado exportado permite que un revisor confirme que reprodujo exactamente las mismas filas. La lección Redacción del informe analítico explica cómo incluir estos registros en el apéndice.
Validar los resultados frente a un nodo
Los conjuntos de datos se crean con software que puede fallar. Antes de incluir una cifra en un informe, comprueba una muestra frente a la fuente (consulta la lección Calidad de los datos, normalización y procedencia):
- Saldos. Compara un saldo calculado en SQL con
eth_getBalancepara la dirección en el mismo bloque, o conbalanceOf(selector0x70a08231) invocado medianteeth_callen ese bloque. - Bloques. Compara los hashes de bloque almacenados con los del nodo (
getblockhashen Bitcoin,eth_getBlockByNumberen cadenas EVM) para detectar filas de bloques huérfanos. - Recuentos. Compara el valor de
transaction_countde cada bloque con el número de filas realmente cargadas entransactions. - Continuidad. Comprueba que los números de bloque no tengan saltos y que el hash padre de cada bloque coincida con el hash del bloque anterior.
-- Comprobación de continuidad: saltos en los números de bloque y vínculos rotos entre hashes padre
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 -- falta un bloque
OR parent_hash <> prev_hash; -- un bloque de otra bifurcación
Cualquier fila devuelta indica un problema: falta un bloque o un bloque almacenado no enlaza con su predecesor. Un resultado vacío en el rango de bloques del caso, junto con verificaciones puntuales coincidentes, es lo que permite afirmar que los datos se validaron.
Enfoque del examen: por qué los motores columnares cobran por las columnas leídas y por quéSELECT *yLIMITpueden inducir a error; la poda de particiones y por qué el filtro de partición debe usar rangos literales sobre la columna sin modificar; la conversión de rangos de bloques en ventanas de tiempo; cuándo es aceptable el muestreo y la diferencia entre el muestreo del almacenamiento y el muestreo determinista mediante hash; los modelos incrementales con una ventana retrospectiva para reorganizaciones, cabezas finalizadas y eliminación e inserción idempotentes; los elementos de una consulta reproducible (rango de bloques fijado, control de versiones, orden determinista, versiones de datos y registro de ejecución); la validación frente a un nodo mediante comprobaciones de saldos, hashes de bloques, recuentos y continuidad.