Files
de-roadmap/SCD.md
T
2025-10-19 12:33:27 +03:00

18 KiB
Raw Blame History

Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных

Для кого эта статья?
Для тех, кто уже умеет писать базовые SQL-запросы в PostgreSQL, знаком с понятиями таблиц, строк и колонок, и теперь делает первые шаги в аналитике и проектировании хранилищ данных.


1. Введение: зачем вообще нужны измерения и почему они «медленно меняются»?

Представьте, что вы строите отчёт по продажам. У вас есть таблица с фактами — например, «продано 10 единиц товара X клиенту Y 15 марта». Но чтобы понять, кто такой клиент Y или что за товар X, вам нужны справочники — таблицы с описанием клиентов, товаров, регионов и т.п.

В мире аналитики такие справочники называют измерениями (dimensions), а таблицу с продажами — фактами (facts).

А теперь представьте: клиент сменил адрес или перешёл в другую категорию (например, из «обычного» в «VIP»). Если вы просто обновите строку в таблице клиентов, то потеряете информацию о том, какой статус у клиента был на момент продажи. А это критично: отчёт «продажи VIP-клиентам в марте» окажется неверным!

Такие атрибуты — которые меняются со временем, но не каждый день — и называются медленно меняющимися измерениями (Slowly Changing Dimensions, SCD).


2. Что такое Slowly Changing Dimensions (SCD)?

SCD — это подход к хранению изменений в измерениях с учётом времени. Он позволяет отвечать на вопросы вроде:

  • Какой адрес у клиента был на дату заказа?
  • Сколько продаж пришлось на товары категории «Электроника» до того, как её переименовали в «Гаджеты»?

Без SCD вы видите только текущее состояние, а с ним — всю историю.


3. Типы SCD — простыми словами

Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные.

Type 0 — Никогда не меняется

Атрибут фиксирован навсегда. Например, дата рождения клиента или идентификатор паспорта.
Такие поля не требуют специальной обработки — они просто не обновляются.

Type 1 — Просто перезаписать

Вы просто делаете UPDATE, и старое значение исчезает.

Просто.
История теряется.

Подходит, если изменение — это исправление ошибки (например, опечатка в имени).

Type 2 — Новая строка для новой версии

Каждое изменение порождает новую строку в таблице. Старая строка остаётся, но помечается как «устаревшая».

Полная история.
Можно восстановить состояние на любую дату.
Больше данных, сложнее запросы.

Это самый распространённый подход в аналитике.

Type 3 — Добавить колонку «предыдущее значение»

В таблице появляются поля вроде previous_category, category_change_date.

Простая история «до/после».
Хранит только одно предыдущее значение. Не масштабируется.

Используется редко, чаще как компромисс в очень простых системах.

Type 4, 5, 6 — Продвинутые гибриды (для справки)

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

  • Type 4: история выносится в отдельную таблицу («мини-хранилище» для одного измерения).
  • Type 5: комбинация Type 4 и Type 1 — текущее значение в основной таблице, а история — отдельно.
  • Type 6: объединяет Type 1, 2 и 3 в одной таблице — очень гибко, но сложно.

Вам не нужно запоминать эти типы сейчас. Достаточно знать, что они бывают — на случай, если встретите их в документации.


4. Практический пример на PostgreSQL: Type 1 vs Type 2

Допустим, у нас есть таблица клиентов:

-- Исходная таблица (до изменений)
CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL  -- например: 'Regular', 'VIP'
);

Type 1: просто обновляем

Клиент №1 стал VIP:

UPDATE customers
SET category = 'VIP'
WHERE customer_id = 1;

Теперь в таблице только новое значение. Если продажа была сделана до этого UPDATE, вы не узнаете, что клиент тогда был «Regular».


Type 2: сохраняем историю — подробнее

Чтобы хранить историю, мы меняем структуру таблицы. Вот ключевые поля:

  • customer_key — искусственный (surrogate) первичный ключ. Он уникален для каждой версии клиента.
  • customer_id — бизнес-идентификатор (например, из CRM). Он не меняется и связывает все версии одного клиента.
  • valid_from — дата, с которой эта версия стала актуальной.
  • valid_to — дата, по которую эта версия была актуальной. Если NULL — значит, версия актуальна сейчас.
  • is_current — флаг (true/false), который быстро показывает, какая строка — последняя. Удобен для запросов.

Пример структуры:

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 = сейчас)
    is_current BOOLEAN NOT NULL          -- true = актуальная версия
);

Шаг 1. Добавляем начальную запись (клиент зарегистрировался 1 января 2024):

INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
VALUES (1, 'Иван Петров', 'Regular', '2024-01-01', NULL, true);

Шаг 2. 15 апреля 2025 клиент становится VIP. Мы делаем два действия:

  1. Закрываем старую запись: указываем, что она была актуальна до 14 апреля.
  2. Добавляем новую запись: она начинает действовать с 15 апреля и пока актуальна.
-- 1. Завершаем предыдущую версию
UPDATE customers_scd2
SET valid_to = '2025-04-14',
    is_current = false
WHERE customer_id = 1 AND is_current = true;

-- 2. Вставляем новую версию
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true);

Теперь в таблице две строки для одного клиента. И мы можем спросить:

Какой была категория клиента на 10 апреля 2025?

SELECT category
FROM customers_scd2
WHERE customer_id = 1
  AND '2025-04-10' >= valid_from
  AND '2025-04-10' <= COALESCE(valid_to, '9999-12-31');

Результат: 'Regular' — правильно!

💡 Почему COALESCE(valid_to, '9999-12-31')?
Потому что у актуальной записи valid_to IS NULL. Чтобы условие работало, мы временно заменяем NULL на очень далёкую дату.


Type 2: можно ли обойтись без is_current?

Поле is_current не обязательно. Оно удобно для быстрого поиска актуальной версии (например, WHERE is_current = true), но всю ту же информацию несёт пара valid_from / valid_to.

Многие реализации SCD Type 2 обходятся без этого флага — особенно в системах, где важна нормализация или минимизация избыточности. В таких случаях актуальность определяют по условию:

WHERE valid_to IS NULL

Или, если используется «закрытый» интервал (например, valid_to = '2025-04-14' для прошлой версии, а у новой — valid_from = '2025-04-15', valid_to = '9999-12-31'), то:

WHERE CURRENT_DATE BETWEEN valid_from AND valid_to

Так что is_current — это опциональное упрощение, а не часть стандарта.


Type 2 через логику, похожую на UPSERT

В реальных ETL-процессах часто используют идемпотентные операции: запуск скрипта дважды не должен ломать данные. Для этого удобно применять подход, похожий на upsert (update + insert), но адаптированный под логику SCD Type 2.

В PostgreSQL классический ON CONFLICT не подходит напрямую, потому что мы не обновляем существующую строку, а добавляем новую при изменении.

Поэтому логика выглядит так:

  1. Сравнить входящие данные с последней версией в таблице.
  2. Если атрибуты изменились — закрыть старую запись и вставить новую.
  3. Если не изменились — ничего не делать.

Пример (часто реализуется в 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 is_current = true
)
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
SELECT 1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true
WHERE EXISTS (
  SELECT 1 FROM last_version WHERE category != 'VIP'
);

-- Шаг 2: если вставка произошла — закрываем старую запись
UPDATE customers_scd2
SET valid_to = '2025-04-14', is_current = false
WHERE customer_id = 1 AND is_current = true
  AND EXISTS (
    SELECT 1 FROM customers_scd2
    WHERE customer_id = 1 AND category = 'VIP' AND valid_from = '2025-04-15'
  );

На практике такие логики чаще выносят в ETL-инструменты (например, dbt с пакетом dbt-scd), потому что чистый SQL быстро становится громоздким.

💡 Главное: SCD Type 2 — это не одна операция, а процесс: сравнить → закрыть старое → добавить новое.


А что, если СУБД не позволяет UPDATE? (Trino, Hive, ClickHouse в режиме append-only)

Некоторые аналитические системы (например, Hive в формате ORC/Parquet, Trino, ClickHouse в режиме только вставки) не поддерживают UPDATE старых строк. Как тогда реализовать SCD Type 2?

Ответ: никаких UPDATE не нужно — ведь в Type 2 мы и так не меняем старые данные, а только добавляем новые!

Алгоритм:

  1. Читаем последнюю версию измерения (например, через оконную функцию).
  2. Сравниваем с новыми данными.
  3. Если есть изменения — пишем новую строку с новыми valid_from / valid_to.
  4. Старые строки остаются нетронутыми.

Пример в Trino/Hive-стиле (только INSERT):

-- new_customers — staging-таблица с обновлёнными данными
-- 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
)
SELECT
  uuid() AS customer_key,               -- или хеш, sequence и т.п.
  n.customer_id,
  n.name,
  n.category,
  current_date AS valid_from,
  CAST(NULL AS DATE) AS valid_to,
  true AS is_current
FROM new_customers n
LEFT JOIN current c ON n.customer_id = c.customer_id
WHERE c.category IS DISTINCT FROM n.category;

А чтобы «закрыть» предыдущую версию, не нужно её обновлять — просто при чтении всегда берите актуальную на дату версию через условия с valid_from/valid_to.

Таким образом, SCD Type 2 идеально подходит для систем без поддержки UPDATE — потому что он по своей природе append-only (только добавление).


5. Когда что использовать?

Сценарий Рекомендуемый тип
Исправление опечатки Type 1
Юридически значимые изменения (статус, тариф, регион) Type 2
Очень простая аналитика без требований к истории Type 1
Нужна только «последняя смена» и ничего больше Type 3 (осторожно!)

Совет новичку: если сомневаетесь — выбирайте Type 2. Лучше иметь историю и не использовать её, чем не иметь и не суметь ответить на важный вопрос.


6. Подводные камни и советы

  • Не используйте customer_id как первичный ключ в Type 2. Он повторяется! Вместо этого — customer_key (surrogate key).
  • Всегда задавайте valid_to как NULL для актуальной записи — это упрощает запросы.
  • Используйте COALESCE(valid_to, '9999-12-31') в условиях, чтобы избежать NULL-проблем.
  • Type 2 увеличивает объём данных — но для аналитики это нормально.
  • В связке с фактами: в таблице фактов храните customer_key, а не customer_id — иначе не получится соединить с нужной версией.

7. Заключение

Медленно меняющиеся измерения — это не «магия», а практический инструмент для честной и точной аналитики во времени.

Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своём PostgreSQL. Задайте себе вопрос:

«Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»

Если нет — вам нужен SCD Type 2.