业务代码里的数据很少恰好长成页面或接口需要的样子:报名表要筛掉未通过的人,成绩榜要稳定排序,班级要生成统计摘要,学生与课程还要按键关联。LINQ 的价值,是把这些变化写成一条可读、可组合、可测试的数据流水线。
本章以“训练营报名看板”为贯穿案例。你不仅会写出查询,还会判断它何时执行、执行几次、需要多少额外空间,以及操作究竟发生在内存还是数据库端。

完成本章后,你应该能做到:
Where、Select、排序、分组、聚合、连接与 SelectMany 组织真实查询。DistinctBy 与 Chunk 建立清晰的数据出口。IEnumerable<T> 与 IQueryable<T> 的执行边界,减少数据库传输与翻译失败。建议按“数据源 → 运算符 → 执行时机 → 关系运算 → 远程边界”的顺序阅读。代码默认使用现代 C# 集合表达式;若项目版本较旧,可把 [...] 改为 new List<T> { ... }。
本节自测
先建立稳定的数据模型。关联关系应使用 Id,不要用可能重名、改名的姓名作为键。
public sealed record Student(
int Id,
string Name,
int Score,
string Team,
string[] Tags);
public sealed record Enrollment(int StudentId, string Course, decimal Fee);
public sealed record EnrollmentDto(string Name, string Course, int Score);
List<Student> students =
[
new(1, "小林", 92, "A组", ["C#", "API"]),
new(2, "小周", 78, "B组", ["SQL"]),
new(3, "小陈", 85, "A组", ["LINQ", "测试"]),
new(4, "小何", 88, "B组", [])
];
List<Enrollment> enrollments =
[
new(1, "Web API", 399m),
new(1, "LINQ", 199m),
new(3, "单元测试", 299m)
];一条查询包含三个角色:
var plan = students
.Where(s => s.Score >= 85)
.Select(s => s.Name); // 只定义计划
Console.WriteLine(string.Join(",", plan)); // 此处枚举小林,小陈,小何本节自测
方法语法把标准查询运算符写成扩展方法链,覆盖面最完整:
var methodResult = students
.Where(s => s.Score >= 85)
.OrderByDescending(s => s.Score)
.ThenBy(s => s.Name)
.Select(s => new { s.Name, s.Score });查询语法更接近一句数据描述,复杂连接时尤其容易扫读:
var queryResult =
from s in students
where s.Score >= 85
orderby s.Score descending, s.Name
select new { s.Name, s.Score };两者得到相同的元素。查询语法会被编译器改写为查询运算符调用,并不是另一套运行引擎。也可以混用:先用查询语法完成 join,再调用只存在于方法语法中的运算符。

本节自测
Where 改变元素数量,Select 改变元素形状,OrderBy 改变顺序。常见的高效顺序是先过滤,再只投影所需字段,最后按需求排序;但排序键如果不在投影结果中,可以先排序再投影。
var leaderboard = students
.Where(s => s.Score >= 80) // O(n),流式
.OrderByDescending(s => s.Score) // O(n log n),需要缓冲
.ThenBy(s => s.Name)
.Select(s => new { s.Name, s.Score }); // O(n),流式
foreach (var row in leaderboard)
Console.小林: 92
小何: 88
小陈: 85稳定结果不能依赖源集合“碰巧有序”。凡是分页、排行榜或测试断言需要固定顺序,就明确写出完整排序键。
现代集合查询还常用两个工具:
var bestPerTeam = students
.OrderByDescending(s => s.Score)
.DistinctBy(s => s.Team);
foreach (Student[] batch in students.Chunk(2))
Console.WriteLine(string.Join("、", batch.Select(s => s.Name)));小林、小周
小陈、小何DistinctBy 保留每个键首次遇到的元素,因此“每组最高分”必须先按分数降序。Chunk(2) 的最后一批最多两个元素,不保证刚好装满。
本节自测
许多返回 IEnumerable<T> 的运算符只是保存查询逻辑。每次枚举,都会从当前数据源重新开始:
List<int> numbers = [1, 2, 3];
IEnumerable<int> evens = numbers.Where(n => n % 2 == 0);
numbers.Add(4);
Console.WriteLine(string.Join(", ", evens));
2, 4
2, 4
2, 4, 6
立即返回标量的 Any、First、Count、Sum 会触发枚举;ToList、ToArray、ToDictionary 既触发枚举又保存结构。Any 和 First 能短路,找到答案即可停止。
避免无意的重复枚举:
// 远程查询可能因此执行两次。
if (query.Any())
foreach (var item in query) { /* ... */ }
// 需要同时判断并消费时,在明确边界物化一次。
var rows = query.ToList();
if (rows.Count > 0)
foreach (var item in rows) { /* ... */ }本节自测
GroupBy 生成多个 IGrouping<TKey,TElement>:每一组既有 Key,又是可继续枚举的元素序列。
var summary = students
.GroupBy(s => s.Team)
.Select(g => new
{
Team = g.Key,
Count = g.Count(),
Average = g.Average(s => s.Score),
Max = g.Max(s => s.Score)
})
A组: 2人, 均分88.5, 最高92
B组: 2人, 均分83.0, 最高88哈希分组平均需要 O(n) 时间与 O(n) 额外空间;排序分组还会增加排序成本。Average、Min、Max 面对空序列会抛出异常,但 GroupBy 产生的实际组不会是空组;自己聚合可空集合时应先判断或使用可空数值。
本节自测
Join 是内连接:外层与内层都存在匹配键时才产生结果。
var paidCourses = students.Join(
enrollments,
s => s.Id,
e => e.StudentId,
(s, e) => new EnrollmentDto(s.Name, e.Course, s.Score));GroupJoin 为每个外层元素附上一组匹配项。它很适合表达“学生及其全部报名”:
var studentsWithCourses = students.GroupJoin(
enrollments,
s => s.Id,
e => e.StudentId,
(s, matches) => new
{
s.Name,
Courses = matches.Select(e => e.Course).ToList()
});左连接需要把空匹配补成一行:
var leftJoin =
from s in students
join e in enrollments on s.Id equals e.StudentId into matches
from e in matches.DefaultIfEmpty()
select new { s.Name, Course = e?.Course ?? "未报名" };
内存中的 Join 通常为一侧建立查找结构,平均时间接近 O(n+m),额外空间与被索引的一侧相关。若键的相等规则特殊,可在 IEnumerable 场景提供比较器;数据库提供程序能否翻译自定义比较器则要另行确认。
本节自测
Select 会保持嵌套形状,SelectMany 会把内部序列串成一个序列:
IEnumerable<string[]> nested = students.Select(s => s.Tags);
IEnumerable<string> flat = students.SelectMany(s => s.Tags);如果展开后仍需知道标签属于谁,使用结果选择器:
var tagOwners = students.SelectMany(
s => s.Tags,
(s, tag) => new { s.Name, Tag = tag });
foreach (var row in tagOwners)
Console.WriteLine($"{row.Name} -> {row.Tag}");没有标签的小何不会产生任何行。这与左连接的“保留空匹配”不同;若业务要显示“无标签”,可先把空数组替换为只含一个占位值的序列。
本节自测
匿名类型适合方法内部,跨层返回应使用明确 DTO。DTO 只携带消费端需要的字段,既减少耦合,也能帮助数据库查询减少列传输。
var cards =
from s in students
join e in enrollments on s.Id equals e.StudentId
where s.Score >= 85
orderby s.Score descending, s.Name
select new EnrollmentDto(s.Name, e.Course, s.Score);去重前先决定“保留哪一个”:
var latestPerStudent = records
.OrderByDescending(x => x.UpdatedAt)
.DistinctBy(x => x.StudentId);批量调用外部服务时再分块:
foreach (EnrollmentDto[] batch in cards.Chunk(100))
await SendBatchAsync(batch);不要把 Chunk 当作并发控制;它只切分序列。并发数量、失败重试和幂等性仍需要单独设计。
本节自测
IEnumerable<T> 上的查询通常接收已编译委托,在 .NET 进程中逐项执行。IQueryable<T> 接收表达式树,由查询提供程序分析并翻译成 SQL 或其他远程查询。

public sealed record StudentRow(int Id, string Name, int Score);
IQueryable<StudentRow> query = db.Students
.Where(s => s.Score >= 85)
.OrderByDescending(s => s.Score)
.Select(s => new StudentRow
只要仍在 IQueryable 上,提供程序就有机会把过滤、排序和投影下推。过早 ToList() 会先把数据搬到内存,后续条件只能本地运行。
AsEnumerable() 与 ToList() 不同:前者不物化,只把后续运算切换到 IEnumerable 解析;真正枚举时,前半段远程查询先执行,后半段再在本地继续。它可作为明确的翻译边界,但必须在远程端先尽量缩小数据。
var labels = db.Students
.Where(s => s.Score >= 85) // 尽量在数据库筛选
.Select(s => new { s.Name, s.Score })
.AsEnumerable() // 从这里开始是本地委托
.Select(s => FormatLabel(s.Name, s.Score))
.ToList();数据库提供程序不一定能翻译任意 .NET 方法、比较器或自定义函数。遇到翻译失败,先寻找提供程序支持的等价表达,再考虑经过小结果集边界转到本地。
本节自测
常见陷阱清单:
foreach 前先 Count(),让昂贵查询执行两次。First() 假定必有结果;不确定时应选择 FirstOrDefault() 并处理缺失。本节自测
目标:输出成绩不低于 85 分的学生及其课程;没有报名也要显示;同一课程只保留一次;最终按成绩降序、姓名和课程升序,并映射为 DTO。
var dashboard = students
.Where(s => s.Score >= 85)
.GroupJoin(
enrollments,
s => s.Id,
e => e.StudentId,
(s, courses) => new
{
Student = s,
Courses = courses
.DistinctBy(
92 | 小林 | LINQ
92 | 小林 | Web API
88 | 小何 | 未报名
85 | 小陈 | 单元测试进一步练习:
Fee = 0 的 DTO 字段。本节自测
可靠的 LINQ 不只是把循环写短。你需要同时读出四件事:元素如何变化、查询何时执行、成本在哪里产生、远程与本地的边界在哪里。
Where 缩小集合,Select 塑造 DTO,完整排序保证稳定结果。GroupBy 做摘要,Join 做匹配,GroupJoin 保留一组匹配,SelectMany 展开一对多。IQueryable 的关键是可翻译性;先在远程端过滤与投影,再谨慎越过本地边界。本节自测