Суть Читай реальный SQL, dbt-модель, предикат Parquet и сниппет векторного retrieval по всему треку, предскажи поведение и выбери фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги дата-платформы живут в запросах, трансформациях и интеграционном коде — не в слайдах. Прочитай каждый сниппет сквозь трек, предскажи его влияние на корректность или стоимость, потом выбери фикс, который senior сделает первым.
Цель
Отработай цикл, который гоняешь на реальной платформе: прочитай запрос или трансформацию, реши, подходит ли она хранилищу, против которого исполняется, и потянись за фиксом с наибольшим рычагом — верная раскладка, верный контракт доставки — прежде чем добавлять железо.
Сниппет 1 — аналитический запрос на OLTP
-- ежечасно против живой Postgres-таблицы ordersSELECT country, date_trunc('day', created_at) AS d, sum(amount)FROM ordersWHERE created_at >= now() - interval '90 days'GROUP BY 1, 2;
Викторина
Completed
Этот 90-дневный агрегат гоняется ежечасно на живой OLTP-БД. Что не так с размещением и каков фикс?
Heads-up Индекс помогает выбрать диапазон дат, но запрос всё равно агрегирует миллионы строк в строковой раскладке и всё равно грузит транзакционный стор. Паттерн доступа — OLAP; хранилище должно быть колоночным, а не просто лучше проиндексированным.
Heads-up 90-дневный group-by по живой OLTP-таблице — тяжёлый скан, конкурирующий с транзакционным путём; ровно поэтому существует разделение нагрузок. Дешёвая агрегация требует колоночной раскладки, читающей только затронутые колонки.
Heads-up Реплика изолирует нагрузку, но сохраняет строковую раскладку, так что скан всё равно медленный — он читает каждую колонку каждой строки. Изоляция — половина ответа; вторая половина — колоночный стор под сканы.
Сниппет 2 — инкрементальная dbt-модель
{{ config(materialized='incremental', unique_key='order_id') }}SELECT order_id, customer_id, amount, status, updated_atFROM {{ source('raw', 'orders') }}{% if is_incremental() %}WHERE updated_at > (SELECT max(updated_at) FROM {{ this }}){% endif %}
Викторина
Completed
Заказ жёстко удалён в источнике. Что покажет эта gold-модель после следующего инкрементального прогона и каков фикс?
Heads-up Инкрементальные модели аппендят/мержат только изменённые строки из фильтра источника; они никогда не пересканируют весь источник, поэтому не могут заметить отсутствие. Удаление невидимо для фильтра по high-water-mark, если не придёт как явное событие изменения.
Heads-up unique_key управляет тем, как совпавшая входящая строка мержится (upsert) — и не делает ничего, когда строка вообще не приходит. Жёсткое удаление не порождает входящей строки, так что совпадать и удалять нечего.
Heads-up Full-refresh действительно реконсилит удаления, но выбрасывать инкрементальность на каждом прогоне убивает экономию. Senior-фикс — пробрасывать удаления как CDC-tombstones и мержить их, оставляя инкрементальный путь для частого случая.
Сниппет 3 — предикат Parquet
# Iceberg-таблица, партиционированная по event_date, country — обычная колонкаdf = spark.read.table("events")result = (df .filter("upper(country) = 'US'") # функция оборачивает колонку .filter("event_date = '2026-05-01'") .groupBy("country").count())
Викторина
Completed
Фильтр по event_date прунит хорошо, но фильтр по country сканирует гораздо больше ожидаемого. Почему и каков фикс?
Heads-up Parquet прунит и непартиционные колонки, используя min/max-статистику футера по row group. Проблема здесь в том, что оборачивание колонки в upper() прячет её от этой статистики, а не в том, что pruning ограничен партициями.
Heads-up Порядок фильтров не меняет pruning — оптимизатор пушит оба предиката вниз. Поломку даёт обёртка-функция на country, делающая её предикат не-sargable против статистики футера.
Heads-up Избыточное партиционирование по колонке высокой кардинальности создаёт проблему мелких файлов и не поможет, пока upper() оборачивает предикат. Сначала сделай предикат prunable; только потом думай о партиционировании country, если кардинальность вменяемая.
Сниппет 4 — векторный retrieval
def recommend(query: str, k: int = 10): qvec = embed(query) hits = vector_index.search(qvec, ef_search=20, top_k=k) return [h.product for h in hits] # эмбеддинги пересобираются ночью
Викторина
Completed
Тут спрятаны две проблемы: результаты иногда рекомендуют товар, удалённый часы назад, и recall ощущается низким. Каковы senior-фиксы?
Heads-up Переэмбеддинг всего каталога каждую минуту дико дорог и всё равно оставляет окно. Дешёвый и корректный фикс — фильтр живости против OLTP на время запроса: пусть система записи отвечает «существует ли это ещё?».
Heads-up ANN меняет recall на скорость через параметры вроде ef_search; слишком маленький список поиска возвращает не те десять, а не неизбежный предел. Расширение ef_search (или HNSW M/ef_construction) восстанавливает recall ценой задержки, которую ты выбираешь.
Heads-up Возврат большего числа строк не убирает удалённые товары (всё ещё нет проверки живости) и не улучшает качество кандидатов, если ef_search остаётся 20 — ты просто получаешь больше того же приближённого набора. Чини recall через ef_search, а устаревание — через живой фильтр.
Итог
Любой баг платформы читается в запросе, трансформации или интеграционном сниппете: OLAP-агрегат, застрявший на OLTP-сторе; инкрементальная модель, не видящая удалений; предикат Parquet, обёрнутый в функцию и убивший pruning; и векторный поиск, одновременно устаревший и голодный на recall. Фикс почти никогда не в железе — это верное хранилище под паттерн доступа, удаления, проброшенные как CDC-tombstones, sargable-предикаты, дающие футеру прунить, и живой OLTP-фильтр плюс настроенный ef_search для retrieval. Прочитай код, размести нагрузку, потом проверь.