参数嗅探听起来像是一个调试技巧,但在 SQL Server 里它直接决定了一条参数化 SQL 会跑成毫秒级还是秒级。它的核心矛盾在于:执行计划缓存为了减少编译开销,会让结构相同的查询复用同一个计划;而参数嗅探又让这个计划过度依赖首次传入的参数分布。当两个参数的数据量级差异巨大时,缓存的计划就可能从最优解变成性能陷阱。要理解这个问题,需要先弄清计划缓存和参数嗅探是如何绑在一起的。

执行计划缓存与参数嗅探的底层关系
SQL Server 接收到一条查询后,会先计算其文本的哈希值,并在计划缓存中查找是否存在可复用的执行计划。对于即席查询,如果文本完全一致且参数值被替换为参数标记,就可能命中同一条缓存计划。对于存储过程或 sp_executesql 提交的语句,参数化程度更高,缓存键通常只与对象 ID 或语句文本有关,而不包含具体参数值。例如下面的存储过程在首次执行时会根据传入的 @City 值生成执行计划:
CREATE PROCEDURE dbo.GetOrdersByCity
@City NVARCHAR(50)
AS
BEGIN
SELECT OrderID, CustomerID, OrderDate, Amount
FROM dbo.Orders
WHERE City = @City;
END;
第一次用 @City 等于北京调用时,如果该城市的订单量占全表的百分之八十,优化器很可能选择聚集索引扫描,因为它估计需要返回大量行,扫描比查找加回表更划算。这个计划随后被写入缓存。第二次用 @City 等于一个只有几十行的小城市调用时,优化器本应选择非聚集索引查找并回表,但由于计划已经缓存,SQL Server 默认不会重新编译,而是继续使用原来那个扫描计划。结果就是一个小范围查询被迫扫描整张表,逻辑读和 CPU 都急剧上升。
参数嗅探并不是错误,而是优化器在参数化查询场景下的一种权衡。它默认信任首次编译时的参数值具有代表性,这在数据分布均匀的列上通常没问题。但如果列的数据倾斜严重,或者参数首次传入时恰巧是极端值,就会让后续所有复用者承担错误计划的代价。更隐蔽的是,这类问题往往不会稳定复现:只要缓存计划被清理或发生重编译,下一次编译遇到不同参数,性能表现又会变化,给排查带来很大干扰。
参数嗅探导致计划失效的典型表现
在生产环境中,参数嗅探问题最常见的现象是同一个存储过程或同一段应用 SQL,偶尔跑得很快,偶尔又变得非常慢,而执行的代码没有发生任何变化。慢查询通常伴随着高逻辑读、高 CPU 和大量等待,但查看执行计划时会发现优化器选择的运算符并不合理。例如一个应该走非聚集索引查找的查询,执行计划里却显示聚集索引扫描或表扫描,并且估计行数和实际行数相差几个数量级。
还有一种典型表现是执行计划缓存中出现多个几乎相同的计划,但 sys.dm_exec_query_stats 中的执行次数、总逻辑读和工作时间差异很大。这是因为某些查询被频繁重编译,或者不同 SET 选项导致计划无法共享。参数嗅探虽然不会直接增加重编译次数,但它会让缓存中的单个计划在某些参数区间表现极差。可以通过下面的查询观察当前缓存中哪些计划的参数嗅探相关属性异常:
SELECT TOP 20
qs.plan_handle,
qs.execution_count,
qs.total_logical_reads / NULLIF(qs.execution_count, 0) AS avg_logical_reads,
qs.total_worker_time / NULLIF(qs.execution_count, 0) AS avg_cpu_us,
st.text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE st.text LIKE '%Orders%'
ORDER BY avg_logical_reads DESC;
如果发现某条语句平均逻辑读非常高,但单独复制出来加上具体参数值执行又很快,基本可以判断是参数嗅探选择了不适合当前参数的计划。另一个判断依据是使用 DBCC FREEPROCCACHE 或 ALTER PROCEDURE 重新编译后问题暂时消失,但过一段时间又复发,这说明不是索引缺失或统计信息完全失真,而是计划复用机制在作祟。需要注意的是,清空缓存虽然能暂时解决,但会带来全局编译压力,应作为最后手段,而不是常规优化方案。
参数嗅探还与统计信息密切相关。如果统计信息过期,优化器连首次编译时都可能估计错误;如果统计信息准确,但参数值本身具有明显倾斜,仍然可能出现嗅探问题。因此处理之前应先确认统计信息是否及时更新,避免把脏数据分布误判为参数嗅探问题。可以通过 DBCC SHOW_STATISTICS 查看直方图,确认列上不同值的数据量分布。
强制参数化的机制与启用场景
强制参数化是数据库级别的一个选项,它会让 SQL Server 对符合条件的即席查询自动参数化,从而减少计划缓存中的重复计划,并扩大计划复用的范围。在默认的简单参数化下,只有少数安全且简单的查询会被自动参数化;而在强制参数化下,只要查询文本结构一致而常量值不同,SQL Server 就会尝试用参数替换常量,并共用同一个执行计划。
可以通过下面的语句将数据库设置为强制参数化:
ALTER DATABASE CurrentDB SET PARAMETERIZATION FORCED;
启用强制参数化后,像下面两条查询:
SELECT OrderID, CustomerID, Amount FROM dbo.Orders WHERE City = '北京'; SELECT OrderID, CustomerID, Amount FROM dbo.Orders WHERE City = '上海';
会被视为同一个参数化模板,第二次执行可以复用第一次的计划。这对大量只有常量值不同的即席查询非常有帮助,可以显著降低编译开销,同时提高缓存命中率。但强制参数化也有明显副作用:它同样会引入参数嗅探问题,而且影响范围更广。原本只有存储过程和 sp_executesql 会因参数嗅探产生计划偏差,现在普通即席查询也可能被固定到一个不适合所有值的计划上。
因此强制参数化更适合查询模板数量庞大、单条语句执行次数不多、但整体编译开销高的 OLTP 系统。对于数据倾斜严重的查询,不建议盲目在数据库级别开启,否则可能把个别慢查询扩散成普遍问题。折中方案是保留数据库默认参数化,对特定高价值查询使用 sp_executesql 显式参数化,或者使用计划指南为某条语句指定固定计划或查询提示。
优化参数嗅探的实用手段
针对已经确认的参数嗅探问题,有几种不同的干预层级。最轻量的是在存储过程内部使用 OPTION (RECOMPILE),让该语句每次执行都重新编译,完全放弃计划重用。这对于执行频率低、单次执行开销大、参数波动剧烈的查询非常合适。例如:
SELECT OrderID, CustomerID, Amount FROM dbo.Orders WHERE City = @City OPTION (RECOMPILE);
代价是每次执行都有编译开销,对于每秒执行上百次的 OLTP 查询并不友好。另一种方式是使用 OPTION (OPTIMIZE FOR UNKNOWN),让优化器不依赖具体参数值,而是使用统计信息的平均密度来评估行数。这种方式牺牲了最优计划的可能,但能获得更稳定的性能表现,适合大多数参数区间都要保持可接受响应时间的场景。
SELECT OrderID, CustomerID, Amount FROM dbo.Orders WHERE City = @City OPTION (OPTIMIZE FOR UNKNOWN);
还可以使用 OPTIMIZE FOR 指定一个具有代表性的参数值,例如传入占比较高的城市,让计划偏向扫描;或者传入小城市,让计划偏向查找。这需要 DBA 对数据分布有明确判断。计划指南则可以在不修改应用代码的情况下,将查询提示强加到指定 SQL 模板上。例如使用 sp_create_plan_guide 为某个频繁出现参数嗅探问题的查询模板附加 OPTION (OPTIMIZE FOR UNKNOWN)。
如果存储过程中的不同参数区间需要不同的执行计划,可以结合 IF 分支拆分逻辑,把大结果集和小结果集的查询分别用不同语句处理,每条语句单独编译并缓存。这样虽然代码变多,但每个计划都能专注于自己的数据分布。还可以在执行计划缓存层面使用 DBCC FREEPROCCACHE 针对特定 plan_handle 清除,但清除后何时重新编译仍取决于下一次执行参数,不能根治问题。
监控缓存命中与验证优化效果
优化参数嗅探问题后,需要通过监控确认计划缓存是否达到预期效果。sys.dm_exec_query_stats 提供 CPU、逻辑读、执行次数等指标,可以对比修改前后的平均消耗。sys.dm_exec_cached_plans 可以查看缓存中的计划类型、使用次数和占用内存。通过下面的查询可以看到当前数据库中哪些缓存计划被反复使用,哪些很少被引用:
SELECT
cp.plan_handle,
cp.objtype,
cp.usecounts,
cp.size_in_bytes,
st.text
FROM sys.dm_exec_cached_plans AS cp
CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) AS st
WHERE cp.cacheobjtype = 'Compiled Plan'
AND st.text NOT LIKE '%sys.dm_exec_cached_plans%'
ORDER BY cp.usecounts DESC;
如果启用强制参数化,可以观察缓存中 Adhoc 计划数量是否下降,Prepared 计划数量是否上升。还可以使用 sys.dm_exec_query_plan 提取 XML 执行计划,确认参数列表和 ParameterList 中是否包含实际嗅探到的值。参数嗅探问题优化后,XML 计划中的估计行数与实际行数应接近,关键运算符是否合理可以通过图形执行计划或 SET STATISTICS PROFILE ON 查看。
对于已经稳定的系统,建议定期收集慢查询日志和等待统计,而不是等用户投诉后才被动处理。参数嗅探无法完全避免,因为它是执行计划重用的伴生机制。合理的做法是在编译开销、计划质量和系统稳定性之间找到适合业务特点的平衡点。对倾斜严重的列,最稳妥的往往是使用 OPTION (RECOMPILE) 或 OPTION (OPTIMIZE FOR UNKNOWN),把不确定性从执行计划缓存中移除。
最后需要强调的是,强制参数化并非参数嗅探的对立面,它只是扩大了参数化的范围。真正解决参数嗅探危害,要理解优化器估计行数的逻辑,确认统计信息新近度,再根据查询频率和数据分布选择针对性的查询提示或计划指南。盲目清空缓存、重启实例或直接开启强制参数化,都可能把局部性能问题转化为全局编译压力或普遍性计划偏差,反而不利于系统长期稳定运行。