Ночная смена: девять утра

09:00. Витрина за вчера пустая, за позавчера удвоена

18 мин
Чему научишься
  • читать витрину как показания приборов: где день пуст, а где посчитан дважды
  • искать причину в журнале прогонов, а не в переписке
  • различать «загрузка упала» и «загрузка отработала дважды» — эти аварии лечат по-разному
  • строить загрузку дня так, чтобы её можно было запускать сколько угодно раз: очистить диапазон даты и записать заново

Тикет №01. «Отчёт врёт»

09:00, 15 марта 2184 года. Ты заступаешь на приёмный уровень станции «Хранилище-9» — тусклый грузовой ярус, куда лента приёмки всю ночь втаскивает архивные снимки и раскладывает их по хранилищу. Утром её работу читает Академия.

В очереди один тикет, открытый в 08:41 смотрителем архива:

«Суточный отчёт врёт. За вчера пусто — как будто лента стояла. За позавчера выручка вдвое больше, чем в самих снимках. Что из этого правда?»

Ответ неприятный: правды в отчёте нет вообще. Вчера лента шла, а позавчера в снимках ровно половина того, что показывает витрина. Обе строки тикета — про один и тот же участок, но про две разные ночи.

Ленту вёл предшественник. С девяти утра она твоя.

КВЕРИ: Доброе утро, архивист. Предшественник оставил тебе журнал прогонов и две недописанные проверки. Я всю ночь дремал в фоновом процессе — и всё видел. Пустая ночь и удвоенная ночь — разные аварии; начни с того, чтобы их различить.

Витрина dm_daily_revenue — конец ленты, последняя полка: одна строка на день и город. Перед ней стоят отдельные слои загрузки. Их устройство разберём в следующем уроке; пока достаточно знать, что витрина хранит итог.

Сегодня задача проще: прочитать показания и назвать аварию своим именем.

Архивист у голопанели суточного отчёта: одна клетка дня пустая и подсвечена тревожным, соседняя сдвоена; за спиной стоит лента с неснятыми ящиками.
Одна зелёная строка в журнале не отменяет соседние сигналы: один день так и не загрузился, другой записали дважды.
Данные витрины начиная с 12 марта. «Сегодня» в этом курсе — 2184-03-15, поэтому вчера — это 14-е, а позавчера — 13-е. Отсутствие 14-го проявится как отсутствие строки. Смотри на две вещи: сколько строк приходится на день и сколько разных прогонов их записали.

Две разные аварии в одной таблице

Показания сняты — теперь назовём то, что видно.

Данных за вчера в витрине нет — не «нули», а именно отсутствие строк. Данные за позавчера записаны дважды: строк вдвое больше нормы, и записаны они двумя разными run_id.

Со стороны это выглядит как одна поломка отчёта. На деле поломки две, и лечат их по-разному:

СимптомЧто произошлоЧем лечится
дня нетшаг не доехал до концапроверить попытку; после остановки — перезапустить дату
день дваждышаг доехал ДВАЖДЫ и оба раза дописалперезапуск не поможет — нужна идемпотентность

Разница принципиальная. В первом случае повтор — лекарство, во втором — добавка к болезни: он положит в витрину третий комплект строк.

Второй случай требует идемпотентности: свойства шага давать один и тот же результат, сколько бы раз его ни запустили.

Пока запомни симптом: два run_id на один день — это признак повторной загрузки, которая дописала, вместо того чтобы заменить.

Но симптом — ещё не история. Что происходило ночью, показывает журнал прогонов etl_runs. Его ведут не для отчётности: только там остаётся след каждой попытки, в том числе запущенной автоматически.

КВЕРИ: Разворачиваю журнал за обе ночи. Люди помнят, что они запускали. Журнал помнит, что запустилось.

Журнал прогонов задачи load_mart — той, что кладёт день в витрину. Одна строка здесь — одна ПОПЫТКА за одну логическую дату. Сумм и группировок нарочно нет: улика видна только в отдельных попытках. Читай построчно: состояние, rows_out, время начала и конца.

Что случилось ночью

Журнал показывает всю историю — по попыткам, а не по итогу.

13 марта — три попытки одной задачи вместо одной.

  • 990001, 02:31–02:33, up_for_retry, rows_out = 0 — попытка не доехала и ничего не записала;
  • 990002, 02:41–02:44, success, rows_out = 22 — повтор посчитал день и положил его в витрину: двадцать два города, двадцать две строки;
  • 8812, 03:24–03:28, failed, rows_out = 1552 — ещё одна попытка того же дня, которая упала, но уже успела дойти до записи.

Вот и объяснение сорока четырём строкам: день положили в витрину больше одного раза, потому что загрузчик при повторе дописывал к тому, что там уже лежало.

Никто не заметил по простой причине: успешная попытка в списке есть, а зелёная попытка выглядит как «всё хорошо». Авария спряталась не за отсутствием сигнала, а за сигналом об успехе рядом с чужой записью.

Про rows_out у неуспешных попыток. 1552 — это не «столько строк в витрине». Счётчик пишется по ходу работы, и у попытки, которая не дошла до конца, он подтверждает ровно одно: она успела дойти до записи.

Сколько именно строк дошло до витрины и дошли ли они вообще — по нему не определить. Поэтому смотрят сначала на состояние, и только потом на числа.

14 марта. Та же задача до сих пор в состоянии running, finished_at пуст, и rows_out у неё тоже не ноль — но это не доказывает, что строки уже попали в витрину: попытка не завершилась, а дня там нет.

Значит, задача не упала, а зависла. Данных за вчера нет, потому что загрузка их ещё не привезла; что с ними на предыдущих слоях, витрина не говорит.

Чего журнал НЕ говорит: какая именно попытка положила какие именно строки. У витрины свой run_id загрузки, у журнала — свой номер попытки, и связь между ними появится в главе de2.

Но на главный вопрос смены он ответил: попыток было несколько, и одна из них упала, дойдя до записи.

Отсюда правило смены, к которому курс будет возвращаться десять глав подряд:

Сначала спроси у журнала прогонов, что происходило, и только потом — у данных, что в них лежит. Данные показывают следствие, журнал показывает причину.

Три попытки одной логической даты: одна не записала ничего, вторая положила 22 строки, третья упала, дойдя до записи, — а два идентификатора прогона в витрине показывают два комплекта одного дня.

Голый INSERT — это не загрузка

Причина задвоения — в одном глаголе. Загрузчик витрины дописывал: считал день и добавлял результат к тому, что уже лежит в таблице.

Дописывать правильно только тогда, когда событие происходит один раз. Загрузка за дату — не такое событие: она случается столько раз, сколько её запустят, а запустят её обязательно больше одного раза — ретраем ночью или руками утром.

Нужное свойство шага называется идемпотентность: сколько бы раз его ни выполнили с одними и теми же входами, состояние получается одно и то же. Не «шаг не упадёт при повторе», а именно «после второго прогона в таблице ровно то же, что после первого».

Чтобы этого добиться, загрузчик меняет единицу записи. Он работает не с отдельными строками, а с диапазоном одной даты — куском витрины, который целиком относится только к этому дню. Такой кусок будем называть диапазоном дня.

Тогда шаг из «посчитать и дописать» превращается в пару:

  1. DELETE всего, что относится к этой дате;
  2. INSERT … SELECT того, что посчитали сейчас.

Смысл пары виден на повторах. Первый запуск удаляет ноль строк и кладёт двадцать две. Второй удаляет двадцать две и кладёт двадцать две. Десятый — то же самое.

Соседние даты при этом не страдают: удаляется СВОЙ диапазон, а не таблица целиком.

Одна оговорка про : без между DELETE и INSERT витрина пуста за этот день, и отчёт, попавший в эту щель, покажет ноль. В транзакционной базе обе операции поэтому обычно объединяют. В песочнице курса управлять транзакцией нельзя — BEGIN не пропустит валидатор, — поэтому ниже пара идёт просто двумя операторами подряд.

Соберём такой загрузчик и запустим его дважды подряд, ровно как это сделал ночной ретрай.

Загрузчик за 13 марта: снять день, положить день. Пара идёт ДВАЖДЫ подряд — второй раз тем же кодом, как при ночном ретрае. Два CTE, LEFT JOIN и COALESCE только рассчитывают показатели дня; сейчас их разбирать не нужно. Следи за DELETE перед каждым INSERT и за итогом. В конце — тот же запрос, что был в первой ячейке урока: тогда было 44 строки и два run_id, а выручка вдвое выше, чем в самих снимках.

Что изменилось. Было: 44 строки за 13 марта, два run_id, выручка 2 931 242.80 — вдвое больше, чем в снимках. Стало после ДВУХ прогонов подряд: 22 строки, один run_id, выручка 1 465 621.40 — ровно то же, что после одного прогона.

Это и есть идемпотентность в готовом виде: второй запуск ничего не добавил к первому.

Пара DELETE диапазона + INSERT … SELECT — прямой способ сделать загрузку идемпотентной. Есть и другие способы (INSERT OVERWRITE, MERGE по ключу); их механика здесь не важна. Важна единица перезаписи: логическая дата прогона, а не строка и не таблица целиком.

Почему не таблица целиком — увидишь в de1l5, где ровно это решение сломает соседний день.

Вопрос с собеседования

Как это спрашивают на собеседовании. «Ночная загрузка упала и была перезапущена. Какие два проблемных состояния данных можно увидеть утром и как отличить одно от другого?»

Ответ, который ждут: либо день не доехал (шаг не дошёл до конца — после проверки текущей попытки нужен перезапуск), либо день посчитан дважды (шаг дописал вместо замены — перезапуск сделает хуже).

Отличают их по витрине и журналу прогонов вместе: есть ли строки за дату, сколько прогонов их записали, сколько было попыток и чем они закончились.

Следующий вопрос почти всегда — «как сделать шаг идемпотентным». Здесь ждут: перезаписывать диапазон логической даты (DELETE + INSERT … SELECT, INSERT OVERWRITE или MERGE по ключу), а не дописывать.

Первый из трёх вариантов ты только что увидел в работе; доказывать его тестом будешь в de1l5.

Проверь себя
За 13 марта в витрине два run_id и вдвое больше строк, чем в соседние дни. Как исправить саму витрину и устранить причину удвоения?
Проверь себя
За 14 марта в витрине нет ни одной строки. Что это значит по журналу прогонов?
Закрепление: реши задачи
Решено 0 из 3 · для зачёта достаточно 2
Главное из урока
  • Витрина dm_daily_revenue: 14 марта отсутствует, 13 марта задвоен двумя run_id.
  • Причину показал etl_runs: 13-го у задачи load_mart три попытки вместо одной — одна не доехала, одна записала день, одна упала, дойдя до записи; 14-го она до сих пор висит в running с пустым finished_at.
  • rows_out у незавершённой попытки доказывает лишь, что она дошла до записи: какие строки попали в витрину, по счётчику не определить. Сначала состояние, потом числа.
  • Пустой день после проверки и остановки зависшей попытки исправляют перезапуском. Удвоенный день перезапуском НЕ исправить — нужен шаг, который при повторе заменяет, а не дописывает.
  • Лекарство показано и проверено на живых данных: DELETE диапазона даты + INSERT … SELECT. Два прогона подряд дали ровно один день: 22 строки, один run_id, выручка вдвое меньше прежней — то есть настоящая.

Дальше по ленте: где физически лежат данные между источником и витриной — и почему полок именно столько.