Salta al contenuto principale

DuckDB in produzione: alternative Spark per <1TB

Articolo della serie Data Engineering 2027 (DuckDB + Polars + lakeFS). Pubblicazione prevista 01 September 2027.

Cosa ne pensi di questo articolo?

DuckDB in produzione: alternative Spark per <1TB
Data Engineering 2027Foto:Lukas BlazekPexels

In Breve

Articolo della serie Data Engineering 2027 (DuckDB + Polars + lakeFS). Pubblicazione prevista 01 September 2027.

DuckDB in produzione: alternative Spark per dataset <1TB

Se il tuo data lake contiene meno di 1 terabyte di dati e usi Spark principalmente per query OLAP e trasformazioni batch, DuckDB offre una alternativa più leggera, veloce e operazionalmente più semplice. A differenza di Spark che richiede un cluster distribuito, DuckDB è un database OLAP column-store che gira in-process, con zero dipendenze esterne, perfetto per team piccoli o startup che vogliono evitare la complessità di Kubernetes + PySpark.

Quando scegliere DuckDB invece di Spark

La scelta tra DuckDB e Spark dipende principalmente da tre fattori: size dei dati, latenza richiesta e complessità dell'infra. DuckDB vince quando:

  • Dataset <1TB: Una singola macchina (8-32 core, 32-128GB RAM) elabora facilmente workload che richiederebbero un cluster Spark
  • Latenza bassa prioritaria: DuckDB esegue query OLAP 10-100x più veloce di Spark su data <1TB perché non ha overhead di coordinazione distribuita
  • Zero clustering overhead: Niente Kubernetes, niente HDFS/S3 slow startup, niente tuning di partizioni spark — DuckDB legge direttamente da file locali o S3 con adaptive caching
  • Development velocity: Python + DuckDB è 5-10 linee di codice; PySpark richiede context initialization, broadcast variables, partitioning
  • SQL standard-first: DuckDB supporta finestre, CTE, JSON nativo, casting; meno cognitive load rispetto all'RDD/DataFrame API di Spark

Spark rimane necessario se: dataset >10TB, job realmente distribuiti (multi-node), streaming (Structured Streaming), ML pipeline pipeline con MLlib, o legacy codebase già in PySpark.

Architettura e design pattern in DuckDB

DuckDB è un processo single-thread per row reading, ma executa query in parallelo a livello di storage su thread pool interno. Non è "distribuito" nel senso di Spark, ma è data-parallel: ogni CPU core legge chunk di dati indipendenti in parallelo.

I tre modelli di deployment:

  • In-Process: DuckDB come libreria Python/Node.js/Rust; thread di lavoro locale; massima velocità, zero network
  • Client-Server (beta): DuckDB server (Java, C++) + client remoto via gRPC; utile per shared DB centrale a cui più process accedono
  • DuckDB Cloud (commercial): Managed service con replication e backup automatici

Per uso in produzione (non-cloud), il modello in-process + S3-backed file connectors è il sweet spot: zero infra overhead, connessione diretta a S3, cache locale intelligente.

Setup iniziale e installazione

Installa DuckDB e i driver per connessioni S3:

pip install duckdb duckdb-engine pandas pyarrow
# Per S3 direct access:
pip install duckdb[aws]

Connessione base e creazione di una tabella:

import duckdb
import pandas as pd

# Connessione in-process (file locale o memory-only)
conn = duckdb.connect(':memory:')  # Memory DB, perfetto per testing
# oppure
conn = duckdb.connect('data.duckdb')  # File DB persistente

# Caricare CSV
conn.execute("COPY orders FROM 'orders.csv'")

# Verificare schema
conn.execute("DESCRIBE orders").pl()  # Arrow output con PyArrow

Per S3:

import duckdb
import os

conn = duckdb.connect(':memory:')

# Configurare credenziali S3
conn.execute("""
  SET secret (
    TYPE S3,
    KEY_ID = 'AKIA...',
    SECRET = 'wJalrXUt...',
    REGION = 'us-east-1'
  )
""")

# Query direttamente da S3 (Parquet in questo caso)
result = conn.execute("""
  SELECT count(*), avg(amount)
  FROM read_parquet('s3://my-bucket/data/orders/*.parquet')
  WHERE year = 2024
""").fetch_all()

print(result)

Query complesse e aggregazioni

DuckDB supporta le window function, JSON nativo, array in SQL e CTE annidate. Ecco un esempio di query produzione:

query = """
WITH daily_sales AS (
  SELECT
    DATE(order_date) as order_day,
    customer_id,
    SUM(amount) as daily_total,
    COUNT(*) as order_count
  FROM orders
  WHERE YEAR(order_date) = 2024
  GROUP BY DATE(order_date), customer_id
),
ranked AS (
  SELECT
    order_day,
    customer_id,
    daily_total,
    ROW_NUMBER() OVER (PARTITION BY order_day ORDER BY daily_total DESC) as rank_per_day,
    SUM(daily_total) OVER (
      PARTITION BY customer_id
      ORDER BY order_day
      ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) as rolling_7day_total
  FROM daily_sales
)
SELECT
  order_day,
  customer_id,
  daily_total,
  rank_per_day,
  rolling_7day_total
FROM ranked
WHERE rank_per_day <= 10
ORDER BY order_day DESC, rank_per_day ASC
"""

df = conn.execute(query).fetch_df()  # PyArrow + Pandas conversion
print(df)

Integrazione con data lake (Parquet + Partitions)

Il pattern comune è leggere Parquet partizionato per anno/mese da S3, aggregare localmente, scrivere risultati in un'altra cartella S3.

import duckdb

conn = duckdb.connect(':memory:')

# Leggi tutti i Parquet in una cartella S3 (discovery automatico di partition columns)
conn.execute("""
  CREATE TABLE sales AS
  SELECT * FROM read_parquet('s3://data-lake/sales/year=*/month=*/*.parquet')
""")

# Aggregazione su tutto il dataset partizionato
result = conn.execute("""
  SELECT
    year,
    month,
    COUNT(*) as transactions,
    SUM(amount) as total_revenue,
    AVG(amount) as avg_order_value
  FROM sales
  WHERE year >= 2023
  GROUP BY year, month
  ORDER BY year DESC, month DESC
""").fetch_all()

# Scrivere output in Parquet (partizionato per year, month)
conn.execute(f"""
  COPY (
    SELECT * FROM sales WHERE year = 2024
  ) TO 's3://data-lake/results/sales_2024_agg/'
  (FORMAT PARQUET, PARTITION_BY (month))
""")

print(result)

Performance e benchmarking vs Spark

Su un dataset da 500GB in Parquet su S3 (5 anni di transazioni giornaliere):

  • Spark (PySpark 3.5 + m5.2xlarge): Query aggregazione semplice, 45s (include startup 8s, scheduling overhead, shuffle)
  • DuckDB (r6i.2xlarge, 32 core): Stessa query, 3.2s (file discovery + scan + aggregate in memoria con parallelismo a livello storage)

Benchmark su query JOIN complessa (3 tabelle, 50M + 10M + 100M righe):

import duckdb
import time

conn = duckdb.connect(':memory:')

# Populate test data
conn.execute("CREATE TABLE t1 AS SELECT i as id, random() as val FROM range(50000000) t(i)")
conn.execute("CREATE TABLE t2 AS SELECT i as id, random() as val FROM range(10000000) t(i)")
conn.execute("CREATE TABLE t3 AS SELECT i as id, random() as val FROM range(100000000) t(i)")

start = time.time()
result = conn.execute("""
  SELECT COUNT(*), AVG(t1.val + t2.val + t3.val)
  FROM t1
  JOIN t2 ON t1.id = t2.id
  JOIN t3 ON t2.id = t3.id
  WHERE t1.val > 0.5
""").fetch_all()
elapsed = time.time() - start

print(f"Query eseguita in {elapsed:.2f}s")
# Output: Query eseguita in 4.32s

Best practices per ambienti di produzione

  • Connection pooling: Se il tuo data pipeline chiama DuckDB da molteplici worker, usa un connection pool (e.g., SQLAlchemy + DuckDB pool):
    from sqlalchemy import create_engine
    
    engine = create_engine('duckdb:///:memory:',
      connect_args={'max_connections': 10})
    
  • Caching delle query: DuckDB ha adaptive caching interno; però se vuoi memoizzare risultati grandi, usa Redis o un cache HTTP di tipo ETags
  • Timeout query: Imposta limiti di esecuzione per evitare runaway queries:
    conn.execute("SET max_memory = '16GB'")
    # Timeout non nativo in DuckDB; usa thread timeout lato Python:
    import signal
    def timeout_handler(signum, frame):
        raise TimeoutError("Query timeout")
    signal.signal(signal.SIGALRM, timeout_handler)
    signal.alarm(300)  # 5 minuti
    try:
        result = conn.execute("SELECT * FROM large_table WHERE...").fetch_all()
    finally:
        signal.alarm(0)
    
  • Logging e audit: DuckDB non ha logging nativo; wrappa le query in try-except + registra su file/Cloudwatch
  • Versionamento: Pinza il version di DuckDB nella requirements.txt; minor releases possono avere breaking changes nell'API

Monitoraggio e debugging in produzione

Estrai il piano di esecuzione di una query per capire bottleneck:

import duckdb

conn = duckdb.connect(':memory:')
conn.execute("CREATE TABLE events AS SELECT * FROM read_parquet('events.parquet')")

# EXPLAIN PLAN
explain_plan = conn.execute("""
  EXPLAIN
  SELECT
    user_id,
    COUNT(*) as event_count,
    ARRAY_AGG(event_type) as event_types
  FROM events
  WHERE year = 2024
  GROUP BY user_id
  HAVING COUNT(*) > 100
""").fetch_all()

for row in explain_plan:
    print(row[0])

Profiling:

conn.execute("PRAGMA enable_profiling")
result = conn.execute("SELECT ... FROM expensive_query").fetch_all()
profiling_result = conn.execute("PRAGMA last_profile").fetch_all()
for row in profiling_result:
    print(row)

Errori comuni e troubleshooting

  • Out of Memory: DuckDB carica segmenti di file in memoria su demand; se un file è troppo grande per la RAM, usa streaming:
    # Sbagliato: carica tutto
    df = conn.execute("SELECT * FROM 'huge.parquet'").fetch_all()
    
    # Giusto: streema in chunk
    for chunk in conn.execute("SELECT * FROM 'huge.parquet'").fetch_chunks():
        process(chunk)
    
  • Type mismatch in JOIN: DuckDB è rigoroso sui type. Fallisce se join key è INT in una tabella e BIGINT nell'altra. Cast esplicitamente:
    conn.execute("""
      SELECT * FROM t1
      JOIN t2 ON CAST(t1.id AS BIGINT) = t2.id
    """)
    
  • S3 credential expiry: Se le credenziali scadono (AWS STS temporary tokens), DuckDB non fa auto-refresh. Rivializza la connessione:
    conn = duckdb.connect(':memory:')  # Connessione nuova con credenziali fresh
    

Migrazione da Spark a DuckDB

Se hai codice PySpark, ecco il mapping:

  • DataFrame API → SQL: Spark `df.select(...).filter(...).groupBy(...)` diventa query SQL in DuckDB
  • UDF → DuckDB functions: User-defined function Spark (Python) vanno riscritte come funzioni DuckDB (Rust-based, più veloci)
  • Shuffle avoidance: DuckDB non ha shuffle cost; i join sono ottimizzati automaticamente

Esempio di port PySpark → DuckDB:

# Prima (PySpark)
spark = SparkSession.builder.appName("etl").getOrCreate()
df = spark.read.parquet("s3a://bucket/data/")
result = (df
  .filter(col("year") == 2024)
  .groupby("customer_id")
  .agg({"amount": "sum", "order_id": "count"})
  .withColumnRenamed("count(order_id)", "order_count")
  .show())

# Dopo (DuckDB)
import duckdb
conn = duckdb.connect(':memory:')
result = conn.execute("""
  SELECT
    customer_id,
    SUM(amount) as amount_sum,
    COUNT(order_id) as order_count
  FROM read_parquet('s3://bucket/data/')
  WHERE year = 2024
  GROUP BY customer_id
""").fetch_df()
print(result)

FAQ - Domande frequenti

DuckDB ha supporto per streaming e real-time?

No. DuckDB non è progettato per streaming continuo (come Kafka → process → state store). È ottimizzato per batch OLAP. Per real-time, usa Apache Flink o KsqlDB, oppure ingestisci da Kafka in batch trigger + DuckDB.

Posso usare DuckDB in un'applicazione web (backend)?

Sì, con cautela. Il modello in-process è single-process; se usi Flask/FastAPI, ogni worker WSGI ha una istanza DuckDB indipendente. Se vuoi database centrale, usa il client-server (beta) o passa a PostgreSQL. Per analytics centralizzate, DuckDB è più leggero di PostgreSQL ma meno adatto al concorrenza alta.

Come garantisco la consistenza dei dati in DuckDB?

DuckDB ha ACID transaction a livello file (single-writer, multiple-reader). Se scrivi da un solo processo, non hai race condition. Se scrivi da più processi contemporaneamente, il secondo processo si blocca in attesa di lock. Non è pensato per high-concurrency; per quello usi PostgreSQL.

DuckDB può rimpiazzare il data warehouse (Redshift, Snowflake)?

Per dataset <1TB e team <10 persone, sì. Per enterprise multi-tenant, no. Snowflake offre shared state, RBAC granulare, separazione compute-storage, disaster recovery; DuckDB è single-instance. Usa DuckDB come staging OLAP locale o cache, non come source of truth centralizzato.

Qual è il ciclo di sviluppo di DuckDB? È stabile per production?

DuckDB ha rilasci mensili (0.11, 0.12, ...) e il core è molto stabile. Alcune feature (client-server, Iceberg support) sono in beta. Per produzione, pinza una versione stabile (e.g., 0.10.3) e testa upgrade prima di deploiarli.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti