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(预加载):直接从数据库把关联数据读出来,即尽可能的早读取
  • 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

逐级包含

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();

查询时对导航属性中的内容进行过滤

可以使用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();
}

3.2 显式加载

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

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

    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 懒加载

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

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

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

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实现,这两个方法都只能在DbSet<>上使用。但是执行自己sql时需要注意sql注入攻击。

//执行原始sql
var blogs

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

Entity Framework Core 性能优化

使用 Entity Framework Core 更新相关数据

Entity Framework Core快速开始

Entity Framework Core 快速开始

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

Entity Framework Core 迁移命令