☰
EF Core慢查询排查实战:TagWith、链路追踪与执行计划三件套
2026/10/16 7:40:22 网站建设 项目流程

总有些下午,群里毫无征兆地响起来:运营同事截了张图,说客户工单列表打开要转三秒半,然后补了一句“这个页面以前明明挺快的”。我第一反应不是去改 EF Core 的 LINQ 写法,而是先问自己一个问题——这条慢查询到底是哪段代码发出去的?

做过 EF Core 慢查询排查的人应该都懂这种挫败感:数据库监控里明明能看到一条跑了 2.8 秒的 SELECT,但它没有名字、没有来路,你翻遍整个代码库也未必能认出来。更麻烦的是,就算你找到了一条疑似 SQL,它和你写的 LINQ 之间还隔着一层翻译,你没法直接看代码断言“这就是瓶颈”。

后来我把排查思路收敛成一套固定组合拳:TagWith 给 SQL 打标签,OpenTelemetry 把命令挂进调用链,执行计划负责揭示物理层面的真实代价。这套流程跑通之后,定位一次 EF Core 慢查询通常用不了半小时。这篇文章就把这套打法完整拆给你,包括每一步为什么这么做、执行计划到底看什么、以及几个我在实盘里踩过的坑。

1. 慢查询排查为什么总是卡在“这段SQL到底是谁发的”

先说一个容易被低估的问题:EF Core 在你写ToListAsync()的时候才把 LINQ 表达式翻译成 SQL 发出去,日常开发中你几乎不会直视最终生成的语句。黑盒一旦出问题,排查就变成了“猜谜游戏”。

1.1 传统排查方式的两大痛点

最常见的做法是开数据库层面的监控,比如 SQL Server 的扩展事件或者第三方 DBA 工具。它们能精确告诉你某条语句跑了多久、逻辑读多少,但给不出业务上下文。你看到的是一段冷冰冰的参数化 SQL:

SELECT [w].[Id], [w].[CustomerId], [w].[Status], [w].[CreatedAt] FROM [WorkOrders] AS [w] WHERE ([w].[CustomerId] = @__customerId_0) AND ([w].[Status] = @__status_1) ORDER BY [w].[CreatedAt] DESC OFFSET @__p_2 ROWS FETCH NEXT @__p_3 ROWS ONLY

你根本不知道这是哪个页面、哪个接口、哪个用户操作触发的。更扎心的是,同一段 SQL 可能被十几个接口复用,你无法判断线上那条慢的是哪一个入口。

另一种做法是在业务代码里到处加日志,把每个查询的 SQL 先打印出来。这个方法的问题在于:你需要先知道大概哪个模块慢,才能决定在哪儿打日志。而且“复现”本身就很难,参数不同、统计信息不同、并发不同,慢与不慢完全是两回事。等你打出日志,往往已经过去一整天了。

1.2 一套可以复用的三段式定位思路

我后来想明白了一件事:慢查询排查其实要回答三个独立的问题——

  1. 慢在哪一层:是 HTTP 层慢、应用代码慢,还是数据库命令慢?
  2. 慢的是哪段代码:这条 SQL 对应业务里的哪个查询逻辑?
  3. 为什么慢:是缺索引、扫描了太多行、还是执行计划本身选错了?

这三个问题分别对应三个工具:OpenTelemetry回答第 1 个,TagWith回答第 2 个,执行计划回答第 3 个。只要按顺序走完这三步,半小时内基本能锁定根因。下面逐个展开。

2. TagWith:先给SQL发张“身份证”,再谈定位

EF Core 2.2 开始提供了一个常年被忽视的小功能TagWith()。它做的事情很简单:往生成的 SQL 前面塞一段注释。但就是这么一个小注释,能在排查时省掉大量猜代码的时间。

2.1 TagWith怎么用

用法几乎没有任何学习成本:

var list = await _context.WorkOrders .TagWith("CustomerWorkOrders_GetPageByCustomer") .Where(w => w.CustomerId == customerId && w.Status == status) .OrderByDescending(w => w.CreatedAt) .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync();

执行时生成的 SQL 会变成这样:

/* CustomerWorkOrders_GetPageByCustomer */ SELECT [w].[Id], [w].[CustomerId], [w].[Status], [w].[CreatedAt] FROM [WorkOrders] AS [w] WHERE ([w].[CustomerId] = @__customerId_0) AND ([w].[Status] = @__status_1) ORDER BY [w].[CreatedAt] DESC OFFSET @__p_2 ROWS FETCH NEXT @__p_3 ROWS ONLY

注意第一行的注释。有了它,你在数据库监控里、在 OpenTelemetry 的db.statement字段里、在执行计划里,都能一眼认出这段 SQL 的“身份证号”。

多个TagWith()调用会生成多行注释,所以你可以分层打标签:先打一层业务模块名,再打一层具体场景名,最后加上关键入参标识。多行注释在日志里也很好辨认。

2.2 TagWithCallSite:连代码行号一起带上

如果你不想手动维护标签名,EF Core 7 还提供了TagWithCallSite()。它会在标签里自动附上调用点的代码文件、成员名和行号:

var query = _context.WorkOrders .TagWith("CustomerWorkOrders") .TagWithCallSite() .Where(w => w.CustomerId == customerId);

生成的 SQL 注释会类似:

/* CustomerWorkOrders */ /* File: Services/CustomerWorkOrderService.cs Line: 87 */ SELECT ...

这对我这种经常忘写标签的人属于救命功能。团队里如果有人不愿意维护标签,直接用TagWithCallSite()兜底,至少能定位到文件与行号。

注意一点:TagWith()只对查询(IQueryable<T>)有效,ExecuteUpdate/ExecuteDelete这类命令不适用。另外,有些数据库方言对注释的长度或格式有限制,标签尽量控制在 50 个字符以内,别把整段业务描述塞进去。

2.3 标签规范:别让身份证变成摆设

光有标签还不够,标签命名本身就是一门小学问。我见过有人写TagWith("query1"),也有人写TagWith("这里慢"),这跟没写没有什么区别。

我目前比较推荐的结构是“业务模块 + 接口用途 + 版本或场景”:

  • OrderAPI_GetPageByCustomer_ListV1
  • WorkOrder_ExportReport_SpV2
  • Report_Dashboard_Top10Customer

标签的价值主要体现在后续检索:线上出了慢查询,你复制这段注释到代码库里一搜,立刻跳到对应方法。所以宁可多花十秒钟想一个准确的名字,也别图快随手敲一个。

3. OpenTelemetry:把数据库命令挂进完整请求链路

TagWith 解决了“这段 SQL 是谁的”,但光有标签还不够。线上高并发场景里,一条慢 SQL 可能是某个特定请求触发的,你以为你找到了代码,却不知道是哪个入参、哪条链路把它带出来的。这时候就需要链路追踪。

3.1 在.NET 8里接入EF Core的可观测性

当前 .NET 生态里做可观测性最主流的选择就是 OpenTelemetry。它不只是给 HTTP 调用打点,还能通过官方插桩把数据库命令也变成一个个 span。接入步骤不复杂:

先引入几个 NuGet 包:

<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.10.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.9" />

然后在服务注册里打开追踪:

builder.Services.AddOpenTelemetry() .WithTracing(tracing => { tracing.AddAspNetCoreInstrumentation(); tracing.AddEntityFrameworkCoreInstrumentation(options => { // 记录命令文本,否则看不到具体 SQL options.SetDbStatementForText = true; options.EnableConnectionLevelAttributes = true; }); tracing.AddConsoleExporter(); // 生产环境换成 OTLP Exporter,对接可观测平台 });

几个配置项的含义值得说一下:

  • SetDbStatementForText = true:默认情况下,EF Core 插桩不会把 SQL 文本放进 span,原因很现实——SQL 里可能藏着用户身份证、手机号这类敏感信息。但排查慢查询时没有 SQL 文本又寸步难行,所以需要显式打开,并在后面做好脱敏。
  • EnableConnectionLevelAttributes = true:开启后,连接串信息会放到 span 的属性里,而不是作为 span 名称的一部分,避免一堆高基数属性把链路平台搞卡。

3.2 从trace里怎么读“谁在慢”

接入之后,一次完整请求在链路平台里大体是这样一串 span:

  • 最外层是GET /api/customers/{id}/workorders的 HTTP span;
  • 下面挂着一层 ASP.NET Core 中间件产生的 span;
  • 再往下是一个WorkOrders的 EF Core span,属性里带着db.system=sqlserver、db.statement等字段。

真正排查时,我一般直接看时间占比:HTTP span 耗时 3.1 秒,EF Core span 耗时 2.9 秒,那瓶颈几乎不用怀疑,就在数据库命令这一层。点开这个 span,看db.statement字段:

/* CustomerWorkOrders_GetPageByCustomer */ SELECT [w].[Id], [w].[CustomerId], ...

因为上一步给查询打了 TagWith,SQL 文本一出来我就知道是哪个方法。链路负责告诉你“卡在哪一层、是哪个请求”,TagWith 负责告诉你“是哪一行代码”,两个信息一拼接,定位范围立刻缩小到具体方法。

3.3 生产落地注意点

接入 OpenTelemetry 虽然是标准操作,但有几个细节在正式环境经常被忽略。

第一,带着参数值的 SQL 是双刃剑。db.statement默认是参数化之后的文本,参数值不会直接拼进 SQL 里。如果插桩配置把参数展开记录,建议在 Exporter 层做一层过滤,把敏感字段剔除再送出去。你可以写一个自定义 Processor,解析并改写 span 的属性,这条路径很多人都用过。

第二,全量采样在高峰期会带来可观的存储成本。我不会一刀切关采样,而是把对慢请求的采样率设高、对快请求设低。OpenTelemetry 的采样器支持基于父 span 或错误状态做决策,例如保留所有 4xx/5xx 和耗时超过阈值的请求,其余按 10% 采样。这套比例要根据自己业务的 QPS 调,没有标准答案。

4. 读懂执行计划:不是看成本数值,而是看扫描、查找和行数偏差

链路和标签帮你锁定了“哪条 SQL 慢”,但还没回答“为什么慢”。下一站必须看执行计划——它是 SQL Server 在自己统计信息基础上做出的执行策略“体检报告”。

4.1 获取执行计划的三种方式

用 SSMS 连接目标环境后,最直接的操作是“包含实际执行计划”(Ctrl+M),然后执行查询。跑完后在结果面板里多出一个“执行计划”标签页。

如果你更喜欢命令行或者要集成到排查脚本里,可以这样:

SET STATISTICS TIME ON; SET STATISTICS IO ON; SET STATISTICS XML ON; GO -- 粘贴从链路里拷贝出来的 SQL,把参数换成代表性常量 SELECT ... GO SET STATISTICS XML OFF;

SET STATISTICS XML ON会返回一个 XML 格式的实际执行计划,便于保存和对比。SET STATISTICS IO、SET STATISTICS TIME输出的逻辑读、物理读、CPU 耗时则是优化前后对比的关键指标。

4.2 第一眼先看这四个信号

拿到执行计划后,我不会去看那个“预估子树成本”,数字再小也可能骗人。我更关注四个信号:

信号含义大概率的性能隐患
表扫描 / 聚集索引扫描优化器没有合适的索引可用,只能翻整张表缺索引、谓词不 SARG
Key Lookup / RID Lookup非聚集索引命中了条件,但还要回聚簇索引取其他列索引覆盖不足,回表次数多
预估行数 vs 实际行数数值差距一个数量级以上统计信息过期、参数嗅探
绿色缺失索引提示SQL Server 根据这次查询跳出来给的“建议”索引设计确实有缺口,需要人工复核

最容易被忽视的是第二个:Key Lookup。很多人看到执行计划里有 Index Seek 就以为万事大吉,其实如果这个 Seek 命中了 1 万行,再对 1 万行做 Key Lookup,性能一样很糟糕。回表不是免费的,每一次都是随机 IO。

预估行数和实际行数相差大同样关键。优化器是基于统计信息猜行数的,猜错了就会选错策略。一个典型场景是统计信息过期:表从 10 万行涨到了 200 万行,SQL Server 不知道,于是估出了一个杯水车薪的行数,然后选了一个自认为“划算”的做法。

4.3 SARG与隐式转换

执行计划里的警告符号也值得逐个过一遍。最常见的两类问题:

一类是谓词不 SARG。比如你把筛选条件写成WHERE YEAR(CreatedAt) = 2024,列被函数包住了,索引 Seek 直接失效,SQL Server 只能把所有行的 CreatedAt 都取出来算一遍。改成WHERE CreatedAt >= '2024-01-01' AND CreatedAt < '2025-01-01'才能走到索引。

另一类是隐式转换。比如表里的一列是varchar(20),LINQ 里用的是 string 类型,EF Core 默认生成的参数类型是nvarchar,SQL Server 在比较时要把varchar列转换成nvarchar,转化之后同一列上的索引就没法被正常 Seek 了。执行计划里通常会出现类似CONVERT_IMPLICIT的警告图标。

这类问题不建议在 SQL 层面硬解,回到 EF Core 模型层去统一列类型更干净。比如把列统一成nvarchar,或者把数据库列类型明确配置成varchar,让两端对齐。

5. 30分钟实战复盘:从链路告警到复合索引落地

前面讲的是方法论,下面用一个我在实际项目中帮团队排查过的“客户工单列表”案例,完整走一遍这套流程。为了方便说明,这个项目叫客户工单管理系统,数据库是 SQL Server,ORM 是 EF Core 8。

5.1 现象

运营反馈:后台“客户工单列表”页面打开很慢,按客户筛选尤其明显,体感三秒以上。当时接口的 P99 已经从前一天的 400ms 涨到了 3.1s。页面逻辑不复杂:按客户 ID + 工单状态筛选,按创建时间倒序分页。

5.2 五分钟内锁定慢在哪一层

打开链路平台,找到这个接口对应的 trace。一眼看到的分布是:

  • HTTP span:3.1 秒
  • EF Core 的数据库 span:2.9 秒
  • 应用内部其他逻辑:约 200ms

瓶颈直接指向数据库命令。点开数据库 span,db.statement文本开头赫然是:

/* OrderAPI_GetPageByCustomer_ListV1 */ SELECT ...

因为查询在写的时候打了 TagWith,海量 SQL 日志里没有任何纠结,直接定位到了OrderAPI项目里GetPageByCustomer这个方法。到这里只用了大概五分钟。

5.3 执行计划里真正致命的不是扫描

把这段 SQL 拷贝到 SSMS,打开“包含实际执行计划”后执行。肉眼扫了一遍,几个现象:

  • 工单表 86 万行,查询用到了现有索引IX_WorkOrders_Status(只有 Status 一列)。
  • 状态筛选命中约 12800 行,之后对每一行做Key Lookup回表取其他列。
  • 因为最终要按 CreatedAt 倒序分页,这些回表后的行还要进入一个 Sort 算子,内存排序又吃掉了不少 CPU。
  • 逻辑读约 18000 多次,耗时 2.9 秒。
  • 页面恰好有个 Missing Index 绿色提示,建议在 Status 基础上增加 CreatedAt 等列。

这里很容易得出一个“错误结论”:是不是把 Status 索引加上 CreatedAt 就够了?其实不然。业务查询里第一个筛选条件是 CustomerId,选择性很强,单加 Status 相关索引并不能让优化器在“按客户筛选”的场景里走到最优路径。我当时犹豫了一下,最后还是按完整业务路径设计索引。

5.4 索引调整

最终选择新建复合索引:

CREATE NONCLUSTERED INDEX IX_WorkOrders_CustomerId_Status_CreatedAt ON dbo.WorkOrders (CustomerId, Status, CreatedAt DESC) INCLUDE (Title, AssigneeId, Priority);

为什么这样设计?因为查询等价于“按客户 + 状态过滤,再按创建时间倒序排序并分页”。索引键顺序把等值条件(CustomerId、Status)放前面,排序字段(CreatedAt)放后面,顺序和查询完全对上,SQL Server 就能用一次 Index Seek 拿到数据,不需要回表,也不需要在内存里额外排序。

CreatedAt DESC的方向很关键。排序方向与索引顺序不一致时依然可能触发排序算子,所以在 DDL 里就写好方向,比之后靠优化器硬撑稳妥。

5.5 复测结果

索引创建完成后,重新跑同一段 SQL,指标对比非常明显:

指标优化前优化后
查询耗时2.9 秒约 95ms
逻辑读1824648
关键算子Clustered Index Scan + 12800 次 Key LookupIndex Seek,无回表
排序算子内存排序消除

全链路 HTTP 接口从 3.1 秒降到 120ms 左右。从打开链路平台到索引落地复测,总时长没有超过 30 分钟。

6. 排查完别忘了这些容易被跳过的收尾动作

把索引建完、接口恢复之后,工作其实只完成了一半。下面几个收尾动作是很多人在实战里容易跳过的,但恰恰是决定下一次排查快慢的关键。

6.1 执行计划要看“实际”而不是“估计”

我看过不少同事拿着“估计执行计划”在分析,得出的结论经常偏离事实。估计执行计划是优化器在真正执行之前拍脑袋算出来的,它反映的是“优化器以为会发生什么”。而实际执行计划才是“到底发生了什么”。

特别是在参数嗅探场景下,两者可能展现出完全不同的算子——估计计划说用 Index Seek,实际执行却走了 Scan。所以排查时务必确认打开的是“包含实际执行计划”,或者用SET STATISTICS XML ON抓实际计划,别用那个带“估计”字样的。

6.2 索引建议需要人工二次判断

SQL Server 的 Missing Index 提示是个很好的“探针”,但不能照着建。它通常只基于单次查询给出建议,不会考虑这张表上其他二三十个查询的形态。无脑建索引的结果往往是索引泛滥,写操作被拖慢,甚至出现一个表上堆了十几个索引的奇观。

我一般会对索引建议做三轮筛选:

  1. 这个建议覆盖的列是否和真实查询完全匹配?
  2. 建了它会不会与其他高频查询的索引冲突或重复?
  3. 这张表的写入频率有多高?收益能不能覆盖成本?

回归到案例里,SQL Server 原本提示的是 Status + CreatedAt 索引,而我最终建的是 CustomerId + Status + CreatedAt 索引。后者更贴合真实业务条件,这就是人工判断的价值。

6.3 让排查经验沉淀成团队习惯

一次慢查询修完了,如果只是把索引建了就散场,类似问题大概率会在另一个接口上复现。我会建议团队把三件事固化下来:

  • 关键查询一律打上 TagWith 标签,标签规范写入代码评审检查单。这是十万火急时最快检索 SQL 来源的途径。
  • OpenTelemetry 的链路追踪保持常态化开启,不做“出事了才开”的临时方案。链路数据要有留存周期,不然出事时查不到历史 trace。
  • 定期拉取 Top 慢 SQL 并归档执行计划。不需要天天看,但每两周复盘一次,把行数估计偏差、新出现的 Key Lookup、索引使用情况做一次对比。

至于监控告警,我不会把阈值设得太激进。先按当前 P99 的两倍作为慢查询报警线,等团队对基线有感觉之后再慢慢收紧。慢查询排查拼的不是某个炫酷工具,而是把“谁发的、在哪一层、为什么慢”这三件事高效地串起来。

最后再说一个自己的体会:我现在写 EF Core 查询,已经不太会先纠结 LINQ 怎么写更“优雅”了,反而会先在脑子里过一遍——这条查询最终会变成什么样的 SQL,它会访问哪个索引,会不会排序,分页会不会吃掉大量内存。带着这种意识去写代码,很多慢查询根本到不了线上,在代码评审阶段就已经被改掉了。TagWith、OpenTelemetry、执行计划这三件套,说到底都是在帮你建立这种“从 SQL 视角反推代码设计”的习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询