Для кого эта статья?
Для тех, кто уже умеет писать базовые SQL-запросы в PostgreSQL, знаком с понятиями таблиц, строк и колонок, и теперь делает первые шаги в аналитике и проектировании хранилищ данных.
Представьте, что вы строите отчёт по продажам. У вас есть таблица с фактами — например, «продано 10 единиц товара X клиенту Y 15 марта». Но чтобы понять, кто такой клиент Y или что за товар X, вам нужны справочники — таблицы с описанием клиентов, товаров, регионов и т.п.
В мире аналитики такие справочники называют измерениями (dimensions), а таблицу с продажами — фактами (facts).
А теперь представьте: клиент сменил адрес или перешёл в другую категорию (например, из «обычного» в «VIP»). Если вы просто обновите строку в таблице клиентов, то потеряете информацию о том, какой статус у клиента был на момент продажи. А это критично: отчёт «продажи VIP-клиентам в марте» окажется неверным!
Такие атрибуты — которые меняются со временем, но не каждый день — и называются медленно меняющимися измерениями (Slowly Changing Dimensions, SCD).
SCD — это подход к хранению изменений в измерениях с учётом времени. Он позволяет отвечать на вопросы вроде:
- Какой адрес у клиента был на дату заказа?
- Сколько продаж пришлось на товары категории «Электроника» до того, как её переименовали в «Гаджеты»?
Без SCD вы видите только текущее состояние, а с ним — всю историю.
Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные.
Атрибут фиксирован навсегда. Например, дата рождения клиента.
Такие поля не требуют специальной обработки — они просто не обновляются.
Вы просто делаете UPDATE, и старое значение исчезает.
✅ Просто.
❌ История теряется.
Подходит, если изменение — это исправление ошибки (например, опечатка в имени).
Каждое изменение порождает новую строку в таблице. Старая строка остаётся, но помечается как «устаревшая».
✅ Полная история.
✅ Можно восстановить состояние на любую дату.
❌ Больше данных, сложнее запросы.
Это самый распространённый подход в аналитике.
В таблице появляются поля вроде previous_category, category_change_date.
✅ Простая история «до/после».
❌ Хранит только одно предыдущее значение. Не масштабируется.
Используется редко, чаще как компромисс в очень простых системах.
Эти типы существуют, но встречаются редко и почти не используются новичками:
- Type 4: история выносится в отдельную таблицу («мини-хранилище» для одного измерения).
- Type 5: комбинация Type 4 и Type 1 — текущее значение в основной таблице, а история — отдельно.
- Type 6: объединяет Type 1, 2 и 3 в одной таблице — очень гибко, но сложно.
Вам не нужно запоминать эти типы сейчас. Достаточно знать, что они бывают — на случай, если встретите их в документации.
Допустим, у нас есть таблица клиентов:
-- Исходная таблица (до изменений)
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL -- например: 'Regular', 'VIP'
);Клиент №1 стал VIP:
UPDATE customers
SET category = 'VIP'
WHERE customer_id = 1;Теперь в таблице только новое значение. Если продажа была сделана до этого UPDATE, вы не узнаете, что клиент тогда был «Regular».
Чтобы хранить историю, мы меняем структуру таблицы. Вот ключевые поля:
customer_key— искусственный (surrogate) первичный ключ. Он уникален для каждой версии клиента.customer_id— бизнес-идентификатор (например, из CRM). Он не меняется и связывает все версии одного клиента.valid_from— дата, с которой эта версия стала актуальной.valid_to— дата, по которую эта версия была актуальной. ЕслиNULL— значит, версия актуальна сейчас.
Пример структуры:
CREATE TABLE customers_scd2 (
customer_key SERIAL PRIMARY KEY, -- уникальный ID каждой версии
customer_id INT NOT NULL, -- неизменный бизнес-ID клиента
name TEXT NOT NULL,
category TEXT NOT NULL,
valid_from DATE NOT NULL, -- с какой даты действует
valid_to DATE -- по какую дату действовала (NULL = сейчас)
);Шаг 1. Добавляем начальную запись (клиент зарегистрировался 1 января 2024):
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'Regular', DATE '2024-01-01', NULL);Шаг 2. 15 апреля 2025 клиент становится VIP. Мы делаем два действия:
- Закрываем старую запись: указываем дату начала новой версии (интервал
[valid_from, valid_to)). - Добавляем новую запись: она начинает действовать с 15 апреля и пока актуальна.
-- 1. Завершаем предыдущую версию
UPDATE customers_scd2
SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND valid_to IS NULL;
-- 2. Вставляем новую версию
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL);Теперь в таблице две строки для одного клиента. И мы можем спросить:
Какой была категория клиента на 10 апреля 2025?
SELECT category
FROM customers_scd2
WHERE customer_id = 1
AND DATE '2025-04-10' >= valid_from
AND (valid_to IS NULL OR DATE '2025-04-10' < valid_to);Результат: 'Regular' — правильно!
💡 Почему не
BETWEEN valid_from AND valid_to?
Потому что у актуальной записиvalid_to IS NULL. В SQL сравнения сNULLне даютTRUE, поэтому для “текущей” версии обычно пишутvalid_to IS NULL OR ....
В реальных ETL-процессах часто используют идемпотентные операции: запуск скрипта дважды не должен ломать данные. Для этого удобно применять подход, похожий на upsert (update + insert), но адаптированный под логику SCD Type 2.
В PostgreSQL классический ON CONFLICT не подходит напрямую, потому что мы не обновляем существующую строку, а добавляем новую при изменении.
Поэтому логика выглядит так:
- Сравнить входящие данные с последней версией в таблице.
- Если атрибуты изменились — закрыть старую запись и вставить новую.
- Если не изменились — ничего не делать.
Пример (часто реализуется в Python, dbt, Airflow и т.п.):
-- Предположим, новая версия: customer_id=1, category='VIP', effective_date='2025-04-15'
-- Шаг 1: вставляем новую версию, только если есть изменения
WITH last_version AS (
SELECT * FROM customers_scd2
WHERE customer_id = 1 AND valid_to IS NULL
)
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
SELECT 1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL
WHERE EXISTS (
SELECT 1 FROM last_version WHERE category != 'VIP'
);
-- Шаг 2: если вставка произошла — закрываем старую запись
UPDATE customers_scd2
SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND valid_to IS NULL
AND EXISTS (
SELECT 1 FROM customers_scd2
WHERE customer_id = 1 AND category = 'VIP' AND valid_from = DATE '2025-04-15'
);На практике такие логики чаще выносят в ETL-инструменты (например, dbt с пакетом dbt-scd), потому что чистый SQL быстро становится громоздким.
💡 Главное: SCD Type 2 — это не одна операция, а процесс: сравнить → закрыть старое → добавить новое.
В учебном проекте из папки dwh-modeling/sql/ эти идеи можно увидеть «вживую»:
- в
02_dml_stg-dds.sqlсобирается полная история клиентов (SCD2) из всех событий вstg.customers_raw— это пример первичной загрузки /full backfill; - в
03_demo_increment.sqlреализован инкрементальный SCD2: в одной транзакции добавляются новые версии клиентов из снимкаods.customersи закрываются предыдущие актуальные строки вdds.dim_customer.
Некоторые аналитические системы (например, Hive в формате ORC/Parquet, Trino, ClickHouse в режиме только вставки) не поддерживают UPDATE старых строк. Как тогда реализовать SCD Type 2?
Ответ: никаких UPDATE не нужно — ведь в Type 2 мы и так не меняем старые данные, а только добавляем новые!
Алгоритм загрузки (ETL):
- Сравнить входящие данные с последней версией в таблице.
- Если есть изменения — пишем новую строку с новыми
valid_from. - Старые строки остаются нетронутыми.
Пример в Trino/Hive-стиле (только INSERT):
Давайте разберём этот запрос по шагам, чтобы понять его логику:
-- new_customers — staging-таблица с обновлёнными данными
-- dim_customers_scd2 — основная таблица (append-only)
INSERT INTO dim_customers_scd2
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, -- уникальный ID каждой версии
n.customer_id, -- неизменный бизнес-ID клиента
n.name,
n.category,
COALESCE(n.effective_date, current_date) AS valid_from -- дата начала действия новой версии
FROM new_customers n
LEFT JOIN current_customers c ON n.customer_id = c.customer_id
WHERE c.customer_id IS NULL OR c.category != n.category; -- условие верно, если какие-то из полей справочника поменялисьCTE current_customers находит последнюю версию каждого клиента из таблицы dim_customers_scd2:
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
FROM dim_customers_scd2PARTITION BY customer_id— группируем по клиентамORDER BY valid_from DESC— сортируем версии от самой новой к самой старойWHERE rn = 1— выбираем только самую свежую версию
Теперь сравниваем новые данные с текущими:
FROM new_customers n
LEFT JOIN current_customers c ON n.customer_id = c.customer_idВозможные сценарии после JOIN:
| Сценарий | n.customer_id | c.customer_id | Условие WHERE | Результат |
|---|---|---|---|---|
| Новый клиент | 2 | NULL | ✅ c.customer_id IS NULL |
Вставляется |
| Категория изменилась | 1 | 1 | ✅ c.category != n.category |
Вставляется |
| Без изменений | 3 | 3 | ❌ оба условия ложны | Пропускается |
Для подходящих записей создаём новую версию:
uuid()— генерируем уникальный ключ для новой версииcurrent_date- функция, возвращающая текущую датыCOALESCE(n.effective_date, current_date)— устанавливаем дату начала действия новой версии
💡 Правильный подход к датам: В реальных ETL-процессах важно использовать дату из исходных данных, когда она доступна. Мы используем
COALESCE(n.effective_date, current_date), что означает:
- Если в
new_customersесть полеeffective_date— используем его- Если нет — используем текущую дату (
current_date)Почему это важно:
effective_dateотражает реальную дату изменения (например, когда клиент стал VIP)current_date— это дата загрузки данных, которая может не совпадать с датой изменения- Использование правильной даты критично для точного исторического анализа
Пример правильной структуры исходных данных:
new_customers: customer_id | name | category | effective_date 1 | Иван Петров | VIP | 2025-04-15 ← дата реального изменения
До выполнения запроса:
dim_customers_scd2:
customer_id | name | category | valid_from
1 | Иван Петров | Regular | 2024-01-01
Новые данные:
new_customers:
customer_id | name | category
1 | Иван Петров | VIP ← изменилась категория
2 | Мария Иванова| Regular ← новый клиент
После выполнения запроса:
dim_customers_scd2:
customer_id | name | category | valid_from
1 | Иван Петров | Regular | 2024-01-01 ← старая версия
1 | Иван Петров | VIP | 2025-11-04 ← новая версия
2 | Мария Иванова| Regular | 2025-11-04 ← новый клиент
💡 Ключевой момент: В append-only системах мы не обновляем старые записи, а только добавляем новые. История сохраняется автоматически!
Поскольку мы не можем обновлять valid_to у предыдущей версии, стандартный подход с BETWEEN не сработает. Вместо этого, для поиска нужной версии мы полагаемся на оконные функции или на логику «ближайшей даты, но не позже».
Это самый частый запрос. Мы хотим видеть самую свежую информацию о клиенте.
-- Вариант с оконной функцией (универсальный и надежный)
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
FROM dim_customers_scd2
)
SELECT
customer_id, name, category, valid_from
FROM ranked
WHERE rn = 1;Этот запрос берёт строку с самой поздней датой начала действия для каждого клиента.
Это основная цель SCD2. Например, «какой статус клиента был на дату заказа 2025-03-15?».
В append-only мире у нас нет valid_to, поэтому мы ищем последнюю версию, у которой valid_from ≤ целевой даты.
-- Запрос для получения состояния на '2025-03-15'
WITH as_of_date AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
FROM dim_customers_scd2
WHERE valid_from <= DATE '2025-03-15'
)
SELECT
customer_id, name, category, valid_from
FROM as_of_date
WHERE rn = 1;Как это работает:
WHERE valid_from <= DATE '2025-03-15'отфильтровывает все версии, которые появились после нашей целевой даты.ORDER BY valid_from DESCсортирует оставшиеся версии от самой свежей к самой старой.ROW_NUMBER() ... WHERE rn = 1выбирает самую свежую из актуальных на ту дату версий.
Это и есть «путешествие во времени» (time travel) в системах без встроенной поддержки этой функции.
Почему не использовать флаг «текущая версия»?
В append-only системах любой флаг актуальности становится устаревшим сразу после новой вставки. БезUPDATEего сложно поддерживать, поэтому в таких архитектурах чаще полагаются на даты и оконные функции — так модель остаётся идемпотентной.
Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них естественным выбором, так как его логика основана исключительно на добавлении данных, а не на их изменении.
| Сценарий | Рекомендуемый тип |
|---|---|
| Исправление опечатки | Type 1 |
| Юридически значимые изменения (статус, тариф, регион) | Type 2 |
| Очень простая аналитика без требований к истории | Type 1 |
| Нужна только «последняя смена» и ничего больше | Type 3 (осторожно!) |
Совет новичку: если сомневаетесь — выбирайте Type 2. Лучше иметь историю и не использовать её, чем не иметь и не суметь ответить на важный вопрос.
- Не используйте
customer_idкак первичный ключ в Type 2. Он повторяется! Вместо этого —customer_key(surrogate key). - Всегда задавайте
valid_toкакNULLдля актуальной записи, если это допустимо в вашей СУБД — это упрощает модель. - Для условий “актуально на дату” используйте паттерн:
d >= valid_from AND (valid_to IS NULL OR d < valid_to). - Type 2 увеличивает объём данных — но для аналитики это нормально.
- В связке с фактами: в таблице фактов храните
customer_key, а неcustomer_id— иначе не получится соединить с нужной версией.
Медленно меняющиеся измерения — это не «магия», а практический инструмент для честной и точной аналитики во времени.
Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своей БД. Задайте себе вопрос:
«Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»
Если нет — вам нужен SCD Type 2.
И помните: даже в системах без UPDATE вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.