☰
SQL Server Query Store 深度解析:从执行计划历史回溯到性能诊断实战
2026/10/14 16:16:09 网站建设 项目流程

1. 为什么值得重新认识 Query Store

Query Store 这个功能,我最早是在 SQL Server 2016 上开始接触的。当时第一反应是"这不就是个加强版的执行计划缓存查询工具吗",直到有一次线上核心库出现了一次诡异的性能回退——同一个存储过程,昨天跑 200ms,今天突然变成 8 秒,执行计划缓存里翻来翻去也看不出问题,因为计划已经被刷掉了。那次事故之后,我才真正把 Query Store 当成生产环境的"黑匣子"来用。

简单说,Query Store 是数据库引擎内置的一个"飞行数据记录仪"。它把查询语句、执行计划、运行时统计这三样东西持久化到用户数据库内部,形成一个可以回溯的时间轴。数据库重启、计划缓存清空、内存压力导致计划被驱逐,这些过去会让诊断线索彻底消失的场景,在 Query Store 开启之后都不再是问题。它解决的核心痛点就是:执行计划的历史不可追溯。

这篇文章适合谁看?如果你是要负责线上数据库稳定性的人,不管是 DBA、后端开发还是数据平台工程师,只要你会遇到"这条 SQL 昨天还好好的今天怎么慢了"这类问题,Query Store 就是你必须掌握的技能。我会从它到底存了什么、为什么这么设计、怎么开启配置、怎么读它的数据这几个角度,把这块内容讲透。这一篇是系列的开篇,重点放在概念和整体认知上,后续会展开到具体的等待统计、计划强制、回归检测等实战话题。

需要先说明一点:Query Store 在 Azure SQL Database 和 Azure SQL Data Warehouse(现在叫 Synapse SQL 专用池)上的行为,跟本地 SQL Server 有一些差异,尤其是 DW 场景下它的采集粒度和可用视图并不完全一致。这个系列会兼顾两边,但开篇先把通用概念打牢。

2. Query Store 到底存了什么

2.1 三张核心内部表撑起整个体系

很多人以为 Query Store 是个"内存里的监控",其实不是。它在每个启用了该功能的用户数据库里,创建了一组内部表,物理存储在数据库文件里。核心是三块:

  • 计划相关表:记录每条查询对应的执行计划,包括计划本身的 XML、计划哈希、计划生成时间、编译时的统计信息等。
  • 运行时统计表:按时间区间聚合的执行指标,比如执行次数、总耗时、CPU 时间、逻辑读、物理读、内存授予、行数等。
  • 查询文本表:保存查询的原始文本和规范化后的文本(把常量替换成参数占位符),这样同一个查询模板的不同参数值会被归并到一条记录上。

这三块通过 query_id、plan_id 这些内部标识串起来。你平时查的sys.query_store_query、sys.query_store_plan、sys.query_store_runtime_stats这些视图,本质上就是对这些内部表的封装。

为什么要持久化而不是放内存?因为诊断性能问题往往发生在事后。内存里的 DMV(比如sys.dm_exec_query_stats)在实例重启、计划被驱逐、缓存刷新之后就归零了,你根本没法回答"过去两小时这条查询的计划变过几次"这种问题。持久化之后,数据按你配置的保留策略滚动清理,形成一个有限但足够用的历史窗口。

2.2 时间维度是它区别于 DMV 的关键

普通 DMV 给你的是"当前快照",Query Store 给你的是"时间序列"。这是本质区别。

它默认按一个可配置的间隔(INTERVAL_LENGTH_MINUTES)把运行时统计聚合成一个个时间桶。比如你设成 60 分钟,那么每个小时会有一条聚合记录,记录这一小时内这条查询执行了多少次、平均耗时多少、最大耗时多少、标准差多少。这个设计的好处是存储可控——你不会因为一条高频查询每秒执行上千次就把表撑爆,因为它是按桶聚合的,不是逐条记录。

但这里有个坑:聚合会丢失细节。如果你设的间隔太长,一次持续 30 秒的尖峰可能被平均到一整个小时里,看起来"平均耗时很正常",实际上用户已经骂街了。所以间隔的选择要结合你的诊断粒度需求,后面配置章节会细讲。

2.3 规范化文本让同类查询归并

Query Store 会把WHERE id = 123和WHERE id = 456归并成同一个查询模板WHERE id = @p。这个机制叫查询规范化。它的价值在于:你不会因为参数不同就把同一条逻辑查询看成几百条不同的记录,统计才有意义。

但反过来,如果某条查询因为参数嗅探(parameter sniffing)导致不同参数走不同计划,规范化之后它们会挂在同一个 query_id 下,但对应多个 plan_id。这恰恰是诊断计划回归的关键线索——同一个查询模板,多个计划,性能差异巨大,你一眼就能看出问题。

注意:规范化不是万能的。有些动态拼接 SQL 因为文本结构差异太大,会被识别成不同查询。这时候你需要关注sys.query_store_query里的query_hash和query_plan_hash,用哈希来判断逻辑等价性。

3. 为什么它比传统诊断手段更靠谱

3.1 传统手段的三个致命短板

在没有 Query Store 的年代,我们诊断性能问题主要靠这几样:执行计划缓存 DMV、Profiler/Extended Events 抓现场、性能计数器看趋势。这三样各有短板。

执行计划缓存 DMV 的问题是易失。计划被驱逐、实例重启、DBCC FREEPROCCACHE一执行,历史全没了。而且它只保留"当前"的计划,你没法知道这条查询过去用过哪些计划、什么时候切换的。

Profiler 和 Extended Events 的问题是开销和时效。你要抓现场就得一直开着,开销不小;而且它是"从现在开始"抓,历史问题抓不到。等你收到告警再开,黄花菜都凉了。

性能计数器的问题是粒度太粗。它告诉你"这台服务器 CPU 高了",但不会告诉你"是哪条查询、哪个计划导致的"。从计数器到具体 SQL,中间还差着十万八千里。

3.2 Query Store 补上了"历史可回溯"这块拼图

Query Store 的定位很清晰:它不替代 Extended Events 做深度单次诊断,也不替代性能计数器做系统级监控,它专门解决"查询级别的历史性能回溯"。

举个我实际遇到的场景。某业务库每周一早上会有一波性能抖动,持续大概十几分钟。用传统手段根本抓不到,因为等你收到告警连上去,抖动已经过去了。开了 Query Store 之后,我直接查过去一周同一时间段的运行时统计,发现是某条报表查询在周一早上被定时任务触发,走了个全表扫描的计划,把 IO 打满了。有了这个证据,优化就有了明确方向——要么给那条查询加索引,要么用计划强制固定一个好计划。

这种"事后取证"能力,是 Query Store 最核心的价值。它把性能诊断从"现场抓包"变成了"历史取证",工作方式完全不一样了。

3.3 计划强制:从诊断到干预的闭环

Query Store 还有个杀手锏功能叫计划强制(Plan Forcing)。当你发现某条查询因为计划回归变慢了,而旧的好计划还在 Query Store 里存着,你可以直接"强制"数据库使用那个旧计划,不用改代码、不用加 Hint、不用重启。

这个功能的原理是:Query Store 在编译阶段介入,如果发现这条查询被标记了强制计划,就跳过优化器的正常选择流程,直接用指定的计划。这相当于给优化器打了个"补丁",在紧急故障时能快速止血。

当然,计划强制不是银弹。如果数据分布发生了根本变化,旧计划可能也不适用了,强制反而会带来新问题。所以它更适合作为"临时止血 + 争取优化时间"的手段,而不是长期方案。这个度怎么把握,后面实战篇会详细讲。

4. 开启与配置:参数背后的取舍

4.1 开启 Query Store 的最小操作

开启本身很简单,一条语句的事:

ALTER DATABASE [你的数据库名] SET QUERY_STORE = ON;

但默认配置未必适合生产。默认情况下,OPERATION_MODE是READ_WRITE,DATA_FLUSH_INTERVAL_SECONDS是 900 秒(15 分钟),INTERVAL_LENGTH_MINUTES是 60 分钟,MAX_STORAGE_SIZE_MB是 100MB,QUERY_CAPTURE_MODE是ALL。这套默认值在小库上够用,但在高负载库上要么存储爆掉,要么采集粒度太粗。

一个更贴近生产的配置大概长这样:

ALTER DATABASE [你的数据库名] SET QUERY_STORE = ON ( OPERATION_MODE = READ_WRITE, CLEANUP_POLICY = (STALE_QUERY_THRESHOLD_DAYS = 30), DATA_FLUSH_INTERVAL_SECONDS = 900, INTERVAL_LENGTH_MINUTES = 15, MAX_STORAGE_SIZE_MB = 2048, QUERY_CAPTURE_MODE = AUTO, SIZE_BASED_CLEANUP_MODE = AUTO, MAX_PLANS_PER_QUERY = 200, WAIT_STATS_CAPTURE_MODE = ON );

下面逐个说这些参数为什么这么设。

4.2 关键参数逐个拆解

INTERVAL_LENGTH_MINUTES:这是聚合桶的长度。默认 60 分钟太粗,我一般设 15 分钟。为什么?因为大多数性能抖动持续几分钟到十几分钟,15 分钟的桶能大致框住它,又不至于让存储增长太快。如果你做的是高频交易类系统,抖动可能只有几十秒,那可以设到 5 分钟甚至 1 分钟,代价是存储消耗成倍增加。

MAX_STORAGE_SIZE_MB:Query Store 能占用的最大空间。默认 100MB 在高负载库上可能几小时就满了,满了之后会进入只读模式,不再采集新数据。生产库我一般给到 1GB 到 4GB,具体看库的写入量和保留天数。这里有个计算思路:假设你每秒有 100 条不同的查询模板,每个桶每条记录大概几百字节,15 分钟一个桶,一天 96 个桶,30 天就是 2880 个桶,粗算下来 100 × 2880 × 500 字节 ≈ 144MB,再算上计划 XML 的开销,给 1GB 是比较稳妥的。

QUERY_CAPTURE_MODE:控制哪些查询被采集。ALL是全采,AUTO是引擎自动忽略那些执行次数极少、开销极小的查询,NONE是停采。生产环境我强烈建议用AUTO,因为ALL会把大量一次性、低价值的查询也记进去,白白撑大存储。AUTO的过滤逻辑是引擎内置的,它会放过那些"值得关注"的查询。

MAX_PLANS_PER_QUERY:同一条查询最多保留多少个计划。默认 200。如果你的查询因为参数嗅探频繁生成新计划,这个值可能不够,但设太大又会撑存储。200 对绝大多数场景够用,除非你遇到那种计划爆炸的极端情况。

WAIT_STATS_CAPTURE_MODE:是否采集等待统计。这个在 SQL Server 2017 之后才有,Azure SQL DB 也支持。开启后你能看到每条查询的等待类型分布,对诊断锁等待、IO 等待非常有用。代价是额外的存储和轻微开销,生产环境建议开。

STALE_QUERY_THRESHOLD_DAYS:超过这个天数没被执行的查询会被清理。30 天是个平衡点,既保留了足够的历史,又不会让存储无限增长。

4.3 配置时的三个实操心得

第一,别在业务高峰期改配置。修改 Query Store 配置会触发一次内部的重置或清理,虽然开销不大,但在高负载时可能引起短暂的统计抖动。我一般选在低峰期做。

第二,改完配置后观察存储增长。用这个查询看当前占用:

SELECT current_storage_size_mb, max_storage_size_mb, readonly_reason FROM sys.database_query_store_options;

如果readonly_reason不为 0,说明 Query Store 已经进入只读,不再采集新数据了,通常是存储满了。这时候要么调大MAX_STORAGE_SIZE_MB,要么缩短保留天数。

第三,Azure SQL Database 上的配置持久性。Azure SQL DB 的 Query Store 配置是跟着数据库走的,但如果你用了弹性池或者做了异地复制,要注意配置不会自动同步到副本。副本上的 Query Store 需要单独确认状态。

5. 怎么读 Query Store 的数据

5.1 从内置报表入手最快

如果你用 SQL Server Management Studio 或者 Azure Data Studio,连上数据库后展开数据库节点,会看到一个"Query Store"文件夹,里面有一堆现成的报表:回归查询、总体资源消耗、耗时最长的查询、等待统计等等。

新手我建议先从"总体资源消耗"和"回归查询"这两个报表看起。前者告诉你哪些查询最耗资源,后者直接帮你揪出"变慢了的查询"。这两个报表能覆盖 80% 的日常诊断需求,不用写一行 SQL。

但报表有个局限:它展示的是聚合后的结果,你想深挖细节还是得自己查视图。而且 Azure SQL DB 的某些报表在门户上的呈现和 SSMS 里不完全一样,习惯哪个用哪个。

5.2 手写查询的几个核心视图

要真正玩转 Query Store,得熟悉这几个视图的关联关系:

视图名作用关键字段
sys.query_store_query查询模板信息query_id, query_text_id, query_hash
sys.query_store_query_text查询文本query_text_id, query_sql_text
sys.query_store_plan执行计划plan_id, query_id, query_plan, is_forced_plan
sys.query_store_runtime_stats运行时统计plan_id, avg_duration, count_executions
sys.query_store_runtime_stats_interval时间区间runtime_stats_interval_id, start_time, end_time
sys.query_store_wait_stats等待统计plan_id, wait_category, total_query_wait_time_ms

一个典型的"找出过去 24 小时最耗 CPU 的查询"的查询长这样:

SELECT TOP 20 q.query_id, qt.query_sql_text, SUM(rs.count_executions) AS total_executions, SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0 AS total_cpu_ms, SUM(rs.avg_duration * rs.count_executions) / 1000.0 AS total_duration_ms FROM sys.query_store_query q JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id JOIN sys.query_store_plan p ON q.query_id = p.query_id JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id JOIN sys.query_store_runtime_stats_interval rsi ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id WHERE rsi.start_time >= DATEADD(HOUR, -24, GETUTCDATE()) GROUP BY q.query_id, qt.query_sql_text ORDER BY total_cpu_ms DESC;

这里有个细节要注意:avg_cpu_time是平均值,要算总量得乘以count_executions。很多人直接对avg_cpu_time求和,结果完全不对。这是新手最容易踩的坑之一。

5.3 时间区间视图是理解聚合的钥匙

sys.query_store_runtime_stats_interval这张表很多人忽略,但它其实是理解 Query Store 数据模型的关键。它记录了每个聚合桶的起止时间。你查运行时统计时,如果不 join 这张表,就不知道那条统计属于哪个时间段。

而且这张表能帮你判断"数据是不是断了"。如果某个时间段没有对应的 interval 记录,说明那段时间 Query Store 没在采集(可能是被关了,或者进入了只读)。诊断时看到数据有断档,先别急着下结论,确认一下采集是否正常。

提示:Azure SQL Database 上时间都是 UTC 存储的。你本地时间如果是东八区,查的时候记得做时区转换,否则会看错时间段。我见过有人因为没转时区,把凌晨的低峰数据当成晚高峰来分析,结论完全跑偏。

6. 常见问题与排查技巧实录

6.1 Query Store 开了但看不到数据

这是最高频的问题。排查顺序我一般是这样:

先查sys.database_query_store_options,看actual_state_desc字段。如果是OFF,说明没开成功;如果是READ_ONLY,看readonly_reason的值,常见的是存储满了(值为 2 或 3)。如果是READ_WRITE但没数据,那可能是QUERY_CAPTURE_MODE设成了NONE,或者刚开启还没到第一个 flush 周期。

还有一个容易被忽略的点:Query Store 是按数据库开启的。你在实例级别找不到它,必须切到具体的用户数据库里操作。有些人连到 master 库去查,当然什么都没有。

6.2 存储增长过快怎么办

先看是哪类查询在撑存储。用这个查询找占用最多的查询模板:

SELECT TOP 10 q.query_id, qt.query_sql_text, COUNT(DISTINCT p.plan_id) AS plan_count, SUM(rs.count_executions) AS total_exec FROM sys.query_store_query q JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id JOIN sys.query_store_plan p ON q.query_id = p.query_id JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id GROUP BY q.query_id, qt.query_sql_text ORDER BY plan_count DESC;

如果发现某条查询的plan_count特别大(几百上千),说明它计划爆炸了,通常是参数嗅探或者动态 SQL 导致的。解决办法:要么调小MAX_PLANS_PER_QUERY,要么针对这条查询做优化(比如用OPTION (RECOMPILE)或者参数化改造)。

另一个常见原因是INTERVAL_LENGTH_MINUTES设得太小。如果你设成 1 分钟,存储消耗是 15 分钟配置的 15 倍。除非你有明确的秒级诊断需求,否则别设这么小。

6.3 计划强制后反而更慢了

这个坑我踩过。计划强制的前提是"旧计划在当前数据分布下依然适用"。如果数据量、数据分布已经发生了根本变化,旧计划可能已经是个坏计划了,强制它等于主动选择慢。

判断方法:强制之后观察一段时间,对比强制前后的avg_duration和avg_cpu_time。如果强制后指标反而恶化,赶紧取消强制:

EXEC sys.sp_query_store_unforce_plan @query_id = 123, @plan_id = 456;

我的经验是,计划强制只适合作为紧急止血手段,用完之后必须跟进根因优化。长期依赖强制计划,等于把优化器的智能给废了,迟早出问题。

6.4 常见问题速查表

现象可能原因排查动作
看不到任何数据未开启 / 只读 / 采集模式为 NONE查 sys.database_query_store_options
数据有断档采集中断 / 只读 / 实例重启查 runtime_stats_interval 连续性
存储增长快间隔太短 / 计划爆炸 / 保留太久查 plan_count 和 interval 配置
强制计划无效计划已不适用 / 被新编译覆盖对比强制前后性能指标
时间对不上UTC 与本地时区差异查询时做时区转换
查询文本被截断文本超长查 query_sql_text 完整字段

6.5 几个只有踩过才知道的细节

细节一:Query Store 的数据在数据库还原、附加之后会保留,但query_id和plan_id可能会重新分配。如果你有脚本硬编码了这些 ID,还原后会失效。所以计划强制这类操作,最好在还原后重新确认一遍。

细节二:在 Azure SQL Database 上,如果你做了"时间点还原",还原出来的库会继承源库的 Query Store 数据,但采集会从还原点重新开始。这意味着还原点之后的数据是空的,别以为是功能坏了。

细节三:sys.query_store_runtime_stats里的avg_duration单位是微秒,不是毫秒。我第一次看的时候差点把 200 微秒当成 200 毫秒,闹了笑话。换算成毫秒要除以 1000。

细节四:Query Store 对 DDL 语句、系统存储过程这类查询的采集行为跟普通 DML 不一样。有些系统内部查询不会被采集,这是设计如此,不是 bug。

7. 这一篇先聊到这

Query Store 这个东西,刚上手会觉得视图多、关联复杂,但一旦理解了它"持久化 + 时间序列 + 计划关联"这三个核心设计,后面查什么都是顺藤摸瓜。我个人在实际操作中的体会是,别一上来就想着写复杂 SQL,先用内置报表把整体情况摸清楚,锁定可疑查询之后,再针对性地查视图深挖。这样效率最高,也不容易迷失在数据里。

下一篇我会重点讲运行时统计的深度解读,包括怎么从avg_duration和stdev_duration的对比中判断性能抖动,以及等待统计怎么帮你定位瓶颈到底在 CPU、IO 还是锁上。如果你现在手上就有 Query Store 数据,可以先自己试着查一下过去 24 小时执行次数最多的前十条查询,感受一下它的数据长什么样,下一篇看起来会更有体感。

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

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

立即咨询