Как спроектировать модель данных, чтобы восстановить состояние сущности на любую дату?
Используйте временную модель данных: для каждой версии сущности храните период её действия — момент начала и момент окончания. Состояние на дату получают выбором версии, для которой дата попадает в интервал [начало, окончание), а текущую версию обозначают незакрытым интервалом.
Важно заранее определить, какую историю нужно хранить: фактическую историю действия данных или ещё и историю того, когда система узнала об изменении. Для второго случая нужна двухвременная модель.
Обычная таблица хранит последнее известное значение и при обновлении уничтожает прежнее. Такой подход достаточен для оперативной работы, но не позволяет достоверно ответить, какое значение действовало на прошлой дате.
Потребность в восстановлении прошлого состояния возникает в отчётности, расчёте условий по состоянию на дату, расследовании ошибок и предметных областях, где изменения должны быть объяснимыми. Временная модель отделяет актуальное состояние от последовательности его версий.
Предположим, у клиента меняется тариф. Если просто заменить старое значение новым, расчёт операции за прошлый месяц может использовать сегодняшний тариф. Копирование текущих данных в отдельную архивную таблицу частично решает задачу, но часто не определяет точные границы действия версии и усложняет согласованное чтение.
Неверная модель создаёт несколько рисков: пересекающиеся периоды, отсутствие версии для нужной даты, две одновременно действующие версии и неоднозначность при исправлении ошибочно внесённых данных. Особенно опасно хранить только дату изменения: она показывает момент записи, но не всегда означает момент, с которого значение стало действовать.
Для каждой версии хранятся как минимум идентификатор сущности, значения атрибутов, действует_с и действует_до. Интервал обычно задают как полуоткрытый: начало включается, окончание не включается. Тогда соседние версии могут соприкасаться без неоднозначности: одна действует до момента T, следующая — начиная с T.
При изменении не перезаписывают действующую версию. Её период закрывают моментом изменения, а затем создают новую версию с тем же моментом начала. Для текущей версии действует_до оставляют пустым или используют специальный верхний предел. Запрос состояния на дату должен выбрать ровно одну версию, попадающую в её интервал.
На уровне модели нужно обеспечить инварианты: периоды одной сущности не пересекаются, для одной сущности существует не более одной текущей версии, а начало периода новой версии согласовано с окончанием предыдущей. Эти правила следует защищать не только логикой приложения, но и ограничениями базы данных либо транзакционной блокировкой, иначе параллельные изменения могут создать две действующие версии.
Если исправляется историческая ошибка, различайте два времени. Время действия отвечает на вопрос «когда значение было применимо в предметном мире», а системное время — «когда система записала или узнала об этом значении». Хранение обоих интервалов образует двухвременную модель и позволяет, например, восстановить как исправленное состояние на дату, так и прежний результат отчёта, сформированного до исправления.
Основной компромисс — рост объёма данных и усложнение запросов. Снимки состояния упрощают чтение, но занимают больше места и могут дублировать данные; журнал изменений экономичнее, однако для восстановления состояния требуется последовательное применение событий. Временные версии обычно удобнее для аналитических запросов, но требуют дисциплины изменения и явной политики исправления истории.
В системе расчёта комиссий нужно пересчитывать операции по тарифу, действовавшему в момент операции, и объяснять результат аудитору. Рассматривались три варианта: хранить только текущий тариф, делать ежедневные снимки и хранить интервальные версии тарифа.
Текущий тариф не позволяет надёжно восстановить прошлое состояние. Ежедневные снимки дают состояние на выбранный день, но не всегда позволяют определить внутридневное изменение и заметно увеличивают объём данных. Интервальные версии точно фиксируют границы действия и позволяют искать тариф непосредственно по моменту операции.
Выбрали интервальную модель с запретом пересечения периодов и отдельным журналом кто, когда и по какой причине внёс изменение. Для исправлений задним числом добавили системное время записи, чтобы отличать первоначальный расчёт от результата после корректировки. В итоге расчёт использовал тариф по времени операции, а аудит мог воспроизвести как актуальную, так и ранее зафиксированную картину данных.
Нужно заранее выбрать соглашение о границах, обычно [начало, окончание): момент окончания старой версии включается в новую. Нельзя смешивать включительные и исключительные границы или сравнивать даты с разной точностью, иначе одна операция может попасть сразу в две версии либо не попасть ни в одну.
Нет, если дата изменения в системе и дата начала действия могут различаться. Например, корректировку тарифа внесли сегодня, но установили, что он действовал с начала прошлого месяца. Для такого сценария нужны отдельные понятия времени действия и времени фиксации в системе; иначе нельзя корректно отличить ретроспективное исправление от обычного обновления.
Два параллельных процесса могут одновременно прочитать одну текущую версию и оба создать новые версии. Каждый процесс по отдельности увидит корректное состояние, но после фиксации появятся пересекающиеся интервалы. Поэтому проверку нужно выполнять атомарно: через подходящее ограничение базы данных, блокировку диапазона или сериализацию операций изменения, дополненную обработкой конфликта.