1. 为什么 Query Store 值得单独拿出来讲
做数据库运维和优化的朋友大概率都经历过这样的场景:某个存储过程上周跑得飞快,这周突然慢成蜗牛,你去问开发改了什么,开发说没改;你去查执行计划,发现跟之前完全不一样了,但问题是——之前的计划长什么样,你根本没有任何记录。这种“死无对证”的窘境,在没有 Query Store 之前,几乎是 DBA 日常的真实写照。
Query Store 是 SQL Server 2016 引入、并在 Azure SQL Database 和 Azure SQL Data Warehouse(现在叫 Synapse SQL 专用池)中默认开启的一项“飞行记录仪”功能。它做的事情说起来很简单:把查询语句、执行计划、运行时统计信息持久化地存到用户数据库内部,让你随时可以回看某条语句在任意时间段的性能表现和计划变化。但真正用起来之后你会发现,它解决的不只是“回看”的问题,而是从根本上改变了性能排障的工作方式。
这篇文章适合谁看?如果你平时要管 Azure SQL Database 或者 SQL DW,经常被“突然变慢”搞得焦头烂额,或者你正在做性能基线建设、计划回归检测,那 Query Store 是你绕不开的一个核心工具。即便你用的是本地 SQL Server,只要版本在 2016 以上,这套思路也完全通用。我会从设计思路、核心概念、实操配置、常见问题排查几个维度,把 Query Store 拆开揉碎讲清楚,尽量让你看完就能上手用起来。
2. Query Store 的整体设计与核心思路拆解
2.1 它到底解决了什么痛点
在 Query Store 出现之前,我们排查性能问题主要靠几个东西:DMV(动态管理视图)、执行计划缓存、扩展事件、SQL Trace/Profiler。这些工具不是不好用,而是各有各的硬伤。
DMV 和计划缓存的问题在于“易失性”。服务器一重启,缓存清空;内存压力一大,计划被踢出去;哪怕什么都没发生,缓存也会自然老化。你上午看到的慢查询,下午再去看,可能计划已经不在缓存里了。扩展事件和 Profiler 倒是能抓实时数据,但开销不小,而且你得提前开着,事后想补抓是不可能的。
Query Store 的思路完全不同。它把查询相关的信息当作“业务数据”一样持久化存储,写在用户数据库内部的一组系统表中。这意味着:服务器重启不影响、内存压力不影响、计划被淘汰不影响。你随时可以查到“过去一小时/一天/一周”内某条语句的执行情况。这个设计思路的转变,是它最大的价值所在。
2.2 内部架构:数据是怎么被记录下来的
Query Store 在内部大致是这样工作的:当一条查询被编译或执行时,数据库引擎会异步地把相关信息写入内存中的缓冲区,然后由后台任务定期刷到磁盘上的内部表中。整个过程对用户查询的性能影响非常小,官方给出的数据是通常不超过 1%-2% 的开销,实际测下来在绝大多数场景下基本感知不到。
它记录的核心信息包括几类:查询文本的规范化形式(把参数值替换成占位符,这样不同参数的同一条语句归为一组)、执行计划(包括计划本身的 XML 和计划指纹)、运行时统计(执行次数、持续时间、CPU 时间、逻辑读/物理读、内存授予等)、等待统计(这个在较新版本中才有)。这些数据按照时间窗口聚合,你可以按小时间隔、天间隔去查看趋势。
这里有个关键概念叫“计划指纹”(Plan Fingerprint),它是根据计划的结构算出来的一个哈希值。同一个查询文本可能对应多个计划指纹,因为优化器可能因为统计信息变化、参数嗅探、索引变更等原因生成不同的计划。Query Store 把“查询”和“计划”分开存储,再用关联表把它们连起来,这样你就能清楚地看到:同一条语句,在不同时间段用了哪些不同的计划,每个计划的表现如何。
2.3 三种采集模式的选择逻辑
Query Store 有一个很重要的配置项叫QUERY_CAPTURE_MODE,它决定了哪些查询会被记录。这个参数直接影响到存储开销和数据的可用性,选错了要么浪费空间,要么漏掉关键信息。
- ALL:捕获所有查询。数据最全,但开销最大,尤其是在有大量即席查询(ad-hoc)的系统上,Query Store 会迅速膨胀。
- AUTO(默认):只捕获“有意义”的查询。具体规则是:执行次数够多、或者资源消耗够大的语句才会被记录,那些只跑一次、开销又小的语句会被过滤掉。这个模式在绝大多数场景下是最优解。
- NONE:停止捕获新查询,但已有的数据继续保留。适合你想临时冻结采集、只做分析的时候用。
- CUSTOM(SQL Server 2019 及 Azure SQL 较新版本支持):你可以自定义捕获策略,比如设置执行次数阈值、总耗时阈值等。
我个人的建议是:除非你有非常明确的合规审计需求需要记录所有语句,否则一律用 AUTO。AUTO 模式下 Query Store 的体积增长是可控的,而且它过滤掉的本来就是那些不值得关注的“噪音”查询。
3. 核心概念与关键参数实操解析
3.1 开启 Query Store 的正确姿势
在 Azure SQL Database 上,Query Store 默认就是开启的,而且默认使用 AUTO 捕获模式。你可以通过下面这条语句确认当前状态:
SELECT actual_state_desc, readonly_reason, current_storage_size_mb, max_storage_size_mb, query_capture_mode_desc FROM sys.database_query_store_options;如果actual_state_desc显示为OFF,那说明没开。开启的方式很简单:
ALTER DATABASE [你的数据库名] SET QUERY_STORE = ON ( OPERATION_MODE = READ_WRITE, QUERY_CAPTURE_MODE = AUTO, MAX_STORAGE_SIZE_MB = 1024, INTERVAL_LENGTH_MINUTES = 60, SIZE_BASED_CLEANUP_MODE = AUTO, STALE_QUERY_THRESHOLD_DAYS = 30 );这里几个参数值得展开说一下。MAX_STORAGE_SIZE_MB控制 Query Store 能用的最大空间,默认是 100MB,对于繁忙的系统来说往往不够用,建议根据你的负载调到 512MB 到 2GB 之间。INTERVAL_LENGTH_MINUTES决定运行时统计的聚合粒度,默认 60 分钟,调小(比如 15 分钟)能看到更细的趋势,但存储开销会相应增加。STALE_QUERY_THRESHOLD_DAYS控制多久没执行的查询会被清理掉,默认 30 天。
注意:在 Azure SQL Database 上,Query Store 的最大存储空间上限跟你的服务层级有关。Basic 层最多 100MB 左右,S 系列可以到 1GB 以上。如果你发现 Query Store 进入只读状态,第一件事就是查
readonly_reason,十有八九是空间满了。
3.2 读懂 Query Store 的核心视图
Query Store 的数据通过一组目录视图暴露出来,最常用的有这几个:
| 视图名称 | 作用 | 关键字段 |
|---|---|---|
sys.query_store_query | 存储规范化后的查询文本 | query_id,query_text_id,object_id |
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,avg_cpu_time,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 |
实际用的时候,你很少需要直接去 join 这些底层视图。Query Store 提供了一批内置的存储过程和 DMV 封装,比如sys.query_store_top_resource_consumers这类,但更常见的方式是通过 SSMS 的图形界面或者自己写查询来组合。
举个实际查询的例子,找出过去 24 小时内平均 CPU 时间最高的 10 条语句:
SELECT TOP 10 q.query_id, qt.query_sql_text, SUM(rs.count_executions) AS total_executions, AVG(rs.avg_cpu_time) AS avg_cpu_time, AVG(rs.avg_duration) AS avg_duration FROM sys.query_store_runtime_stats rs JOIN sys.query_store_plan p ON rs.plan_id = p.plan_id JOIN sys.query_store_query q ON p.query_id = q.query_id JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_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 avg_cpu_time DESC;这个查询模式你可以在很多场景下复用,把avg_cpu_time换成avg_duration、avg_logical_io_reads就能从不同维度找问题。
3.3 计划强制:Query Store 最被低估的功能
Query Store 有一个功能叫“计划强制”(Plan Forcing),我觉得是它最有价值的能力之一,但很多人没用起来。场景是这样的:某条查询之前一直用计划 A,跑得很快;某天优化器突然选了个计划 B,性能暴跌。你通过 Query Store 找到了之前的好计划 A,然后可以手动把计划 A 强制绑定给这条查询。之后不管优化器怎么想,都会用计划 A。
操作方式有两种。图形界面下,在 SSMS 的 Query Store 报表里找到对应查询,右键选择“Force Plan”即可。T-SQL 方式如下:
EXEC sys.sp_query_store_force_plan @query_id = 123, @plan_id = 456;取消强制用sp_query_store_unforce_plan。你还可以通过sys.query_store_plan的is_forced_plan字段查看哪些计划被强制了。
实操心得:计划强制不是万能药。如果数据分布发生了根本性变化,旧计划可能也不再适用。我的做法是,强制之后持续观察一段时间,设置一个提醒,如果性能再次下降就重新评估。另外,强制计划不会阻止优化器重新编译,只是编译后会被替换回你指定的计划,所以它有一定的 CPU 开销,不要滥用。
3.4 计划回归检测:自动化发现“变慢”的元凶
Query Store 内置了一个“计划回归”检测能力。所谓回归,就是同一条查询的新计划比旧计划性能明显更差。你可以通过下面的查询找出最近发生回归的语句:
SELECT q.query_id, qt.query_sql_text, p.plan_id, p.is_forced_plan, rs.avg_duration, rs.count_executions, rsi.start_time FROM sys.query_store_plan p JOIN sys.query_store_query q ON p.query_id = q.query_id JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_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 q.query_id IN ( SELECT query_id FROM sys.query_store_plan GROUP BY query_id HAVING COUNT(DISTINCT plan_id) > 1 ) ORDER BY q.query_id, rsi.start_time;这个查询会列出所有有多个计划的查询,以及每个计划在不同时间段的性能。你可以直观地看到:某个时间点之后,计划 ID 变了,平均耗时从 50ms 跳到了 500ms,那基本就是计划回归了。
Azure SQL Database 的自动调优(Automatic Tuning)功能就是基于 Query Store 的数据来做决策的。它可以自动检测回归并帮你强制回旧计划,甚至可以自动创建缺失的索引。不过自动调优默认是关闭的,需要手动在数据库级别开启。我个人的建议是,先把 Query Store 跑一段时间积累数据,观察自动调优给出的建议是否靠谱,再决定要不要让它自动执行。
4. 完整实操流程与配置方案
4.1 从零开始配置一套可用的 Query Store
假设你刚接手一个 Azure SQL Database,Query Store 还没开或者用的是默认配置。下面是我推荐的一套配置流程。
第一步,检查当前状态和数据库负载特征:
-- 查看当前配置 SELECT * FROM sys.database_query_store_options; -- 粗略评估数据库的查询负载量 SELECT COUNT(*) AS cached_plan_count FROM sys.dm_exec_cached_plans;如果cached_plan_count很大(比如超过 5 万),说明即席查询很多,AUTO 捕获模式是必须的,否则 Query Store 会爆。
第二步,根据负载调整配置。下面这套参数适合大多数中等负载的生产库:
ALTER DATABASE [你的数据库名] SET QUERY_STORE = ON ( OPERATION_MODE = READ_WRITE, QUERY_CAPTURE_MODE = AUTO, MAX_STORAGE_SIZE_MB = 1024, INTERVAL_LENGTH_MINUTES = 30, SIZE_BASED_CLEANUP_MODE = AUTO, STALE_QUERY_THRESHOLD_DAYS = 30, WAIT_STATS_CAPTURE_MODE = ON );WAIT_STATS_CAPTURE_MODE在较新版本中可用,开启后会记录等待类型,对分析阻塞和资源争用非常有帮助。INTERVAL_LENGTH_MINUTES设为 30 是一个折中,既能看清趋势,又不会让统计表膨胀太快。
第三步,验证配置生效:
SELECT actual_state_desc, readonly_reason, current_storage_size_mb, max_storage_size_mb, query_capture_mode_desc, interval_length_minutes, stale_query_threshold_days FROM sys.database_query_store_options;actual_state_desc应该是READ_WRITE,readonly_reason应该是 0 或 NULL。
4.2 日常监控:每天花五分钟看什么
配置好之后,日常怎么用?我自己的习惯是每天上班第一件事,跑几个固定查询扫一眼。
第一个,看 Query Store 自身是否健康:
SELECT current_storage_size_mb, max_storage_size_mb, (current_storage_size_mb * 100.0 / max_storage_size_mb) AS used_pct, readonly_reason FROM sys.database_query_store_options;如果used_pct超过 80%,就要考虑调大MAX_STORAGE_SIZE_MB或者缩短STALE_QUERY_THRESHOLD_DAYS。
第二个,看过去 24 小时最耗资源的查询:
SELECT TOP 20 qt.query_sql_text, SUM(rs.count_executions) AS exec_count, SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) AS avg_duration_ms, SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) AS avg_cpu_ms, SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions) AS avg_logical_reads FROM sys.query_store_runtime_stats rs JOIN sys.query_store_plan p ON rs.plan_id = p.plan_id JOIN sys.query_store_query q ON p.query_id = q.query_id JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_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 qt.query_sql_text ORDER BY avg_cpu_ms DESC;第三个,看有没有计划回归的迹象。这个可以用前面 3.4 节的查询,也可以简化成:找出过去 24 小时内平均耗时比之前 7 天平均值高出 3 倍以上的查询。
4.3 在 Azure SQL DW(Synapse SQL 专用池)上的差异
Azure SQL Data Warehouse 的 Query Store 跟 Azure SQL Database 有一些差异,需要特别注意。
首先,在专用池上 Query Store 默认是开启的,但捕获模式和行为可能因版本而异。其次,由于 DW 的查询模式跟 OLTP 完全不同——通常是少量的大批量分析查询,而不是海量的小事务——所以QUERY_CAPTURE_MODE设为 ALL 也未尝不可,因为查询数量本身就少。
另外,DW 上的 Query Store 对存储空间的消耗相对更敏感,因为分析查询的文本和计划通常更大。建议把MAX_STORAGE_SIZE_MB设得充裕一些,同时定期检查stale_query_threshold_days是否合理。
在 DW 上排查问题时,除了看 CPU 和耗时,还要重点关注avg_logical_io_reads和avg_physical_io_reads,因为数据仓库的瓶颈往往在 IO 上。等待统计在 DW 上也特别有用,可以帮你区分是 CPU 瓶颈、IO 瓶颈还是并发瓶颈。
5. 常见问题与排查技巧实录
5.1 Query Store 进入只读状态怎么办
这是最常见的问题,没有之一。表现是actual_state_desc变成READ_ONLY,新的查询数据不再被记录。原因通常有三个:
- 空间满了:
readonly_reason会返回 1 或 2。解决办法是调大MAX_STORAGE_SIZE_MB,或者手动清理旧数据。 - 数据库本身是只读的:比如你做了 geo-replica 的只读副本,Query Store 自然也是只读的。
- 权限问题:当前登录账号没有足够的权限。
手动清理旧数据的语句:
ALTER DATABASE [你的数据库名] SET QUERY_STORE CLEAR; -- 或者更精细地清理 ALTER DATABASE [你的数据库名] SET QUERY_STORE (STALE_QUERY_THRESHOLD_DAYS = 7);注意:
CLEAR会清空所有 Query Store 数据,包括你正在分析的历史记录。执行前务必确认你不再需要这些数据。更稳妥的做法是先调大空间上限,让系统自动清理。
5.2 为什么有些查询在 Query Store 里找不到
如果你发现某条明明跑过的查询在 Query Store 里查不到,先检查这几项:
- 捕获模式是不是 AUTO?AUTO 模式下,执行次数少且资源消耗低的查询不会被记录。你可以临时改成 ALL 来验证。
- 查询是不是在 Query Store 开启之前执行的?Query Store 只记录开启之后的数据。
- 查询是不是在系统数据库或 master 上执行的?Query Store 是数据库级别的,只记录当前数据库内的查询。
- 查询是不是被
STALE_QUERY_THRESHOLD_DAYS清理掉了?如果一条查询超过阈值天数没执行,它的数据会被自动清理。
5.3 计划强制后性能反而更差了
这种情况虽然不常见,但确实会发生。原因通常是:你强制的那个“好计划”是在特定数据分布下生成的,而现在的数据分布已经变了,旧计划不再适用。
排查方法是:对比强制前后的avg_duration和avg_cpu_time,如果强制后反而更差,立即取消强制:
EXEC sys.sp_query_store_unforce_plan @query_id = 123, @plan_id = 456;然后考虑其他优化手段,比如更新统计信息、添加索引、重写查询。
5.4 Query Store 对性能的影响到底有多大
这是很多人关心的问题。官方文档说开销通常在 1%-2%,但实际影响取决于你的负载特征和配置。在 AUTO 捕获模式下,对绝大多数 OLTP 系统的影响基本可以忽略。但在以下场景下需要留意:
- 系统有大量即席查询,且捕获模式设为 ALL,Query Store 的写入开销会明显增加。
INTERVAL_LENGTH_MINUTES设得太小(比如 1 分钟),聚合频率过高,也会增加开销。- 存储空间不足导致频繁清理,也会带来额外的 IO 压力。
我的建议是,在生产环境开启 Query Store 之前,先在测试环境跑一轮基准测试,对比开启前后的 CPU 和 IO 指标。如果差异在 3% 以内,基本可以放心开。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| Query Store 只读 | 空间满/数据库只读/权限不足 | 查readonly_reason | 调大空间/清理旧数据/检查权限 |
| 查不到某条查询 | 捕获模式过滤/开启前执行/已过期清理 | 临时改 ALL 验证 | 调整捕获模式或阈值 |
| 计划强制后变慢 | 数据分布变化 | 对比强制前后统计 | 取消强制,重新评估 |
| 性能开销明显 | 捕获模式 ALL/聚合间隔过小 | 对比开启前后基准 | 改 AUTO/调大间隔 |
| 存储增长过快 | 即席查询多/阈值过长 | 查current_storage_size_mb | 改 AUTO/缩短阈值/调大上限 |
6. 几个我踩过的坑和实用建议
第一个坑是关于INTERVAL_LENGTH_MINUTES的。我一开始为了看细粒度趋势,把它设成了 5 分钟。结果在一个中等负载的库上,Query Store 的存储空间一周就涨到了 800MB,而且查询统计表的行数暴增,查询变慢。后来改回 30 分钟,空间增长立刻降下来了。所以这个参数不是越小越好,要根据你的分析需求和存储预算来平衡。
第二个坑是关于计划强制的。我曾经在一个关键业务库上强制了一个计划,当时确实解决了性能问题。但三个月后,业务数据量翻了一倍,那个被强制的计划变成了瓶颈。由于没有设置提醒,问题拖了一周才被发现。从那以后,我强制任何计划都会在日历上设一个两周后的提醒,定期回顾。
第三个建议是关于数据保留的。Query Store 的数据不是越多越好。保留 30 天通常足够覆盖大多数性能问题的回溯需求。如果你有合规要求需要保留更久,建议定期把 Query Store 数据导出到外部表,而不是让它一直堆在数据库里。
最后一个实用技巧:你可以用 Query Store 来验证索引变更的效果。在添加或删除索引之前,先记录下相关查询的性能基线;变更之后,对比 Query Store 里的数据,就能量化索引变更带来的收益。这比凭感觉判断靠谱得多。
这个内容后续还可以这样扩展:结合自动调优功能做端到端的性能自愈方案,或者把 Query Store 的数据接入到外部监控平台做统一告警。如果你在用的是 Azure SQL Database,自动调优加上 Query Store 的组合,基本上能覆盖 80% 以上的常见性能问题场景。