34

SQL 一直有一个很棒的特性:级联删除。你提前计划好,什么时候删除,BAM!无需担心所有这些依赖记录。

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

有没有我完全错过的更好的解决方案?

4

5 回答 5

37

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


级联软删除

抽象的

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

关于 GORM 中的软删除

在用 Go 编写的fabric8-services/fabric8-wit项目中,我们为我们的数据库使用了一个面向对象的映射器,称为GORM

GORM 提供了一种软删除数据库条目的方法:

如果模型有DeletedAt字段,它会自动获得软删除能力!那么它不会在调用时从数据库中永久删除Delete,而只会将 fieldDeletedAt的值设置为当前时间。

假设你有一个模型定义,也就是一个看起来像这样的 Go 结构:

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

假设您已将现有用户条目ID从数据库加载到 objectu中。

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 中的软删除问题 - 依赖反转和无级联

上面提到的软删除非常适合归档单个记录,但对于依赖它的所有记录,它可能会导致非常奇怪的结果。这是因为 GORM 的软删除不会像DELETE使用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 将它交给开发人员来(软)删除所有依赖城市。换句话说,以前被建模为城市与国家的关系现在被颠倒为国家与城市的关系。这是因为用户/开发人员现在负责(软)删除属于一个国家/地区的所有城市,当该国家/地区被删除时。

提议

如果我们可以进行软删除和 a 的所有好处,那不是很好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. 发生DELETEcountries桌子上
  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 的模型结构中有 a ,GORM 会自动AND deleted_at IS NULL为每个查询添加一个。)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_atNULL

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(),您的新表将收到 hard 。如果您也使用新的从属表,DELETE这不是问题。archive_record()但你只需要记住它。

最后的想法

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

在我们的应用程序中,工作项的某些字段没有指定外键。一个很好的例子是区域 ID。这意味着当区域为DELETEd 时,关联的工作项不会自动为DELETEd。当一个区域被自己移除时,有两种情况:

  1. 直接向用户请求删除。
  2. 用户请求删除一个空间,然后该区域由于其对空间的外键约束而被删除。

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

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

如何申请到我们的数据库?

脚步

  1. 为继承原始表的所有表创建“*_archived”表。
  2. archive_record()使用上述功能安装软删除触发器。
  3. 通过执行将触发该功能的硬操作将所有条目移动deleted_at IS NOT NULL到各自的表中。_archiveDELETEarchive_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;
于 2018-10-29T13:17:35.790 回答
21

我不想这么说,但触发器是专门为这种事情设计的。

(讨厌的部分是因为好的触发器很难编写,当然也不能调试)

于 2009-02-03T09:08:46.323 回答
7

外键约束可以进行级联更新。如果您在键和删除标志上链接您的表,那么当主表中的删除标志更改时,该更改将向下传播到详细表。我没有尝试过,但它应该可以工作。

于 2009-02-03T09:12:59.947 回答
2

我认为软删除的一个好处通常是不是每个表都有一个软删除标志,所以需要级联的东西很少。这些行在数据库中只是未使用,但不是孤立的 - 它们仅由已删除的行引用。

但是,就像所有事情一样,这取决于您的模型。

于 2009-02-03T09:13:29.103 回答
0

不确定您在谈论什么后端,但您可以了解您的“删除标志”更改并使用触发器将更改向下级联。

于 2009-02-03T09:09:49.423 回答