SQL Server 参数嗅探实测:同一个存储过程 2 ms 和 530 ms,2022 的 PSP 在 1 万倍倾斜下没触发,Query Store hint 不改代码 530→20 ms
在 SQL Server 2022 CU27-GDR(16.0.4305.5,兼容级别 160)测试库上复现参数嗅探:orders 100 万行,大客户 50 万行、其余 1 万个客户各 50 行。先用小客户编译,大客户每次 153 万逻辑读、约 510–530 ms;先用大客户编译则走并行批模式扫描,约 20 ms。PSP 优化在这份数据上没触发(XEvent 原因 SkewnessThresholdNotMet),倾斜做到 20 万倍才生成 2 个变体。对比 OPTION(RECOMPILE)、OPTIMIZE FOR UNKNOWN、Query Store hints、覆盖索引的读数和耗时,附只读排查 SQL 和可复跑脚本。
SQL Server 2022(16.0.4305.5)实测(测试库、自造数据):同一个按 customer_id 汇总的存储过程,计划先被 50 行的小客户编译后,50 万行的大客户每次要 1532120 次逻辑读、510–530 ms;换成大客户先编译,是并行批模式扫描,29957 次读、15–25 ms。兼容级别 160 的 PSP 优化在这份 1 万倍倾斜的数据上没触发,扩展事件给出 SkewnessThresholdNotMet;倾斜做到 20 万倍才拆成 2 个计划变体。修法里 OPTION(RECOMPILE) 和 Query Store hint 效果相同(大客户 16–31 ms、小客户 166 读),OPTIMIZE FOR UNKNOWN 选中的仍是慢计划;覆盖索引让读数降到 1799,但耗时取决于编译时的参数。
适用对象
- 判断是不是参数嗅探,先看同一个计划的读数跨度:本次 sys.dm_exec_query_stats 里 min_logical_reads 190、max 1594620,相差 8000 多倍;计划 XML 里 ParameterCompiledValue 是 (12),即用小客户编译。
- SQL Server 2022 的 PSP 不是倾斜了就会触发:大客户 50 万行、小客户 50 行(1 万倍)时扩展事件给出 SkewnessThresholdNotMet(30);做到 100 万对 5 行(20 万倍)才拆出 2 个计划变体。
- 不能改代码时,用 sp_query_store_set_hints 给这条查询挂 OPTION(RECOMPILE),实测和改代码加 RECOMPILE 一样:小客户 166 读 1–3 ms,大客户 29957 读 17–23 ms。
- OPTIMIZE FOR UNKNOWN 在这份数据上选的是 Seek + Key Lookup,大客户仍是 1532120 读、538/547 ms,没解决问题。
- 覆盖索引把大客户的读数从 153 万降到 1799,但小客户先编译时走串行行模式,仍要 222–231 ms;只看逻辑读会以为已经修好。
同一个存储过程,谁先编译决定了大客户是 530 ms 还是 20 ms
存储过程 dbo.customer_summary 只有一句:SELECT COUNT(*), SUM(amount), MAX(created_at) FROM orders WHERE customer_id = @cid。我每轮先 DBCC FREEPROCCACHE(只在测试库),再按不同顺序调用,SET STATISTICS IO, TIME ON 记录读数和耗时。
小客户(cid=12)先编译时,优化器按 50 行估算,选了索引 Seek + Key Lookup 的串行计划。小客户只要 157 次读(首次 166),大客户沿用这个计划,要对 50 万行逐行回表,1532120 次读、510–530 ms,复跑一轮 514–527 ms。大客户先编译时,选的是并行聚集索引扫描,除 Parallelism 外的算子都是批模式,29957 次读、15–25 ms;小客户用这个计划也只要 12–14 ms,只是 CPU 时间 85–98 ms。
| 编译时参数 | 计划 | cid=12(50 行) | cid=1(50 万行) |
|---|---|---|---|
| cid=12 先编译 | 串行 Index Seek + Key Lookup + Stream Aggregate | 157 读,0 ms | 1532120 读,510–530 ms |
| cid=1 先编译 | 并行 Clustered Index Scan,批模式 Hash Match | 12–14 ms(CPU 85–98 ms) | 29957 读,15–25 ms(CPU 105–141 ms) |
cid=1(50 万行)单次耗时
沿用小客户计划
510–530 ms
大客户先编译
15–25 ms
Query Store hint RECOMPILE
17–23 ms
SQL Server 2022 16.0.4305.5,测试库、自造数据,取每组最大值。
怎么认出是参数嗅探:看同一个计划的读数跨度和 ParameterCompiledValue
线上最常见的描述是:这个过程平时 1 秒内,偶尔几十秒,DBCC FREEPROCCACHE 或重建统计信息后就好了,过几天又犯。我用下载区只读 SQL 的第 2 段看 sys.dm_exec_query_stats:这条语句 min_logical_reads 190、max_logical_reads 1594620,比值 8392.7;再从计划 XML 里取 ParameterCompiledValue,是 (12),说明缓存里的计划是用小客户编译的。
跑这段 SQL 时我踩了一个坑:sqlcmd 默认 QUOTED_IDENTIFIER OFF,调用 XML 的 .value()/.nodes() 报 Msg 1934。要加 -I,或在 SSMS 里跑。
本节判断
- 同一个 plan_handle 的 max_logical_reads / min_logical_reads 很大(本次 8000 多倍)。
- ParameterCompiledValue 是少数派的值(这里是 50 行的小客户),而慢的是大户。
- Query Store 里同一 query_id 有多个 plan_id,且平均耗时差一个数量级。
- 清计划缓存后问题消失:这只说明换了计划,下一次被谁编译仍然随机。
兼容级别 160 也没触发 PSP:扩展事件说 SkewnessThresholdNotMet
我原以为兼容级别 160 会自动拆计划。可无论先用谁编译,sys.query_store_query_variant 都是空的,计划还是单一的一个。数据库作用域配置 PARAMETER_SENSITIVE_PLAN_OPTIMIZATION 是 1,确认是打开的。
我建了一个扩展事件会话抓 parameter_sensitive_plan_optimization_skipped_reason,再调用一次,事件给出 reason=SkewnessThresholdNotMet、reason_code=30。DBCC SHOW_STATISTICS 看直方图只有 6 个步长:RANGE_HI_KEY=1 的 EQ_ROWS 是 500000,其余步长的 AVG_RANGE_ROWS 都是 50。倾斜是 1 万倍,但没达到 PSP 的阈值。
于是我又建了 orders_skew:customer_id=1 有 100 万行,另外 2 万个客户各 5 行(20 万倍)。这次 PSP 触发了:Query Store 里出现 1 个调度计划(parent query 41)和 2 个查询变体(42、43),小客户 18 次读,大客户 32948 次读、17–26 ms(复跑 21–35 ms),不再互相拖累。所以 PSP 能不能救你,要先用这个扩展事件在自己的数据上确认。
| 表 | 大客户行数 | 小客户行数 | 倾斜 | 结果 |
|---|---|---|---|---|
| orders | 500000 | 50 | 1 万倍 | 未触发,SkewnessThresholdNotMet(30) |
| orders_skew | 1000000 | 5 | 20 万倍 | 触发,1 个调度计划 + 2 个变体 |
四种修法实测:RECOMPILE、OPTIMIZE FOR UNKNOWN、Query Store hint、覆盖索引
每种修法都在清空计划缓存后先用 cid=12、再用 cid=1 调用,这正是线上出问题的顺序。
OPTION(RECOMPILE):每次按实际参数编译,编译约 1 ms,小客户 166 读 1–2 ms,大客户 29957 读 16–31 ms。代价是每次都编译,调用频率每秒上千次的过程要先算 CPU 账。
OPTIMIZE FOR UNKNOWN:按密度向量估算(平均每个客户约 100 行),选中的仍是 Seek + Key Lookup,大客户 1532120 读、538/547 ms,复跑 475/599 ms。在这种一个大户加一堆小户的分布下,平均值偏向小户,它就和小客户先编译的结果一样。
Query Store hint:从 sys.query_store_query_text 找到这条语句的 query_id(本次是 33),EXEC sys.sp_query_store_set_hints @query_id = 33, @query_hints = N'OPTION(RECOMPILE)'。不改存储过程,效果和改代码一样:小客户 166 读 1–3 ms,大客户 29957 读 17–23 ms。实验后我用 sp_query_store_clear_hints 清掉了。
| 修法 | 要不要改代码 | cid=12 | cid=1 | 备注 |
|---|---|---|---|---|
| 不处理(基线) | — | 157 读 | 1532120 读,510–530 ms | 计划随谁先编译而变 |
| OPTION(RECOMPILE) | 要 | 166 读,1–2 ms | 29957 读,16–31 ms | 每次编译约 1 ms |
| OPTIMIZE FOR UNKNOWN | 要 | Seek 计划 | 1532120 读,538/547 ms | 这份数据上无效 |
| Query Store hint RECOMPILE | 不要 | 166 读,1–3 ms | 29957 读,17–23 ms | sp_query_store_set_hints |
| 覆盖索引 INCLUDE(amount, created_at) | 不要,加索引 | 5 读 | 1799 读,但 222–231 ms | 见下一节 |
覆盖索引:逻辑读降到 1799,大客户还是 220 ms
我第一反应是加覆盖索引,去掉 Key Lookup:CREATE INDEX ... ON orders(customer_id) INCLUDE(amount, created_at),索引从 14904 KB 涨到 43784 KB。小客户先编译后,大客户的逻辑读从 1532120 降到 1799,看 STATISTICS IO 像是修好了;可耗时还是 222–231 ms(复跑 182–212 ms)。计划是串行、行模式的 Stream Aggregate,50 万行一行一行地聚合。
换成大客户先编译,同一个索引上是串行批模式 Hash Match,46–48 ms(复跑 44–48 ms),小客户 5 次读。也就是说覆盖索引减少了 IO,但计划依然取决于编译时的参数,嗅探问题还在,只是没那么严重。
大客户先编译时那 20 ms 从哪来:主要是并行,不是批模式
我想知道快计划里批模式(2019 起的 batch mode on rowstore)贡献多少,就在同一条语句上分别加 hint 对比:OPTION(USE HINT('DISALLOW_BATCH_MODE')) 仍是并行扫描,29957 读、19–22 ms,基本不变;OPTION(MAXDOP 1) 变成串行,29523 读、95–130 ms。这台机器上,速度主要来自并行。生产上如果 MAXDOP 设成 1 或者 cost threshold for parallelism 设得很高,大客户先编译时的计划也不会这么快。
我的处理顺序
下载区的只读 SQL 只有 SELECT/WITH,需要 VIEW SERVER STATE(2022 起为 VIEW SERVER PERFORMANCE STATE)和库内读取 Query Store 的权限,用 sqlcmd -I 运行。演示脚本会建 sniff_lab 库、清计划缓存、挂和清 Query Store hint,只能在测试实例上跑。
参数嗅探从止血到根治
- 01
1
确认
跑只读 SQL:读数跨度、ParameterCompiledValue、Query Store 多计划;有条件再用扩展事件看 PSP 跳过原因。
- 02
2
止血
不能改代码就用 Query Store hint 挂 OPTION(RECOMPILE) 或强制已知好的计划;先确认调用频率,评估编译 CPU。
- 03
3
看 PSP
兼容级别 160 时确认 PSP 是否已经拆出变体;没拆就不要指望它。
- 04
4
根治
按业务拆分大户和小户的路径、调整索引,改完在测试库按小户先、大户后的顺序复测。
- 05
5
收尾
记录 hint 和原因,后续版本升级或代码修好后清掉 hint。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
SQL Server 参数嗅探怎么判断?
看同一个计划的读数跨度和 ParameterCompiledValue。本次 sys.dm_exec_query_stats 里 min_logical_reads 190、max 1594620,计划是用 50 行的小客户(12)编译的,50 万行的大客户每次 510–530 ms。
SQL Server 2022 的 PSP 能自动解决参数嗅探吗?
不一定。本次兼容级别 160、1 万倍倾斜时 PSP 没触发,扩展事件原因是 SkewnessThresholdNotMet;倾斜到 20 万倍才拆出 2 个计划变体。要先用扩展事件在自己的数据上确认。
不改代码怎么解决参数嗅探?
用 Query Store hints:EXEC sys.sp_query_store_set_hints 给这条查询挂 OPTION(RECOMPILE)。本次大客户从 1532120 读、约 530 ms 降到 29957 读、17–23 ms,小客户仍是 166 读。
OPTIMIZE FOR UNKNOWN 有用吗?
取决于数据分布。本次一个大户加 1 万个小户,按平均值估算选中的仍是 Seek + Key Lookup,大客户 538/547 ms,没有改善。
加覆盖索引能解决参数嗅探吗?
能减少 IO,但计划仍随编译参数变化。本次覆盖索引后大客户读数降到 1799,小客户先编译时仍要 222–231 ms,大客户先编译是 46–48 ms。