简介:对正在排查 SQL Server 慢查询的开发者和 DBA 来说,执行计划中索引查找(Index Seek)意外退化为索引扫描(Index Scan),往往是性能下降的关键信号。这份 PDF 围绕这一现象,结合具体测试场景与执行计划示例,归纳了隐式转换、非 SARG 谓词、统计信息不准确、连接与排序操作、索引覆盖不足、索引碎片、参数嗅探等常见诱因,并在每类场景后说明判断要点与规避方法,如统一数据类型、使用显式转换、更新统计信息、创建覆盖索引和定期维护索引。文中还给出从缓存执行计划中搜索隐式转换 SQL 的脚本,可直接用于日常巡检和代码评审。压缩包共 1 个 PDF 文档,大小约 415KB;已有 331 人学习下载。对需要理解执行计划机制并快速定位低效查询的读者,这是一份实用的问题分析笔记。
1. 索引查找变成索引扫描:一条让 SQL Server 调优者警觉的执行计划信号
在 SQL Server 上做索引查找变成索引扫描的问题分析,是很多慢查询调优故事的第一幕。明明表上有索引、统计信息也新鲜,执行计划里却清一色 Index Scan,这个反常信号意味着查询写法、参数形态或索引设计里藏着一处隐蔽问题。这篇博客想讲清楚的是:Seek 退化到 Scan 的根本原因有哪几类,如何定位,以及怎样用最少操作让索引重新被高效利用。它适合一线 DBA、大数据量下的后端开发者,以及一切被“有索引却跑不快”反复折磨的从业者。目标是让你看完后能顺着排查路径,自己动手复现并解决同类问题。
2. 读懂 Seek 与 Scan 的成本分岔:优化器凭什么放弃索引查找
2.1 Seek 是定位,Scan 是遍历:先认清两种算子的行为差异
Index Seek 不是“用了索引”,Index Scan 也不是“没走索引”。两者的本质差异在于导航方式:Seek 从索引根节点一路下探到匹配的叶子页,只返回满足条件的局部数据;Scan 则遍历整棵索引树的叶子层,把全部或大部分数据过一遍。执行计划里看到 Index Scan 时,如果表上确实有可用索引,多半是优化器做了成本比较后认为 Scan 更便宜,或者查询条件根本没法被表达成可用的定位条件。
在 SSMS 中按 Ctrl+M 开启“包含实际执行计划”,跑完查询就能看到算子图标。Seek 算子旁的“谓词”属性会显示定位条件,Scan 算子则只有“扫描谓词”或没有谓词。还有一个容易被忽略的细节是“残余谓词”,它的存在意味着 Seek 虽然下探到了索引页,但索引本身没有完整记录该条件,还需要在定位后的每一行上再过滤一次,这种半吊子 Seek 的性能往往比直接 Scan 好不到哪去。
有不少人把非聚集索引 Scan 误认为“至少走了索引”。实际上,非聚集索引 Scan 同样会把所有叶子页读一遍,如果查询列没有被索引覆盖,每次命中一行还要做一次回表,成本甚至高于连续读聚集索引的表扫描。遇到这类计划,先看索引的键列和 INCLUDE 列有没有覆盖查询列,再谈 Seek 退化的问题。
2.2 成本估算的临界点:预估行数如何决定走向
优化器选择 Seek 还是 Scan,说到底是一次成本估算。当预估返回行数占表总行数的比例上升时,Seek 的优势就衰减,因为 Seek 意味着大量单页随机 I/O,而 Scan 是顺序 I/O。两者交织处大约在总行数的 20% 到 30% 附近,具体取决于页数、缓冲池命中率和索引深度。这个临界点不是固定值,而是优化器根据统计信息算出来的一个分界。
影响这个估算的核心变量是统计信息里的密度和直方图。优化器不是真实执行,它靠统计信息猜行数。如果统计信息过期,预估就失真,临界点会被错误地跨过,计划里就会看到 Scan。这也是为什么做完大表更新后,DBA 的第一反应是更新统计信息,而不是重建索引——只有先让优化器“看见”真实数据分布,Seek 才有机会被纳入考虑。
决定分界点的还有两个隐藏变量:索引深度和每个页能装多少行。索引深度决定了每次 Seek 的定位代价,深度 4 的索引比深度 2 的索引贵;页密度高则 Scan 一次读到的有效行更多。这就是为什么同样 20% 的返回比例,在深度 3、页密度高的表上优化器可能选 Seek,在深度 5、页密度低的表上却选 Scan。你不需要背公式,只要知道 Seek 和 Scan 不是二选一的宝座,而是一条滑动的成本曲线。
来看一段快速查看成本评估的 SQL,用 SHOWPLAN 文本模式输出计划,不会真正执行查询,但是排查 Seek 退化时很好用:
SET SHOWPLAN_ALL ON; GO SELECT SalesOrderID, OrderDate FROM Sales.SalesOrderDetail WHERE ProductID = 897; GO SET SHOWPLAN_ALL OFF; GO这段 SQL 的作用是把执行计划以文本形式打印出来。看输出里的“Estimated Number of Rows”和“Estimated Operator Cost”两列,这是理解 Seek/Scan 走向的两把钥匙。如果预估行数和实际行数偏差一个数量级,先别急着改索引,回去查统计信息。参数说明:SHOWPLAN_ALL 是会话级设置,用完必须关掉,否则后续所有查询都只出计划不执行。
2.3 查询参数化的副作用:常量消失后优化器开始盲猜
很多业务系统把 SQL 写成存储过程参数化查询,好处是计划重用,坏处是优化器在编译时拿不到参数的实际值,只能用统计信息里的密度估算选择性。一个典型场景:WHERE Status = @Status,一个常量的选择性可能是 1%,适合 Seek;另一个常量的选择性是 60%,适合 Scan。参数化后优化器只能按平均密度猜,最终选了一个中庸计划。
这个“中庸”往往表现为 Scan,因为它对单次执行都不算最优,但由于计划被缓存,它成了所有参数共享的唯一方案。这种情况的常规解法有 OPTION(RECOMPILE)、OPTION(OPTIMIZE FOR UNKNOWN),以及把高频参数拆成独立查询。这些会在第四章展开细说,这里先把机制点透:Seek 变 Scan 不一定是查询写坏了,有时是参数化让索引失去了被精准利用的外部信息。
3. 非 SARG 写法:哪些查询条件把索引查找活活逼成索引扫描
SARGable,即 Search Argument Able,指的是查询条件能否借助索引快速定位。不能 SARG 的写法通常让索引列被函数、运算符或类型转换包裹,优化器无法把条件折叠成 Seek 的起点和终点,只能退而求其次走 Scan。这一章列的三种写法是生产环境里最常见的翻车来源。
3.1 对索引列做函数运算:YEAR(OrderDate) 的连环代价
先看一个我反复见到的现场。报表中心跑月初统计,一条 SQL 把三千万行的订单表扫了个遍:
-- 翻车写法:索引列被 YEAR() 包裹 SELECT OrderID, CustomerID, TotalAmount FROM Sales.Orders WHERE YEAR(OrderDate) = 2024;现象是执行计划显示 Index Scan,逻辑读几十万,而这个表在 OrderDate 上明明有非聚集索引。原因是 YEAR(OrderDate) 把 OrderDate 列的原始值在进入索引搜索前改写了,索引键里存的是原始日期值,不是年份值,优化器无法把“年份等于 2024”换算成索引键上的一个范围,于是放弃 Seek。
改写方式是把条件变成半开区间,让优化器能看到明确的起点和终点:
SELECT OrderID, CustomerID, TotalAmount FROM Sales.Orders WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01';改写后的条件能直接映射到索引键,逻辑读能从几十万降到几百。注意,不仅是函数,列上的算术运算也一样,比如 ColumnA + 1 = 10。把运算挪到等号另一边,改成 ColumnA = 9,Seek 才能生效。这个原则叫“保持索引列裸奔”。
3.2 隐式转换:列和参数类型不一致导致的看不见的 Scan
隐式转换比函数更隐蔽,因为 SQL Server 不报错。最常见的场景是手机号字段用了 varchar,查询参数传了 int:
-- 翻车写法:参数类型与列类型不一致 SELECT CustomerID, PhoneNumber FROM Sales.Customers WHERE PhoneNumber = 13800138000;PhoneNumber 在表里是 varchar(20),参数 13800138000 是 int 常量。SQL Server 的隐式类型转换规则是向高优先级方向转,int 优先级比 varchar 高,实际发生的是 CONVERT(varchar, PhoneNumber) = 13800138000,索引列被转换函数包住,Seek 条件失效,计划落到 Scan。
正确写法是让参数类型与列类型一致:
SELECT CustomerID, PhoneNumber FROM Sales.Customers WHERE PhoneNumber = '13800138000';把参数写成字符串字面量,或者程序传参时声明为 varchar。判断方法是在执行计划里找 CONVERT_IMPLICIT 警告,黄色的感叹号很醒目,它一般出现在谓词表达式里。一看到它先查类型,别先查索引。
注意:强制 CAST 时也要小心。CAST(PhoneNumber AS varchar(20)) = '13800138000' 依然会导致 Scan,正确做法是 CAST('13800138000' AS varchar(20)) = PhoneNumber,保持列不被包裹。
3.3 OR 与 IN 的分岔:为什么 IN 能走多个 Seek,OR 却经常变成 Scan
后端开发最容易踩的是 OR 与 IN 的差异。两者表面是同一语义,但执行计划走向可能完全不同:
-- 翻车写法:两个条件用 OR 连接 SELECT OrderID, Status, CustomerID FROM Sales.Orders WHERE Status = 'Paid' OR CustomerID = 10086;即使 Status 和 CustomerID 各有单列索引,优化器也并非一定会做索引交集。它可能计算后发现合并代价高,直接改成 Scan。即使两边各走 Seek,最终还要做串联和去重,这步操作在成本模型里经常被高估,优化器宁可一次 Scan。
更稳定的写法是拆成 UNION ALL,让每个分支各自命中索引:
SELECT OrderID, Status, CustomerID FROM Sales.Orders WHERE Status = 'Paid' UNION ALL SELECT OrderID, Status, CustomerID FROM Sales.Orders WHERE CustomerID = 10086 AND Status <> 'Paid';第二个分支要加 Status <> 'Paid',否则两段结果可能出现同一行,UNION ALL 不去重,业务数据会翻倍。而 IN 的情况通常好很多,WHERE Status IN ('Paid', 'Pending', 'Refunded') 这类写法,优化器倾向于拆成多个 Seek 再用 Concatenation 拼接,是 Seek 友好的。所以能写成 IN 就别写 OR,如果必须用 OR,用 UNION ALL 拆路。
3.4 前导通配符:% 开头的 LIKE 天生无法被索引搜索利用
LIKE '%ABC%' 因为通配符在前,优化器确定不了起始键,索引无法下探,执行计划只能 Scan。LIKE 'ABC%' 则是 SARGable 的,因为字符串排序规则让优化器知道起点是 'ABC',终点是 'ABD',可以映射到一个索引键范围。解决方案是业务上改存倒排文本,或者考虑全文索引,没有通用的小改动能将前导通配符变成 Seek。
这里有一个容易被忽略的点:LIKE 'ABC%' 虽然能走 Seek,但如果查询列没有被索引覆盖,Seek 之后同样会触发回表,行数多时优化器仍可能退化到 Scan。因此,处理前导通配符时要连同 SELECT 列一起检查,必要时加 INCLUDE 列补齐覆盖。
4. 统计信息与参数嗅探:优化器记错账导致的索引退化
索引本身没问题、查询也符合 SARG 原则,但执行计划里依然是 Index Scan,这时问题多半出在优化器做判断前拿到的数据上。统计信息失真和参数嗅探是两大头号嫌疑,排查顺序应该排在建索引之前。
4.1 统计信息过期:密度与直方图跟不上数据变化
统计信息是优化器的视力。它记录每个索引键的直方图,即值的分布。当表上插入、删除、更新的行数占比超过阈值,大约 20% 的行数变化时,统计信息会标记为过期。此时直方图可能还停留在表只有 10000 行的状态,而实际表已经有 1000000 行,原本预估返回 100 行的 Seek,被错估成返回 1000 行,跨过临界点后优化器就选了 Scan。
先查看统计信息最后更新时间:
SELECT name, stats_date(object_id, stats_id) AS last_update FROM sys.stats WHERE object_id = OBJECT_ID('Sales.Orders');手动更新统计信息:
UPDATE STATISTICS Sales.Orders; -- 精确更新某索引,全扫描方式 UPDATE STATISTICS Sales.Orders IX_Orders_OrderDate WITH FULLSCAN;第一条不带选项,按采样方式更新,速度快但精度一般;第二条使用 FULLSCAN 扫描全部分发数据,精度最高、耗时最长,适合在维护窗口对大表执行。日常自动更新够用就不用每天手动更新。如果更新后计划仍不变,说明问题不在统计信息,去看参数嗅探或强制计划。
4.2 参数嗅探:首次调用的参数值决定后续所有会话
参数嗅探是所有走存储过程或参数化查询的系统都躲不开的机制。SQL Server 编译时会把首次传入的参数值放进成本估算,并将该计划存入计划缓存。之后即使参数变化,只要查询文本不变、统计信息没变,计划就沿用旧版本。
假设 Orders 表有 1024 万行,Status 列的值分布是 'Paid' 占 99%,'Draft' 占 0.5%。存储过程第一次执行时恰好有人传了 'Draft',优化器看到返回行数很小,生成 Seek 计划并缓存。之后业务高峰期所有会话都传 'Paid',Seek 计划被复用于高频参数,等于用随机 I/O 查一百万行,比 Scan 更慢。反过来,如果第一次传 'Paid' 缓存了 Scan 计划,后续小范围的 'Draft' 查询也沿用 Scan,这正是从 Seek 变 Scan 的一条具体线索。
排查参数嗅探,先看计划缓存里有哪些候选计划:
SELECT plan_handle, creation_time, last_execution_time FROM sys.dm_exec_query_stats ORDER BY last_execution_time DESC;拿到对应的 plan_handle 后,清掉那个特定计划:
DBCC FREEPROCCACHE(plan_handle);DBCC FREEPROCCACHE() 清全部,带参数只清指定计划。生产环境慎用全局清缓存,否则所有查询都要重新编译,可能引起 CPU 尖峰。常见修复方案是给存储过程加 OPTION(RECOMPILE),让每次执行都重新编译,避免嗅探绑定,代价是 CPU 开销增加。
注意:对低频重查询,RECOMPILE 几乎无感;对高频轻查询,每次多几十毫秒编译时间可能得不偿失。中间路线是 OPTION(OPTIMIZE FOR UNKNOWN),不取参数具体值,按统计信息密度生成一个中庸计划,对分布均匀的列友好。
4.3 覆盖索引缺失与列顺序:Seek 成功也兜不住回表的账
有时计划里确实出现过 Seek,但随后紧跟着大量 Key Lookup。当回表行数多,优化器算总账发现 Seek 加回表的成本已经高于直接 Scan,它会从 Seek 退化到 Scan。这种问题不能说优化器傻了,而是两个选择都贵,它选了看起来更平滑的那个。
缓解思路是让索引覆盖查询。比如下面这条查询:
SELECT SalesOrderID, OrderDate, TotalDue FROM Sales.Orders WHERE CustomerID = 11000;如果只有一个单列索引 IX_Orders_CustomerID,Seek 能定位 CustomerID=11000 的指针,但 OrderDate 和 TotalDue 得回聚集索引取。返回 500 行就要 500 次随机 I/O。给这个查询设计一个覆盖索引:
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID_Inc ON Sales.Orders (CustomerID) INCLUDE (OrderDate, TotalDue);INCLUDE 列不参与排序和定位,只用来存叶子页里的附加列。这样 Seek 之后可以直接从索引页取数,Key Lookup 消失,Scan 的诱因也随之消失。复合索引列顺序的要点是:等值谓词列放在最前,范围谓词放后面。比如 WHERE CustomerID = 11000 AND OrderDate >= '2024-01-01',索引键顺序 (CustomerID, OrderDate) 才能让 OrderDate 的范围定位生效。顺序写反,OrderDate 上的范围就没法被利用,只能变成 Seek 后的残余过滤,或干脆走 Scan。
5. 避坑记录:五个让索引查找变成索引扫描的真实翻车现场
这一章按现象、原因、解决的格式,整理五条我亲手排查过的坑。它们都在生产环境真实发生过,并且都有执行计划佐证。
5.1 现象:存储过程里的局部变量让 Seek 失效
现象是某个存储过程内部先声明了局部变量,DECLARE @start datetime = DATEADD(day, -30, GETDATE()),然后写 WHERE CreatedAt >= @start。CreatedAt 上有索引,但执行计划还是 Index Scan。
原因是局部变量在编译时被当作 UNKNOWN,不参与参数嗅探。优化器对它的选择性只能按密度“猜”,猜出来的比例高于 Seek 临界点,就选 Scan。解决方法是把日期计算挪到查询外面,让 SQL Server 看见一个裸常量;或者把逻辑改成存储过程入参,由调用方传入。局部变量这个坑的本质是优化器看不见值,而不是缺少统计信息,光更新统计信息解决不了。
5.2 现象:统计信息天天更新,但分区表计划还是 Scan
有一张分区表,每天凌晨 ETL 写入上百万行。DBA 更新了全局统计信息,也重建过索引,白天查询还是 Scan。原因是全局统计信息已更新,但分区级统计信息没跟上。查询里带了分区裁剪条件,优化器取的是分区级直方图,全局直方图根本派不上用场。
解决方法是按分区更新统计信息,对每个分区执行 UPDATE STATISTICS 时带 RESAMPLE 选项,让采样比例继承分区现有配置。更彻底的做法是在维护窗口对整表做一次 FULLSCAN 更新,之后重建索引,让分区级和全局级统计信息保持一致。从那以后我把分区表的统计信息维护单独列进作业,不再和普通表混在一起。
5.3 现象:JOIN 条件上的隐式转换引爆全表扫描
一条存储过程里,Orders.OrderID 是 varchar(20),Products.ID 是 int,JOIN 条件直接写等号。两表各自的索引都有,但执行计划显示连接时扫描了订单主表。原因是 varchar 与 int 比较时,varchar 列被隐式转换,OrderID 的索引失效。
解决方法是统一两列的数据类型,或者在 JOIN 时把参数显式 CAST 成目标类型。关键点在于转换参数而不是转换列,写成 p.ID = CAST(@OrderID AS int),而不是 CAST(p.ID AS int) = @OrderID。排查这类问题时,直接看执行计划里连接运算符下方的警告图标,有黄色感叹号就先查类型,往往比调整 JOIN 顺序更有效。
5.4 现象:索引碎片率一路涨,Seek 也跟着变 Scan
索引整理之前,查询的预估行数没问题,实际返回行数也不多,但 Seek 还是退化成了 Scan。原因是索引叶子页碎片率高,页与页之间的逻辑顺序和物理顺序错位严重。一次 Seek 本来要读几十个页,现在要读上百个页,随机 I/O 成本被放大,优化器宁愿选择顺序 Scan。
先查碎片率,再决定用什么级别整理:
SELECT OBJECT_NAME(ips.object_id) AS table_name, i.name AS index_name, ips.avg_fragmentation_in_percent FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Sales.Orders'), NULL, NULL, 'LIMITED') ips JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id WHERE ips.avg_fragmentation_in_percent > 30;碎片率在 5% 到 30% 之间,用 ALTER INDEX ... REORGANIZE 做逻辑重排,锁开销小;碎片率超过 30%,用 ALTER INDEX ... REBUILD WITH (ONLINE = ON) 重建索引,联机方式能减少阻塞。重建完索引后再看执行计划,Seek 经常自己就回来了。
5.5 现象:加了 OPTION(RECOMPILE) 后 Seek 回来了,但 CPU 涨了
现象是存储过程线上慢,我加了 RECOMPILE,执行计划恢复 Seek,但监控显示 CPU 整体上涨。原因是这个存储过程每分钟被调用上千次,RECOMPILE 让每次调用重新编译,编译开销超过了查询收益。
解决方法是撤掉 RECOMPILE,改成 OPTION(OPTIMIZE FOR(@Status='S')),为这个存储过程指定典型参数,让参数嗅探退化为固定参数提示,计划稳定且编译次数少。后来生产验证 CPU 回落,查询保持 Seek。从这里我学到一个习惯:任何强制手段都要考虑调用频率,高频低耗时查询最怕编译开销,低频高耗时查询才适合 RECOMPILE。
6. 回归验证:如何确认索引查找真的回来了
改完查询或索引后不能只看执行计划图形就收工,要量化“Seek 回来”和“整体变快”。我的验证工序分三层。
6.1 第一层:用 SET STATISTICS IO 量化逻辑读
打开统计信息输出:
SET STATISTICS IO ON; SET STATISTICS TIME ON;跑一遍修正前的 SQL 和修正后的 SQL,对比逻辑读、物理读与 CPU 耗时。逻辑读从几万降到几百,比看执行计划更直观。这里注意,缓存命中率高会让物理读很低,所以要以逻辑读为主。如果逻辑读降了但耗时没降,下一步就要看是不是阻塞或编译时间占了主导。
6.2 第二层:清理并观察计划缓存里的旧计划
有时候修改了存储过程,但缓存里还是老计划,执行计划看着像 Scan,其实是旧缓存没清。我的习惯是先拿到 plan_handle 再清单个,或者直接 DBCC FREEPROCCACHE() 在测试库上去除干扰。生产环境不要随便全局清缓存,如果动完代码后计划还是老样子,优先怀疑计划缓存复用问题而不是索引设计问题。
6.3 第三层:用极端参数探测参数嗅探是否解除
验证参数嗅探是否真的解除了,方法是用两个极端参数连续调用:
EXEC dbo.GetOrders 'Draft'; EXEC dbo.GetOrders 'Paid';分别抓出两次的执行计划。如果两者都按各自参数生成了合理路线,说明方案有效;如果两者恒定都是 Scan,说明问题不仅是嗅探,还在写法或索引设计。这一步能分辨是优化器不选 Seek,还是 Seek 根本没条件可用。
| 检查项 | 上游方法 | 通过标准 |
|---|---|---|
| 逻辑读 | SET STATISTICS IO ON | 修正后逻辑读较之前下降一个数量级 |
| 执行计划形状 | 实际执行计划 | 关键路径出现 Index Seek,无 Scan |
| 参数稳定性 | 两个极端参数分别执行 | 两次计划均存在 Seek 且路由合理 |
排查时还有一个诊断技巧:用 WITH (INDEX(IX_Orders_OrderDate)) 索引提示强制走 Seek,能立刻分辨是优化器不选 Seek,还是 Seek 根本没条件可用。如果强制后计划还是 Scan,多半是条件写坏了;如果强制后 Seek 回来了且逻辑读低,那不用长期用提示,回头修统计信息或参数嗅探就好。我现在的习惯是任何一次 Seek 退化排查,都先抓住两样东西:一个实际执行计划、一份统计信息 IO 输出。没有这两样,后面所有推断都是猜。诊断完成后再动索引或查询,改完再做一次三方对比,确认逻辑读、执行计划形状、极端参数三关都过了才收工。希望帮到你。
本文还有配套的精品资源,点击获取