如何让 EF Core 以指定方式生成查询?
结论
EF Core 没有通用且稳定的 API,能要求查询翻译器“必须生成 OUTER APPLY”“禁止使用 ROW_NUMBER()”,或复刻某个旧版本的 SQL shape。
LINQ 查询描述的是结果语义,而不是 SQL 模板。只要结果等价,EF Core 及其数据库 provider 就可能在不同版本中采用不同的翻译方式。修改 LINQ 表达式或加入 p.PartnerId - 0,即使能让当前版本生成预期的 SQL,也不能作为长期契约。
如果查询必须保持特定的 SQL 结构,可以为这条性能敏感路径直接编写 SQL,或将查询封装成数据库对象。EF Core 仍可用于其他查询,只需对少数热点查询显式控制 SQL。
为什么没有通用的 SQL shape hint
EF Core 的查询过程大致分为三层:
- LINQ 表达式描述所需结果。
- EF Core 和 provider 将表达式转换为关系查询。
- 数据库优化器决定最终的执行计划。
FirstOrDefault()、navigation property 和 correlated subquery 都可能经过规范化、消除或改写。下面两段 LINQ 写法不同,但翻译器可能认为它们语义相同:
p.Addresses
.Where(a => a.AddressTypeId == 'P')
.FirstOrDefault()
p.Addresses
.Where(a =>
a.PartnerId == p.PartnerId &&
a.AddressTypeId == 'P')
.FirstOrDefault()
navigation property 已经表达了 a.PartnerId == p.PartnerId 这层关系,因此翻译器可以删除这个显式条件。
a.PartnerId == p.PartnerId - 0 之所以能改变 SQL,是因为当前翻译器没有化简这个表达式。后续的 EF Core 版本、provider 更新或查询中其他表达式的变化,都可能让这种写法失效。它也会使代码的业务意图变得含糊,不适合作为正式方案。
先确认问题是否真的来自 SQL 结构
SQL 文本的形状不能直接说明数据库会怎样执行。SQL Server 未必逐行执行 correlated subquery,也未必会物化并编号 view 的全部结果。是否存在全表扫描、排序、spool 或 predicate pushdown,应以实际执行计划为准。
可以先做以下检查:
- 使用
ToQueryString()保存 EF Core 生成的 SQL。 - 使用接近生产环境的数据量查看 Actual Execution Plan。
- 配合
SET STATISTICS IO, TIME ON,比较逻辑读取、CPU 时间和实际行数。 - 检查 view 展开后的计划,确认估算行数与实际行数是否严重偏离。
- 检查筛选列、关联列和排序列是否有合适的索引支持。
例如,业务规则是“每个 Partner 取 RowId 最小的 P 类型地址”时,索引通常要围绕 PartnerId、AddressTypeId 和 RowId 设计,并根据需要包含 City。具体的列顺序仍取决于实际谓词、数据分布和执行计划。
FirstOrDefault() 如果没有搭配 OrderBy(),“第一条”就没有确定含义。LINQ 和手写 SQL 都应明确排序规则:
var query =
from p in context.Partners
select new
{
p.Name,
City = p.Addresses
.Where(a => a.AddressTypeId == 'P')
.OrderBy(a => a.RowId)
.Select(a => a.City)
.FirstOrDefault()
};
这种写法明确了查询语义,但不能保证 EF Core 一定生成 OUTER APPLY。
需要固定 SQL 时使用 FromSql
必须使用 OUTER APPLY 时,可以把查询结果映射到 keyless entity type:
public sealed class PartnerCityRow
{
public string Name { get; set; } = null!;
public string? City { get; set; }
}
在模型中配置:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<PartnerCityRow>().HasNoKey();
}
然后直接编写所需的 SQL:
var rows = await context.Set<PartnerCityRow>()
.FromSqlInterpolated($"""
SELECT
p.Name,
selectedAddress.City
FROM dbo.Partner AS p
OUTER APPLY
(
SELECT TOP (1)
a.City
FROM dbo.Address AS a
WHERE a.PartnerId = p.PartnerId
AND a.AddressTypeId = 'P'
ORDER BY a.RowId
) AS selectedAddress
WHERE p.Status = {status}
""")
.AsNoTracking()
.ToListAsync();
参数应通过 FromSqlInterpolated 传入,不要拼接字符串。影响性能的外层过滤条件也可以放进原始 SQL。继续在 FromSql 结果上组合 LINQ 时,EF Core 通常会在外面再包一层查询,最终的 SQL 形状仍可能变化。
还可以把查询封装在数据库中
如果这段 SQL 会被多处调用,或需要交给 DBA 独立调优,可以封装成:
- view;
- table-valued function;
- stored procedure;
- 其他由数据库版本控制的查询对象。
EF Core 可以映射 view 或 table-valued function,也能读取存储过程的结果。不过,存储过程通常无法像普通的可组合 SQL 那样继续追加任意 LINQ 运算,因此过滤和排序往往要通过参数传入。
这样可以把 SQL shape、索引和数据库端调优集中在数据库层,但也会增加数据库对象的部署和版本管理工作。
EF Core 提供了哪些相关控制能力
EF Core 提供了一些受支持的查询控制 API,但每个 API 都只控制特定行为,不能用来任意指定 SQL shape:
AsSplitQuery()和AsSingleQuery()控制包含多个相关集合时使用拆分查询还是单条查询。AsNoTracking()控制实体跟踪,不控制连接方式。TagWith()为 SQL 添加诊断注释,不会强制执行计划。EF.Functions和映射的数据库函数可以表达特定的数据库操作,但通常无法指定整个查询的连接结构。FromSql、FromSqlInterpolated和FromSqlRaw用于直接控制 SQL。
数据库 provider 可能还会提供额外能力。这类能力通常是 provider-specific,也不能保证 OUTER APPLY、窗口函数或其他 SQL 结构在后续版本中保持不变。
是否应该扩展查询翻译器
EF Core 提供自定义方法翻译、表达式转换和服务替换等扩展点,也可以通过 command interceptor 在命令执行前处理 SQL。要重写整个查询结构,通常需要依赖 provider 或查询管线的内部实现。
这类代码维护成本较高:
- EF Core 升级后需要重新验证;
- provider 更新可能改变表达式树或 SQL 生成过程;
- 按字符串改写 SQL 容易破坏参数、别名和嵌套查询;
- 很难覆盖所有查询组合。
如果大量查询都需要同一种转换,而且团队愿意承担版本适配和回归测试成本,可以考虑自定义 translator。对于单条热点查询,直接编写 SQL 通常更简单。
推荐的工程处理方式
EF Core 可以继续负责大多数 CRUD、关系映射、事务和普通查询。只有经过执行计划确认存在问题的查询,才需要改用显式 SQL。
升级 EF Core 或数据库 provider 时,还可以为关键查询增加回归检查:
var sql = query.ToQueryString();
生成的 SQL 可以保存下来审查,但不宜对空格、别名等文本细节做完整字符串断言,因为这类断言很脆弱。更实用的做法是检查关键结构,同时结合真实数据上的性能基准和执行计划。
LINQ 适合表达查询语义。需要保证 SQL shape 时,应在对应查询中显式接管 SQL,而不要把 LINQ 翻译结果当成固定的 SQL 模板。
备注:内容仅供参考。
