Каскадное мягкое удаление

У SQL всегда была замечательная особенность: каскадное удаление. Планируешь заранее, а когда пора что-то удалять, БАМ! Не нужно беспокоиться обо всех этих зависимых записях.

Однако в настоящее время на самом деле УДАЛЕНИЕ чего-либо практически табу. Вы помечаете его как удаленное и больше не показываете. К сожалению, мне не удалось найти надежного решения для этого, когда есть зависимые записи. Я всегда вручную кодировал сложную сеть мягких удалений.

Есть ли лучшее решение, которое я полностью упустил?


person Arthur Chaparyan    schedule 03.02.2009    source источник


Ответы (5)


Ненавижу это говорить, но триггеры созданы специально для такого рода вещей.

(Ненависть состоит в том, что хорошие триггеры очень сложно написать и, конечно же, их нельзя отладить)

person Learning    schedule 03.02.2009
comment
Если вы хотите избежать написания процедуры триггера для каждой таблицы, которая должна иметь заархивированные каскадные мягкие удаления, вы можете взглянуть на мой ответ (stackoverflow.com/a/53046345/835098). Он длинный, но только потому, что я не хотел прыгать прямо в глубину, не объясняя, что делает каждый шаг и почему мы его используем. - person Konrad Kleine; 25.05.2020

Недавно я придумал решение для каскадного мягкого удаления с помощью Postgres 9.6, которое использует наследование для разделения записей на удаленные и не удаленные. Вот копия документа, который я пишу для нашего проекта:


Каскадное мягкое удаление

Абстрактный

В этом документе я описываю наш текущий подход к удалению объектов в нашей базе данных Postgres и представляю недостатки текущей реализации. Например, до сих пор у нас нет возможности иметь каскадное мягкое удаление. Затем я показываю метод, сочетающий сильные стороны каскадного жесткого удаления Postgres и подхода к архивированию, который легко реализовать, поддерживать и который обеспечивает повышение производительности всех поисковых запросов.

О мягких удалениях в ГОРМ

В проекте fabric8-services / fabric8-wit, написанном на Go, мы используем объектно-ориентированный преобразователь для нашей базы данных под названием GORM.

GORM предлагает способ мягкого удаления записей базы данных:

Если модель имеет поле DeletedAt, она автоматически получит возможность мягкого удаления! тогда он не будет навсегда удален из базы данных при вызове Delete, а только установит значение поля DeletedAt на текущее время.

Предположим, у вас есть определение модели, другими словами структура Go, которая выглядит так:

// User is the Go model for a user entry in the database
type User struct {
    ID        int
    Name      string
DeletedAt *time.Time
}

Допустим, вы загрузили существующую запись пользователя по ее ID из БД в объект u.

id := 123
u := User{}
db.Where("id=?", id).First(&u)

Если вы затем удалите объект с помощью GORM:

db.Delete(&u)

запись БД не будет удалена с использованием DELETE в SQL, но строка будет обновлена, а для deleted_at будет установлено текущее время:

UPDATE users SET deleted_at="2018-10-12 11:24" WHERE id = 123;

Проблемы с мягким удалением в GORM - Инверсия зависимостей и отсутствие каскада

Вышеупомянутое мягкое удаление удобно для архивирования отдельных записей, но может привести к очень странным результатам для всех записей, которые от него зависят. Это связано с тем, что мягкое удаление с помощью GORM не каскадно, как потенциальный DELETE в SQL, если бы внешний ключ был смоделирован с ON DELETE CASCADE.

Когда вы моделируете базу данных, вы обычно создаете таблицу, а затем, возможно, другую, у которой есть внешний ключ к первой:

CREATE TABLE countries (
    name text PRIMARY KEY,
    deleted_at timestamp
);

CREATE TABLE cities (
    name text,
    country text REFERENCES countries(name) ON DELETE CASCADE,
    deleted_at timestamp
);

Здесь мы смоделировали список стран и городов, которые ссылаются на конкретную страну. Когда вы DELETE записываете страну, все города также будут удалены. Но поскольку в таблице есть столбец deleted_at, который содержится в структуре Go для страны или города, средство отображения GORM только мягко удалит страну и оставит принадлежащие ей города нетронутыми.

Передача ответственности с БД пользователю / разработчику

Таким образом, GORM дает разработчику возможность (программно) удалить все зависимые города. Другими словами, то, что ранее было смоделировано как отношение от городов к странам, теперь перевернуто как отношение от стран к городам. Это связано с тем, что пользователь / разработчик теперь несет ответственность за (мягкое) удаление всех городов, принадлежащих стране, при удалении страны.

Предложение

Разве не было бы замечательно, если бы у нас были мягкие удаления и все преимущества ON DELETE CASCADE?

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

Таблица архива

Предположим на секунду, что у нас может быть другая таблица с именем countries_archive, которая имеет точно ту же структуру, что и таблица countries. Также предположим, что все будущие миграции схемы, которые выполняются до countries, применяются к countries_archive таблице. Единственное исключение - ограничения уникальности и внешние ключи не будут применяться к countries_archive.

Думаю, это уже звучит слишком хорошо, чтобы быть правдой, не так ли? Что ж, мы можем создать такую ​​таблицу, используя так называемое наследование в Postgres:

CREATE TABLE countries_archive () INHERITS (countries);

Результирующая countries_archive таблица будет предназначена для хранения всех записей, где deleted_at IS NOT NULL.

Обратите внимание, что в нашем коде Go мы никогда не будем напрямую использовать какую-либо _archive таблицу. Вместо этого мы будем запрашивать исходную таблицу, от которой наследуется *_archive таблица, и Postgres затем волшебным образом автоматически заглянет в *_archive таблицу. Немного ниже я объясню, почему это так; это связано с разбиением на разделы.

Перенос записей в архивную таблицу на (софт) -DELETE

Поскольку две таблицы, countries и countries_archive выглядят совершенно одинаково по схеме, мы можем очень легко INSERT попасть в архив, используя функцию триггера, когда

  1. DELETE происходит с countries таблицей
  2. или когда происходит мягкое удаление, задав для deleted_at значение, отличное от NULL.

Функция триггера выглядит так:

CREATE OR REPLACE FUNCTION archive_record()
RETURNS TRIGGER AS $$
BEGIN
    -- When a soft-delete happens...
    IF (TG_OP = 'UPDATE' AND NEW.deleted_at IS NOT NULL) THEN
        EXECUTE format('DELETE FROM %I.%I WHERE id = $1', TG_TABLE_SCHEMA, TG_TABLE_NAME) USING OLD.id;
        RETURN OLD;
    END IF;
    -- When a hard-DELETE or a cascaded delete happens
    IF (TG_OP = 'DELETE') THEN
        -- Set the time when the deletion happens
        IF (OLD.deleted_at IS NULL) THEN
            OLD.deleted_at := now();
        END IF;
        EXECUTE format('INSERT INTO %I.%I SELECT $1.*'
                    , TG_TABLE_SCHEMA, TG_TABLE_NAME || '_archive')
        USING OLD;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

Чтобы связать функцию с триггером, мы можем написать:

CREATE TRIGGER soft_delete_countries
    AFTER
        -- this is what is triggered by GORM
        UPDATE OF deleted_at 
        -- this is what is triggered by a cascaded DELETE or a direct hard-DELETE
        OR DELETE
    ON countries
    FOR EACH ROW
    EXECUTE PROCEDURE archive_record();

Выводы

Первоначально функция наследования в postgres была разработана для данных разделения. Когда вы выполняете поиск в разделенных данных с использованием определенного столбца или условия, Postgres может определить, в каком разделе выполнять поиск, и тем самым может повысить производительность вашего запрос.

Мы можем извлечь выгоду из этого повышения производительности, ища только существующие сущности, если не указано иное. Существующие записи - это те, где верно deleted_at IS NULL. (Обратите внимание, что GORM автоматически добавит AND deleted_at IS NULL к каждому запросу, если в структуре модели GORM есть DeletedAt.)

Посмотрим, знает ли уже Postgres, как воспользоваться нашим разделением, запустив EXPLAIN:

EXPLAIN SELECT * FROM countries WHERE deleted_at IS NULL;
+-------------------------------------------------------------------------+
| QUERY PLAN                                                              |
|-------------------------------------------------------------------------|
| Append  (cost=0.00..21.30 rows=7 width=44)                              |
|   ->  Seq Scan on countries  (cost=0.00..0.00 rows=1 width=44)          |
|         Filter: (deleted_at IS NULL)                                    |
|   ->  Seq Scan on countries_archive  (cost=0.00..21.30 rows=6 width=44) |
|         Filter: (deleted_at IS NULL)                                    |
+-------------------------------------------------------------------------+

Как мы видим, Postgres по-прежнему выполняет поиск в обеих таблицах, countries и countries_archive. Давайте посмотрим, что произойдет, если мы добавим проверочное ограничение в countries_archive таблицу при создании таблицы:

CREATE TABLE countries_archive (
    CHECK (deleted_at IS NOT NULL)
) INHERITS (countries);

Теперь Postgres знает, что он может пропустить countries_archive, когда ожидается, что deleted_at будет NULL:

EXPLAIN SELECT * FROM countries WHERE deleted_at IS NULL;
+----------------------------------------------------------------+
| QUERY PLAN                                                     |
|----------------------------------------------------------------|
| Append  (cost=0.00..0.00 rows=1 width=44)                      |
|   ->  Seq Scan on countries  (cost=0.00..0.00 rows=1 width=44) |
|         Filter: (deleted_at IS NULL)                           |
+----------------------------------------------------------------+

Обратите внимание на отсутствие последовательного сканирования таблицы countries_archive в вышеупомянутом EXPLAIN.

Преимущества и риски

Преимущества

  1. У нас есть регулярные каскадные удаления, и мы можем позволить БД выяснить, в каком порядке удалять объекты.
  2. В то же время мы архивируем наши данные. Каждое мягкое удаление
  3. Никаких изменений кода Go не требуется. Нам нужно только настроить таблицу и триггер для каждой таблицы, которая должна быть заархивирована.
  4. Когда мы полагаем, что больше не хотим такого поведения с триггерами и каскадным мягким удалением, мы можем легко вернуться.
  5. Все будущие миграции схемы, выполняемые в исходной таблице, также будут применены к _archive версии этой таблицы. За исключением ограничений, что хорошо.

Риски

  1. Предположим, вы добавляете новую таблицу, которая ссылается на другую существующую таблицу с внешним ключом, имеющим ON DELETE CASCADE. Если существующая таблица использует функцию archive_record(), указанную выше, ваша новая таблица будет получать жесткие DELETEs, когда что-то в существующей таблице мягко удаляется. Это не проблема, если вы также используете archive_record() для своей новой зависимой таблицы. Но вы просто должны это запомнить.

Последние мысли

Представленный здесь подход не решает проблему восстановления отдельных строк. С другой стороны, такой подход не усложняет задачу. Это просто остается нерешенным.

В нашем приложении для некоторых полей рабочего элемента не указан внешний ключ. Хорошим примером являются идентификаторы областей. Это означает, что когда область равна DELETEd, связанный рабочий элемент автоматически не становится DELETEd. Есть два сценария, когда область удаляется сама:

  1. Удаление запрашивается напрямую у пользователя.
  2. Пользователь запрашивает удаление пространства, а затем область удаляется из-за ограничения внешнего ключа на пространстве.

Обратите внимание, что в первом сценарии запросы пользователя проходят через код контроллера области, а затем через код репозитория области. У нас есть шанс на любом из этих слоев изменить все рабочие элементы, которые в противном случае ссылались бы на несуществующую область. Во втором сценарии все, что связано с областью, происходит и остается на уровне БД, поэтому у нас нет шансов изменить рабочие элементы. Хорошая новость в том, что нам это не обязательно. Каждый рабочий элемент ссылается на пространство и поэтому будет удален в любом случае, когда пространство исчезнет.

То, что применимо к областям, также применимо к итерациям, меткам и столбцам доски.

Как подать заявку в нашу базу данных?

Шаги

  1. Создайте * _архивированные таблицы для всех таблиц, наследующих исходные таблицы.
  2. Установите триггер мягкого удаления, используя указанную выше функцию archive_record().
  3. Переместите все записи, где deleted_at IS NOT NULL, в соответствующую _archive таблицу, выполнив жесткую DELETE, которая вызовет функцию archive_record().

Пример

Вот полностью рабочий пример, в котором мы продемонстрировали каскадное мягкое удаление двух таблиц, countries и capitals. Мы показываем, как архивируются записи независимо от метода, который был выбран для удаления.

CREATE TABLE countries (
    id int primary key,
    name text unique,
    deleted_at timestamp
);
CREATE TABLE countries_archive (
    CHECK ( deleted_at IS NOT NULL )
) INHERITS(countries);

CREATE TABLE capitals (
    id int primary key,
    name text,
    country_id int references countries(id) on delete cascade,
    deleted_at timestamp
);
CREATE TABLE capitals_archive (
    CHECK ( deleted_at IS NOT NULL )
) INHERITS(capitals);

CREATE OR REPLACE FUNCTION archive_record()
RETURNS TRIGGER AS $$
BEGIN
    IF (TG_OP = 'UPDATE' AND NEW.deleted_at IS NOT NULL) THEN
        EXECUTE format('DELETE FROM %I.%I WHERE id = $1', TG_TABLE_SCHEMA, TG_TABLE_NAME) USING OLD.id;
        RETURN OLD;
    END IF;
    IF (TG_OP = 'DELETE') THEN
        IF (OLD.deleted_at IS NULL) THEN
            OLD.deleted_at := now();
        END IF;
        EXECUTE format('INSERT INTO %I.%I SELECT $1.*'
                    , TG_TABLE_SCHEMA, TG_TABLE_NAME || '_archive')
        USING OLD;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER soft_delete_countries
    AFTER
        UPDATE OF deleted_at 
        OR DELETE
    ON countries
    FOR EACH ROW
    EXECUTE PROCEDURE archive_record();
    
CREATE TRIGGER soft_delete_capitals
    AFTER
        UPDATE OF deleted_at 
        OR DELETE
    ON capitals
    FOR EACH ROW
    EXECUTE PROCEDURE archive_record();

INSERT INTO countries (id, name) VALUES (1, 'France');
INSERT INTO countries (id, name) VALUES (2, 'India');
INSERT INTO capitals VALUES (1, 'Paris', 1);
INSERT INTO capitals VALUES (2, 'Bengaluru', 2);

SELECT 'BEFORE countries' as "info", * FROM ONLY countries;
SELECT 'BEFORE countries_archive' as "info", * FROM countries_archive;
SELECT 'BEFORE capitals' as "info", * FROM ONLY capitals;
SELECT 'BEFORE capitals_archive' as "info", * FROM capitals_archive;

-- Delete one country via hard-DELETE and one via soft-delete
DELETE FROM countries WHERE id = 1;
UPDATE countries SET deleted_at = '2018-12-01' WHERE id = 2;

SELECT 'AFTER countries' as "info", * FROM ONLY countries;
SELECT 'AFTER countries_archive' as "info", * FROM countries_archive;
SELECT 'AFTER capitals' as "info", * FROM ONLY capitals;
SELECT 'AFTER capitals_archive' as "info", * FROM capitals_archive;
person Konrad Kleine    schedule 29.10.2018
comment
Большое спасибо @cdunham! Приятно слышать, что усилия по созданию этого кому-то полезны. Сообщите мне, работает ли это для вас или нет. Надеюсь, вы понимаете, что вам не нужен GORM, чтобы это работало. Я уверен, что с другими системами в этом нет необходимости. Фактически, в наши дни я больше увлекаюсь поиском событий / CQRS / DDD, когда вы избавляетесь от концепции состояния. Состояние - это то, как вещь выглядит после того, как произошли события. - person Konrad Kleine; 25.05.2020
comment
Вместо использования timenow () в функции archive_record () следует использовать now (), чтобы эта функция работала в более новых версиях Postgres. - person vkopio; 03.08.2020
comment
Спасибо @vkopio за этот совет! Я убедился, что он работает, и обновил свой код выше. - person Konrad Kleine; 04.08.2020
comment
Есть также одно предостережение относительно функции archive_record: она не работает, если таблица имеет сгенерированные столбцы, поскольку она пытается вставить сгенерированное значение в архивную таблицу, что недопустимо. - person vkopio; 03.05.2021

Ограничения внешнего ключа могут выполнять каскадные обновления. Если вы связали свои таблицы как с ключом, так и с флагом удаления, то при изменении флага удаления в главной таблице это изменение распространится на подробную таблицу. Я не пробовал, но должно работать.

person Matt Hamilton    schedule 03.02.2009
comment
Это безумно элегантный ИМО. Единственная проблема с этим заключается в том, что вы не можете использовать NULL Delete_Date, а вместо этого должны использовать произвольную дату, например 9999-12-31. - person HaxElit; 01.09.2010
comment
Подумав еще немного, это не сработает, потому что если вы мягко удалите зависимую запись, вы получите ошибку ограничения ключа, потому что дата удаления родительского элемента отличается. Наверное, хорошо, чтобы быть правдой;) - person HaxElit; 01.09.2010

Я думаю, что преимущество мягких удалений обычно заключается в том, что не каждая таблица имеет флаг мягкого удаления, поэтому количество вещей, которые необходимо каскадировать, невелико. Строки просто не используются в базе данных, но не осиротели - на них просто ссылаются только удаленные строки.

Впрочем, как и все, это зависит от вашей модели.

person Cade Roux    schedule 03.02.2009
comment
Хм, но тогда у вас есть база данных, полная строк, и вы не сразу видите, какая из них используется, верно? Вы можете присоединиться к основному столу, но если каскад пойдет на несколько уровней в глубину, это может стать беспорядком. Или я что-то упускаю? - person Piskvor left the building; 03.02.2009
comment
Это зависит от модели. В реляционном дизайне, если удаленный флаг не принадлежит отношению / кортежу / таблице, то есть это не атрибут ключа, я бы его не ставил. В звездообразной схеме вы бы поместили их только в основные таблицы. - person Cade Roux; 03.02.2009
comment
Если вы приведете пример схемы подсистемы, я покажу вам, в какие из них я бы поставил флаг удаленного. Я бы, например, никогда не помещал их в таблицу «многие ко многим», если вы не ведете историю изменений связей, и в этом случае вам также потребуется добавить даты вступления в силу. - person Cade Roux; 03.02.2009

Не уверен, о каком бэкенде вы говорите, но вы можете уловить изменение своего «флага удаления» и каскадировать это изменение с помощью триггера.

person Steven Robbins    schedule 03.02.2009