From f78983964340823ef44e18b073ae2ba14953c581 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 4 Nov 2025 17:20:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=20SCD2=20=D0=B4=D0=BB=D1=8F=20append-only=20=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SCD.md | 108 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/SCD.md b/SCD.md index f51a7f8..cac5851 100644 --- a/SCD.md +++ b/SCD.md @@ -1,4 +1,3 @@ - # Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных > **Для кого эта статья?** @@ -34,7 +33,7 @@ SCD — это подход к хранению изменений в измер Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные. ### **Type 0 — Никогда не меняется** -Атрибут фиксирован навсегда. Например, дата рождения клиента или идентификатор паспорта. +Атрибут фиксирован навсегда. Например, дата рождения клиента. Такие поля не требуют специальной обработки — они просто не обновляются. ### **Type 1 — Просто перезаписать** @@ -62,7 +61,7 @@ SCD — это подход к хранению изменений в измер > Используется редко, чаще как компромисс в очень простых системах. -### **Type 4, 5, 6 — Продвинутые гибриды (для справки)** +### **Type 4, 5, 6 — Продвинутые гибриды** Эти типы существуют, но **встречаются редко** и почти не используются новичками: - **Type 4**: история выносится в отдельную таблицу («мини-хранилище» для одного измерения). @@ -237,12 +236,10 @@ WHERE customer_id = 1 AND is_current = true Ответ: **никаких UPDATE не нужно** — ведь в Type 2 мы и так **не меняем старые данные**, а только **добавляем новые**! -Алгоритм: - -1. Читаем последнюю версию измерения (например, через оконную функцию). -2. Сравниваем с новыми данными. -3. Если есть изменения — **пишем новую строку** с новыми `valid_from` / `valid_to`. -4. Старые строки остаются нетронутыми. +Алгоритм загрузки (ETL): +1. Сравнить входящие данные с последней версией в таблице. +2. Если есть изменения — **пишем новую строку** с новыми `valid_from` / `valid_to`. +3. Старые строки остаются нетронутыми. Пример в Trino/Hive-стиле (только INSERT): @@ -251,30 +248,81 @@ WHERE customer_id = 1 AND is_current = true -- dim_customers_scd2 — основная таблица (append-only) INSERT INTO dim_customers_scd2 -WITH latest AS ( - SELECT *, - ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn - FROM dim_customers_scd2 -), -current AS ( - SELECT * FROM latest WHERE rn = 1 +WITH current_customers AS ( + SELECT * + FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn + FROM dim_customers_scd2 + ) + WHERE rn = 1 -- отбираем первую, самую свежую, запись ) SELECT - uuid() AS customer_key, -- или хеш, sequence и т.п. - n.customer_id, + uuid() AS customer_key, -- уникальный ID каждой версии + n.customer_id, -- неизменный бизнес-ID клиента n.name, n.category, - current_date AS valid_from, - CAST(NULL AS DATE) AS valid_to, - true AS is_current + valid_from FROM new_customers n -LEFT JOIN current c ON n.customer_id = c.customer_id -WHERE c.category IS DISTINCT FROM n.category; +LEFT JOIN current_customers c ON n.customer_id = c.customer_id +WHERE c.customer_id IS NULL OR c.category != n.category; -- условие верно, если какие-то из полей справочника поменялись ``` -А чтобы «закрыть» предыдущую версию, **не нужно её обновлять** — просто при чтении всегда берите **актуальную на дату** версию через условия с `valid_from`/`valid_to`. +**Главный вопрос после загрузки: как же читать эти данные?** -> Таким образом, SCD Type 2 **идеально подходит** для систем без поддержки UPDATE — потому что он по своей природе **append-only** (только добавление). +Поскольку мы не можем обновлять `valid_to` у предыдущей версии (у неё останется `NULL`), стандартный подход с `BETWEEN` не сработает. Вместо этого, для поиска нужной версии мы полагаемся на **оконные функции** или на логику «ближайшей даты, но не позже». + +#### Паттерн 1: Найти последнюю (актуальную) версию на сегодня + +Это самый частый запрос. Мы хотим видеть самую свежую информацию о клиенте. + +```sql +-- Вариант с оконной функцией (универсальный и надежный) +WITH ranked AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn + FROM customers_scd2 +) +SELECT + customer_id, name, category, valid_from +FROM ranked +WHERE rn = 1; +``` + +Этот запрос берёт строку с самой поздней датой начала действия для каждого клиента. + +#### Паттерн 2: Найти версию, которая была актуальна на конкретную дату + +Это основная цель SCD2. Например, «какой статус клиента был на дату заказа `2025-03-15`?». + +В append-only мире у нас нет `valid_to`, поэтому мы ищем **последнюю версию, у которой `valid_from` ≤ целевой даты**. + +```sql +-- Запрос для получения состояния на '2025-03-15' +WITH as_of_date AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn + FROM customers_scd2 + WHERE valid_from <= DATE '2025-03-15' +) +SELECT + customer_id, name, category, valid_from +FROM as_of_date +WHERE rn = 1; +``` + +**Как это работает:** +1. `WHERE valid_from <= DATE '2025-03-15'` отфильтровывает все версии, которые появились **после** нашей целевой даты. +2. `ORDER BY valid_from DESC` сортирует оставшиеся версии от самой свежей к самой старой. +3. `ROW_NUMBER() ... WHERE rn = 1` выбирает самую свежую из **актуальных на ту дату** версий. + +Это и есть «путешествие во времени» (time travel) в системах без встроенной поддержки этой функции. + +> **Почему не использовать `is_current`?** +> В append-only системах флаг `is_current` становится устаревшим сразу после новой вставки. Его очень сложно поддерживать без `UPDATE`, поэтому в таких архитектурах его чаще **не используют**, полагаясь полностью на даты и оконные функции. Это делает модель данных более чистой и идемпотентной. + +Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении. --- @@ -294,7 +342,7 @@ WHERE c.category IS DISTINCT FROM n.category; ## 6. Подводные камни и советы - **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key). -- Всегда задавайте `valid_to` как `NULL` для актуальной записи — это упрощает запросы. +- Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает запросы. - Используйте `COALESCE(valid_to, '9999-12-31')` в условиях, чтобы избежать `NULL`-проблем. - Type 2 увеличивает объём данных — но для аналитики это нормально. - В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией. @@ -303,9 +351,15 @@ WHERE c.category IS DISTINCT FROM n.category; ## 7. Заключение -Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени. +Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени. Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своём PostgreSQL. Задайте себе вопрос: > «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?» Если нет — вам нужен SCD Type 2. + +И помните: даже в системах без `UPDATE` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ. + +--- + +Готово! Статья теперь включает полный цикл: от концепции — к реализации в традиционной СУБД — и далее к адаптации для современных аналитических платформ. \ No newline at end of file