Skip to content

查询管道与 LINQ 翻译 ​

概述 ​

EF Core 的查询管道(Query Pipeline)是将 LINQ 表达式转换为 SQL 语句的核心引擎。理解这个翻译过程对于编写高效查询、排查性能问题和避免常见陷阱至关重要。

查询执行流程 ​

C# LINQ 查询
    ↓
[1] 表达式树(Expression Tree)
    ↓
[2] 查询预处理(规范化/优化)
    ↓
[3] LINQ 翻译器(转换为 SQL AST)
    ↓
[4] SQL 生成器(生成具体 SQL)
    ↓
[5] 参数化与缓存
    ↓
发送到数据库执行

表达式树基础 ​

什么是表达式树? ​

csharp
// 普通委托 - 直接执行
Func<Product, bool> func = p => p.Price > 100;

// 表达式树 - 可分析的结构
Expression<Func<Product, bool>> expr = p => p.Price > 100;

// 查看表达式树结构
Console.WriteLine(expr.Body);        // (p.Price > 100)
Console.WriteLine(expr.Parameters);  // [p]

EF Core 如何使用表达式树 ​

csharp
// LINQ 查询被编译为表达式树
var query = context.Products
    .Where(p => p.Price > 100)      // Expression<Func<Product, bool>>
    .OrderBy(p => p.Name)           // Expression<Func<Product, string>>
    .Select(p => new { p.Id, p.Name }); // Expression<Func<Product, DTO>>

// EF Core 遍历表达式树,翻译成 SQL
string sql = query.ToQueryString();

翻译过程详解 ​

阶段 1: 查询预处理 ​

csharp
// 原始查询
var query = context.Products
    .Where(p => p.Price > 100 && p.CategoryId == 1)
    .Where(p => p.StockQuantity > 0);  // 多个 Where

// EF Core 自动合并条件
// 等价于:
var optimized = context.Products
    .Where(p => p.Price > 100 && p.CategoryId == 1 && p.StockQuantity > 0);

阶段 2: LINQ 方法翻译 ​

支持的 LINQ 方法 ​

csharp
// ✅ 过滤
.Where(p => p.Price > 100)
.Where(p => p.Name.Contains("Laptop"))

// ✅ 投影
.Select(p => new { p.Id, p.Name })
.Select(p => p.Name.ToUpper())

// ✅ 排序
.OrderBy(p => p.Price)
.OrderByDescending(p => p.CreatedAt)
.ThenBy(p => p.Name)

// ✅ 分页
.Skip(10).Take(20)

// ✅ 聚合
.Count()
.Sum(p => p.Price)
.Average(p => p.Price)
.Min(p => p.Price)
.Max(p => p.Price)

// ✅ 分组
.GroupBy(p => p.CategoryId)

// ✅ 连接
.Join(context.Categories, 
       p => p.CategoryId, 
       c => c.Id, 
       (p, c) => new { Product = p, Category = c })

// ✅ 包含
.Include(p => p.Category)
.ThenInclude(c => c.Products)

// ✅ 去重
.Distinct()

// ✅ 条件判断
.FirstOrDefault(p => p.Price > 100)
.SingleOrDefault(p => p.Id == 1)
.Any(p => p.Price > 1000)
.All(p => p.Price > 0)

无法翻译的方法 ​

csharp
// ❌ 自定义方法
.Where(p => IsExpensive(p.Price))  // ⚠️ 无法翻译

bool IsExpensive(decimal price) => price > 1000;

// ❌ .NET 特定方法
.Where(p => p.ToString().StartsWith("A"))  // ⚠️ ToString 可能无法翻译

// ❌ 客户端集合操作
.Select(p => MyHelper.FormatPrice(p.Price))  // ⚠️ 自定义辅助方法

// ❌ 某些 DateTime 方法
.Where(p => p.CreatedAt.DayOfWeek == DayOfWeek.Monday)  // ⚠️ 部分支持

阶段 3: SQL 生成 ​

csharp
// C# LINQ
var query = context.Products
    .Where(p => p.Price > 100 && p.CategoryId == 1)
    .OrderByDescending(p => p.Price)
    .Skip(10)
    .Take(20)
    .Select(p => new { p.Id, p.Name, p.Price });

// 生成的 SQL (SQL Server)
/*
SELECT [t].[Id], [t].[Name], [t].[Price]
FROM (
    SELECT [p].[Id], [p].[Name], [p].[Price], 
           ROW_NUMBER() OVER(ORDER BY [p].[Price] DESC) AS [row]
    FROM [Products] AS [p]
    WHERE ([p].[Price] > 100.0) AND ([p].[CategoryId] = 1)
) AS [t]
WHERE [t].[row] > 10 AND [t].[row] <= 30
*/

翻译规则与限制 ​

客户端评估 vs 服务端评估 ​

EF Core 8+ 的行为 ​

csharp
// EF Core 8+ 默认禁止隐式客户端评估

// ❌ 错误: 会抛出异常
var query = context.Products
    .Where(p => IsExpensive(p.Price));  // 💥 InvalidOperationException

// ✅ 正确: 显式切换到客户端
var query = context.Products
    .AsEnumerable()  // 明确标记: 从这里开始在客户端执行
    .Where(p => IsExpensive(p.Price));

// ✅ 更好: 重写为可翻译的表达式
var query = context.Products
    .Where(p => p.Price > 1000);  // 可以在服务器端执行

强制客户端评估(谨慎使用) ​

csharp
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    
    // ⚠️ 允许客户端评估(不推荐,会影响性能)
    options.ConfigureWarnings(warnings =>
        warnings.Log(RelationalEventId.ClientEvaluationWarning));
});

可翻译的 .NET 方法 ​

csharp
// ✅ 字符串方法
p.Name.ToLower()
p.Name.ToUpper()
p.Name.Trim()
p.Name.Contains("search")
p.Name.StartsWith("A")
p.Name.EndsWith("z")
p.Name.Substring(0, 5)
p.Name.Replace("old", "new")
p.Name.Length

// ✅ 数学方法
Math.Abs(p.Price)
Math.Round(p.Price, 2)
Math.Floor(p.Price)
Math.Ceiling(p.Price)
Math.Pow(p.BasePrice, 2)

// ✅ DateTime 方法
DateTime.Now
DateTime.UtcNow
p.CreatedAt.Year
p.CreatedAt.Month
p.CreatedAt.Day
p.CreatedAt.Hour
p.CreatedAt.AddDays(7)
p.CreatedAt.AddMonths(1)
p.CreatedAt.AddYears(1)

// ✅ 类型转换
Convert.ToInt32(p.SomeValue)
Convert.ToString(p.Price)
Convert.ToDecimal(p.IntValue)

// ✅ EF.Functions (数据库特定函数)
EF.Functions.Like(p.Name, "%search%")
EF.Functions.FreeText(p.Description, "keyword")  // SQL Server 全文搜索
EF.Functions.ILike(p.Name, "%search%")  // PostgreSQL 不区分大小写

不可翻译的操作 ​

csharp
// ❌ 自定义方法调用
.Where(p => CustomLogic(p.Price))

// ❌ 委托调用
.Where(p => someDelegate(p))

// ❌ 反射操作
.Where(p => p.GetType().Name == "Product")

// ❌ 大多数格式化方法
.Where(p => p.Price.ToString("C") == "$100.00")

// ❌ 复杂的 LINQ 嵌套
.Select(p => items.Where(i => i.ProductId == p.Id).ToList())

// ❌ 客户端集合操作后尝试返回 IQueryable
.Select(p => new { 
    Products = context.Products.Where(x => x.CategoryId == p.Id).ToList() // ⚠️ 会在客户端执行
})

复杂查询翻译 ​

Join 查询 ​

csharp
// LINQ Join
var query = from p in context.Products
            join c in context.Categories on p.CategoryId equals c.Id
            where c.Name == "Electronics"
            select new { p.Name, c.Name as Category };

// 生成的 SQL
/*
SELECT [p].[Name], [c].[Name] AS [Category]
FROM [Products] AS [p]
INNER JOIN [Categories] AS [c] ON [p].[CategoryId] = [c].[Id]
WHERE [c].[Name] = N'Electronics'
*/

GroupBy 聚合 ​

csharp
// LINQ GroupBy
var query = context.Products
    .GroupBy(p => p.CategoryId)
    .Select(g => new 
    { 
        CategoryId = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p => p.Price),
        MaxPrice = g.Max(p => p.Price)
    });

// 生成的 SQL
/*
SELECT [p].[CategoryId] AS [CategoryId], 
       COUNT(*) AS [Count], 
       AVG([p].[Price]) AS [AvgPrice], 
       MAX([p].[Price]) AS [MaxPrice]
FROM [Products] AS [p]
GROUP BY [p].[CategoryId]
*/

子查询 ​

csharp
// LINQ 子查询
var query = context.Categories
    .Select(c => new 
    { 
        c.Name,
        ProductCount = c.Products.Count(),
        ExpensiveProducts = c.Products.Count(p => p.Price > 1000)
    });

// 生成的 SQL
/*
SELECT [c].[Name], 
       (SELECT COUNT(*) FROM [Products] AS [p] WHERE [c].[Id] = [p].[CategoryId]) AS [ProductCount],
       (SELECT COUNT(*) FROM [Products] AS [p] WHERE ([c].[Id] = [p].[CategoryId]) AND ([p].[Price] > 1000.0)) AS [ExpensiveProducts]
FROM [Categories] AS [c]
*/

Any/All 翻译 ​

csharp
// Any -  EXISTS
var hasExpensive = context.Products.Any(p => p.Price > 1000);
/*
SELECT CASE 
    WHEN EXISTS (SELECT 1 FROM [Products] AS [p] WHERE [p].[Price] > 1000.0) 
    THEN CAST(1 AS BIT) 
    ELSE CAST(0 AS BIT) 
END
*/

// All -  NOT EXISTS ... NOT
var allInStock = context.Products.All(p => p.StockQuantity > 0);
/*
SELECT CASE 
    WHEN NOT EXISTS (SELECT 1 FROM [Products] AS [p] WHERE [p].[StockQuantity] <= 0) 
    THEN CAST(1 AS BIT) 
    ELSE CAST(0 AS BIT) 
END
*/

性能优化技巧 ​

1. 避免 N+1 查询 ​

csharp
// ❌ 错误: N+1 查询
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
    var customer = await context.Customers.FindAsync(order.CustomerId); // N 次查询
}

// ✅ 正确: 使用 Include
var orders = await context.Orders
    .Include(o => o.Customer)
    .ToListAsync();  // 1 次查询

2. 使用投影减少数据传输 ​

csharp
// ❌ 加载所有列
var products = await context.Products.ToListAsync();

// ✅ 只选择需要的列
var products = await context.Products
    .Select(p => new { p.Id, p.Name, p.Price })
    .ToListAsync();

3. 避免在 Where 中使用客户端方法 ​

csharp
// ❌ 客户端评估
var query = context.Products
    .Where(p => FormatPrice(p.Price).Contains("99"));

string FormatPrice(decimal price) => price.ToString("F2");

// ✅ 服务端评估
var query = context.Products
    .Where(p => EF.Functions.Like(SqlFunctions.StringConvert((double)p.Price), "%99%"));

4. 使用 AsNoTracking 提升读取性能 ​

csharp
// 只读查询,不需要变更跟踪
var products = await context.Products
    .AsNoTracking()
    .Where(p => p.Price > 100)
    .ToListAsync();

5. 预编译查询(高频查询) ​

csharp
// 预编译查询
private static readonly Func<AppDbContext, int, Task<Product?>> _getProductById = 
    EF.CompileAsyncQuery((AppDbContext db, int id) => 
        db.Products.FirstOrDefault(p => p.Id == id));

// 使用(性能提升 10-20%)
var product = await _getProductById(context, productId);

调试翻译问题 ​

查看生成的 SQL ​

csharp
// 方法 1: ToQueryString
var query = context.Products.Where(p => p.Price > 100);
string sql = query.ToQueryString();
Console.WriteLine(sql);

// 方法 2: 日志记录
options.LogTo(Console.WriteLine, LogLevel.Information);

// 方法 3: MiniProfiler
// 安装 MiniProfiler.EntityFrameworkCore 包

识别客户端评估警告 ​

csharp
// 配置警告
options.ConfigureWarnings(warnings =>
{
    warnings.Throw(RelationalEventId.QueryClientEvaluationWarning);  // 抛出异常
    // 或
    warnings.Log(RelationalEventId.QueryClientEvaluationWarning);   // 仅记录日志
});

常见翻译失败及解决方案 ​

问题 1: 自定义方法 ​

csharp
// ❌ 失败
.Where(p => CalculateDiscount(p.Price) > 10)

// ✅ 修复: 内联逻辑
.Where(p => p.Price * 0.9m > 10)

问题 2: DateTime 比较 ​

csharp
// ❌ 可能失败
.Where(p => p.CreatedAt.Date == DateTime.Today)

// ✅ 修复: 使用范围查询
var today = DateTime.Today;
var tomorrow = today.AddDays(1);
.Where(p => p.CreatedAt >= today && p.CreatedAt < tomorrow)

问题 3: 字符串比较 ​

csharp
// ❌ 区分大小写(取决于数据库排序规则)
.Where(p => p.Name == "laptop")

// ✅ 明确指定
.Where(p => EF.Functions.ILike(p.Name, "laptop"))  // PostgreSQL
// 或
.Where(p => p.Name.ToLower() == "laptop")  // 通用

高级翻译特性 ​

自定义函数映射 ​

csharp
// 定义 CLR 方法
public static class DbFunctions
{
    [DbFunction("SOUNDEX", IsBuiltIn = true)]
    public static string SoundEx(string input) 
        => throw new NotSupportedException();
}

// 在查询中使用
var query = context.Products
    .Where(p => DbFunctions.SoundEx(p.Name) == DbFunctions.SoundEx("Laptop"));

// 生成的 SQL
// WHERE SOUNDEX([p].[Name]) = SOUNDEX(N'Laptop')

标量函数映射 ​

csharp
// 数据库函数
// CREATE FUNCTION dbo.CalculateTax(@price DECIMAL(18,2)) 
// RETURNS DECIMAL(18,2) AS BEGIN RETURN @price * 0.08 END

// C# 映射
[DbFunction("CalculateTax", Schema = "dbo")]
public static decimal CalculateTax(decimal price) 
    => throw new NotSupportedException();

// 使用
var query = context.Products
    .Select(p => new { 
        p.Name, 
        Price = p.Price,
        Tax = DbFunctions.CalculateTax(p.Price)
    });

表值函数映射 ​

csharp
// 数据库 TVF
// CREATE FUNCTION dbo.GetProductsByCategory(@categoryId INT)
// RETURNS TABLE AS RETURN SELECT * FROM Products WHERE CategoryId = @categoryId

// C# 映射
public IQueryable<Product> GetProductsByCategory(int categoryId)
    => FromExpression(() => GetProductsByCategory(categoryId));

// 注册
modelBuilder.HasDbFunction(typeof(AppDbContext).GetMethod(nameof(GetProductsByCategory)));

// 使用
var products = await context.GetProductsByCategory(1).ToListAsync();

最佳实践 ​

✅ 推荐做法 ​

  1. 优先使用可翻译的 LINQ

    csharp
    // ✅ 服务端执行
    .Where(p => p.Price > 100)
  2. 必要时显式切换客户端

    csharp
    // ✅ 明确标记
    .AsEnumerable()
    .Where(p => CustomMethod(p))
  3. 定期审查生成的 SQL

    csharp
    // 开发环境始终记录 SQL
    if (env.IsDevelopment())
    {
        options.LogTo(Console.WriteLine, LogLevel.Information);
    }
  4. 使用预编译查询优化热点路径

    csharp
    private static readonly Func<...> _compiledQuery = 
        EF.CompileQuery(...);

❌ 避免的错误 ​

  1. 不要在循环中执行查询

    csharp
    // ❌ N+1 问题
    foreach (var id in ids)
    {
        var item = await context.Items.FindAsync(id);
    }
    
    // ✅ 批量加载
    var items = await context.Items
        .Where(i => ids.Contains(i.Id))
        .ToListAsync();
  2. 不要忽略客户端评估警告

    csharp
    // ❌ 隐藏警告
    options.ConfigureWarnings(w => w.Ignore(...));
    
    // ✅ 解决根本原因
  3. 不要在投影中执行子查询

    csharp
    // ❌ 性能差
    .Select(c => new { 
        c.Name, 
        Orders = c.Orders.ToList()  // 每个分类都执行查询
    })
    
    // ✅ 使用 Include 或 Join

总结 ​

翻译能力速查表 ​

LINQ 方法是否支持备注
Where✅完全支持
Select✅支持匿名类型和 DTO
OrderBy✅支持 ThenBy
Skip/Take✅生成分页 SQL
Join✅INNER JOIN
GroupBy✅聚合查询
Include✅LEFT JOIN
Any/All✅EXISTS 子查询
First/FirstOrDefault✅TOP 1
Count/Sum/Avg✅聚合函数
Distinct✅DISTINCT
Concat/Union✅UNION ALL / UNION
Contains✅IN 子句
自定义方法❌需切换到客户端

核心要点 ​

  1. 理解表达式树: LINQ 被编译为表达式树,EF Core 遍历树结构翻译为 SQL
  2. 知道什么能翻译: 标准 LINQ 方法大部分支持,自定义方法不支持
  3. 监控生成的 SQL: 使用 ToQueryString 或日志查看实际 SQL
  4. 避免隐式客户端评估: EF Core 8+ 会抛出异常,必须显式切换
  5. 优化查询性能: 使用投影、AsNoTracking、预编译查询等技术

基于 MIT 许可发布