【问题标题】:Cascading Soft Delete级联软删除
【发布时间】:2010-10-05 02:53:31
【问题描述】:

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

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

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

【问题讨论】:

    标签: sql soft-delete


    【解决方案1】:

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

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

    【讨论】:

    • 如果您想避免为每个应具有归档级联软删除的表编写触发器过程,您可以查看我的答案 (stackoverflow.com/a/53046345/835098)。它很长,但只是因为我想避免直接跳入深渊,而不解释每个步骤的作用以及我们使用它的原因。
    【解决方案2】:

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

    【讨论】:

      【解决方案3】:

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

      【讨论】:

      • 这是非常优雅的 IMO。这样做的唯一问题是您不能使用 NULL Delete_Date 而是必须使用诸如“9999-12-31”之类的任意日期。
      • 再想一想,它不起作用,因为如果你软删除一个依赖记录,你会得到一个关键约束错误,因为父母的删除日期不同。真好,我猜 ;)
      【解决方案4】:

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

      不过,就像所有东西一样,这取决于您的型号。

      【讨论】:

      • 嗯,但是您有一个充满行的数据库,而没有立即看到使用了哪些行 - 对吗?您可以加入主表,但如果级联深入几个级别,这可能会变得混乱。还是我错过了什么?
      • 取决于型号。在关系设计中,如果删除的标志不属于关系/元组/表 - 即它不是键的属性,我不会放一个。在星型模式中 - 您只能将它们放在核心表上。
      • 如果你举一个子系统模式的例子,我会告诉你我会在哪些地方放置一个已删除的标志。例如,除非您保留链接更改历史记录,否则我不会将它们放在多对多表上,在这种情况下,您还需要添加生效日期。
      【解决方案5】:

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

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

      提案

      如果我们可以进行软删除和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 := 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 中的继承功能被开发为partition data。当您使用特定列或条件搜索分区数据时,Postgres 可以找出要搜索的分区,从而可以improve the performance of your query

      除非另有说明,否则我们可以通过仅搜索存在的实体来从这种性能改进中受益。存在的条目是那些deleted_at IS NULL 成立的条目。 (注意,如果 GORM 的模型结构中有 DeletedAt,GORM 会自动为每个查询添加一个 AND deleted_at IS NULL。)

      让我们看看 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 知道当 deleted_at 预期为 NULL 时,它可以跳过 countries_archive

      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)                           |
      +----------------------------------------------------------------+
      

      请注意前面提到的EXPLAIN 中没有对countries_archive 表的顺序扫描。

      好处和风险

      好处

      1. 我们有常规的级联删除,可以让数据库确定删除的顺序。
      2. 与此同时,我们也在归档我们的数据。每次软删除
      3. 无需更改 Go 代码。我们只需要为每个要归档的表设置一个表和一个触发器。
      4. 每当我们发现不再需要这种带有触发器和级联软删除的行为时我们可以轻松返回
      5. 将来对原始表进行的所有架构迁移也将应用于该表的_archive 版本。除了约束,这很好。

      风险

      1. 假设您添加一个新表,该表引用另一个现有表,其外键具有ON DELETE CASCADE。如果现有表使用上面的archive_record() 函数,当现有表中的某些内容被软删除时,您的新表将收到硬DELETEs。这不是问题,如果您也将archive_record() 用于您的新依赖表。但你只需要记住它。

      最后的想法

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

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

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

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

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

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

      步骤

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

      示例

      这是一个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 := 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;
      

      【讨论】:

      • 非常感谢@cdunham!很高兴听到创建它的努力对某人有用。让我知道这是否适合您。我希望你明白你不需要 GORM 来完成这项工作。我确信对于其他系统,这不是必需的。事实上,这些天我更喜欢事件溯源/CQRS/DDD,在那里你摆脱了状态的概念。状态是事件发生后事物的样子。
      • 不应在 archive_record() 函数中使用 timenow(),而应使用 now() 来使其在较新的 Postgres 版本上工作。
      • 感谢@vkopio 的这个提示!我已经验证它可以正常工作并更新了我上面的代码。
      • archive_record 函数还有一个警告:如果表已生成列,则它不起作用,因为它试图将生成的值插入到归档表中,这是不允许的。
      猜你喜欢
      • 2013-06-19
      • 2015-10-06
      • 2018-03-30
      • 2016-05-13
      • 1970-01-01
      • 1970-01-01
      • 2013-08-01
      • 2022-11-02
      • 1970-01-01
      相关资源
      最近更新 更多