ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Query Store 实战:SQL Server 与 Azure SQL 性能调优与计划回归排查指南

Query Store 实战:SQL Server 与 Azure SQL 性能调优与计划回归排查指南

1. 为什么我们需要 Query Store

数据库性能调优这件事,干过几年的人都有一个共同感受:最怕的不是问题本身,而是问题“时好时坏”。早上跑得好好的查询,到了下午突然变慢;你连上去一看,执行计划跟昨天完全不一样了。更麻烦的是,你没法回到过去,看看当时到底发生了什么。

在 Query Store 出现之前,我们排查这类问题的常规手段无非几种:查 DMV(动态管理视图),但 DMV 只保留当前内存中的计划,重启就没了;抓 Profiler 或者 Extended Events,开销大不说,还得提前开着,等你发现问题的时候往往已经错过了现场;再就是靠 Plan Cache 里的残存信息碰运气。这些方法有一个共同的缺陷——没有历史视角。

Query Store 解决的正是这个核心痛点。你可以把它理解成数据库的“飞行记录仪”:它持续不断地把查询语句、执行计划、运行时统计信息落盘保存,形成一个可回溯的历史档案。不管实例重启、计划被逐出缓存,还是统计信息更新导致计划翻转,你都能在 Query Store 里找到完整的证据链。

这个功能最早在 SQL Server 2016 上亮相,随后 Azure SQL Database 和 Azure SQL Data Warehouse(现在叫 Azure Synapse Analytics 里的专用 SQL 池)也陆续支持。对于云上环境来说,Query Store 的价值更大——因为你没法像本地机房那样随意挂 Profiler,很多诊断手段在 PaaS 平台上是被限制的,Query Store 几乎成了唯一稳定可靠的历史性能数据来源。

这篇文章适合谁看?如果你是刚接触 Azure SQL 的开发者,想搞清楚为什么查询有时候快有时候慢;如果你是 DBA,正在为计划回归(Plan Regression)头疼;或者你是数据工程师,负责 SQL DW 上的 ETL 性能调优——Query Store 都是你绕不开的工具。我会从原理讲到实操,从配置参数讲到排查技巧,尽量把踩过的坑都摊开来说。

2. Query Store 的核心机制拆解

2.1 数据采集的三个维度

Query Store 采集的信息可以归为三大类,理解了这三类数据,你就理解了它的全部能力边界。

第一类是查询文本和计划。每次有查询编译或重新编译,Query Store 会把查询的 SQL 文本(或者查询计划的哈希)以及对应的执行计划捕获下来。注意,它捕获的是“计划”本身,不是查询结果。这意味着它不会记录你的数据内容,不用担心敏感信息泄露的问题——但反过来说,如果查询文本里硬编码了敏感值,那还是会被存下来,这一点后面会细说。

第二类是运行时统计。包括执行次数、总耗时、平均耗时、逻辑读、物理读、CPU 时间、内存授予、等待类型等等。这些数据是按时间窗口聚合的,默认按小时汇总,你也可以手动调整间隔。正是有了这些统计,你才能回答“这个查询上周的平均耗时是多少”这种问题。

第三类是计划相关的元数据。比如计划是什么时候被编译的、用了哪些索引、有没有并行、内存授予多少、是否发生了计划回归。这些信息帮助你判断一个计划“为什么”变慢,而不仅仅是“变慢了”。

2.2 数据在内部是怎么组织的

Query Store 在内部使用一组系统视图来暴露数据,核心的几个视图你需要记住:

视图名称作用
sys.query_store_query_text存储查询文本
sys.query_store_query存储查询的编译信息,关联文本和计划
sys.query_store_plan存储执行计划及其元数据
sys.query_store_runtime_stats存储运行时聚合统计
sys.query_store_runtime_stats_interval定义统计的时间窗口
sys.query_store_wait_stats存储等待统计信息

这些视图之间的关系是:一个查询文本可以对应多个查询(比如不同 SET 选项下编译的同一语句),一个查询可以对应多个计划(计划回归的根源),一个计划可以对应多个时间窗口的运行时统计。理解这个层级关系,后面写排查查询的时候才不会写错 JOIN。

数据写入是异步的,默认情况下不会阻塞用户查询。但有一个例外:当 Query Store 的存储空间快满的时候,它会从READ_WRITE模式切换到READ_ONLY模式,这时候新的数据就不再采集了。所以监控 Query Store 自身的空间使用情况是运维的必修课。

2.3 与 Plan Cache 的本质区别

很多人会把 Query Store 和 Plan Cache 混为一谈,觉得都是存计划的地方。但两者的定位完全不同。

Plan Cache 是内存结构,目的是加速查询编译——下次同样的查询来了,直接从缓存里拿计划,省去编译开销。它的生命周期由内存压力和计划逐出策略决定,实例一重启就全没了。你没法控制它保留什么、丢弃什么。

Query Store 是持久化存储,目的是提供历史诊断能力。它的数据写在用户数据库的文件组里(Azure SQL 上你感知不到具体文件,但逻辑上是一样的),重启不丢。你可以配置保留策略,决定保留多久、最多占多少空间。它不参与查询编译的加速,纯粹是“记录”。

打个比方:Plan Cache 像是你书桌上随手放的工具,用完可能就收起来了;Query Store 像是仓库里的档案柜,东西放进去就一直在,需要的时候去翻。

3. 在 Azure SQL DB 上启用与配置 Query Store

3.1 默认状态与启用方式

Azure SQL Database 从某个时间点开始,新建数据库默认就开启了 Query Store,模式是READ_WRITE,保留策略是自动的。但如果你是从本地迁移上来的,或者数据库创建得比较早,可能需要手动开启。

开启的命令很简单:

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

如果要同时指定参数:

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 = 60, MAX_STORAGE_SIZE_MB = 1000, QUERY_CAPTURE_MODE = AUTO, SIZE_BASED_CLEANUP_MODE = AUTO );

这里每个参数都有讲究,我逐个拆解。

3.2 关键参数逐个讲透

OPERATION_MODE:可选READ_WRITE、READ_ONLY、OFF。READ_WRITE是正常采集模式;READ_ONLY表示还能查历史数据但不再采集新数据,通常出现在空间满的时候;OFF就是彻底关闭。生产环境当然用READ_WRITE,但如果遇到性能问题怀疑是 Query Store 本身导致的,可以临时切到READ_ONLY观察。

CLEANUP_POLICY (STALE_QUERY_THRESHOLD_DAYS):数据保留天数,默认 30 天。这个值设多大取决于你的排查需求。如果你需要做月度对比分析,那就设 30 以上;如果只是日常排障,7 到 14 天也够。设得越大,占用空间越多。

DATA_FLUSH_INTERVAL_SECONDS:内存中的数据多久刷一次到磁盘,默认 900 秒(15 分钟)。这意味着如果实例突然崩溃,最多可能丢失 15 分钟的数据。如果你对数据完整性要求高,可以调小到 300 秒甚至更短,但会增加 I/O 开销。实测下来,对于大多数业务场景,900 秒是可以接受的。

INTERVAL_LENGTH_MINUTES:运行时统计的聚合窗口,默认 60 分钟。这个参数直接影响你查询统计信息时的粒度。设成 60,你看到的就是每小时一条记录;设成 5,粒度更细但数据量会翻很多倍。我的建议是:如果主要做趋势分析,60 分钟足够;如果要精确定位某个时间点的性能抖动,可以设成 15 或 30。

MAX_STORAGE_SIZE_MB:Query Store 能用的最大空间,默认 100 MB。这个默认值在繁忙系统上很快就会被撑满,一旦满了就变成READ_ONLY,后续数据全部丢失。生产环境我一般建议至少设 1000 MB,写入量大的系统可以设到 5000 MB 甚至更高。具体设多少,可以先用下面的查询看看当前实际用量:

SELECT current_storage_size_mb, max_storage_size_mb, (current_storage_size_mb * 100.0 / max_storage_size_mb) AS used_percent FROM sys.database_query_store_options;

QUERY_CAPTURE_MODE:控制哪些查询会被捕获。可选值有ALL、AUTO、NONE、CUSTOM。ALL是捕获所有查询,包括那些只执行一次的临时查询,数据量会很大;AUTO是智能模式,会忽略执行次数少、耗时短的查询,推荐大多数场景使用;CUSTOM允许你通过QUERY_CAPTURE_POLICY自定义捕获条件,比较灵活但配置复杂。

SIZE_BASED_CLEANUP_MODE:当空间使用达到 90% 时是否自动清理最旧的数据。AUTO是开启,OFF是关闭。建议保持AUTO,否则空间满了就只能手动清理。

3.3 在 Azure SQL DW 上的差异

Azure SQL DW(专用 SQL 池)上的 Query Store 和 SQL DB 有一些差异,需要特别注意。

首先,SQL DW 上的 Query Store 默认是关闭的,需要手动开启。其次,由于 SQL DW 的 MPP 架构,查询是在多个计算节点上并行执行的,Query Store 采集的是协调节点上的信息,某些分布式执行的细节可能不如单节点那么完整。第三,SQL DW 上的存储空间管理和 SQL DB 不同,MAX_STORAGE_SIZE_MB的默认值和上限可能不一样,配置前最好查一下当前版本的具体限制。

在 SQL DW 上排查性能问题,Query Store 最大的价值在于识别那些“在某个时间点突然变慢”的 ETL 查询。因为 ETL 通常是批量定时执行的,一旦某个环节的计划发生回归,整个数据加载窗口就可能被拖长,而 Query Store 能帮你精确定位到是哪个查询、哪个计划出了问题。

4. 用 Query Store 排查真实性能问题

4.1 找到最耗资源的查询

这是最常见的需求:数据库整体变慢了,到底是哪些查询在拖后腿?

下面这个查询按总 CPU 时间排序,列出 Top 20 的查询:

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, SUM(rs.avg_logical_io_reads * rs.count_executions) AS total_logical_reads 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(DAY, -7, GETUTCDATE()) GROUP BY q.query_id, qt.query_sql_text ORDER BY total_cpu_ms DESC;

注意这里用avg_cpu_time * count_executions来估算总 CPU 时间,因为runtime_stats里存的是聚合后的平均值。这个估算在大多数情况下够用,但如果你需要精确值,得看runtime_stats里是否有total_cpu_time字段(不同版本可能不一样)。

提示:sys.query_store_runtime_stats里的时间单位是微秒,换算成毫秒要除以 1000。这个细节很容易搞错,我第一次用的时候就被坑过,看着数字挺大,其实是单位没换算。

4.2 识别计划回归

计划回归是 Query Store 最擅长的场景。所谓计划回归,就是同一个查询,之前用计划 A 跑得很快,后来因为统计信息更新、索引变更或者参数嗅探等原因,换成了计划 B,结果变慢了。

下面这个查询能帮你找出那些“有多个计划且性能差异明显”的查询:

SELECT q.query_id, qt.query_sql_text, p.plan_id, p.is_forced_plan, rs.avg_duration / 1000.0 AS avg_duration_ms, rs.avg_cpu_time / 1000.0 AS avg_cpu_ms, rs.count_executions, rsi.start_time, rsi.end_time 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 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 变了,那基本可以确定是计划回归。

4.3 强制使用好的计划

找到问题之后,解决办法之一是用 Query Store 的“计划强制”功能,让优化器固定使用那个表现好的计划。

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

执行之后,sys.query_store_plan里对应计划的is_forced_plan会变成 1。之后这个查询再来的时候,优化器会优先使用被强制的计划。

但这里有几个坑必须说清楚:

第一,强制计划不是万能的。如果数据分布发生了根本性变化,原来的好计划可能也不再适用。强制使用一个过时的计划,可能导致更严重的性能问题。所以强制之后一定要持续观察。

第二,强制计划可能导致编译失败。如果被强制的计划引用了已经被删除的索引,查询会直接报错。这种情况需要先解除强制,再重新评估。

第三,不是所有查询都能强制计划。比如包含OPTION (RECOMPILE)的查询、某些分布式查询在 SQL DW 上,可能不支持强制。具体限制可以查官方文档。

解除强制用:

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

4.4 等待统计的妙用

从某个版本开始,Query Store 还采集了等待统计信息,存在sys.query_store_wait_stats里。这个视图能告诉你查询在等待什么——是 CPU、I/O、锁还是网络。

SELECT q.query_id, qt.query_sql_text, ws.wait_category_desc, SUM(ws.total_query_wait_time_ms) AS total_wait_ms FROM sys.query_store_wait_stats ws JOIN sys.query_store_plan p ON ws.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 WHERE ws.runtime_stats_interval_id >= ( SELECT MAX(runtime_stats_interval_id) - 24 FROM sys.query_store_runtime_stats_interval ) GROUP BY q.query_id, qt.query_sql_text, ws.wait_category_desc ORDER BY total_wait_ms DESC;

等待统计的价值在于,它能帮你区分“查询本身慢”和“查询在等别人”。如果一个大查询的等待主要是CPU,那说明计算量大,可能需要优化逻辑;如果主要是Lock或Latch,那说明有并发争用,得从事务隔离级别或索引设计入手。

5. 实操中的常见问题与避坑指南

5.1 Query Store 自身导致性能问题

这是最尴尬的情况:你开 Query Store 是为了排查性能问题,结果它自己成了性能问题。

Query Store 的开销主要来自两方面:一是数据采集和写入,二是空间管理。在写入量极大的系统上(比如每秒几千次查询编译),Query Store 的写入可能成为瓶颈。

判断方法:查sys.database_query_store_options里的current_storage_size_mb增长速度和readonly_reason。如果readonly_reason不为 0,说明已经进入只读模式了。

缓解措施:

  • 把QUERY_CAPTURE_MODE从ALL改成AUTO,减少捕获量
  • 把DATA_FLUSH_INTERVAL_SECONDS调大,减少刷盘频率
  • 把INTERVAL_LENGTH_MINUTES调大,减少聚合记录数
  • 如果还是不行,考虑临时切到READ_ONLY,等排查完再切回来

5.2 空间满了怎么办

空间满的表现是OPERATION_MODE变成READ_ONLY,新数据不再采集。这时候你有两个选择:清理旧数据或者扩大空间。

手动清理:

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

这个命令会清空所有 Query Store 数据,慎用。如果只是想清理旧数据,可以调整STALE_QUERY_THRESHOLD_DAYS然后等自动清理,或者手动触发:

ALTER DATABASE [你的数据库名] SET QUERY_STORE (CLEANUP_POLICY = (STALE_QUERY_THRESHOLD_DAYS = 7));

扩大空间:

ALTER DATABASE [你的数据库名] SET QUERY_STORE (MAX_STORAGE_SIZE_MB = 2048);

注意:在 Azure SQL Database 上,扩大 Query Store 空间不会额外收费,因为它用的是你数据库自身的存储配额。但如果你的数据库存储已经接近上限,扩大 Query Store 空间可能导致数据库整体空间不足。

5.3 查询文本被截断

Query Store 默认只保留查询文本的前 4096 个字符(具体长度取决于版本和配置)。如果你的查询很长(比如动态生成的 SQL),后面的部分会被截断,导致你看到的文本不完整。

解决办法:查sys.query_store_query_text时注意query_sql_text字段的长度,如果发现被截断,可以结合statement_sql_handle去 Plan Cache 里找完整文本(如果还在的话)。或者从应用侧记录完整的 SQL 日志。

5.4 参数嗅探与计划回归的区分

参数嗅探导致的性能问题,表现和计划回归很像,但根因不同。参数嗅探是“同一个计划对不同参数值表现差异巨大”,计划回归是“计划本身变了”。

区分方法:在 Query Store 里看同一个计划在不同时间窗口的性能。如果计划 ID 没变但性能波动大,那大概率是参数嗅探;如果计划 ID 变了且性能也变了,那就是计划回归。

参数嗅探的缓解手段包括:使用OPTION (RECOMPILE)、使用OPTION (OPTIMIZE FOR UNKNOWN)、或者把查询拆成多个针对特定参数范围的版本。但这些手段都有副作用,需要根据具体情况权衡。

5.5 常见问题速查表

现象可能原因排查方向
Query Store 不采集数据模式为 READ_ONLY 或 OFF查sys.database_query_store_options
数据突然中断空间满触发只读查readonly_reason和空间使用率
查询文本不完整文本被截断查query_sql_text长度,结合其他日志
强制计划后查询报错计划引用了已删除对象解除强制,重新评估
性能数据与实际不符聚合窗口太大调小INTERVAL_LENGTH_MINUTES
实例重启后数据丢失未刷盘的数据丢失调小DATA_FLUSH_INTERVAL_SECONDS

6. 一些实战中的个人体会

Query Store 这个功能,我用了几年下来,最大的感受是:它把数据库性能诊断从“玄学”变成了“工程”。以前遇到“昨天还好好的今天突然慢了”这种问题,基本靠猜;现在有了 Query Store,你可以像查日志一样查性能历史,证据链清清楚楚。

但它也不是银弹。我见过太多人开了 Query Store 之后就不管了,结果空间满了变成只读,等真正需要排查问题的时候发现数据早就断了。所以我的建议是:把 Query Store 当成一个需要运维的组件,而不是一个开了就忘的开关。定期检查空间使用率、定期清理旧数据、根据业务变化调整捕获模式,这些日常维护不能省。

另外一点,强制计划这个功能要慎用。它确实能快速止血,但本质上是一种“绕过问题”的手段,而不是“解决问题”。真正健康的做法是找到计划回归的根因——是统计信息不准?是索引缺失?还是参数嗅探?解决了根因,才不用一直靠强制计划撑着。

最后分享一个小技巧:如果你在 Azure 门户上操作,Query Store 的数据其实可以通过“Query Performance Insight”这个界面可视化查看,不用每次都写 SQL。但那个界面能做的分析比较有限,深度排查还是得自己写查询。两者结合着用,效率最高。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进