SQL中的级联菱形删除

Posted

技术标签:

【中文标题】SQL中的级联菱形删除【英文标题】:Cascading diamond shaped deletes in SQL 【发布时间】:2016-05-23 08:17:02 【问题描述】:

如果我的数据库中有一个简单的 User 表和一个以 User.id 作为外键的简单 Item 表:

(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
name NVARCHAR (MAX) NULL,
email NVARCHAR (128) NULL,
authenticationId NVARCHAR (128) NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id))

CREATE TABLE Items
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
userId UNIQUEIDENTIFIER NOT NULL,
name NVARCHAR (MAX) NULL,
description NVARCHAR (MAX) NULL,
isPublic BIT DEFAULT 0 NOT NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (userId) REFERENCES Users (id))

如果从表中删除用户,我需要首先删除所有相关项以避免破坏参照完整性约束。这很容易通过CASCADE DELETE 完成

CREATE TABLE Items
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
userId UNIQUEIDENTIFIER NOT NULL,
name NVARCHAR (MAX) NULL,
description NVARCHAR (MAX) NULL,
isPublic BIT DEFAULT 0 NOT NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (userId) REFERENCES Users (id) ON DELETE CASCADE)

但是,如果我也有引用用户的集合,以及将项目收集到集合中的表,我就有麻烦了,即以下附加代码不起作用。

CREATE TABLE Collections
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
userId UNIQUEIDENTIFIER NOT NULL,
name NVARCHAR (MAX) NULL,
description NVARCHAR (MAX) NULL,
isPublic BIT DEFAULT 0 NOT NULL,
layoutSettings NVARCHAR (MAX) NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (userId) REFERENCES Users (id) ON DELETE CASCADE)

CREATE TABLE CollectedItems
(itemId UNIQUEIDENTIFIER NOT NULL,
collectionId  UNIQUEIDENTIFIER NOT NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY CLUSTERED (itemId, collectionId),
FOREIGN KEY (itemId) REFERENCES Items (id) ON DELETE CASCADE,
FOREIGN KEY (collectionId) REFERENCES Collections (id) ON DELETE CASCADE)

错误表明这“可能导致循环或多个级联路径”。我看到推荐的解决方法是

    重新设计表格,但我不知道如何;或者,通常表示为"a last resort" 使用触发器。

所以我像这样删除ON DELETE CASCADE 和instead use triggers (documentation):

CREATE TRIGGER DELETE_User
   ON Users
   INSTEAD OF DELETE
AS 
BEGIN
 SET NOCOUNT ON
 DELETE FROM Items WHERE userId IN (SELECT id FROM DELETED)
 DELETE FROM Collections WHERE userId IN (SELECT id FROM DELETED)
 DELETE FROM Users WHERE id IN (SELECT id FROM DELETED)
END

CREATE TRIGGER DELETE_Item
   ON Items
   INSTEAD OF DELETE
AS 
BEGIN
 SET NOCOUNT ON
 DELETE FROM CollectedItems WHERE itemId IN (SELECT id FROM DELETED)
 DELETE FROM Items WHERE id IN (SELECT id FROM DELETED)
END

CREATE TRIGGER DELETE_Collection
   ON Collections
   INSTEAD OF DELETE
AS 
BEGIN
 SET NOCOUNT ON
 DELETE FROM CollectedItems WHERE collectionId IN (SELECT id FROM DELETED)
 DELETE FROM Collections WHERE id IN (SELECT id FROM DELETED)
END

然而这失败了,虽然很微妙。我有一堆单元测试(用 xUnit 编写)。单独的测试总是通过。但是集体运行一些随机失败并出现 SQL 死锁。在 another answer 中,我被指向了 SQL Profiler,它显示了两个删除调用之间的死锁。

解决这些菱形删除级联的正确方法是什么?

【问题讨论】:

这适用于哪个 RDBMS?请添加标签以指定您使用的是mysqlpostgresqlsql-serveroracle 还是db2 - 或其他完全不同的东西。 既然你说测试只是“整体”失败,我猜你的 DELETE 触发器之间没有发生死锁,这对我来说似乎很好。因此,也许您需要进一步了解触发器。 他们在删除触发器上(尽管很难从分析器中得到它。)我将编辑问题并放入屏幕截图和死锁的更多细节,虽然它是一个但棘手,因为我在问题中给出的示例是从真实表格中简化而来的。 ...“其他”用户可以收集其他用户的物品吗?如果是这样,我敢打赌那就是导致问题的原因。你有冲突的删除都是为了同一件事。 否;但是还有其他表允许 UserA 收藏一些 UserB 的集合。我的单元测试(还)没有行使这一点,所以这不是死锁的根源。保持这些建议通过,谢谢 【参考方案1】:

我更喜欢有自动级联操作,无论是 DELETE 还是 UPDATE。只是为了安心。想象一下,您已经配置了级联删除,然后您的程序由于一些错误尝试删除错误的用户,即使数据库有一些与之相关的数据。相关表中的所有相关数据将在没有任何警告的情况下消失。

通常我会确保首先使用显式的单独过程删除所有相关数据,每个相关表一个,然后删除主表中的行。删除会成功,因为引用的表中没有子行。

对于您的示例,我将有一个专用的存储过程 DeleteUser 和一个参数 UserID,它知道哪些表与用户相关以及应以什么顺序删除详细信息。此过程经过测试,是删除用户的唯一方法。如果程序的其余部分错误地尝试直接从Users 表中删除一行,那么如果相关表中有一些数据,此尝试将失败。如果错误删除的用户没有任何详细信息,尝试会成功,但至少不会丢失很多数据。

对于您的架构,过程可能如下所示:

CREATE PROCEDURE dbo.DeleteUser
    @ParamUserID int
AS
BEGIN
    SET NOCOUNT ON; SET XACT_ABORT ON;

    BEGIN TRANSACTION;
    BEGIN TRY
        -- Delete from CollectedItems going through Items
        DELETE FROM CollectedItems
        WHERE CollectedItems.itemId IN
        (
            SELECT Items.id
            FROM Items
            WHERE Items.userId = @ParamUserID
        );

        -- Delete from CollectedItems going through Collections
        DELETE FROM CollectedItems
        WHERE CollectedItems.collectionId IN
        (
            SELECT Collections.id
            FROM Collections
            WHERE Collections.userId = @ParamUserID
        );

        -- Delete Items
        DELETE FROM Items WHERE Items.userId = @ParamUserID;

        -- Delete Collections
        DELETE FROM Collections WHERE Collections.userId = @ParamUserID;

        -- Finally delete the main user
        DELETE FROM Users WHERE ID = @ParamUserID;

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        ROLLBACK TRANSACTION;
        ...
        -- process the error
    END CATCH;
END

如果你真的想设置级联删除,那么我会定义 one 触发器,仅用于Users 表。同样,级联删除不会有外键,但Users 表上的触发器将具有与上述过程非常相似的逻辑。

【讨论】:

谢谢 - 我将删除我的触发器并将它们重写为像这样的存储过程。希望这能解决僵局 - 我会回来报告。 确实如此 - 不再出现死锁。再次感谢【参考方案2】:

我想到了几种工作方式:

    不要删除用户,只需将其停用即可。添加一个 BIT 字段active 并将其设置为 0 用于停用用户。简单、容易、快速,并维护一个日志,您的系统中有哪些用户以及他们的关联状态是什么。通常您不应该删除有关用户的此类信息,您希望保留它以供将来参考。

    不要依赖级联和触发器,在代码中自己处理。级联和触发器可能难以维护,其行为也难以预测(参见您遇到的死锁)。

    如果您不能/不想执行上述任何操作,请考虑从用户删除触发器中删除所有内容。首先disable the delete triggers 引用表,执行所有删除操作,然后enable the delete triggers 引用表。

【讨论】:

...您仍然可以从代码中获得死锁,特别是当您仍在运行(几乎)完全相同的语句时,只是从较慢的位置。 visibility 可能更好(发生这种情况),但实际的排序预测并没有改变。我真的不喜欢将禁用触发器作为“标准的、由客户端启动的操作”的一部分。一方面,它会阻止并发调用。 Drat - 理解和修复这些死锁是我所追求的。 @Clockwork-Muse 正如我在 2 中所说,触发器真是一团糟……我只在绝对没有其他方法的情况下使用它们。事实证明,我从不使用它们。我会选择选项 1,如果您可以停用用户,您为什么还要删除他们? 我同意,“删除”用户的历史会很有趣。但是一些团队成员,尤其是那些具有法律或隐私经验的成员,希望用户删除他们的帐户以删除他们的所有数据。所以 1 适合我们。【参考方案3】:

要尝试的另一件事是在您删除用户/项目/集合时在触发器中将隔离级别设置为 SERIALIZABLE。由于您在删除用户时可能会删除许多项目/集合/收集的项目,因此在此运行期间有另一个事务 INSERT 可能会导致问题。 SERIALIZABLE 在一定程度上解决了这个问题。

SQL-Server 在级联删除中使用该隔离级别正是出于以下原因: https://docs.microsoft.com/en-us/archive/blogs/conor_cunningham_msft/conor-vs-isolation-level-upgrade-on-updatedelete-cascading-ri

【讨论】:

好吧,你可以仍然SERIALIZABLE 陷入僵局(事实上,考虑到它必须升级某些事情的方式更容易)。您必须格外小心操作的顺序,以防止发生此类引用

以上是关于SQL中的级联菱形删除的主要内容,如果未能解决你的问题,请参考以下文章

Mongoose中的级联样式删除

Hibernate的一对多自关联中的级联删除问题

sql 级联删除问题

Ruby ActiveRecord 模型中的级联删除?

没有级联删除目标的级联删除关系

休眠 - 多对多关系中的级联删除