Anti-Join dedup (left_anti): загрузка инкремента без дублей - паттерн для batch ETL
Anti-Join dedup (left_anti): загрузка инкремента без дублей - паттерн для batch ETL
Когда данные поступают порциями (батчами), неизбежно возникает вопрос: как убедиться, что в целевую таблицу попадут только новые записи, а не всё подряд? Наивный APPEND безо всяких проверок - это рецепт катастрофы: повторный запуск пайплайна, перекрытие временных окон двух батчей, network retry при записи - и данные задваиваются.
Паттерн LEFT ANTI JOIN отвечает на этот вопрос элегантно: «из входящего батча возьми только те строки, которых ещё нет в целевой таблице». Это не тяжёлый MERGE со сканом всей истории, не Full Overwrite с полной перезаписью - это хирургически точный фильтр на этапе перед записью.
1. Архитектурная проблема: почему наивный APPEND создаёт дубли¶
1.1 Реальные сценарии появления дублей при инкрементальной загрузке¶
Batch ETL pipeline, работающий каждый час, сталкивается с дублями по нескольким причинам:
Перекрытие временных окон. Пайплайн читает события за «последние 2 часа» для надёжности (чтобы не пропустить опоздавшие события). Если он запускается каждый час, каждый батч содержит один час пересечения с предыдущим:
Батч 1: события 10:00 - 12:00
Батч 2: события 11:00 - 13:00 ← час пересечения 11:00-12:00
Без дедупликации события 11:00–12:00 записываются в таблицу дважды.
Airflow retry. Задача упала в конце и была перезапущена Airflow. Источник повторно отдаёт те же данные. Если pipeline просто делает APPEND - все строки дублируются.
CDC lag. Debezium прочитал WAL от LSN=1000, записал события в Kafka. Затем произошёл рестарт коннектора - он начинает с checkpoint LSN=800, перечитывает события 800–1000 повторно.
Late-arriving records. Внешний API прислал события с event_ts=10:00:00, когда уже запускается батч для окна 11:00–12:00. При следующем перезапуске эти события попадут в батч снова.
1.2 Почему MERGE не всегда оптимален¶
MERGE INTO (UPSERT) - мощный инструмент, но у него есть специфика:
- Стоимость на Iceberg Copy-on-Write: MERGE перечитывает все data files, содержащие затронутые строки, и создаёт новые версии. Для таблицы в 10 ТБ даже вставка 1000 новых строк может требовать перечтения гигабайт.
- Избыточность при insert-only workloads: если мы уверены, что обновлять существующие строки не нужно (только вставлять новые), MERGE избыточен - он проверяет каждую входящую строку на совпадение с существующими.
- Не работает на plain Parquet: без Iceberg или Delta Lake MERGE недоступен вообще. Anti-join работает с любым форматом.
1.3 Суть паттерна Left Anti Join¶
Диаграмма показывает суть: из 100 входящих событий 85 уже присутствуют в целевой таблице (их event_id совпадает). LEFT ANTI JOIN оставляет только 15 - те, которых ещё нет. Именно они записываются через APPEND. В таблице не появляется ни одного дубля.
2. Механика LEFT ANTI JOIN в Spark¶
2.1 Что возвращает LEFT ANTI JOIN¶
LEFT ANTI JOIN возвращает строки из левой таблицы, для которых НЕ НАЙДЕНО совпадений в правой. Это дополнение к LEFT SEMI JOIN (который возвращает строки, для которых совпадение НАЙДЕНО):
- LEFT ANTI JOIN:
evt-002иevt-004- они в инкременте, но не в target → НОВЫЕ, нужно вставить - LEFT SEMI JOIN:
evt-001иevt-003- они есть в обоих → УЖЕ ЗАГРУЖЕНЫ evt-099из target никогда не попадает в результат (он только в правой таблице)
2.2 Сравнение с альтернативами в SQL¶
-- LEFT ANTI JOIN (PySpark/Spark SQL)
SELECT inc.*
FROM incoming_events inc
LEFT ANTI JOIN target_events tgt
ON inc.event_id = tgt.event_id;
-- Эквивалент через NOT EXISTS
SELECT inc.*
FROM incoming_events inc
WHERE NOT EXISTS (
SELECT 1 FROM target_events tgt
WHERE tgt.event_id = inc.event_id
);
-- Эквивалент через LEFT JOIN + IS NULL
SELECT inc.*
FROM incoming_events inc
LEFT JOIN target_events tgt
ON inc.event_id = tgt.event_id
WHERE tgt.event_id IS NULL;
-- ПЛОХО: NOT IN с подзапросом
SELECT inc.*
FROM incoming_events inc
WHERE inc.event_id NOT IN (SELECT event_id FROM target_events);
-- Проблема: если в target есть NULL event_id - результат пустой!
-- SQL: "x NOT IN (NULL, ...)" всегда FALSE при наличии NULL в списке
Все четыре запроса возвращают одинаковый результат (при отсутствии NULL в ключах). Но Spark оптимизирует их по-разному.
2.3 Физический план: почему LEFT ANTI быстрее NOT IN¶
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.window import Window
spark = SparkSession.builder \
.appName("AntiJoin-Deep-Dive") \
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
.config("spark.sql.catalog.lakehouse", "org.apache.iceberg.spark.SparkCatalog") \
.config("spark.sql.catalog.lakehouse.type", "hadoop") \
.config("spark.sql.catalog.lakehouse.warehouse", "/tmp/warehouse") \
.getOrCreate()
# Тестовые данные
incoming = spark.createDataFrame([
("evt-001", "user_1", "click"),
("evt-002", "user_2", "buy"),
("evt-003", "user_1", "view"),
], ["event_id", "user_id", "event_type"])
target = spark.createDataFrame([
("evt-001", "user_1", "click"),
("evt-003", "user_1", "view"),
], ["event_id", "user_id", "event_type"])
# Способ 1: left_anti (рекомендуемый)
anti_join = incoming.join(target, on="event_id", how="left_anti")
print("=== left_anti EXPLAIN ===")
anti_join.explain(mode="formatted")
# Способ 2: NOT EXISTS (эквивалент)
incoming.createOrReplaceTempView("incoming")
target.createOrReplaceTempView("target")
not_exists = spark.sql("""
SELECT * FROM incoming i
WHERE NOT EXISTS (SELECT 1 FROM target t WHERE t.event_id = i.event_id)
""")
print("=== NOT EXISTS EXPLAIN ===")
not_exists.explain(mode="formatted")
Физический план left_anti:
== Physical Plan ==
* BroadcastHashJoin [event_id], [event_id], LeftAnti ← Anti семантика на уровне join
:- * Scan: incoming
+- BroadcastExchange ← target broadcast в память
+- * Scan: target
Spark оптимизирует left_anti как специализированный join: он строит hash-таблицу из правой части (target), затем для каждой строки из левой части (incoming) ищет совпадение в хэш-таблице. Если совпадение найдено - строка отбрасывается. Если не найдено - строка сохраняется.
Критически важно: Spark не генерирует NULL-колонки из правой таблицы (в отличие от LEFT JOIN + WHERE IS NULL). Это экономит память и CPU при больших правых таблицах.
NOT IN с подзапросом опасен при наличии NULL: в SQL x NOT IN (1, 2, NULL) всегда возвращает FALSE, потому что x != NULL неизвестно. Если в target есть хотя бы одна строка с event_id = NULL, весь запрос вернёт пустой DataFrame!
3. Пошаговая реализация: canonical anti-join ETL¶
3.1 Шаг 1 - Внутренняя дедупликация инкремента¶
Перед anti-join инкремент сам может содержать дубли (Kafka at-least-once). Если мы пропустим этот шаг, anti-join вернёт дубли из инкремента:
# Имитируем входящий батч с дублями внутри
raw_increment = spark.createDataFrame([
("evt-010", "order_2001", "created", 1500.0, "2026-05-23 10:00:00"),
("evt-010", "order_2001", "created", 1500.0, "2026-05-23 10:00:00"), # дубль!
("evt-011", "order_2002", "pending", 750.0, "2026-05-23 10:05:00"),
("evt-012", "order_2003", "confirmed", 200.0, "2026-05-23 10:10:00"),
# evt-001 уже есть в target - anti-join должен его отсечь
("evt-001", "order_1001", "confirmed",1500.0, "2026-05-23 09:00:00"),
], ["event_id", "order_id", "status", "amount", "event_ts"])
# ШАГ 1: Дедупликация самого инкремента
# Без этого evt-010 дважды попадёт в anti-join и оба пройдут фильтр
clean_increment = raw_increment.dropDuplicates(["event_id"])
print(f"Инкремент до dedup: {raw_increment.count()} строк")
print(f"Инкремент после dedup: {clean_increment.count()} строк")
# До: 5, после: 4 (убрали дубль evt-010)
Почему это важно: если инкремент содержит два evt-010 и оба отсутствуют в target, anti-join пропустит оба - в таблице появится дубль. Сначала дедуплицируем инкремент, потом делаем anti-join с target.
3.2 Шаг 2 - Чтение целевой таблицы с оптимизацией¶
Если target содержит данные за 3 года (100 ТБ), читать всё для anti-join слишком дорого. Используем скользящее окно - читаем только данные за разумный период:
# Создаём target таблицу (имитация существующей Silver)
spark.sql("""
CREATE TABLE IF NOT EXISTS lakehouse.silver.order_events (
event_id STRING,
order_id STRING,
status STRING,
amount DECIMAL(10,2),
event_ts TIMESTAMP,
event_date DATE
)
USING iceberg
PARTITIONED BY (event_date)
""")
# Имитируем существующие данные в Silver
existing_data = spark.createDataFrame([
("evt-001", "order_1001", "confirmed", 1500.0, "2026-05-23 09:00:00", "2026-05-23"),
("evt-002", "order_1001", "created", 1500.0, "2026-05-22 08:00:00", "2026-05-22"),
], ["event_id", "order_id", "status", "amount", "event_ts", "event_date"])
existing_data \
.withColumn("event_ts", F.to_timestamp("event_ts")) \
.withColumn("event_date", F.to_date("event_date")) \
.withColumn("amount", F.col("amount").cast("decimal(10,2)")) \
.writeTo("lakehouse.silver.order_events") \
.append()
# ОПТИМИЗАЦИЯ: читаем target только за последние 7 дней
# Spark применяет Partition Pruning - читает только нужные партиции
LOOKBACK_DAYS = 7
target_window = (
spark.table("lakehouse.silver.order_events")
.filter(
F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS)
)
.select("event_id") # берём только ключ - минимальный объём данных
)
print(f"Строк в target window ({LOOKBACK_DAYS} дней): {target_window.count()}")
Почему select("event_id"): правая часть anti-join нужна только для проверки наличия ключа. Нет смысла читать все поля таблицы - только ключ. Это уменьшает объём данных в правой стороне join-а в разы.
Выбор LOOKBACK_DAYS - компромисс:
- Слишком мало (1 день): события старше суток пройдут anti-join как «новые» и создадут дубли при late-arriving
- Слишком много (365 дней): читаем огромный объём данных, join медленный
- Правило:
LOOKBACK_DAYS≥ максимально возможной задержки late-arriving events в вашей системе
3.3 Шаг 3 - Anti-join и запись результата¶
# ШАГ 3: Anti-join - оставляем только действительно новые события
new_events = (
clean_increment # левая: дедуплицированный инкремент
.join(
target_window, # правая: только ключи из target-окна
on="event_id",
how="left_anti" # строки без совпадения в правой
)
)
print(f"\nАнти-джойн результат:")
new_events.show()
# event_id | order_id | status | amount | event_ts
# evt-010 | order_2001 | created | 1500.0 | ...
# evt-011 | order_2002 | pending | 750.0 | ...
# evt-012 | order_2003 | confirmed | 200.0 | ...
# evt-001 отсечён: он уже есть в target
print(f"Новых событий для записи: {new_events.count()}")
# 3 из 4 (evt-001 отсечён anti-join)
# ШАГ 4: Безопасный APPEND только новых строк
(
new_events
.withColumn("event_ts", F.to_timestamp("event_ts"))
.withColumn("amount", F.col("amount").cast("decimal(10,2)"))
.withColumn("event_date", F.to_date("event_ts"))
.writeTo("lakehouse.silver.order_events")
.append()
)
print("Данные записаны в Silver через безопасный APPEND")
print(f"Итого в Silver: {spark.table('lakehouse.silver.order_events').count()} строк")
# 5 строк: 2 существующих + 3 новых
3.4 Spark SQL эквивалент¶
-- Эквивалентный запрос на Spark SQL (более читаемый для SQL-разработчиков)
INSERT INTO lakehouse.silver.order_events
SELECT
inc.event_id,
inc.order_id,
inc.status,
CAST(inc.amount AS DECIMAL(10,2)),
CAST(inc.event_ts AS TIMESTAMP),
TO_DATE(inc.event_ts) AS event_date
FROM clean_increment_view AS inc
WHERE NOT EXISTS (
SELECT 1
FROM lakehouse.silver.order_events AS tgt
WHERE tgt.event_id = inc.event_id
AND tgt.event_date >= CURRENT_DATE() - INTERVAL 7 DAYS
);
NOT EXISTS и left_anti дают Spark одинаковый physical plan через Catalyst Optimizer. Используйте тот синтаксис, который понятнее команде - результат идентичен.
4. Оптимизация производительности¶
4.1 Broadcast Anti-Join: когда одна сторона мала¶
По умолчанию Spark выбирает тип join-а (SortMergeJoin или BroadcastHashJoin) автоматически на основе размера таблиц. Но иногда явный hint помогает:
# Случай 2: Broadcast target-ключи (маленькая правая сторона)
# Spark автоматически broadcast-ит таблицы < spark.sql.autoBroadcastJoinThreshold (10MB)
# Но если target_window.count() < 100k строк - лучше указать явно:
new_events_broadcast = (
clean_increment
.join(
F.broadcast(target_window), # явный broadcast hint
on="event_id",
how="left_anti"
)
)
new_events_broadcast.explain(mode="formatted")
# Physical Plan:
# * BroadcastHashJoin [event_id], [event_id], LeftAnti
# :- * Scan: clean_increment (без Shuffle!)
# +- BroadcastExchange (target распределяется в память каждого executor)
# +- * Scan: target_window
# Случай 1: Broadcast инкремент (маленькая левая сторона)
# Не всегда возможен для left_anti: broadcast в left_anti оптимально
# применяется к ПРАВОЙ таблице
# Если инкремент мал - рассмотрите перестановку: target ANTI JOIN increment
small_increment_anti = (
target_window
.join(
F.broadcast(clean_increment.select("event_id")),
on="event_id",
how="left_anti" # НЕ то же самое! Это строки target без совпадений
)
# Это anti-join другой направленности - осторожно!
)
4.2 Partition Pruning: фильтр ДО join-а¶
Самая эффективная оптимизация - читать из target только нужные партиции:
# Неоптимально: Spark читает ВСЕ партиции target
bad_anti = (
clean_increment
.join(
spark.table("lakehouse.silver.order_events"), # вся таблица!
on="event_id",
how="left_anti"
)
)
# Оптимально: Spark читает только партиции за последние 7 дней
# Static Partition Pruning: фильтр применяется ДО join-а
good_anti = (
clean_increment
.join(
spark.table("lakehouse.silver.order_events")
.filter(F.col("event_date") >= F.date_sub(F.current_date(), 7)),
on="event_id",
how="left_anti"
)
)
# Spark SQL вариант с явным фильтром в подзапросе
spark.sql("""
SELECT inc.*
FROM clean_increment inc
LEFT ANTI JOIN (
SELECT event_id
FROM lakehouse.silver.order_events
WHERE event_date >= CURRENT_DATE() - INTERVAL 7 DAYS -- Partition Pruning!
) tgt
ON inc.event_id = tgt.event_id
""")
4.3 SortMerge Anti-Join для гигантских объёмов¶
Когда обе стороны join-а слишком большие для Broadcast, Spark использует SortMergeJoin. Ключевая настройка - количество shuffle-партиций:
# Для больших объёмов: настраиваем shuffle
spark.conf.set("spark.sql.shuffle.partitions", "400") # больше партиций = меньше размер каждой
spark.conf.set("spark.sql.adaptive.enabled", "true") # AQE автоматически подбирает оптимум
# AQE (Adaptive Query Execution) автоматически:
# 1. Уменьшает shuffle.partitions если данных меньше ожидаемого
# 2. Переключает SortMergeJoin → BroadcastHashJoin если одна сторона оказалась малой
# 3. Обрабатывает Data Skew через split-операции
large_increment = spark.table("lakehouse.bronze.orders_large") # 100M строк
large_target = spark.table("lakehouse.silver.orders_large") # 1B строк
# AQE определит правильную стратегию автоматически
new_only = (
large_increment
.join(
large_target
.filter(F.col("event_date") >= F.date_sub(F.current_date(), 30))
.select("event_id"),
on="event_id",
how="left_anti"
)
)
4.4 Bloom Filter для пропуска data files¶
Apache Iceberg поддерживает Bloom Filter - вероятностную структуру данных, которая позволяет быстро ответить: «может ли этот event_id существовать в данном файле?» Если фильтр говорит «нет» - файл вообще не читается:
# Включаем Bloom Filter для колонки event_id при записи
spark.sql("""
ALTER TABLE lakehouse.silver.order_events
SET TBLPROPERTIES (
'write.metadata.metrics.column.event_id' = 'full',
'write.parquet.bloom-filter-enabled.column.event_id' = 'true',
'write.parquet.bloom-filter-false-positive-probability.column.event_id' = '0.01'
)
""")
# Теперь при anti-join Iceberg будет пропускать data files,
# в которых точно нет искомых event_id - без чтения самих файлов
Bloom Filter работает как «ранний выход»: перед тем как читать Parquet-файл, Iceberg проверяет его Bloom Filter. Если filter говорит «этого event_id здесь нет» - файл пропускается. Ложноположительные срабатывания (false positive) возможны, ложноотрицательные (false negative) - нет. Параметр 0.01 означает 1% вероятность ложного срабатывания.
5. Anti-Join vs MERGE vs Overwrite: матрица выбора¶
5.1 Когда что применять¶
5.2 Таблица сравнения¶
| Критерий | Anti-Join + APPEND | MERGE INTO | Overwrite Partitions |
|---|---|---|---|
| Поддержка UPDATE | ❌ | ✅ | ✅ (весь партишен) |
| Поддержка DELETE | ❌ | ✅ | ✅ (весь партишен) |
| Работает без Iceberg | ✅ (plain Parquet) | ❌ | ✅ |
| Стоимость вычислений | Низкая | Средняя–Высокая | Средняя |
| Идемпотентность | ✅ Естественная | ✅ С правильным ON | ✅ |
| Late-arriving data | ⚠️ Требует lookback | ✅ UPSERT обновит | ✅ Перезапись партиции |
| CDC потоки | ❌ (нет UPDATE) | ✅ | ⚠️ (только daily) |
| Append-only факты | ✅ Идеально | Избыточно | Избыточно |
6. Правильный выбор ключа anti-join¶
6.1 Типы ключей и их надёжность¶
Качество дедупликации полностью определяется надёжностью join-ключа. Ненадёжный ключ - ненадёжная дедупликация:
6.2 Проблема NULL-ключей в anti-join¶
# ЛОВУШКА: NULL-значения в join-ключе
events_with_null = spark.createDataFrame([
("evt-001", "order_1001"),
(None, "order_1002"), # NULL event_id!
("evt-003", "order_1003"),
], ["event_id", "order_id"])
target_keys = spark.createDataFrame([
("evt-001",),
("evt-003",),
], ["event_id"])
# LEFT ANTI JOIN с NULL ключом в левой таблице
result = events_with_null.join(target_keys, on="event_id", how="left_anti")
result.show()
# event_id | order_id
# null | order_1002 ← NULL-строка ПРОШЛА anti-join!
# (evt-001 и evt-003 отсечены корректно)
# В SQL: NULL != NULL → совпадение не найдено → строка попадает в ANTI результат
# Это может быть нежелательным поведением!
# РЕШЕНИЕ: фильтруем NULL-ключи перед anti-join
clean_for_anti = events_with_null.filter(F.col("event_id").isNotNull())
result_clean = clean_for_anti.join(target_keys, on="event_id", how="left_anti")
result_clean.show()
# Пустой результат - оба не-NULL ключа найдены в target
NULL != NULL в SQL - это фундаментальное правило трёхзначной логики. Строка с event_id = NULL всегда проходит anti-join, потому что NULL = NULL неизвестно → совпадение «не найдено» → строка остаётся. Это приводит к тому, что NULL-ключи всегда вставляются в target.
6.3 Составной ключ: когда одного поля недостаточно¶
# Некоторые системы не имеют глобального UUID - ключ составной
# Пример: события уникальны по (order_id, event_type, event_ts)
increment_comp = spark.createDataFrame([
("order_1001", "created", "2026-05-23 10:00:00", 1500.0),
("order_1001", "paid", "2026-05-23 10:05:00", 1500.0),
("order_1002", "created", "2026-05-23 10:10:00", 750.0),
], ["order_id", "event_type", "event_ts", "amount"])
target_comp = spark.table("lakehouse.silver.order_events") \
.filter(F.col("event_date") >= F.date_sub(F.current_date(), 7)) \
.select("order_id", "event_type", "event_ts")
# Anti-join по составному ключу
new_comp = (
increment_comp
.join(
target_comp,
on=["order_id", "event_type", "event_ts"], # составной ключ
how="left_anti"
)
)
# Spark SQL эквивалент:
spark.sql("""
SELECT inc.*
FROM increment_comp inc
LEFT ANTI JOIN target_comp tgt
ON inc.order_id = tgt.order_id
AND inc.event_type = tgt.event_type
AND inc.event_ts = tgt.event_ts
""")
При составном ключе нужно быть особенно осторожным с типами: event_ts как STRING в инкременте и TIMESTAMP в target → JOIN не найдёт совпадений → все строки пройдут как «новые». Всегда приводите типы ключей к одинаковому типу перед anti-join.
7. Идемпотентность anti-join pipeline¶
7.1 Почему anti-join естественно идемпотентен¶
Диаграмма показывает: при втором запуске Airflow с теми же входными данными anti-join обнаружит, что все 15 «новых» строк из первого запуска уже существуют в Silver. Результат anti-join - пустой DataFrame. APPEND 0 строк. Таблица не изменилась.
Это естественная идемпотентность - она возникает автоматически из-за семантики anti-join, без дополнительного кода.
7.2 Граница lookback window и late-arriving¶
Единственная уязвимость: если событие опоздало настолько, что его timestamp выходит за пределы LOOKBACK_DAYS:
# Пример: LOOKBACK_DAYS = 7
# evt-OLD было загружено 10 дней назад (за пределами lookback window)
# Теперь источник снова прислал evt-OLD (например, при переотправке истории)
LOOKBACK_DAYS = 7
target_window = (
spark.table("lakehouse.silver.order_events")
.filter(F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS))
.select("event_id")
)
# evt-OLD (10 дней назад) НЕ попадёт в target_window
# Anti-join не найдёт его в правой таблице
# evt-OLD ПРОЙДЁТ как «новое» и будет записан повторно!
# Решения:
# 1. Увеличить LOOKBACK_DAYS (но растёт стоимость join-а)
# 2. Хранить отдельную "seen events" таблицу с только ключами (экономично)
# 3. Использовать MERGE INTO вместо anti-join для таких случаев
# Решение 2: отдельная компактная таблица ключей
spark.sql("""
CREATE TABLE IF NOT EXISTS lakehouse.silver.order_events_keys (
event_id STRING,
loaded_date DATE
)
USING iceberg
PARTITIONED BY (loaded_date)
TBLPROPERTIES ('write.metadata.metrics.column.event_id' = 'full')
""")
# Ключевая таблица хранит ТОЛЬКО event_id - минимальный размер
# Lookback по loaded_date, не по event_ts - всегда актуально
8. Антипаттерны anti-join deduplication¶
8.1 Anti-join без деduplication инкремента¶
# АНТИПАТТЕРН: anti-join без предварительной дедупликации инкремента
raw_with_dups = spark.createDataFrame([
("evt-NEW", "order_3001", "created"),
("evt-NEW", "order_3001", "created"), # дубль в инкременте!
], ["event_id", "order_id", "status"])
# Anti-join не сможет помочь: оба evt-NEW отсутствуют в target
# оба пройдут фильтр и оба будут записаны!
bad_result = raw_with_dups.join(target_keys, on="event_id", how="left_anti")
print(f"Строк пройдёт: {bad_result.count()}") # 2 - оба дубля!
# ПРАВИЛЬНО: сначала дедуплицировать инкремент
clean = raw_with_dups.dropDuplicates(["event_id"])
good_result = clean.join(target_keys, on="event_id", how="left_anti")
print(f"Строк пройдёт: {good_result.count()}") # 1
8.2 Anti-join для mutable данных¶
# АНТИПАТТЕРН: использование anti-join для обновляемых сущностей
# Заказ order_1001 уже в Silver со статусом "created"
# Новый батч содержит тот же order_id со статусом "confirmed"
increment_update = spark.createDataFrame([
("evt-001-v2", "order_1001", "confirmed", 1500.0), # обновлённая версия
], ["event_id", "order_id", "status", "amount"])
# Anti-join по event_id: evt-001-v2 НЕТ в target → пройдёт как «новое»
# В Silver появится ДВЕ строки для order_1001: created и confirmed
# Это ПРАВИЛЬНО если Silver хранит историю событий (event sourcing)
# Но НЕВЕРНО если Silver хранит текущее состояние заказа!
# Для mutable сущностей (current state) → MERGE INTO, не anti-join
Правило: anti-join подходит для append-only фактовых таблиц (события, транзакции, логи). Для мутабельных сущностей (текущий статус заказа, профиль клиента) - MERGE INTO.
8.3 Anti-join по ненадёжному ключу¶
# АНТИПАТТЕРН: использование event_ts как ключа anti-join
# Разные источники могут присылать одно событие с разным timestamp!
source_a = ("order_1001", "created", "2026-05-23 10:00:00.000")
source_b = ("order_1001", "created", "2026-05-23 10:00:00.001") # 1 мс разницы!
# Anti-join по (order_id, event_ts): не найдёт совпадение из-за timestamp разницы
# Оба события вставятся как «новые» - дубль по бизнес-смыслу
# ПРАВИЛЬНО: использовать стабильный бизнес-ключ (order_id + event_type)
# или UUID, если источник его предоставляет
9. End-to-End кейс: incremental batch ingestion¶
9.1 Сценарий¶
Ежечасный батч загружает события заказов из внешнего API в Silver. API может возвращать перекрывающиеся временные окна при retry. Нужен идемпотентный pipeline.
import datetime
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder \
.appName("AntiJoin-ETL-E2E") \
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
.config("spark.sql.catalog.lakehouse", "org.apache.iceberg.spark.SparkCatalog") \
.config("spark.sql.catalog.lakehouse.type", "hadoop") \
.config("spark.sql.catalog.lakehouse.warehouse", "/tmp/warehouse") \
.getOrCreate()
spark.conf.set("spark.sql.adaptive.enabled", "true")
# ─── ПАРАМЕТРЫ БАТЧА ─────────────────────────────────────────────────────────
BATCH_TS = "2026-05-23 11:00:00" # эталонное время запуска (не current_timestamp!)
LOOKBACK_DAYS = 7 # окно поиска в target
TARGET_TABLE = "lakehouse.silver.order_events"
# ─── ВХОДЯЩИЙ БАТЧ ИЗ API ────────────────────────────────────────────────────
# API вернул данные за 10:00–11:00. Часть из них уже загружена предыдущим батчем!
raw_api_response = spark.createDataFrame([
# Технический дубль: evt-001 загружен прошлым батчем
("evt-001", "order_1001", "created", 1500.0, "2026-05-23 10:00:00"),
# Технический дубль внутри батча
("evt-010", "order_2001", "created", 500.0, "2026-05-23 10:15:00"),
("evt-010", "order_2001", "created", 500.0, "2026-05-23 10:15:00"),
# Действительно новые события
("evt-011", "order_2002", "confirmed", 2000.0, "2026-05-23 10:30:00"),
("evt-012", "order_2003", "pending", 750.0, "2026-05-23 10:45:00"),
("evt-013", "order_2004", "created", 1200.0, "2026-05-23 10:55:00"),
], ["event_id", "order_id", "status", "amount", "event_ts"])
print(f"Событий из API: {raw_api_response.count()}") # 6
# ─── ШАГ 1: ИНИЦИАЛИЗАЦИЯ TARGET ─────────────────────────────────────────────
spark.sql(f"""
CREATE TABLE IF NOT EXISTS {TARGET_TABLE} (
event_id STRING,
order_id STRING,
status STRING,
amount DECIMAL(10,2),
event_ts TIMESTAMP,
event_date DATE
)
USING iceberg
PARTITIONED BY (event_date)
""")
# Имитируем существующие данные (загружены прошлым батчем)
existing = spark.createDataFrame([
("evt-001", "order_1001", "created", 1500.0, "2026-05-23 10:00:00", "2026-05-23"),
], ["event_id", "order_id", "status", "amount", "event_ts", "event_date"])
existing \
.withColumn("event_ts", F.to_timestamp("event_ts")) \
.withColumn("amount", F.col("amount").cast("decimal(10,2)")) \
.withColumn("event_date", F.to_date("event_date")) \
.writeTo(TARGET_TABLE).append()
print(f"Существующих строк в Silver: {spark.table(TARGET_TABLE).count()}") # 1
# ─── ШАГ 2: ДЕДУПЛИКАЦИЯ ИНКРЕМЕНТА ──────────────────────────────────────────
clean_increment = (
raw_api_response
.filter(F.col("event_id").isNotNull()) # убираем NULL-ключи
.dropDuplicates(["event_id"]) # технические дубли внутри батча
)
print(f"После дедупликации инкремента: {clean_increment.count()} строк") # 5
# ─── ШАГ 3: LOADING TARGET WINDOW ────────────────────────────────────────────
target_keys = (
spark.table(TARGET_TABLE)
.filter(F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS))
.select("event_id") # только ключ - минимальный объём данных
)
print(f"Ключей в target window ({LOOKBACK_DAYS} дней): {target_keys.count()}") # 1
# ─── ШАГ 4: ANTI-JOIN ────────────────────────────────────────────────────────
new_events = (
clean_increment
.join(
F.broadcast(target_keys), # target_keys мал - broadcast
on="event_id",
how="left_anti"
)
)
print(f"Новых событий для вставки: {new_events.count()}") # 4
new_events.select("event_id", "order_id", "status").show()
# evt-010 | order_2001 | created (прошёл: нет в target)
# evt-011 | order_2002 | confirmed (прошёл: нет в target)
# evt-012 | order_2003 | pending (прошёл: нет в target)
# evt-013 | order_2004 | created (прошёл: нет в target)
# evt-001 ОТСЕЧЁН: уже есть в target
# ─── ШАГ 5: БЕЗОПАСНЫЙ APPEND ────────────────────────────────────────────────
(
new_events
.withColumn("event_ts", F.to_timestamp("event_ts"))
.withColumn("amount", F.col("amount").cast("decimal(10,2)"))
.withColumn("event_date", F.to_date("event_ts"))
.writeTo(TARGET_TABLE)
.append()
)
print(f"\nИтого в Silver: {spark.table(TARGET_TABLE).count()} строк") # 5
spark.table(TARGET_TABLE).orderBy("event_ts").show()
# ─── ПРОВЕРКА ИДЕМПОТЕНТНОСТИ ─────────────────────────────────────────────────
print("\n=== СИМУЛЯЦИЯ AIRFLOW RETRY (те же входные данные) ===")
# Повторяем шаги 2-5 с теми же raw_api_response
clean_increment_retry = (
raw_api_response
.filter(F.col("event_id").isNotNull())
.dropDuplicates(["event_id"])
)
target_keys_retry = (
spark.table(TARGET_TABLE)
.filter(F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS))
.select("event_id")
)
new_events_retry = (
clean_increment_retry
.join(F.broadcast(target_keys_retry), on="event_id", how="left_anti")
)
print(f"Новых событий при retry: {new_events_retry.count()}") # 0 - ВСЕ УЖЕ ЕСТЬ!
# APPEND 0 строк - таблица не изменилась
if new_events_retry.count() > 0:
new_events_retry \
.withColumn("event_ts", F.to_timestamp("event_ts")) \
.withColumn("amount", F.col("amount").cast("decimal(10,2)")) \
.withColumn("event_date", F.to_date("event_ts")) \
.writeTo(TARGET_TABLE).append()
print(f"Silver после retry: {spark.table(TARGET_TABLE).count()} строк") # всё те же 5!
print("Идемпотентность подтверждена: retry не создал дублей ✓")
9.2 Финальный результат¶
Событий из API: 6
Существующих строк в Silver: 1
После дедупликации инкремента: 5 строк
Ключей в target window (7 дней): 1
Новых событий для вставки: 4
event_id | order_id | status
evt-010 | order_2001 | created
evt-011 | order_2002 | confirmed
evt-012 | order_2003 | pending
evt-013 | order_2004 | created
Итого в Silver: 5 строк
=== СИМУЛЯЦИЯ AIRFLOW RETRY ===
Новых событий при retry: 0
Silver после retry: 5 строк
Идемпотентность подтверждена: retry не создал дублей ✓
Итог: из 6 входящих событий API:
- 1 технический дубль внутри батча (
evt-010дважды) - убранdropDuplicates - 1 событие уже загружено (
evt-001) - отсечено anti-join - 4 действительно новых - записаны через APPEND
- При Airflow retry - 0 изменений, таблица осталась консистентной
10. Финальная шпаргалка¶
Используйте LEFT ANTI JOIN когда:
- Данные append-only: события, транзакции, логи (не обновляемые сущности)
- Нужна идемпотентная вставка без overhead MERGE
- Источник - plain Parquet или CSV (без Iceberg/Delta)
- Инкремент мал по сравнению с target (Broadcast Anti-Join)
- Нет требований к обновлению или удалению существующих строк
Используйте MERGE INTO когда:
- Данные мутабельные: статус заказа, профиль клиента (CDC с UPDATE/DELETE)
- Нужна точечная синхронизация состояния
- Используете Iceberg или Delta Lake
Используйте Overwrite Partitions когда:
- Детерминированные агрегаты Gold-слоя (всегда пересчитываются)
- Нужна полная замена партиции без сложной логики ключей