Entity Framework Core 数据查询原理详解

Posted JimCarter

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Entity Framework Core 数据查询原理详解相关的知识,希望对你有一定的参考价值。

https://docs.microsoft.com/zh-cn/ef/core/querying/

1. 查询sql的生成机制

一般来说,ef会将linq表达式生成为只需要在数据库端执行的sql。但是有些linq表达式没法生成完全由数据库处理的sql,如:

//StandardizeUrl是一个方法
var blogs = context.Blogs
    .OrderByDescending(blog => blog.Rating)
    .Select(
        blog => new { Id = blog.BlogId, Url = StandardizeUrl(blog.Url) })
    .ToList();

这种情况下ef provider不了解StandardizeUrl方法的生成,就没法将其转换为对应的sql。所以select方法之前的部分会生成sql,然后在数据库端执行,select方法会在客户端执行。

但是客户端执行有时会带来性能问题:

var blogs = context.Blogs
    .Where(blog => StandardizeUrl(blog.Url).Contains("dotnet"))
    .ToList();

这个就相当于把全表数据查询了。

如果需要强制让客户端执行某些查询,则可以使用ToList(把全部数据放到内存中)或AsEnumerable(流式访问数据)方法:

var blogs = context.Blogs
    .AsEnumerable()
    .Where(blog => StandardizeUrl(blog.Url).Contains("dotnet"))
    .ToList();

2. 跟踪查询与非跟踪查询

ef默认使用的是跟踪查询,会把查询出来的实体实例信息记录在跟踪器中。当调用SaveChanges方法时,会把更改的部分保存到数据库。

注意:使用了[Keyless]特性的实体永远不会被跟踪

var blog = context.Blogs.SingleOrDefault(b => b.BlogId == 1);
blog.Rating = 5;
context.SaveChanges();

当返回结果时,如果ef检查到跟踪器中已经存在BlogId==1的实例了,则会把这个实例直接返回(还是会执行一遍sql),而且不会用数据库中的值覆盖现有实例的值。所以以下代码可能会出乎你的意料:

//-------------------------------------
var user =await _context.SysUsers.FindAsync(id);
user.UserName = "11111";
user = await _context.SysUsers.FindAsync(id);//此时user的name还是11111,可以使用AsNoTracking解决

//--------------------------------------
var orders = context.Orders.Where(o => o.Id > 1000).ToList();
//此时查询出来的是order的id大于1000的数据,而不是大于5000的数据。因为在跟踪查询的情况下,filter里的导航属性会被认为已经加载完成
var filtered = context.Customers.Include(c => c.Orders.Where(o => o.Id > 5000)).ToList();

如果要使用非跟踪查询,则调用AsNoTracking方法,可以提高性能:

var blogs = context.Blogs
    .AsNoTracking()
    .ToList();
    
//或者全局级别进行设置
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

如果查询之后返回的不是实体是匿名对象,ef只会跟踪匿名对象中的实体类型:

  1. 会跟踪Blog对象
var blog = context.Blogs.Select(b =>new { Blog = b, PostCount = b.Posts.Count() });
  1. 会跟踪BlogPost,因为post来自于linq
var blog = context.Blogs.Select(
        b =>new { Blog = b, Post = b.Posts.OrderBy(p => p.Rating).LastOrDefault() });
  1. 不执行任何跟踪
var blog = context.Blogs.Select(b =>new { Id = b.BlogId, b.Url });

3. 加载导航属性的数据

支持三种加载方式:

  • Eager loading(预加载):查询父实体时使用Include方法,直接从数据库把关联子实体数据读出来,即尽可能的早读取
  • Explic loading(显式加载):当需要时,手动调用方法从数据库读数据
  • Lazy loading(懒加载):遍历导航属性时才从数据库读数据(需要进行一番配置),即尽可能的晚读取。

3.1 预加载,使用Include

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .Include(blog => blog.Owner)
        .ToList();
}

注意:因涉及到联表查询,所以可能会有性能问题。而且ef会根据之前已经加载到context中的实例自动填充导航属性,所以有时候你没写include也可能会在导航属性中发现值。

多层级的include

逐级包含IncludeThenInclude

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .ThenInclude(post => post.Author)//也把Post下的Author给查出来
        .ThenInclude(author => author.Photo)
        .Include(blog => blog.Owner)
        .ToList();
}

也可以使用单个Include方法加载多个导航,以节省代码:

    var blogs = context.Blogs
        .Include(blog => blog.Owner.AuthoredPosts)//自动把owner查出来
        .ThenInclude(post => post.Blog.Owner.Photo)
        .ToList();

Include里的导航属性进行过滤

可以使用Where, OrderBy, OrderByDescending, ThenBy, ThenByDescending, Skip, Take

using (var context = new BloggingContext())
{
    var filteredBlogs = context.Blogs
        .Include(
            blog => blog.Posts
                .Where(post => post.BlogId == 1)
                .OrderByDescending(post => post.Title)
                .Take(5))
        .ToList();
}

但是Include只支持同一个过滤操作

using (var context = new BloggingContext())
{
    var filteredBlogs = context.Blogs
        .Include(blog => blog.Posts.Where(post => post.BlogId == 1))
        .ThenInclude(post => post.Author)
        .Include(blog => blog.Posts)//无效,因为与blog.Posts.Where(post => post.BlogId == 1)不一样
        .ThenInclude(post => post.Tags.OrderBy(postTag => postTag.TagId).Skip(3))
        .ToList();
}

注意:Include与ThenInclude的命名空间是:Microsoft.EntityFrameworkCore

3.2 显式加载,使用context.Entry()

查出来数据之后,通过调用DbContext.Entry(....)方法,手动加载导航属性的数据。

using (var context = new BloggingContext())
{
	//先查出来,此时blog的导航属性没有值
    var blog = context.Blogs.Single(b => b.BlogId == 1);

    context.Entry(blog)
        .Collection(b => b.Posts)//获取导航属性的值
        .Load();

    context.Entry(blog)
        .Reference(b => b.Owner)//获取导航属性的值
        .Load();
        
    context.Entry(blog)
        .Collection(b => b.Posts)
        .Query()
        .Where(p => p.Rating > 3)//还可以进一步执行筛选
        .ToList();
}

CollectionReference的区别:一个用来获取集合,一个用来获取单个对象。

3.3 懒加载,使用Virtual

使用懒加载最简单的方式是安装nuget包:Microsoft.EntityFrameworkCore.Proxies,然后调用UseLazyLoadingProxies启用该包。

//可在dbcontext中配置
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseLazyLoadingProxies()
        .UseSqlServer(myConnectionString);
        
//或在startup里配置
.AddDbContext<BloggingContext>(
    b => b.UseLazyLoadingProxies()
          .UseSqlServer(myConnectionString));

然后将导航属性定义为virtual:

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }
    //设置可以懒加载
    public virtual ICollection<Post> Posts { get; set; }
}

然后当你访问导航数据里的数据时才会触发数据库查询操作。

3.4 修复可能出现的循环引用导致json序列化失败

如果你使用的是Newtonsoft.Json,则这么配置就好了

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
        .AddJsonOptions(
            options => options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore
        );
}

如果你使用的是System.Text.Json截止发稿时的最新版本(EF Core 5.0)则可以这么配置:

services.AddControllers()
        .AddJsonOptions(opt =>
        {
            opt.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
        });

但是这种情况下,返回的json字符串会出现额外的一些如$id$values之类的元数据节点,目前还没找到方式怎么去除,但是不会影响你的反序列结果。

4. 拆分查询

一般查询导航属性的数据时都会使用join进行连表查询,如果查询一对多关系时linq涉及到多个表A\\B\\C\\D。那么连表之后产生的数据量就是ABC*D, 随着加载更多的一对多关系就可能产生“笛卡尔爆炸”(cartesian explosion)问题。为了解决这个潜在的问题,可以使用AsSplitQuery方法将一个查询sql拆分多个查询sql。当然什么使用视自己的linq查询情况而定,如果只是join两个表,就算了。

4.1 AsSplitQuery

AsSplitQuery要配合Include使用,而且只对一对多的关系进行查询时才生效,多对一和一对一不会拆分。

对于一个Role包含多个User,一个User包含多个Child类似这种结构来说,如果有如下查询

//where语句放在include前面或AsSplitQuery后面是无所谓的,不会影响sql的实际生成
_context.Roles.Include(r=>r.Users).ThenInclude(u=>u.MyChildren).AsSplitQuery().Where(r => r.Id == 1).ToList();

会生成类似以下3个sql语句:

[17:16:25 INF] Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "s"."Id", "s"."RoleName"
FROM "SysRole" AS "s"
WHERE "s"."Id" = 1
ORDER BY "s"."Id"
[17:16:26 INF] Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "s0"."Id", "s0"."CreateTime", "s0"."Pwd", "s0"."RoleId", "s0"."UserName", "s"."Id"
FROM "SysRole" AS "s"
INNER JOIN "SysUser" AS "s0" ON "s"."Id" = "s0"."RoleId"
WHERE "s"."Id" = 1
ORDER BY "s"."Id", "s0"."Id"
[17:16:26 INF] Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "s1"."Id", "s1"."Name", "s1"."SysUserId", "s"."Id", "s0"."Id"
FROM "SysRole" AS "s"
INNER JOIN "SysUser" AS "s0" ON "s"."Id" = "s0"."RoleId"
INNER JOIN "SysChild" AS "s1" ON "s0"."Id" = "s1"."SysUserId"
WHERE "s"."Id" = 1
ORDER BY "s"."Id", "s0"."Id"

如果不做拆分,则sql是:

[17:17:39 INF] Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "s"."Id", "s"."RoleName", "t"."Id", "t"."CreateTime", "t"."Pwd", "t"."RoleId", "t"."UserName", "t"."Id0", "t"."Name", "t"."SysUserId"
FROM "SysRole" AS "s"
LEFT JOIN (
    SELECT "s0"."Id", "s0"."CreateTime", "s0"."Pwd", "s0"."RoleId", "s0"."UserName", "s1"."Id" AS "Id0", "s1"."Name", "s1"."SysUserId"
    FROM "SysUser" AS "s0"
    LEFT JOIN "SysChild" AS "s1" ON "s0"."Id" = "s1"."SysUserId"
) AS "t" ON "s"."Id" = "t"."RoleId"
WHERE "s"."Id" = 1
ORDER BY "s"."Id", "t"."Id", "t"."Id0"

显然拆分之后可以减少中间表的数据量。

对于多对一或一对一的实体关系,不管是否使用拆分,查询sql都只是一个,因为对性能没什么影响:

_context.Children.Where(c=>c.Id==1).Include(c => c.SysUser).ThenInclude(u => u.Role).AsSplitQuery().ToList()
[17:24:11 INF] Executed DbCommand (4ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "s"."Id", "s"."Name", "s"."SysUserId", "s0"."Id", "s0"."CreateTime", "s0"."Pwd", "s0"."RoleId", "s0"."UserName", "s1"."Id", "s1"."RoleName"
FROM "SysChild" AS "s"
INNER JOIN "SysUser" AS "s0" ON "s"."SysUserId" = "s0"."Id"
INNER JOIN "SysRole" AS "s1" ON "s0"."RoleId" = "s1"."Id"
WHERE "s"."Id" = 1

4.2 启用全局拆分

所以一对多的查询都将启用拆分查询

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(
            @"Server=(localdb)\\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0",
            o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}

启用了全局拆分也可以使用AsSingleQuery针对某一个查询进行合并查:

using (var context = new SplitQueriesBloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToList();
}

4.3 拆分查询的缺点

  1. 因为是拆分成了多个sql,所以当执行查询时更改数据,就无法保证数据的一致性。
  2. 如果数据库延迟很高,多次查询会降低性能

5. 执行原始sql

有时候你可能认为ef生成的sql不够优美,想让ef直接执行自己的sql。可以通过FromSqlRawFromSqlInterpolated实现,这两个方法都只能

以上是关于Entity Framework Core 数据查询原理详解的主要内容,如果未能解决你的问题,请参考以下文章

Entity Framework Core 性能优化

使用 Entity Framework Core 更新相关数据

Entity Framework Core快速开始

Entity Framework Core 快速开始

Entity Framework Core 在保存时不验证数据?

Entity Framework Core 迁移命令