一、先聊聊为啥慢:EF Core 每次查询都在“重新翻译”
用过 EF Core 的朋友应该都有体会:写起来是真方便,一句 LINQ 就能把数据查出来,不用自己拼 SQL。但方便的背后是有代价的。你写的 from b in Blogs where b.Id == id select b 这种 C# 代码,EF Core 得先把这棵“表达式树”翻译成真正的 SQL 语句,然后再去数据库执行。问题就出在这个“翻译”上。
这个过程大概要经历这么几步:接收你的 LINQ 表达式,把它拆开,一层一层分析,再用数据库的方言(比如 SQL Server 的语法)生成 SQL,最后还要把查询结果映射回实体对象。像 Dapper 这种工具,你直接给它一条 SQL,它直接就执行了,跳过了前面那套复杂的翻译流程。所以同样一条重复查询,EF Core 每次都要多花几毫秒甚至十几毫秒去做“翻译”工作。几毫秒听起来不多,但如果是高并发的接口,累加起来就很可观了。
不过,EF Core 自己也想到了这个问题,它在内部其实做了一个“查询计划缓存”。意思就是说,如果你两次写的 LINQ 查询结构一模一样,那么第二次执行时,它可以不从头翻译,而是直接去缓存里找上次算出来的 SQL。只不过这个缓存机制在性能上还是跟 Dapper 有一段距离,因为即使命中了缓存,EF Core 还需要做很多附加操作,比如对表达式树进行各种比较、处理参数、检查模型状态等等。所以今天的主角——编译查询(Compiled Query),就是直接跳过这套流程,让执行速度尽量贴近 Dapper。
二、查询编译和缓存到底是怎么回事
先别急着上代码,咱们把原理掰开了看。
EF Core 的查询缓存,本质上是把“表达式树”和“最终生成的 SQL”放在一个字典里。当你写一条查询时,EF Core 会计算它的一个“结构签名”,比如查询里用了哪个表、哪些条件、哪些 Include 之类的。如果签名相同,就直接复用 SQL。但这个签名计算本身也有开销,而且表达式树中如果有闭包变量,处理起来会更麻烦。
而编译查询,是用一个静态委托把查询固定下来。委托一旦编译成功,就不再需要表达式树了,调用的时候直接传入上下文和参数,它内部直接执行缓存好的查询计划。这样你跳过了查询缓存命中判断,也跳过了表达式树的克隆和比较,性能自然就上去了。
实际测试中,在循环里执行几百上千次同一结构的查询,EF Core 普通查询会随着次数增多逐渐变快,因为缓存生效了;但编译查询从一开始就快很多,尤其是在首次查询之后,后边的速度基本能跟 Dapper 打平。这是因为他省掉的是最耗时的部分。
三、动手写一个编译查询示例
下面我们用实际代码演示一下。为了照顾不同基础的朋友,我会把项目跑起来需要的东西都写明。所有示例都基于 C# 和 EF Core 6.0。
// 技术栈:C# + EF Core 6.0(使用 SQL Server 作为数据库)
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
// 先定义实体类
public class Blog
{
public int Id { get; set; }
public string Title { get; set; }
public string Author { get; set; }
}
// 定义数据库上下文
public class BlogContext : DbContext
{
public DbSet<Blog> Blogs { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
// 注意:这只是为了演示方便,实际项目请把连接字符串放在配置文件里
options.UseSqlServer("Server=.;Database=BlogDemo;Trusted_Connection=True;");
}
}
public class Program
{
// 重点:这就是一个编译查询委托
// 参数:BlogContext 和 int id,返回 Blog 实体
private static Func<BlogContext, int, Blog> _getBlogById =
EF.CompileQuery((BlogContext ctx, int id) =>
ctx.Blogs.AsNoTracking().FirstOrDefault(b => b.Id == id));
// 再来一个返回集合的编译查询,查某个作者的所有文章
private static Func<BlogContext, string, IQueryable<Blog>> _getBlogsByAuthor =
EF.CompileQuery((BlogContext ctx, string author) =>
ctx.Blogs.AsNoTracking().Where(b => b.Author == author));
static void Main(string[] args)
{
using var context = new BlogContext();
// 第一次调用编译查询:仍然需要执行 SQL,但不需要翻译表达式树了
Console.WriteLine("第一次查询 ID=1 的文章:");
var blog1 = _getBlogById(context, 1);
Console.WriteLine(blog1?.Title);
// 第二次调用:完全复用已经编译好的查询计划,速度飞快
Console.WriteLine("第二次查询 ID=1 的文章:");
var blog2 = _getBlogById(context, 1);
Console.WriteLine(blog2?.Title);
// 换一个参数,查询框架仍然会复用,因为查询结构没变,只是参数值变了
Console.WriteLine("查询 ID=2 的文章:");
var blog3 = _getBlogById(context, 2);
Console.WriteLine(blog3?.Title);
// 演示返回集合的编译查询
Console.WriteLine("查询作者为 '张三' 的文章:");
var blogs = _getBlogsByAuthor(context, "张三").ToList();
foreach (var b in blogs)
{
Console.WriteLine($"标题:{b.Title}");
}
}
}
看到没,编译查询的写法其实就是把原来的查询表达式包到一个 EF.CompileQuery 里,然后赋给一个静态字段。注意它返回的是一个委托,委托的类型要对应上下文类型、参数类型和返回类型。只要查询结构不变,不管你传什么参数,都能复用。
那如果我不想写这么死板的委托,想要一个通用的缓存工具,该怎么做呢?其实我们可以用 ConcurrentDictionary 自己做一个简单的小工具,把不同结构的查询缓存起来。但这里要提醒一下:动态构造表达式树本身比较复杂,而且很容易掉进性能坑。对于 99% 的场景,你只需要把高频查询手工写成编译查询就够了,不需要过度设计。
四、什么时候该用编译查询
不是所有查询都值得用编译查询。如果你只是偶尔查一次数据,那编译查询意义不大,因为第一次执行时也需要花费少量额外开销创建委托,而且代码会变得不那么直观。
真正适合的场景是:
- 循环体内查询。比如在后台任务里循环处理一批数据,每次循环都要根据 ID 查一次数据库。
- 高频 Web API 接口。比如根据订单 ID 获取订单,这个接口一秒被调用几百次,那这个查询就该改成编译查询。
- 批量数据同步或报表导出。这些场景往往要循环执行同一条查询很多次,区别非常明显。
如果你的查询里带有 Include 这种关联加载,编译查询同样支持。你只需要把 Include 也写进表达式里。但注意,不能把上下文本身以外的对象作为闭包变量传进去,否则表达式树会变复杂,编译查询反而起不到作用。
下面是带 Include 的编译查询示例:
// 技术栈:C# + EF Core 6.0
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
// 假设我们还有文章实体
public class Post
{
public int Id { get; set; }
public int BlogId { get; set; }
public string Title { get; set; }
public Blog Blog { get; set; }
}
// 扩展上下文
public class BlogContextWithPosts : DbContext
{
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
}
public static class CompiledQueries
{
// 带 Include 的编译查询:获取博客及其所有文章
public static Func<BlogContextWithPosts, int, Blog> GetBlogWithPosts =
EF.CompileQuery((BlogContextWithPosts ctx, int id) =>
ctx.Blogs
.AsNoTracking()
.Include(b => b.Posts)
.FirstOrDefault(b => b.Id == id));
}
这里要注意,如果你的查询里用了 AsNoTracking,那编译查询里也要带上,不然行为会不一致。
五、一些容易踩的坑和注意事项
编译查询用起来简单,但坑也不少。我把自己踩过的和在社区里见过的问题总结一下。
第一个坑:编译查询不支持 AsNoTracking 后面的链式操作随意拼。比如你写 EF.CompileQuery((ctx, id) => ctx.Blogs.AsNoTracking().FirstOrDefault(...)),这是没问题的。但如果你在查询里用了闭包变量,比如:
var minId = 10;
var query = EF.CompileQuery((BlogContext ctx) => ctx.Blogs.Where(b => b.Id > minId).ToList());
这种代码编译可能不报错,但 minId 被当作一个参数捕获了,每次调用时它取的是当前值,而且编译查询可能会因为这个闭包而构建额外的表达式树,失去本来该有的性能优势。正确的做法是把 minId 作为委托参数传进去。
第二个坑:编译查询只能针对一个上下文类型。比如你为 BlogContext 写了一个编译查询,就不能把它用在一个继承自 BlogContext 的子类上,因为委托里已经明确是 BlogContext 了。如果你有多个上下文类型,需要为每个类型单独写编译查询。
第三个坑:不要尝试用一个编译查询去处理完全不同的 SQL。编译查询的“结构”是固定的,你不能在运行时动态增删 Where 条件还能复用同一个委托。如果你真的需要动态查询,建议普通 LINQ 查询,或者使用条件拼接,但性能提升就不明显了。
第四个坑:编译查询返回类型如果是 IQueryable,那么它的惰性执行可能会让你误以为编译好了就万事大吉。实际上,IQueryable 只有在被枚举的时候才会真正执行 SQL。如果你只把编译查询的委托调用了,但是没有 .ToList() 或 .FirstOrDefault(),那么数据库查询并没有发生。所以在实际使用中,我建议直接用 List<T> 或单个实体作为返回类型,这样一次调用就把数据拿回来了,更符合直觉。
第五个坑:在 ASP.NET Core 的依赖注入容器里,不要尝试把编译查询委托注册为单例,除非委托里没有使用待释放的上下文。因为编译查询委托只是接收上下文作为参数,它本身不持有上下文,所以注册为单例是安全的。但如果你在委托内部 new 了一个上下文,那就大错特错了,因为上下文应该由请求作用域来管理。
六、它和 Dapper 相比,到底好在哪差在哪
很多人一听 EF Core 可以追上 Dapper,就会问:那是不是以后都可以用 EF Core 了?其实两者定位不同。
Dapper 的特点就是“小、快、稳”。它就是一个扩展方法,直接映射列和对象。你写 SQL,它就执行,没有任何多余的中间步骤。对于性能要求极端的场景,比如一个方法里只查一行数据,Dapper 一定是王者。
EF Core 的优势是生产力高、可维护性好。你改一个模型,EF Core 自动帮你生成对应的 SQL;你换数据库,大多数查询不用改。编译查询把这个优势进一步放大:既享受 EF Core 的开发效率,又在重复高频查询上获得了接近 Dapper 的性能。但注意是“接近”,不是“超越”。因为 EF Core 始终还是要做实体映射和状态跟踪,哪怕用了 AsNoTracking,它也要把数据复制到实体对象里,这一步永远存在。
所以我的观点是:如果项目整体已经用了 EF Core,遇到性能瓶颈不要急着换 Dapper,先看看是不是可以用编译查询优化。如果还是慢,再考虑用 Dapper 单独写那个热点查询,保持 EF Core 的整体生态。
七、实际场景中的表现
我见过一个后台服务,循环同步 10 万条数据,每循环一条就要根据某个业务编号去数据库查一次主表。最初用普通 LINQ 查询,循环结束后总耗时接近 8 分钟。后来把那条查询改成编译查询,总耗时降到 3 分钟,而且是改动最小、风险最低的方案。这中间有一些时间还被数据库连接上下文的开销占据了,但编译查询确实帮了大忙。
再比如一个 Web API 接口,根据文章 ID 返回详情,流量高峰期每秒请求上百次。优化前用普通查询,服务器 CPU 占用率很高。把重点查询改成编译查询后,虽然不明显,但响应时间有了几毫秒到十几毫秒的改善,压力测试中的 p95 指标下降了不少。为什么有这么大的差别?因为每个请求在数据库操作前,少做了很多 EF Core 内部的“翻译准备”工作。
八、总结一下
EF Core 的查询编译与缓存机制,本质上是在帮你省掉重复翻译 SQL 的开销。编译查询是你手动把这个翻译工作提前做完,做成一个可复用的委托,然后在需要的时候直接调用。这样一来,你的理想状态下,速度快到可以和 Dapper 并驾齐驱。
但也要清楚,并非所有查询都适合编译查询。它适合那种查询结构固定、执行频率很高的场景。如果你的查询条件错综复杂,结构经常改变,那就老老实实用普通查询,因为编译查询的威力发挥不出来。
最后提醒一句:性能优化永远要先找到瓶颈,不要盲目为了快而快。先用性能计数器或者日志观察一下,看看这条查询是不是真的被频繁调用,是不是真的慢到影响用户体验,然后再决定要不要上编译查询。相信看完这篇文章,你已经知道怎么动手了。
评论
围绕“查询编译与缓存:让EF Core的重复查询跑得跟Dapper一样快”参与讨论