Skip to content

客户端vs服务端评估 Client vs Server Evaluation ​

目录 ​


什么是查询评估 ​

概念理解 ​

查询评估(Query Evaluation) 是 EF Core 决定 LINQ 查询的哪部分转换为 SQL(服务端执行),哪部分在 C# 中执行(客户端执行)的过程。

csharp
var products = context.Products
    .Where(p => p.Price > 100)              // ← 服务端评估(SQL)
    .OrderBy(p => p.Name)                   // ← 服务端评估(SQL)
    .Select(p => new {                      // ← 服务端评估(SQL)
        p.Id,
        p.Name,
        FormattedPrice = FormatPrice(p.Price)  // ← ??? 
    })
    .ToList();

关键问题: FormatPrice() 方法能在 SQL 中执行吗?

评估流程 ​

mermaid
graph TD
    A[LINQ 查询] --> B{EF Core 分析}
    B --> C{能转换为 SQL?}
    C -->|能| D[服务端评估]
    C -->|不能| E{.NET 版本?}
    D --> F[生成 SQL]
    F --> G[数据库执行]
    E -->|.NET Core 2.x| H[客户端评估警告]
    E -->|.NET Core 3.0+| I[抛出异常]
    H --> J[内存中执行]
    I --> K[运行时错误]

.NET Core 3.0+ 的变更 ​

重大Breaking Change ​

csharp
// .NET Core 2.x: 允许隐式客户端评估
var products = context.Products
    .Where(p => p.Price > 100)
    .Select(p => new {
        p.Name,
        FormattedPrice = FormatPrice(p.Price)  // ⚠️ 警告: 客户端评估
    })
    .ToList();

// 行为:
// 1. WHERE 子句在数据库执行
// 2. 加载所有匹配记录到内存
// 3. FormatPrice() 在客户端执行
// 💥 性能问题: 可能加载大量数据!

// .NET Core 3.0+: 默认禁止隐式客户端评估
var products = context.Products
    .Where(p => p.Price > 100)
    .Select(p => new {
        p.Name,
        FormattedPrice = FormatPrice(p.Price)  // 💥 InvalidOperationException!
    })
    .ToList();

// 错误信息:
// The LINQ expression 'FormatPrice(p.Price)' could not be translated. 
// Either rewrite the query in a form that can be translated, or switch to 
// client evaluation explicitly by inserting a call to 'AsEnumerable()', 
// 'AsAsyncEnumerable()', 'ToList()', or 'ToListAsync()'.

为什么改变? ​

原因: 隐式客户端评估导致严重的性能问题

csharp
// ❌ 危险: .NET Core 2.x  silently 加载全部数据
var customers = context.Customers
    .Select(c => new {
        c.Name,
        Orders = c.Orders.Where(o => o.Total > 1000).ToList()  // 💥 N+1 查询!
    })
    .ToList();

// 实际执行:
// 1. SELECT * FROM Customers (加载所有客户)
// 2. 对每个客户: SELECT * FROM Orders WHERE CustomerId = ? AND Total > 1000
// 💥 如果有 1000 个客户,执行 1001 次查询!

// .NET Core 3.0+: 直接抛出异常,强制开发者意识到问题

服务端评估 ​

可翻译的操作 ​

1. 基本过滤和投影 ​

csharp
// ✅ 完全服务端评估
var products = await context.Products
    .Where(p => p.Price > 100 && p.IsActive)
    .OrderByDescending(p => p.CreatedAt)
    .Select(p => new {
        p.Id,
        p.Name,
        p.Price,
        DiscountedPrice = p.Price * 0.9m  // ✅ 算术运算可翻译
    })
    .ToListAsync();

// 生成的 SQL:
// SELECT Id, Name, Price, Price * 0.9 AS DiscountedPrice
// FROM Products
// WHERE Price > 100 AND IsActive = 1
// ORDER BY CreatedAt DESC

2. 字符串操作 ​

csharp
// ✅ 大部分字符串方法可翻译
var products = await context.Products
    .Where(p => p.Name.StartsWith("Laptop"))
    .Where(p => p.Name.Contains("Pro"))
    .Where(p => p.Name.Length > 10)
    .Select(p => new {
        p.Name,
        UpperName = p.Name.ToUpper(),
        TrimmedName = p.Name.Trim()
    })
    .ToListAsync();

// 支持的字符串方法:
// - StartsWith, EndsWith, Contains
// - ToUpper, ToLower
// - Trim, Substring
// - Replace, IndexOf
// - Concat (+ 运算符)

3. 日期时间操作 ​

csharp
// ✅ DateTime 方法可翻译
var orders = await context.Orders
    .Where(o => o.OrderDate.Year == 2024)
    .Where(o => o.OrderDate.Month >= 6)
    .Where(o => EF.Functions.DateDiffDay(o.OrderDate, DateTime.UtcNow) <= 30)
    .Select(o => new {
        o.Id,
        o.OrderDate,
        DayOfWeek = EF.Functions.DatePartWeekday(o.OrderDate)
    })
    .ToListAsync();

// 支持的 DateTime 方法:
// - Year, Month, Day, Hour, Minute, Second
// - Date, TimeOfDay
// - AddDays, AddHours, AddMinutes
// - EF.Functions.DateDiff* (SQL Server)

4. 聚合函数 ​

csharp
// ✅ 聚合函数在服务端执行
var stats = await context.Orders
    .Where(o => o.OrderDate >= DateTime.Today.AddMonths(-1))
    .GroupBy(o => o.CustomerId)
    .Select(g => new {
        CustomerId = g.Key,
        OrderCount = g.Count(),
        TotalAmount = g.Sum(o => o.TotalAmount),
        AverageAmount = g.Average(o => o.TotalAmount),
        MinAmount = g.Min(o => o.TotalAmount),
        MaxAmount = g.Max(o => o.TotalAmount)
    })
    .ToListAsync();

// 生成的 SQL 使用 GROUP BY 和聚合函数
// 高效! 只返回统计结果,不传输原始数据

5. 关联查询 ​

csharp
// ✅ JOIN 在服务端执行
var orderDetails = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .Where(o => o.TotalAmount > 1000)
    .ToListAsync();

// 生成的 SQL 使用 JOIN
// 单个查询获取完整对象图

检查是否可翻译 ​

csharp
// 启用日志查看翻译情况
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information);
});

// 输出:
// info: Compiling query model for 'Orders.Where(...).Select(...)'
// info: Generated SQL: SELECT ... FROM Orders ...
// 
// 如果无法翻译:
// warn: The LINQ expression '...' could not be translated and will be evaluated locally.

客户端评估 ​

显式客户端评估 ​

csharp
// 方法 1: AsEnumerable() / ToList() 切换点
var products = context.Products
    .Where(p => p.Price > 100)        // ← 服务端评估
    .AsEnumerable()                    // ← 切换到客户端
    .Select(p => new {                 // ← 客户端评估
        p.Name,
        FormattedPrice = FormatPrice(p.Price),  // ✅ C# 方法
        Description = GenerateDescription(p)     // ✅ 复杂逻辑
    })
    .ToList();

// 执行流程:
// 1. SELECT * FROM Products WHERE Price > 100 (数据库)
// 2. 加载结果到内存
// 3. FormatPrice() 和 GenerateDescription() 在 C# 中执行

// ⚠️ 注意: 确保 WHERE 子句已经过滤掉大部分数据!

何时使用客户端评估 ​

✅ 适合客户端评估:

  • 复杂的 C# 逻辑(无法转换为 SQL)
  • 调用外部 API 或服务
  • 格式化显示数据
  • 业务规则验证

❌ 不适合客户端评估:

  • 过滤条件(应在数据库执行)
  • 排序(应在数据库执行)
  • 分页(应在数据库执行)
  • 聚合(应在数据库执行)

示例: 混合法 ​

csharp
public class ProductService
{
    private readonly AppDbContext _context;
    private readonly IPricingService _pricingService;  // 外部服务
    
    public async Task<List<ProductDto>> GetProductsWithPricingAsync()
    {
        // 步骤 1: 服务端过滤和投影(减少数据量)
        var products = await _context.Products
            .Where(p => p.IsActive && p.Stock > 0)
            .Select(p => new {
                p.Id,
                p.Name,
                p.BasePrice,
                p.CategoryId
            })
            .ToListAsync();
        
        // 步骤 2: 客户端处理(调用外部服务)
        var result = products.Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            BasePrice = p.BasePrice,
            // 调用外部定价服务(无法在 SQL 中执行)
            FinalPrice = _pricingService.CalculatePrice(p.Id, p.BasePrice),
            // 复杂业务逻辑
            Availability = GetAvailabilityText(p.Stock, p.CategoryId)
        }).ToList();
        
        return result;
    }
    
    private string GetAvailabilityText(int stock, int categoryId)
    {
        // 复杂业务规则,无法转换为 SQL
        if (stock == 0) return "Out of Stock";
        if (stock < 10) return "Low Stock";
        if (categoryId == 1) return "In Stock (Fast Moving)";
        return "In Stock";
    }
}

混合评估问题 ​

问题 1: 意外加载大量数据 ​

csharp
// ❌ 危险: 忘记先过滤
var customers = context.Customers
    .AsEnumerable()  // 💥 加载所有客户到内存!
    .Where(c => c.Name.StartsWith("A"))  // 客户端过滤
    .ToList();

// 如果有 100 万客户,全部加载到内存!

// ✅ 正确: 先服务端过滤
var customers = context.Customers
    .Where(c => c.Name.StartsWith("A"))  // 服务端过滤
    .AsEnumerable()  // 切换到客户端
    .Select(c => new {  // 客户端处理
        c.Name,
        FormattedPhone = FormatPhone(c.Phone)
    })
    .ToList();

问题 2: N+1 查询 ​

csharp
// ❌ 危险: 客户端评估导致 N+1
var customers = context.Customers
    .AsEnumerable()  // 加载所有客户
    .Select(c => new {
        c.Name,
        Orders = c.Orders  // 💥 访问导航属性触发查询!
            .Where(o => o.Total > 100)
            .ToList()
    })
    .ToList();

// 执行 1 + N 次查询!

// ✅ 正确: Include 预加载
var customers = await context.Customers
    .Include(c => c.Orders)
    .Where(c => c.Orders.Any(o => o.Total > 100))
    .Select(c => new {
        c.Name,
        Orders = c.Orders
            .Where(o => o.Total > 100)
            .ToList()
    })
    .ToListAsync();

问题 3: 分页失效 ​

csharp
// ❌ 错误: 分页在客户端执行
var products = context.Products
    .AsEnumerable()  // 💥 加载全部产品!
    .OrderBy(p => p.Name)
    .Skip(10)
    .Take(10)
    .ToList();

// ✅ 正确: 分页在服务端执行
var products = await context.Products
    .OrderBy(p => p.Name)
    .Skip(10)      // SQL: OFFSET 10 ROWS
    .Take(10)      // SQL: FETCH NEXT 10 ROWS ONLY
    .Select(p => new {
        p.Name,
        FormattedPrice = p.Price.ToString("C")  // 客户端格式化
    })
    .ToListAsync();

.NET 8/9/10 新特性 ​

.NET 8: 改进的翻译能力 ​

csharp
// .NET 8 支持更多方法的 SQL 翻译

// 新增支持的方法
var products = await context.Products
    .Where(p => p.Name.Contains("test", StringComparison.OrdinalIgnoreCase))  // ✅ .NET 8
    .Where(p => Math.Round(p.Price, 2) > 100)  // ✅ .NET 8
    .Select(p => new {
        p.Name,
        FirstChar = p.Name.Substring(0, 1)  // ✅ .NET 8 改进
    })
    .ToListAsync();

// 翻译覆盖率提升 30-40%

.NET 9: 增强的诊断 ​

csharp
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .EnableDetailedErrors()
           .LogTo(Console.WriteLine, LogLevel.Warning);
});

// .NET 9 输出更详细的评估信息:
// warn: Client evaluation detected in 'Select' clause.
// info: Consider moving this logic to server side for better performance.
// info: Translated 85% of query to SQL. 15% evaluated on client.

.NET 10: 智能评估优化(路线图) ​

预计特性:

  • AI 驱动的查询优化建议
  • 自动重写可翻译的表达式
  • 客户端评估性能分析工具
  • 可视化查询执行计划

最佳实践与陷阱 ​

最佳实践 ​

1. 最大化服务端评估 ​

csharp
// ✅ 推荐: 尽可能在服务端执行
var result = await context.Orders
    .Where(o => o.OrderDate >= startDate)     // 服务端过滤
    .Where(o => o.TotalAmount > minAmount)    // 服务端过滤
    .OrderByDescending(o => o.OrderDate)      // 服务端排序
    .Skip(pageSize * pageNumber)              // 服务端分页
    .Take(pageSize)                           // 服务端分页
    .Select(o => new {                        // 服务端投影
        o.Id,
        o.OrderDate,
        o.TotalAmount,
        CustomerName = o.Customer.Name
    })
    .ToListAsync();

// 只有最后的数据传输到客户端

2. 明确切换点 ​

csharp
// ✅ 推荐: 明确标注切换点
var products = await context.Products
    .Where(p => p.IsActive)          // 服务端
    .ToListAsync();                  // 执行查询

// 然后在客户端处理
var dtos = products.Select(p => new ProductDto
{
    Name = p.Name,
    FormattedPrice = FormatPrice(p.Price)  // 客户端
}).ToList();

// 清晰分离,易于理解和调试

3. 使用原生函数 ​

csharp
// ✅ 推荐: 使用 EF.Functions 调用数据库函数
var products = await context.Products
    .Where(p => EF.Functions.Like(p.Name, "%Laptop%"))  // SQL LIKE
    .Where(p => EF.Functions.DateDiffDay(p.CreatedAt, DateTime.UtcNow) < 30)
    .ToListAsync();

// 比客户端评估高效得多

4. 监控和测试 ​

csharp
// 单元测试: 验证查询可翻译
[Fact]
public async Task Query_ShouldBeFullyTranslatedToSql()
{
    var query = context.Products
        .Where(p => p.Price > 100)
        .Select(p => new { p.Name, p.Price });
    
    // 启用日志并检查是否有客户端评估警告
    var products = await query.ToListAsync();
    
    // 断言: 没有客户端评估警告
    AssertNoClientEvaluationWarnings(logger);
}

常见陷阱 ​

陷阱 1: 不支持的方法 ​

csharp
// ❌ 错误: 无法翻译的方法
var products = await context.Products
    .Select(p => new {
        p.Name,
        HashCode = p.Name.GetHashCode()  // 💥 无法翻译!
    })
    .ToListAsync();

// ✅ 解决: 客户端评估
var products = await context.Products
    .Select(p => p.Name)
    .ToListAsync();

var result = products.Select(name => new {
    Name = name,
    HashCode = name.GetHashCode()  // 客户端计算
}).ToList();

陷阱 2: 自定义方法 ​

csharp
// ❌ 错误: 自定义方法无法翻译
public static decimal CalculateDiscount(decimal price)
{
    return price * 0.9m;
}

var products = await context.Products
    .Select(p => new {
        p.Name,
        DiscountedPrice = CalculateDiscount(p.Price)  // 💥 无法翻译!
    })
    .ToListAsync();

// ✅ 解决 1: 内联表达式
var products = await context.Products
    .Select(p => new {
        p.Name,
        DiscountedPrice = p.Price * 0.9m  // ✅ 可翻译
    })
    .ToListAsync();

// ✅ 解决 2: 客户端评估
var products = await context.Products
    .ToListAsync();

var result = products.Select(p => new {
    p.Name,
    DiscountedPrice = CalculateDiscount(p.Price)  // 客户端计算
}).ToList();

陷阱 3: 第三方库方法 ​

csharp
// ❌ 错误: Newtonsoft.Json 方法无法翻译
var products = await context.Products
    .Select(p => new {
        p.Name,
        JsonData = JsonConvert.SerializeObject(p)  // 💥 无法翻译!
    })
    .ToListAsync();

// ✅ 解决: 客户端序列化
var products = await context.Products
    .ToListAsync();

var result = products.Select(p => new {
    p.Name,
    JsonData = JsonConvert.SerializeObject(p)  // 客户端序列化
}).ToList();

总结 ​

核心要点 ​

  1. 服务端评估: 转换为 SQL,在数据库执行(高效)
  2. 客户端评估: 在 C# 中执行(.NET Core 3.0+ 需显式声明)
  3. 最佳实践: 最大化服务端评估,明确切换点
  4. 性能影响: 客户端评估可能加载大量数据
  5. 监控: 启用日志检测意外的客户端评估

评估决策流程 ​

LINQ 查询操作
├─ 能转换为 SQL?
│   ├─ 是 → 服务端评估 ✅
│   └─ 否 → 需要客户端评估
│       ├─ 可以先过滤?
│       │   ├─ 是 → 先 Where/Take,再 AsEnumerable()
│       │   └─ 否 → 警告: 可能性能差
│       └─ 必须客户端?
│           ├─ 是 → 明确使用 AsEnumerable()
│           └─ 否 → 重写为可翻译的表达式

性能对比 ​

场景: 100万条记录,需要100条

方式              | 数据传输 | 内存占用 | 耗时
-----------------|----------|----------|------
完全服务端       | 100条    | 低       | 50ms
意外客户端评估   | 100万条  | 高       | 5000ms
显式客户端评估   | 1000条*  | 中       | 200ms

* 先过滤后的数据量

代码模板 ​

csharp
// 模板 1: 完全服务端评估
var result = await context.Entities
    .Where(e => e.Condition)
    .OrderBy(e => e.Field)
    .Skip(skip).Take(take)
    .Select(e => new Dto { ... })
    .ToListAsync();

// 模板 2: 混合评估
var data = await context.Entities
    .Where(e => e.Filter)      // 服务端
    .ToListAsync();             // 执行查询

var result = data.Select(e => new Dto
{
    Field = ComplexMethod(e)   // 客户端
}).ToList();

// 模板 3: 明确切换点
var result = context.Entities
    .Where(e => e.Filter)      // 服务端
    .AsEnumerable()            // 明确切换
    .Select(e => new Dto
    {
        Field = ComplexMethod(e)  // 客户端
    })
    .ToList();

下一步 ​

基于 MIT 许可发布