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只会跟踪匿名对象中的实体类型:
- 会跟踪
Blog
对象
var blog = context.Blogs.Select(b =>new { Blog = b, PostCount = b.Posts.Count() });
- 会跟踪
Blog
和Post
,因为post来自于linq
var blog = context.Blogs.Select(
b =>new { Blog = b, Post = b.Posts.OrderBy(p => p.Rating).LastOrDefault() });
- 不执行任何跟踪
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
逐级包含Include
、ThenInclude
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();
}
Collection
和Reference
的区别:一个用来获取集合,一个用来获取单个对象。
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 拆分查询的缺点
- 因为是拆分成了多个sql,所以当执行查询时更改数据,就无法保证数据的一致性。
- 如果数据库延迟很高,多次查询会降低性能
5. 执行原始sql
有时候你可能认为ef生成的sql不够优美,想让ef直接执行自己的sql。可以通过FromSqlRaw
和FromSqlInterpolated
实现,这两个方法都只能
以上是关于Entity Framework Core 数据查询原理详解的主要内容,如果未能解决你的问题,请参考以下文章
使用 Entity Framework Core 更新相关数据