级联软删除

时间:2009-02-03 09:04:35

标签: sql soft-delete

SQL总是有一个很棒的功能:级联删除。你事先计划好,什么时候删除东西,BAM!无需担心所有这些依赖记录。

但是,现在实际上删除任何东西几乎都是禁忌。您将其标记为已删除并停止显示。不幸的是,当存在依赖记录时,我无法找到可靠的解决方案。我总是手动编写复杂的软删除网。

那里有一个我完全错过的更好的解决方案吗?

5 个答案:

答案 0 :(得分:15)

我不想这么说,但触发器专门针对这类事情设计。

(讨厌的部分是因为好的触发器很难写,当然,无法调试)

答案 1 :(得分:8)

我最近想出了一种使用Postgres 9.6级联软删除的解决方案,该解决方案利用继承将条目划分为已删除和未删除条目。这是我正在为我们的项目编写的文档的副本:


级联软删除

摘要

在本文档中,我描述了当前处理Postgres数据库中对象删除的方法,并介绍了当前实现的缺陷。例如,到目前为止,我们还没有级联的软删除功能。然后,我展示了一种结合了Postgres的级联硬删除的优点和易于实施,维护并在所有搜索查询中提高性能的归档方法的方法。

关于GORM中的软删除

在用Go语言编写的fabric8-services/fabric8-wit项目中,我们正在使用面向对象的映射器来命名我们的数据库GORM

GORM提供了一种方法来soft-delete个数据库条目:

  

如果模型具有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)

将不会在SQL中使用DELETE删除数据库条目,但将更新该行并将deleted_at设置为当前时间:

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

GORM中存在软删除问题-依赖关系反转且无级联

上面提到的软删除非常适合归档单个记录,但是对于依赖它的所有记录,它可能导致非常奇怪的结果。这是因为,如果使用DELETE对外键进行建模,则GORM的软删除不会像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国家记录时,所有城市也将被删除。但是,由于该表具有在某个国家或城市的Go结构中进行的deleted_at列,因此GORM映射器只会软删除该国家,而不会影响所属城市。

将责任从数据库转移到用户/开发人员

因此,

GORM将其交给开发人员以(软删除)所有依赖城市。换句话说,以前建模为从城市与国家/地区的关系的模型现在已被反转为从国家/城市与城市的关系的模型。这是因为删除国家/地区后,用户/开发人员现在负责(软)删除该国家/地区的所有城市。

提案

如果我们可以进行软删除以及ON DELETE CASCADE的所有好处,那不是很好吗?

事实证明,我们可以毫不费力地拥有它。现在,让我们集中讨论一个表,即countries表。

存档表

假设有一秒钟,我们可以拥有另一个名为countries_archive的表,该表具有与countries表完全相同的相同结构。另外,假设将来对countries完成的所有模式迁移都将应用于countries_archive表。唯一的例外是唯一约束外键不会应用于countries_archive

我想,这听起来真是太好了,对吧?好吧,我们可以使用Postgres中的Inheritenance创建这样的表:

CREATE TABLE countries_archive () INHERITS (countries);

生成的countries_archive表将用于将所有记录存储在deleted_at IS NOT NULL处。

请注意,在我们的Go代码中,我们永远不会直接使用任何_archive表。相反,我们将查询从中继承*_archive表的原始表,然后Postgres神奇地自动查看*_archive表。下面我会解释为什么会这样。它与分区有关。

将条目移动到(软)-DELETE上的存档表中

由于countriescountries_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 := timenow();
        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中的继承功能已开发到partition data。当您使用特定的列或条件搜索分区数据时,Postgres可以找出要搜索的分区,从而可以improve the performance of your query

除非另有说明,否则仅通过搜索存在的实体,我们就可以从性能改进中受益。存在的条目是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仍在搜索两个表countriescountries_archive。让我们看看在创建表时向countries_archive表添加检查约束时会发生什么:

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

现在,Postgres知道当预期countries_archivedeleted_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()函数,则当现有表中的某些内容被软删除时,新表将收到硬DELETE。如果您还对新的从属表也使用archive_record(),这不是问题。但是您只需要记住它即可。

最终想法

此处介绍的方法不能解决还原单个行的问题。另一方面,这种方法不会使它变得更难或更复杂。只是没有解决。

在我们的应用程序中,工作项的某些字段未指定外键。区域ID是一个很好的例子。这意味着当区域DELETE d时,关联的工作项不会自动DELETE d。删除区域本身有两种方案:

  1. 直接从用户请求删除。
  2. 用户请求删除一个空格,然后由于其在该空格上的外键约束而将该区域删除。

请注意,在第一种情况下,用户的请求通过区域控制器代码,然后通过区域存储库代码。在任何这些层中,我们都有机会修改所有将引用不存在区域的工作项。在第二种情况下,与该区域相关的所有事情都发生并停留在DB层上,因此我们没有机会修改工作项。好消息是我们不必这样做。每个工作项都引用一个空格,因此,当该空格消失时,无论如何都会删除它。

适用于区域的内容也适用于迭代,标签和木板列。

如何申请我们的数据库?

步骤

  1. 为继承原始表的所有表创建“ * _archived”表。
  2. 使用上述archive_record()函数安装软删除触发器。
  3. 通过执行硬deleted_at IS NOT NULL来触发_archive函数,将DELETE中的所有条目移动到各自的archive_record()表中。

示例

这里是fully working example,其中我们展示了对两个表countriescapitals的级联软删除。我们展示了如何独立于删除所选择的方法来存档记录。

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 := timenow();
        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;

答案 2 :(得分:6)

外键约束可以执行级联更新。如果您在密钥和删除标志上链接了表,那么当主表中的删除标志发生更改时,该更改将向下传播到详细信息表。我没试过,但它应该有用。

答案 3 :(得分:2)

我认为软删除的好处通常是并非每个表都有一个软删除标志,因此需要级联的事物数量很少。这些行在数据库中只是未使用,但不是孤立的 - 它们只是被删除的行引用。

与所有事情一样,这取决于你的模特。

答案 4 :(得分:0)

不确定你正在谈论的是什么后端,但是你可以在你的“删除标志”上进行更改并使用触发器将更改级联下来。