写原生 SQL 之前,先想明白一件事:不是 EF Core 不行,而是有些查询场景天生不适合让 LINQ 去硬扛。我在实际项目里接触 EF Core 原生 SQL 是从一个报表接口开始的——一张二十多行的统计 SQL,业务侧要求拉出近 90 天按渠道、按状态、按小时的分布数据,还要带环比。用 LINQ 写出来不仅长得像天书,翻译成 SQL 之后嵌套了三四层子查询,跑一次 800 毫秒起步。后面改成 FromSql 把核心聚合直接怼给数据库,接口性能直接落到 120 毫秒以内。这个经历让我开始认真研究 EF Core 里原生 SQL 的边界,尤其是 FromSql、SqlQuery 和对象映射之间那些“能查出来但不一定能映射”的怪问题。
这篇文章适合已经被 EF Core 的 LINQ 翻译折腾过、想在某些场景下用原生 SQL 提速或者写复杂查询的人。我会把查询 API 的版本差异、对象映射的硬性约束、以及真正容易踩坑的边界情况拆开讲,最后给一套能直接抄的分层写法。
1. 先把需求想清楚:什么时候必须上原生 SQL
1.1 LINQ 写不出来或者写出来很丑的场景
EF Core 的 LINQ 能力已经非常强了,但有几个场景它天生不适合。第一个是直接调数据库端函数,比如 PostgreSQL 里的 date_trunc、jsonb_each,SQL Server 里的 OPENJSON、STRING_AGG 配合窗口函数这类。虽然 EF Core 8 开始可以映射一些自定义函数,但维护成本不低。第二个是动态 SQL 需求,报表系统里用户勾选不同的筛选维度,拼接出不同结构的 SQL,你用 LINQ 去动态拼 IQueryable 也挺灵活,但一旦涉及服务端分页 + 聚合 + 排名窗口的混合逻辑,拼出来的表达式树你自己都未必能一眼看对,更别说数据库优化器了。第三个是性能敏感的读接口,当你知道某条 SQL 的最优写法是什么,却没把握让 EF Core 翻译成一样的效果时,直接告诉它 SQL 语句反而是最可靠的做法。
注意:这里说的“告诉它 SQL”不是让你到处梭哈。能用 LINQ 写清楚、翻译结果也在预期范围内的,继续用 LINQ。原生 SQL 是手术刀,不是开山斧。
1.2 性能敏感场景里,EF Core 翻译常常不如意的地方
我观察到一个规律:EF Core 的翻译器在多层聚合嵌套、跨表去重后的分页、按时间粒度分组的报表这三种场景里最容易“摆烂”。表现为生成的子查询结构臃肿,或者干脆抛出“无法翻译”让你转客户端评估——那是更大的坑。举个例子,某次要查“每个客户最近一笔有效订单的金额”,LINQ 写出来是:
csharp复制var query = _context.Customers
.Select(c => new
{
Customer = c,
LastOrder = c.Orders
.Where(o => o.Status == OrderStatus.Valid)
.OrderByDescending(o => o.CreatedAt)
.FirstOrDefault()
});
翻译成 SQL 之后,EF Core 会生成一个 OUTER APPLY,外层可能还要再包一层投影,在订单表数据量大、索引不够给力的情况下就是慢。而手写 SQL 时可以主动用窗口函数 ROW_NUMBER() OVER (PARTITION BY ...),性能差一个量级。这类场景才是原生 SQL 的主场。
1.3 原生 SQL 的对象映射边界,先在心里画一条线
EF Core 原生 SQL 并不是“能查到就能映射成任何东西”。这些年我用下来,总结成一句话:有明确实体映射的用 FromSql/ToSqlQuery,查标量用 SqlQueryScalar 系列,想映射任意未被映射的类则要等到 EF Core 9 的 Database.SqlQueryRaw 支持未映射类型才顺畅。这条线以内的功能可以放心用,线外就会遇到“运行时才报错、编译期看着没问题”的尴尬。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. FromSql 系列:有实体映射的 SQL 查询怎么写到极致
2.1 三个入口的演进与选择
EF Core 7 之前,FromSqlRaw 和 FromSqlInterpolated 是两个入口,前者直接吃字符串,后者吃插值字符串。EF Core 7 开始增加了可以从 IQueryable 上直接用的 FromSql,它的签名是 FromSql($""...),本质上和 FromSqlInterpolated 行为一致,但是语法上更干净、不容易写串。这也是我目前用得最多的入口。
csharp复制// 老写法,手拼参数容易注入
var blogs = await _context.Blogs
.FromSqlRaw("SELECT * FROM [Blogs] WHERE [Url] = {0}", url)
.ToListAsync();
// 新写法,插值参数会被自动参数化
var blogs = await _context.Blogs
.FromSql($"SELECT * FROM [Blogs] WHERE [Url] = {url}")
.ToListAsync();
FromSqlInterpolated 参数占位符是 {0} 这种,很容易跟复合格式字符串搞混。FromSql 带了 FormattableString 编译期检查,写起来反人类程度低很多。EF Core 8 之后我更推荐直接养成用 FromSql 的肌肉记忆。
还有一个细节是:FromSqlRaw 不是不能用,它适合 SQL 模板本身已经经过校验、你只是往里填参数值,并且希望完全控制格式化的场景。比如代码里保存了完整的查询模板字符串,用 string.Format 处理好之后传进去。但只要你写的是“看起来像普通查询的代码”,我都会让你回到 FromSql。
2.2 FromSql + LINQ 组合的“半原生”玩法和坑
FromSql 返回的是 IQueryable<T>,这意味着你可以继续在上面加 LINQ 条件、Join、GroupBy。EF Core 会把你写的 SQL 当作一个子查询/表源,然后把后续 LINQ 翻译成包在它外面的 SQL。
csharp复制var recentOrders = _context.Orders
.FromSql($"SELECT * FROM Orders WHERE CreatedAt >= {startDate}")
.Where(o => o.Status == OrderStatus.Valid)
.OrderByDescending(o => o.Amount)
.Take(20)
.ToList();
这个特性的爽点在于:核心条件可以用原生 SQL 精准表达,而权限过滤、分页这些通用逻辑依然通过 EF Core 翻译。但我必须提醒一个隐藏坑:一旦 FromSql 和某些不能被翻译的 LINQ 组合,EF Core 可能会把外层无限制地下推到查询提供器,生成莫名其妙的嵌套结构。更常见的问题是对 Take、Skip 这类操作,EF Core 在把分页包装到外层 SQL 时,如果内层 SQL 已经带了 ORDER BY,某些数据库生成的 SQL 会不合法。我自己就遇到过 SQL Server 报“除非同时指定 OFFSET,否则 ORDER BY 子句无效”的情况。解决办法是外层不要再用 OrderBy 去干预,分页参数全交给 SQL 端写清楚。
实操心得:FromSql 里的 SQL 尽量只做“取数+条件”,不要在里面写排序和分页。排序分页留给外层 LINQ 或干脆全部在 SQL 里做,两条路选一条走到底,别混着写。
2.3 FromSql 对返回列和实体属性的硬性要求
FromSql 返回的实体必须是数据库里有映射的实体,而且查询出来的列必须覆盖实体上的所有必需映射属性。EF Core 对列是否齐全的校验发生在构建实体实例时,换句话说,少列不会在 SQL 执行时报错,而是在映射的时候抛 InvalidOperationException——这时候排查起来就费劲了。
最典型的问题场景是:你只想查表里两三列用来填充一个局部展示,于是写了:
csharp复制var names = await _context.Users
.FromSql($"SELECT Id, Name FROM Users")
.ToListAsync(); // 如果 User 里还有其他必填属性,这里就炸了
EF Core 会尝试构造完整的 User 实体,结果发现 Email、CreatedAt 没取回来,直接异常。如果你只想要部分列,正确做法是查询出来之后再投影成 DTO,或者用后面的 SqlQuery 系列。这个点就是对象映射边界的第一道墙:实体查询 = 要整条实体,别指望查一半字段也能构造出来。
3. SqlQuery 家族:除映射实体之外,还能查点什么
3.1 标量查询 SqlQueryScalar,最被低估的 API
EF Core 8 的一大补强是引入了 Database.SqlQueryScalar<T> 和 Database.SqlQueryScalarRaw<T>,用来执行返回单列标量值的 SQL。它们返回的不是 IQueryable<T>,而是一个包含 Value 属性的查询结果对象。设计有点绕,但能跑。
csharp复制var result = await _context.Database
.SqlQueryScalar<decimal>(
$"SELECT SUM(Amount) FROM Orders WHERE Status = 'Valid'")
.FirstOrDefaultAsync();
var total = result.Value;
很多人不知道这个 API 的存在,还在用裸 ADO.NET 去拿聚合值。实际上,对于“只查一个数”的场景,SqlQueryScalar 能避免实体映射的开销,也没有 DbConnection 生命周期管理的麻烦。EF Core 作者之一 Shay 在 issue 里提到这个 API 定位就是替换掉那些“用 ExecuteSqlRaw 再手动读返回值”的 hack 写法。
不过需要注意泛型参数只能是 int、long、decimal、string、DateTime、Guid 这类标量类型。泛型对象必须能映射到一个单列结果,连两个列的匿名对象都不行。SQL 返回多列时,这个 API 会直接抛异常。另外它执行后默认不会跟踪,因为压根不是实体。
3.2 非查询命令 ExecuteSql,写更新批量操作的替代品
SqlQuery 系列还有一个常被忽视的成员:Database.ExecuteSql 和 ExecuteSqlAsync,用于执行非查询 SQL。EF Core 8 中它支持以 FormattableString 形式调用并自动参数化。
csharp复制var affected = await _context.Database
.ExecuteSqlAsync(
$"UPDATE Orders SET Status = 'Cancelled' WHERE CreatedAt < {expireDate} AND Status = 'Pending'");
我见过很多团队为了批量更新,把几百上千条记录查出来逐条 Set 状态再 SaveChanges,性能和事务压力都很难看。但看到 ExecuteSql 就两眼放光想无脑用的人也不少,最后死在跨数据库兼容性上。比如 PostgreSQL 里 UPDATE ... FROM 的语法在 SQL Server 里需要不同的写法,所以这类 SQL 应该按数据库方言单独维护,而不是写一套到处跑。
抗坑提示:ExecuteSql 是“绕开模型”的操作,它不会自动启用级联、不会触发审计字段、不会维护导航属性。如果业务层有这些需求,要么在 SQL 里手动做,要么回到 SaveChanges 管线。
3.3 EF Core 9 的未映射类型支持,映射边界终于后移了
EF Core 9 最让我兴奋的是 Database.SqlQueryRaw 对未映射类型的支持。之前版本里,SqlQueryRaw<SomeDto> 会限制在 DbSet 映射类型上,也就是说你必须先有对应实体的 DbSet,才能做原生 SQL 查询并映射到它,未注册的 DTO 或从其他程序集拿来的类型统统不行——这个限制让不少人在实体和 DTO 之间被迫建一堆无用 DbSet。
EF Core 9 之后可以写:
csharp复制public class OrderSummaryDto
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
public decimal TotalAmount { get; set; }
}
var summaries = await _context.Database
.SqlQueryRaw<OrderSummaryDto>(
@"SELECT o.Id AS OrderId,
c.Name AS CustomerName,
SUM(oi.Price * oi.Quantity) AS TotalAmount
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
JOIN OrderItems oi ON oi.OrderId = o.Id
GROUP BY o.Id, c.Name")
.ToListAsync();
有两点需要确认:类型属性不能是导航属性、不能有复杂类型属性,能映射的只是平坦的标量属性;同时构造函数不能带复杂参数,最好保留无参构造或主键构造器。即使到了 EF Core 9,依然不是想映射什么就映射什么——但至少从“必须预先注册实体”松绑到了“能自动匹配属性名就算”,这个边界的释放对写 DTO 查询的人帮助巨大。
4. 对象映射边界:到底什么能映射、什么想都别想
4.1 映射的本质:列名到属性名的隐式约定
EF Core 原生 SQL 的对象映射逻辑很简单,它不会聪明到分析你 SQL 里 AS 出来的别名然后再做什么类型推理。它做的事情只有一件:把查询结果的列名和对象的属性名做匹配,匹配上了就按类型注入值,匹配不上就忽略或者报错(取决于是否必须)。所以控制映射成败的大权在于“列名是否等于属性名”。
这个点听起来不难,实际项目里坑非常多。最常见的是 SQL Server 的 Windows 排序规则不区分大小写,但 PostgreSQL 严格区分大小写。你 SQL 里写了 SELECT id, name FROM ...,在 PostgreSQL 里返回的列名是小写 id、name,但你的 C# 属性是 Id、Name,EF Core 做匹配时大小写不一致?实测结果是:EF Core 的映射逻辑默认不区分大小写,会匹配成功。但我依然吃过亏——当查询结果是子查询或者 UNION 场景,某些驱动返回的列名不规范,导致匹配失败。避免的土办法是:凡是 SQL 的 SELECT 列,全部显式给别名,并且别名与 C# 属性名逐字保持一致。
sql复制SELECT o.Id AS Id,
c.Name AS CustomerName,
o.CreatedAt AS CreatedAt
...
这招的收益非常大:即便将来某个数据库驱动变了,列名行为有差异,你的映射也不会突然间崩掉。
4.2 想都别想的映射场景:匿名字段、嵌套结构与复杂表达式
原生 SQL 映射永远发生在“平坦”的模型上。你不可能把下面这条 SQL 映射成一个包含 Customer 子对象的 DTO:
csharp复制var result = await _context.Database
.SqlQueryRaw<OrderWithCustomerDto>(@"
SELECT o.Id, o.Amount, c.Id AS CustomerId, c.Name AS CustomerName
FROM Orders o JOIN Customers c ON c.Id = o.CustomerId")
.ToListAsync();
就算 DTO 写成:
csharp复制public class OrderWithCustomerDto
{
public int Id { get; set; }
public decimal Amount { get; set; }
public CustomerBrief Customer { get; set; }
}
EF Core 也不会神奇地把 CustomerId、CustomerName 拼装成 Customer 对象。它要求返回的列必须能平铺映射到 DTO 的标量属性上。如果你的目标结构是嵌套的,只能查完之后手动 GroupBy 再组装,或者在 DTO 设计阶段就主动降维成扁平结构。
这个点尤其要提醒“从 Dapper 转过来”的朋友。Dapper 里可以用 splitOn 将结果集切片映射嵌套对象,EF Core 的原生 SQL 查询没有这套能力。选择 EF Core 做原生 SQL 查询,意味着接受它的映射模型是“扁平列 → 扁平对象”,不要总想着让 EF Core 成为 Dapper。
4.3 跟踪行为与模型约束:为什么没有主键的表查询经常炸
原生 SQL 查询实体时,EF Core 依然会给实体做状态跟踪(如果 DbSet 的查询是默认跟踪行为)。跟踪要求实体必须有主键或唯一标识;如果查询返回的记录没有唯一标识,EF Core 就会自己造一个“shadow identity”来维持跟踪,并且文档明确提示:“如果没有唯一标识,跟踪行为可能导致返回重复的实体实例”。
这里说的“重复”是指查询结果里两条不同的记录,如果它们没有唯一键,EF Core 可能识别为同一个实体,导致只返回其中一条。这在查询视图、报表时尤其危险。避坑方法两种:一是给查询加上 AsNoTracking():
csharp复制var items = await _context.SomeView
.FromSql($"SELECT * FROM SomeView WHERE ...")
.AsNoTracking()
.ToListAsync();
二是直接改用 SqlQuery 系列,它天然是无跟踪的。我自己凡是查“非实体形状”或者“没有主键的表/视图”原生 SQL,都默认走 SqlQuery 而非 FromSql。这个小习惯避免了很多“数据少了几行”的玄学 bug。
5. 实操:一套高效稳定上生产的分层封装写法
5.1 我的选择:以 SqlQuery 为主、FromSql 为辅的方案
到这里我可以给你一个实战总结出的推荐方案,它在我参与过的几个中大型项目中都跑得很稳:
| 需求场景 | 推荐 API | 原因 |
|---|---|---|
| 对 DbSet 实体做原生查询,且要参与 Linq 组合、跟踪 | FromSql |
返回 IQueryable,能链接后续操作 |
| 只读展示复杂报表,不想跟踪、不想建许多 DbSet | Database.SqlQueryRaw<T>(EF Core 8+ 支持未映射类型更低) |
无跟踪,扁平 DTO 直接映射 |
| 取一个聚合值/单列 | SqlQueryScalar<T> |
开销最小,无实体映射 |
| 执行 update/delete/bulk 操作 | ExecuteSql |
参数化自动、简单粗暴 |
这套思路的内核是“按查询结果的形态选 API”。结果形态越接近实体,越靠 FromSql;越像报表或 DTO,越靠 SqlQueryRaw;结果只剩一个数,当然 SqlQueryScalar 最合适。不推荐的是把 ExecuteSql 当作日常 CURD 的替代品,它更适合有限的批量操作。
5.2 一组可以直接抄的仓储代码结构
我一般会把这类原生 SQL 查询收敛到一个 ISpecialReportRepository 之类的接口里,因为 EF Core 的 DbContext 已经够复杂了,不要再让一堆 SQL 字符串长在 Controller 里。
csharp复制public interface IOrderReportRepository
{
Task<IReadOnlyList<OrderSummaryDto>> GetDailySummariesAsync(
DateTime start, DateTime end, CancellationToken ct);
Task<decimal> GetTotalAmountAsync(
DateTime start, DateTime end, CancellationToken ct);
}
public sealed class OrderReportRepository(
AppDbContext dbContext) : IOrderReportRepository
{
public async Task<IReadOnlyList<OrderSummaryDto>> GetDailySummariesAsync(
DateTime start, DateTime end, CancellationToken ct)
{
return await dbContext.Database
.SqlQueryRaw<OrderSummaryDto>(
"""
SELECT CAST(CreatedAt AS DATE) AS StatDate,
COUNT(*) AS OrderCount,
SUM(Amount) AS TotalAmount
FROM Orders
WHERE CreatedAt >= {0} AND CreatedAt < {1}
GROUP BY CAST(CreatedAt AS DATE)
ORDER BY StatDate
""",
start, end)
.ToListAsync(ct);
}
public async Task<decimal> GetTotalAmountAsync(
DateTime start, DateTime end, CancellationToken ct)
{
var result = await dbContext.Database
.SqlQueryScalar<decimal>(
$"SELECT COALESCE(SUM(Amount), 0) FROM Orders WHERE CreatedAt >= {start} AND CreatedAt < {end}")
.FirstOrDefaultAsync(ct);
return result.Value;
}
}
注意 SqlQueryRaw 不支持 FormattableString 插值方式,需要使用参数占位符;但其实我更常用 SqlQuery 插值形式的变体来避免手滑写错占位符。EF Core 8+ 里 Database.SqlQuery<T> 存在吗?实际上命名为 SqlQueryRaw(string/params object[])和 SqlQuery(FormattableString)两种,在 EF Core 8 中对标量查询可用。这里为了表述一致,项目中建议统一用 SqlQuery(插值)或者显式参数化调用,兼顾安全和可读性。
如果你是特性敏感的人,建议查一下当前 EF Core 版本里 Database.SqlQuery() 与 SqlQueryRaw() 的准确签名,版本间命名有微调,我这边描述的使用逻辑不依赖版本号,但 API 名字请以官方文档为准。
5.3 参数化与查询计划复用的实战细节
能参数化的查询尽量参数化,这不是一句空话。在 SQL Server 上,如果 EF Core 生成的是参数化 SQL,执行计划能被复用,重复调用时省去编译开销;如果你把参数直接拼进字符串,每次 SQL 文本都不同,缓存直接失效,严重时会把计划缓存打爆。
这里特别提醒:FromSql 的插值写法会帮你自动参数化,而 FromSqlRaw 需要你自己传 params object[]。Database.SqlQueryRaw<T>(sql, params) 也一样,务必用占位符。千万别图省事用 $"SELECT ... WHERE X = {value}" 塞进去——这种写法被当成字符串拼接,EF Core 不负责参数化,等于拿裸字符串去执行,有注入风险。区分这两者的最简单方式是看 API 名字里带不带 Raw,带 Raw 的都对字符串内容不做解析,你得自己保证安全。
csharp复制// 危险写法(绝对不要学)
var data = await _repo.Database
.SqlQueryRaw<Dto>($"SELECT ... WHERE Id = {inputId}")
.ToListAsync();
// 正确写法之一
var data = await _repo.Database
.SqlQueryRaw<Dto>(
"SELECT ... WHERE Id = {0}", inputId)
.ToListAsync();
// 更省心的写法
var data = await _repo.Database
.SqlQuery<Dto>(
$"SELECT ... WHERE Id = {inputId}")
.ToListAsync();
我实际经验里还遇到过一个不太起眼但影响很大的问题:PostgreSQL 的 timestamp 和 timestamptz 混用。你传 C# 的 DateTime 参数时,EF Core 默认映射成数据库什么类型,要看参数对象的 Kind 属性。DateTimeKind.Local 和 Utc 传输的结果可能差 8 小时。为了避免这类时间 bug,我强制团队在仓储层入口把时间都转成 UTC,并且 SQL 里直接写明期望的时区语义(AT TIME ZONE 之类),防止不同开发者的本地环境和数据库之间的隐性耦合。
6. 踩坑清单:这些问题比报错更难排查
6.1 常见异常快速对照
我把这些年从各种异常信息里爬出来的经验整理成一张表,遇到问题时能少花半天:
| 现象 | 可能的根因 | 解决手段 |
|---|---|---|
| 执行时报“InvalidOperationException: The required column 'xxx' was not present in the results” | 查询列少了实体某个映射属性 | 把缺少的列加进去,或改用 DTO 查询 |
| 返回行数比实际少,且查询没报错 | 无唯一键、默认跟踪导致实体被合并 | 加 AsNoTracking() 或者用 SqlQuery 系列 |
| “FromSql 只能用于实体类型” | 返回类型不是 DbSet 中的实体类型 | 换成 Database.SqlQueryRaw |
| 列别名大小写问题导致的映射失败 | 数据驱动返回列名和属性名大小写不匹配 | SELECT 列统一显式别名,与 C# 属性完全一致 |
| EF Core 9 仍报“未映射类型”相关错误 | 类型带了不能被 SQL 映射的属性 | 检查是否有导航属性、构造函数是否带参 |
| “除非指定 FOR UPDATE/OFFSET,否则 ORDER BY 无效” | FromSql 内部 SQL 带 order by 又被外层 Linq 分页 | SQL 内不带 OrderBy,或所有排序都交给 SQL |
| SqlQueryScalar 泛型传了复杂类型 | 单列查询不能映射成实体 | 改查标量类型,或改用 SqlQuery |
| ExecuteSql 后 DbContext 里实体状态还是旧值 | SQL 旁路了上下文追踪 | 要么重新查询,要么清空 Local,别指望自动同步 |
这张表看着简单,但每一项背后都对应着真实事故。比如返回行数变少那条,我见过一个同事排查了整整一天,最后发现是因为视图没有唯一键,EF Core 跟踪时把多行 ID 相同的判定成了同一行。顺带说一句,EF Core 8 之后针对“非键实体查询”有更清晰的报错提示,但还是建议在模型配置里对这类 View 实体加上 HasNoKey() 并指定无跟踪查询,从源头堵住。
6.2 从设计上规避“映射边界”问题的几条原则
如果让我总结一套“不会频繁踩坑”的编码纪律,我总结成四条:
第一条:面向 DTO 查询,不要面向实体查询。 实体是给 DDD / 业务逻辑用的,报表和原生 SQL 的场景一律面向扁平 DTO。这样你根本不用处理“缺少实体列”的问题,因为 DTO 上只放你要的字段,查询结果列天然匹配。
第二条:SQL 的列名永远显式别名,并且别名等于 DTO 属性名。 不要依赖 SELECT * 之后靠属性名字碰运气,也不要写 AS xxx 和属性名不一致的别名。书写代价极小,排查代价极大,这笔账值得算。
第三条:原生 SQL 全部收敛在仓储层。 不要在 Controller、Application Service、甚至 DbContext 子类里散落 SQL 字符串。统一收敛后,排查、试用、切换数据库都会容易很多。而且如果要接数据库健康检查或 SQL 审计,集中的地方好埋点。
第四条:从 EF Core 7 开始项目中尽量只使用一套 FromSql+SqlQuery API。 避免 FromSqlRaw / FromSqlInterpolated / SqlQueryRaw 混写。版本迭代快,文档更新跟不上是常态,选一套主 API 并及时跟进升级通知,比什么都学一点强得多。
这四条是我在多次事故中沉淀下来的,并非从教科书上看的理论。尤其第一条,从实体查询切到 DTO 查询之后,“缺少列”的问题几乎再没遇到过。
6.3 一个容易被忽略的坑:数据库权限与只读账号下的“映射”
原生 SQL 查询还有一个被低估的问题:权限模型。很多时候你只有只读账号去跑报表,但 FromSql 产生的实体查询在默认情况下会带跟踪,某些更新操作会先做 SELECT 确认版本;如果你的查询 SQL 本身没问题,但数据库账号没有某列的 SELECT 权限,结果集里少了列,实体映射阶段照样炸。这类问题在生产环境才会暴露,因为测试库权限通常给得很大。解决方法依然是无跟踪 + DTO,或者查询列时显式限定权限范围内的列。
另外,如果你的 SQL Server 部署在高安全环境下,VIEW ANY COLUMN ENCRYPTION KEY DEFINITION 权限缺失会让查询直接返回加密列的密文而不是明文。这种事和对象映射本身无关,但排查起来你会一度怀疑 EF Core 映射逻辑出了问题。我只能说:遇到诡异映射结果,先验证数据库权限和数据本身,再怀疑 EF Core。
7. 从 FromSql 到 SqlQuery 的选型决策:一张决策图说明白
我不太喜欢画流程图,但这里可以用文字帮你画一条判断路径。当你决定对某条查询使用原生 SQL 时,先问自己三个问题:
第一个问题:我查出来的数据最终要绑定到实体上吗? 如果答案是“是,全部字段都是实体字段,并且要接着用 LINQ 组合”,走 FromSql。如果“否,只是拿几个字段做展示或者报表”,跳第二个问题。
第二个问题:结果是想要单个值还是需要一个列表? 单值走 SqlQueryScalar;需要列表但目标类型不是实体,则看你的 EF Core 版本是否支持未映射类型。EF Core 9 及以上支持得很顺手,EF Core 8 里需要一定 HACK 或用匿名类型的变通方案,EF Core 8 以下基本没有太好的办法,只能先查询实体再手动投影。
第三个问题:这条 SQL 会被复用吗? 如果只是某个页面局部一次性调用,怎么方便怎么来;如果要被多个接口、多个仓储复用,务必把 SQL 抽离成常量或独立查询对象,避免字符串复制三份后维护时改漏。
这个决策路径看起来像套流程,但好处是能培养团队在写代码前先想清楚“我到底要用 EF Core 的哪一层能力”,而不是一拍脑袋直接上 FromSqlRaw。
8. 实测记录:同一个报表,三种写法的性能差异
为了不让文章停留在理论上,我把前阵子一个报表接口的真实数据放出来。环境是 SQL Server 2019,数据量大概有 1200 万行订单明细,目标查询是“统计过去 30 天每日订单总数和总金额,按渠道分类”。
第一种写法:纯 LINQ,把 Orders 和 Channels Join 起来,再按日期和渠道分组。EF Core 生成的 SQL 大概有 3 层嵌套子查询,加了 COUNT、SUM 之外还有一个 DISTINCT 去重,平均执行时间约 620 ms。第一次冷查询 1100 ms,参数变化后计划缓存还能复用,所以热查询比例还算稳定。
第二种写法:用 FromSql 先把日期范围限定死,再在外面接 LINQ 的 GroupBy。SQL Server 实测还是被 EF 包装了一层,变成“从内层派生表做 GROUP BY”,执行时间约 480 ms。性能提升了,但没有特别离谱。
第三种写法:直接用 SqlQueryRaw<DailyChannelSummaryDto>,SQL 自己写清楚 Group By 和排序,连 OrderBy 都放在 SQL 里。数据库拿到了最终形态的 SQL,执行时间约 180 ms,接口整体从 700 ms 降到 260 ms。
| 写法 | 执行耗时 | 备注 |
|---|---|---|
| 纯 LINQ | 约 620 ms | 嵌套子查询多 |
| FromSql + LINQ 组合 | 约 480 ms | 仍有外层包装 |
| SqlQueryRaw + 纯手写 SQL | 约 180 ms | 最终 SQL 直接执行 |
这个数字不代表 FromSql 不行,而是证明了一个常识:你把查询的哪一步交给数据库优化器,最终 SQL 的复杂度直接影响执行计划。EF 的查询管线再强,也不如手写 SQL 收敛。当你已经清楚最优 SQL 长什么样时,就别再试图用 EF Core 去“翻译”出一个近似的形态了,直接把最优形态喂给它。
我在这轮实测里还发现一个有意思的细节:SqlQueryRaw 虽然执行 SQL 文本完全相同,但第一次调用之后的执行计划也被缓存了。SQL Server 层面的计划缓存不受 EF Core 是否参数化影响,数据库自己会做参数化处理。所以只要不是每次拼接出不同的 SQL 文本,性能差距主要在应用到数据库的网络往返和结果集映射开销上,整体可控。
9. 给团队的代码约定与 Review 检查清单
如果你们团队决定放开 EF Core 原生 SQL 的使用权限,我建议把以下几条作为 PR Review 的必查项。它能拦住大多数常见的低级问题,保证大家不会从“LINQ 不够用,上一段 SQL”退化成“到处都是 SQL 字符串”。
第一:Raw 方法中是否有字符串插值。 看到 FromSqlRaw($"...") 或 SqlQueryRaw<T>($"...") 直接打回,要求改成 params object[] + 占位符,或者用不带 Raw 的插值版本。
第二:SQL 文本是否超出 3 行且没有换行缩进。 长 SQL 在字符串里挤成一坨,不是不能跑,是没法维护。用 C# 11 的 raw string literal """ 来保持可读性,这个语法在 .NET 7 之后非常好用,SQL 里的引号也不容易出错。
第三:最终查询结果映射的是 DTO 还是 DbSet 实体。 如果是实体,Review 时额外确认 SQL 列是否覆盖实体的全部必填列。如果是 DTO,确认类上没有导航属性。
第四:调用 SQL 的地方有没有 Repository 封装。 我从来不建议在 Controller 里看到 Database 属性被直接调用。除非你的项目小到不需要分层,否则这种调用一律拆出去。
第五:执行的 SQL 是否在 Review 前由开发者自己用数据库工具跑过。 这种“开发环境能跑,生产环境语法报错”的事我遇到太多次了。EF Core 的 SQL 提供器和 SQL Server Management Studio 的语法解析差异不算大,但 Oracle 和 PostgreSQL 上各种方言陷阱不少。这个习惯能挡掉一半问题。
这个 Review 清单不是用来卡人的,而是希望团队整体少交点学费。毕竟原生 SQL 的灵活性是双刃剑,用得好效率翻倍,用不好就是给未来埋定时炸弹。
我个人在后来的项目中开始把 EF Core 原生 SQL 当作“高级定制工具”,而不是“日常 Dapper 替代品”。它在 EF Core 生态内的定位就是专门处理那些 LINQ 表达不了、或者表达出来性能不达标的查询。与其纠结 API 名字、版本变化,不如先把映射边界想清楚:实体的走 FromSql,DTO 的走 SqlQuery,标量的走 SqlQueryScalar,命令型的走 ExecuteSql。按这条主线走,你会发现边界内自由得很,边界外不碰也不亏。
最后再分享一个小技巧:如果你经常在 EF Core 里跑报表 SQL,可以给 DbContext 增加一个只读的 QueryView 入口——用模型配置把数据库视图映射成无键实体,再由视图实体接 FromSql 的原生查询,既保留了 EF Core 的元数据能力(列名映射、SQL 生成校验),又规避了原生 SQL 的很多拼接问题。这个模式我用了很久,对大多数报表类的查询需求来说,算是效率和可维护性平衡得最好的方案了。
