1. 重新认识Query Store:它到底解决什么问题
Query Store这个功能,我第一次接触是在一次性能调优的紧急场景里。当时某个核心业务库突然出现大量查询变慢,但问题是——执行计划在缓存里已经被刷掉了,根本抓不到现场。那时候只能靠猜、靠试,效率极低。后来开始认真用Query Store,才发现这东西本质上就是给数据库装了一个“飞行记录仪”,把查询的执行计划、运行时统计、等待信息全部持久化下来,随时可以回看。
简单来说,Query Store是数据库引擎内置的一个“查询性能历史仓库”。它不依赖任何外部工具,不需要额外安装组件,直接在数据库层面开启就能用。它记录的核心信息包括:每条查询的文本、执行计划、编译次数、执行次数、CPU时间、逻辑读写、等待时间等。这些数据不是放在内存里,而是写入数据库内部的系统表中,所以即使实例重启、计划缓存清空,历史数据依然在。
它解决的核心痛点有三个。第一,计划回归问题——同一条SQL,昨天跑得飞快,今天突然变慢,原因往往是优化器选了一个更差的计划。没有Query Store的时候,你只能靠DMV碰运气,或者用Profiler抓现场,但Profiler对生产环境影响大,而且抓到的信息也不完整。第二,性能基线缺失——很多团队在系统上线后根本没有性能基线,出了问题不知道“正常”应该是什么样。Query Store可以自动帮你建立这个基线,你随时可以对比不同时间段的资源消耗。第三,调优缺乏数据支撑——以前调优靠感觉,现在可以看历史趋势,哪条查询消耗了最多的CPU、哪条查询的读写次数异常高,一目了然。
适合谁来用?我觉得只要是负责数据库性能的人,都应该把Query Store用起来。不管你是DBA、后端开发,还是运维工程师,只要你的系统跑在SQL Server 2016及以上版本、Azure SQL Database或者Azure SQL Data Warehouse上,这个功能就是标配。尤其是那些经常被“查询突然变慢”困扰的团队,Query Store几乎是必选项。
注意:Query Store默认是关闭的,需要手动开启。开启之后会对性能产生轻微影响,但通常可以忽略不计。具体开销取决于工作负载的复杂度和采集频率。
2. Query Store的核心架构与工作原理
2.1 数据采集的底层机制
Query Store的工作方式可以拆成三个环节:采集、聚合、持久化。采集环节发生在查询编译和执行的时候,引擎会把查询文本、计划句柄、执行统计等信息写入内存中的缓冲区。这个缓冲区不是无限大的,它有一个内部阈值,当缓冲区写满或者达到刷新间隔时,数据会被异步刷入磁盘上的内部表。这个设计的好处是,对正常查询执行的侵入性很小,不会因为采集数据而拖慢业务。
聚合环节是Query Store比较聪明的地方。它不会把每一次执行都单独存一条记录,而是按时间窗口做聚合。默认情况下,时间窗口是60分钟,也就是说,同一个查询在同一个小时内的多次执行会被合并成一条统计记录。这样做既节省空间,又让趋势分析更清晰。你可以通过INTERVAL_LENGTH_MINUTES参数调整这个窗口,但一般不建议改得太小,否则数据量会膨胀得很快。
持久化环节就是把聚合后的数据写入数据库内部的系统表。这些表在用户视角下是不可见的,但可以通过sys.query_store_runtime_stats、sys.query_store_plans等系统视图查询。数据会按照你设置的保留策略自动清理,默认保留30天。这个保留期可以根据你的存储空间和合规要求调整。
2.2 内部表与视图的关系
Query Store的内部结构其实不复杂,核心就是几张表加一组视图。我画个简单的对应关系帮你理解:
| 内部表/视图 | 存储内容 | 关键字段 |
|---|---|---|
sys.query_store_query | 查询文本和查询标识 | query_id,query_text_id |
sys.query_store_query_text | 查询文本的规范化形式 | query_text_id,query_sql_text |
sys.query_store_plan | 执行计划及其属性 | plan_id,query_id,query_plan |
sys.query_store_runtime_stats | 运行时统计聚合 | runtime_stats_id,plan_id,avg_cpu_time |
sys.query_store_runtime_stats_interval | 时间窗口定义 | runtime_stats_interval_id,start_time,end_time |
sys.query_store_wait_stats | 等待统计信息 | wait_category,avg_wait_time |
这些视图之间的关系是:一个查询(query)可以有多个计划(plan),一个计划可以有多个时间窗口的运行时统计(runtime_stats)。等待统计则是按计划和时间窗口进一步细分。理解这个层级关系,后面写查询分析的时候就不会迷路。
2.3 与计划缓存和DMV的本质区别
很多人会问:既然有sys.dm_exec_query_stats这些DMV,为什么还要Query Store?这个问题问得好。DMV的数据是放在内存里的,实例重启就没了,而且只保留当前缓存中的计划。如果计划被逐出缓存,历史统计也跟着消失。Query Store不一样,它是持久化的,而且保留了计划的历史版本。也就是说,你可以看到同一个查询在过去一周内用过的所有计划,以及每个计划对应的性能表现。这是DMV做不到的。
另一个区别是,DMV只告诉你“现在是什么样”,Query Store告诉你“过去是什么样、什么时候变了”。对于排查计划回归这类问题,后者才是真正有用的。我自己的经验是,DMV适合实时诊断,Query Store适合趋势分析和事后复盘,两者互补,不是替代关系。
3. 开启Query Store的实操步骤与参数详解
3.1 在Azure SQL Database上开启
Azure SQL Database默认对Query Store的支持很友好,开启方式也简单。你可以通过T-SQL直接操作:
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 );这段代码里每个参数都有讲究。OPERATION_MODE有两个值:READ_WRITE和READ_ONLY。前者正常采集数据,后者只读不写,适合在排查问题时临时切换,避免采集开销。CLEANUP_POLICY控制数据保留天数,默认30天,如果存储紧张可以调小。DATA_FLUSH_INTERVAL_SECONDS是内存数据刷入磁盘的间隔,默认900秒,调小会增加I/O但数据更实时。INTERVAL_LENGTH_MINUTES是聚合窗口,默认60分钟。MAX_STORAGE_SIZE_MB是Query Store占用的最大空间,超过后会触发清理。QUERY_CAPTURE_MODE有三个值:ALL捕获所有查询,AUTO忽略不重要的查询,NONE停止捕获。SIZE_BASED_CLEANUP_MODE控制空间不足时的清理策略。
提示:在Azure SQL Database上,
MAX_STORAGE_SIZE_MB的上限取决于你的服务层级。基础层级可能只有几百MB,高级层级可以到GB级别。开启前最好确认一下当前层级的限制。
3.2 在Azure SQL Data Warehouse上的差异
Azure SQL Data Warehouse(现在叫Synapse SQL专用池)对Query Store的支持和普通SQL Database有些差异。最大的区别是,DW上的Query Store默认就是开启的,而且采集模式和行为略有不同。DW的查询模式以批量分析为主,查询数量少但单条查询资源消耗大,所以Query Store在DW上的价值更多体现在计划对比和资源消耗趋势上。
在DW上调整Query Store参数时,要注意INTERVAL_LENGTH_MINUTES的默认值可能不同,而且DW对存储空间的限制更严格。我建议在DW上把QUERY_CAPTURE_MODE设为AUTO,避免捕获大量短查询导致存储快速膨胀。另外,DW的sys.query_store_runtime_stats中,avg_cpu_time的单位和普通SQL Database一致,但avg_logical_io_reads的统计口径可能不同,分析时要注意区分。
3.3 参数配置的取舍逻辑
参数配置没有一刀切的标准,核心是平衡“采集精度”和“存储开销”。我一般会按这个思路来定:
- 如果系统查询量很大,
QUERY_CAPTURE_MODE用AUTO,让引擎自动过滤掉那些执行次数少、资源消耗低的查询。 - 如果存储空间充足,
MAX_STORAGE_SIZE_MB可以设大一点,比如2000MB以上,保留更长的历史。 DATA_FLUSH_INTERVAL_SECONDS默认900秒就够了,除非你在做实时性要求很高的诊断,可以临时调到300秒。INTERVAL_LENGTH_MINUTES默认60分钟适合大多数场景,如果你需要更细粒度的趋势,可以调到15或30分钟,但存储增长会明显加快。
我踩过的一个坑是:有一次把MAX_STORAGE_SIZE_MB设得太小,结果Query Store频繁触发清理,历史数据只保留了两三天,排查问题时根本不够用。后来改成2000MB,保留30天,就舒服多了。所以这个参数一定要根据实际查询量和保留需求来算。
4. 用Query Store做性能分析的实战方法
4.1 找出最消耗资源的查询
开启Query Store之后,第一件事就是找出当前系统里最耗资源的查询。这个分析不需要等太久,采集几个小时就有数据了。下面这个查询可以帮你按CPU时间排序:
SELECT TOP 20 q.query_id, qt.query_sql_text, SUM(rs.avg_cpu_time * rs.count_executions) AS total_cpu_time, SUM(rs.count_executions) AS total_executions, AVG(rs.avg_cpu_time) AS avg_cpu_time FROM sys.query_store_runtime_stats rs JOIN sys.query_store_plans 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 GROUP BY q.query_id, qt.query_sql_text ORDER BY total_cpu_time DESC;这个查询的逻辑是:把每个查询在所有时间窗口内的CPU时间加起来,得到总消耗,然后按降序排列。count_executions是执行次数,avg_cpu_time是单次平均CPU时间。注意,avg_cpu_time的单位是微秒,算出来的总时间也是微秒,你可以除以1000000转换成秒,看起来更直观。
我一般会同时看三个维度:总CPU时间、总执行次数、平均CPU时间。总CPU时间高但执行次数也高的查询,可能是高频低耗查询,优化空间在减少执行次数上。总CPU时间高但执行次数少的查询,可能是单次消耗大的复杂查询,优化空间在计划或索引上。
4.2 检测计划回归的完整流程
计划回归是Query Store最核心的应用场景。检测流程分三步:先找到性能下降的查询,再对比不同计划的性能,最后强制使用好的计划。
第一步,用下面的查询找出最近一段时间内平均CPU时间明显上升的查询:
WITH plan_stats AS ( SELECT p.query_id, rs.plan_id, rs.runtime_stats_interval_id, rs.avg_cpu_time, rs.count_executions, ROW_NUMBER() OVER (PARTITION BY p.query_id ORDER BY rs.runtime_stats_interval_id DESC) AS rn FROM sys.query_store_runtime_stats rs JOIN sys.query_store_plans p ON rs.plan_id = p.plan_id ) SELECT ps1.query_id, ps1.plan_id AS recent_plan_id, ps1.avg_cpu_time AS recent_avg_cpu, ps2.plan_id AS older_plan_id, ps2.avg_cpu_time AS older_avg_cpu FROM plan_stats ps1 JOIN plan_stats ps2 ON ps1.query_id = ps2.query_id WHERE ps1.rn = 1 AND ps2.rn = 2 AND ps1.avg_cpu_time > ps2.avg_cpu_time * 1.5;这个查询对比了同一个查询最近两个时间窗口的平均CPU时间,如果最近一个窗口比上一个窗口高出50%以上,就标记为疑似回归。1.5这个系数可以根据你的敏感度调整,我一般用1.5到2之间。
第二步,找到疑似回归的查询后,查看它用过的所有计划:
SELECT p.plan_id, p.query_id, rs.avg_cpu_time, rs.count_executions, rs.runtime_stats_interval_id, p.query_plan FROM sys.query_store_plans p JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id WHERE p.query_id = @query_id ORDER BY rs.runtime_stats_interval_id DESC;把@query_id替换成你找到的查询ID,就能看到这个查询在不同时间窗口用了哪些计划,每个计划的平均CPU时间是多少。如果某个计划明显比另一个差,那就是回归的根源。
第三步,强制使用好的计划:
EXEC sp_query_store_force_plan @query_id = @query_id, @plan_id = @good_plan_id;这个存储过程会让优化器在编译这条查询时,优先使用你指定的计划。注意,强制计划不是永久性的,如果计划本身不再适用(比如索引被删了),强制会失效。你可以用sp_query_store_unforce_plan取消强制。
注意:强制计划之前,一定要确认那个“好计划”在当前数据分布下依然有效。我见过有人强制了一个旧计划,结果因为数据量增长,旧计划反而更慢了。所以强制之后要持续观察一段时间。
4.3 等待统计的解读与应用
Query Store从SQL Server 2017开始支持等待统计,Azure SQL Database也早就支持了。等待统计能告诉你查询在等待什么资源,是CPU、I/O、锁还是网络。下面这个查询按等待类别汇总:
SELECT ws.wait_category_desc, SUM(ws.avg_wait_time * ws.count_executions) AS total_wait_time, AVG(ws.avg_wait_time) AS avg_wait_time FROM sys.query_store_wait_stats ws GROUP BY ws.wait_category_desc ORDER BY total_wait_time DESC;常见的等待类别包括:CPU(CPU调度等待)、Network IO(网络传输等待)、Buffer IO(数据页读写等待)、Lock(锁等待)、Memory(内存授予等待)。如果Buffer IO占比很高,说明查询在大量读数据页,可能需要加索引或者优化查询逻辑。如果Lock占比高,说明有阻塞,需要检查事务隔离级别和并发模式。
我自己的经验是,等待统计要和运行时统计结合起来看。比如一条查询CPU时间很高,但等待统计里CPU等待并不高,说明它是在真正消耗CPU做计算,而不是在等CPU调度。这种情况下优化方向是减少计算量,比如简化表达式、减少函数调用。反过来,如果CPU等待很高,但CPU时间不高,说明查询在排队等CPU,可能是并发太高,需要调整工作负载。
5. 常见问题与避坑经验实录
5.1 Query Store数据不采集或采集不全
这是新手最常遇到的问题。开启Query Store之后,发现sys.query_store_runtime_stats里没数据,或者只有很少的数据。原因通常有几个:一是QUERY_CAPTURE_MODE设成了NONE,这个模式下什么都不采集。二是数据库刚开启Query Store不久,数据还在内存缓冲区里,没到刷新时间。三是查询本身执行次数太少,被AUTO模式过滤掉了。
排查步骤很简单:先查sys.database_query_store_options,确认actual_state_desc是READ_WRITE,query_capture_mode_desc不是NONE。然后等一个刷新周期(默认15分钟),再看数据。如果还是没有,可以临时把QUERY_CAPTURE_MODE改成ALL,看看是不是过滤策略的问题。
提示:在Azure SQL Database上,如果数据库被暂停(比如无服务器层级自动暂停),Query Store的采集也会暂停。恢复后需要等一段时间才有新数据。
5.2 存储空间增长过快
Query Store的数据量增长取决于查询数量和采集频率。如果系统里有大量动态SQL或者短查询,ALL模式下存储会膨胀得很快。我见过一个系统,开启Query Store一周就占了5GB。解决办法是切换到AUTO模式,让引擎自动过滤。另外,INTERVAL_LENGTH_MINUTES不要设得太小,MAX_STORAGE_SIZE_MB要设一个合理的上限。
如果存储已经满了,Query Store会自动进入READ_ONLY模式,停止采集新数据。这时候你需要手动清理旧数据或者扩大存储限制。清理可以用ALTER DATABASE ... SET QUERY_STORE CLEAR;,但这个会清空所有历史数据,慎用。更好的做法是调整CLEANUP_POLICY,让旧数据自动过期。
5.3 强制计划后性能反而变差
强制计划是一个“双刃剑”。你强制了一个当时看起来好的计划,但随着数据增长或索引变化,这个计划可能不再最优。更麻烦的是,强制计划会阻止优化器选择新的、可能更好的计划。所以我的建议是:强制计划只作为临时手段,用来快速恢复性能,然后尽快找到根本原因并修复。修复后取消强制,让优化器自己选。
如果强制后发现性能变差,立即用sp_query_store_unforce_plan取消强制,然后重新分析。不要硬扛,强制计划不是长久之计。
5.4 在只读副本或地理副本上的行为
Azure SQL Database的只读副本和地理副本对Query Store的支持有限。只读副本上Query Store是只读的,不能开启采集。地理副本上,Query Store的数据不会自动同步,需要单独配置。如果你在副本上做性能分析,要注意数据可能不是最新的。我一般建议在主库上做Query Store分析,副本上只做实时诊断。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 无数据采集 | 采集模式为NONE或数据库暂停 | 改为AUTO或ALL,恢复数据库 |
| 数据量增长过快 | 采集模式为ALL,查询量大 | 改为AUTO,调大聚合窗口 |
| 存储满后停止采集 | 达到MAX_STORAGE_SIZE_MB上限 | 清理旧数据或扩大上限 |
| 强制计划后变慢 | 计划不再适用 | 取消强制,重新分析 |
| 副本上无数据 | 副本不支持采集 | 在主库上分析 |
| 等待统计缺失 | 版本不支持或未开启 | 确认版本,检查配置 |
6. 我的实操心得与后续扩展方向
Query Store用久了,我最大的体会是:它不是一个“开了就完事”的功能,而是需要持续维护和解读的。我一般会每周花半小时看一下Query Store的报告,重点关注CPU消耗排名前10的查询和等待统计的变化趋势。这样可以在问题变大之前就发现苗头。
另一个心得是,不要孤立地看Query Store的数据。它和索引使用统计、锁等待统计、执行计划缓存结合起来看,才能形成完整的性能画像。比如Query Store告诉你某条查询CPU高,索引统计告诉你它做了大量扫描,两者一结合,加索引的方向就明确了。
后续我还会继续写Query Store的进阶用法,包括如何用Query Store做A/B测试、如何结合自动调优功能、如何在CI/CD流程里集成Query Store检查。这些内容在实际项目里都很有用,尤其是自动调优和Query Store的联动,能省掉大量手动调优的时间。
最后分享一个小技巧:如果你在排查一个紧急的性能问题,可以临时把DATA_FLUSH_INTERVAL_SECONDS调到60秒,这样数据几乎实时写入,能更快看到现场。问题解决后再调回900秒,避免长期增加I/O开销。这个操作我试过很多次,实测下来很稳,对生产影响可控。