Entity Framework的Include和OrderBy random生成了重复数据。

20

当我从数据库检索包括某些子项(通过 .Include),并随机排序时,EF给出了一个意外的结果.. 它创建/克隆了额外的项。

为了更好地解释,我创建了一个简单的EF CodeFirst项目来重现这个问题。首先,我将为此项目提供代码。

该项目

创建一个基本的MVC3项目,并通过Nuget添加EntityFramework.SqlServerCompact包。
这会添加以下软件包的最新版本:

  • EntityFramework v4.3.0
  • SqlServerCompact v4.0.8482.1
  • EntityFramework.SqlServerCompact v4.1.8482.2
  • WebActivator v1.5

模型和DbContext

using System.Collections.Generic;
using System.Data.Entity;

namespace RandomWithInclude.Models
{
    public class PeopleContext : DbContext
    {
        public DbSet<Person> Persons { get; set; }
        public DbSet<Address> Addresses { get; set; }
    }

    public class Person
    {
        public int ID { get; set; }
        public string Name { get; set; }

        public virtual ICollection<Address> Addresses { get; set; }
    }

    public class Address
    {
        public int ID { get; set; }
        public string AdressLine { get; set; }

        public virtual Person Person { get; set; }
    }
}

数据库设置和种子数据: EF.SqlServerCompact.cs

using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using RandomWithInclude.Models;

[assembly: WebActivator.PreApplicationStartMethod(typeof(RandomWithInclude.App_Start.EF), "Start")]

namespace RandomWithInclude.App_Start
{
    public static class EF
    {
        public static void Start()
        {
            Database.DefaultConnectionFactory = new SqlCeConnectionFactory("System.Data.SqlServerCe.4.0");
            Database.SetInitializer(new DbInitializer());
        }
    }
    public class DbInitializer : DropCreateDatabaseAlways<PeopleContext>
    {
        protected override void Seed(PeopleContext context)
        {
            var address1 = new Address {AdressLine = "Street 1, City 1"};
            var address2 = new Address {AdressLine = "Street 2, City 2"};
            var address3 = new Address {AdressLine = "Street 3, City 3"};
            var address4 = new Address {AdressLine = "Street 4, City 4"};
            var address5 = new Address {AdressLine = "Street 5, City 5"};
            context.Addresses.Add(address1);
            context.Addresses.Add(address2);
            context.Addresses.Add(address3);
            context.Addresses.Add(address4);
            context.Addresses.Add(address5);
            var person1 = new Person {Name = "Person 1", Addresses = new List<Address> {address1, address2}};
            var person2 = new Person {Name = "Person 2", Addresses = new List<Address> {address3}};
            var person3 = new Person {Name = "Person 3", Addresses = new List<Address> {address4, address5}};
            context.Persons.Add(person1);
            context.Persons.Add(person2);
            context.Persons.Add(person3);
        }
    }
}

控制器:HomeController.cs

using System;
using System.Data.Entity;
using System.Linq;
using System.Web.Mvc;
using RandomWithInclude.Models;

namespace RandomWithInclude.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            var db = new PeopleContext();
            var persons = db.Persons
                                .Include(p => p.Addresses)
                                .OrderBy(p => Guid.NewGuid());

            return View(persons.ToList());
        }
    }
}

视图:Index.cshtml

@using RandomWithInclude.Models
@model IList<Person>

<ul>
    @foreach (var person in Model)
    {
        <li>
            @person.Name
        </li>
    }
</ul>

就这些,你的应用程序应该编译:)

SELECT 
    [Project1].[ID] AS [ID], 
    [Project1].[Name] AS [Name], 
    [Project1].[C2] AS [C1], 
    [Project1].[ID1] AS [ID1], 
    [Project1].[AdressLine] AS [AdressLine], 
    [Project1].[Person_ID] AS [Person_ID]
FROM ( SELECT 
    NEWID() AS [C1], 
    [Extent1].[ID] AS [ID], 
    [Extent1].[Name] AS [Name], 
    [Extent2].[ID] AS [ID1], 
    [Extent2].[AdressLine] AS [AdressLine], 
    [Extent2].[Person_ID] AS [Person_ID], 
    CASE WHEN ([Extent2].[ID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C2]
    FROM  [People] AS [Extent1]
    LEFT OUTER JOIN [Addresses] AS [Extent2] ON [Extent1].[ID] = [Extent2].[Person_ID]
)  AS [Project1]
ORDER BY [Project1].[C1] ASC, [Project1].[ID] ASC, [Project1].[C2] ASC

解决方法

  1. 如果我从查询中删除 .Include(p =>p.Addresses),一切都正常。但是,未加载地址并且每次访问该集合时都会向数据库发出新的调用。
  2. 我可以先从数据库获取数据,然后通过在 .OrderBy.. 前添加 .ToList() 来随机化,就像这样:var persons = db.Persons.Include(p => p.Addresses).ToList().OrderBy(p => Guid.NewGuid());

有人有任何想法为什么会发生这种情况吗?
这可能是 SQL 生成中的错误吗?


这可能是SQL生成中的一个错误吗?您是否尝试执行两个查询(工作/不工作)并查看它们在结果上是否有所不同?无论如何,我认为这只是您在框架中发现的另一个错误。请参见http://msdn.microsoft.com/en-us/library/bb896317.aspx和“投影到匿名类型”,这似乎有关。这个框架中有一些可疑的东西需要被消灭。 - stefan
@stefan:是的,我已经尝试过了,当然返回了不同的结果,这确实指向了一个SQL生成错误。但我不认为它已经在那里列出了... - Filip Cornelissen
这个问题在使用EF 4.1和SQL Server(在2008 R2上测试过)时也会出现,不仅仅是在SQLCompact上。我认为,@np-hard 的答案是正确的,问题出在EF的对象材料化,而不是生成的SQL语句上。如果 OrderBy 不是在父实体的属性上,就会发生荒谬的情况。 - Slauma
6个回答

12

通过阅读AakashM的回答Nicolae Dascalu的回答,我们可以得出结论,Linq OrderBy 需要一个稳定的排名函数,而 NewID/Guid.NewGuid 却不是。

因此,我们必须使用另一个在单个查询中稳定的随机生成器。

为了实现这一点,在每次查询之前,使用 .Net Random 生成器获取一个随机数。然后将该随机数与实体的唯一属性相结合,以获得随机排序。并且为了稍微“随机化”结果,checksum 它。(checksum 是一个计算哈希的 SQL Server 函数;这个想法最初来源于这篇博客。)

假设 PersonId 是一个 int,你可以这样编写你的查询:

// Random instances should be stored and reused, not instanciated at each usage.
// But beware, it is not thread safe. If you want to share it between threads, you
// would have to use locks, see its documentation.
// https://learn.microsoft.com/en-us/dotnet/api/system.random.
// But using locks is a bad idea for scalability, especially in a Web context.
var randomGenerator = new Random();
// ...

var rnd = randomGenerator.NextDouble();
var persons = db.Persons
    .Include(p => p.Addresses)
    .OrderBy(p => SqlFunctions.Checksum(p.Id * rnd));

就像NewGuid hack一样,这很可能不是一个分布好且良好的随机生成器。但是它不会导致实体在结果中被重复。

注意:
如果您的查询排序不能保证实体排名的唯一性,那么您必须补充它来保证唯一性。例如,如果您在checksum调用中使用实体的非唯一属性,则在OrderBy之后添加类似于.ThenBy(p => p.Id)的内容。
如果您的排名对于查询的根实体不是唯一的,那么其包含的子项可能与其他具有相同排名的实体的子项混合在一起。那么bug将继续存在。

注意:
我更喜欢使用.Next()方法获取一个int,然后通过异或(^)与实体int唯一属性组合,而不是使用double并进行乘法运算。但是SqlFunctions.Checksum不幸的是没有为int数据类型提供重载,尽管SQL服务器功能支持它。您可以使用类型转换来解决这个问题,但是为了保持简单,我最终选择了乘法运算。


不错的解决方案!但是性能如何?它看起来一点也不轻量级! - SepehrM
1
根据我的当前数据量和请求负载,这对用户没有明显的影响。但是我的当前数据量很小:少于5,000个匹配行需要排序。(由于应用程序的性质,它可能不会超过50,000个。) 无论如何,我已经有了备选方案:我在一个SQL作业中使用相同的技术来定期重新计算目标表上的索引“RandomRank”列,以处理其他需要跨请求进行“静态”随机排序的情况。因此,如果动态性能出现问题,我可以与业务协商,改为在所有地方使用静态方案。 - Frédéric
顺便说一下,这个“CheckSum”排序对我来说并不比“NewGuid”排序更重:两者都会导致服务器没有索引可用于执行排序;“NewID”计算可能并不比乘法后跟随校验和轻。 - Frédéric
SqlFunctions是从哪里来的?我如何在.NET Core中实现这个? - Neme
似乎在 .Net Core 中不可用。请参考我在最后一段中添加的链接。 - Frédéric

8
简述:这里存在一个信息泄漏的抽象问题。对我们而言,Include只是一个简单的指令,将一组东西粘贴到每个返回的Person行上。但EF的Include实现是通过为每个Person-Address组合返回整个行并在客户端重新进行组装来完成的。按易挥发值排序会导致这些行被打乱,并且破坏了EF所依赖的Person组。
当我们查看此LINQ的ToTraceString()时:
 var people = c.People.Include("Addresses");
 // Note: no OrderBy in sight!

我们看到

SELECT 
[Project1].[Id] AS [Id], 
[Project1].[Name] AS [Name], 
[Project1].[C1] AS [C1], 
[Project1].[Id1] AS [Id1], 
[Project1].[Data] AS [Data], 
[Project1].[PersonId] AS [PersonId]
FROM ( SELECT 
    [Extent1].[Id] AS [Id], 
    [Extent1].[Name] AS [Name], 
    [Extent2].[Id] AS [Id1], 
    [Extent2].[PersonId] AS [PersonId], 
    [Extent2].[Data] AS [Data], 
    CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
    FROM  [Person] AS [Extent1]
    LEFT OUTER JOIN [Address] AS [Extent2] ON [Extent1].[Id] = [Extent2].[PersonId]
)  AS [Project1]
ORDER BY [Project1].[Id] ASC, [Project1].[C1] ASC

因此,对于每个 A,我们得到 n 行,再加上没有任何 A 的每个 P 1 行。

然而,添加一个 OrderBy 子句会将要排序的内容放在排序列的开头

var people = c.People.Include("Addresses").OrderBy(p => Guid.NewGuid());

提供

SELECT 
[Project1].[Id] AS [Id], 
[Project1].[Name] AS [Name], 
[Project1].[C2] AS [C1], 
[Project1].[Id1] AS [Id1], 
[Project1].[Data] AS [Data], 
[Project1].[PersonId] AS [PersonId]
FROM ( SELECT 
    NEWID() AS [C1], 
    [Extent1].[Id] AS [Id], 
    [Extent1].[Name] AS [Name], 
    [Extent2].[Id] AS [Id1], 
    [Extent2].[PersonId] AS [PersonId], 
    [Extent2].[Data] AS [Data], 
    CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C2]
    FROM  [Person] AS [Extent1]
    LEFT OUTER JOIN [Address] AS [Extent2] ON [Extent1].[Id] = [Extent2].[PersonId]
)  AS [Project1]
ORDER BY [Project1].[C1] ASC, [Project1].[Id] ASC, [Project1].[C2] ASC

在您的情况下,按顺序的事物不是P的属性,而是易变的,因此可以对于相同的P的不同P-A记录有所不同,整个过程就失败了。
我不确定这种行为在“按预期工作~~~铸铁错误”连续体的哪个位置上,但至少我们现在知道了它。

1
请查看Nicolae的回答,这可能真的是一个“按预期工作”的情况,甚至是“预期”的。对于NewGuid随机排序黑客来说,它似乎只是破碎的。 - Frédéric

5
我认为查询生成没有问题,但当EF试图将行转换为对象时,肯定存在问题。
这里似乎有一个固有的假设:在连接语句中返回的同一人的数据将按顺序分组在一起,无论是否按顺序排列。
例如,联接查询结果将始终如下:
P.Id P.Name  A.Id A.StreetLine
1    Person 1 10    --- 
1    Person 1 11
2    Person 2 12
3    Person 3 13
3    Person 3 14 

即使您按照其他列排序,同一人也总是相继出现。

对于任何联接查询,这种假设大多数情况下都是正确的。

但我认为这里存在一个更深层次的问题。OrderBy适用于当您需要以特定顺序获取数据时(与随机相反),因此这种假设似乎是合理的。

我认为您应该真正地将数据提取出来,然后在代码中根据其他方式进行随机化。


“内在假设”:我同意并猜测相同的事情。在随机排序的情况下,对象实例化返回错误结果,可能需要EF对返回的数据进行分组,这将使急切加载的数据材料化更加昂贵。在内存中排序也可能更便宜,因为它只对父实体进行排序,而不是数据库将要执行的两个连接(父、子)表的排序。 - Slauma
OrderBy 是用于在特定顺序下获取数据的(与随机相反)。随机顺序如何不是一种确定的顺序呢?此外,在计算机科学中,“随机”只是一个特定的算法,因此只是按照查询的唯一序列排序。 - stefan
2
在我的辩护中,"random"作为形容词确实意味着“没有明确的计划或预先安排的顺序” :-) - np-hard

2
从理论上讲: 为了对项目列表进行排序,比较函数应该相对于项目是稳定的;这意味着对于任何2个项目x,y,x
我认为问题与误解OrderBy方法的规范(文档)相关: keySelector-从元素中提取键的函数。 EF没有明确提到提供的函数是否应该为同一对象返回相同的值,因为它在您的情况下返回不同/随机值,但我认为他们在文档中使用的“键”术语暗示了这一点。

这篇博客文章进一步证实,使用不稳定的比较函数会导致错误。 - Frédéric

0

当您定义查询路径以定义查询结果时(使用Include),查询路径仅在返回的ObjectQuery实例上有效。其他ObjectQuery实例和对象上下文本身不受影响。此功能允许您链接多个“包含”以进行急切加载。

因此,您的语句翻译为

from person in db.Persons.Include(p => p.Addresses).OrderBy(p => Guid.NewGuid())
select person

而不是你打算的。

from person in db.Persons.Include(p => p.Addresses)
select person
.OrderBy(p => Guid.NewGuid())

因此,您的第二个解决方案很好用 :)
参考:在Entity Framework中查询概念模型时加载相关对象 - http://msdn.microsoft.com/en-us/library/bb896272.aspx

嘿@Sai,感谢你的回应,但是你的查询语法有误。我将其更改为:(from person in db.Persons.Include(p => p.Addresses) select person).OrderBy(p => Guid.NewGuid()),但这只会生成相同的SQL并保留问题..所以这不是答案,只是我的查询方式不同 :) - Filip Cornelissen

0

我也遇到了这个问题,并通过向我正在获取的主类添加一个随机化 Guid 属性来解决它。然后,我将该列的默认值设置为 NEWID(),如下所示(使用 EF Core 2)

builder.Entity<MainClass>()
    .Property(m => m.Randomizer)
    .HasDefaultValueSql("NEWID()");

在获取数据时,情况会变得有些复杂。我创建了两个随机整数作为我的排序索引,然后像这样运行查询

var rand = new Random();
var randomIndex1 = rand.Next(0, 31);
var randomIndex2 = rand.Next(0, 31);
var taskSet = await DbContext.MainClasses
    .Include(m => m.SubClass1)
        .ThenInclude(s => s.SubClass2)
    .OrderBy(m => m.Randomizer.ToString().Replace("-", "")[randomIndex1])
        .ThenBy(m => m.Randomizer.ToString().Replace("-", "")[randomIndex2])
    .FirstOrDefaultAsync();

这似乎运行良好,应该为大型数据集提供足够的熵以实现相对随机化。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接