Логический порядок: FROM и JOIN → WHERE → GROUP BY → HAVING → оконные функции → SELECT → DISTINCT → UNION → ORDER BY → LIMIT. HAVING работает уже по группам, поэтому в нём доступны агрегаты, а в WHERE нет. CTE из WITH логически вычисляется до FROM основного запроса (планировщик может встроить его в запрос), DISTINCT применяется к результату SELECT, то есть после оконных функций.
Из этого следуют частые ошибки: алиас из SELECT недоступен в WHERE и GROUP BY, результат оконной функции нельзя отфильтровать в том же запросе, а ORDER BY по алиасу работает, потому что он идёт после SELECT. Это логический порядок: физически оптимизатор переставляет шаги, но результат обязан совпасть.
WHERE фильтрует строки до группировки, HAVING фильтрует группы после неё и может использовать агрегаты. Условие на обычную колонку в HAVING формально допустимо, если колонка входит в GROUP BY, но это заставляет базу группировать лишние строки: год заказа ставится в WHERE, а порог «больше 10 заказов» в HAVING.
Нельзя: окна считаются после WHERE и HAVING. Оконное выражение выносится в подзапрос или CTE, а фильтр WHERE rn = 1 пишется уровнем выше. По той же причине оконную функцию нельзя вложить в другую и нельзя использовать в GROUP BY.
Для INNER JOIN разницы нет: строка либо соединилась и прошла фильтр, либо нет, планировщик даже строит одинаковый план. Для LEFT JOIN разница принципиальная. Условие на правую таблицу в ON применяется при соединении: строки левой таблицы без пары остаются с NULL справа. То же условие в WHERE применяется после соединения и отбрасывает строки, где правая часть NULL, превращая LEFT JOIN во внутренний. Условие на левую таблицу (t2.age > 10, где t2 слева) в ON и в WHERE даёт одинаковый набор строк, потому что левые строки LEFT JOIN не отбрасывает, но в ON оно повлияет лишь на то, найдётся ли пара, а в WHERE уберёт строку целиком.
Сравнение с NULL даёт NULL, а WHERE оставляет только строки, где условие истинно. Чтобы NULL считался обычным значением, нужен IS NULL в условии или IS DISTINCT FROM вместо <>.
Один NULL в подзапросе делает результат пустым: x <> NULL неизвестно для любого x, и ни одна строка не проходит. Безопасная замена: NOT EXISTS с коррелированным подзапросом. Обычный IN при NULL в списке работает ожидаемо: истина, если значение нашлось.
count(*) считает строки, count(col) считает строки, где колонка не NULL, count(DISTINCT col) считает уникальные не-NULL значения. Отсюда типичная ошибка: count(o.order_id) после LEFT JOIN даёт ноль для клиентов без заказов, а count(*) даст единицу.
NULL ни с чем не соединяется, даже с другим NULL: NULL = NULL даёт NULL, а не TRUE. При INNER JOIN строки с NULL в ключе выпадают с обеих сторон, при LEFT JOIN левые строки с NULL остаются с NULL справа. Дубли ключа перемножаются: если 2 встречается два раза слева и два раза справа, в результате будет четыре строки с двойкой. Для примера t1.num1 = (1,2,2,3,3,4,NULL,NULL) и t2.num2 = (1,2,2,3,5,NULL,NULL): 1 даст одну строку, 2 четыре строки, 3 две строки, 4 одну строку с NULL справа, два NULL слева по одной строке с NULL справа. Итого 10 строк. Если NULL нужно считать равными, пишут ON t1.k = t2.k OR (t1.k IS NULL AND t2.k IS NULL) или IS NOT DISTINCT FROM.
Деление целых даёт целое с отбрасыванием дробной части. Один из операндов нужно сделать дробным до деления: 100.0 * a / b или a::numeric / b. Самое частое место ошибки это доля в процентах, которая «вдруг» стала нулём.
float хранит двоичное приближение и накапливает погрешность: 0.1 + 0.2 не равно 0.3, а сумма миллиона платежей расходится с бухгалтерией на копейки. numeric считает точно в десятичной системе, за это платят скоростью, и для денег это правильная цена.
В PostgreSQL и Greenplum: ts::date или date_trunc('day', ts) для даты, ts::time для времени, extract(month from ts) или date_part('year', ts) для частей, date_trunc('month', ts) для первого числа месяца, to_char(ts, 'YYYY-MM') для строки с месяцем. Для фильтра по периоду лучше не извлекать части, а сравнивать с границами полуинтервалом: условие по самой колонке использует индекс и партиции, а extract(...) = 2025 нет.
now() и current_timestamp возвращают время начала текущей транзакции, одно и то же в пределах всей транзакции; clock_timestamp() даёт реальное время в момент вызова и меняется от строки к строке; current_date только дату. Для загрузок в хранилище удобно now(): все строки одной вставки получают одинаковую метку.
Если в поле только цифры, достаточно привести к числу: col::bigint или col::bigint::text, если результат нужен строкой. Если могут быть буквы, приведение упадёт, и нужна строковая функция: ltrim(col, '0') убирает все ведущие нули, в том числе у 0007A → 7A. Граничный случай: строка из одних нулей после ltrim станет пустой, и если это нежелательно, добавляют coalesce(nullif(ltrim(col, '0'), ''), '0'). Универсальный вариант через регулярное выражение: regexp_replace(col, '^0+', '').
Нормализовать обе стороны: lower(trim(col)), при необходимости translate или regexp_replace для лишних символов. Если такое сравнение нужно в фильтре регулярно, создают индекс по выражению lower(col) или используют тип citext. Регистр в статусах («Paid» и «paid») это типичный дефект источника, и нормализовать его нужно один раз на входе в хранилище, а не в каждом отчёте.
Через остаток от деления: WHERE id % 2 = 0 для чётных и = 1 для нечётных; в Oracle и некоторых диалектах MOD(id, 2). Ловушка с отрицательными числами: в PostgreSQL -3 % 2 даёт −1, поэтому для нечётных надёжнее id % 2 <> 0. Если id строковый, сначала привести к числу. В Python аналогично % для остатка и // для целой части деления.
UNION объединяет результаты двух запросов и убирает дубли, UNION ALL оставляет всё. Число и порядок колонок должны совпадать, имена берутся из первого запроса. Для T1 из трёх одинаковых строк (13, 15) и T2 из (13, 15) и (13, 14) UNION вернёт две строки: (13, 14) и (13, 15), UNION ALL вернёт пять. Если типы колонок различаются, база пытается привести их к общему типу (int и numeric сойдутся в numeric, int и text дадут ошибку); явное приведение ::text снимает вопрос.
UNION убирает полные дубли строк, для этого сортирует или хеширует результат, и это дорого на больших объёмах. UNION ALL просто склеивает результаты. В загрузках почти всегда нужен UNION ALL: дубли там либо невозможны, либо их нужно считать, а не молча терять. UNION уместен, когда дубли между частями ожидаемы и не нужны.
INNER JOIN: от 0 (нет совпадений) до 12 (все ключи одинаковые, каждая строка с каждой). LEFT JOIN: от 3 (все левые без пары или по одной паре на строку) до 12. FULL JOIN: минимум 4 (три левые строки нашли по паре, одна правая осталась с NULL), максимум 12; при полном отсутствии совпадений будет 7 (3 + 4 строк с NULL), это не минимум, а другой крайний случай. CROSS JOIN: всегда 12. Смысл вопроса в том, чтобы вы показали понимание: соединение по неуникальному ключу умножает строки, а внешние соединения не теряют строки своей стороны.
SELECT value FROM t GROUP BY value HAVING count(*) > 1. Если нужны сами строки-дубли, а не значения, то оконная функция count(*) OVER (PARTITION BY value) в подзапросе и фильтр > 1 снаружи: она не сворачивает строки.
Полное сравнение с учётом дублей: объединить обе таблицы через UNION ALL с меткой источника, сгруппировать по всем колонкам и сравнить число строк из каждой таблицы: SELECT a, b, c, count(*) FILTER (WHERE src = 1) AS n1, count(*) FILTER (WHERE src = 2) AS n2 ... GROUP BY a, b, c HAVING count(*) FILTER (WHERE src = 1) <> count(*) FILTER (WHERE src = 2). Пустой результат означает эквивалентность. Другой способ: FULL JOIN по всем колонкам с IS NOT DISTINCT FROM (чтобы NULL совпадали) и фильтр на отсутствующую сторону, плюс проверка count(*) с каждой стороны для дублей. Сравнение агрегатов (count, sum) это быстрая предварительная проверка, но она не ловит перестановки значений между строками.
Пронумеровать чётные и нечётные отдельно оконной функцией row_number() OVER (ORDER BY id) в каждом подмножестве, затем соединить по номеру через FULL JOIN: число строк равно большему из двух наборов, а недостающие значения станут NULL. Общий случай обязателен: нельзя полагаться на то, что чётных и нечётных поровну или что числа идут подряд.
SELECT max(num) FROM (SELECT num FROM my_numbers GROUP BY num HAVING count(*) = 1) t. Агрегат max по пустому набору вернёт одну строку с NULL сам по себе, отдельная обработка не нужна; если написать без внешнего агрегата с ORDER BY ... LIMIT 1, пустой результат даст ноль строк вместо NULL.
Справочник категорий задаётся прямо в запросе через VALUES или UNION ALL, а счета присоединяются LEFT JOIN по условию диапазона: LEFT JOIN accounts a ON a.income BETWEEN c.lo AND c.hi (или через CASE внутри агрегата count(*) FILTER (WHERE ...) для каждой категории в одной строке с последующим UNPIVOT). Главная мысль: если нужны строки, которых нет в данных, их нужно породить из справочника, а не надеяться на GROUP BY.
Для таблицы из восьми строк, где в колонке flag два NULL: COUNT(*), COUNT(1), COUNT(0) и COUNT('flag') вернут 8, потому что константа не NULL и считается для каждой строки; COUNT(flag) вернёт 6, считаются только не-NULL значения; COUNT(NULL) всегда 0. SUM(flag) сложит значения без NULL. SUM(1) это не 1, а число строк: выражение вычисляется для каждой строки и суммируется, поэтому SUM(2) даст 16, SUM(4) даст 32, то есть константа умножается на число строк выборки. SUM(*) ошибка синтаксиса: звёздочка означает все колонки, а складывать строку с датой невозможно, SUM требует конкретное выражение.
С подзапросом: отобрать имена с HAVING COUNT(*) > 10 по оценке 5, затем посчитать двойки для них. Без подзапроса всё делается одной группировкой через условную агрегацию: SELECT name, SUM(CASE WHEN mark = 2 THEN 1 ELSE 0 END) AS twos FROM school GROUP BY name HAVING SUM(CASE WHEN mark = 5 THEN 1 ELSE 0 END) > 10. В PostgreSQL короче COUNT(*) FILTER (WHERE mark = 2). Приём «агрегат с условием» закрывает большинство задач вида «посчитать одно для тех, у кого другое».
Запятая между таблицами это декартово произведение, то же, что CROSS JOIN: каждая строка t1 сочетается с каждой строкой t2, а затем WHERE оставляет пары, где условие истинно. Для t1.a = (1, 1, 2, 3, 3) и t2.b = (1, 3, 2, 4, 5) считаем по строкам t1: для 1 подходят 3, 2, 4, 5 (четыре строки, и так дважды), для 2 подходят 3, 4, 5 (три), для 3 подходят 4, 5 (две, дважды): 4 + 4 + 3 + 2 + 2 = 15. Такой запрос это JOIN с неравенством, он не использует хеш-соединение и на больших таблицах превращается в вложенные циклы.
INNER оставляет только пары. LEFT сохраняет все строки слева, справа подставляя NULL. FULL сохраняет обе стороны, полезен для сверок двух источников. CROSS даёт все пары и нужен для календарей и сеток. Semi join через EXISTS проверяет наличие без размножения строк, anti join через NOT EXISTS ищет то, чего нет во второй таблице. LATERAL позволяет подзапросу ссылаться на строку слева: «последние три заказа каждого клиента».
Выбор строк, для которых нет пары во второй таблице. Три записи: NOT EXISTS (SELECT 1 FROM t2 WHERE t2.k = t1.k), LEFT JOIN t2 ... WHERE t2.k IS NULL, NOT IN (SELECT k FROM t2). Первая самая безопасная: NOT IN ломается на NULL в подзапросе, а LEFT JOIN с проверкой на NULL размножит строки при дублях ключа справа. Планировщик PostgreSQL для NOT EXISTS строит Hash Anti Join, это дёшево.
Ключ справа неуникален, и строки слева размножились: у заказа три платежа, и его сумма посчиталась трижды. Лечится агрегацией второй таблицы до нужного зерна перед соединением или заменой JOIN на EXISTS, если из второй таблицы нужен только фильтр. Проверка: число строк до и после соединения и count(DISTINCT ключ) справа.
Фильтр по правой таблице в WHERE отбрасывает строки с NULL и превращает LEFT JOIN во внутренний. Условия на правую таблицу пишутся в ON, условия на левую остаются в WHERE.
Условие в ON помимо ключа: o.created_at >= h.valid_from AND o.created_at < h.valid_to, полуинтервалом, иначе граница попадёт в два периода. Если заказ не попал ни в один период, он пропадёт из INNER JOIN, и это нужно решить явно: LEFT JOIN с проверкой или отдельная строка «нет истории».
Четыре способа: NOT EXISTS с коррелированным подзапросом; LEFT JOIN credits ... WHERE credits.client_id IS NULL; NOT IN (SELECT client_id FROM credits WHERE client_id IS NOT NULL); EXCEPT между множеством клиентов и множеством клиентов с кредитами. На собеседовании ждут, что вы назовёте ловушки: NOT IN с NULL в подзапросе вернёт пустоту, LEFT JOIN при нескольких кредитах у клиента размножит строки до проверки на NULL, EXCEPT теряет дубли и требует одинакового набора колонок.
Условная агрегация: SELECT id, max(value) FILTER (WHERE key = 'fio') AS fio, max(value) FILTER (WHERE key = 'city') AS city FROM source_data GROUP BY id (в базах без FILTER: max(CASE WHEN key = 'fio' THEN value END)). Набор колонок фиксирован в запросе; если ключи заранее неизвестны, нужен динамический SQL или crosstab из расширения tablefunc. Обратная операция, unpivot, делается через UNION ALL или LATERAL (VALUES ...).
FULL JOIN это совпавшие пары плюс несовпавшие строки слева плюс несовпавшие справа. LEFT минус INNER даёт строки левой таблицы без пары: 10 − 6 = 4. RIGHT минус INNER даёт строки правой без пары: 12 − 6 = 6. Итого 6 + 4 + 6 = 16. Та же логика работает в обратную сторону: по числу строк FULL и INNER можно посчитать, сколько строк не нашли пару с каждой стороны, это удобный приём для сверок.
Nested Loop для маленького внешнего набора с индексом внутри: для каждой внешней строки поиск по индексу. Hash Join для большой таблицы против меньшей по равенству: по меньшей строится хеш-таблица в памяти, большая читается один раз. Merge Join для двух потоков, отсортированных по ключу (индексы или явная сортировка), и для неравенств по диапазону. Выбор делает планировщик по статистике; принудить можно настройками enable_hashjoin, enable_nestloop, но в проде это крайняя мера, лучше обновить статистику или добавить индекс.
Прямое сравнение каждой строки с каждой это O(n × m). Хеширование меньшей таблицы стоит O(m), а поиск по хешу для каждой строки большой таблицы O(1), итого O(n + m). Цена: хеш-таблица должна помещаться в память (work_mem), иначе соединение идёт батчами с записью на диск, и в плане видно Batches: 2+.
В распределённых системах (Spark, Greenplum) соединение требует, чтобы строки с одинаковым ключом оказались на одном узле. Маленькую таблицу можно разослать целиком на каждый узел (broadcast) и соединить локально, без перераспределения большой таблицы по сети. Если забродкастить большую таблицу, каждый узел получит её копию целиком: память исполнителей переполнится, задача упадёт по OOM или уйдёт в спилл на диск; поэтому порог broadcast ограничен (в Spark по умолчанию 10 МБ, spark.sql.autoBroadcastJoinThreshold).
Способы доставить строки с одинаковым ключом соединения на один узел: разослать маленькую таблицу целиком на все узлы (broadcast motion) или перераспределить большую по ключу через сеть (redistribute motion). Именно перемещение данных обычно стоит дороже самого соединения, поэтому в Greenplum и ClickHouse ключ распределения выбирают под самые частые соединения, а в плане Greenplum ищут узлы Motion.
Уменьшить объём до соединения: отфильтровать и агрегировать каждую сторону до нужного зерна, оставить только нужные колонки. Соединять по ключу, по которому таблицы распределены или отсортированы, чтобы избежать перераспределения. Убедиться, что статистика свежая и планировщик выбрал hash или merge, а не nested loop по большой таблице. В MPP и Spark бродкастить маленькую сторону, бороться с перекосом ключа. Самое дорогое оптимизировать после: DISTINCT поверх размноженного результата не лечит, а маскирует проблему.
Три семейства: ранжирующие (row_number, rank, dense_rank, ntile), смещения (lag, lead, first_value, last_value, nth_value) и агрегаты как окна (sum, avg, count, min, max OVER). В OVER три части: PARTITION BY делит строки на группы, ORDER BY задаёт порядок внутри группы, а рамка ROWS / RANGE / GROUPS BETWEEN ... ограничивает, какие строки группы участвуют в вычислении для текущей строки.
Без ORDER BY рамка это вся партиция: sum(amount) OVER (PARTITION BY client) даёт итог по клиенту в каждой строке. С ORDER BY рамка по умолчанию становится RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: та же сумма превращается в накопительный итог. Отсюда «странности»: last_value с ORDER BY возвращает текущую строку, а count(*) OVER (ORDER BY d) считает нарастающим итогом.
При ничьей row_number даёт разные номера, rank одинаковый с пропуском следующего (1, 2, 2, 4), dense_rank одинаковый без пропуска (1, 2, 2, 3). Для «ровно одна строка на группу» нужен row_number с однозначной сортировкой. Для «топ-3 с ничьими» rank или dense_rank в зависимости от того, сколько строк допустимо вернуть.
Рамка окна по умолчанию заканчивается на текущей строке: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Нужно раскрыть её: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. first_value от этого не страдает, поэтому ошибка заметна не сразу.
Рамка задаёт, какие строки партиции попадают в вычисление для текущей: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW это скользящее окно из семи строк, ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING это «всё до текущей», RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW это окно по значению, а не по числу строк. ROWS считает строки, RANGE значения (с пропусками в датах они расходятся), GROUPS группы равных значений. Без рамки скользящие средние, «сумма до текущей» и «был ли сбой раньше в этот день» не выразить.
sum(x) OVER (PARTITION BY account ORDER BY d) даёт накопительный итог. avg(x) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) даёт среднее по семи строкам, RANGE interval '6 days' PRECEDING по семи календарным дням. Чем оконная функция отличается от агрегатной: агрегат сворачивает группу в одну строку, окно оставляет все строки и приписывает каждой результат по её окну.
Да: lead(x) OVER (ORDER BY d) равен lag(x) OVER (ORDER BY d DESC) с точностью до обработки ничьих в сортировке. На практике пишут ту, что читается естественнее: lag для «предыдущее значение», lead для «дата следующей версии минус день» при построении периодов.
Пропуски: generate_series по календарю и anti join к данным. Сессии: lag по времени внутри пользователя, флаг «новая сессия», если разрыв больше порога, и накопительная sum флага даёт номер сессии. Тот же приём «флаг + накопительная сумма» решает задачи про периоды ненулевого остатка, схлопывание пересекающихся интервалов и «не учитывать загрузки после первой ошибки за день».
lag(val) даёт только одну строку назад, а NULL может тянуться несколько строк подряд. Приём: count(val) OVER (PARTITION BY id ORDER BY ttime) увеличивается только на не-NULL строках и даёт номер группы, внутри которой первое значение и есть нужное; затем first_value(val) OVER (PARTITION BY id, grp ORDER BY ttime). Альтернатива: max(val) FILTER не подходит, но работает last_value(val) IGNORE NULLS там, где поддерживается (Oracle, Snowflake, не PostgreSQL).
Ранг строки равен числу строк со значением больше плюс один: SELECT a.id, a.points, (SELECT count(*) FROM t b WHERE b.points > a.points) + 1 AS rnk FROM t a, или то же через self-join с LEFT JOIN t b ON b.points > a.points ... GROUP BY a.id, a.points и count(b.id) + 1. Это даёт семантику rank (с пропусками при ничьих); для dense_rank считают count(DISTINCT b.points). Сложность O(n²), поэтому в проде так не пишут, но на собеседовании проверяют именно понимание, что такое ранг.
Классическая задача «острова»: у абонента есть сессии (start, end), и если end предыдущей равен start следующей, это одна непрерывная сессия. Сначала через LAG(end) OVER (PARTITION BY msisdn ORDER BY start) помечаем начало нового острова: CASE WHEN start = prev_end THEN 0 ELSE 1 END. Затем накопительной суммой этого флага SUM(flag) OVER (PARTITION BY msisdn ORDER BY start) получаем номер острова, и группируем по абоненту и номеру: MIN(start), MAX(end). Если допускаются пересечения, а не только стык, условие меняется на start <= MAX(end) OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING).
row_number() OVER (PARTITION BY ключ ORDER BY версия DESC) и фильтр = 1 снаружи, в PostgreSQL короче через DISTINCT ON (ключ) ... ORDER BY ключ, версия DESC. max() по каждой колонке не работает: агрегаты не помнят строку, и статус возьмётся от одной версии, а сумма от другой.
Проверка: GROUP BY все колонки HAVING count(*) > 1 или сравнение count(*) с count(DISTINCT ...). Способы убрать дубли: SELECT DISTINCT или GROUP BY при пересборке таблицы; row_number() OVER (PARTITION BY ключ ORDER BY ...) и оставить первую; в PostgreSQL DISTINCT ON; удаление через DELETE ... USING по ctid (физическому адресу строки), когда у строк нет уникального идентификатора; CREATE TABLE AS SELECT DISTINCT с последующим переименованием, что на больших таблицах быстрее DELETE. В Python то же самое: set для хешируемых записей, dict по ключу с сохранением последней версии, pandas.drop_duplicates(subset=..., keep='last').
Защитить целевую таблицу и перестроить путь данных вокруг неё. Загрузку направить в staging-таблицу без ограничений, затем перекладывать в целевую запросом с дедупликацией (INSERT ... SELECT DISTINCT ON или ON CONFLICT DO NOTHING), а staging очищать. На целевой таблице поставить UNIQUE или первичный ключ: поток, который попытается вставить дубль напрямую, упадёт с ошибкой нарушения ограничения, и это правильно, потому что ошибка видна, а молчаливый дубль нет; если падение недопустимо, вставка через ON CONFLICT DO NOTHING. Периодичность очистки, если частота вставок неизвестна: не по расписанию, а по событию (после каждой загрузки staging) либо раз в сутки перед построением отчётов с мониторингом числа удалённых дублей, чтобы увидеть, когда проблема ушла.
В PostgreSQL: DELETE FROM t a USING t b WHERE a.ctid < b.ctid AND a.key = b.key или через row_number в CTE и удаление строк с номером больше 1. В хранилищах чаще не удаляют, а пересобирают таблицу запросом с дедупликацией: дешевле и безопаснее для повторного запуска.
Рекурсивный CTE: якорная часть выбирает сотрудников без руководителя с уровнем 0, рекурсивная часть соединяет таблицу с предыдущим шагом по manager_id = parent.id и прибавляет единицу: WITH RECURSIVE h AS (SELECT id, name, 0 AS lvl FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, h.lvl + 1 FROM employees e JOIN h ON e.manager_id = h.id) SELECT * FROM h ORDER BY lvl, name. При цикле в данных рекурсия не остановится: защищаются накоплением пути в массив и условием NOT e.id = ANY(path) или ограничением глубины.
Self-join таблицы по связи «сотрудник → руководитель»: SELECT e.name FROM employees e JOIN employees m ON m.employee_id = e.id_master WHERE e.salary > m.salary. Классический вопрос на понимание, что одна таблица может играть две роли в запросе; алиасы обязательны.
Сгруппировать по manager_id, оставить группы с count(*) >= 5, затем соединить с той же таблицей, чтобы получить имена: SELECT e.name FROM employee e JOIN (SELECT managerId FROM employee GROUP BY managerId HAVING count(*) >= 5) m ON m.managerId = e.id. Прямые подчинённые это одна ступень иерархии; для всех подчинённых нужна рекурсия.
Таблица employee(id, department_id, chief_flg, birth_dt): руководитель это строка с chief_flg = true в том же подразделении. Self join по подразделению с условием на флаг и дату: SELECT e.id FROM employee e JOIN employee c ON c.department_id = e.department_id AND c.chief_flg AND e.birth_dt < c.birth_dt WHERE NOT e.chief_flg. Старше означает более раннюю дату рождения, на этом часто путаются. Если руководителей в подразделении может быть несколько, сотрудник попадёт в результат по каждому, тогда нужен DISTINCT или условие «старше всех» через агрегат по подразделению.
Фильтр по периоду действия версии: WHERE :d >= valid_from AND :d < valid_to, где закрытая дата последней версии хранится как 9999-12-31 или NULL (тогда coalesce(valid_to, 'infinity')). Полуинтервал [from, to) исключает попадание даты в две версии на границе. Текущее состояние: WHERE valid_to = '9999-12-31' или флаг is_current. При соединении фактов с такой таблицей условие по дате факта ставится в ON рядом с ключом, иначе каждая транзакция размножится на все версии клиента.
Выбрать транзакции за день полуинтервалом по дате, соединить с клиентами по client_id и условию, что дата транзакции попадает в период версии: JOIN clients c ON c.id = t.client_id AND t.date >= c.start_date AND t.date < c.end_date, затем GROUP BY c.id, c.name. Если нужен не «имя на момент транзакции», а текущее имя, соединяют с текущей версией. Интервьюер обычно уточняет именно это, поэтому стоит спросить до написания.
Поля: valid_from, valid_to, is_current (необязательно) и хеш отслеживаемых атрибутов для быстрого сравнения. Алгоритм на порцию из источника: 1) сравнить входящие строки с текущими версиями по бизнес-ключу; 2) новые ключи вставить с valid_from = дата загрузки и открытым valid_to; 3) у ключей с изменившимся хешем закрыть текущую версию (valid_to = дата загрузки) и вставить новую; 4) ключи без изменений не трогать; 5) если источник сообщает удаления, закрыть версию. В одной транзакции или идемпотентно через MERGE, чтобы повторный запуск за ту же дату не плодил версий.
DELETE удаляет построчно с журналированием, может иметь WHERE, срабатывает на триггеры, а в PostgreSQL оставляет мёртвые версии строк до VACUUM. TRUNCATE очищает таблицу целиком, мгновенно, сбрасывает файлы данных и может сбросить sequence. В PostgreSQL оба откатываются в транзакции, в некоторых базах TRUNCATE это DDL без отката. Для перезаписи партиции целиком TRUNCATE на порядки дешевле.
DDL описывает структуру (CREATE, ALTER, DROP), DML меняет данные (INSERT, UPDATE, DELETE, MERGE), DQL читает (SELECT), DCL управляет правами (GRANT, REVOKE), TCL управляет транзакциями (BEGIN, COMMIT, ROLLBACK). Инженеру данных важны все пять: загрузка это DML в транзакции, доступы аналитикам это DCL.
Обычное представление это сохранённый запрос: при каждом обращении выполняется заново, данные всегда свежие, стоимость запроса каждый раз. Материализованное хранит результат как таблицу: читается быстро, но данные устаревают до следующего REFRESH. Если данные часто меняются и нужна свежесть, обычное представление или сама таблица с индексами; материализованное для тяжёлых агрегатов, которые достаточно обновлять по расписанию. В ClickHouse материализованное представление работает иначе: это триггер на вставку, который сразу пишет преобразованные строки в целевую таблицу.
Ограничения: NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK, DEFAULT (строго говоря, DEFAULT не ограничение, но его обычно называют шестым). Ключи: первичный, уникальный (альтернативный), внешний, простой и составной, натуральный (бизнес-ключ) и суррогатный (сгенерированный), кандидатный. Внешний ключ не является идентификатором таблицы, в которой находится: он ссылается на другую таблицу. Подвиды суррогатных: автоинкремент (sequence, identity), UUID, хеш от бизнес-ключа (так делают в Data Vault, чтобы ключ был воспроизводим без обращения к базе).
Обычно так называют запрос, который нарушает уникальность или конфликтует с другой транзакцией: вставка с существующим ключом (решается ON CONFLICT / MERGE), два процесса одновременно обновляют одну строку (блокировка и порядок коммитов), либо коллизия имён колонок при соединении (id из двух таблиц без алиасов). На собеседовании стоит уточнить, что именно имеет в виду интервьюер, и показать INSERT ... ON CONFLICT (key) DO UPDATE как ответ на самую частую трактовку.
Алиас это псевдоним таблицы или выражения в запросе: FROM orders o, SUM(amount) AS total. Алиас колонки из SELECT формально появляется после вычисления SELECT, поэтому по стандарту в WHERE и HAVING его использовать нельзя, а в ORDER BY можно. В GROUP BY зависит от СУБД: PostgreSQL, MySQL и ClickHouse разрешают группировать по алиасу из SELECT, Oracle и SQL Server нет, там нужно повторить выражение или вынести его в подзапрос. Алиас таблицы обязателен при self join и удобен везде, где одна таблица встречается дважды.
ALTER TABLE это DDL: меняет структуру таблицы, добавляет или удаляет колонки, меняет тип, ограничения, переименовывает; обычно берёт исключительную блокировку и в ряде СУБД не откатывается транзакцией. UPDATE это DML: меняет значения в существующих строках по условию, не трогая структуру, логируется построчно и откатывается. Вопрос проверяет понимание разницы между схемой и данными; рядом обычно спрашивают про DELETE и TRUNCATE и про то, что UPDATE без WHERE обновит всю таблицу.
Повторный запуск за тот же период даёт тот же результат, без дублей и потерь. Достигается upsert через ON CONFLICT или MERGE с проверкой версии, либо перезаписью периода: DELETE окна и INSERT в одной транзакции (в партиционированных таблицах заменой партиции). В Airflow это означает, что задача получает дату периода параметром и не зависит от «сейчас». Любая загрузка в проде падает и перезапускается, поэтому это свойство обязательное.
Источник пишет в staging как есть, без ограничений и быстро. Дальше один запрос переносит данные в целевую таблицу: дедупликация внутри порции (DISTINCT ON по ключу с выбором последней версии) и защита от уже загруженного (ON CONFLICT DO NOTHING или anti join к целевой). Staging очищается после успешного переноса, всё в одной транзакции. Так ошибки источника не ломают витрину, а переливка идемпотентна.
По времени поступления данных в источник (updated_at, loaded_at, номер версии, LSN), не по времени события: события опаздывают, и окно по дате события их теряет. Окно берётся с запасом назад, а дубли на стыке окон убирает идемпотентная запись. Если в источнике нет такой колонки, остаются варианты: CDC по журналу базы, сравнение хешей строк с текущим состоянием, полная перезагрузка небольших таблиц.
По метке времени изменения или монотонному ключу (самый дешёвый, требует надёжной колонки в источнике); CDC по журналу транзакций (Debezium, логическая репликация: видны и удаления); сравнение снимков (snapshot): выгрузить всё, сравнить с предыдущим состоянием по хешу строки и получить diff, дорого, но работает с любым источником; триггеры в источнике. Snapshot это полное состояние на момент времени, diff это набор изменений между двумя снимками: вставки, обновления, удаления. Хранить снимки просто и позволяет откатиться, хранить diff экономно и сохраняет историю изменений.
10 ГБ это объём для одной машины, кластер не нужен. Выгрузка потоком, а не целиком в память: COPY из источника в файлы или чтение курсором батчами; хранение промежуточного результата в Parquet; дедупликация и преобразования в целевой базе SQL-запросом (staging → INSERT ... SELECT DISTINCT ON) или в DuckDB/Polars поверх файлов, если целевая база слабая; загрузка в приёмник COPY/executemany батчами в транзакции. Оркестрация в Airflow с идемпотентными шагами, проверки качества после загрузки. Spark здесь избыточен, pandas целиком не поместит 10 ГБ в память.
UPDATE employees SET salary = CASE WHEN salary = 900 THEN 1000 ELSE 1500 END. Условие внутри CASE вычисляется для каждой строки, поэтому один проход меняет всех. Важно помнить, что без WHERE обновится вся таблица, и это здесь задумано, а в реальной работе такой запрос сначала проверяют через SELECT с тем же CASE. Если «остальных» трогать не нужно, условие переносят в WHERE: UPDATE ... SET salary = 1000 WHERE salary = 900.
Atomicity: транзакция либо выполняется целиком, либо откатывается целиком. Consistency: база переходит из одного согласованного состояния в другое, ограничения не нарушаются. Isolation: параллельные транзакции не видят промежуточных состояний друг друга (степень задаётся уровнем изоляции). Durability: закоммиченные данные переживают сбой. BASE (Basically Available, Soft state, Eventually consistent) это модель распределённых систем, где ради доступности и масштабирования допускают временную несогласованность: данные «сойдутся» позже. Реляционные базы и хранилища живут по ACID, Kafka, S3 и многие NoSQL ближе к BASE.
Read Uncommitted допускает грязное чтение (в PostgreSQL его нет, он ведёт себя как Read Committed). Read Committed убирает грязное чтение, но допускает неповторяющееся чтение и фантомы. Repeatable Read гарантирует один снимок на всю транзакцию, в PostgreSQL он убирает и фантомы, но параллельные обновления одной строки заканчиваются ошибкой сериализации. Serializable гарантирует результат, эквивалентный последовательному выполнению. По умолчанию в PostgreSQL Read Committed; для загрузки, которая читает несколько таблиц согласованно, берут Repeatable Read.
Multi-Version Concurrency Control: каждая транзакция видит снимок данных на момент своего начала, читатели не блокируют писателей, а писатели читателей. Обратная сторона: обновлённые и удалённые строки остаются в таблице как мёртвые версии до VACUUM, и таблица с частыми обновлениями распухает.
По объекту: блокировка таблицы (DDL, TRUNCATE, LOCK TABLE), строки (UPDATE, DELETE, SELECT ... FOR UPDATE), страницы в некоторых СУБД, advisory-блокировки на уровне приложения. По режиму: разделяемые (shared, чтение) и исключительные (exclusive, запись), в PostgreSQL восемь режимов табличных блокировок с матрицей совместимости. Для инженера данных важны два следствия: ALTER TABLE ждёт окончания всех запросов к таблице и блокирует новые, а длинная транзакция загрузки держит блокировки строк и мешает VACUUM.
Из-за MVCC обновления и удаления оставляют мёртвые версии строк; VACUUM помечает их место как свободное для повторного использования и обновляет карту видимости, ANALYZE собирает статистику для планировщика. autovacuum делает это сам по порогам, но на больших таблицах с массовыми загрузками пороги по умолчанию (20% изменённых строк) срабатывают поздно: их снижают для конкретных таблиц или запускают VACUUM ANALYZE явно после загрузки. VACUUM FULL переписывает таблицу целиком, возвращая место системе, но держит исключительную блокировку. В Greenplum то же самое, плюс таблицы append-optimized накапливают «скрытые» строки, и их нужно вакуумить после UPDATE/DELETE.
Запрос ORDER BY ts DESC LIMIT 1000 с индексом по ts дёшев сам по себе: читается хвост индекса, без сортировки всей таблицы. Если индекса нет, добавить. Дальше снимать нагрузку с базы: кэшировать результат на несколько секунд (один запрос в базу, остальные из кэша), читать с реплики, в хранилищах держать небольшую «горячую» таблицу или материализованное представление с последними записями, которое обновляется одной задачей. Ограничить число одновременных соединений через пул (PgBouncer), чтобы 100 клиентов не превратились в 100 бэкендов.
Уникальность ключа, обязательные поля не NULL, допустимые значения статусов, ссылочная целостность, свежесть (когда последняя успешная загрузка) и полнота (число строк в сравнении с ожиданием и с предыдущими днями). Плюс сверка с источником в обе стороны: что есть только там и только здесь. Проверки запускаются после загрузки и блокируют публикацию при провале; отчёт по качеству это таблица «проверка, дата, число нарушений, порог, статус», по которой строят алерты.
FULL JOIN по бизнес-ключу с меткой, откуда пришла строка: left_only это пропуски в приёмнике, right_only лишнее, both сравнивают по хешу значимых колонок. Задвоения ищут до соединения: count(*) против count(DISTINCT ключ) на каждой стороне, иначе дубли размножат результат сверки. Для больших таблиц сверяют агрегаты по дням (число строк, суммы) и углубляются только в расходящиеся дни.
Правила задаются заранее: тип и формат поля, обязательность, допустимые значения, диапазоны, ссылки на справочники, бизнес-правила (сумма позиций равна сумме заказа). Ошибочная запись не исправляется молча: либо вся загрузка останавливается (строгие данные), либо запись уходит в карантин (отдельную таблицу с причиной и датой), а загрузка продолжается с отчётом «отклонено N». Карантин нужен, чтобы ошибки источника были видны и исправлялись у источника, а не терялись.
Построить ключ из содержимого: хеш от всех значимых колонок (md5(concat_ws('|', col1, col2, ...))), хранить его в таблице с уникальным индексом и сравнивать по нему при загрузке. Если часть колонок «шумит» (время загрузки, технические поля), исключить их из хеша. Так же устроена дедупликация в Data Vault: хеш бизнес-ключа и хеш-дифф атрибутов.
EXPLAIN (ANALYZE, BUFFERS), найти узел с наибольшим временем с учётом loops, сравнить оценку rows с фактом. Расхождение в разы указывает на устаревшую статистику или функции над колонками. Дальше по ситуации: индекс, переписать коррелированный подзапрос в соединение, убрать DISTINCT, который прячет размножение, уменьшить объём до соединения, проверить work_mem (сортировка на диске, батчи у hash join). В MPP добавляется проверка перекоса и Motion в плане.
Сначала выяснить, что изменилось: объём данных (рост или перекос в конкретный день), план запроса (устарела статистика, пропал индекс, изменился порядок соединений), ресурсы (другие задачи в то же окно, место на диске, спилл), код (новый JOIN, новая колонка). Сравнить план сегодня и в быстрый день, посмотреть мониторинг. Если объём по вторникам в пять раз больше: партиционировать обработку по частям, вынести тяжёлые шаги в инкремент, расширить ресурсы под этот день (в Spark динамическая аллокация), развести задачи по времени, и главное, заложить порог в мониторинг, чтобы узнать о проблеме до того, как её заметит заказчик.
CTE читается как единый запрос и удобен, пока частей мало, но в PostgreSQL до 12 CTE всегда материализовался, а сейчас может быть встроен в запрос, и статистики по нему нет: планировщик строит план по оценкам. Временная таблица даёт точку отладки, ANALYZE и индексы, её можно переиспользовать в нескольких запросах, и большой промежуточный результат не пересчитывается. Для витрины из нескольких тяжёлых шагов временные таблицы (или staging-таблицы с явной очисткой) обычно надёжнее; CTE для логически одного запроса.
Оптимизировать до соединения, а не после: отфильтровать по партициям и датам, оставить только нужные колонки, агрегировать до зерна соединения, убедиться, что ключи одного типа (иначе индекс не работает). Соединять по ключу, по которому данные распределены или отсортированы. Проверить статистику и план. Если результат соединения нужен многим витринам, посчитать его один раз в промежуточную таблицу.
Отдельная структура (обычно B-дерево), которая по значению колонки находит строки без чтения всей таблицы. Минусы: место на диске, замедление вставок и обновлений (каждый индекс обновляется вместе с таблицей), раздувание при массовых изменениях, устаревание статистики. При массовой загрузке индексы иногда удаляют перед вставкой и создают после: так быстрее, чем обновлять их построчно. В аналитических хранилищах (Greenplum, ClickHouse) индексы играют меньшую роль, чем распределение, партиции и ключ сортировки.
Плюс: точечные и диапазонные запросы по этим колонкам перестают читать всю таблицу, план меняется с Seq Scan на Index Scan. Минусы: индекс по нескольким колонкам работает только для условий по префиксу (порядок колонок важен), каждая вставка обновляет индекс, на 30 млн строк создание занимает минуты и в PostgreSQL блокирует запись (если не CONCURRENTLY), а если фильтр отбирает большую долю строк, планировщик всё равно выберет Seq Scan. Решение: индекс только по действительно селективным колонкам, под реальные запросы, с проверкой плана до и после.
B-tree по умолчанию: равенство, диапазоны, сортировка, подходит почти всегда. Hash только для равенства, выигрыш небольшой. GIN для массивов, JSONB и полнотекстового поиска. GiST и SP-GiST для геометрии и диапазонов. BRIN для огромных таблиц, где значение коррелирует с физическим порядком (дата загрузки): крошечный индекс по блокам. Частичный индекс (WHERE status = 'active') и индекс по выражению (lower(email)) под конкретные запросы. Кластеризованный индекс определяет физический порядок строк (в SQL Server и InnoDB это первичный ключ, в PostgreSQL CLUSTER делает это разово), некластеризованный живёт отдельно и хранит ссылки на строки.
Индекс ускоряет поиск строк внутри таблицы, партиция делит таблицу на физические части по значению колонки, и запрос с условием по этой колонке читает только нужные части. Индекс для точечного поиска, партиции для диапазонов дат и для обслуживания: удалить или перезагрузить месяц целиком это отбросить партицию, а не DELETE миллионов строк. Они не исключают друг друга: индексы создаются внутри каждой партиции.
Партиционирование делит таблицу на части внутри одного сервера по значению колонки (чаще по дате): ускоряет запросы с фильтром по ней и обслуживание. Шардирование делит данные между серверами по ключу: масштабирует запись и объём, но соединения между шардами и перебалансировка становятся вашей заботой. В Greenplum дистрибуция по сегментам это шардирование внутри кластера, а партиции внутри каждого сегмента.
Для запросов по диапазону дат и для перезаписи или удаления периодов целиком: отбросить партицию мгновенно, а DELETE по индексу долго. Индекс для точечного поиска, партиции для сканирования диапазонов. В аналитических базах (ClickHouse, Greenplum) партиционирование и ключ сортировки важнее индексов.
Разбить пространство на сетку: добавить колонки с номером ячейки (округлённые широта и долгота или геохеш) и искать сначала по ячейке, а точную проверку делать по строкам внутри неё. В MPP по ячейке можно и распределять, и партиционировать: запрос по области попадает в несколько сегментов и партиций, а не во все. Принцип тот же, что для любого распределения: ключ должен равномерно раскладывать данные и совпадать с тем, по чему фильтруют и соединяют.
[1, 2, 3, 4]. Присваивание b = a не копирует список, а создаёт вторую ссылку на тот же объект; append меняет объект, и через a видно то же самое. Независимая копия это list(a) или copy.deepcopy для вложенных структур.
== сравнивает значения, is проверяет, что это один и тот же объект в памяти. Два пустых списка: [] == [] даёт True, [] is [] даёт False, это разные объекты. Два экземпляра класса без __eq__: и ==, и is дадут False, потому что == по умолчанию сравнивает по идентичности; чтобы сравнивать по содержимому, нужно определить __eq__ (и __hash__, если объекты будут ключами). is используется для None, True, False; с числами и строками он иногда даёт True из-за кэша маленьких целых и интернирования, полагаться на это нельзя.
Изменяемые: list, dict, set, bytearray, экземпляры классов. Неизменяемые: int, float, complex, bool, str, bytes, tuple, frozenset, range, None. Кортеж неизменяем, но элементы внутри него могут быть изменяемыми. set хранит уникальные хешируемые элементы без порядка и проверяет принадлежность за O(1); list хранит любые элементы с порядком и дублями, поиск за O(n).
Динамическая и строгая: тип привязан к объекту, а не к имени, и неявных приведений между несовместимыми типами нет ("1" + 1 это TypeError). Аннотации типов не проверяются во время выполнения, их читают редактор, mypy и люди.
Да, сколько угодно: выполнится первый достигнутый, остальной код после него не запускается. Так пишут ранний выход (if not rows: return []). Если ни один return не достигнут, функция возвращает None. Несколько значений возвращаются одним return a, b как кортеж.
Singleton гарантирует, что у класса один экземпляр, и даёт к нему глобальную точку доступа: в Python через переопределение __new__, метакласс или декоратор класса; на практике чаще всего модуль с объектом, созданным при импорте (модули и так загружаются один раз). Отличие от глобальной переменной: экземпляр создаётся лениво при первом обращении, класс контролирует создание и может нести поведение и состояние (соединение с базой, конфиг), а глобальная переменная это просто имя, которое кто угодно может перепривязать. Другие паттерны, которые реально встречаются в пайплайнах: фабрика (создание коннектора по типу источника), стратегия (взаимозаменяемые алгоритмы обработки), адаптер (единый интерфейс к разным API), декоратор.
Основной механизм это счётчик ссылок: объект уничтожается сразу, когда счётчик стал нулём. Циклы ссылок счётчик не ловит, их находит сборщик gc: периодически обходит объекты-контейнеры по трём поколениям и освобождает недостижимые циклы. Запускается автоматически по порогам выделений, вручную через gc.collect().
Все объекты живут в куче, имена и контейнеры хранят ссылки. Для маленьких объектов CPython использует свой аллокатор pymalloc с аренами, поэтому освобождённая память может не вернуться операционной системе. Локальные имена живут в кадре функции и исчезают при return; память освобождается, когда на объект не остаётся ссылок.
Global Interpreter Lock: мьютекс в CPython, который позволяет только одному потоку выполнять байт-код в каждый момент времени. Поэтому потоки не ускоряют вычисления на CPU: два потока считают не быстрее одного. Потоки помогают, когда задача ждёт ввода-вывода (сеть, диск, база): во время ожидания GIL отпускается. Для CPU-нагрузки берут процессы (multiprocessing), библиотеки на C, которые отпускают GIL (NumPy, pandas в части операций), или выносят расчёт в базу и Spark. С Python 3.13 есть экспериментальная сборка без GIL.
Список хранит все элементы сразу, итератор и генератор отдают по одному по запросу и хранят только своё состояние. for line in file держит в памяти одну строку, file.readlines() весь файл. Генератор (x * 2 for x in rows) не создаёт промежуточного списка. Цена ленивости: обойти можно один раз, а ошибка возникает не при создании, а при чтении.
Типичный случай в DE: выкачать тысячи страниц из API или много файлов из S3, где всё время уходит на ожидание сети. asyncio с aiohttp делает это в одном потоке через событийный цикл: пока один запрос ждёт ответа, отправляется следующий; ThreadPoolExecutor даёт то же самое для библиотек без async-версии (requests, драйверы баз). Для обработки самих данных ни то ни другое не помогает из-за GIL: там процессы, Spark или SQL. Честный ответ «применял для параллельной выкачки, обработку делал в базе» звучит лучше, чем выдуманный опыт.
Ключом может быть любой хешируемый объект: тот, у которого есть __hash__, и хеш не меняется за время жизни. Словарь кладёт ключ в ячейку по его хешу; если бы ключ изменился, хеш стал бы другим, и словарь искал бы его не в той ячейке. Поэтому встроенные изменяемые типы (list, dict, set) хеша не имеют, а int, str, tuple из хешируемых, frozenset, bytes, None и функции имеют. Экземпляр MyClass хешируем по умолчанию (по адресу), значит может быть ключом; чтобы два экземпляра с одинаковыми полями считались одним ключом, нужны согласованные __eq__ и __hash__, например через @dataclass(frozen=True).
Словарь и множество это хеш-таблицы: от ключа считается hash(), по нему вычисляется индекс ячейки, при коллизии проверяются соседние по открытой адресации. В среднем поиск, вставка и удаление O(1), в худшем случае (все хеши совпали) O(n). Поиск в списке 5 in nums перебирает элементы по одному: O(n), в среднем половина списка. Поэтому «есть ли ключ среди ста тысяч» делают через set, а не через список.
Список это массив ссылок с запасом по ёмкости. append дописывает в конец: амортизированно O(1), редкое переразмещение массива при переполнении. insert(i, x) сдвигает все элементы правее позиции: O(n), для insert(0, x) сдвигается весь список. Если нужно добавлять в начало, берут collections.deque с O(1) с обоих концов.
Оба это массив ссылок: доступ по индексу O(1), поиск и in O(n), у списка append амортизированно O(1), вставка в начало O(n). Кортеж неизменяем и может быть ключом словаря. Для проверки принадлежности на больших объёмах нужен set с O(1).
def log(*args, **kwargs): args внутри функции это кортеж позиционных аргументов, kwargs словарь именованных. Вызов log(1, "a", table="orders", rows=10) даст args == (1, "a") и kwargs == {"table": "orders", "rows": 10}. Именованные передаются как key=value при вызове или распаковкой готового словаря log(**params); позиционные распаковываются из последовательности log(*values). Имена args и kwargs это соглашение, звёздочки это синтаксис.
По присваиванию: параметр становится ещё одной ссылкой на переданный объект. Изменение объекта на месте (row["x"] = 1, lst.append) видно снаружи, перепривязка параметра (row = {}) нет. Для неизменяемых типов это выглядит как передача по значению, для изменяемых как по ссылке, но механизм один.
Анонимная функция из одного выражения. Уместна как короткий ключ сортировки (sorted(rows, key=lambda r: r["dt"])) или условие в filter, используемые один раз. Несколько инструкций, присваивание и try в ней невозможны; как только логика растёт или нужна повторно, это def с именем. Присваивать lambda имени считается плохим стилем.
Значение по умолчанию вычисляется один раз при определении функции и живёт в объекте функции. def f(x, bucket=[]) даёт один список на все вызовы: элементы накапливаются между вызовами. Идиома: bucket=None и if bucket is None: bucket = [] внутри.
Функция, которая принимает функцию и возвращает новую, обычно обёртку с действиями до и после вызова исходной. @dec над def f эквивалентно f = dec(f). Несколько декораторов применяются снизу вверх (ближайший к функции первым), а при вызове выполняются сверху вниз: @a @b def f это a(b(f)). functools.wraps сохраняет имя и docstring исходной функции.
Объект для конструкции with: при входе вызывается __enter__, при выходе из блока, в том числе по исключению, __exit__. Так гарантируется освобождение ресурса: with open(...) закрывает файл, with conn: коммитит или откатывает транзакцию, with lock: снимает блокировку. Свой пишут классом с __enter__/__exit__ или функцией-генератором с декоратором @contextlib.contextmanager: код до yield это вход, после yield выход, try/finally вокруг yield даёт очистку при ошибке. В пайплайнах контекстный менеджер это стандартный способ не оставить соединение или временный файл открытыми после падения.
Функция с yield при вызове возвращает генератор: итератор, который выполняет тело лениво, по одному элементу на запрос, и сохраняет состояние между запросами. return завершает функцию и уничтожает кадр, yield отдаёт значение и ставит функцию на паузу. Для инженера данных это способ обрабатывать файл больше памяти: читать, преобразовывать и писать по одному элементу, собирая конвейер из маленьких тестируемых шагов.
Итерируемый объект это тот, у которого __iter__ возвращает итератор. Итератор это объект с __next__, который отдаёт элементы по одному и возбуждает StopIteration в конце; обходится только один раз. Цикл for вызывает iter, а потом next до конца. Файл, map, zip, csv.reader возвращают итераторы, список и словарь итерируемы, но не итераторы.
Первый параметр метода, в который Python подставляет экземпляр при вызове obj.method(): obj.method() это Class.method(obj). Через него метод читает и пишет атрибуты именно этого объекта. Имя это соглашение, подстановка это механизм языка.
Доступ к методам следующего класса в порядке MRO, обычно родителя. Используется, чтобы дополнить унаследованный метод, а не переписать его целиком; в __init__ дочернего класса вызов super().__init__() обязателен, иначе атрибуты родителя не будут созданы.
Инкапсуляция, наследование, полиморфизм, абстракция. В Python инкапсуляция держится на соглашении об именах с подчёркиванием, полиморфизм реализуется утиной типизацией (важно наличие метода, а не тип), абстракция через abc или просто через договорённость об интерфейсе. В коде загрузок из четырёх реально работают полиморфизм и абстракция интерфейсов (источник с методом read(), у каждого своя реализация).
Конструкция try / except / else / finally: в try код, который может упасть; except Тип as e обрабатывает указанные типы (сверху вниз, первый подходящий); else выполняется, только если исключения не было; finally всегда, даже при return или непойманном исключении, и нужен для освобождения ресурсов. Исключения это классы с иерархией: BaseException → Exception → ValueError, TypeError, KeyError, OSError (и его потомки FileNotFoundError, ConnectionError), RuntimeError; KeyboardInterrupt и SystemExit наследуют от BaseException, поэтому голый except: ловит и их. Свои исключения наследуют от Exception или от ближайшего по смыслу встроенного; raise ... from e сохраняет цепочку. Правило: ловить самое узкое, что умеете обработать, остальное пусть падает с traceback.
Возбудить исключение из кода задачи: любое непойманное исключение в PythonOperator или @task переводит задачу в failed и запускает retry по настройкам. Для явного смысла есть AirflowFailException (упасть без повторов) и AirflowSkipException (пометить как пропущенную). Главное не ловить исключение и не возвращать None вместо него: тогда задача будет зелёной при сломанных данных.
Два указателя: сравнивать текущие элементы списков, забирать меньший и сдвигать его указатель, в конце дописать остаток того списка, который не кончился. Это шаг merge sort, сложность O(n + m) и O(n + m) памяти под результат. sorted(a + b) даст O((n + m) log(n + m)), хотя в CPython Timsort на двух отсортированных кусках работает почти линейно; на собеседовании ждут именно два указателя. Готовое решение в стандартной библиотеке: heapq.merge(a, b), оно ленивое и подходит для потоков.
Два указателя с краёв: наибольший по модулю элемент всегда на одном из концов, его квадрат идёт в результат последним. Заполнять результат с конца, сдвигая левый или правый указатель, O(n). Наивное sorted(x * x for x in nums) это O(n log n), и интервьюер обычно просит улучшить.
collections.Counter(values).most_common(1)[0][0] за O(n). Руками: словарь счётчиков в один проход, затем ключ с максимальным значением. Уточнить у интервьюера, что делать при нескольких модах: вернуть любую, все или первую встреченную. Решение через max(set(values), key=values.count) короткое, но O(n × k).
Стек: открывающая скобка кладётся в стек, закрывающая должна совпасть с вершиной, иначе последовательность некорректна; в конце стек должен быть пуст. Для нескольких видов скобок словарь пар {")": "(", "]": "[", "}": "{"}. O(n) по времени и памяти. Случаи, которые ждут на собеседовании: пустая строка (корректна), только закрывающая, незакрытая в конце, перекрёст ([)].
Один проход с подсчётом серии: пока следующий символ равен текущему, увеличивать счётчик, при смене символа записать символ и, если счётчик больше 1, число. Можно через itertools.groupby: "".join(ch + (str(n) if n > 1 else "") for ch, n in ((k, len(list(g))) for k, g in groupby(s))). Граничные случаи: пустая строка, один символ, серия длиной 1 без числа по условию.
Один проход со словарём счётчиков: элемент попадает в результат, если его счётчик меньше N, после чего счётчик увеличивается. O(n) времени, O(k) памяти по числу уникальных значений. Это та же схема, что дедупликация с сохранением порядка (N = 1).
6 - 2. Множественное присваивание сначала вычисляет правую часть в кортеж ("6", "2"), потом распаковывает в имена, поэтому обмен происходит без временной переменной. Значения остаются строками, но print разделяет аргументы пробелом, и на выводе это не видно.
(96, 130). filter возвращает ленивый итератор с элементами, для которых функция истинна; tuple() его материализует. Без tuple() напечатался бы объект <filter ...>.
10. Строка итерируема по символам, map(int, ...) превращает каждый символ в число, sum складывает: 1 + 2 + 3 + 4.
5. set оставляет уникальные значения, key=values.count для каждого считает, сколько раз оно встречается в исходном списке, max выбирает самое частое: это мода списка. Сложность O(n × k), где k число уникальных значений, потому что count каждый раз проходит весь список; для больших данных берут collections.Counter(values).most_common(1) за O(n).
[[1, 2], [3, 4]]. items[1] это ссылка на внутренний список, append меняет его на месте, внешний список видит изменение, потому что хранит ту же ссылку.
Ответ с задачей для каждой звучит убедительнее списка: psycopg для загрузок в Postgres и свой драйвер для ClickHouse, pandas для разведки и отчётов, pyarrow для Parquet, PySpark когда данные на кластере, SQLAlchemy как универсальный коннектор, requests/aiohttp для API, boto3 для S3. Главное, что проверяют: понимаете ли вы, где pandas заканчивается и начинается база или Spark.
Зависимости фиксируются в репозитории (requirements.txt с версиями, pyproject.toml с uv или poetry и lock-файлом), а не ставятся руками на сервере. Деплой собирает окружение из этого файла: образ Docker с pip install -r requirements.txt или uv sync, для Airflow пересборка образа воркеров или добавление пакета в образ через CI. Так окружение воспроизводимо, а pip install на проде вручную это первое, что ломает повторяемость.
Разделитель (запятая, точка с запятой, табуляция), наличие заголовка, символ кавычек и экранирования, кодировка (UTF-8, cp1251, BOM), формат дат и чисел (десятичный разделитель, тысячи), признак NULL (пустая строка, \N, NULL), переводы строк внутри полей, размер файла (читать потоком, не целиком). Всё это задаётся явно в csv.reader/pandas.read_csv, а не угадывается, и типы приводятся на входе с ошибкой на плохом значении.
OLTP обслуживает приложение: много коротких транзакций, точечные чтения и записи по ключу, нормализованная схема, строгие ограничения, важна задержка одной операции. OLAP обслуживает аналитику: мало запросов, но каждый читает миллионы строк, агрегаты по колонкам, денормализованные витрины, важна пропускная способность. Отсюда разные движки: строковые базы с индексами для OLTP, колоночные и MPP для OLAP. Нагрузка одного на базу другого плохо заканчивается: аналитический запрос блокирует и тормозит приложение, а OLTP-база не выдержит сканирований.
База данных хранит текущее состояние для работы приложения. Хранилище собирает данные из многих источников, хранит историю, приводит данные к единым справочникам и моделям и оптимизировано под аналитические запросы. В хранилище данные не меняются приложением, а загружаются пайплайнами по расписанию, поэтому там допустимы денормализация, отсутствие внешних ключей и перезапись периодов.
Колоночное хранение читает с диска только нужные колонки, а однотипные данные в колонке сжимаются в разы лучше. Аналитический запрос «сумма по двум колонкам из пятидесяти» читает 4% данных. Плюс векторизованная обработка. Минус: точечные обновления и чтение целой строки дороги, поэтому для OLTP колоночные базы не подходят. ClickHouse, Parquet, колоночные таблицы Greenplum устроены так.
DWH: структурированные данные в базе со схемой, строгими слоями и SQL, дорого масштабировать объём. Data Lake: файлы любого формата в объектном хранилище, схема при чтении, дёшево хранить всё, но без транзакций и с риском «болота». Lakehouse: файлы в озере плюс табличный формат (Iceberg, Delta, Hudi), который даёт схему, транзакции, обновления и версии, и SQL-движки поверх (Trino, Spark). Выбор зависит от объёма, разнообразия данных и того, кто и чем их читает.
Greenplum это PostgreSQL, размноженный на сегменты: таблицы распределяются по узлам по ключу, запрос выполняется параллельно, добавляются Motion-операции для перемещения строк между узлами. Это MPP для аналитики с полноценным SQL и JOIN, но с медленными точечными операциями. ClickHouse колоночный, заточен на вставку больших порций и агрегаты по одной широкой таблице со скоростью, недостижимой для Greenplum, но хуже с JOIN больших таблиц, без полноценных транзакций и обновлений (ReplacingMergeTree вместо UPDATE). Greenplum для хранилища с моделью и сложными соединениями, ClickHouse для витрин, событий и дашбордов.
Монолит выполняет запрос на одном узле, масштабируется вертикально и ограничен его диском и памятью. MPP разбивает данные по узлам (shared nothing), каждый узел обрабатывает свою часть параллельно, координатор собирает результат; масштабирование добавлением узлов. Цена: данные нужно правильно распределить, соединения по разным ключам требуют пересылки по сети, перекос данных делает один узел узким местом, а точечные операции и транзакции дороже, чем в монолите.
Raw / staging: копируют как есть, фиксируют время загрузки, ничего не меняют. ODS (операционный слой): очищают, приводят типы, дедублицируют, иногда хранят короткую историю. Core / DDS (детальный слой): интегрируют источники в единую модель (Kimball, Data Vault, якорная), ведут историю по SCD, задают ключи. Data mart (витрины): агрегируют и денормализуют под конкретных потребителей, считают метрики. Глаголы для ответа: raw копирует, staging приводит, core интегрирует и хранит историю, mart отвечает на вопрос бизнеса.
Денормализован. Витрина отвечает на запросы аналитиков и BI, а соединения из десяти таблиц в каждом запросе это медленно и ошибкоопасно (задвоения, потерянные строки). Денормализация даёт один-два JOIN или вообще одну широкую таблицу, предсказуемую производительность и понятную для потребителя структуру. Цена: избыточность и необходимость пересчитывать витрину при изменении справочников, что и делает пайплайн.
Полное состояние таблицы или витрины на момент времени, сохранённое отдельно (партиция по дате снимка). Снимки позволяют сравнить состояние на две даты, пересчитать отчёт «как было», откатиться. Альтернатива снимкам это хранение изменений (SCD2, diff): экономнее по месту, но запрос «состояние на дату» сложнее.
ETL: извлечь, преобразовать на отдельном движке (Spark, Informatica, Python), загрузить готовое. ELT: извлечь, загрузить сырое в хранилище, преобразовать там SQL-запросами (dbt поверх Greenplum, ClickHouse, Snowflake). ELT проще и использует мощность хранилища, ETL нужен, когда хранилище слабое, данных слишком много для него или преобразование не выражается в SQL (парсинг, ML, вызовы API). Трансформацию на источнике делают только лёгкую фильтрацию, чтобы не нагружать боевую базу.
Инмон строит сначала нормализованное корпоративное хранилище (3НФ) как единый источник истины, а витрины отдельно сверху: долго, строго, хорошо для больших организаций. Кимбалл строит хранилище из витрин со схемой «звезда» с общими измерениями (bus matrix), быстро даёт результат бизнесу и проще для аналитиков. На практике смешивают: детальный слой в 3НФ или Data Vault, витрины по Кимбаллу.
Звезда: таблица фактов и денормализованные измерения вокруг, один JOIN до любого атрибута. Снежинка: измерения нормализованы в несколько таблиц (товар → категория → отдел), меньше избыточности, но больше соединений и сложнее запросы. Для витрин и BI почти всегда звезда; снежинка там, где измерения огромны или часто меняются по иерархии.
Data Vault: хабы (бизнес-ключи), линки (связи между ключами) и сателлиты (атрибуты с историей). Преимущества: гибкость к изменениям источников, параллельная загрузка, полная история, аудируемость, хеш-ключи без обращения к базе. Недостатки: много таблиц и соединений, витрины обязательны, порог входа и дисциплина. Якорная модель идёт дальше: каждый атрибут в отдельной таблице (6НФ), история на уровне атрибута, изменений схемы ещё меньше, но таблиц и соединений ещё больше; практична только с генерацией запросов инструментом.
Факт: составной ключ из внешних ключей на измерения, числовые аддитивные меры (сумма, количество), дата события, мало текстовых колонок, много строк. Измерение: суррогатный первичный ключ, описательные атрибуты (названия, категории, текст), поля истории SCD, относительно мало строк. Большие varchar в фактах раздувают самую большую таблицу, портят сжатие и скорость сканирования, а главное, текст это описание сущности и должен жить в измерении, на которое факт ссылается ключом.
Нормализация делит данные на таблицы так, чтобы каждый факт хранился один раз: заказ ссылается на клиента по ключу, а не копирует его имя и адрес. Убирает аномалии обновления и экономит место, но требует соединений. Денормализация сознательно дублирует данные ради скорости чтения: витрины, таблицы для BI, широкие таблицы в ClickHouse, кэши. Пример нормализованной схемы: customers(id, name), orders(id, customer_id, date), order_items(order_id, product_id, qty), products(id, title, category_id).
Натуральный ключ приходит из предметной области (ИНН, номер договора, email); суррогатный генерируется хранилищем (последовательность, UUID, хеш бизнес-ключа) и не несёт смысла. Суррогатный ключ нужен, чтобы история версий (SCD2) имела уникальный идентификатор строки, чтобы не зависеть от изменения натурального ключа в источнике и чтобы объединять несколько источников с разными ключами. Первичный ключ бывает простым и составным; у составного важны минимальность (нет лишних колонок), стабильность (значения не меняются), и то, что по нему реально ищут и соединяют: ключ из пяти колонок тяжёл в JOIN и индексах.
ORDER BY ts DESC LIMIT 1000 с индексом по ts; без индекса база отсортирует всю таблицу. Если в хранилище таблица партиционирована по дате, добавить фильтр по последним партициям, чтобы не сканировать всё. Для «последних по каждому клиенту» уже нужна оконная функция.
Полуинтервалом: ts >= '2025-04-01' AND ts < '2025-04-02'. Приведение ts::date = '2025-04-01' даёт тот же результат, но отключает индекс и отсечение партиций, а BETWEEN '2025-04-01' AND '2025-04-02' захватывает полночь следующего дня. Часовой пояс уточнить: день в UTC и в Москве это разные интервалы.
В реляционной модели отношение это таблица: заголовок из пар «имя, тип» и тело из множества кортежей. Кортеж это строка, атрибут это колонка. Связи между сущностями: один к одному (человек и паспорт, часто это просто одна таблица, разделённая по соображениям доступа), один ко многим (мать и дети, внешний ключ на стороне «многих»), многие ко многим (студенты и преподаватели), которая в реляционной базе реализуется только через промежуточную таблицу с двумя внешними ключами, образующими составной ключ.
Первая: значения атомарны, нет повторяющихся групп и списков в одной ячейке, строки не дублируются. Вторая: первая плюс каждый неключевой атрибут зависит от всего составного ключа, а не от его части, иначе данные о части ключа выносятся в свою таблицу. Третья: вторая плюс нет транзитивных зависимостей, неключевой атрибут не зависит от другого неключевого (город не хранится вместе с индексом в таблице заказов). Цель нормализации убрать дублирование и аномалии вставки, обновления и удаления. В хранилищах витрины осознанно денормализуют ради скорости чтения, а анкорная модель и Data Vault это примеры сильно нормализованных схем (шестая нормальная форма).
Slowly Changing Dimensions: способы хранить изменения атрибутов измерения. Тип 0: не менять. Тип 1: перезаписать, истории нет. Тип 2: новая строка на каждую версию с valid_from, valid_to, часто is_current и хешем атрибутов; история полная, таблица растёт. Тип 3: предыдущее значение в соседней колонке, история на один шаг. Тип 4: история в отдельной таблице. Тип 6 (1+2+3): текущая версия в каждой строке плюс полная история. В хранилищах стандарт это SCD2 для измерений с историей и SCD1 для справочников, где история не нужна.
Версии не обновляются, а дописываются: каждая новая версия это новая строка с valid_from, а закрытие старой версии делается либо перезаписью партиции целиком из источника, либо движком ReplacingMergeTree по ключу (бизнес-ключ, valid_from) с колонкой версии, либо на чтении: valid_to вычисляется оконной функцией по следующей версии. Запрос «состояние на дату» через argMax по версии не позднее даты или FINAL. Главное решить заранее, какой слой отвечает за закрытие версий: обычно это делает хранилище-источник (Postgres/Greenplum), а ClickHouse получает готовые версии.
Меньше данных через сеть и диск, короче окно загрузки, меньше нагрузка на источник, дешевле повторный запуск. Цена: нужна надёжная метка изменений в источнике, обработка опоздавших и удалённых записей, идемпотентность на стыках окон. Полная перезагрузка остаётся для маленьких справочников и как периодическая сверка.
Единый DAG с задачами на каждую таблицу (генерируемыми из конфига): проще обозримость, общие зависимости и порядок, одна точка настройки расписания; минус: одна длинная задача задерживает остальные, перезапуск одной таблицы требует частичного запуска, DAG с сотнями задач тяжёл для планировщика. Отдельный DAG на таблицу: независимые расписания и ретраи, перезапуск точечный; минус: сотни DAG в интерфейсе, дублирование настроек, зависимости между DAG через сенсоры или datasets. Компромисс: DAG на группу таблиц одного источника с динамической генерацией задач из конфига, тяжёлые таблицы отдельно.
Этапы: инвентаризация схем и объёмов; маппинг типов (NUMBER без точности, DATE с временем, CLOB, часовые пояса); выгрузка полная для первого раза (параллельно по партициям, через PXF или файлы в Parquet) и инкремент по метке изменений или CDC дальше; загрузка в staging Greenplum с правильной дистрибуцией; сверка числа строк и контрольных сумм по дням; переключение потребителей. Несовместимость: PL/SQL-процедуры, функции (NVL, DECODE, CONNECT BY), поведение пустой строки и NULL в Oracle, сортировка и регистр; переписываются на PL/pgSQL и стандартный SQL с тестами на эталонных выборках.
PXF это внешние таблицы Greenplum: сегменты читают HDFS, S3 или другую базу параллельно, напрямую в Greenplum, без промежуточного кластера, и SQL-запрос сразу пишет в целевую таблицу. Это выгоднее Spark, когда нужно просто перелить данные: нет накладных расходов на запуск приложения, нет лишнего копирования, параллелизм на стороне сегментов. Spark нужен, когда до загрузки требуется сложная обработка, которой нет в SQL, или данные нужно отдать не только в Greenplum.
Сессия с таймаутами и повторами на сетевые ошибки и 5xx, пагинация курсором, инкремент по дате изменения с запасом, сырые ответы сохраняются как есть в raw (JSON Lines в S3), разбор вложенных структур на следующем шаге: плоские поля в таблицу, повторяющиеся вложенные списки в дочерние таблицы со ссылкой на родителя, схема зафиксирована моделью. POST встречается для выгрузки отчётов по заказу: создать задание, опрашивать статус, скачать файл.
Проверки в самом пайплайне: тесты dbt (unique, not_null, accepted_values, relationships), Great Expectations или Soda для деклараций ожиданий, собственные SQL-проверки с таблицей результатов. Наблюдаемость: свежесть, объём и схема таблиц отслеживаются по метаданным (Elementary, Monte Carlo, OpenMetadata), алерты в Grafana по метрикам из таблицы результатов проверок. Минимальная рабочая схема без покупных инструментов: таблица dq_results(check, table, dt, failed_rows, threshold), задача Airflow после каждой загрузки и алерт при превышении порога.
Технические метрики: доля NULL в обязательных полях, уникальность ключа, ссылочная целостность, свежесть, число строк относительно вчера и относительно источника. Бизнес-метрики: сумма проводок сходится с источником, нет отрицательных остатков, доля заказов без клиента ниже порога. Метрика это SQL-запрос с порогом и владельцем, результат пишется в таблицу, чтобы видеть динамику. Важно называть конкретику: «сверка суммы выдач по дням с ядром банка, расхождение больше 0,01% блокирует публикацию».
Определить зерно: одна строка на пару «клиент, товар». Источники по SCD2 (клиенты, товары) соединяются по периоду действия версии на дату транзакции, а не просто по ключу, иначе покупки размножатся на все версии. Окно: транзакции за полгода от даты расчёта, дата передаётся параметром. Возраст считается на дату расчёта. Витрина материализуется таблицей с перезаписью целиком раз в неделю (или инкрементом по неделям с пересчётом хвоста), с датой расчёта в колонке. Представление поверх соединений плохо, потому что каждый запрос аналитика будет заново соединять три больших таблицы, а «текущий возраст» и «полгода от сегодня» будут меняться от запроса к запросу, и отчёты перестанут сходиться.
Сначала отобрать клиентов со статусом A или B и хотя бы одной покупкой за три месяца до даты (EXISTS). Затем для покупок за предшествующий месяц соединить транзакции с локациями и регионами по периоду действия локации, посчитать по клиенту общее число покупок и число покупок в нужных регионах (count(*) FILTER (WHERE region_name IN (...))), оставить нужные / всего >= 0.6. Деление делать в numeric, регионы брать из справочника по id, а не по LIKE по названию; даты полуинтервалом.
Raw (копия источника, иммутабельная, с датой загрузки), staging (типизация, дедупликация), core (интегрированная модель с историей), marts (под потребителей), плюс служебные: метаданные загрузок, качество, карантин. Поток: загрузка в raw инкрементом с идемпотентностью, преобразования SQL между слоями оркестрируются по зависимостям, каждый шаг пишет в свой слой и не трогает чужой, витрины пересчитываются от core. Доступ потребителям только к marts и, точечно, к core.
Отталкиваться от вопросов, на которые отвечает дашборд: метрики и разрезы определяют зерно и измерения. Звезда с готовыми агрегатами по самым частым разрезам, денормализованные измерения с понятными названиями, предрасчитанные флаги и категории вместо вычислений в BI, партиции по дате, ключ сортировки под фильтры. Отдельно таблица агрегатов по дням для быстрых графиков и детальная для проваливания. Документация полей и владелец у каждой витрины.
Инвентаризация объектов и потребителей (что реально используется), приоритизация по критичности, маппинг типов и функций, перенос данных слоями с историей, параллельная работа двух систем с ежедневной сверкой, переключение потребителей по одному. Несовместимые функции и процедуры переписываются с тестами на эталонных выборках; то, что не используется, не переносится. Главные риски: неучтённые потребители и расхождения из-за разницы в семантике NULL, дат и сортировки.
MPP-база на основе PostgreSQL. Мастер (координатор) принимает запросы, хранит каталог и строит план; сегменты хранят данные и выполняют свою часть плана параллельно; интерконнект пересылает строки между сегментами (Motion); зеркала сегментов и резервный мастер дают отказоустойчивость. Таблицы распределены по сегментам по ключу дистрибуции, внутри сегмента могут быть партиционированы.
По хранению: heap (как в PostgreSQL, для таблиц с частыми обновлениями), append-optimized строковые (AO, для больших таблиц с массовой вставкой) и колоночные (AOCO, для широких таблиц аналитики и сжатия). По распределению: distributed by (ключ), distributed randomly (равномерно, без ключа) и replicated (полная копия на каждом сегменте). Replicated для маленьких справочников, которые часто соединяют с большими фактами: соединение становится локальным без Motion. Внешние таблицы (PXF) читают данные из HDFS, S3 и других баз. Сжатие (zlib, zstd) и колоночная ориентация выбираются под профиль чтения.
Горизонтально: добавлением сегмент-хостов с последующим перераспределением данных (gpexpand), что требует окна и времени пропорционально объёму. Вертикально: больше памяти и дисков на хостах. Ограничения: мастер один (резервный только на случай отказа), поэтому точечные запросы и много мелких транзакций упираются в него; ресурсные группы делят CPU и память между пользователями.
У каждого primary-сегмента может быть зеркало (mirror segment) на другом хосте: изменения с primary доезжают на зеркало через WAL-репликацию. Если primary падает, мастер переключает сегмент на зеркало и кластер продолжает работать в деградированном режиме, пока упавший хост не починят и не вернут через gprecoverseg. Мастер тоже дублируется: standby master получает копию каталога и поднимается вручную или скриптом при отказе основного. Без зеркал отказ одного хоста останавливает весь кластер, потому что каждый запрос трогает все сегменты.
Внешняя таблица это описание данных вне базы: файл на диске или на gpfdist-сервере, S3, Hadoop через PXF. Данные не хранятся в Greenplum, читаются при каждом запросе сразу всеми сегментами параллельно, поэтому это основной способ быстрой загрузки (INSERT INTO t SELECT * FROM ext_table). Обычно только чтение, писать можно в writable external table. Foreign table это стандартный для PostgreSQL механизм FDW: подключение к другой базе (PostgreSQL, другой Greenplum) так, будто таблица локальная, с чтением и, в зависимости от обёртки, записью. Разница в назначении: external для файлов и массовой загрузки, foreign для федеративных запросов к другой базе.
Ключ дистрибуции определяет, на какой сегмент попадает строка. Правильный ключ: с высокой кардинальностью и равномерным распределением (нет перекоса), совпадающий с ключом самых частых соединений, чтобы JOIN выполнялся локально без Motion. Плохие кандидаты при неравномерных данных: статус, дата ежедневной загрузки (все строки дня попадут на один сегмент), колонка с большой долей NULL или другим частым значением. Одно поле для дистрибуции и партиционирования использовать можно. Выбор проверяют отдельно для равномерности распределения и отсечения партиций: партиции обычно по дате для отсечения диапазонов, а дистрибуция по ключу сущности для соединений; дата как ключ дистрибуции может дать перекос, если большой объём приходится на несколько дат.
При перекосе часть сегментов хранит и обрабатывает данных больше других: запрос ждёт самый загруженный сегмент, остальные простаивают, память и диск одного узла заканчиваются раньше. Бывает перекос хранения (плохой ключ дистрибуции) и перекос обработки (соединение или группировка по ключу с частыми значениями, например NULL или «не указан»). Проверить: gp_toolkit.gp_skew_coefficients, или SELECT gp_segment_id, count(*) FROM t GROUP BY 1 и сравнить сегменты. Лечение: другой ключ дистрибуции, DISTRIBUTED RANDOMLY для таблиц без естественного ключа соединения, «соль» для частого значения при JOIN, вынос NULL-ключей.
Варианты: сделать маленькую таблицу replicated, тогда соединение локально на каждом сегменте без пересылки; перераспределить одну из таблиц заранее под ключ соединения (CREATE TABLE ... DISTRIBUTED BY или ALTER TABLE SET DISTRIBUTED BY), если это соединение основное; при разовом запросе планировщик сам выберет broadcast маленькой стороны, если оценка объёма мала, иначе redistribute. Если replicated-таблицу не использовать, справочник будет пересылаться при каждом запросе, а при плохой статистике большая таблица может уехать в broadcast и забить память сегментов.
Когда операция (hash join, сортировка, агрегация) не помещается в выделенную память, движок сбрасывает промежуточные данные на диск и обрабатывает частями. Spill позволяет продолжить обработку на диске, но увеличивает I/O и может сильно замедлить запрос. При превышении лимитов временных файлов или нехватке места запрос завершится ошибкой. В плане Greenplum это видно по Work_mem wanted и spill files, в Spark по Spill (Memory/Disk) в UI. Лечение: больше памяти на операцию (ресурсные группы, statement_mem), меньше данных до операции (фильтры, нужные колонки), устранение перекоса.
Это два независимых слоя. Дистрибуция (DISTRIBUTED BY) физическая: строка по хешу ключа попадает на сегмент, партиционированные и обычные таблицы раскладываются по сегментам одинаково. Партиционирование (PARTITION BY) логическое: уже внутри сегмента строка попадает в свою партицию, которая физически отдельная таблица. Документация формулирует это прямо: партиционирование не меняет распределение данных по сегментам. Поэтому и смотреть на них нужно по-разному: ключ дистрибуции отвечает за равномерность и за соединения без перераспределения, партиции за отсечение данных по фильтру (обычно по дате). DISTRIBUTED RANDOMLY даёт равномерность без подходящего ключа, но любой JOIN по такой таблице потребует redistribute motion, поэтому для больших таблиц фактов это запасной вариант.
Партиция это логическое деление таблицы на части по диапазону или списку значений внутри каждого сегмента. Поле выбирают по самому частому фильтру и по единице обслуживания: обычно дата события или загрузки (по месяцам или дням), потому что запросы берут период, а загрузка перезаписывает период целиком. Субпартиции это второй уровень деления внутри партиции (месяц → регион или тип); они оправданы только при большом объёме и реальных фильтрах по второму полю, иначе множат число файлов и замедляют планирование. Партиции не по дате бывают по списку: регион, источник, тип документа, когда запросы и загрузки идут именно в этом разрезе.
Партиция по умолчанию принимает строки, не подходящие ни под одну явную партицию. В неё могут незаметно попасть ошибочные даты или строки будущих периодов, для которых ещё не создали партиции. Отсечение default зависит от условий запроса, границ и выбранного плана; в Greenplum 7 partition pruning может её исключить. При добавлении партиции на новый диапазон нужно вынести из default строки этого диапазона. Без default вставка строки вне известных диапазонов завершится ошибкой: отсутствие подходящей партиции сразу станет видно. Практика: создавать будущие партиции заранее и контролировать размер и содержимое default, если она есть.
EXPLAIN ANALYZE, в плане искать Motion (broadcast/redistribute большой таблицы), перекос между сегментами (max и avg по сегментам в узлах плана), spill на диск, устаревшую статистику (ANALYZE), отсутствие отсечения партиций. Основная проблема в MPP почти всегда движение данных и перекос: один сегмент работает за всех. Дальше: дистрибуция под соединение, replicated-справочники, фильтры до соединения, колоночные таблицы со сжатием для широких сканов, ресурсные группы под память.
Аналитические запросы читают большие диапазоны, где полное сканирование сегментами параллельно быстрее индекса, а индексы замедляют массовую вставку и занимают место. Индексы оправданы для точечных выборок по высокоселективному условию и для таблиц с частыми обновлениями (heap). Вместо индексов роль играют дистрибуция, партиции, колоночное хранение и сжатие. Bitmap-индексы для колонок с малым числом значений встречаются, B-tree редко.
Блокировки согласуют доступ транзакций к таблицам. Длинный SELECT может задержать TRUNCATE и DDL, которым нужна блокировка ACCESS EXCLUSIVE; у разных форм ALTER TABLE требования отличаются. AO-таблицы поддерживают UPDATE и DELETE, но режим блокировок и совместимость с параллельной загрузкой проверяют для конкретной версии Greenplum. Сначала смотрят pg_locks, сессии и цепочку ожидания. Для массовой замены периода можно подготовить новую таблицу или партицию, проверить данные и выбрать способ переключения с учётом зависимостей и блокировок. В Greenplum 7 ATTACH PARTITION и legacy EXCHANGE PARTITION требуют разных блокировок. Небольшое обновление и пересборка периода имеют разную стоимость; универсального запрета на UPDATE в AO нет.
Массовый UPDATE/DELETE на AO-таблицах порождает скрытые строки и требует VACUUM, а на больших таблицах занимает часы и держит блокировки. Поэтому обновления делают заменой: пересобрать партицию или таблицу запросом (CREATE TABLE AS ... DISTRIBUTED BY), затем ALTER TABLE ... EXCHANGE PARTITION или переименование в одной транзакции. Удаление периода это DROP PARTITION. Для SCD2 закрытие версий это редкий точечный UPDATE на heap-таблице или перезапись сателлита.
Heap-таблица живёт по правилам PostgreSQL: UPDATE пишет новую версию строки, старая остаётся мёртвой до VACUUM, таблица «раздувается». AO-таблица никогда не перезаписывает данные, а только дописывает блоки, поэтому её можно сжимать (zlib, zstd, RLE) и хранить поколоночно. UPDATE и DELETE в ней не трогают строки, а помечают их в отдельной битовой карте видимости; каждая такая операция раздувает карту, а VACUUM должен перестроить сжатые блоки и карту целиком, что заметно тяжелее heap. Одиночные INSERT тоже вредны: каждый создаёт накладные структуры, AO рассчитана на пачки. Отсюда правило: справочники и часто меняющиеся таблицы в heap, большие таблицы фактов с пакетной загрузкой в AO (чаще column-oriented), а изменения в них делать перезаливкой партиции.
Как в PostgreSQL: B-tree по умолчанию, bitmap для колонок с умеренной кардинальностью (сотни и тысячи значений, хорошо сочетается с AND/OR по нескольким колонкам), GIN и GiST для полнотекстового поиска и геоданных, BRIN для больших таблиц, отсортированных по времени. Особенность MPP: уникальный индекс и первичный ключ возможны только если колонки индекса включают ключ дистрибуции, иначе сегмент не может проверить уникальность, потому что дубликат может лежать на другом сегменте. Уникальность в AO-таблицах не поддерживается вовсе. На аналитической нагрузке индексы используют редко: план и так читает большие диапазоны, а индекс замедляет загрузку и занимает место.
Семейство MergeTree это основа: MergeTree для сырых событий, ReplacingMergeTree для дедупликации по ключу сортировки с колонкой версии, SummingMergeTree и AggregatingMergeTree для предагрегатов, CollapsingMergeTree/VersionedCollapsingMergeTree для отмены строк, Replicated* варианты для репликации, Distributed как прокси над шардами. Остальные: Log-движки для мелких таблиц, Memory, Kafka и S3 для интеграций, MaterializedView. Выбор: нужна ли дедубликация или свёртка при слиянии, нужна ли репликация, какой ключ сортировки под фильтры.
Первичный ключ не гарантирует уникальность: это разреженный индекс по ключу сортировки, одна засечка на index_granularity строк (8192 по умолчанию). Запрос с фильтром по префиксу ключа пропускает блоки, которые точно не подходят. Поэтому ORDER BY выбирают под самые частые фильтры: сначала низкокардинальные колонки (источник, тип), потом дата или id. Дополнительно есть индексы пропуска данных (minmax, set, bloom filter, ngrambf) для колонок вне ключа и проекции. Партиционирование (обычно по месяцу) отсекает целые части.
Это триггер на вставку: когда в исходную таблицу приходит блок строк, запрос представления применяется к этому блоку, и результат записывается в целевую таблицу (явную TO table или внутреннюю). Старые данные не пересчитываются, только новые вставки. Так строят агрегаты на лету (AggregatingMergeTree как цель) и ветвят поток из Kafka-движка в несколько таблиц. Подводные камни: представление не видит ALTER и удаления в источнике, при ошибке в запросе ломается вставка в источник.
ReplacingMergeTree при фоновом слиянии кусков оставляет одну строку на ключ сортировки (последнюю или с максимальной версией). Слияние происходит в фоне и в неизвестный момент, поэтому дубли в таблице есть всегда, пока куски не слились, и SELECT без FINAL их видит. Принудительно: OPTIMIZE TABLE ... FINAL (дорого, целиком), SELECT ... FINAL (дедупликация на чтении) или argMax/GROUP BY по ключу в запросе. В витринах обычно сочетают: вставка с версией, чтение с FINAL или через агрегат по ключу.
Колоночное хранение: запрос читает только нужные колонки, а однотипные значения рядом отлично сжимаются. Векторная обработка: данные обрабатываются блоками по десятки тысяч строк с SIMD-инструкциями, а не построчно. Сортировка по ключу и разреженный первичный индекс: похожие данные лежат рядом, ненужные гранулы пропускаются целиком. Параллелизм на всех ядрах и всех шардах. И отказ от того, что дорого в OLTP: нет транзакций в классическом смысле, нет дешёвого UPDATE и DELETE, нет построчных индексов. Поэтому ClickHouse хорош для аналитики и плох для точечных изменений.
Партиция это логическая группа кусков данных по выражению, обычно по месяцу. Она даёт дешёвые операции над диапазоном: DROP PARTITION, перенос по TTL, замена партиции при перезаливке, и позволяет запросу с условием по ключу партиции не трогать остальные. Но куски разных партиций никогда не сливаются между собой, поэтому партиционирование по дню или по клиенту плодит тысячи мелких кусков: вставки замедляются, фоновые слияния не успевают, запросы читают много файлов. Правило: партиций десятки, редко сотни; для отсечения по фильтру использовать ключ сортировки, а не партиции.
ORDER BY определяет физический порядок строк внутри куска: от него зависят скорость фильтров и GROUP BY и сжатие. PRIMARY KEY задаёт колонки разреженного индекса и по умолчанию равен ключу сортировки, но может быть его префиксом: ORDER BY (a, b, c) PRIMARY KEY (a, b) экономит память индекса, когда по c фильтруют редко. Обратное невозможно: первичный ключ обязан быть префиксом сортировки. Уникальность ни тот ни другой не гарантирует, дубли по ключу в ClickHouse нормальное явление.
Данные в куске разбиты на гранулы по 8192 строки (index_granularity), а первичный индекс хранит значения ключа только на границах гранул, поэтому помещается в память даже для миллиардов строк. При запросе ClickHouse по условию на первые колонки ключа находит диапазон гранул, которые могут содержать подходящие строки, и читает только их: это и есть пропуск гранул. Условие по колонке, которой нет в начале ключа, индексу не помогает: читаются все гранулы, хотя и только нужные колонки. Для таких колонок есть индексы пропуска данных (minmax, set, bloom_filter), которые отсекают гранулы по статистике.
Репликация делается на уровне таблицы движками Replicated*MergeTree: реплики одного шарда синхронизируют куски через ClickHouse Keeper (или ZooKeeper), который хранит метаданные и очередь репликации. Схема мульти-мастер: писать можно в любую реплику, вставки дедуплицируются по хешу блока. Шардирование это разрезание таблицы по серверам: каждый шард хранит свою часть, а поверх локальных таблиц создаётся таблица с движком Distributed, которая не хранит данных, раскладывает INSERT по ключу шардирования и рассылает SELECT на все шарды, собирая результат на инициаторе. Типовые схемы: большие таблицы фактов шардированы и реплицированы, маленькие справочники реплицированы целиком на каждый сервер, чтобы соединения были локальными.
Нет. В Greenplum мастер хранит каталог, строит план и раздаёт его сегментам, это единая точка входа и отказа (её прикрывает standby). ClickHouse децентрализован: каждый узел хранит полные метаданные о своих таблицах и о кластере, любой узел может принять запрос и стать инициатором, который рассылает его по шардам через Distributed-таблицу и объединяет результат. Координация реплик живёт в Keeper, но он не исполняет запросы. Поэтому «мастером» в ClickHouse иногда называют узел-инициатор конкретного запроса, а не архитектурный элемент.
Правило автоматического удаления или перемещения устаревших данных, заданное выражением от колонки времени: TTL created_at + INTERVAL 1 YEAR DELETE на уровне строк, TTL ... TO DISK 'cold' или TO VOLUME для переноса на дешёвое хранилище, TTL на колонку обнуляет её значение до значения по умолчанию. Срабатывает в фоне во время слияний, поэтому данные исчезают не мгновенно, а в пределах настройки merge_with_ttl_timeout. Это основной способ держать таблицу логов или событий в заданном объёме без ручных DROP PARTITION.
Каждый INSERT создаёт на диске новый кусок: каталог с файлами колонок, отсортированный по ключу, с собственным индексом. Читать сотни кусков дорого, поэтому фоновый процесс сливает мелкие куски одной партиции в крупные (отсюда название MergeTree), попутно применяя логику движка: схлопывание дублей в ReplacingMergeTree, суммирование в SummingMergeTree, TTL. Состояние кусков видно в system.parts, а слишком большое число активных кусков (parts_to_throw_insert) заставляет ClickHouse отклонять вставки.
EXPLAIN (то же, что EXPLAIN PLAN) выводит дерево шагов запроса: чтение, фильтры, агрегации, соединения. EXPLAIN PIPELINE показывает процессоры и число потоков, полезно, когда запрос не параллелится. EXPLAIN SYNTAX печатает запрос после переписывания оптимизатором. EXPLAIN PLAN actions=1 раскрывает выражения внутри шагов, indexes=1 показывает, сколько партиций и гранул отсёк первичный индекс и индексы пропуска: это главный инструмент проверки, что ключ сортировки работает. EXPLAIN ESTIMATE оценивает число строк и кусков без выполнения.
system.query_log с временем, прочитанными байтами и памятью каждого запроса, основной источник для разбора медленных запросов. system.processes (SHOW PROCESSLIST) показывает, что выполняется сейчас. system.parts даёт размер и число кусков по партициям, system.merges и system.mutations показывают фоновые слияния и мутации, system.replicas и system.replication_queue состояние репликации, system.clusters конфигурацию кластера, system.errors счётчики ошибок. Структуру таблицы показывает DESCRIBE TABLE или system.columns.
uniqExact считает точно, но держит в памяти все значения. uniq и uniqCombined считают приближённо (HyperLogLog с ошибкой порядка процента) на фиксированной памяти и подходят для дашбордов. Для инкрементальных агрегатов используют комбинаторы: материализованное представление на AggregatingMergeTree хранит uniqState(user_id) по дням, а запрос делает uniqMerge(users); промежуточные состояния можно объединять по любым периодам, что невозможно с готовыми числами.
В ClickHouse обновления заменяют на вставку новой версии строки: ReplacingMergeTree по бизнес-ключу с колонкой версии (время обновления), чтение с FINAL или через argMax. Либо перезапись партиции: витрина партиционирована по дню, инкремент пересчитывает дни с изменениями и заменяет партиции (ALTER TABLE ... REPLACE PARTITION из временной таблицы). Дубли при insert-only неизбежны до слияния, поэтому потребители читают через FINAL/агрегат, а для отчётов берут материализованные агрегаты.
ClickHouse даёт секунды на агрегатах по миллиардам строк, что нужно дашбордам, и дёшево хранит события благодаря сжатию. Обновление по расписанию из оркестратора: инкремент по дате или ключу с перезаписью партиций, полный пересчёт для маленьких витрин. Триггером служат только материализованные представления на вставку, когда данные идут потоком (Kafka → MV → агрегат). Инкремент считают по бизнес-полю изменения (дата обновления строки в источнике), а не по дате события, с перезаписью хвоста в несколько дней под опоздавшие данные.
Источник пишет события в Kafka (часто через Debezium как CDC из базы). Дальше два пути: Kafka Connect с ClickHouse-коннектором пишет в таблицу батчами, либо таблица с движком Kafka внутри ClickHouse читает топик, а материализованное представление перекладывает строки в MergeTree и параллельно в агрегаты. Гарантия доставки «хотя бы раз», поэтому целевая таблица ReplacingMergeTree по ключу события. Мониторить лаг консьюмера и отставание слияний.
Каждый INSERT создаёт отдельный кусок на диске, а слияния идут в фоне ограниченным числом потоков. Поток вставок по одной строке в секунду плодит тысячи кусков: файловая система захлёбывается, слияния отстают, SELECT читает сотни файлов, а при превышении parts_to_throw_insert вставки начинают падать с «Too many parts». Решения: собирать пачки от десятков тысяч строк на стороне приложения или в Kafka, использовать async_insert, который буферизует вставки на сервере, или таблицу с движком Buffer перед целевой. Для массовой загрузки выгодны бинарные форматы Native и RowBinary или простые текстовые TabSeparated, CSV, JSONEachRow через INSERT ... FORMAT.
Через мутации ALTER TABLE ... UPDATE и ALTER TABLE ... DELETE: они выполняются асинхронно, переписывают целиком затронутые куски и не предназначены для частых операций. Лёгкие варианты: DELETE FROM в новых версиях помечает строки маской и доделывает удаление при слиянии, DROP PARTITION и REPLACE PARTITION для перезаливки периода. Для регулярных изменений меняют схему данных: ReplacingMergeTree с версией и FINAL или argMax в запросе, CollapsingMergeTree со знаком, либо версионирование на стороне загрузчика. Правило: ClickHouse рассчитан на вставку и чтение, изменения планируют как перезапись блоков, а не точечные правки.
Движок распределённых вычислений: драйвер строит план из операций над DataFrame, разбивает его на стадии и задачи, менеджер кластера (YARN, Kubernetes, standalone) выделяет исполнители, каждый исполнитель обрабатывает свои партиции данных в памяти. Ленивые трансформации, выполнение по действию (write, count, collect), стадии разделяются shuffle. Catalyst оптимизирует план, Tungsten отвечает за память и кодогенерацию.
MapReduce записывает результат каждого шага на диск и строит задачу из жёсткой пары map/reduce, цепочка шагов это несколько проходов по диску. Spark держит промежуточные данные в памяти, строит DAG из многих операций и оптимизирует его целиком, поддерживает SQL, стриминг и ML в одном API. На практике Spark быстрее в разы и проще в разработке; MapReduce остался в легаси.
RDD это низкоуровневая распределённая коллекция объектов с функциональным API (map, reduceByKey), без схемы и без оптимизатора: Spark не знает, что внутри. DataFrame это таблица со схемой, операции выражаются декларативно (SQL или API), Catalyst оптимизирует план, Tungsten хранит данные компактно вне кучи JVM. В PySpark DataFrame ещё и быстрее RDD, потому что обработка идёт в JVM без сериализации в Python. RDD нужен только для нетипичной логики.
local[n] всё в одном процессе на машине разработчика. Standalone это собственный простой менеджер кластера Spark (master и workers) без YARN и Kubernetes. Режим деплоя client держит драйвер там, где запущен spark-submit (ноутбук, Airflow-воркер), cluster запускает драйвер внутри кластера; для продовых задач нужен cluster, чтобы падение клиентской машины не убивало job, а для интерактивной работы client.
row_number() без партиции собирает все данные в одну партицию на одном исполнителе: миллиард строк упадёт или будет считаться часами. Правильно: monotonically_increasing_id() даёт уникальные, но не сплошные номера (номер партиции в старших битах). Для сплошной нумерации: посчитать число строк в каждой партиции, вычислить смещение каждой партиции накопительной суммой, и к номеру внутри партиции (row_number по партиции или zipWithIndex) прибавить смещение. Это один проход плюс маленькая таблица смещений.
SparkSubmitOperator (или BashOperator с spark-submit) в режиме cluster, параметры конфигурации и дата периода передаются аргументами, зависимости по данным через сенсоры. Задача Airflow только запускает и ждёт завершения, код job живёт в репозитории и собирается в артефакт (jar, wheel, образ). Логи и статус в Spark UI/History Server, в Airflow ретраи и алерты. В Kubernetes вместо spark-submit часто SparkKubernetesOperator.
Узкие (map, filter, union, withColumn): каждая партиция результата зависит от одной партиции родителя, данные между узлами не ходят, операции конвейеризуются в один этап. Широкие (groupBy, join, distinct, repartition, оконные функции): партиция результата собирается из многих партиций родителя, поэтому нужен shuffle, запись промежуточных файлов и передача по сети. Граница между этапами (stage) в плане проходит ровно по широким трансформациям, и именно их число и объём определяют время джобы.
Запрос DataFrame или SQL проходит четыре фазы. Анализ: разрешение имён колонок и таблиц через каталог, проверка типов, получается логический план. Логическая оптимизация по правилам: predicate pushdown, column pruning, свёртка констант, упрощение выражений. Физическое планирование: для одного логического плана строится несколько физических (разные стратегии соединения) и выбирается лучший по стоимости. Кодогенерация: Tungsten генерирует байткод JVM для целого этапа (whole-stage codegen), чтобы не интерпретировать дерево выражений для каждой строки. Увидеть это можно через explain(True).
RDD хранит обычные объекты JVM: Spark не знает их структуру, не может оптимизировать операции, тратит память на заголовки объектов и нагружает сборщик мусора, а при shuffle сериализует через Java-сериализацию или Kryo. DataFrame имеет схему, поэтому Tungsten хранит строки в компактном бинарном формате в off-heap памяти, работает с ними без десериализации, использует кеш процессора эффективнее и генерирует специализированный код. Плюс Catalyst оптимизирует план целиком. Поэтому RDD оставляют для нетабличных данных и кастомной логики, а всё табличное пишут на DataFrame.
AQE перестраивает план во время выполнения по статистике, собранной после завершения shuffle-этапов. Три главные оптимизации: объединение мелких shuffle-партиций в более крупные, чтобы не плодить тысячи мелких задач; переключение sort-merge join на broadcast join, если одна сторона после фильтрации оказалась маленькой; разбиение перекошенных партиций при соединении на части. Включается spark.sql.adaptive.enabled=true (по умолчанию с 3.2) и снимает значительную часть ручной настройки spark.sql.shuffle.partitions.
При spark.speculation=true Spark замечает задачи, которые выполняются заметно дольше остальных в этапе, и запускает их копии на других узлах; побеждает та, что закончится первой, вторая убивается. Помогает против медленных узлов: больной диск, шумный сосед, проблемная сеть, особенно в облаке. Не помогает, если задача медленная из-за перекоса данных: копия будет обрабатывать ту же большую партицию. И может навредить, если задача делает побочные эффекты, которые нельзя повторять.
Сериализация переводит объекты в байты для shuffle, кэширования с сериализацией и передачи broadcast-переменных. Java-сериализация по умолчанию медленная и многословная, Kryo в разы быстрее и компактнее, включается spark.serializer=org.apache.spark.serializer.KryoSerializer, классы можно зарегистрировать заранее для экономии места. Для DataFrame без UDF это почти не важно: Tungsten хранит данные в своём бинарном формате. Kryo имеет значение для RDD, Dataset с объектами и UDF, возвращающих свои классы.
Через watermark: порог, насколько старые события по event time система ещё принимает в агрегации. Spark следит за максимальным временем события в потоке и вычитает заданную задержку (withWatermark("ts", "10 minutes")); окна старше порога закрываются и их состояние удаляется из state store, а события старше порога отбрасываются. Без watermark потоковая агрегация по окнам держала бы состояние бесконечно и упала бы по памяти. Выбор задержки это компромисс между полнотой данных и объёмом состояния.
Shuffle это пересылка строк между исполнителями так, чтобы строки с одним ключом оказались вместе: при groupBy, join, distinct, repartition, оконных функциях с partitionBy, сортировке. Это самая дорогая операция: запись на диск, сеть, граница стадии. Избежать: broadcast маленькой таблицы в join, предагрегация, партиционирование данных по ключу соединения при записи (bucketing), фильтры до соединения, меньше distinct. repartition(n) всегда делает shuffle и выравнивает партиции; coalesce(n) только склеивает партиции на том же исполнителе без shuffle, поэтому может дать перекос; shuffle при coalesce возникает, если число партиций увеличивают (coalesce с shuffle=true или repartition).
Broadcast Hash Join: маленькая сторона рассылается на все исполнители, без shuffle большой; Shuffle Hash Join: обе стороны перераспределяются по ключу, на каждой партиции строится хеш по меньшей; Sort Merge Join: обе стороны перераспределяются и сортируются, по умолчанию для больших таблиц; Broadcast Nested Loop для неравенств. Оптимизатор выбирает по оценке размера: если сторона меньше spark.sql.autoBroadcastJoinThreshold (10 МБ), broadcast. SortMerge вместо broadcast выбирается, когда оценка размера больше порога, статистики нет или таблица после фильтра кажется большой; лечится подсказкой broadcast(df) или повышением порога, если память исполнителей позволяет. Бродкастить ту сторону, которая меньше после фильтров и помещается в память каждого исполнителя.
Узкая операция обрабатывает партицию независимо (filter, map), широкая требует данных из многих партиций и порождает shuffle (join, groupBy). При соединении двух больших таблиц Spark считает хеш ключа, перераспределяет обе таблицы так, чтобы одинаковые ключи попали в одну партицию, сортирует партиции по ключу и сливает их (Sort Merge Join). Отсюда стоимость: две записи на диск и пересылка по сети, и отсюда же уязвимость к перекосу ключа.
Несколько ключей встречаются в разы чаще остальных, после shuffle их партиции огромны, и одна задача работает, пока остальные ждут; в UI видно по разбросу времени и объёма задач в стадии и по «хвостовой» задаче. Лечение: AQE (spark.sql.adaptive.skewJoin.enabled) сам режет перекошенные партиции; «соль» к ключу на большой стороне и размножение маленькой на число солей; broadcast маленькой стороны; вынос и отдельная обработка NULL-ключей и топ-ключей; предагрегация.
Открыть Spark UI: найти стадию, посмотреть распределение задач (перекос), объём shuffle read/write, spill, GC. Посмотреть физический план (explain): какой join выбран, есть ли лишний Exchange, не перепутан ли broadcast большой таблицы. Типовые причины: перекос ключа, SortMergeJoin вместо broadcast из-за отсутствия статистики, слишком мало партиций после shuffle (spark.sql.shuffle.partitions), взрыв строк из-за неуникального ключа, маленькие файлы на входе.
repartition(n) или repartition(col) делает полный shuffle и раскладывает данные равномерно, им можно увеличить число партиций или разложить данные по ключу перед записью. coalesce(n) только уменьшает число партиций, склеивая соседние на тех же узлах без shuffle, поэтому дешевле, но даёт неравномерные партиции и, если применить слишком рано, сокращает параллелизм всего предыдущего этапа. Типичный приём: перед записью coalesce до разумного числа файлов, а при перекосе или увеличении параллелизма repartition.
Маленькая таблица собирается на драйвере и рассылается в память каждого исполнителя, после чего соединение идёт локально без shuffle. Это ломается, если «маленькая» таблица превышает spark.sql.autoBroadcastJoinThreshold (10 МБ по умолчанию) и оптимизатор выбирает sort-merge, или если её принудительно broadcast-ят хинтом, а она не помещается в память драйвера или исполнителей: получается OOM на драйвере при сборе или долгие сборки мусора на исполнителях. Размер оценивается по статистике и после фильтра может быть неизвестен, поэтому помогает ANALYZE TABLE, AQE, который переключает стратегию по фактическому размеру, и ручной хинт только для таблиц, чей размер известен.
Партиционирование при записи раскладывает данные по каталогам по значению колонки (year=2024/month=05): подходит для колонок с малым числом значений, читатель пропускает ненужные каталоги (partition pruning). Бакетирование делит данные внутри партиции на фиксированное число файлов по хешу колонки с высокой кардинальностью (user_id): две таблицы, бакетированные по одному ключу и числу бакетов, соединяются без shuffle, агрегация по ключу тоже обходится без него. Бакетирование работает только для таблиц в каталоге (Hive, Spark) и требует, чтобы писатель и читатель были Spark; партиции понимают все движки.
Оптимизация Spark 3 для соединения большой партиционированной таблицы фактов с небольшим измерением, на которое наложен фильтр. Без неё Spark читал бы все партиции фактов, потому что условие стоит на измерении. С DPP Spark сначала вычисляет отфильтрованные ключи измерения (обычно через broadcast), а затем подставляет их как фильтр партиций при чтении фактов, читая только нужные каталоги. Работает, когда ключ соединения совпадает с колонкой партиционирования фактов.
Меньше данных в shuffle: фильтровать и отбирать колонки до join и groupBy, предпочитать агрегации с локальной предредукцией (reduceByKey вместо groupByKey для RDD, обычные агрегаты DataFrame уже так работают). Правильное число партиций: spark.sql.shuffle.partitions по умолчанию 200, его подбирают под объём, целясь в партиции по 100–200 МБ, или отдают AQE. Избегать shuffle целиком: broadcast join для маленькой стороны, бакетирование для повторяющихся соединений по одному ключу, repartition по ключу один раз перед несколькими операциями. И следить за перекосом, потому что один тяжёлый ключ сводит на нет остальное.
Память исполнителя (spark.executor.memory) делится на резерв (300 МБ), пользовательскую часть под объекты кода и унифицированную область (spark.memory.fraction, 60%), внутри которой execution (shuffle, join, сортировка) и storage (кэш) берут память друг у друга; плюс память вне кучи (memoryOverhead, важна для PySpark). Расчёт: оценить объём данных после фильтров и число партиций, взять 2–5 ядер на исполнитель, память так, чтобы партиция помещалась с запасом, число исполнителей по параллелизму и квоте очереди; включить динамическую аллокацию, чтобы не держать ресурсы во время простоя. Проверять в UI spill и GC и корректировать.
Без кэша каждая ветка пересчитает DataFrame с нуля. cache() / persist(level) сохраняет результат после первого действия. Смотреть: помещается ли он в память (MEMORY_AND_DISK безопаснее MEMORY_ONLY, иначе часть партиций вытеснится и пересчитается), не держит ли кэш память, нужную для shuffle, вызван ли unpersist после использования. Кэшировать после фильтров, а не до; для больших промежуточных результатов надёжнее записать в Parquet и читать заново.
Фильтры и выбор колонок до соединений (pushdown в Parquet), broadcast маленьких таблиц, борьба с перекосом (AQE, соль), правильное число партиций (shuffle.partitions, repartition перед записью, чтобы не плодить маленькие файлы), кэш переиспользуемых DataFrame, избегание UDF на Python в пользу встроенных функций, партиционирование данных по дате при записи, динамическая аллокация. В UI: длительность стадий, разброс задач, shuffle read/write, spill, GC time, план с Exchange.
Режим, в котором Spark запрашивает исполнители у менеджера кластера по мере появления задач в очереди и освобождает простаивающие после таймаута. Нужен внешний shuffle service или его аналог, чтобы данные shuffle пережили удаление исполнителя. Плюс: не держать ресурсы в простое, лучше делить кластер; минус: задержка на запуск исполнителей и непредсказуемое время при перегруженном кластере.
Одно Spark-приложение работает в одном кластере; «мультикластер» на практике означает либо запуск джобов на нескольких кластерах через оркестратор (Airflow с разными подключениями), либо чтение данных из разных источников (HDFS одного кластера, S3 другого) одним приложением, либо федерацию через Kubernetes. Честный ответ: уточнить, что имеется в виду, и описать маршрутизацию задач по кластерам оркестратором с общим хранилищем данных в S3.
collect() или toPandas() на большом DataFrame тянут все данные в память драйвера; broadcast слишком большой таблицы собирает её на драйвере перед рассылкой; очень большой план или метаданные (десятки тысяч партиций, тысячи колонок, глубокая цепочка трансформаций в цикле) раздувают память драйвера. Избегать: take, limit, запись результата в хранилище вместо сбора, контроль размера broadcast, checkpoint для обрезания длинного плана, увеличение spark.driver.memory только когда причина понятна.
Перекос данных: одна партиция после shuffle не помещается в память, особенно при оконных функциях и collect_list по тяжёлому ключу. Слишком много ядер на исполнителе: каждая задача получает долю памяти, при 10 ядрах на 20 ГБ на задачу остаётся мало. Большие объекты в UDF, сложные вложенные структуры, кэширование десериализованных RDD. Лечат увеличением числа партиций, чтобы каждая стала меньше, борьбой с перекосом, разумным числом ядер (обычно до пяти), spark.executor.memoryOverhead для off-heap и Python-процессов при PySpark, и пересмотром кэша.
Когда execution-памяти исполнителя не хватает для сортировки, агрегации или хеш-таблицы соединения, Spark сбрасывает часть промежуточных данных на локальный диск и потом читает обратно. Это видно в Spark UI как Spill (Memory) и Spill (Disk) у этапа, и это один из главных признаков медленной джобы. Уменьшают его увеличением памяти на задачу (больше spark.executor.memory или меньше ядер), увеличением числа партиций, чтобы каждая обрабатывалась меньшей порцией, заменой sort-merge join на broadcast, устранением перекоса и удалением лишних колонок до тяжёлых операций.
cache() и persist(level) сохраняют данные в памяти или на локальном диске исполнителей, но цепочка трансформаций (lineage) остаётся: при потере исполнителя партиция пересчитывается заново. checkpoint() записывает данные в надёжное хранилище (HDFS, S3) и обрезает lineage: дальнейший план начинается с этих данных как с нового источника. Нужен для итеративных алгоритмов и длинных циклов, где план разрастается и драйвер тратит память и время на его анализ, а также для Structured Streaming, где checkpoint хранит смещения и состояние для восстановления после сбоя.
Parquet колоночный бинарный формат со схемой и сжатием: файл делится на row groups, внутри каждой колонка хранится отдельным chunk со статистикой (min/max, число null), словарным кодированием и сжатием. Движок читает только нужные колонки и пропускает row groups по статистике. CSV это текст: нет типов, нет сжатия по колонкам, читать нужно целиком и парсить каждое значение; он нужен только для обмена с людьми и системами, которые ничего другого не понимают. Parquet обычно в 5–10 раз меньше CSV и в разы быстрее для аналитики.
Spark режет вход на сплиты размером spark.sql.files.maxPartitionBytes (128 МБ по умолчанию) с учётом границ row group и файлов: примерно сумма размеров файлов / 128 МБ, маленькие файлы объединяются до openCostInBytes. Число row group влияет на границы: row group не делится между задачами. Поэтому много мелких файлов и мелкие row groups дают много задач с накладными расходами, а один огромный row group не параллелится.
Каждый файл это как минимум одна задача и обращение к метаданным: тысячи файлов по мегабайту дают тысячи коротких задач, планирование и открытие файлов занимает больше времени, чем чтение. На HDFS мелкие файлы ещё и съедают память NameNode. Лечение: при записи repartition/coalesce до разумного числа файлов (100–500 МБ каждый), компакция существующих данных отдельной задачей, в Iceberg/Delta встроенная компакция. В S3 проблема мягче (нет NameNode), но накладные расходы на запросы к объектам остаются.
Оба колоночные со сжатием и статистикой. ORC родом из Hive: stripe вместо row group, встроенные индексы по строкам и bloom-фильтры, лучше интеграция с Hive и ACID-транзакциями Hive. Parquet родом из экосистемы Hadoop/Twitter, стал стандартом де-факто для Spark, Arrow, Iceberg, DuckDB и облачных сервисов. На практике выбирают Parquet, кроме случаев, когда хранилище живёт в Hive.
Текстовые: CSV, JSON, JSON Lines (просто, без схемы, большие). Строковые бинарные: Avro (схема, эволюция схемы, хорош для Kafka и построчной записи). Колоночные: Parquet, ORC (аналитика, сжатие, чтение нужных колонок). Табличные форматы поверх файлов: Iceberg, Delta, Hudi (транзакции, версии, схема). Колоночные выигрывают на аналитике за счёт чтения части колонок, сжатия однотипных данных и пропуска блоков по статистике, проигрывают на построчных записях и обновлениях.
Оптимизатор спускает условия фильтра к источнику данных, чтобы они применились при чтении, а не после загрузки в память. Для баз данных через JDBC фильтр уходит в SQL-запрос. Для Parquet и ORC работает статистика: каждая группа строк хранит min и max по колонкам, и если условие не попадает в диапазон, группа пропускается без чтения с диска; вместе с отбором только нужных колонок это и даёт колоночным форматам преимущество. Для CSV и JSON pushdown не работает: файл всё равно читается и разбирается целиком. Pushdown не срабатывает, если колонка обёрнута в функцию или UDF, поэтому фильтры стоит писать по голым колонкам.
Экосистема для хранения и обработки больших данных на кластере обычных машин: HDFS (распределённая файловая система), YARN (менеджер ресурсов), MapReduce (исходный движок обработки), плюс надстройки: Hive (SQL поверх файлов), Spark, HBase, Sqoop (импорт из баз), Oozie/Airflow для оркестрации. Сегодня из этого набора чаще всего живут HDFS или S3 как хранилище, YARN или Kubernetes как ресурсы и Spark как движок.
Файл делится на блоки по 128 МБ (настраивается), каждый блок реплицируется на три DataNode (фактор репликации 3). Файл в 100 МБ займёт один блок меньше полного размера, блок займёт только 100 МБ на диске, но три копии. Клиент получает от NameNode список узлов, пишет первую копию, DataNode передают блок дальше по конвейеру. NameNode хранит метаданные в памяти: имя файла, права, список блоков и их расположение на узлах; сами данные через NameNode не проходят. Репликация даёт отказоустойчивость и локальность чтения: задача запускается там, где лежит блок.
В HDFS каждый файл и блок это запись в памяти NameNode (около 150 байт): миллионы мелких файлов исчерпывают память единственного NameNode и замедляют операции с метаданными, а каждый файл занимает отдельный блок и задачу при чтении. В S3 нет NameNode и лимита на число объектов, поэтому система не ломается, но остаются накладные расходы: листинг миллионов объектов медленный, каждый объект это отдельный запрос с задержкой, чтение мелких файлов Spark'ом дорогое. Лечение общее: компакция в файлы по 100–500 МБ и партиционирование без лишней детализации.
HDFS: блоки на дисках узлов кластера, локальность данных, NameNode как единая точка метаданных, пропускная способность кластера, ограничение по числу файлов. S3: объектное хранилище, отделённое от вычислений, масштабируется бесконечно и дёшево, нет локальности, выше задержка на запрос, нет переименования папок (переименование это копирование), листинг дорогой, итоговая согласованность исторически. Сейчас стандарт: S3 как хранилище, Spark/Trino как вычисления, Iceberg как слой таблиц. S3 против PostgreSQL: S3 хранит файлы без схемы, транзакций и индексов, дёшево и много; база хранит структурированные данные с запросами, ограничениями и транзакциями, дорого и ограниченно по объёму.
Hive даёт SQL поверх файлов в HDFS/S3 и каталог таблиц (Hive Metastore), который используют и Spark, и Trino. Эффективен для пакетной обработки больших таблиц по расписанию, где задержка не важна; для интерактивных запросов его сменили Spark, Impala, Trino. Метаданные Hive это таблица и её партиции как пути к папкам, список файлов определяется листингом директории при запросе. Iceberg хранит метаданные в самих файлах: snapshot → manifest list → manifest с перечнем файлов данных и статистикой по колонкам, поэтому планирование не требует листинга, видны версии и возможны атомарные изменения.
Префиксы по слою и источнику с партициями по дате: raw/<источник>/<таблица>/dt=2025-04-01/part-0001.parquet, отдельно staging, marts, quarantine. Это даёт отсечение по дате при чтении Spark/Trino, понятные права по префиксам и политики жизненного цикла (удалить raw старше года). Файлы в корень без префиксов работают, но листинг и права становятся неуправляемыми. Теги на бакетах и объектах нужны для учёта затрат и политик. Каталог (Glue, Hive Metastore, OpenMetadata) делает файлы таблицами с схемой и lineage; без него озеро превращается в свалку.
Ориентир: файлы по 100–500 МБ, партиции по дню, число объектов в партиции единицы или десятки. Тысячи мелких JSON от API сохраняют в raw как есть (это журнал), но на следующем слое компактят в Parquet. Регулярность задаёт пайплайн: одна загрузка в сутки это одна партиция; стриминг пишет часто и требует периодической компакции. Мониторить число объектов и суммарный размер по префиксам.
Iceberg это табличный формат поверх файлов в озере (Parquet в S3/HDFS): таблица с схемой, атомарными транзакциями, версиями (снимками), эволюцией схемы и партиций, скрытым партиционированием и возможностью обновлять и удалять строки. Trino (бывший Presto) это распределённый SQL-движок, который выполняет запросы к Iceberg, Hive, Postgres, Kafka и другим источникам без копирования данных, с интерактивной задержкой. Вместе они дают lakehouse: дешёвое хранение в S3 и SQL поверх него.
Три уровня. Файл метаданных таблицы (metadata.json): схема, спецификация партиций, список снимков и текущий снимок. Manifest list на снимок: список manifest-файлов со статистикой по партициям. Manifest: список файлов данных с их партициями, числом строк и min/max по колонкам. Каталог (Hive Metastore, Glue, REST, Nessie) хранит только указатель на текущий metadata.json, и подмена указателя это атомарный коммит.
dbt это инструмент трансформаций внутри хранилища (ELT): модели как SQL-файлы с SELECT, dbt сам создаёт таблицы и представления, строит граф зависимостей по ref(), запускает в правильном порядке, тестирует (unique, not_null, relationships), документирует. Lineage получается из ref() автоматически: граф моделей от источников до витрин, видно, что сломается при изменении таблицы, и можно пересчитать только зависимые модели.
Компоненты: веб-сервер (интерфейс и API), планировщик (scheduler), который разбирает файлы DAG, создаёт запуски по расписанию и ставит задачи в очередь, исполнители (executor: Local, Celery, Kubernetes) и воркеры, которые выполняют задачи, база метаданных (состояния запусков, XCom, соединения), триггерер для отложенных операторов. DAG это Python-файл с объектом DAG (id, расписание, start_date, параметры по умолчанию) и задачами (операторами), связанными зависимостями >>; граф должен быть ацикличным.
Планировщик при разборе файла проверит граф и отклонит DAG с ошибкой Cycle detected: DAG не появится в списке или будет помечен как сломанный, ни одна задача не запустится. Направленный ацикличный граф это требование, а не рекомендация, иначе невозможно определить порядок выполнения.
Расписание задаётся schedule (cron-строка, timedelta, пресеты вроде @daily, или datasets). Запуск с логической датой start_date происходит в конце интервала: при start_date = 16 июля и расписании ежедневно в 10:00 первый запуск произойдёт 17 июля в 10:00 с логической датой 16 июля, потому что интервал данных за 16-е считается завершённым только тогда. Параметр catchup решает, догонять ли пропущенные интервалы. Минимальный интервал формально любой (хоть минута), но на практике ограничен временем разбора DAG и ресурсами планировщика; для частых запусков берут сенсоры с отложенным режимом или стриминг.
dag_id, schedule, start_date, end_date, catchup, default_args (владелец, ретраи, retry_delay, уведомления, depends_on_past), max_active_runs, max_active_tasks, tags, params для ручного запуска, dagrun_timeout, on_failure_callback. В задачах важны retries, pool, priority_weight, trigger_rule, execution_timeout.
Scheduler решает, что и когда запускать, Executor решает, как и где задача будет выполнена: ставит её в очередь, следит за слотами параллелизма и пулами, передаёт воркеру и принимает статус. SequentialExecutor выполняет по одной задаче, работает с SQLite и годится только для разработки. LocalExecutor запускает задачи процессами на той же машине, что и планировщик, нужна PostgreSQL или MySQL. CeleryExecutor раздаёт задачи воркерам через брокер (Redis, RabbitMQ) и масштабируется добавлением воркеров. KubernetesExecutor поднимает под на каждую задачу с нужным образом и ресурсами, ничего не простаивает, но старт задачи дольше. Масштабируют переходом на Celery или Kubernetes, настройками parallelism, max_active_tasks_per_dag, пулами для внешних систем и несколькими планировщиками.
Catchup это параметр DAG: при catchup=True планировщик после включения DAG автоматически создаёт запуски для всех пропущенных интервалов от start_date до текущего момента, строго по порядку. Backfill это ручная команда airflow dags backfill -s ... -e ...: запуски за выбранный период, можно перезапускать уже существующие, ограничиться задачами по regex, игнорировать зависимости. На практике для продовых DAG ставят catchup=False, чтобы случайно не запустить сотни прогонов, а историю догружают backfill-ом осознанно, и пишут задачи идемпотентными по интервалу, чтобы перезапуск за период был безопасен.
Когда готовых задач больше, чем свободных слотов, Executor берёт задачи с большим весом первыми. priority_weight задаёт базовый вес задачи, а weight_rule способ его подсчёта: downstream (по умолчанию) суммирует веса всех задач ниже по графу, поэтому начало длинной цепочки важнее, upstream суммирует веса выше по графу, absolute берёт собственный вес без суммирования, что дешевле для больших DAG. Используют, чтобы критичный пайплайн не ждал в очереди за сотней мелких задач при общем пуле.
Оператор это шаблон задачи: экземпляр оператора в DAG становится задачей. Базовые: PythonOperator и @task для кода, BashOperator, SQLExecuteQueryOperator/PostgresOperator для SQL, SparkSubmitOperator, KubernetesPodOperator для изолированного окружения, EmptyOperator для структуры, BranchPythonOperator для ветвления, TriggerDagRunOperator. Параметры: task_id, retries, pool, trigger_rule, execution_timeout, шаблонизируемые поля с Jinja ({{ ds }}). Честный ответ называет два-три оператора и объясняет, почему SQL в базе выполняется оператором, а не Python-кодом с курсором.
Сенсор это оператор, который ждёт условия: появления файла в S3, партиции в Hive, записи в таблице, завершения задачи в другом DAG (ExternalTaskSensor), времени. Режимы poke (занимает слот воркера) и reschedule (освобождает между проверками), отложенные сенсоры через триггерер. Хук это обёртка над соединением к внешней системе (PostgresHook, S3Hook, HttpHook): берёт реквизиты из Connections и даёт методы; операторы внутри используют хуки, и в своём @task их тоже удобно брать вместо ручных подключений.
XCom передаёт маленькие значения между задачами через базу метаданных: имя файла, число строк, дату; @task возвращает значение, и оно становится XCom автоматически. Для данных XCom не подходит: всё хранится в базе Airflow, большие объёмы её кладут; данные передаются через S3/таблицу, а через XCom только ссылка. Variables это глобальные настройки (пути, лимиты), секреты в Connections или внешнем секрет-бэкенде. Динамические DAG и генерация задач из конфига (expand для dynamic task mapping) это способ не писать сто одинаковых задач руками.
Логическая дата запуска (logical_date, ds) это начало интервала данных, а не момент запуска: задача за 1 апреля запускается 2 апреля, и в код нужно передавать data_interval_start/data_interval_end, а не datetime.now(). Часовые пояса: start_date без tz считается UTC. catchup=True при включении старого DAG запускает все пропущенные интервалы. Изменение start_date или schedule у существующего DAG путает историю запусков. Лечится правилом: вся логика периода берётся из контекста запуска параметром.
Сенсор ждёт события: файла, партиции, ответа API. В режиме poke он занимает слот воркера всё время ожидания и проверяет условие каждые poke_interval секунд: подходит для коротких ожиданий. В режиме reschedule между проверками задача освобождает слот и возвращается в очередь, воркер не занят, но у планировщика больше работы. Deferrable-операторы и сенсоры (.defer()) отдают ожидание отдельному процессу triggerer, который держит тысячи асинхронных ожиданий в одном event loop; воркер не задействован вообще, это правильный выбор для долгих ожиданий. Общие параметры: timeout (по умолчанию семь дней), exponential_backoff, soft_fail.
При истечении timeout сенсор по умолчанию падает в failed, DAG помечается неудачным и приходят алерты. С soft_fail=True задача становится skipped, и зависимые задачи по умолчанию тоже пропускаются, а DAG завершается успешно. Это для необязательных данных: бонусный файл, выгрузка, которой не бывает в выходные, один из нескольких альтернативных источников. Для критичных входов оставляют soft_fail=False, иначе пропуск скроет реальную проблему. Чтобы после пропущенного сенсора всё же выполнить задачу, ей ставят trigger_rule="all_done" или none_failed, а ветвление делают через BranchPythonOperator.
Любое необработанное исключение в python_callable переводит задачу в failed, но принято бросать AirflowException с понятным сообщением: оно попадает в лог и в алерт. При retries > 0 задача сначала уйдёт в up_for_retry и будет перезапущена. AirflowFailException игнорирует retries и сразу помечает задачу проваленной: для случаев, когда повтор бессмыслен, например неверная конфигурация или невалидные входные данные. AirflowSkipException переводит задачу в skipped, когда делать нечего, и это не ошибка.
Dynamic task mapping в Airflow 2.3+: оператор.partial(общие_параметры).expand(параметр=список) создаёт на этапе выполнения по экземпляру задачи на каждый элемент списка, причём список может быть результатом предыдущей задачи через XCom. Так обрабатывают переменный набор файлов, таблиц или дат параллельно без цикла в коде DAG и без пересборки графа. Отличие от генерации DAG кодом: там граф фиксируется при разборе файла, а здесь число задач известно только в рантайме; в UI mapped-задачи видны как одна задача с индексами.
XCom хранится в базе метаданных, поэтому предел зависит от неё: в MySQL это 64 КБ на значение, в PostgreSQL формально до 1 ГБ, в SQLite 2 ГБ. Но XCom задуман для метаданных: путь к файлу, число строк, идентификатор запуска. Таскать через него датафреймы значит нагружать базу планировщика и сериализацию. Для больших объёмов данные кладут в S3 или таблицу и передают только ссылку, либо подключают custom XCom backend, который сам пишет значения в объектное хранилище.
Задачи падают и перезапускаются (retry, backfill, ручной перезапуск), и повторный запуск за тот же интервал должен дать тот же результат без дублей. Обеспечивается: период берётся из контекста запуска, а не из текущего времени; запись заменяет период (DELETE+INSERT в транзакции, замена партиции, upsert по ключу); временные файлы именуются по интервалу; внешние вызовы с ключом идемпотентности. Признак неидемпотентной задачи: INSERT без очистки и now() в коде.
Типичные проблемы и решения: медленный разбор DAG-файлов (тяжёлый код на верхнем уровне файла, обращения к базе при импорте; выносить в задачи, кэшировать конфиг), очередь задач (пулы и приоритеты, больше воркеров, Celery/Kubernetes executor), сенсоры, занимающие слоты (режим reschedule и отложенные операторы), раздутая база метаданных (чистка XCom и логов), один гигантский DAG (разбить, dynamic task mapping), падения из-за зависимостей между DAG (datasets вместо сенсоров).
Пример: DAG загрузки заказов из API в хранилище, ежедневно в 03:00. Задачи: сенсор или проверка доступности источника → выгрузка инкремента за интервал в S3 (raw, JSON Lines) → загрузка в staging Postgres/Greenplum → SQL-преобразование в core с идемпотентной заменой периода → проверки качества → обновление витрины → уведомление. Ретраи на сетевых шагах, пул на подключения к базе, алерт на падение, параметр даты для перезапуска за прошлый день. Роль разработчика: пишет DAG и код, деплой через git и CI; админ Airflow настраивает Connections, пулы, роли, что в маленьких командах делает тот же инженер.
retries и retry_delay задаются в default_args на весь DAG или у конкретной задачи, где переопределяют общие; retry_exponential_backoff и max_retry_delay разводят повторы по времени. Перезапускается только упавшая задача, не весь DAG. На события вешают колбэки: on_failure_callback, on_retry_callback, on_success_callback, sla_miss_callback для уведомлений в Slack или Telegram и для компенсирующих действий. trigger_rule у зависимых задач задаёт реакцию на статусы предков: all_success по умолчанию, all_done, one_failed, none_failed.
Первый уровень: файл DAG должен импортироваться без ошибок и быстро, для этого есть тест на DagBag с проверкой import_errors и ограничения на время разбора. Юнит-тесты проверяют функции из python_callable отдельно от Airflow. airflow tasks test dag task date выполняет одну задачу без записи в базу, airflow dags test прогоняет DAG целиком за дату. Структуру графа проверяют на зависимости и циклы. Версионирование: DAG-файлы лежат в Git, выкатываются через CI/CD в папку dags (или через git-sync в Kubernetes), а сам Airflow историю версий DAG не хранит до версии 3, поэтому изменения логики за прошлые даты нужно продумывать с backfill.
Connection описывает подключение к внешней системе: тип, хост, порт, логин, пароль, extra в JSON; его используют хуки и операторы по conn_id, пароль хранится зашифрованным ключом Fernet. Variable это именованное значение для параметров DAG: список таблиц, дата, флаг; читается через Variable.get, в шаблонах {{ var.value.x }}. Чтение Variable внутри кода DAG на верхнем уровне делает запрос к базе при каждом разборе файла, поэтому их читают внутри задач или через шаблоны. Для секретов подключают secrets backend (HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets): Airflow ищет connection и variable сначала там, потом в базе.
Kafka это распределённый журнал: продюсеры пишут сообщения в топики, консьюмеры читают по смещению (offset), сообщения хранятся заданное время независимо от прочтения. Топик делится на партиции: партиция это упорядоченный журнал на одном брокере (с репликами), единица параллелизма и хранения. Порядок FIFO гарантируется только внутри партиции; на уровне топика с несколькими партициями порядка нет. Консьюмеры в группе делят партиции между собой, одна партиция читается одним консьюмером группы.
При наличии ключа партиция вычисляется как хеш ключа по модулю числа партиций: все сообщения с одним ключом (клиент, заказ) попадают в одну партицию и читаются по порядку, что нужно для событий одной сущности. Без ключа продюсер распределяет сообщения сам: Round Robin по кругу (по одному в каждую партицию поочерёдно) или, в новых версиях, sticky-партиционирование батчами. Простой самодельный способ без Round Robin: случайная партиция или счётчик по модулю числа партиций, оба дают равномерность в среднем; важно не брать поле с перекосом (один крупный клиент) как ключ, иначе одна партиция перегружена.
Схема: CDC из базы через Debezium в Kafka (топик на таблицу, ключ = первичный ключ), консьюмер (Spark Structured Streaming, Kafka Connect sink, Flink или свой сервис) пишет батчами в хранилище с дедупликацией по ключу и смещению. Гарантия «хотя бы раз» делает идемпотентную запись обязательной: upsert или ReplacingMergeTree. Контролировать лаг консьюмера, схему сообщений (Schema Registry, Avro) и обработку удалений (tombstone-сообщения).
Брокеры хранят данные и обслуживают клиентов, кластер из нескольких брокеров координируется через KRaft (раньше ZooKeeper). Продюсеры пишут сообщения в топики, консьюмеры читают. Топик логическое имя потока, разбитое на партиции: упорядоченные журналы, размазанные по брокерам, с репликами. Группа консьюмеров делит партиции топика между своими участниками, чтобы читать параллельно и каждое сообщение обрабатывать один раз на группу. Смещения консьюмеров хранятся во внутреннем топике __consumer_offsets. Вокруг ядра: Kafka Connect для интеграций, Schema Registry для схем, Streams и ksqlDB для обработки.
Offset это порядковый номер сообщения внутри партиции, монотонно растущий. Консьюмер запоминает, до какого смещения он дочитал, и фиксирует его (commit) во внутренний топик __consumer_offsets с ключом «группа, топик, партиция». Хранение сообщений от чтения не зависит: они живут до истечения retention, поэтому группу можно сбросить на начало или на конкретное время (--reset-offsets) и перечитать историю, а новая группа читает с конца или с начала по настройке auto.offset.reset. Коммитить можно автоматически по таймеру или вручную после успешной обработки, от этого зависят гарантии доставки.
Фактор репликации это число копий каждой партиции на разных брокерах. Одна из реплик лидер: только она принимает запись и чтение, остальные фолловеры копируют её журнал. ISR (in-sync replicas) это реплики, которые не отстают от лидера больше допустимого; при acks=all запись считается подтверждённой, когда её получили все реплики из ISR. Если брокер падает, контроллер выбирает новым лидером одну из реплик ISR, клиенты переключаются сами и работа продолжается без потери данных. Под-реплицированные партиции (under-replicated) один из главных показателей здоровья кластера.
Партиция читается ровно одним консьюмером группы, поэтому число партиций это максимальный параллелизм чтения. Консьюмеров больше, чем партиций: лишние простаивают, но подхватят партиции при выбытии других (горячий резерв). Консьюмеров меньше: кто-то читает несколько партиций и может не успевать. Два консьюмера в разных группах читают все сообщения каждый, это нужно, когда один поток обрабатывают разные сервисы.
At most once: коммит смещения до обработки, при падении сообщение теряется. At least once: коммит после обработки, при падении между ними сообщение придёт снова, нужны идемпотентные потребители. Exactly once складывается из трёх частей: идемпотентный продюсер (enable.idempotence=true, брокер отбрасывает повтор по номеру последовательности при ретраях), транзакции продюсера, которые атомарно пишут в несколько партиций и фиксируют смещения потребителя (isolation.level=read_committed у читателя), и идемпотентная запись в конечное хранилище, потому что гарантия Kafka заканчивается на её границе: в базу или в S3 данные всё равно нужно писать с upsert или по ключу.
acks=0: продюсер не ждёт подтверждения, максимальная скорость и возможная потеря. acks=1: подтверждает лидер после записи в свой журнал; если лидер упадёт до репликации, сообщение потеряется. acks=all (-1): ждут все реплики из ISR, вместе с min.insync.replicas=2 это защита от потери при отказе одного брокера. Производительность продюсера дальше регулируют батчингом (batch.size, linger.ms: накопить пачку, прежде чем отправить) и сжатием пачек (lz4, snappy, zstd, gzip), которое экономит сеть и диск брокеров.
Перераспределение партиций между участниками группы: происходит, когда консьюмер добавился, упал, не успел прислать heartbeat или слишком долго обрабатывал пачку (max.poll.interval.ms). Во время классического ребаланса вся группа останавливает чтение, растёт лаг, а незакоммиченные сообщения приходят повторно. Лечат кооперативным протоколом (CooperativeStickyAssignor), статическим членством (group.instance.id), адекватным размером пачки и таймаутами; «штормы ребалансов» обычно означают, что обработка одной пачки дольше лимита.
Лаг это разница между последним смещением в партиции и последним закоммиченным смещением группы, то есть сколько сообщений ещё не обработано. Растущий лаг означает, что потребители не справляются (backpressure) или остановились. Помимо лага смотрят пропускную способность по топикам, число under-replicated партиций, состояние ISR, место на дисках брокеров и частоту ребалансов. Инструменты: JMX-метрики через Kafka Exporter в Prometheus и Grafana, Burrow для лага, панели Kafka UI или Conduktor.
Retention удаляет сегменты журнала по времени или размеру независимо от того, прочитаны ли они: так хранят потоки событий. Compaction (cleanup.policy=compact) оставляет для каждого ключа только последнее значение, старые версии вычищаются в фоне: так хранят состояние, справочники, CDC-снимки, и такой топик можно перечитать с начала, чтобы восстановить текущее состояние. Удаление ключа делают tombstone-сообщением с null в значении: после компакции и тайм-аута ключ исчезает. Политики можно совмещать.
Kafka это распределённый append-only журнал: сообщения не удаляются при чтении, у каждого потребителя своё смещение, историю можно перечитать, порядок гарантирован внутри партиции. RabbitMQ классический брокер очередей: сообщение доставляется потребителю и удаляется, есть богатая маршрутизация (exchanges, routing keys), подтверждения и приоритеты. Kafka выбирают для высокой пропускной способности, стриминга событий, CDC и интеграции многих потребителей с replay; RabbitMQ для задач и команд между сервисами с низкой задержкой и сложной маршрутизацией.
Больше партиций: выше параллелизм чтения и записи, можно масштабировать группу консьюмеров. Но каждая партиция это открытые файлы и память на брокере, более долгий ребаланс и восстановление лидера, больше мелких батчей у продюсера, а порядок всё равно только внутри партиции. Число партиций увеличить можно, уменьшить нельзя, и при увеличении ключи переезжают в другие партиции, ломая локальность. Практика: считать от целевой пропускной способности и числа потребителей с запасом в два-три раза, обычно десятки, а не тысячи.
Последовательная запись и чтение журнала на диске без случайного доступа, батчинг и сжатие пачек на стороне продюсера, zero-copy передача сегментов из page cache в сокет без копирования в память процесса, опора на page cache ОС вместо собственного кэша, отсутствие состояния потребителей на брокере (только смещения) и горизонтальное масштабирование партициями.
Растущий лаг, когда потребители не успевают; штормы ребалансов из-за долгой обработки пачек; дубли при at-least-once без идемпотентной записи; перекос партиций из-за плохого ключа, когда одна партиция перегружена; under-replicated партиции при проблемах с брокером или сетью; переполнение дисков при неверно подобранном retention; несовместимые изменения схемы сообщений, если нет Schema Registry; потеря сообщений при acks=1 или min.insync.replicas=1.
Фреймворк для переноса данных между Kafka и внешними системами без своего кода: source-коннекторы читают из источников (Debezium из баз, файлы, S3) в топики, sink-коннекторы пишут из топиков в приёмники (ClickHouse, Postgres, S3, Elasticsearch). Конфигурация в JSON, масштабирование воркерами, встроенные преобразования (SMT), управление смещениями. Для DE это стандартный способ поднять CDC и доставку в хранилище, оставив код только для нестандартной логики.
Отдельный сервис, который хранит схемы сообщений (Avro, Protobuf, JSON Schema) по версиям и выдаёт им идентификаторы. Продюсер сериализует сообщение по схеме и кладёт в заголовок только её id, потребитель по id получает схему и разбирает данные. Главное свойство: проверка совместимости при регистрации новой версии (backward, forward, full), которая не даёт выкатить изменение, ломающее потребителей, например удалить обязательное поле. Для Data Engineer это ещё и источник схем для автоматического создания таблиц в хранилище.
DLQ это отдельный топик, куда потребитель или коннектор отправляет сообщения, которые не удалось обработать (битый формат, нарушение схемы, ошибка записи), чтобы не останавливать поток и не терять данные; их потом разбирают и переигрывают. Backpressure это ситуация, когда потребитель не успевает за продюсером: в Kafka она проявляется растущим лагом, поскольку брокер данные буферизует, а не отбрасывает. Снимают её масштабированием группы (до числа партиций), батчевой обработкой, оптимизацией записи в приёмник или замедлением продюсера.
Сервисы общаются не прямыми вызовами, а событиями «что произошло»: заказ создан, платёж прошёл. Продюсер не знает потребителей, потребителей может быть много и они добавляются без изменений источника. Kafka выступает шиной событий: хранит их историю, даёт replay новым потребителям и развязывает скорость производителя и потребителей. Для DE это означает, что хранилище подписывается на те же события, что и сервисы, вместо ночных выгрузок из баз.
commit фиксирует изменения в локальном репозитории, push отправляет коммиты в удалённый. Ветка это указатель на коммит: изменения делаются в отдельной ветке от main, проверяются в merge request и вливаются. Повседневные команды: status, add, commit, pull, push, checkout/switch, branch, merge, log, diff, stash. rebase переносит коммиты ветки поверх свежего main, давая линейную историю без merge-коммитов; rebase -i склеивает и правит коммиты перед MR. Правило: не делать rebase уже опубликованных общих веток.
Пайплайн на merge request: линтеры и форматирование (ruff/flake8, black, sqlfluff), юнит-тесты на mock-данных, проверка DAG на импорт, сборка образа или артефакта; при слиянии в main деплой на стенд, прогон интеграционных проверок, затем прод. Секреты не в репозитории: переменные CI с маскированием, Vault или секрет-менеджер облака, в Airflow Connections с секрет-бэкендом; в коде только имена секретов. Код базы (таблицы, процедуры) тоже под версиями: миграции (Liquibase, dbt, Flyway, drizzle) применяются пайплайном, а не руками в проде.
DDL и процедуры в репозитории как миграции с номерами, ревью через merge request, автоматическое применение на тестовом стенде, затем в прод в окно с откатом (обратная миграция или снимок). Данные витрин не деплоятся, пересчитываются пайплайном после изменения схемы. Для dbt-проектов это dbt run из CI по окружениям.
Docker упаковывает код с зависимостями в образ: один и тот же образ для локальной разработки, CI и прода, воспроизводимое окружение для Airflow-воркеров, Spark-драйверов, сервисов загрузки; docker compose поднимает локальный стенд из базы, Airflow и MinIO. Kubernetes запускает эти образы в кластере: KubernetesPodOperator выполняет задачу в изолированном поде с нужными ресурсами, Spark on Kubernetes заменяет YARN, сервисы масштабируются и перезапускаются автоматически. Инженер данных обычно пишет Dockerfile и манифесты для своих задач, а кластер поддерживает DevOps.
Монолит это одно приложение и один деплой: проще разрабатывать и отлаживать, но сложнее масштабировать части независимо. Микросервисы делят систему на независимые сервисы с отдельными деплоями и командами: гибкость и масштабирование ценой сети, согласованности данных и инфраструктуры. Для платформы данных обычно модульный монолит пайплайнов в одном репозитории с общими библиотеками и отдельные сервисы только для того, что живёт постоянно (API выдачи данных, стриминговые консьюмеры). Ответ «зависит от команды и объёма» с аргументами ценится выше, чем модное слово.
Shell уместен для обвязки: запуск и цепочки утилит (psql, spark-submit, aws s3), проверки файлов, ротация логов, сборка и деплой, cron там, где нет оркестратора. Ускорение приходит от инструментов, которые shell вызывает: COPY вместо построчной вставки, параллельная выгрузка через xargs -P или parallel, потоковая обработка через конвейеры без промежуточных файлов. Логику данных в shell не пишут: нет тестов, типов и обработки ошибок, поэтому длиннее 50 строк это уже Python.
Метрики и дашборды: Prometheus + Grafana (нагрузка кластера, лаг Kafka, длительность задач Airflow через StatsD), логи: Loki или ELK, алерты в Telegram/Slack/почту из Grafana и из Airflow (on_failure_callback). Для данных отдельный слой: таблица результатов проверок качества и свежести, из неё метрики в тот же Grafana. Важно разделять алерты «упала задача» (инженер) и «данные не сходятся» (владелец данных).
Горизонтальное масштабирование воркеров Airflow и исполнителей Spark (динамическая аллокация), репликация в Kafka (фактор 3) и в HDFS, зеркала сегментов и резервный мастер Greenplum, реплики ClickHouse через ReplicatedMergeTree и ZooKeeper/Keeper, реплика Postgres для чтения. На уровне пайплайнов: ретраи с паузой, идемпотентные задачи, таймауты, очереди с пулами, чтобы падение одного источника не останавливало остальные. Отказоустойчивость начинается с того, что перезапуск любой задачи безопасен.
Структура ответа на три минуты: роль и команда (сколько инженеров, кто заказчики), стек (источники → хранилище → витрины → потребители), ваша зона ответственности и два-три результата с цифрами (объёмы в строках или гигабайтах в день, сколько пайплайнов, что ускорили и во сколько раз). Дальше интервьюер пойдёт вглубь по каждому слову, поэтому называйте только то, что готовы разобрать: откуда загружали (базы, API, файлы), чем (Airflow + PXF/Spark/Python), какой первый слой (raw как есть, staging с типизацией), как считали инкремент, чем трансформировали (SQL в хранилище или Spark), как проверяли.
Формула: контекст (какая боль у бизнеса), что сделали вы лично (не команда), какое решение приняли и почему отвергли альтернативы, результат в цифрах, что бы сделали иначе. Пример: «витрина сверки платежей считалась 6 часов и падала; я перевёл расчёт на инкремент по дате платежа с перезаписью трёх последних дней, вынес справочники в replicated-таблицы, добавил проверки; стало 20 минут, падений ноль за полгода». Один подготовленный кейс с деталями лучше трёх расплывчатых.
Назвать порядок, а не точные числа: строк в день, размер витрин в гигабайтах, число таблиц и пайплайнов, прирост. Важно связать объём с решениями: «200 млн событий в день, поэтому хранили в ClickHouse партициями по дню, а не в Postgres». Если объёмы были небольшими, сказать честно и показать, что понимаете, что изменится при росте.
Назвать число с обоснованием: что делаете уверенно (оконные функции, оптимизация планов, идемпотентные загрузки), чего не делали (тюнинг на уровне СУБД, PL/pgSQL). Завышенная оценка проверяется задачами тут же, заниженная снижает ожидания; 7–8 с конкретикой звучит убедительно для middle.
Честно сказать, что не работали, и показать перенос опыта: ODI и Pentaho это ETL-инструменты с тем же набором понятий (источник, маппинг, расписание, инкремент), SOAP это XML поверх HTTP с WSDL-описанием, парсится стандартными библиотеками. Назвать, как будете входить: документация, тестовый стенд, первая маленькая задача под ревью. Интервьюер проверяет не знание инструмента, а то, как вы учитесь.
Ожидают не «да», а примеры: как получали требования (ТЗ от аналитика, постановка в задаче, устная договорённость), что уточняли перед работой (источники, частота обновления, кто потребитель, что считать ошибкой), приходилось ли править ТЗ, когда выяснялось, что данных нет или требование противоречиво, и как это оформляли. Про регламенты: порядок выкладки, код-ревью, ночные окна загрузки, дежурства, документирование витрин. Хороший ответ показывает, что кандидат фиксирует договорённости письменно и возвращается к заказчику при расхождениях, а не додумывает.
Отвечать про рост, а не про обиды: «вырос из текущих задач, хочу работать с большими объёмами и стримингом, здесь это есть». Про другие собеседования нейтрально: «рассматриваю несколько команд с похожим стеком, выбираю по задачам и команде». Направление развития конкретное: архитектура хранилища, платформенные инструменты, лидерство в команде, с привязкой к вакансии.
Настоящая слабость с историей исправления: «увлекался оптимизацией там, где достаточно было работающего решения, научился сначала выпускать и мерить, потом улучшать». Не называть «перфекционизм» без деталей и не выбирать слабость, критичную для роли.
Сначала понять причину: людям дорого переучиваться, изменение ломает их процессы или просто не объяснено. Поговорить один на один, показать выгоду на их задаче, предложить пилот на одном пайплайне, договориться о критериях, когда откатим. «Забить» это проигрыш обеим сторонам: изменение саботируют.
Реализовать выбранный вариант добросовестно: решение принято, и его успех теперь общий. Зафиксировать свои риски письменно (в задаче или документе), чтобы вернуться к ним, если они сработают, без «я же говорил». При директивном решении без аргументов спокойно попросить объяснение, потому что оно нужно для правильной реализации, а не для спора; если объяснения нет, уточнить критерии приёмки и делать. Систематическая директивность это уже вопрос к тому, оставаться ли в команде, но не повод для конфликта на месте.
Да, с примером: как уточняли требования к витрине (вопросы про зерно, определения метрик, периодичность, что считать ошибкой), как фиксировали договорённости и показывали прототип на части данных до полной реализации. Инженер, который умеет снять требования, ценится выше, и это стоит показать, даже если формально это делал аналитик.
Да: юнит-тесты на трансформации, проверки качества данных как часть пайплайна, сверка с источником до выкладки. Требования к проверкам обычно формулировали совместно: бизнес-правила от аналитика или заказчика, технические (уникальность, полнота, свежесть) от инженера. Показать понимание, что проверка без порога и владельца это не проверка.
Не отказывать молча и не обещать невозможного. Выяснить реальную потребность: какое решение он примет по этой витрине и когда. Предложить варианты: упрощённую версию (одна метрика вместо десяти, недельная вместо ежедневной), разовую выгрузку вместо регулярной витрины, срок с учётом очереди, или эскалацию приоритета руководителю с оценкой затрат. Зафиксировать договорённость письменно. Интервьюер проверяет, что вы управляете ожиданиями, а не исчезаете.
Разобраться, почему именно вторник (выгрузка за выходные, отчётный день у источника), и измерить, какой шаг не масштабируется. Варианты: обрабатывать по частям параллельно, выделять больше ресурсов на этот день (динамическая аллокация, отдельная очередь), перенести тяжёлые шаги в инкремент, начинать обработку по мере поступления, а не после полной выгрузки, договориться с источником о более раннем старте. Поставить мониторинг длительности с порогом, чтобы следующий рост заметить заранее.
Начать с инвентаризации: какие таблицы, объёмы, кто потребители, какие запросы к ним идут, есть ли метка изменений для инкремента. Выбрать приёмник по нагрузке: Greenplum для модели со сложными соединениями, ClickHouse для событий и агрегатов. Сделать маппинг типов, первую полную выгрузку, инкремент, сверку, параллельный период, переключение. Если источники у систем разные, сначала сверить справочники и ключи между ними (один и тот же клиент под разными id) и построить таблицу соответствий, иначе данные не сойдутся после переноса.
Сначала оценить: что именно не досчиталось и можно ли выпустить отчёт частично или на вчерашних данных с пометкой. Сообщить заказчику сразу, с оценкой времени, а не после починки. Починить минимально безопасным способом (перезапуск за период, ручная догрузка), после выпуска отчёта разобрать причину и написать постмортем с действиями, чтобы не повторилось. Тишина хуже плохих новостей.
Разделить на три группы по три. Первое взвешивание: группа A против группы B. Если равны, фальшивая в C; если одна чаша легче, фальшивая в ней. Второе взвешивание: из найденной тройки положить одну монету против другой: если равны, фальшивая третья, иначе та, что легче. Общий принцип: каждое взвешивание даёт три исхода, поэтому за n взвешиваний можно различить 3ⁿ монет.
Стрелять сразу. После холостого выстрела известно, что курок стоял на одном из четырёх пустых гнёзд; следующее гнездо заряжено только в одном случае из четырёх (когда холостое было последним перед парой патронов), то есть риск 1/4. Если крутить барабан, риск становится 2/6 = 1/3. Если бы патроны лежали не подряд, ответ мог бы быть другим: такие задачи проверяют умение честно пересчитать условную вероятность.