Append strategy: immutable logs
Append — самая простая из четырёх incremental-стратегий. Она делает ровно одно: INSERT новых строк в существующую таблицу. Без UPDATE, без DELETE, без MERGE. Если в источнике появилась новая строка — она вставится. Если та же строка пришла дважды — будет дважды в таблице.
Это звучит примитивно, но в правильном use case append — самая быстрая и надёжная стратегия. В этом уроке разберём, где она блистает, а где даёт неожиданные дубли.
Когда append идеальна — immutable logs
Append рассчитана на append-only данные: события, которые после записи уже не меняются. Типичные примеры:
Эти типы данных по природе immutable — записанное событие не меняется и не удаляется. На них append работает безопасно и быстро.
Общее свойство — каждая запись это факт о моменте времени. Факт не меняется, не удаляется. Если данные ведут себя так в источнике — append идеален.
Базовый append-паттерн
Простейшая append-модель:
{{ config(
materialized='incremental',
incremental_strategy='append'
) }}
select
event_id,
user_id,
event_type,
event_timestamp,
properties
from {{ source('analytics', 'raw_events') }}
{% if is_incremental() %}
where event_timestamp > (select max(event_timestamp) from {{ this }})
{% endif %}
Что происходит на каждом run:
- Первый run:
is_incremental() = False, выполняется полный SELECT, создаётся таблица. - Последующие run:
is_incremental() = True, фильтр оставляет только новые события (гдеevent_timestamp > max(уже_записанного)),INSERT INTO targetэтих строк.
Заметьте — unique_key не указан. Append не использует его, потому что нет дедупликации.
Что под капотом
dbt компилирует append-модель в простой SQL:
-- На первом run:
CREATE TABLE analytics.events AS
SELECT * FROM source.raw_events;
-- На обычном run:
INSERT INTO analytics.events
SELECT * FROM source.raw_events
WHERE event_timestamp > (SELECT MAX(event_timestamp) FROM analytics.events);
Никаких MERGE, JOIN, UPDATE — только INSERT. Это самая дешёвая incremental-операция, которую warehouse может выполнить.
На big-scale (1B+ строк target таблица, 5M дельта):
INSERT5M строк: 30-60 секунд.- MERGE 5M строк (для сравнения): 2-5 минут (warehouse сканирует target в поисках matching).
- DELETE+INSERT 5M (для сравнения): 1-3 минуты.
Append на 5-10x быстрее любой dedup-стратегии. Если можно использовать append — используйте.
Главные риски append
Риск 1: дубли при повторной вставке источника
Самый частый сценарий: источник (Kafka, Fivetran, Airbyte) повторно отправляет события за тот же период. Это случается:
- При retry в EL-инструменте после ошибки.
- При перезапуске Kafka consumer с фиксированной offset.
- При баге в EL-инструменте.
Что произойдёт в append: фильтр where event_timestamp > max(event_timestamp_in_target) сработает, потому что источник прислал те же события с теми же timestamps. И, конечно, фильтр НЕ выкинет повторные — он только пропускает старые.
Wait, давайте перечитаем. Фильтр сравнивает event_timestamp > max(...). Если источник прислал то же событие повторно, его timestamp совпадает с уже записанным, не превышает. Фильтр его отсечёт.
Но если источник прислал событие с чуть более новым timestamp (например, EL-инструмент проставил новый loaded_at, хотя event_timestamp тот же) — событие пройдёт фильтр и появится в таблице дважды.
Решение — фильтровать по правильной колонке:
-- ПЛОХО: фильтр по loaded_at (timestamp загрузки, не события)
{% if is_incremental() %}
where loaded_at > (select max(loaded_at) from {{ this }})
{% endif %}
-- ХОРОШО: фильтр по event_timestamp (timestamp события)
{% if is_incremental() %}
where event_timestamp > (select max(event_timestamp) from {{ this }})
{% endif %}
С фильтром по event_timestamp повтор события (с тем же timestamp) отсечётся. С фильтром по loaded_at — пройдёт и продублируется.
Риск 2: race conditions при concurrent runs
Если два dbt run запустились одновременно (через два разных Airflow tasks, через два пользовательских ad-hoc), они оба читают max(event_timestamp) одновременно, оба инсертят одни и те же строки.
Решение — гарантировать singleton-запуск на уровне оркестратора (Airflow lock, dbt Cloud job mutex), не на уровне dbt. dbt не имеет встроенной защиты от concurrency.
Риск 3: пропущенные late-arriving events
События могут приходить задним числом: пользователь оффлайн, событие копилось локально, через сутки прилетело в Kafka. event_timestamp события — вчерашний, но loaded_at — сегодняшний.
С фильтром event_timestamp > max(event_timestamp) это late-event отсечётся — его timestamp меньше max’а.
Решения:
- Фильтровать по
loaded_at— но это вернёт риск повторной вставки. - Lookback — фильтровать
where event_timestamp > max(event_timestamp) - interval '7 days'. Это перечитывает последнюю неделю каждый run, ловит late events. Минус — больше compute. Разберём детально в уроке 5. - Hybrid — фильтр по
loaded_at > max(loaded_at)+ dedup на следующем слое (intermediate сqualify row_number() = 1).
Production-обычай — паттерн (3): append на staging-слое для скорости, dedup в intermediate для корректности. Это разделение ответственности и хорошо масштабируется.
append + on_schema_change
Параметр on_schema_change управляет тем, что dbt делает при изменении схемы source:
{{ config(
materialized='incremental',
incremental_strategy='append',
on_schema_change='append_new_columns'
) }}
Опции:
ignore(default) — игнорировать новые колонки. Опасно: добавили колонку в source, dbt её не видит в target, downstream получает NULL.fail— упасть с ошибкой, если схема изменилась. Безопасно, но требует ручной интервенции.append_new_columns— автоматически добавить новые колонки в target. Production-обычай.sync_all_columns— также удалить колонки, которые ушли из source. Опасно (удаление колонки = потеря данных в target).
Рекомендация: append_new_columns для append-стратегии. Это безопасный baseline, который не требует ручных миграций при добавлении новых полей в source.
DuckDB-специфика
- Append на DuckDB работает идентично Snowflake/BQ.
on_schema_change='append_new_columns'— поддерживается.- DuckDB не поддерживает concurrent writers на один файл. Если два процесса пытаются писать в одну
.duckdb— второй упадёт. На MotherDuck это решено (multi-writer на cloud).
Production gotchas
Transaction fact table — теоретический базис append-only модели-
Append без watermark-фильтра — если забыли
{% if is_incremental() %}, на каждом run переинсертится вся таблица. Через неделю в target N копий каждой строки. Часто ловится черезdbt test uniqueна staging, но прибыли уже не вернёшь. -
Watermark по wrong column — фильтр по
created_atисточника, который Stripe иногда задним числом обновляет (например,dispute.created_at). Решение — выбрать immutable watermark column. -
Concurrent writes — два Airflow tasks, оба запускают
dbt run --select my_model. Гонка, дубли. Решение — task lock в оркестраторе. -
Append после удаления из source — событие удалили в source (compliance reason, GDPR), но в append-target оно осталось навсегда. Append не знает про удаления. Решение — отдельный flow для GDPR-удалений (singular test + manual cleanup).
-
append + dimensional table — на dim_customers с 100K строк append работать не будет: customers обновляются, append не дедуплицирует, через неделю dim_customers содержит 5 копий каждого customer. Append только для immutable fact tables.
Когда выбирать append vs другие стратегии
Простой декомпозиционный путь. Сначала вопрос про immutability, потом про дедупликацию, потом про производительность.
Попробуй сам
- В своём проекте найдите event-like модели (analytics events, payment events, log events). Если они incremental с unique_key и merge — рассмотрите переход на append (без unique_key).
- Запустите benchmark: переключите модель на append, измерьте время run. Должно быть на 30-50% быстрее.
- Симулируйте late-arriving event: вручную вставьте в source строку с
event_timestamp = vчера. Запустите dbt run. Заметили ли её? Если нет — это и есть пропуск late-data, обсудим в уроке 5. - Симулируйте дубль: вставьте ту же строку в source дважды. Что произойдёт в target? Это зависит от вашей конкретной watermark column.