4. Query Store目录视图初探
Query Store 把捕获到的查询、计划与运行时统计分别落在多张系统目录视图中,这些视图位于每个启用了 Query Store 的数据库内,通过 sys.query_store_* 命名空间访问。理解它们之间的主键与外键关系,是后续做性能治理的前提:只有先知道“一条查询文本存在哪、它的多个计划存在哪、每个计划在不同时间窗口的统计存在哪”,才能写出正确的关联查询,而不是把不同粒度的数据错误地拼在一起。
核心视图的分工
最常用的四张视图构成一条从“查询文本”到“执行统计”的链路:
sys.query_store_query_text:保存去重后的查询文本,主键query_text_id,同一段 SQL 文本只存一份。sys.query_store_query:保存查询的编译上下文,主键query_id,通过query_text_id关联文本;同一文本在不同 SET 选项或不同数据库上下文下会生成不同的query_id。sys.query_store_plan:保存该查询的每个执行计划,主键plan_id,通过query_id关联查询;一个查询可以有多个计划,对应计划回退或重编译。sys.query_store_runtime_stats:保存计划在时间窗口内的聚合运行时统计,通过plan_id关联计划,并带有runtime_stats_interval_id指向sys.query_store_runtime_stats_interval定义的时间区间。
此外还有 sys.query_store_wait_stats(等待类别统计)和 sys.query_store_plan_feedback(计划反馈)等辅助视图,它们同样以 plan_id 为关联键。下图展示了这条关联链以及各视图的关键列,箭头方向表示外键引用方向。
从查询文本定位到运行时统计
实际排查时,通常先拿到一段可疑 SQL 或一个存储过程名,再逐层向下钻取。下面这段示例按 query_sql_text 模糊匹配定位查询,并汇总其各计划在最近时间窗口内的平均耗时与执行次数。示例中的文本与数值均为教学假设,实际结果取决于目标实例的负载。
SELECT
qt.query_sql_text,
q.query_id,
p.plan_id,
p.is_forced_plan,
rs.count_executions,
rs.avg_duration / 1000.0 AS avg_duration_ms,
rs.avg_cpu_time / 1000.0 AS avg_cpu_ms,
rs.avg_logical_io_reads,
rsi.start_time,
rsi.end_time
FROM sys.query_store_query_text AS qt
JOIN sys.query_store_query AS q ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS rsi
ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE qt.query_sql_text LIKE N'%SalesOrderHeader%'
AND rsi.start_time >= DATEADD(HOUR, -24, GETUTCDATE())
ORDER BY rs.avg_duration DESC;
这条语句体现了几个关键点:avg_duration、avg_cpu_time 等列的单位是微秒,除以 1000 得到毫秒更便于阅读;runtime_stats_interval_id 必须与区间视图关联,否则无法判断统计属于哪个时间窗口;同一 query_id 可能返回多行 plan_id,这正是计划回退的信号。
识别计划回退与强制计划
当同一查询出现多个 plan_id 且各自都有执行记录时,说明优化器在不同时间选择了不同计划。可以按 query_id 分组,统计计划数量与各计划的平均耗时差异:
SELECT
q.query_id,
COUNT(DISTINCT p.plan_id) AS plan_count,
MIN(rs.avg_duration) / 1000.0 AS best_avg_ms,
MAX(rs.avg_duration) / 1000.0 AS worst_avg_ms,
SUM(rs.count_executions) AS total_execs
FROM sys.query_store_query AS q
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
GROUP BY q.query_id
HAVING COUNT(DISTINCT p.plan_id) > 1
ORDER BY worst_avg_ms - best_avg_ms DESC;
若 best_avg_ms 与 worst_avg_ms 差距显著,且执行次数都不可忽略,就属于典型的计划不稳定。此时可进一步查看 p.is_forced_plan 与 p.force_failure_count:前者为 1 表示该计划已被强制,后者大于 0 表示强制失败过,需要检查计划是否仍兼容当前对象结构。
sys.query_store_plan 时,query_plan 列是 XML 类型,直接 SELECT 会返回大段 XML。建议用 p.query_plan.query('...') 提取特定节点,或先只取 plan_id 再按需展开,避免结果集过大影响排查效率。等待统计的补充视角
运行时统计回答“慢了多少”,等待统计回答“慢在哪里”。sys.query_store_wait_stats 以 plan_id 和区间为粒度,按 wait_category_desc 分类记录等待时间。将它与运行时统计按相同 plan_id 关联,可以判断某计划的高耗时是 CPU 密集还是被锁、IO 或并行等待拖累:
SELECT
ws.plan_id,
ws.wait_category_desc,
ws.avg_query_wait_time_ms,
ws.total_query_wait_time_ms
FROM sys.query_store_wait_stats AS ws
WHERE ws.plan_id = 42 -- 替换为实际 plan_id
ORDER BY ws.total_query_wait_time_ms DESC;
需要注意,等待统计并非所有版本都默认采集,且其区间与运行时统计区间一一对应。若某 plan_id 在等待统计中无记录,应先确认 Query Store 的等待统计采集是否开启,而不是直接判定该计划没有等待。
使用目录视图时的常见判断依据
| 观察点 | 含义 | 后续动作 |
|---|---|---|
| 同一 query_id 多个 plan_id | 计划回退或重编译 | 比较各计划 avg_duration,评估是否强制稳定计划 |
| count_executions 很小但 avg_duration 很高 | 偶发慢查询,样本不足 | 延长观察窗口,避免过早下结论 |
| avg_logical_io_reads 异常高 | 可能缺失索引或统计信息过期 | 结合执行计划查看扫描算子 |
| is_forced_plan = 1 且 force_failure_count > 0 | 强制计划失效 | 检查对象变更,重新评估强制策略 |
| 等待类别集中在 Lock / IO | 瓶颈不在 CPU | 转向阻塞链或存储层排查 |