mcpField
D
dotnet-linq-optimization

dotnet-linq-optimization

github.com/wshaddix开发8688 次安装35 周前更新
代码仓库

关于 dotnet-linq-optimization

面向 .NET 开发者的 LINQ 性能优化技能,讲解 EF Core 查询执行位置、延迟执行和分配成本,并介绍编译查询、手动循环与 Span 替代方案。

主要功能

  1. 01区分 IQueryable 服务端与 IEnumerable 内存执行
  2. 02运用 EF Core 编译查询
  3. 03识别延迟执行与重复枚举问题
  4. 04分析 LINQ 分配并评估手动循环
  5. 05使用 Span 进行低分配处理
  6. 06优化投影、分页与批量操作

适用场景

  1. 01避免查询过早切换到客户端执行
  2. 02优化高频 EF Core 查询
  3. 03排查重复执行的延迟查询
  4. 04减少热路径集合处理的分配

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-linq-optimization

安装后在 Claude Code 中输入/dotnet-linq-optimization确认已加载。

详细介绍

dotnet-linq-optimization

面向 .NET 应用的 LINQ 性能模式。涵盖 IQueryable<T> 服务端求值与 IEnumerable<T> 客户端物化之间的关键区别、EF Core 热路径中的编译查询、延迟执行陷阱、LINQ-to-Objects 分配模式以及何时改用手动循环,还有基于 Span 的零分配处理替代方案。

不在讨论范围内: EF Core DbContext 生命周期、迁移、拦截器和连接弹性——请参阅 [skill:dotnet-efcore-patterns]。战略性数据架构(仓储模式、读写分离、N+1 治理)——请参阅 [skill:dotnet-efcore-architecture]。Span 和 Memory 基础知识——请参阅 [skill:dotnet-performance-patterns]。微基准测试设置——请参阅 [skill:dotnet-benchmarkdotnet]。

交叉参考:[skill:dotnet-efcore-patterns] 介绍 EF Core 上下文中的编译查询和 DbContext 用法;[skill:dotnet-performance-patterns] 介绍 Span/Memory 基础知识和 ArrayPool 模式;[skill:dotnet-benchmarkdotnet] 介绍如何衡量 LINQ 优化的影响。


IQueryable 与 IEnumerable 的物化

LINQ 性能方面影响最大的决策是求值发生的位置:数据库服务端(IQueryable<T>)还是应用内存中(IEnumerable<T>)。

问题
// DANGEROUS: Materializes entire table into memory, then filters in C#
IEnumerable<Order> orders = dbContext.Orders;
var recent = orders.Where(o => o.CreatedAt > cutoff).ToList();
// SQL: SELECT * FROM Orders  (no WHERE clause!)

// CORRECT: Filter executes on the database server
IQueryable<Order> orders = dbContext.Orders;
var recent = orders.Where(o => o.CreatedAt > cutoff).ToList();
// SQL: SELECT ... FROM Orders WHERE CreatedAt > @cutoff
何时发生物化
操作影响
ToList()、ToArray()、ToDictionary()执行查询,将结果加载到内存中
foreach / await foreach执行查询,流式传输结果
AsEnumerable()从服务端求值切换为客户端求值
Count()、Any()、First()、Single()执行查询,返回标量
IQueryable 上的 Where()、Select()、OrderBy()构建表达式树(不执行)
IEnumerable 上的 Where()、Select()、OrderBy()延迟进行内存中求值
常见错误
// MISTAKE 1: AsEnumerable() before filtering
var results = dbContext.Orders
    .AsEnumerable()           // <-- switches to client evaluation
    .Where(o => o.Total > 100)  // runs in memory, not SQL
    .ToList();

// MISTAKE 2: Calling a C# method in IQueryable predicate
var results = dbContext.Orders
    .Where(o => IsHighValue(o))  // Cannot translate to SQL; throws or falls back
    .ToList();

// FIX: Use expression-compatible predicates or call after materialization
var results = dbContext.Orders
    .Where(o => o.Total > 100)   // SQL-translatable
    .AsEnumerable()
    .Where(o => IsHighValue(o))  // C# logic after materialization
    .ToList();

// MISTAKE 3: Projecting too many columns
var names = dbContext.Orders.ToList().Select(o => o.CustomerName);
// Loads ALL columns, then picks one in memory

// FIX: Project before materializing
var names = dbContext.Orders.Select(o => o.CustomerName).ToList();
// SQL: SELECT CustomerName FROM Orders
检测清单
  • 在 Where/Select 之前出现任何 AsEnumerable() 或转换为 IEnumerable<T>,都可能绕过服务端求值
  • EF Core 在回退到客户端求值时,会以 Warning 级别记录 Microsoft.EntityFrameworkCore.Query 日志
  • 开发期间启用 ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning))

EF Core 热路径的编译查询

编译查询可消除每次调用时编译表达式树的开销。对于每秒执行数千次的查询,它可以显著降低开销。

标准编译查询
public sealed class OrderRepository(AppDbContext db)
{
    // Compiled once, reused across all calls
    private static readonly Func<AppDbContext, Guid, Task<Order?>>
        s_findById = EF.CompileAsyncQuery(
            (AppDbContext ctx, Guid id) =>
                ctx.Orders.FirstOrDefault(o => o.Id == id));

    private static readonly Func<AppDbContext, DateTime, IAsyncEnumerable<Order>>
        s_findRecent = EF.CompileAsyncQuery(
            (AppDbContext ctx, DateTime cutoff) =>
                ctx.Orders
                    .Where(o => o.CreatedAt > cutoff)
                    .OrderByDescending(o => o.CreatedAt));

    public Task<Order?> FindByIdAsync(Guid id) =>
        s_findById(db, id);

    public IAsyncEnumerable<Order> FindRecentAsync(DateTime cutoff) =>
        s_findRecent(db, cutoff);
}
何时使用编译查询
场景使用编译查询?
高频查找(身份验证、缓存)是
管理员仪表板查询(低频)否——开销可以忽略
带动态谓词的查询(用户搜索)否——无法对查询形状进行参数化
Include() 会变化的查询否——Include 会改变表达式树形状
限制
  • 编译查询不能使用动态 Include(),也不能使用会改变表达式树形状的条件 Where() 子句
  • 参数必须是简单类型(不能是复杂对象或集合)
  • 对于单个结果,EF.CompileAsyncQuery 返回 Task<T>;对于集合,返回 IAsyncEnumerable<T>

延迟执行陷阱

LINQ 使用延迟执行:查询运算符会构建一个仅在使用结果时才执行的管道。这种机制功能强大,但也会带来微妙的 Bug。

多次枚举
// BUG: Enumerates the database query twice
IQueryable<Order> query = dbContext.Orders.Where(o => o.Status == Status.Active);

var count = query.Count();         // Executes SQL (1st query)
var items = query.ToList();        // Executes SQL again (2nd query)

// FIX: Materialize once
var items = dbContext.Orders
    .Where(o => o.Status == Status.Active)
    .ToList();

var count = items.Count;           // In-memory, no SQL
循环中的闭包捕获
// BUG: All queries capture the same loop variable 'i' by reference
var queries = new List<IQueryable<Order>>();
for (int i = 0; i < statuses.Length; i++)
{
    queries.Add(dbContext.Orders.Where(o => o.Status == statuses[i]));
    // 'i' is captured by reference -- all queries use final value of i
}

// FIX: Copy to a local variable inside the loop body
for (int i = 0; i < statuses.Length; i++)
{
    var localStatus = statuses[i];
    queries.Add(dbContext.Orders.Where(o => o.Status == localStatus));
}

注意:C# 5+ 中,foreach 循环变量的作用域按迭代划分,不会出现此 Bug。for 循环的索引变量会在各次迭代间共享,因此在循环中构建延迟执行的 LINQ 查询时,这是一种常见陷阱。

方法返回值中的延迟执行
// DANGEROUS: Returns an unevaluated query -- caller may not realize
// the DbContext could be disposed before enumeration
public IEnumerable<Order> GetActiveOrders()
{
    return dbContext.Orders.Where(o => o.Status == Status.Active);
    // Not evaluated yet -- DbContext may be disposed when caller iterates
}

// SAFE: Materialize before returning
public async Task<List<Order>> GetActiveOrdersAsync(CancellationToken ct)
{
    return await dbContext.Orders
        .Where(o => o.Status == Status.Active)
        .ToListAsync(ct);
}

LINQ-to-Objects 分配模式

对内存集合使用 LINQ 运算符会分配迭代器、委托和中间集合。在每秒处理数千个项目的热点路径中,这些分配可能导致 GC 压力。

分配来源
操作分配
Where()、Select()迭代器对象 + 委托
ToList()、ToArray()新集合 + 可能的扩容
OrderBy()为排序进行完整复制
GroupBy()字典 + 分组对象
SelectMany()迭代器 + 内部迭代器
Lambda 捕获局部变量每个捕获作用域分配一个闭包对象
LINQ 分配何时值得关注

对大多数代码而言,LINQ 分配可以忽略不计。仅在以下情况进行优化:

  • 处理发生在热点路径中(每秒调用数千次)
  • BenchmarkDotNet 显示 Allocated 字节数显著
  • GC 指标(每秒 Gen0 回收次数)表明存在压力
手动循环替代方案
// LINQ: Allocates iterator + delegate + List<T>
var result = items
    .Where(x => x.IsActive)
    .Select(x => x.Name)
    .ToList();

// Manual loop: Single List<T> allocation, no iterator/delegate overhead
var result = new List<string>(items.Count);
foreach (var item in items)
{
    if (item.IsActive)
    {
        result.Add(item.Name);
    }
}
// LINQ: Allocates iterator + delegate + bool boxing (Any)
var hasActive = items.Any(x => x.IsActive);

// Manual loop: Zero allocations beyond the enumerator
var hasActive = false;
foreach (var item in items)
{
    if (item.IsActive)
    {
        hasActive = true;
        break;
    }
}
在不放弃 LINQ 的情况下减少分配

改用手动循环之前,请考虑以下折中步骤:

// 1. Use Array.Find / Array.Exists for arrays (no iterator allocation)
var first = Array.Find(items, x => x.IsActive);
var exists = Array.Exists(items, x => x.IsActive);

// 2. Pre-size collections when count is known
var result = new List<string>(items.Length);
result.AddRange(items.Where(x => x.IsActive).Select(x => x.Name));

// 3. Use static lambdas to avoid delegate allocation (C# 9+)
var result = items.Where(static x => x.IsActive).ToList();
// Note: static lambdas prevent accidental closure capture
// but the delegate is already cached by the compiler for
// non-capturing lambdas; the main benefit is enforcement

基于 Span 的集合处理替代方案

对于性能要求最高的场景,Span<T> 和 ReadOnlySpan<T> 支持基于栈且零分配的处理。这些 API 与 LINQ 不兼容,但能覆盖常见模式。

Span 搜索与筛选
// Zero-allocation contains check on an array
ReadOnlySpan<int> values = stackalloc int[] { 1, 2, 3, 4, 5 };
bool found = values.Contains(3);

// Zero-allocation index search
int index = values.IndexOf(4);
用于字符串处理的 MemoryExtensions
// Zero-allocation split and iterate
ReadOnlySpan<char> csv = "alice,bob,charlie";
foreach (var segment in csv.Split(','))
{
    ReadOnlySpan<char> value = csv[segment];
    // Process each value without allocating strings
}

// Zero-allocation trim and compare
ReadOnlySpan<char> input = "  hello  ";
bool match = input.Trim().SequenceEqual("hello");
何时优先使用 Span 而非 LINQ
场景方式
在紧密循环中解析 CSV/日志行ReadOnlySpan<char> + Split
搜索已排序数组Span<T>.BinarySearch
处理来自 I/O 的字节缓冲区对 ReadOnlySpan<byte> 进行切片
集合上的一般业务逻辑LINQ(可读性优先于微优化)

如需了解完整的 Span/Memory 模式和 ArrayPool 用法,请参阅 [skill:dotnet-performance-patterns]。


查询优化模式

实体化前投影

始终只选择所需的列:

// BAD: Loads entire entity graph
var orders = await dbContext.Orders
    .Include(o => o.Lines)
    .Include(o => o.Customer)
    .ToListAsync(ct);

var summaries = orders.Select(o => new
{
    o.Id,
    o.Customer.Name,
    Total = o.Lines.Sum(l => l.Price * l.Quantity)
});

// GOOD: Project in the query -- single SQL with computed columns
var summaries = await dbContext.Orders
    .Select(o => new
    {
        o.Id,
        CustomerName = o.Customer.Name,
        Total = o.Lines.Sum(l => l.Price * l.Quantity)
    })
    .ToListAsync(ct);
使用键集(Seek)方法分页
// Offset pagination: O(N) -- server must skip rows
var page = await dbContext.Orders
    .OrderBy(o => o.Id)
    .Skip(pageSize * pageNumber)
    .Take(pageSize)
    .ToListAsync(ct);

// Keyset pagination: O(1) -- index seek
var page = await dbContext.Orders
    .Where(o => o.Id > lastSeenId)
    .OrderBy(o => o.Id)
    .Take(pageSize)
    .ToListAsync(ct);
批量操作
// BAD: N UPDATE statements (one per tracked entity change)
foreach (var order in orders)
{
    order.Status = OrderStatus.Archived;
}
await dbContext.SaveChangesAsync(ct);
// Generates N individual UPDATE statements in a single round-trip

// GOOD: EF Core 7+ ExecuteUpdateAsync (single SQL statement)
await dbContext.Orders
    .Where(o => o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(
        s => s.SetProperty(o => o.Status, OrderStatus.Archived),
        ct);

Agent 常见陷阱

  1. 筛选前不要将 IQueryable 转换为 IEnumerable——这会悄然将服务端 SQL 求值切换为客户端内存求值,可能加载整张表。检查是否存在 AsEnumerable()、显式转换,或接受 IEnumerable<T> 的方法签名。
  2. 不要从存储库方法返回 IQueryable——调用方可以继续组合运算符,但枚举时 DbContext 可能已经释放。请返回已实体化的集合(List<T>)或使用 IAsyncEnumerable<T>。
  3. 没有基准测试时不要优化 LINQ 分配——对大多数业务逻辑而言,LINQ 迭代器的开销可以忽略不计。在用手动循环替换 LINQ 前,请使用 [skill:dotnet-benchmarkdotnet] [MemoryDiagnoser] 验证分配确实值得关注。
  4. 不要对动态谓词使用编译查询——编译查询会缓存表达式树的形状。如果每次调用时查询形状都发生变化(条件包含项、动态筛选器),编译查询会抛出异常或产生错误结果。
  5. 不要多次枚举延迟查询——每次枚举都会重新执行底层数据源(数据库查询、网络调用)。如果结果会被使用多次,请用 ToList() 实体化。
  6. 深度分页时不要使用 Skip()/Take()——数据库上的偏移分页复杂度为 O(N)。请使用键集(Seek)分页,通过 Where 子句筛选最后看到的键,以确保性能不受页数深度影响。

参考资料

评论

支持 Markdown