大家好,我是码农刚子。

EF Core 大家都用过,简单的增删改查谁都会写。但一遇到多表关联、动态筛选、复杂聚合这些场景,很多人就开始头疼了——写出来的查询要么慢得要死,要么干脆崩了。

今天刚子不跟你扯理论,直接上实战代码,把 EF Core 复杂查询的几个核心技巧给你讲明白。顺便聊聊性能优化的几条铁律,省得你下次再踩坑。


先说核心:EF Core 复杂查询的3个核心技巧

处理复杂查询,你只需要记住这几招:

  1. 关联查询:用 Include + ThenInclude 一次性加载多级关联数据
  2. 动态筛选:用表达式树在运行时动态拼查询条件
  3. 性能优化:用 AsNoTrackingSelect 投影、AsSplitQuery 控制数据加载

这些学会了,90% 的复杂查询场景你都能搞定。

刚子大白话:写 EF Core 查询,关键不是你写得多花哨,而是你要知道它生成的 SQL 长啥样。把 EF Core 当成一个带类型安全的 SQL 生成器,这才是正确心态。


场景一:多表关联查询(Include + ThenInclude)

基础用法:加载关联数据

例如我的博客系统:一个 Blog 有多个 Post,每个 Post 有一个 Author。

// 加载 Blog、关联的 Post、每个 Post 的 Author
var blogs = await context.Blogs
    .Include(b => b.Posts)
        .ThenInclude(p => p.Author)
    .ToListAsync();

这个查询会把三层数据一次加载出来,生成的 SQL 是一个 JOIN 查询,把三张表一次性查完。

Include 也能过滤?当然可以

EF Core 支持在 Include 里对关联集合做过滤:

// 只加载今年发布的文章
var blogs = await context.Blogs
    .Include(b => b.Posts.Where(p => p.PublishDate.Year == DateTime.Now.Year))
    .ToListAsync();

多级关联要多个 ThenInclude

如果需要加载更深层级的关联,继续链式调用 ThenInclude 就行:

// Blog → Posts → Author → AuthorDetails
var blogs = await context.Blogs
    .Include(b => b.Posts)
        .ThenInclude(p => p.Author)
            .ThenInclude(a => a.Details)
    .ToListAsync();

划重点:Include + ThenInclude 链越长,生成的 JOIN 越复杂。如果要加载多个集合导航属性,注意笛卡尔积爆炸的问题。遇到这种情况,可以用 AsSplitQuery() 把一个大查询拆成多个小查询。


场景二:动态查询(表达式树)

为什么需要动态查询?

业务需求经常变:用户按多个条件筛选,但这些条件可能选也可能不选。用静态查询写一堆 if?太丑了,还容易漏条件。

动态查询的核心是用 Expression<Func<T, bool>> 在运行时拼接查询条件。

手写一个动态筛选器

public async Task<List<Product>> SearchProductsAsync(
    string? name = null,
    decimal? minPrice = null,
    decimal? maxPrice = null,
    int? categoryId = null)
{
    var query = context.Products.AsQueryable();

    if (!string.IsNullOrEmpty(name))
        query = query.Where(p => p.Name.Contains(name));
    if (minPrice.HasValue)
        query = query.Where(p => p.Price >= minPrice.Value);
    if (maxPrice.HasValue)
        query = query.Where(p => p.Price <= maxPrice.Value);
    if (categoryId.HasValue)
        query = query.Where(p => p.CategoryId == categoryId.Value);

    return await query.ToListAsync();
}

这样写没问题,但条件越多代码越臃肿。更好的方式是用表达式树工具库,或者自己封装一个 PredicateBuilder

PredicateBuilder 的实现原理

public static class PredicateBuilder
{
    public static Expression<Func<T, bool>> True<T>() { return f => true; }
    public static Expression<Func<T, bool>> False<T>() { return f => false; }

    public static Expression<Func<T, bool>> Or<T>(
        this Expression<Func<T, bool>> expr1,
        Expression<Func<T, bool>> expr2)
    {
        var invokedExpr = Expression.Invoke(expr2, expr1.Parameters);
        return Expression.Lambda<Func<T, bool>>(
            Expression.OrElse(expr1.Body, invokedExpr),
            expr1.Parameters);
    }

    public static Expression<Func<T, bool>> And<T>(
        this Expression<Func<T, bool>> expr1,
        Expression<Func<T, bool>> expr2)
    {
        var invokedExpr = Expression.Invoke(expr2, expr1.Parameters);
        return Expression.Lambda<Func<T, bool>>(
            Expression.AndAlso(expr1.Body, invokedExpr),
            expr1.Parameters);
    }
}

用起来就很优雅了:

var predicate = PredicateBuilder.True<Product>();

if (!string.IsNullOrEmpty(name))
    predicate = predicate.And(p => p.Name.Contains(name));
if (minPrice.HasValue)
    predicate = predicate.And(p => p.Price >= minPrice.Value);
if (maxPrice.HasValue)
    predicate = predicate.And(p => p.Price <= maxPrice.Value);

var products = await context.Products
    .Where(predicate)
    .ToListAsync();

划重点:千万别把自定义方法塞进表达式树。EF Core 不认识你的 MyUtil.IsAdult(x),整段逻辑会被静默跳过,甚至退化为客户端求值——先查出全部数据,再在内存里过滤。性能直接崩。


场景三:分页 + 排序 + 过滤

分页是高频场景,EF Core 配合 LINQ 写起来很顺手:

public async Task<PagedResult<Product>> GetPagedProductsAsync(
    int pageIndex = 1,
    int pageSize = 10,
    string? sortBy = "Id",
    string? sortDirection = "asc",
    string? searchTerm = null)
{
    var query = context.Products.AsQueryable();

    // 过滤
    if (!string.IsNullOrEmpty(searchTerm))
        query = query.Where(p => p.Name.Contains(searchTerm));

    // 排序(注意:这里用了字符串反射,生产环境建议用 switch 或字典映射)
    query = sortDirection?.ToLower() == "desc"
        ? query.OrderByDescending(GetSortExpression(sortBy))
        : query.OrderBy(GetSortExpression(sortBy));

    // 分页
    var totalCount = await query.CountAsync();
    var items = await query
        .Skip((pageIndex - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();

    return new PagedResult<Product>
    {
        Items = items,
        TotalCount = totalCount,
        PageIndex = pageIndex,
        PageSize = pageSize
    };
}

划重点:分页查询必须在 SkipTake 之前先做排序,否则 EF Core 会抛异常。另外,GetSortExpression 这个函数要注意防止 SQL 注入,最好用白名单映射。


场景四:分组与聚合查询

按分类统计产品数量

var categoryStats = await context.Products
    .GroupBy(p => p.CategoryId)
    .Select(g => new
    {
        CategoryId = g.Key,
        ProductCount = g.Count(),
        AvgPrice = g.Average(p => p.Price),
        TotalRevenue = g.Sum(p => p.Price * p.SalesCount)
    })
    .ToListAsync();

这个查询 EF Core 会翻译成一条带 GROUP BY 的 SQL 语句,直接在数据库端完成聚合计算,性能很好。

刚子大白话:能用 GroupBy 就别自己写循环算,数据库干这个比 C# 快多了。


性能优化:这 5 条铁律记住

1. 只读查询用 AsNoTracking()

EF Core 默认会跟踪每个实体的变更,这在只读场景下完全是浪费。

var products = await context.Products
    .AsNoTracking()
    .Where(p => p.Price > 100)
    .ToListAsync();

加上 AsNoTracking,EF Core 不会记录这些实体的状态变化,内存占用和 CPU 开销都大幅降低。

2. 只取需要的字段(投影)

不要每次都 Select *,用投影只拿你真正需要的字段:

var productInfos = await context.Products
    .Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price
    })
    .ToListAsync();

3. 用 Select 投影还能顺便加载关联数据

var orderInfos = await context.Orders
    .Select(o => new
    {
        OrderId = o.Id,
        CustomerName = o.Customer.Name,
        TotalAmount = o.Items.Sum(i => i.Price * i.Quantity),
        ItemCount = o.Items.Count()
    })
    .ToListAsync();

这种方式比 Include 更精准,因为你只拿你需要的数据,SQL 生成的 JOIN 也更精简。

4. N+1 问题用 Include 解决

// ❌ 错误:会触发 N+1 次查询
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name); // 每次访问都触发一次查询
}

// ✅ 正确:一次性预加载
var orders = await context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

Include 显式预加载关联数据,把原本 1+N 次查询压成 1 次 JOIN 查询。

5. 集合过多时用 AsSplitQuery()

如果一个查询包含多个集合导航属性,默认的单查询模式会产生笛卡尔积爆炸。这时用 AsSplitQuery() 拆分成多个 SQL:

var blogs = await context.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Comments)
    .AsSplitQuery()
    .ToListAsync();

EF Core 会分别查询 Blog、Posts、Comments 三张表,然后在内存中组装,避免数据重复膨胀。


复杂查询铁律

场景 推荐方案 注意事项
多表关联加载 Include + ThenInclude 链别太长,注意笛卡尔积
动态多条件筛选 表达式树 / PredicateBuilder 别塞自定义方法,会被静默忽略
只读数据查询 AsNoTracking() + Select 投影 减少内存开销
避免 N+1 预加载 + 禁用延迟加载 Include 一次搞定
多集合查询 AsSplitQuery() 防笛卡尔积爆炸
数据量大 分页 + 索引 Skip/Take 前必须排序
复杂聚合 GroupBy / 聚合函数 EF Core 会翻译成 SQL
实在搞不定 原生 SQL (FromSqlRaw) 最后手段,别滥用

刚子结语

别把 EF Core 当成黑盒。你写出来的 LINQ 查询最终都会翻译成 SQL,不理解 SQL,你就写不出高效的 EF Core 查询。

我刚学 EF Core 的时候,也踩过 N+1、笛卡尔积、客户端求值这些坑。后来我养成了一个习惯:每个复杂查询都去检查生成的 SQL 长啥样

你可以用 EF Core 自带的日志功能:

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)
    .EnableSensitiveDataLogging();

看一眼生成的 SQL,你就知道哪里写得不对了。

刚子的经验:写复杂查询的时候,先想清楚“我要的数据结构是什么”,再用 LINQ 去表达。把 EF Core 当成带类型安全的 SQL 生成器,别把它当成万能魔法箱。

如果你觉得这篇有用,点个赞、转给还在被 EF Core 复杂查询折磨的兄弟

我是刚子,一个写了六年 .NET 代码的程序员。咱们下回见!