Learning Platform
Глоссарий Troubleshooting
Урок 03.02 · 20 мин
Средний
appendincremental strategiesimmutable logsno-dedup

Append strategy: immutable logs

Append — самая простая из четырёх incremental-стратегий. Она делает ровно одно: INSERT новых строк в существующую таблицу. Без UPDATE, без DELETE, без MERGE. Если в источнике появилась новая строка — она вставится. Если та же строка пришла дважды — будет дважды в таблице.

Это звучит примитивно, но в правильном use case append — самая быстрая и надёжная стратегия. В этом уроке разберём, где она блистает, а где даёт неожиданные дубли.

Когда append идеальна — immutable logs

Append рассчитана на append-only данные: события, которые после записи уже не меняются. Типичные примеры:

Что такое append-only данные

Эти типы данных по природе immutable — записанное событие не меняется и не удаляется. На них append работает безопасно и быстро.

Application eventsuser_signed_up, button_clicked, page_viewed. После записи событие не меняется — это факт, что произошло в момент X
Stripe chargesКаждый charge — отдельное событие. status может меняться, но создаётся новая запись statuschange, оригинальная остаётся
IoT telemetryСенсоры пишут timestamped readings. Каждое измерение — отдельная строка, никаких updates
Audit logsЖурнал действий пользователей: who, what, when. После записи не меняется по дизайну (это требование compliance)
Kafka topicsСобытия в Kafka топиках — append-only по протоколу. Идеально подходят к dbt append стратегии
Web analyticsGA4 / Mixpanel / Amplitude events. Каждый event — отдельная запись, immutable

Общее свойство — каждая запись это факт о моменте времени. Факт не меняется, не удаляется. Если данные ведут себя так в источнике — 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:

  1. Первый run: is_incremental() = False, выполняется полный SELECT, создаётся таблица.
  2. Последующие 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 дельта):

  • INSERT 5M строк: 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’а.

Решения:

  1. Фильтровать по loaded_at — но это вернёт риск повторной вставки.
  2. Lookback — фильтровать where event_timestamp > max(event_timestamp) - interval '7 days'. Это перечитывает последнюю неделю каждый run, ловит late events. Минус — больше compute. Разберём детально в уроке 5.
  3. 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 модели
  1. Append без watermark-фильтра — если забыли {% if is_incremental() %}, на каждом run переинсертится вся таблица. Через неделю в target N копий каждой строки. Часто ловится через dbt test unique на staging, но прибыли уже не вернёшь.

  2. Watermark по wrong column — фильтр по created_at источника, который Stripe иногда задним числом обновляет (например, dispute.created_at). Решение — выбрать immutable watermark column.

  3. Concurrent writes — два Airflow tasks, оба запускают dbt run --select my_model. Гонка, дубли. Решение — task lock в оркестраторе.

  4. Append после удаления из source — событие удалили в source (compliance reason, GDPR), но в append-target оно осталось навсегда. Append не знает про удаления. Решение — отдельный flow для GDPR-удалений (singular test + manual cleanup).

  5. append + dimensional table — на dim_customers с 100K строк append работать не будет: customers обновляются, append не дедуплицирует, через неделю dim_customers содержит 5 копий каждого customer. Append только для immutable fact tables.

Когда выбирать append vs другие стратегии

Decision tree: какая стратегия?

Простой декомпозиционный путь. Сначала вопрос про immutability, потом про дедупликацию, потом про производительность.

Данные immutable?События, audit logs, IoT, payments. Записанная строка никогда не меняется и не удаляется
да
appendСамая быстрая стратегия. Без unique_key, без dedup, просто INSERT новых
Данные mutable?Order status меняется, customer info обновляется. Нужен ON CONFLICT UPDATE
merge available?
mergeDuckDB 1.4+, Snowflake/BQ всегда. Native MERGE — самая эффективная для UPDATE
Mutable + no mergeDuckDB до 1.4 или иначе ограничения. Используем DELETE matching + INSERT delta
delete+insertРаботает везде, чуть медленнее merge, но даёт те же гарантии

Попробуй сам

  1. В своём проекте найдите event-like модели (analytics events, payment events, log events). Если они incremental с unique_key и merge — рассмотрите переход на append (без unique_key).
  2. Запустите benchmark: переключите модель на append, измерьте время run. Должно быть на 30-50% быстрее.
  3. Симулируйте late-arriving event: вручную вставьте в source строку с event_timestamp = vчера. Запустите dbt run. Заметили ли её? Если нет — это и есть пропуск late-data, обсудим в уроке 5.
  4. Симулируйте дубль: вставьте ту же строку в source дважды. Что произойдёт в target? Это зависит от вашей конкретной watermark column.
Проверка знанийKnowledge check
Команда использует append на fct_user_events. Через месяц в downstream-таблице dim_users_aggregated видят, что у некоторых пользователей счётчик event_count удвоился. С чего начать диагностику?
ОтветAnswer
Это классический симптом дублей в append-таблице. Диагностика по шагам. Первое — проверить, есть ли дубли реально в fct_user_events: 'SELECT event_id, count(*) FROM fct_user_events GROUP BY event_id HAVING count(*) > 1 LIMIT 100'. Если есть — подтверждена проблема. Второе — выяснить корневую причину. Самая частая — фильтр incremental сравнивает по неправильной колонке (например, loaded_at вместо event_timestamp), и источник иногда переотправляет события с обновлённым loaded_at. Проверить SQL модели и сопоставить с поведением источника. Третье — проверить concurrent runs: посмотреть Airflow/dbt Cloud logs, не было ли двух одновременных запусков этой модели. Четвёртое — если incremental column = event_timestamp, проверить, не отправляет ли источник одно и то же event с slightly different timestamps (одна и та же event_id, но event_timestamp отличается на миллисекунды). Восстановление: 'dbt run --full-refresh' пересчитает target с нуля, фикс корневой причины (правильная watermark column, lock в оркестраторе, или dedup на следующем слое через qualify row_number() = 1). Долгосрочно — не доверять append без unique_key для критичных метрик: либо ставить qualify-dedup в intermediate, либо переходить на merge/delete+insert.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Для каких данных стратегия append идеальна?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5