Data EngineeringSCDDWHисторичность

SCD (Slowly Changing Dimensions): типы 1, 2 и 3 — как хранить историю

2026-07-12 12 мин

Если атрибут измерения меняется со временем — клиент переехал из Казани в Москву, сменил тариф, к нему прикрепили нового менеджера — у вас есть три базовых способа это хранить, и называются они SCD, Slowly Changing Dimensions. Type 1 перезаписывает старое значение поверх нового и теряет историю. Type 2 заводит на каждое изменение новую строку-версию с полями valid_from, valid_to и is_current, поэтому хранит всё — это тот самый золотой стандарт, к которому сводится большинство продакшн-витрин. Type 3 добавляет колонку «предыдущее значение» и помнит ровно один шаг назад. На практике разница между ними — это разница между отчётом, который честно показывает прошлое, и отчётом, который задним числом переписывает историю. Разберу каждый на живых таблицах.

Что такое медленно меняющиеся измерения и при чём тут город клиента?

Начну с модели данных. В хранилище есть факты и измерения. Факт — это событие: заказ, платёж, клик. Он случился один раз и больше не меняется. Измерение — это справочник, который описывает участников события: клиент, товар, магазин, менеджер. Строки в измерениях живут долго и иногда меняются, но медленно — не каждую секунду, как факты, а раз в месяцы. Отсюда и название: slowly changing dimensions.

Классический пример — таблица dim_customers. У клиента есть id, имя, город, тариф. Заказы (orders) ссылаются на него по customer_id. Пока клиент никуда не переезжает, всё просто. Проблема появляется в тот день, когда он меняет город: жил в Казани, сделал за полтора года 50 заказов, потом переехал в Москву и продолжил заказывать. Вопрос, на который вам придётся ответить: что делать со строкой в dim_customers? Перезаписать город? Завести новую строку? От этого решения зависит, что покажет ваш отчёт «выручка по городам» за прошлый год.

Что сломается в отчёте, если просто перезаписать значение?

Самое дорогое, что ломается, — сопоставимость с прошлым. Возьмём тот же переезд: клиент сделал 50 заказов из Казани и 10 из Москвы. Если вы просто обновили город в справочнике на «Москва», то при джойне orders с dim_customers все 60 заказов подтянут актуальный город. Отчёт «выручка по городам за 2024» задним числом переедет: Казань потеряет выручку, которую реально принесла, Москва получит лишнюю.

Дальше начинается неприятное. Финансы открывают дашборд и видят, что прошлогодняя цифра по региону изменилась с последнего раза — хотя в прошлое, казалось бы, попасть уже нельзя. Каждый переезд клиента незаметно шевелит исторические агрегаты. Ошибки в запросе нет, данные «правильные» — просто справочник хранит только текущее состояние мира, а отчёт спрашивает про состояние на дату заказа. Именно это противоречие и решают типы SCD. И именно поэтому тема не абстрактная для дата-инженеров, а прямая боль аналитика: без историчного измерения вы не посчитаете корректно retention по когортам, доход по тарифу на момент покупки или конверсию по каналу, который был у клиента тогда, а не сейчас.

SCD Type 1: перезапись — когда истории не жалко?

Type 1 — это обычный UPDATE. Новое значение затирает старое, прошлого больше не существует.

update dim_customers
set city = 'Москва',
    updated_at = now()
where customer_id = 4820;

Всё. Одна строка на клиента, всегда актуальная, никакого разбухания таблицы. Джойн простой, отчёты быстрые. Расплата одна, но серьёзная: восстановить, что клиент когда-то жил в Казани, уже невозможно — эта информация физически стёрта.

Когда это нормально? Когда историческая версия атрибута не имеет смысла или даже вредна. Опечатку в имени исправили — старую версию хранить незачем. Пользователь попросил удалить неверный email — по требованиям к персональным данным его вообще нельзя держать. Технический флаг, который никто не режет в разрезах, — тоже кандидат на Type 1. Правило простое: если ни один отчёт никогда не спросит «а что было раньше» — берите Type 1 и не усложняйте. Как только такой вопрос хотя бы теоретически возможен — Type 1 закрывает вам дорогу навсегда.

SCD Type 2: как хранить каждую версию строки?

Type 2 — стандарт индустрии для историчных измерений. Идея: строку не трогаем, а на каждое изменение добавляем новую версию. Старую при этом «закрываем» датой.

Для этого в таблицу добавляют служебные колонки: valid_from (с какого момента версия действует), valid_to (до какого) и is_current (флаг актуальной версии — чисто для удобства). Плюс появляется суррогатный ключ customer_sk, уникальный для каждой версии, тогда как customer_id (бизнес-ключ) остаётся общим для всех версий одного клиента.

После переезда таблица выглядит так:

customer_skcustomer_idcityvalid_fromvalid_tois_current
10014820Казань2023-01-102025-06-01false
10444820Москва2025-06-019999-12-31true

Логика обновления — две операции: закрыть текущую версию и вставить новую.

-- закрываем действующую версию
update dim_customers
set valid_to = '2025-06-01',
    is_current = false
where customer_id = 4820
  and is_current = true;

-- вставляем новую версию
insert into dim_customers (customer_id, city, valid_from, valid_to, is_current)
values (4820, 'Москва', '2025-06-01', '9999-12-31', true);

Обратите внимание на две детали, на которых спотыкаются. Первая — интервал полуоткрытый: valid_from новой версии совпадает с valid_to старой, а границы трактуются как valid_from, valid_to). Так между версиями нет ни зазора, ни нахлёста, и заказ ровно на дату перехода попадёт строго в одну строку. Вторая — для текущей версии valid_to ставят не NULL, а «бесконечность» вроде 9999-12-31. Тогда фильтр по дате работает без костылей с coalesce. Да, таблица растёт, джойны становятся сложнее — но взамен вы получаете полную, точную историю каждого атрибута. Для аналитики это почти всегда правильный размен.

SCD Type 3: зачем колонка «предыдущее значение»?

Type 3 — компромисс, который помнит ровно один шаг назад. Вместо новых строк добавляют новую колонку: previous_city рядом с city.

alter table dim_customers add column previous_city text;

update dim_customers
set previous_city = city,   -- старое значение уезжает в «предыдущее»
    city = 'Москва'         -- новое встаёт в актуальное
where customer_id = 4820;

Теперь в одной строке и текущий город, и прошлый. Удобно, когда бизнес мыслит категориями «до и после»: сравнить тариф до и после миграции, регион до и после реорганизации. Строк не прибавляется, джойн остаётся тривиальным.

Ограничение очевидное: помещается ровно одна прошлая версия. Второй переезд затрёт первый — цепочку Казань → Москва → Питер вы уже не восстановите. Поэтому Type 3 на практике встречается редко и обычно точечно: под конкретный атрибут, по которому бизнесу нужен ровно один прыжок в прошлое, а не вся история. В 2026-м, когда место в хранилище стоит дёшево, чаще берут полноценный Type 2.

Какой тип SCD выбрать под конкретную задачу?

Быстрый ориентир, которым пользуюсь сам:

  • Историческая версия не нужна или запрещена (опечатки, персональные данные, техфлаги) — Type 1.
  • Нужна полная история, отчёты должны показывать состояние на дату события — Type 2. Это дефолт для клиентов, тарифов, оргструктуры, любых разрезов, которые режут по времени.
  • Нужен только предыдущий шаг и важна простота одной строки — Type 3, точечно.

Ещё практическое правило: если сомневаетесь — берите Type 2. Переехать с Type 1 на Type 2 задним числом невозможно, потому что история уже утеряна; а вот схлопнуть Type 2 до «только текущей версии» через фильтр is_current = true можно в любой момент одной строкой в запросе. Type 2 ничего не отнимает — он просто оставляет вам выбор.

Отдельно держите в голове, что типы можно смешивать в одной таблице: у клиента город ведут по Type 2, а опечатки в имени правят по Type 1. Никто не заставляет применять один тип ко всем колонкам сразу.

Как SCD Type 2 собирается в dbt snapshots?

Руками писать «закрой версию — вставь новую» на каждое измерение утомительно и легко ошибиться. Поэтому в современном стеке Type 2 почти не пишут вручную — его генерирует dbt через механизм snapshots. Вы описываете источник, а dbt сам ведёт версии.

{% snapshot customers_snapshot %}

{{
    config(
      target_schema='snapshots',
      unique_key='customer_id',
      strategy='timestamp',
      updated_at='updated_at'
    )
}}

select customer_id, city, plan, updated_at
from {{ source('crm', 'customers') }}

{% endsnapshot %}

При каждом запуске dbt snapshot инструмент сравнивает свежие данные источника с сохранённым слепком. Если у клиента изменился атрибут, dbt закрывает старую версию и вставляет новую, добавляя служебные колонки dbt_valid_from, dbt_valid_to (у актуальной версии там NULL) и dbt_scd_id. По сути это ровно тот же Type 2, только руками вы не пишете ни строчки UPDATE/INSERT.

Стратегий определения изменений две. timestamp — самая надёжная: dbt смотрит на колонку updated_at, и если она выросла, значит запись изменилась. check — сравнивает список конкретных колонок (check_cols) и заводит новую версию, если хоть одна из них поменялась; берут её, когда в источнике нет честного updated_at. Один нюанс, о котором стоит помнить: snapshots видят только те состояния, которые застали на момент запуска. Если между двумя прогонами клиент успел переехать дважды, промежуточную версию dbt не увидит — поэтому снапшоты гоняют регулярно, а не раз в квартал.

Как написать корректный историчный отчёт на Type 2?

Вот ради чего всё затевалось. Чтобы отчёт показывал город на момент заказа, а не сегодняшний, джойн делается по бизнес-ключу И по попаданию даты факта в интервал версии.

select
    o.order_id,
    o.order_date,
    o.amount,
    c.city                       -- город, актуальный на дату заказа
from orders o
join dim_customers c
    on o.customer_id = c.customer_id
   and o.order_date >= c.valid_from
   and o.order_date <  c.valid_to;

Ключевое здесь — второе и третье условие джойна. order_date >= valid_from and order_date < valid_to выбирает ровно ту версию клиента, которая действовала в день заказа. Заказ из Казани в 2023-м подтянет Казань, заказ из Москвы в 2025-м — Москву, даже если сегодня клиент уже переехал в третий город. Именно поэтому берут строгое < на верхней границе: полуоткрытый интервал гарантирует, что заказ на стыке версий попадёт строго в одну строку и выручка не задвоится.

А если нужно, наоборот, текущее состояние — «сколько клиентов сейчас в Москве», без всякой истории — фильтруете по флагу:

select city, count(*) as customers
from dim_customers
where is_current = true
group by city;

Одна и та же таблица Type 2 закрывает оба сценария: и «как было тогда», и «как есть сейчас». В этом вся её сила. Такой интервальный джойн стоит один раз собрать руками, чтобы он улёгся в пальцы — потренироваться можно в [SQL-тренажёре или подсмотреть синтаксис BETWEEN и джойнов в SQL-справочнике.

Что спрашивают про SCD на собеседовании?

SCD — любимая тема секции про хранилища, особенно на позициях analytics engineer и на грейдах middle+. Чаще всего просят: объяснить разницу между Type 1 и Type 2 на пальцах, написать запрос, который достаёт актуальную на дату версию (тот самый интервальный джойн выше), рассказать, что такое valid_from/valid_to/is_current и суррогатный ключ. Любят вопрос-ловушку «что будет с историческим отчётом, если сделать Type 1» — проверяют, понимаете ли вы про переписывание прошлого.

Если готовитесь целенаправленно — соберите теорию по хранилищам и прорешайте SQL на джойны и оконки: разборы реальных задач лежат в разделе вопросов с собеседований и в тренажёре задач, а как эту тему спрашивают в конкретных командах — в разборах собеседований по компаниям. Понимание SCD и историчности — из тех навыков, что напрямую двигают грейд и вилку: насколько именно, я разбирал в обзоре зарплат аналитиков.

И совет напоследок: не заучивайте типы как список, держите в голове одну мысль — SCD это про то, врёт ваш отчёт о прошлом или нет. Как только начинаете смотреть на измерения через вопрос «а что было на дату события», выбор типа становится очевидным сам собой. Если хотите системно закрыть SQL для хранилищ — джойны, оконные функции, интервальную логику — и дойти до уровня, когда историчный отчёт пишется на автомате, загляните в курс по SQL. Pro-доступ открывает все задачи тренажёра и разборы, но и на бесплатном хватит, чтобы собрать первый корректный джойн по Type 2 уже сегодня.

Связанная тема — dbt Snapshots: SCD Type 2 для истории изменений.

Отработай SQL на практике
545 SQL-задач с автопроверкой — первые открыты без регистрации.
SQL-тренажёр →