目录简介1. 在.NET 6中使用Enumerable.Chunk进行批处理无需手动缓冲2. 避免在查询中间使用.ToList()3. 在.NET 6中使用DistinctBy、MaxBy和MinBy而不是编写比较器样板代码4. 使用ValueEnumerableSystem.Linq.Async流式处理大型异步序列无需缓冲5. 在处理临时缓冲区时优先使用ArrayPool避免重复分配简介我在生产代码库中经常看到这样一种模式有人写了一个快速的LINQ查询来完成某项工作它运行正常然后就再也没有被修改过。几个月过去了流量增加了数据量增长了曾经感觉瞬间完成的代码开始拖慢整个服务。开发者开始责怪数据库或扩展性问题或开始像撒调料一样到处添加缓存。但大多数时候慢的部分不是数据库。慢的部分是LINQ管道本身更具体地说是应用程序如何使用LINQ。所以这里是我认为现代.NET代码必须具备的五个LINQ模式。这些不是理论技巧。它们解决了当你的应用程序从只是能用发展到被数千或数百万用户使用时出现的实际问题。1. 在.NET 6中使用Enumerable.Chunk进行批处理无需手动缓冲一个非常常见的模式你有一个大的项目列表想要分批处理。在.NET 6之前每个人都写过这样的变体var batch new ListT(); foreach (var item in items) { batch.Add(item); if (batch.Count 100) { Process(batch); batch.Clear(); } } if (batch.Count 0) { Process(batch); }这能工作但代码冗长、重复容易出错。如果团队中多个开发者需要批处理现在就会有几种略有不同的实现。有些人忘记最后的清理。有些人分配过多内存。有些人意外地重用相同的批处理列表导致下游出现bug。在.NET 6及更高版本中框架已经提供了批处理逻辑foreach (var batch in items.Chunk(100)) { Process(batch); }这样读起来就像你真正意思是分块处理项目而不是手动模拟分块行为。Chunk的结果是每个块一个正确大小的数组。没有边界情况逻辑没有手动的Clear调用没有意外的共享引用。2. 避免在查询中间使用.ToList()这是LINQ管道中最常见的性能错误之一不让数据流式处理到最后一刻。开发者经常这样做var data GetRecords() .Where(x x.IsActive) .ToList(); // 不必要的调用.ToList()会强制整个过滤后的数据集立即进入内存。也许今天那个数据集只有几百行。但如果增长到200,000行你现在就在分配和持有一个巨大的List而你可能根本不需要一次性使用它。规则是只有在绝对需要索引或多重枚举时才具体化序列。否则让管道保持惰性。惰性求允许数据一次处理一个元素。如果下一步是将项目流式传输到文件、网络响应、数据库写入器或消费者通道管道就不再需要完整的数据集。更好的做法foreach (var record in GetRecords().Where(x x.IsActive)) { Process(record); }这是流式处理。记录一次处理一个。内存压力保持低位GC保持平静吞吐量保持高位。.ToList()只在边界层使用是合适的当从API返回数据时当绑定到UI时或者当你明确需要数据在内存中时。否则不要过早中断管道。让数据流动。3. 在.NET 6中使用DistinctBy、MaxBy和MinBy而不是编写比较器样板代码在.NET 6之前按属性去重看起来像这样var unique items .GroupBy(x x.Id) .Select(g g.First());或者更糟有人实现了没人想维护的IEqualityComparer。现在它只是var unique items.DistinctBy(x x.Id);类似地选择按属性的最大项过去需要写这样的代码var best items.OrderByDescending(x x.Score).First();或者var best items.Aggregate((a, b) a.Score b.Score ? a : b);这些都有效但都比内置操作符更慢且更难读var best items.MaxBy(x x.Score);这重要的原因是可读性和正确性。当意图明确时bug就不太可能出现。如果其他开发者看到.GroupBy(...).Select(...)他们必须在心理上重构代码打算做什么。但DistinctBy清楚地传达了意图。这些API存在是因为现实世界中有大量代码需要它们。如果你发现自己分组或排序只是为了选择单个代表性元素请切换到较新的操作符而不是维护样板逻辑。4. 使用ValueEnumerableSystem.Linq.Async流式处理大型异步序列无需缓冲有时你的数据源是异步的。例如• 从数据库游标读取行• 从网络流读取行• 逐步获取分页API结果开发者经常写这样的代码var items await GetAsyncEnum().ToListAsync(); foreach (var item in items) { Process(item); }当你调用ToListAsync时你就在强制整个序列缓冲。如果流永不结束例如从Kafka或Azure Service Bus消费消息这就会变得灾难性。解决方案是将序列视为流而不是集合。await foreach (var item in GetAsyncEnum()) { Process(item); }但有时你实际上想继续编写LINQ风格的链式调用。这就是System.Linq.Async及其IAsyncEnumerable扩展方法变得有用的地方。你可以写await foreach (var item in GetAsyncEnum().Where(x x.IsValid)) { Process(item); }这会惰性且异步地处理项目。内存保持平稳。吞吐量平稳扩展。背压自然调节数据流因为枚举只在消费者处理项目时推进。如果你正在处理异步数据源并且仍然默认调用ToListAsync你就是在将流当作静态数组对待。修复这个问题通常会在负载下立即带来性能改进。5. 在处理临时缓冲区时优先使用ArrayPool避免重复分配当你分块处理数据、序列化消息、解码输入、哈希字节数组或执行压缩时你经常分配缓冲区。例如var buffer new byte[4096]; stream.Read(buffer);这看起来无害。但如果这段代码经常运行或并行运行你就会给GC带来持续压力。大缓冲区会进入LOH大对象堆这会触发昂贵的完整回收。正确的解决方案是对象池。对于数组.NET默认提供了一个共享池var buffer ArrayPoolbyte.Shared.Rent(4096); try { stream.Read(buffer); Process(buffer); } finally { ArrayPoolbyte.Shared.Return(buffer); }这完全避免了重复分配。缓冲区被重用。GC负载下降。吞吐量增加。延迟变得更可预测。池化不是微优化。在高吞吐量系统中这是稳定延迟和丑陋GC峰值之间的区别。在以下情况下使用池化• 缓冲区很大• 缓冲区是临时的• 分配频繁发生这几乎适合每个IO密集的后端服务。真实应用程序中的大多数性能问题并不奇特。它们很少由晦涩的CLR内部或边界情况编译器行为引起。它们来自日常模式这些模式随着数据增长而慢慢退化。LINQ很强大但强大意味着责任。过早调用.ToList()、分组只是为了去重、缓冲异步流或重复分配缓冲区这些都是在代码编写时感觉无害但在几个月后作为性能回归回来的决策。性能不是关于编写复杂代码。而是关于编写清晰且不浪费精力的代码。大多数时候最快的代码是避免不必要工作的代码。现代.NET给了你这样做的工具。使用它们。引入地址