Pull to refresh

Comments 50

«Хабралюди, поделитесь, какие интересные гибридные реализации Вы встречали?»

— кэш Google. он всегда всё помнит
Не увидел варианта хранения истории изменений на уровне колонки, на не таблицы. Очень помогает когда в таблице много полей, а изменения затрагивают небольшое количество колонок.
Можно получить простое решение для аудита изменений всех таблиц в бд только с одной таблицей истории, особенно если orm/dal позволяет расширить поведение при сохранении записи.
Дело в том, что это уже получится таблица фактов второго типа, а не измерений.
ID, Key, Value, Time.
Храню в мускуле данные как графы.

Видел еще.
«ID+Timestamp», Key1, Key2, Key 3
Особый интерес вызывает внезапное изменение структуры таблиц. Как без головной боли обновить их историю? Интересно, умеет-ли оракл сам разруливать такие ситуации.
как совет: используйте сразу SCD6(1+2+3)
с ним очень удобно да методы работы отработаны, могу пару приемчиков показать.
будет вторая статья про SCD в рунете ;)
например техника ACT-Views
каждый SCD-дименшен имеет ACT-вьюху
ACT-ВЬЮХИ НЕ ОТФИЛЬТРОВЫВАЮТ ТОЛЬКО НЕАКТИВНОЙ ЗАПИСИ! Они показывают значения
активных записей для активных и неактивных записей.
Таким образом вы не ломаете структуры таблицы. Но в отчете для записей у которых несколько версий будет выведены актуальные данные!
Т.е. вьюшка заменяет реальную таблицу с версионированием, подставляя во все записи актуальные данные.

CREATE OR REPLACE FORCE VIEW «TRAINING_SAL».«DIM_AGENTS_ACT» («AGENT_SURR_ID», «AGENT_ID», «AGENT_CODE», «AGENT_DESC», «AGENT_FIRST_NAME», «AGENT_LAST_NAME», «AGENT_EMPLOYEE_LOGIN», «AGENT_EMPLOYEE_EMAIL», «AGENT_TEAM_ID», «AGENT_TEAM_DESC», «AGENT_TEAM_MANAGER», «AGENT_STANDORT_ID», «AGENT_STANDORT_DESC», «IS_ACTIVE», «VALID_FROM», «VALID_TO», «INSERT_DT», «UPDATE_DT»)
AS
SELECT
— All columns from the table with the active records
— Only SURR_ID from the table with all records is taken
b.agent_surr_id,
a.agent_id,
a.agent_code,
a.agent_desc,
a.agent_first_name,
a.agent_last_name,
a.agent_employee_login,
a.agent_employee_email,
a.agent_team_id,
a.agent_team_desc,
a.agent_team_manager,
a.agent_standort_id,
a.agent_standort_desc,
a.is_active,
a.valid_from,
a.valid_to,
a.insert_dt,
a.update_dt
FROM
— Self-Join table by id key
dim_agents_scd a,
dim_agents_scd b
WHERE a.agent_id = b.agent_id
AND
— Filter for active records only
a.is_active = 'Y';

вот такая идея ;)
Забыл))
Достоинства:
1 в приложение не нужно внедрять сложную логику
2 вьюшка оптимизирована на чтение
3 сохранение данных в SCD-дменшен производиться добавлением новой записи и все!

недостатки:
избыточность)) но на практике дименшены не сильно распухают
Во втором совете я сказал о создании простых обновляемых представлений и они вполне благополучно могут использоваться и во втором типе.
будем считать что я вас дополнил ;)
Я прямо сейчас (решил кофейку попить и хабр почитать, а тут статья — телепатия :) ) историю состояний объектов реализую так:
таблица объектов object (id, atr1, atr2, atr3, object_state_id)
таблица состояний объектов object_state (id, object_id, atr4, atr5, atr6, date)
где atr1-3 атрибуты 0 и 1 типа, не требующие версионности, а atr4-6 требующие, то есть история значений атрибутов хранится в отдельной таблице, а в объекте ссылка на последнее состояние (по сути и она избыточна, но искать каждый раз максимальное значение date точно не тру). Остальные таблицы ссылаются на object_state, к которой при необходимости джойнится object для получения атрибутов 0 и 1 типа.

Можно сказать, наверное, что это спровоцированный тип 2 :) Не исключено, что в таблице object введу atr4-6 с текущими значениями, чтобы не делать джойн каждый раз для получения текущих значений, но это уже после нагрузочного тестирования — это получится практически тип 4. В этом случае предвижу трудности с разделением ссылок из других таблиц на те, которым нужны атрибуты 1-3 (0 и 1 типа), и на те, которым нужны 4-6(с версиями). Возможно даже в некоторых таблицах придётся вводить две ссылки на объект вообще (чтобы можно было получить текущие значения) и на его конкретную версию (чтобы получать значения на момент создания/изменения).

А выборка данных нормально реагирует на постоянные джойны таблиц? Хотя внутренний джойн, конечно, относительно быстро работает.
Без нагрузочного тестирования и профилирования пока приемлемо. Собственно остановился на таком варианте, как на компромиссе между полностью нормализованной БД и почти полностью (за исключением «словарей») денормализованной. Джойны 1:1 (объект->текущее состояние) работают достаточно шустро по сравнению с полностью нормализованными связями (объект->все состояния->условие на текущее), а забот насчёт поддержания целостности меньше по сравнению с полностью денормализованными.
используем связку из трех таблиц для мониторинга изменений сложных объектов

1 таблица: сессия. какой объект был изменен, кем и когда
2 таблица: список затронутых таблиц и первичных ключей. связана с первой по ключу сессии
3 таблица: список измененных полей в строке таблицы. связана со второй по ключу изменения. в этой таблице лежит имя поля, старое значение и новое значение. для удобства, во второй таблице добавлено избыточное поле с типом операции: вставка, изменение или удаление данных

бенифиты:
— полная история изменений, в том числе у объектов, которые включают данные из многих таблиц
— возможность откатиться до нужной версии
— возможность запланировать изменение в будущем и «накатить» изменения вручную либо по крону
— возможность выбирать какие объекты логировать, а какие нет

недостатки:
— логика сохранения изменений ложится на приложение
— внешние изменения не отображаются в логе (хотя их можно вычислить)
Я использую тип 3 в одном своем приложении, чтобы запомнить предыдущее состояние выполнения job'а. Результаты выполнения job'ов хранятся в отдельной таблице, но чтобы не делать лишнюю выборку, быстрее было запоминать предпоследнее значение в самой записи.

Еще в одном проекте над которым я работаю (c#) версионность реализована как в типе 4: при любом изменении записи ее копия вставляется в другую похожую таблицу (эта табличка находится в другой базе, правда в том же экземпляре СУБД). Похожую, потому что во-первых не все оригинальные колонки записи копируются, а во-вторых есть дополнительные колонки:
— primary key из оригинальной таблицы копируется в обычную колонку новой таблице
— у новой таблицы есть свой primary key (чтобы работало с linq2sql)
— дополнительная колонка в новой таблице содержит тип изменения (удаление, update)
— и есть колонка с датой, когда было изменение

Копирование происходит не на уровне базы, а из кода c# достаточно универсально: тот же самый код используется для копирования разных сущностей. Также на уровне работы с данными есть контроль над тем, для каких сущностей вести историю (и для каких полей: по соответствию названий колонок архивной и оригинальной табличек), для каких нет.

С БД на данный момент работает только один клиент.

Поддерживать такую схему не очень удобно — нужно поддерживать две одинаковые таблички. И при bulk update все очень медленно работает. В последнем случае мы просто не поддерживаем архивирование, потому что по предметной области нет смысла вести историю этих записей.
при любом изменении записи ее копия вставляется в другую похожую таблицу (эта табличка находится в другой базе, правда в том же экземпляре СУБД)

Не первый раз уже встречаю, что история ведется в соседней базе. А в чем преимущество такого подхода? Не распухает основная база -> быстрей работает?
Архитектуру придумали до того как я попал на проект, не могу сказать какая была задумка. Но могу сказать, что быстрее точно не работает.
Зато есть один неприятный момент: из-за того что работа может вестись одновременно с двумя соединениями, то может начаться распределенная транзакция и если на сервере она не сконфигурирована (а на наших dev-серверах она не настроена), получаем exception. Основное неудобство — с тестами. Обычно, если тесту нужна база, просто оборачиваем тело метода в TransactionScope и в конце делаем rollback. А с двумя базами так не получается.
>Не первый раз уже встречаю, что история ведется в соседней базе.
>А в чем преимущество такого подхода? Не распухает основная база -> быстрей работает?
ага. для истории, например, можно сменить тип Recovery model и не хранить здоровый transaction log (это я про MS SQL), наверняка в других СУБД тоже можно найти в этом некоторый профит
В СУБД Cache использую что-то наподобии типа 2, только данные храню не в таблице — а в глобале (дереве):

  1. все текущие версии объектов имеют 0ю версию
  2. 1я версия (если она есть) — самая старая
  3. по умолчанию все запросы к БД оперируют с нулевыми версиями
  4. по умолчанию индексы для ненулевых версий не строятся
  5. в случае регулярной работы с историей — строится дополнительный специальный индекс под те объекты историю которых необходимо мониторить


Пример глобала с объектом CommercialIntention (комерческое намерение) 45537379, текущая версия нулевая, количество предыдущих версий 4:
^Media("CommercialIntention",45537379,0,"LicenseID")=71
^Media("CommercialIntention",45537379,0,"Type")="CommercialIntention"
^Media("CommercialIntention",45537379,0,"UpdateTime")="61950,40226.87313"
^Media("CommercialIntention",45537379,0,"baseOrderList")=45999067
^Media("CommercialIntention",45537379,0,"classifiedList")=45999066
^Media("CommercialIntention",45537379,0,"editionId")=6
^Media("CommercialIntention",45537379,0,"editorLicenseID")=71
^Media("CommercialIntention",45537379,0,"statusId")=2

^Media("CommercialIntention",45537379,1,"LicenseID")=45
^Media("CommercialIntention",45537379,1,"Type")="CommercialIntention"
^Media("CommercialIntention",45537379,1,"UpdateTime")="61944,39328.740813"
^Media("CommercialIntention",45537379,1,"baseOrderList")=45537378
^Media("CommercialIntention",45537379,1,"classifiedList")=45537377
^Media("CommercialIntention",45537379,1,"editionId")=6
^Media("CommercialIntention",45537379,1,"sourceTypeId")=50140
^Media("CommercialIntention",45537379,1,"statusId")=1

^Media("CommercialIntention",45537379,2,"LicenseID")=71
^Media("CommercialIntention",45537379,2,"Type")="CommercialIntention"
^Media("CommercialIntention",45537379,2,"UpdateTime")="61944,39807.398159"
^Media("CommercialIntention",45537379,2,"baseOrderList")=45537575
^Media("CommercialIntention",45537379,2,"classifiedList")=45537574
^Media("CommercialIntention",45537379,2,"editionId")=6
^Media("CommercialIntention",45537379,2,"editorLicenseID")=71
^Media("CommercialIntention",45537379,2,"statusId")=2

^Media("CommercialIntention",45537379,3,"LicenseID")=71
^Media("CommercialIntention",45537379,3,"Type")="CommercialIntention"
^Media("CommercialIntention",45537379,3,"UpdateTime")="61950,37803.280408"
^Media("CommercialIntention",45537379,3,"adTypeId")=30202
^Media("CommercialIntention",45537379,3,"baseOrderList")=45996926
^Media("CommercialIntention",45537379,3,"classifiedList")=45996925
^Media("CommercialIntention",45537379,3,"editionId")=6
^Media("CommercialIntention",45537379,3,"editorLicenseID")=55
^Media("CommercialIntention",45537379,3,"sourceTypeId")=50140
^Media("CommercialIntention",45537379,3,"statusId")=3

^Media("CommercialIntention",45537379,4,"LicenseID")=71
^Media("CommercialIntention",45537379,4,"Type")="CommercialIntention"
^Media("CommercialIntention",45537379,4,"UpdateTime")="61950,40193.904669"
^Media("CommercialIntention",45537379,4,"baseOrderList")=45999039
^Media("CommercialIntention",45537379,4,"classifiedList")=45999038
^Media("CommercialIntention",45537379,4,"editionId")=6
^Media("CommercialIntention",45537379,4,"editorLicenseID")=71
^Media("CommercialIntention",45537379,4,"statusId")=8



Как-то ковырял Oracle Workflow, который целиком написан на PL. Фишка как раз была в том, что бизнес процессы могли меняться, но запущенный процесс должен следовать версии, актуальной на момент запуска, даже если впоследствие процесс изменился.

Реализация была как раз по «типу 2». Однако структура данных была «продублирована» views, которые выдавали текущую версию процессов.

P.S. впринципе поле DATE_START не нужно, хватит одного DATE_END
Вообще-то нужно, иначе любой запрос на версию от конкретной даты будет возвращать курсор с кучей значений, которые надо еще отсортировать по DATE_END, и выдать только первый вариант.

А теперь, допустим у Вас есть Oracle, в котором нет LIMIT, и надо связать 3 таблицы с такой версионностью без DATE_START. Тяжело будет написать и долго будет обрабатываться.
LIMIT нет, зато есть более продвинутый rownum, с помощью которого можно легко вернуть самую последнюю запись.
в Oracle никакого аналога LIMIT нет (не считая вложенных select`ов с row_number() over (трампампам)) и rownum вам не поможет если есть сортировка, а без сортировки гарантировать что строки всегда будут в правильном порядке — нельзя. Долго такой баг ловить будете.
чтобы использовать rownum, надо это дело оборачивать в подзапрос. А теперь представьте, что джойните три такие таблицы. И с этими rownum начинается такая свистопляска, что мама не горюй.
Использую дополнительные поля vn(INT), is_latest( 'Y','N'),
соответственно номер версии и флаг, последняя ли она.

На все запросы к таблицам с версиями добавляется автоматом суффикс AND is_latest='Y'.
Используем 4й тип. Только в доп. таблице еще есть запись кто и когда что менял. Помогает находить злостных вредителей.
Если я не ошибаюсь, то 6-й тип — это bitemporal database. У нас например так хранится дерево (граф) объектов. Собственно, какие там есть мысли DATE_{START,END}, VERSION_{START,END}, а также для каждой записи ID и GLOBALID — то есть, ид текущий (DS,DE,VS,VE) и немутирующий идентификатор. При этом достигается гибкость в плане использования будущих периодов и фиксации старых значений (историчность). Версионность — имхо, это конечно костыль. Как написано на вики есть transaction time — его и надо учитывать, и отдельно вести список версий, то есть список точек времени с историей.
Transaction time тут не при чем, т.к. может быть изменена не текущая дата и не текущим временем -> дата транзакции совершенно не валидна с точки зрения объекта.
Я бы еще добавил, что правильная историчность — это один шаг для репликации на другую базу. Взаимное применение изменений баз одной на другую, их пересечение и будет актуальным состоянием базы.
А как насчет такого способа:
Для таблицы %TABLENAME% имеем суррогатный ключ ID+VERSION_NUMBER плюс стандартные поля CREATED, UPDATED.
Создаем таблицу %TABLENAME%_CURRENT_VERSION(ID, VERSION_NUMBER). Для каждой новой версии записи — создаем дубликат в %TABLE_NAME%, в нем инкрементируем VERSION_NUMBER, присваиваем новое значение UPDATED, и обновляем соответствующее значение VERSION_NUMBER в таблице %TABLENAME%_CURRENT_VERSION для данного ID.
Выборка актуальных версий на конкретную дату производим по дате UPDATED,
Актуальную версию выбираем через INNER JOIN к таблице %TABLENAME%_CURRENT_VERSION
При необходимости можно зашедулить перенос устаревших версий в таблицу дубликат, если размеры таблиц начнут сказываться на производительности.
у нас каждая таблица(которая должна иметь историчность) имеет следующие дополнительные поля seq (0 — для текущей записи, primary key является связка (seq,id)), плюс поля кем и когда создано, кем и когда изменено. для выборки текущих значений создаем view. при обновлении записи, сначала генерим новый seq, создаем копию текущего состояния с новым seq, кто и когда обновил, потом обновляем значения в записи с seq=0.
Я еще использовал такой способ — для полей, которые часто меняются создавал отдельную таблицу. Плюс в том, что уменьшается объем памяти, требуемый для хранения истории. Если создавать для каждого поля, то база начинает страшно тормозить из-за многочисленных join.
Поля, которые часто меняются, по сути уже не совсем SCD, это уже практически таблицы фактов.
У нас используется метод 2. Для аудита всех таблиц ведется одна отдельная большая партиционированная таблица с инфо о том, кто изменил (ip, машина, логин, и т.д.), имеющая ссылки на измененную таблицу и id измененного объекта. По прошествии времени старые партиции отправляются в архив.
Тоже используем такую же таблицу аудита с собственными пакетами, которые помимо самих процедур изменения объектов, содержат процедуры для восстановления измененных данных.
Я недавно как раз на эту тему вот такую несложную библиотечку для PostgreSQL писал:
github.com/DmitryKoterov/dklab_rowlog

Можно подключить к любой таблице логирование по типу 4 за одну команду, причем сами данные хранятся в hstore. Пример использования (в README на github-е есть еще):

— Example: monitor change of column «a» and «c». Add an entry to public.rowlog
— table if and only if one of these columns are changed.
CREATE TRIGGER t_rowlog
AFTER INSERT OR DELETE OR UPDATE ON test_src1 FOR EACH ROW
EXECUTE PROCEDURE rowlog.t_rowlog_aiud('diff=>a', 'diff=>c', 'rowlog=>public.rowlog');

Только надо учитывать 2 вещи при использовании:
1. Библиотека не рассчитана на ультравысокие нагрузки, т.к. там внутри несколько EXECUTE SQL-ей.
2. Хранение измененных данных в hstore удобно тем, что быстро новые поля можно добавлять, однако есть и недостаток — если табличка-источник сильно меняется (например, там поля удаляются или переименовываются), в hstore останутся старые версии со старой структурой.

Поэтому на практике я бы порекомендовал ее применять в основном для целей подсчета статистики различной по живой базе (KPI).
Оригинальная мысль, но почему в библиотеке не создать процедуру, которая и будет создавать необходимые триггеры с необходимым кодом?
Некая разновидность 4-го типа реализована в модуле Hibernate-Envers.
Я как раз сейчас пишу подобную штуку для MySQL, чтобы автоматом создавать лог-таблицы, триггеры, а так-же мониторить изменения схемы…
Используем второй способ. Именно так реализована MVCC в InnoDB (MySQL) — оттуда о нём и узнали. Только там вместо дат используются внутренние номера транзакций.

У нас же реализация полностью на ПХП (MySQL еще недорос для перекладывания на неге какой-либо логики) как extension к Yii.

Было интересно узнать про другие способы, спасибо.
Использую вариацию 4-го метода. Вариация заключается в том, что храню только разницу по сравнению с предыдущей записью, следовательно объем хранимой информации сильно меньше (при том что таблица широкая и за раз изменяется только порядка 10% полей). Если поля в текущей и предыдущей записях эквивалентны, в таблицу логов записывается NULL. Впрочем, есть свои хитрости чтобы отличить в таблице лога реальный NULL от повторения значения. Состояние записей на любой прошлый момент времени собираются через аналитику, можно просмотреть шаги изменений, уточнить кто производил изменения и т.п… Текущее состояние видно сразу без всяких хитростей.
Полагаю, я изобрел велосипед. До сих пор не знаю как такой метод называется, но он работает не хуже flashback и позволяет более предсказуемо управлять объемами, плюс хранить дополнительную информацию по изменениям.
На случай если кто-то нагуглит этот топик, даю ссылку на описание своего гибрида.
DateEnd = 01.01.9999
Программисты будущего в новый 9999 год будут материть нас за это =)
«Старайтесь реализовывать механизм изменения записей в хранимых процедурах — категорически нежелательно, чтобы код изменений был разбросан по разным местам, даже если код изменений у вас хранится в четко определенных местах Вашего внешнего приложения;»
Это еще почему? То есть, значит, если я использую DAL в любом его варианты, и до появления истории хранимые процедуры мне нафиг не были нужны, то для добавления истории я должен похоронить принцип «все в одном месте» и разнести код?

По-моему, ровно наоборот. Если в DAL есть свои способы поддержи историчности данных (не важно, каким образом), они на его стороне и должны быть реализован. Это как раз обеспечит униформность работы историчности (и заодно оптимальность работы самого DAL, потому что он знает, с чем работает).
В случаях использования полей начала и конца версии

как один из дополнительных подходов — контролировать непрерывность интервалов с помощью констрейнтов, например так:

drop table scdtest;
create table scdtest(s_code varchar2(10), s_value varchar2(10), s_start_date date not null, s_end_date date, s_latest varchar2(10) as (decode(s_end_date, null, s_code, null)));
alter table scdtest add constraint unq_s_start_date unique (s_code, s_start_date);
alter table scdtest add constraint unq_s_latest unique (s_latest) deferrable INITIALLY DEFERRED;
alter table scdtest add constraint fk_s_s foreign key (s_code, s_end_date) references scdtest (s_code, s_start_date) deferrable INITIALLY DEFERRED;

-- открываем интервал 
insert into scdtest (s_code, s_value, s_start_date, s_end_date) values ('A' ,'A1', to_date('01.01.2000', 'DD.MM.YYYY'), null);
commit;

-- продложаем интервал
update scdtest set s_end_date = to_date('01.01.2001', 'DD.MM.YYYY') where s_latest = 'A';
insert into scdtest (s_code, s_value, s_start_date, s_end_date) values ('A' ,'A2', to_date('01.01.2001', 'DD.MM.YYYY'), null);
commit;


хотелось бы, кстати, узнать какие СУБД это поддерживают

Подозреваю, что в чистом виде никакие, ибо накладно это :) Плохо себе представляю консистентную ревалидацию например 10 террабайт данных по недетерминистической функции.

Хотя, если логика функции проверки укладывается в SQL scope, то теоретически можно построить матвьюху и навешать констрейнтов на неё, но:
— при сильно замороченной логике возможно матвьюху не удастся сделать fast refresh on commit, там вроде требования к детерминистичности те же если не жёстче
— ексепшены на нарушения придуманной логики будете ловить только при завершении транзакции ровно как с отложенными констрейнтами, ибо refresh on commit
— в 10ой версии распределенные транзакции, приводящие к обновлению матвьюхи приводят к ора-600
— двойной расход места
Не смотря на всё это я таким костылем пользовался и он оказался вполне себе живуч.

Ну и отложенную асинхронную проверку можно конечно навелосипедить в любой момент, но это уже другая опера.
Все верно и насчет констрейнтов, это старый известный трюк, например, тут обсуждалось: www.sql.ru/forum/actualutils.aspx?action=gotomsg&tid=305675&msg=2791319 и насчет матвьюх. Но и у того и другого с конкурентностью и нагрузкой очень будет хреново :)
Тип 0 используется в Event Sourcing (ES). Может сложиться ощущение что ES это Тип 2, но это не так. Потому что, ES разделяет понятие события и агрегат. Агрегат это искомая бизнес сущность, например документ. Событие же это то что произошло с документом и оно не изменно. Этьо уже произошло и не должно никогда меняться. Если хотим изменить просто создаем новое событие «А теперь хотим изменить поле на какое-то значение».
Сам факт наличие событий является историей данного документа. ES гораздо больше чем версионность, т.к. события могут говорить не только об изменениях в документе, но и других процессах (документ без изменений был согласован; документ без изменений перешел в архив и т.п.).
Поэтому Тип 0 во всю используется в современном стеке технологий.
Но это так дополнение, спасибо за статью :)
Sign up to leave a comment.

Articles