SQL总是有一个很棒的功能:级联删除.你事先计划好,什么时候删除东西,BAM!无需担心所有这些依赖记录.
但是,现在实际上删除任何东西几乎都是禁忌.您将其标记为已删除并停止显示.不幸的是,当存在依赖记录时,我无法找到可靠的解决方案.我总是手动编写复杂的软删除网络.
那里有一个我完全错过的更好的解决方案吗?
我讨厌说它,但触发器是专门为这种事情而设计的.
(讨厌的部分是因为好的触发器很难写,当然,不能调试)
我最近使用Postgres 9.6提出了级联软删除的解决方案,它利用继承将条目分为已删除和未删除的条目.这是我正在为我们的项目编写的文档的副本:
在本文档中,我描述了当前处理Postgres数据库中对象删除的方法,并提出了当前实现的缺陷.例如,到目前为止,我们没有能力进行级联软删除.然后我展示了一种方法,它结合了Postgres 级联硬删除的优势和易于实现,维护并在所有搜索查询中带来性能提升的归档方法.
在使用Go编写的fabric8-services/fabric8-wit项目中,我们使用面向对象的映射器作为我们的数据库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
从DB 加载到对象中u
.
id := 123
u := User{}
db.Where("id=?", id).First(&u)
如果您继续使用GORM删除对象:
db.Delete(&u)
不会DELETE
在SQL中删除DB条目,但会更新行并将其deleted_at
设置为当前时间:
UPDATE users SET deleted_at="2018-10-12 11:24" WHERE id = 123;
上面提到的软删除很适合归档单个记录,但它可能导致所有依赖它的记录产生非常奇怪的结果.这是因为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中所谓的Inheritenance创建这样一个表:
CREATE TABLE countries_archive () INHERITS (countries);
结果countries_archive
表将用于存储所有记录deleted_at IS NOT NULL
.
请注意,在我们的Go代码中,我们永远不会直接使用任何_archive
表.相反,我们会查询*_archive
表继承的原始表,然后Postgres会*_archive
自动地神奇地查看表.再进一步解释为什么会这样; 它与分区有关.
由于两个表,countries
并且countries_archive
看起来完全相似的模式,我们可以INSERT
很容易地使用触发器函数进入存档时
a DELETE
发生在countries
桌子上
或者通过设置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中的继承功能是为了分区数据而开发的.当您使用特定列或条件搜索分区数据时,Postgres可以找出要搜索的分区,从而可以提高查询的性能.
除非另有说明,否则我们只能通过搜索现有实体来从这种性能改进中受益.存在的条目是deleted_at IS NULL
真实存在的条目.(注意,AND deleted_at IS NULL
如果DeletedAt
GORM的模型结构中有一个GORM ,GORM会自动为每个查询添加一个.)
让我们看看Postgres是否已经知道如何通过运行以下方式来利用我们的分离EXPLAIN
:
EXPLAIN SELECT * FROM countries WHERE deleted_at IS NULL;
+-------------------------------------------------------------------------+
| QUERY PLAN |
|-------------------------------------------------------------------------|
| Append (cost=0.00..21.30 rows=7
| -> Seq Scan on countries (cost=0.00..0.00 rows=1
| Filter: (deleted_at IS NULL) |
| -> Seq Scan on countries_archive (cost=0.00..21.30 rows=6
| 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
| -> Seq Scan on countries (cost=0.00..0.00 rows=1
| Filter: (deleted_at IS NULL) |
+----------------------------------------------------------------+
请注意countries_archive
,前面没有对表格进行顺序扫描EXPLAIN
.
我们有定期删除的级联删除,可以让DB找出删除内容的顺序.
与此同时,我们也在归档我们的数据.每次软删除
不需要更改Go代码.我们只需要为每个要归档的表设置一个表和一个触发器.
每当我们发现我们不再希望这种行为与触发器和级联软删除时,我们可以轻松返回.
将来对原始表进行的所有模式迁移都将应用于_archive
该表的版本.除了约束,这是好的.
假设您添加一个新表,该表引用具有外键的另一个现有表ON DELETE CASCADE
.如果现有表使用archive_record()
上面的函数,DELETE
当现有表中的某些内容为软删除时,新表将收到硬盘.如果您也使用archive_record()
新的依赖表,这不是问题.但你必须记住它.
此处介绍的方法无法解决恢复单个行的问题.另一方面,这种方法不会使其变得更难或更复杂.它仍然没有解决.
在我们的应用程序中,工作项的某些字段没有指定外键.一个很好的例子是区域ID.这意味着当区域为DELETE
d时,关联的工作项不会自动DELETE
d.当一个区域被移除时,有两种情况:
直接从用户请求删除.
用户请求删除空格,然后由于空间上的外键约束而删除该区域.
请注意,在第一个场景中,用户的请求通过区域控制器代码,然后通过区域存储库代码.我们有可能在任何这些图层中修改将引用不存在区域的所有工作项.在第二种情况下,与区域相关的所有内容都会发生并保留在数据库层上,因此我们无法模拟工作项.好消息是我们没有必要.每个工作项都引用一个空格,因此当空间消失时,它将被删除.
适用于区域的内容也适用于迭代,标签和电路板列.
如何申请我们的数据库?脚步
为继承原始表的所有表创建"*_archived"表.
使用上述archive_record()
功能安装软删除触发器.
通过执行硬操作将所有条目移动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 := 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;
外键约束可以执行级联更新.如果您在密钥和删除标志上链接了表,那么当主表中的删除标志发生更改时,该更改将向下传播到详细信息表.我没有尝试过,但它应该工作.