资讯详情 数据库黑匣子:Azure SQL Query Store性能排查实战
📅 2026/10/13 12:09:35
Azure SQL DB/DW 的数据库性能排查我最怕的不是数据库本身难搞而是“没人记得上周那条 SQL 长什么样”。Query Store 这个东西最值钱的地方就是它把这种“记忆”内置到了引擎里哪个查询、用了什么计划、每个时间窗口跑了多久、等了多久全部自动落盘。它的定位很像飞机的黑匣子平时不打扰你出事的时候是非查不可的第一现场。这个系列前面两篇聊了它是什么、怎么启用这一篇不绕弯子直接上我自己在生产环境里反复用的五类常用场景Top N 资源消耗、Plan Regression、等待统计、自身参数调优、上线变更前后对比。每一节都有可以直接抄的 SQL 和具体操作顺序适合正在做 Azure SQL DB/DW 巡检和应急处理的人参考。1. 先厘清定位为什么说它是“数据库的黑匣子”1.1 它和你自己写监控作业的本质区别在没有 Query Store 之前最常见的监控方式是定时作业抓 DMV。比如每 15 分钟执行一次查询把sys.dm_exec_query_stats的数据插到一张监控表里。这套做法不是不行但它有几个很致命的问题第一抓到的只是“某个瞬间”的状态查询抖了一下就错过了第二你抓到的是汇总数字没有细到“这一个查询、这一个计划、这一个时间片”第三它不会自动保留执行计划 XML想复盘计划变更时往往发现已经被内存释放了。Query Store 不一样。它是引擎内部记录的粒度是按计划plan_id和时间片runtime_stats_interval_id分开存储执行计划 XML、等待统计也一起存下来。所以你以后要回答的不再是“那天下班前 CPU 高不高”而是“query_id 等于 123 这条查询在 6 月 3 日 20:00 到 20:15 这一段时间里plan_id 等于 456 跑了几次平均耗时多少等锁等了多久”。这种粒度靠外围脚本很难补齐。还有一个容易被忽略的点Query Store 在 Azure SQL Database 里默认就是开启的不像以前还要先评估“要不要部署监控方案”。你建完数据库它就已经在记录了。这也意味着你过去踩过的坑、发生过的性能问题其实大部分都能从它的历史数据里翻出来只是很多人不知道去哪个视图看而已。1.2 数据存在哪、怎么读三种入口我平时读 Query Store 数据基本走三条路按使用场景不同切换很少只用一种。核心视图是这几张sys.query_store_query_text查询文本包含完整的 SQL 字符串。sys.query_store_query查询维度一个逻辑查询一条记录。sys.query_store_plan执行计划包含计划 ID、编译时间、计划 XML。sys.query_store_runtime_stats运行统计按计划和时间片聚合的 CPU、耗时、IO、内存、DOP 等指标。sys.query_store_wait_stats等待统计记录每个计划和时间片里各类等待的累计时间。它们之间的关系很简单query_text_id - query_id - plan_id - runtime_stats / wait_stats。只要拿到 query_id 或 plan_id就能把文本、计划、指标全部串起来。除了视图还有两个改不了的习惯。第一个是 SSMS 的标准报表。在对象资源管理器里展开数据库右键点“报表 - 标准报表”下面有“查询存储”这一组报表其中“Overall Resource Consumption”“Top Resource Consuming Queries”“Regressed Queries”三个报表在应急时最有用点开就能看到图形化趋势和明细列表。第二个是 Azure 门户里的“Query Performance Insight”和“Intelligent Insights”前者给你看时间维度上的资源消耗排名后者会基于 Query Store 的数据自动给出诊断结论适合把问题讲给业务方听。有一点需要单独提醒如果你的环境是 Azure SQL Data Warehouse现在的 Synapse Analytics 专用 SQL 池体系Query Store 的可用能力与 Azure SQL Database 并不完全一致部分报表和操作在 DW 里没有对应入口。所以本文的示例统一以 Azure SQL Database 为准在 DW 上实际执行前先确认sys.database_query_store_options在你的池里有哪些字段可用别拿单库的经验硬套。入口适合谁典型用途系统视图需要深入分析的 DBA自定义聚合、跨时间段对比、生成报表SSMS 查询存储报表快速定位问题的值班人员趋势总览、Top N、回归查询自动识别Azure 门户给业务或上级做展示直观图表、智能诊断、趋势汇报2. 场景一一条SQL拖垮全局——Top N 资源消耗排查接到“系统变慢”的反馈时第一件事不是打开 SSMS 乱翻执行计划而是先用 Query Store 按资源消耗排序把第一梯队揪出来。这里有个经验CPU 高、耗时长、逻辑读多分别对应不同的问题方向。CPU 高通常是计算量大或统计信息导致低效计划耗时长要先看是不是在等锁或等 IO逻辑读多十有八九和索引、扫描有关。所以第一步宁可多跑几个指标也别只盯着 CPU。2.1 手写 T-SQL 按 CPU/时长/IO 筛选下面这条查询是我在生产环境里用得最多的按最近 24 小时的总 CPU 时间排序顺手把耗时、逻辑读、执行次数一起列出来SELECT TOP 10 q.query_id, qt.query_sql_text, SUM(rs.count_executions) AS exec_count, 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, MAX(rs.last_execution_time) AS last_exec_time FROM sys.query_store_query_text AS qt JOIN sys.query_store_query AS q ON qt.query_text_id q.query_text_id JOIN sys.query_store_plan AS p ON q.query_id p.query_id JOIN sys.query_store_runtime_stats AS rs ON p.plan_id rs.plan_id WHERE rs.execution_type 0 AND rs.last_execution_time DATEADD(HOUR, -24, SYSDATETIME()) GROUP BY q.query_id, qt.query_sql_text ORDER BY total_cpu_ms DESC;有几个细节很多人第一次看会踩坑。第一avg_cpu_time和avg_duration的单位是微秒不是毫秒所以我统一除 1000 转成毫秒。第二sys.query_store_runtime_stats每个计划在每个时间片里可能存多行按execution_type区分正常、中止、异常这三种情况。排查性能问题只关心正常执行的记录所以要加execution_type 0否则异常执行的数据会把平均值搅乱。第三总资源消耗是avg乘以count_executions再累加的结果直接用SUM(rs.avg_cpu_time)会把高频但每次执行很快的查询漏掉这个错误很常见。同一时间窗口内我还建议把avg_duration和avg_cpu_time一起看。如果某条查询总耗时很高但总 CPU 很低说明大部分时间不是在计算而是在等待如果 CPU 和耗时几乎相等才是真的在“埋头苦算”。2.2 报表方式SSMS 和 Azure 门户如果不想写 SQLSSMS 里的“Top Resource Consuming Queries”报表可以直接从图形入口进。报表顶部可以切换指标比如 Duration、CPU、Logical IO右侧会列出对应的 SQL 文本和执行计划入口。点任意一条查询可以直接跳到它的计划详情。这个流程很适合快速形成结论但我不建议只用报表做根因分析因为报表默认的时间粒度和过滤条件不一定符合现场需求。Azure 门户里的“查询性能洞察”功能同样是吃 Query Store 的数据展示的指标包括 CPU、数据 IO、日志 IO 等还能拉长查询的明细列表。这个入口更适合事后向业务方交代直接截图某个时间段的趋势图比讲一堆 DMV 语句直观得多。2.3 粗筛之后再钻取拿到 Top N 列表后我会对第一名的查询做一次钻取看它的单日趋势和计划明细。下面这条 SQL 针对指定 query_id把它的运行统计按时间倒序拉出来SELECT rs.runtime_stats_interval_id, 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, rs.avg_dop, rs.last_execution_time FROM sys.query_store_runtime_stats AS rs JOIN sys.query_store_plan AS p ON rs.plan_id p.plan_id WHERE p.query_id query_id AND rs.execution_type 0 ORDER BY rs.last_execution_time DESC;这里重点看三样东西avg_duration有没有突变、avg_dop是不是一直很高、对应时间段的 plan_id 是否切换过。如果平均耗时的上涨和 plan_id 的切换时间点完全重合那基本可以断定不是数据量增长造成的自然变慢而是计划回归问题直接进入下一个场景。3. 场景二性能突然变差——Plan Regression 的定位与干预这是 Query Store 最经典的场景。生产环境里很多“明明什么都没改昨天还好好的今天突然慢了”其实就是执行计划被优化器换掉了。你什么都没动但统计信息更新、索引自动维护、参数嗅探、并行阈值变化都会导致计划选择发生变化。没有历史计划信息这种问题基本只能靠猜。3.1 定位“计划变了”的 SQL最快的入口还是 SSMS 里的“Regressed Queries”报表它会自动帮我们筛选出“之前快、最近慢”的查询。但我习惯手动做一遍因为手工跑一遍能同时看到计划切换的准确时间点。针对已确定的 query_id可以这样查SELECT q.query_id, p.plan_id, rs.avg_duration / 1000.0 AS avg_duration_ms, rs.avg_cpu_time / 1000.0 AS avg_cpu_ms, rs.count_executions, rs.last_execution_time, p.is_forced_plan, p.force_failure_count, CAST(p.query_plan AS XML) AS plan_xml FROM sys.query_store_query AS q JOIN sys.query_store_plan AS p ON q.query_id p.query_id JOIN sys.query_store_runtime_stats AS rs ON p.plan_id rs.plan_id WHERE q.query_id query_id AND rs.execution_type 0 ORDER BY p.plan_id, rs.last_execution_time DESC;一个 query_id 下挂了多个 plan_id 是正常现象。你要做的是把每个 plan_id 的avg_duration排个序看哪个计划表现好、哪个计划表现差再对比它们的last_execution_time找到计划切换的精确时间点。拿到时间点之后把两个计划 XML 都打开做对比是不是从 Hash Join 变成了 Nested Loops是不是从并行计划变成了串行是不是从一个索引 Seek 变成了 Table Scan这些差异通常一眼就能看出来。这里有一个常见的误判数据量持续增长导致的变慢一般 plan_id 不会来回跳平均耗时是缓慢爬升的。只有“计划切换 耗时突变”同时出现才值得走强制计划这条路。如果计划没变你先去查统计信息更新时间、索引碎片度别急着 force。3.2 强制旧计划的完整操作确认旧计划确实更好之后操作分三步。第一步把上面查询结果里表现好的 plan_id 记下来第二步执行强制计划EXEC sys.sp_query_store_force_plan query_id 123, plan_id 456;第三步隔几分钟后再查运行统计确认avg_duration是否回落到正常水平。强制成功之后sys.query_store_plan里对应行的is_forced_plan会变成 1。如果反悔了或者业务高峰期过去了想恢复优化器自由选择执行EXEC sys.sp_query_store_unforce_plan query_id 123, plan_id 456;强制计划在 Azure SQL Database 里是持久化的元数据不是内存里的临时状态故障转移、服务重启之后依然生效所以不用怕重启丢配置。但持久化也意味着它是一份长期承诺别轻易加。3.3 强制计划不是终点我见过不少人 force 完之后就再也不管了这是比较危险的做法。强制计划只是快速止血不是根因修复。计划回归的根因可能是统计信息过期、索引选择不当、参数嗅探也可能是应用传进来的参数分布发生了变化。如果根因还在你今天 force 住一个旧计划明天数据分布一变这个旧计划也会变成新坑。所以我的习惯是force 之后立刻记录一份备注写明“为什么 force、原本的新计划差在哪、建议的长期优化动作”然后在后续巡检里定期看一眼sys.query_store_plan里is_forced_plan 1的清单评估哪些强制已经可以解除。如果某条查询的强制计划导致force_failure_count开始累积一定要当成告警处理说明这个计划在编译阶段就开始出问题了。另外如果开了 Azure SQL Database 的自动计划修正能力系统可能会自动 force 一些计划这类操作同样会被写进 Query Store。你看到的is_forced_plan 1不一定都是人为操作的排查时要把这个因素考虑进去。4. 场景三不用瞎猜瓶颈——用等待统计做查询体检很多人排查慢查询只知道看执行计划忽略等待统计。执行计划告诉你“它打算怎么执行”等待统计告诉你“时间到底耗在了哪里”。同样是耗时 5 秒的查询一段可能卡在锁上另一段可能卡在磁盘 IO 上处理方式完全不同。没有等待数据很容易把阻塞问题误判成“SQL 写得太烂”。4.1 等待分类怎么看Query Store 里的等待统计不是原始 wait_type 级别而是做了归类通过wait_category_desc字段给我们一个相对粗的视角。这个设计其实很聪明原始等待类型上百种直接看会陷入细节。常用的分类大致可以理解成等待分类大致含义常见场景CPU查询在计算不是在等待大聚合、嵌套循环、统计信息不准Worker Thread线程池耗尽并发过高、连接风暴Lock锁等待阻塞链、长事务未提交Buffer IO页面 IO 等待全表扫描、冷缓存、慢盘Buffer Latch缓冲池内部锁存等待高并发访问同一数据页Network IO等待客户端接收数据应用端处理太慢、网络缓冲Parallelism并行算子协调等待并行倾斜、数据分布不均Memory等待内存授权查询内存不足、并发大查询Log IO / Log Write日志写入等待日志磁盘慢、事务提交频繁分类只看个大概方向具体根因仍然要靠执行计划和当时的环境判断。这里提醒一句等待分类的原始类型映射在不同版本里可能略有差异别把某个分类的含义背死以实际数据为准。4.2 读 sys.query_store_wait_stats 的实用查询我自己常用的查询是把最近 7 天每个查询的等待时间按分类汇总找出“某个查询到底在等什么”SELECT TOP 20 q.query_id, qt.query_sql_text, ws.wait_category_desc, SUM(ws.count_executions) AS exec_count, SUM(ws.avg_query_wait_time_ms * ws.count_executions) AS total_wait_ms FROM sys.query_store_wait_stats AS ws JOIN sys.query_store_plan AS p ON ws.plan_id p.plan_id JOIN sys.query_store_query AS q ON p.query_id q.query_id JOIN sys.query_store_query_text AS qt ON q.query_text_id qt.query_text_id WHERE ws.execution_type 0 AND ws.last_execution_time DATEADD(DAY, -7, SYSDATETIME()) GROUP BY q.query_id, qt.query_sql_text, ws.wait_category_desc ORDER BY total_wait_ms DESC;注意avg_query_wait_time_ms的单位就是毫秒和刚才avg_cpu_time的微秒不一样别再做一次除 1000。拿到结果以后我会做一层很简单的验证把这条查询的 total_wait 和它在 runtime stats 里的 total_duration 放在一起。如果等待时间占了耗时的绝大部分说明瓶颈在等待资源如果耗时远大于等待且 CPU 也高那多半是计算本身太贵或者并行计划里多个线程互相等。4.3 两类高频等待的排查方向生产环境里遇到最多的是 Buffer IO 和 Lock 这两类。Buffer IO 等待高尤其是伴随较高的逻辑读时第一反应是查索引是不是大范围扫描是不是没有覆盖索引统计信息是不是过期导致优化器低估了行数这时候可以到 Azure 门户的智能见解里看它给的建议也可以手动打开执行计划 XML找到扫描算子对应的表和索引名再决定补索引还是更新统计信息。Lock 等待高的典型特征是平均耗时偶发爆炸平时看起来挺正常而且数据库整体 CPU 不高。这种问题先去查当时有没有长事务有没有把“查询 后续业务逻辑”放在同一个事务里迟迟不提交。我曾经处理过一起典型的锁等待应用代码在一个循环里反复执行 SELECT每条 SELECT 之前都开启一个隐式事务中间还夹杂着业务判断导致一行数据被长时间锁住。这类问题在 Query Store 里看到的就是wait_category_desc Lock的等待总量居高不下顺着它去查事务提交时间点很快就定位到问题了。还有一类 Parallelism 等待容易被误读看到wait_category_desc Parallelism不代表并行不好有时候反而是并行倾斜造成部分 worker 空转。这种情况优先查数据分布再看有没有必要调整该查询的并行度设置。Azure SQL Database 里可以通过数据库范围配置控制 MAXDOP具体值取决于查询特征不建议全局一刀切。5. 场景四给 Query Store 自己调参数——避免“记录仪罢工”Query Store 本身也是个“存储消费者”它也会被塞满也会有自己罢工的时候。很多人天天用 Query Store 查别人却忘了检查 Query Store 自己的状态。这个场景在运维里排很靠前因为一旦它满了进入只读模式上面几个场景全都没数据可查自动调优也跟着失效。5.1 先看当前配置再动手在任何调整之前先把当前配置读出来。不同版本、不同服务层级的默认值可能不一样凭印象改参数是大忌SELECT operation_mode_desc, query_capture_mode_desc, max_storage_size_mb, interval_length_minutes, data_flush_interval_seconds, stale_query_threshold_days, size_based_cleanup_mode_desc, wait_stats_capture_mode_desc, max_plans_per_query FROM sys.database_query_store_options;重点看operation_mode_desc是不是READ_WRITE。如果是READ_ONLY说明 Query Store 已经停止写入新数据你在应急场景里看到的可能是不完整的历史。很多“为什么这个月的 QS 数据断档了”的问题最后都落到这一列上。5.2 各参数的含义与建议sys.database_query_store_options里每个字段都有意义但日常真正需要动的不多。我按重要程度排一下参数作用我的建议OPERATION_MODE读写模式正常保持 READ_WRITE发现 READ_ONLY 需要立刻处理QUERY_CAPTURE_MODE捕获范围生产用 AUTO避免把大量小查询也记录下来MAX_STORAGE_SIZE_MB存储上限根据业务量设置一般 1024 到 4096 之间需要自行评估INTERVAL_LENGTH_MINUTES统计聚合粒度15 分钟比 60 分钟更容易定位时间点但数据量更大DATA_FLUSH_INTERVAL_SECONDS落盘频率保持默认即可不需要频繁改动STALE_QUERY_THRESHOLD_DAYS历史保留天数默认 30 天通常够用做长期趋势分析可以加大SIZE_BASED_CLEANUP_MODE空间不足时自动清理保持 AUTO防止被卡在 READ_ONLYMAX_PLANS_PER_QUERY每个查询保留计划数参数化查询太多的环境容易触顶触顶后旧计划被淘汰需要调整时用一条 ALTER DATABASE 完成ALTER DATABASE [YourDB] SET QUERY_STORE ( OPERATION_MODE READ_WRITE, QUERY_CAPTURE_MODE AUTO, MAX_STORAGE_SIZE_MB 2048, INTERVAL_LENGTH_MINUTES 15, DATA_FLUSH_INTERVAL_SECONDS 900, CLEANUP_POLICY (STALE_QUERY_THRESHOLD_DAYS 30), SIZE_BASED_CLEANUP_MODE AUTO );这里要提醒一句QUERY_CAPTURE_MODE NONE看起来能省不少空间但代价是它只保留已有数据不再捕获新查询。如果你设置成 NONE 之后才发现某段时间没有新数据不要奇怪先检查是不是有人把捕获模式关了。5.3 存储满进入 READ_ONLY 的应急恢复如果发现operation_mode_desc READ_ONLY按下面的顺序处理。第一步先把捕获模式降级控制新增量比如确认QUERY_CAPTURE_MODE不是 ALL第二步清理掉最占空间的旧数据。只想清空全部历史、重新开始积累基线的话执行ALTER DATABASE [YourDB] SET QUERY_STORE CLEAR;如果只是想删除某几条占用空间的查询及其计划也可以精准操作EXEC sys.sp_query_store_remove_plan plan_id 123; EXEC sys.sp_query_store_remove_query query_id 456;清理完成后再把操作模式设回 READ_WRITEALTER DATABASE [YourDB] SET QUERY_STORE (OPERATION_MODE READ_WRITE);这里有个容易反复踩的坑如果根本原因是存储上限太小、捕获模式过宽你这次清完过两天又会满。所以 READ_ONLY 恢复只是第一步紧接着必须调整容量配置并且监控它别再掉回 READ_ONLY。我在实际运维中踩过最痛的一次坑就是查了一个月的历史数据才发现 Query Store 早已进入 READ_ONLY自动调优一直没工作相当于黑匣子断电了一个月。所以后来我每次巡检脚本里都会加一行检查operation_mode_desc并且设置告警。6. 场景五上线变更前后对比——用数据决定“敢不敢发”加索引、改查询、换服务层级、从一个性能层级升级到另一个性能层级这些操作最怕的就是“上线前没人敢保证效果上线后全靠体感”。Query Store 天然就是一个 A/B 测试记录器因为它在变更前后都在持续记录同一批查询的表现。用好这个场景你完全可以拿数据回答“这个改动能上还是不能上”。6.1 变更前先打基线如果 Query Store 一直开着其实你已经有了现成的基线数据。要做的是在变更之前把关键查询的当前表现导出来存档免得后面要对比时还要去猜“之前到底什么样”。我习惯的做法是把需要重点观察的 query_id 列出来然后跑一条按时间段聚合的查询把每个计划的表现存进一个单独的表或者 ExcelSELECT p.query_id, p.plan_id, ROUND(AVG(rs.avg_duration / 1000.0), 2) AS avg_duration_ms, ROUND(AVG(rs.avg_cpu_time / 1000.0), 2) AS avg_cpu_ms, AVG(rs.avg_logical_io_reads) AS avg_logical_reads, COUNT(*) AS sample_count, MIN(rs.first_execution_time) AS first_exec, MAX(rs.last_execution_time) AS last_exec FROM sys.query_store_runtime_stats AS rs JOIN sys.query_store_plan AS p ON rs.plan_id p.plan_id WHERE rs.execution_type 0 AND rs.last_execution_time 2025-06-01 00:00:00 AND rs.last_execution_time 2025-06-08 00:00:00 GROUP BY p.query_id, p.plan_id ORDER BY p.query_id, p.plan_id;时间范围按你的变更计划来定尽量取业务形态完整的一周避开节假日等特殊时段。这里要注意基线里的数据是“变更前”的真实表现不是主管拍脑袋给的预期值。后续对比时一切都以它为准。6.2 变更后用 Query Store 对账变更上线后不要只看一两个小时的曲线就下结论。数据库缓存是冷的、统计信息重新编译、历史计划可能残留这些因素都会让第一天数据失真。我一般观察 3 到 7 天再用同样的查询去拉变更后的时间段然后逐条做对比。对比时重点看三件事一是平均耗时和 CPU 是否下降二是逻辑读是否下降三是 plan_id 是否变化、变化方向是否合理。如果只是耗时下降但逻辑读没变很可能是缓存命中率提升带来的短期收益如果耗时、读、CPU 全面下降且计划也换了更优形态这个变更基本可以确认有效。还有一类情况容易被忽略对存储过程或 SQL 文本做了修改之后Query Store 里的查询 ID 或文本可能会变化旧查询停更、新查询出现。这时候不要机械地按 query_id 对齐要按业务逻辑和对象名来对齐。否则你会看到“旧查询消失了”误以为是上线失败。6.3 和 Azure SQL Automatic Tuning 的配合Query Store 不只是给人看的Azure SQL Database 的自动调优能力也直接依赖它。其中最相关的是“自动计划修正”也就是检测到计划回归后自动强制最近的好计划。这个能力可以这样打开ALTER DATABASE [YourDB] SET AUTOMATIC_TUNING (FORCE_LAST_GOOD_PLAN ON);也可以到门户的数据库顾问里把这项开关打开。打开之后Query Store 的角色就不只是“黑匣子”了它还是自动调优的“传感器”。这也解释了我前面为什么反复强调要保证 Query Store 处于 READ_WRITE 状态如果它满了、停了自动调优拿不到数据自然也无从判断计划是否回归。顺带一提自动调优里还有自动创建索引、自动删除索引这两个选项它们同样会参考 Query Store 收集的查询特征。启用后可以通过sys.dm_db_tuning_recommendations这个 DMV 查看系统给出的调优建议。我个人的建议是先确保 Query Store 自身健康再逐步打开这些自动能力否则只会增加一个不工作的黑盒。最后再分享一个小技巧。我现在的排障流程已经固化成一套固定的 SOP接到性能问题先看 Top N 资源消耗有嫌疑查询就对比历史计划怀疑资源瓶颈立刻看等待统计最后永远检查一遍 Query Store 自身是不是 READ_WRITE。这套流程在 Azure SQL DB 上比任何第三方监控都更能说明问题因为它看的不是猜测而是这段业务真实跑过的每一条轨迹。