【问题标题】:EF Core Cascading Deletes for Speed?EF Core 级联删除以提高速度?
【发布时间】:2019-03-15 09:55:32
【问题描述】:

我正在开发一个已建立的(但可以更改,假设现有数据在任何更改后仍然存在)代码库并调查一些非常缓慢的删除。到目前为止,我只是成功地让事情变得更糟,所以我们到了。为了避免造成额外不必要的混乱,我已经取消了我在下面尝试的大部分更改。

有一个数据类 ProductDefinition,它模拟了一个相同的对象层次结构,类似于例如文件夹结构:每个 PD(根目录除外)都有一个父级,但就像一个文件夹可以有多个子级一样。

public class ProductDefinition
{
    public int ID { get; set; }

    // each tree of PDs should have a 'head' which will have no parent
    // but most will have a ParentPDID and corresponding ParentPD
    public virtual ProductDefinition ParentProductDefinition { get; set; } 
    public int? ParentProductDefinitionId { get; set; }  

    public virtual List<ProductDefinition> ProductDefinitions { get; set; } 
                                    = new List<ProductDefinition>();

    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    // etc. Fields. Nothing so large you'd expect speed issues

}

在Context中已经明确声明了对应的表

public DbSet<ProductDefinition> ProductDefinitions { get; set; }

以及在 Context.OnModelCreating 上定义的 Fluent API 关系

modelBuilder.Entity<ProductDefinition>()
            .HasMany(productDefinition => productDefinition.ProductDefinitions)
            .WithOne(childPd => childPd.ParentProductDefinition)
            .HasForeignKey(childPd => childPd.ParentProductDefinitionId)
            .HasPrincipalKey(productDefinition => productDefinition.ID);

似乎已经尝试在 ProductDefinitionManager 类中确定删除

public static async Task ForceDelete(int ID, ProductContext context)
    {
        // wrap the recursion in a save so that it only happens once
        await ForceDeleteNoSave(ID, context);
        await context.SaveChangesAsync();
    }

还有

private static async Task ForceDeleteNoSave(int ID, ProductContext context)
    {
        var pd = await context.ProductDefinitions
                             .AsNoTracking()
                             .Include(x => x.ProductDefinitions)
                             .SingleAsync(x => x.ID == ID);

        if (pd.ProductDefinitions != null && pd.ProductDefinitions.Count != 0)
        {
            var childIDs = pd.ProductDefinitions.Select(x => x.ID).ToList();

            // delete the children recursively
            foreach (var child in childIDs)
            {
                // EDITED HERE TO CORRECTLY REFLECT THE CURRENT CODE BASE
                await ForceDeleteNoSave(child, context);
            }
        }

        // delete the PD
        // mark Supplier as edited
        var supplier = await context.Suppliers.FindAsync(pd.SupplierID);
        supplier.Edited = true;

        // reload with tracking
        pd = await context.ProductDefinitions.FirstOrDefaultAsync(x => x.ID == ID);
        context.ProductDefinitions.Remove(pd);
    }

目前,上述解决方案“有效”,但是:

a) 需要 2 多分钟才能完成 b)似乎给 React 前端一个 502 错误(但见上文)。当然,FE 声称是 502

我的主要问题是:有没有办法提高删除速度,例如通过在 FluentAPI 中定义级联删除(我在尝试应用迁移时遇到了问题)?但我欢迎讨论可能导致 FE 报告 Bad Gateway 的原因。

【问题讨论】:

    标签: c# ef-code-first entity-framework-core


    【解决方案1】:

    不幸的是,这是自引用关系,由于“多级联路径”问题,无法使用级联删除 - SqlServer(可能还有其他)数据库的限制(Oracle 没有此类问题)。

    在不支持“多级联路径”的数据库中,最好的处理方法是使用数据库触发器(“而不是删除”)。

    但是假设我们想通过 EF Core 中的客户端代码来处理它。问题是如何有效地加载递归树状结构(EF Core 中的另一个不容易的任务,因为缺乏递归查询支持)。

    您的代码的问题在于它使用 深度优先 算法,该算法执行大量数据库查询。更合适和更高效的方法是使用 breath first 算法 - 简单来说,按 level 加载项目。这样,数据库查询的数量将是树中的最大深度,远小于元素的数量。

    实现该方法的一种方法是从应用初始过滤器的查询开始,然后使用SelectMany 获得下一个级别(每个SelectMany 都会向前一个查询添加一个连接)。当查询没有返回数据时,流程结束:

    public static async Task ForceDelete(int ID, ProductContext context)
    {
        var items = new List<ProductDefinition>();
    
        // Collect the items by level    
        var query = context.ProductDefinitions.Where(e => e.ID == ID);
        while (true)
        {
            var nextLevel = await query
                .Include(e => e.Supplier)
                .ToListAsync();
            if (nextLevel.Count == 0) break;
            items.AddRange(nextLevel);
            query = query.SelectMany(e => e.ProductDefinitions);
        }
    
        foreach (var item in items)
            item.Supplier.Edited = true;
    
        context.RemoveRange(items);
    
        await context.SaveChangesAsync();
    }
    

    请注意,执行的查询会预先加载相关的Supplier,因此可以轻松更新。

    一旦收集了这些项目,它们就会通过RemoveRange 方法简单地标记为删除。顺序无关紧要,因为 EF Core 无论如何都会按依赖顺序应用命令。

    另一种收集项目的方法是使用上一级的IDs 作为过滤器(SQL IN):

    // Collect the items by level    
    Expression<Func<ProductDefinition, bool>> filter = e => e.ID == ID;
    while (true)
    {
        var nextLevel = await context.ProductDefinitions
            .Include(e => e.Supplier)
            .Where(filter)
            .ToListAsync();
        if (nextLevel.Count == 0) break;
        items.AddRange(nextLevel);
        var parentIds = nextLevel.Select(e => e.ID);
        filter = e => parentIds.Contains(e.ParentProductDefinitionId.Value);
    }
    

    我更喜欢前者。缺点是 EF Core 会生成巨大的表名别名,并且在深度较大的情况下可能会遇到一些 SQL 连接数限制。后者没有深度限制,但可能与大 IN 子句有问题。您应该检查哪一个更适合您的情况。

    【讨论】:

      【解决方案2】:

      好的。很难理解为什么这很慢。数据结构有多大等。

      当我看到上面的代码时,首先映入我眼帘的是:

      public static async Task ForceDelete(int ID, ProductContext context)
      {
          // wrap the recursion in a save so that it only happens once
          await ForceDeleteNoSave(ID, context);
          await context.SaveChangesAsync();
      }
      

      这个方法是递归调用的,但每次你处理完一堆孩子时,它会调用context.SaveChagesAsync()。这意味着当您运行代码时,您将获得多次保存和多次调用数据库。

      这似乎是一种反模式,因为如果您的程序在中途崩溃,它已经删除了一些孩子。

      取而代之的是InitForceDelete(),它最终会调用context.SaveChangesAsync(),所以这一切都在一个操作中完成。

      类似这样的:

      public static async Task InitForceDelete(int ID, ProductContext context)
      {
          // wrap the recursion in a save so that it only happens once
          await ForceDeleteNoSave(ID, context);
          await context.SaveChangesAsync();
      }
      
      private static async Task ForceDeleteNoSave(int ID, ProductContext context)
      {
          var pd = await context.ProductDefinitions
                               .AsNoTracking()
                               .Include(x => x.ProductDefinitions)
                               .SingleAsync(x => x.ID == ID);
      
          if (pd.ProductDefinitions != null && pd.ProductDefinitions.Count != 0)
          {
              var childIDs = pd.ProductDefinitions.Select(x => x.ID).ToList();
      
              // delete the children recursively
              foreach (var child in childIDs)
              {
                  await ForceDeleteNoSave(child, context);
              }
          }
          var supplier = await context.Suppliers.FindAsync(pd.SupplierID);
          supplier.Edited = true;
      
          // reload with tracking
          pd = await context.ProductDefinitions.FirstOrDefaultAsync(x => x.ID == ID);
          context.ProductDefinitions.Remove(pd);
      }
      

      其次,您应该尝试检查您的 SQL 服务器上正在执行的 sql。您应该能够找到由您的 LINQ 语句触发的执行计划,并查看 SQL 是否完全疯狂。也许您的代码正在为每个 ProductDefinition 执行一个调用,这会使其变得超级慢。

      很抱歉,我不能更准确地说,但是从您提供的代码中很难直接给出指示,除非您不断调用 context.SaveChagesAsync()。

      【讨论】:

      • 嗨,克里斯蒂安,感谢您的回复。我将不得不修改使用的代码 sn-p,因为我已经发现并纠正了您在嵌套 SaveChangesAsync() 中发现的问题。实际上,使用您刚刚看到的代码,我得到了“期望更改一行但更改零行”的异常。
      • 关于对象本身,它很小。如果我在 Excel 电子表格或 SSMS 中执行此操作,我希望它需要不到一秒钟的时间。事实上,在应用程序的其他地方,我只是通过吸入整个表格来避免“得到这个,现在得到那个,现在那个东西”的瓶颈。这里的调查表明,当我们 SaveChangesAsync() 时,EF 会按顺序加载每一行并执行删除。如果使用 RemoveRange 似乎也是这种情况,尽管在昨晚的某个时候,我似乎记得读过有关 RemoveRange 的“单次命中”版本......?
      • @technorabble 我认为这里的大问题是您正在对未定义的深度进行递归删除。这对于 EF 来说很难有效地转化为有效的 SQL。如果它是一个小对象——即使有级联删除——我希望它会很快。唯一真正的解决方案是直接在您的上下文中执行自定义 SQL 语句。
      • 这就是为什么我想知道docs.microsoft.com/en-us/ef/ef6/modeling/code-first/fluent/… 是否更合适。告诉数据库会发生什么,然后业务逻辑代码可以只删除指定的单个 PD,然后数据库处理其余部分。
      • @technorabble 可能是这样,尽管我仍然认为 LINQ 很难有效地翻译。但是你能做的最好的事情是尝试创建一个语句来触发你的数据库并查看 LINQ 语句的执行计划,看看 SQL 是否更有效。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2018-07-19
      • 2018-08-29
      • 1970-01-01
      • 2014-09-16
      • 1970-01-01
      • 2015-04-24
      相关资源
      最近更新 更多