Salta al contenuto principale

09 - Modelli Incrementali Avanzati in dbt: Strategie Merge, Microbatch e Change Data Capture

dbt 18 min Modelli Incrementali Avanzati in dbt: Strategie Merge, Microbatch e Change Data Capture Oltre l'incremental di base: strategia microbatch per...

Cosa ne pensi di questo articolo?

In Breve

dbt 18 min Modelli Incrementali Avanzati in dbt: Strategie Merge, Microbatch e Change Data Capture Oltre l'incremental di base: strategia microbatch per...

dbt 18 min

Modelli Incrementali Avanzati in dbt: Strategie Merge, Microbatch e Change Data Capture

Oltre l'incremental di base: strategia microbatch per dataset time-series da miliardi di righe, integrazione con CDC da database sorgente, gestione dei late-arriving data e come garantire idempotenza nei modelli incrementali.

dbt incremental advanced microbatch dbt CDC dbt merge strategy event_time dbt

Il Problema dei Modelli Incrementali Naif

Il modello incrementale base visto nel modulo 04 - filtro su MAX(timestamp) e merge per unique_key - è sufficiente per molti casi d'uso. Ma quando il volume cresce oltre il miliardo di righe, quando i dati arrivano in ritardo di ore o giorni, o quando la sorgente è un sistema CDC che emette operazioni di tipo INSERT/UPDATE/DELETE, servono pattern più sofisticati.

Questo modulo esplora tre scenari avanzati: la strategia microbatch (introdotta in dbt 1.9) per dataset time-series enormi, l'integrazione con il Change Data Capture per replicare fedelmente le modifiche da database sorgente, e le tecniche per garantire l'idempotenza in presenza di late-arriving data.

Recap: Le Strategie Incrementali Standard

Prima di procedere con i pattern avanzati, ricapitoliamo il quadro completo delle strategie disponibili in dbt 1.8+:

-- Le 4 strategie standard (+ microbatch in 1.9):

-- 1. append: solo INSERT, nessun UPDATE, nessuna deduplicazione
-- Casi d'uso: audit log, event stream immutabili, fact tables append-only
{{ config(materialized='incremental', incremental_strategy='append') }}

-- 2. merge: UPSERT su unique_key (default con unique_key definita)
-- Casi d'uso: dimensioni che cambiano, ordini aggiornabili, qualsiasi SCD-1
{{ config(materialized='incremental', unique_key='id', incremental_strategy='merge') }}

-- 3. delete+insert: elimina partizioni interessate e reinserisce
-- Casi d'uso: BigQuery partitioned tables, re-processing per data
{{ config(materialized='incremental', unique_key=['id', 'date'],
          incremental_strategy='delete+insert',
          partition_by={'field': 'date', 'data_type': 'date'}) }}

-- 4. insert_overwrite: sovrascrive intere partizioni (Spark, BigQuery)
-- Casi d'uso: dataset partizionati per data, Spark/Databricks
{{ config(materialized='incremental', incremental_strategy='insert_overwrite',
          partition_by={'field': 'date', 'data_type': 'date'}) }}

Microbatch: La Strategia per Dataset Time-Series Enormi

La strategia microbatch è stata introdotta in dbt 1.9 per risolvere un problema specifico: modelli incrementali time-series dove il volume per singola esecuzione è ancora troppo grande, o dove il re-processing di finestre temporali specifiche deve essere semplice e atomico.

Come Funziona Microbatch

Invece di eseguire un'unica query incrementale grande, microbatch divide l'esecuzione in lotti temporali (batch) definiti dal campo event_time e dalla batch_size. Ogni batch è processato in modo atomico e indipendente.

-- models/marts/events/fct_events_microbatch.sql
{{ config(
    materialized='incremental',
    incremental_strategy='microbatch',
    event_time='event_timestamp',    -- colonna timestamp per la suddivisione in batch
    begin='2024-01-01',              -- data di inizio per il full refresh iniziale
    batch_size='day',                -- granularità di ogni batch: hour | day | month | year
    lookback=1                       -- ri-processa anche N batch precedenti (late-arriving data)
) }}

SELECT
    event_id,
    user_id,
    session_id,
    event_type,
    page_url,
    referrer_url,
    device_type,
    country_code,
    event_timestamp,
    DATE_TRUNC('day', event_timestamp)  AS event_date,
    DATE_TRUNC('hour', event_timestamp) AS event_hour
FROM {{ ref('stg_events') }}

-- NOTA IMPORTANTE con microbatch:
-- NON usare is_incremental() con microbatch.
-- dbt gestisce automaticamente il filtro temporale su event_time.
-- La query deve essere scritta come se processasse TUTTI i dati --
-- dbt aggiunge automaticamente il filtro: WHERE event_timestamp >= batch_start AND event_timestamp < batch_end

Esecuzione e Re-Processing con Microbatch

-- Esecuzione normale: processa i batch mancanti dall'ultima run
dbt run --select fct_events_microbatch

-- Re-processare un giorno specifico (molto utile per correzioni dati):
dbt run \
  --select fct_events_microbatch \
  --event-time-start "2024-06-15" \
  --event-time-end "2024-06-16"

-- Re-processare un intervallo (es. dopo una correzione alla sorgente):
dbt run \
  --select fct_events_microbatch \
  --event-time-start "2024-06-01" \
  --event-time-end "2024-07-01"

-- Full refresh dall'inizio (usa il 'begin' configurato):
dbt run --select fct_events_microbatch --full-refresh

-- Output tipico durante l'esecuzione:
-- Running microbatch model fct_events_microbatch
--   Batch 2024-06-13 00:00:00 to 2024-06-14 00:00:00 [1 of 3]: OK (123,456 rows)
--   Batch 2024-06-14 00:00:00 to 2024-06-15 00:00:00 [2 of 3]: OK (98,234 rows)
--   Batch 2024-06-15 00:00:00 to 2024-06-16 00:00:00 [3 of 3]: OK (45,678 rows)

Vantaggi di Microbatch vs Incremental Standard

Incremental standard con MAX(timestamp)

  • Una query che processa tutti i dati nuovi in un colpo
  • Se fallisce a metà: devi ri-partire dall'inizio
  • Re-processing di un giorno specifico: logica custom complessa
  • Late-arriving data: lookback window manuale
  • Monitoring: sai quante righe ha processato, non per quale periodo

Microbatch

  • N query piccole, una per batch temporale
  • Se fallisce a metà: riprende dal batch fallito
  • Re-processing di un giorno: --event-time-start/end
  • Late-arriving data: lookback=1 ri-processa i batch recenti
  • Monitoring: granularità per batch, facile individuare anomalie

Change Data Capture: Replicare le Modifiche dal Database Sorgente

Il Change Data Capture (CDC) cattura ogni INSERT, UPDATE e DELETE che avviene nel database sorgente e lo propaga nella pipeline. È lo standard per replicare dati in tempo quasi reale senza impatto sul sistema transazionale.

Pattern CDC con Debezium + Kafka

Lo stack più comune per CDC con dbt: Debezium legge il WAL (Write-Ahead Log) di PostgreSQL o MySQL e pubblica i cambiamenti su Kafka. Un connector Kafka (Fivetran, Airbyte, o custom) carica i messaggi nella raw table del warehouse. dbt poi consolida la storia delle modifiche.

-- La raw table CDC tipica dal connettore ha questa struttura:
-- Ogni riga rappresenta un'operazione (INSERT/UPDATE/DELETE)

-- raw.orders_cdc_stream:
-- _op          VARCHAR  -- 'r' (read/snapshot), 'c' (create/insert), 'u' (update), 'd' (delete)
-- _ts_ms       BIGINT   -- timestamp dell'operazione in milliseconds
-- _after       VARIANT  -- stato DOPO la modifica (JSON) -- NULL per DELETE
-- _before      VARIANT  -- stato PRIMA della modifica (JSON) -- NULL per INSERT
-- order_id     VARCHAR  -- chiave primaria (estratta dal messaggio)
-- customer_id  VARCHAR  -- ...altri campi estratti...
-- models/staging/stg_orders_cdc.sql
-- Normalizza i messaggi CDC in una struttura flat

{{ config(
    materialized='incremental',
    unique_key=['order_id', '_ts_ms'],
    incremental_strategy='append'   -- append-only: ogni operazione è una riga
) }}

SELECT
    order_id,
    _op                                          AS operation_type,
    TO_TIMESTAMP(_ts_ms / 1000)                  AS operation_timestamp,

    -- Estrai i campi dall'JSON _after (per INSERT e UPDATE)
    CASE WHEN _op != 'd'
         THEN _after:customer_id::VARCHAR
         ELSE _before:customer_id::VARCHAR
    END                                          AS customer_id,

    CASE WHEN _op != 'd'
         THEN _after:status::VARCHAR
         ELSE NULL
    END                                          AS status,

    CASE WHEN _op != 'd'
         THEN _after:total_amount::DECIMAL(10,2)
         ELSE NULL
    END                                          AS total_amount,

    CASE WHEN _op != 'd'
         THEN _after:updated_at::TIMESTAMP
         ELSE NULL
    END                                          AS updated_at,

    _op = 'd'                                    AS is_deleted

FROM {{ source('raw', 'orders_cdc_stream') }}

{% if is_incremental() %}
WHERE TO_TIMESTAMP(_ts_ms / 1000) > (
    SELECT MAX(operation_timestamp) FROM {{ this }}
)
{% endif %}
-- models/marts/dim_orders_from_cdc.sql
-- Consolida la storia CDC: prende solo l'ultimo stato di ogni ordine

{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge',
    merge_update_columns=['customer_id', 'status', 'total_amount', 'updated_at', 'is_deleted', 'last_operation_at']
) }}

WITH latest_per_order AS (
    -- Prendi solo l'operazione più recente per ogni ordine
    SELECT
        order_id,
        customer_id,
        status,
        total_amount,
        updated_at,
        is_deleted,
        operation_timestamp AS last_operation_at,
        -- Row number per prendere solo il record più recente
        ROW_NUMBER() OVER (
            PARTITION BY order_id
            ORDER BY operation_timestamp DESC
        ) AS rn
    FROM {{ ref('stg_orders_cdc') }}

    {% if is_incremental() %}
    -- In modalità incrementale: considera le operazioni recenti + lookback
    WHERE operation_timestamp > (
        SELECT DATEADD('hour', -2, MAX(last_operation_at))
        FROM {{ this }}
    )
    {% endif %}
)
SELECT
    order_id,
    customer_id,
    status,
    total_amount,
    updated_at,
    is_deleted,
    last_operation_at
FROM latest_per_order
WHERE rn = 1
  AND NOT is_deleted  -- esclude i record cancellati dalla vista finale

Pattern SCD-2 da CDC: Mantenere la Storia Completa

-- models/marts/dim_orders_history.sql
-- SCD Type 2 completo da stream CDC: ogni cambiamento di stato viene storicizzato

{{ config(materialized='incremental', unique_key='order_state_id') }}

WITH cdc_with_prev AS (
    SELECT
        order_id,
        operation_type,
        operation_timestamp,
        status,
        total_amount,
        customer_id,
        -- Stato precedente per rilevare cambiamenti reali
        LAG(status) OVER (PARTITION BY order_id ORDER BY operation_timestamp)        AS prev_status,
        LAG(total_amount) OVER (PARTITION BY order_id ORDER BY operation_timestamp)  AS prev_total,
        -- Prossimo timestamp per calcolare valid_to
        LEAD(operation_timestamp) OVER (PARTITION BY order_id ORDER BY operation_timestamp) AS next_op_at
    FROM {{ ref('stg_orders_cdc') }}
    WHERE NOT is_deleted

    {% if is_incremental() %}
    AND operation_timestamp > (
        SELECT DATEADD('hour', -1, MAX(valid_from)) FROM {{ this }}
    )
    {% endif %}
),
filtered_changes AS (
    -- Includi solo righe dove qualcosa è effettivamente cambiato
    SELECT *
    FROM cdc_with_prev
    WHERE operation_type = 'c'                  -- nuovi inserimenti
       OR status != prev_status                  -- cambio di stato
       OR ABS(total_amount - prev_total) > 0.01  -- cambio importo
)
SELECT
    -- Chiave surrogata per questa versione del record
    {{ dbt_utils.generate_surrogate_key(['order_id', 'operation_timestamp']) }} AS order_state_id,
    order_id,
    customer_id,
    status,
    total_amount,
    operation_timestamp                          AS valid_from,
    COALESCE(next_op_at, '9999-12-31'::TIMESTAMP) AS valid_to,
    next_op_at IS NULL                           AS is_current
FROM filtered_changes

Idempotenza: La Proprietà Fondamentale

Un modello incrementale è idempotente se eseguirlo più volte con gli stessi dati produce sempre lo stesso risultato. È una garanzia critica in produzione: retry automatici, re-processing per correzioni, e full refresh non devono produrre risultati diversi.

Anti-pattern: Modelli Non Idempotenti

-- ANTI-PATTERN: questo modello NON è idempotente

{{ config(materialized='incremental') }}

SELECT
    order_id,
    customer_id,
    total_amount,
    CURRENT_TIMESTAMP AS inserted_at  -- PROBLEMA: ogni re-run produce timestamp diversi
FROM {{ ref('stg_orders') }}

{% if is_incremental() %}
WHERE order_id NOT IN (SELECT order_id FROM {{ this }})  -- PROBLEMA: lento su tabelle grandi
{% endif %}

-- Il problema: se la pipeline ri-processa lo stesso giorno per una correzione,
-- CURRENT_TIMESTAMP cambierà e avrai righe duplicate o inconsistenti.
-- CORRETTO: modello idempotente

{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge'
) }}

SELECT
    order_id,
    customer_id,
    total_amount,
    created_at,           -- usa il timestamp dalla sorgente, non CURRENT_TIMESTAMP
    updated_at            -- timestamp di aggiornamento della sorgente
FROM {{ ref('stg_orders') }}

{% if is_incremental() %}
-- Filtro basato su updated_at (non su NOT IN, che è O(n*m)):
WHERE updated_at > (
    SELECT MAX(updated_at) FROM {{ this }}
)
{% endif %}

-- Con merge su unique_key='order_id':
-- * Re-run dello stesso range di dati: MERGE aggiornerà le righe esistenti
--   con gli stessi valori → nessun cambiamento netto (idempotente)
-- * Nuovi dati: MERGE inserirà le nuove righe
-- * Dati corretti retroattivamente: MERGE aggiornerà correttamente

Testing dell'Idempotenza

-- tests/assert_incremental_idempotent.sql
-- Verifica che eseguire il modello due volte non cambi il conteggio delle righe

-- Questo test è un placeholder: l'idempotenza vera si verifica in CI
-- confrontando il conteggio prima e dopo un secondo run sugli stessi dati.

-- Test pratico: verifica che non ci siano duplicati sulla unique_key
SELECT
    order_id,
    COUNT(*) AS occurrences
FROM {{ ref('fct_orders') }}
GROUP BY order_id
HAVING COUNT(*) > 1

-- Se questo test passa, il merge sta funzionando correttamente.

Gestione dei Late-Arriving Data

I late-arriving data sono eventi con timestamp passato che arrivano nella pipeline in ritardo. È un problema comune con sorgenti mobile (app offline), sistemi con retry, e pipeline batch che arrivano con ritardo variabile.

-- Pattern 1: Lookback window fissa
-- Semplice e robusto per la maggior parte dei casi

{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge'
) }}

SELECT
    event_id,
    user_id,
    event_type,
    event_timestamp,
    received_at  -- quando è arrivato nel warehouse
FROM {{ ref('stg_events') }}

{% if is_incremental() %}
WHERE event_timestamp >= (
    -- Lookback di 48 ore per catturare late-arriving data
    SELECT DATEADD('hour', -48, MAX(event_timestamp))
    FROM {{ this }}
)
{% endif %}
-- Pattern 2: Lookback adattivo basato sul lag storico
-- Più sofisticato: calcola il lookback in base al ritardo osservato

{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge'
) }}

{% set lookback_hours = var('event_lookback_hours', 48) %}

WITH late_data_analysis AS (
    -- Calcola il ritardo medio degli ultimi 7 giorni
    -- (solo in produzione e quando la tabella esiste)
    {% if is_incremental() %}
    SELECT
        AVG(DATEDIFF('hour', event_timestamp, received_at)) AS avg_lag_hours,
        MAX(DATEDIFF('hour', event_timestamp, received_at)) AS max_lag_hours
    FROM {{ this }}
    WHERE received_at > DATEADD('day', -7, CURRENT_TIMESTAMP)
    {% else %}
    SELECT {{ lookback_hours }} AS avg_lag_hours, {{ lookback_hours }} AS max_lag_hours
    {% endif %}
),
dynamic_lookback AS (
    -- Usa il 95° percentile del ritardo storico + 20% di buffer
    SELECT GREATEST(
        {{ lookback_hours }},
        CEIL(max_lag_hours * 1.2)
    ) AS lookback_hours
    FROM late_data_analysis
)
SELECT
    e.event_id,
    e.user_id,
    e.event_type,
    e.event_timestamp,
    e.received_at
FROM {{ ref('stg_events') }} e
CROSS JOIN dynamic_lookback dl

{% if is_incremental() %}
WHERE e.event_timestamp >= DATEADD('hour', -dl.lookback_hours,
    (SELECT MAX(event_timestamp) FROM {{ this }}))
{% endif %}

Monitoring dei Modelli Incrementali

-- models/admin/incremental_health_check.sql
-- Modello di monitoraggio: traccia metriche degli incrementali in produzione

{{ config(materialized='table') }}

SELECT
    'fct_orders'                                         AS model_name,
    COUNT(*)                                             AS total_rows,
    MIN(created_at)                                      AS earliest_record,
    MAX(updated_at)                                      AS latest_record,
    DATEDIFF('hour', MAX(updated_at), CURRENT_TIMESTAMP) AS hours_since_last_update,
    COUNT(DISTINCT DATE_TRUNC('day', created_at))        AS distinct_days_covered,
    CURRENT_TIMESTAMP                                    AS check_timestamp
FROM {{ ref('fct_orders') }}

UNION ALL

SELECT
    'fct_events_hourly',
    COUNT(*),
    MIN(event_hour),
    MAX(event_hour),
    DATEDIFF('hour', MAX(event_hour), CURRENT_TIMESTAMP),
    COUNT(DISTINCT DATE_TRUNC('day', event_hour)),
    CURRENT_TIMESTAMP
FROM {{ ref('fct_events_hourly') }}

Checklist per Modelli Incrementali in Produzione

  • Idempotenza verificata: esegui dbt run --full-refresh periodicamente (mensile) e confronta row count con il normale run.
  • Test di freshness: usa dbt-expectations.expect_row_values_to_have_recent_data per avvisare se i dati sono più vecchi di N ore.
  • Late-arriving data monitorati: aggiungi una colonna received_at e monitora il gap tra event_timestamp e received_at.
  • Schema evolution gestita: configura on_schema_change='sync_all_columns' per aggiungere nuove colonne senza full refresh.
  • Full refresh schedulato: esegui periodicamente per evitare drift tra schema del modello e tabella effettiva.

Conclusioni e Prossimi Passi

I modelli incrementali avanzati sono dove la data engineering su dbt richiede ragionamento attento su trade-off: microbatch semplifica il re-processing ma aggiunge complessità di configurazione; CDC richiede comprensione profonda del formato dei messaggi; idempotenza è non negoziabile in sistemi che gestiscono retry e correzioni dati.

Il criterio unificante: ogni modello incrementale deve rispondere alla domanda “cosa succede se lo eseguo due volte sugli stessi dati?” Se la risposta non è “lo stesso risultato”, il modello ha un bug.

Il modulo finale della serie chiude il cerchio con dbt in contesti enterprise: governance dei dati, Semantic Layer con MetricFlow e orchestrazione con Airflow.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

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