XNOVA
← Все статьи

СТАТЬЯ / data-engineering

Retry, Rerun и Backfill: почему повторная загрузка может испортить данные

Показываем, как повтор после COMMIT удваивает выручку, почему запись по ключу это исправляет и как проверить пересчёт исторического дня.

Автор: XNova

После повторной загрузки выручка выросла вдвое

За день в источнике было два заказа на 30 рублей. Загрузку пришлось повторить, и в отчёте стало 60 рублей, хотя новых заказов не появилось. Повторная попытка завершилась успешно, но повторно записала те же строки.

Причина связана с моментом сбоя. Первая попытка успела сохранить данные в базе, но не сообщила об успехе. Планировщик получил ошибку и запустил задачу снова. Если загрузчик при каждом запуске просто добавляет строки, он не отличает новые заказы от уже сохранённых.

Проверим это на небольшой таблице заказов с полями order_id, order_date, amount_cents и source_version. Суммы хранятся в целых копейках, а дневная выручка рассчитывается через SUM(amount_cents). Один order_id обозначает один заказ — именно это правило нарушит небезопасная загрузка.

Дальше разберём, какие повторы встречаются в обработке данных, воспроизведём сбой после COMMIT, исправим способ записи и проверим пересчёт одного исторического дня. Пример использует вымышленные заказы и SQLite. Airflow и dbt нужны лишь для пояснения терминов; в эксперименте они не запускались.

Чем различаются retry, rerun и backfill

Во всех трёх случаях данные могут обрабатываться повторно. Разница — в причине и масштабе повторной работы.

ОперацияЧто происходитПример
Retry — повторная попыткаЗадача запускается ещё раз после ошибкиЗагрузка записала строки, но не смогла подтвердить успех
Rerun — повторный запускТу же обработку выполняют заново, например после исправления кодаОператор заново загружает заказы за вчера
Backfill — обработка прошлых периодовЗапускают обработку выбранных исторических интерваловПересчитывают данные за несколько дней прошлого месяца

В документации задач Apache Airflow состояние up_for_retry означает, что попытка завершилась неудачно и возможен повтор. Но Airflow не знает, успела ли первая попытка зафиксировать транзакцию в вашей базе. Rerun здесь — общее название повторного выполнения, а не отдельная универсальная команда Airflow.

Механизм backfill в Airflow создаёт запуски за прошлые даты. Параметр повторной обработки (none, failed или completed) определяет, создавать ли новый запуск, если за эту дату уже существует другой. Эти настройки управляют запусками DAG, но не гарантируют отсутствие дубликатов в таблицах.

Следовательно, для любого из трёх случаев нужно заранее определить, что произойдёт, если одни и те же записи поступят повторно.

Один заказ — одна строка

Возьмём два заказа за 1 октября 2026 года:

order_idorder_dateamount_centssource_version
O-1012026-10-011 0001
O-1022026-10-012 0001

Итог — 3 000 копеек, то есть 30 рублей. В целевой таблице должен находиться один экземпляр каждого заказа. Для этого примера ключ записи — order_id, а source_version обозначает версию данных об этом заказе.

Идентификатор запуска (run_id или batch_id) решает другую задачу: позволяет выяснить, когда и какой обработкой записаны данные. При повторе он может измениться, тогда как order_id останется прежним. Использовать номер запуска вместо ключа заказа для удаления дубликатов было бы ошибкой. Два разных заказа с одинаковой суммой тоже нельзя считать дублями.

Похожее различие используется в инкрементальных моделях dbt: unique_key указывает, какую строку следует обновлять. Без такого ключа многие адаптеры используют добавление строк, а не замену. Но даже правильно выбранный ключ не доказывает, что источник передал полный набор данных.

Для пересчёта по датам отдельно зададим полуоткрытый диапазон [2026-10-01, 2026-10-02): начало включено, конец исключён. При работе с временными метками также потребуется единый часовой пояс и правило, по какому времени заказ относится к периоду.

Как сбой после COMMIT приводит к дубликатам

В небезопасном загрузчике операция выглядит так:

прочитать два заказа
    → вставить их в таблицу
    → выполнить COMMIT
    → не получить подтверждение успеха
    → повторить вставку тех же заказов

Первая попытка в нашем сценарии намеренно вызывает ошибку уже после успешного COMMIT. Запись сохранилась, но вызывающая сторона считает попытку неудачной. Повторная попытка добавляет те же заказы в таблицу, где order_id не объявлен уникальным.

Результат двух попыток:

unsafe_first: rows=2, unique=2, revenue_cents=3000
unsafe_retry: rows=4, unique=2, revenue_cents=6000

Здесь rows — число строк (COUNT(*)), unique — число разных order_id (COUNT(DISTINCT order_id)), revenue_cents — их сумма. Заказов по-прежнему два, но каждый записан дважды. В итоге выручка увеличилась с 30 до 60 рублей без нового заказа.

Для сравнения мы прервали другую загрузку до COMMIT и явно выполнили ROLLBACK:

before_commit_rollback: rows=0, unique=0, revenue_cents=0

Ни одна строка не осталась в таблице. В документации транзакций SQLite описаны границы BEGIN, COMMIT и ROLLBACK. Именно положение сбоя относительно фиксации изменений определяет, с каким состоянием встретится повторная попытка. В примере это управляемый сбой внутри программы, а не имитация аварии самой СУБД.

Как повторить загрузку без удвоения суммы

Сначала запретим хранить несколько строк с одним order_id, затем научим загрузчик обновлять существующий заказ. В SQLite для этого подходит INSERT ... ON CONFLICT DO UPDATE:

CREATE TABLE orders (
    order_id TEXT PRIMARY KEY,
    order_date TEXT NOT NULL,
    amount_cents INTEGER NOT NULL,
    source_version INTEGER NOT NULL
);

INSERT INTO orders (order_id, order_date, amount_cents, source_version)
VALUES ('O-102', '2026-10-01', 2500, 2)
ON CONFLICT(order_id) DO UPDATE SET
    order_date = excluded.order_date,
    amount_cents = excluded.amount_cents,
    source_version = excluded.source_version
WHERE excluded.source_version >= orders.source_version;

Если order_id уже существует, SQLite UPSERT выполняет обновление. excluded.amount_cents — сумма из входящей строки. Ею заменяют старую сумму, а не прибавляют её к прежнему значению. Дополнительное условие не позволяет версии с меньшим source_version перезаписать более новую.

В эксперименте сначала записали исходные два заказа, затем повторили загрузку без изменений, исправили сумму O-102 с 2 000 до 2 500 копеек и снова передали старые данные:

safe_first: rows=2, unique=2, revenue_cents=3000
safe_retry: rows=2, unique=2, revenue_cents=3000
safe_correction: rows=2, unique=2, revenue_cents=3500
stale_replay: rows=2, unique=2, revenue_cents=3500

После повторной подачи старой версии выручка осталась 3 500 копеек. Проверка опирается на конкретное правило сравнения версий. Если при равной версии источник присылает разные значения, условие >= их не различит; для такой ситуации понадобится дополнительная политика разрешения конфликтов.

Есть и другая граница: UPSERT не удаляет заказ, который исчез из полного снимка источника. Для восстановления полного состояния выбранного периода потребуется отдельная операция.

Как пересчитать один день и сохранить соседний

Предположим, источник исправил данные за 1 октября: O-101 по-прежнему стоит 1 000 копеек, а O-102 — уже 2 500. За 2 октября в таблице есть другой заказ, O-201, на 700 копеек. Задача — получить правильное состояние первого дня, не изменив второй.

В примере применяется замена диапазона в одной транзакции. Программа проверяет, что входной набор содержит ожидаемые ключи и все записи относятся к [2026-10-01, 2026-10-02), а затем удаляет старые строки за этот день и вставляет новые. При ошибке изменения откатываются.

Результат:

backfill_day: rows=2, unique=2, revenue_cents=3500
outside_window: rows=1, unique=1, revenue_cents=700
incomplete_snapshot: refused

Сумма за 1 октября стала 35 рублей, данные за 2 октября остались прежними. При неполном снимке программа отказала в пересчёте: incomplete_snapshot: refused. В учебном сценарии полный список ожидаемых order_id известен заранее. В рабочем процессе его нужно получать из независимого надёжного источника либо подтверждать полноту другим способом. Без такой проверки пустой или неполный снимок можно ошибочно принять за основание для удаления данных.

Если backfill запускается через Airflow, необходимо различать дату запуска и диапазон обрабатываемых данных. Документация Dag Runs связывает logical_date с интервалом данных, но точная интерпретация зависит от расписания и способа запуска. Исторический пересчёт не должен случайно сдвигать текущий указатель инкрементальной загрузки.

Проверка проведена с одним писателем в SQLite. Она не доказывает безопасность параллельных загрузок, изменений ключей или доступа внешних потребителей во время пересчёта.

Как проверить результат и повторить опыт

После восстановления сравните состояние данных с исходным набором. Для нашего случая нужны число строк, количество разных order_id, дневная сумма и неизменность соседнего дня. Если источник предоставляет полный снимок, полезно также сверить сами идентификаторы и значения, а не ограничиваться итоговой суммой: две ошибки могут взаимно компенсироваться.

В учебном проекте XNova используются Python и встроенный SQLite. Данные синтетические, базы создаются в памяти, сеть и внешние сервисы не нужны. Чтобы воспроизвести результаты из статьи, закрепите проверенный коммит:

git clone https://github.com/xnova-tech/retry-rerun-backfill-lab
cd retry-rerun-backfill-lab
git checkout 13fd17e5eba4c8ba6f330b1098be5d6dc5385536
python -I examples/replay_scenario.py
python -I examples/replay_scenario.py --self-test

Первая команда с replay_scenario.py выводит результаты небезопасной и исправленной загрузки, транзакционного отката и пересчёта. Вторая проверяет ожидаемые значения и отрицательные случаи. Команда python -I examples/replay_scenario.py --self-test вернула self_tests: passed.

self_tests: passed

Если повтор в вашей системе уже исказил данные, сначала установите, какие изменения успели зафиксироваться. Затем ограничьте затронутые заказы или даты, получите достоверный полный вход для восстановления и повторите обработку с подходящим способом записи. После этого сравните результат с источником, а также проверьте соседние периоды. Удалять всю историческую таблицу для исправления одного дня обычно нет необходимости.

Проект отдельно проверяет отказ при устаревших данных, неполном снимке, неверных границах и конфликте ключей между днями. Airflow и dbt в опыте не запускались; также не проверялись параллельные записи, перенос заказа между датами и поведение внешних витрин. Приведённые суммы относятся только к синтетическому набору заказов.

Какие условия нужны для безопасного повтора

Повторная попытка может начаться после того, как данные уже записаны. В нашем примере это привело к четырём строкам вместо двух и выручке 60 рублей вместо 30. Когда запись стала зависеть от ключа заказа, повтор исходного набора больше не менял сумму; исправленная версия заказа заменила старую.

Для своего загрузчика зафиксируйте четыре условия: ключ бизнес-записи, правило обновления и удаления, границу транзакции и полноту диапазона, который пересчитывается. При проверке сопоставляйте строки и метрики с источником, а не ограничивайтесь сообщением об успешном запуске.

Эти условия дают основу для безопасного retry, rerun и backfill. Оставшиеся случаи — конкурирующие запуски, запоздалые изменения и читатели витрин — требуют дополнительных проверок в конкретной системе.

Источники