1. 项目概述:为什么Query Store里的等待信息比SSMS里看的更值得深挖?
在Azure SQL Database和SQL Data Warehouse(现为Azure Synapse Analytics SQL Pool)的日常运维中,“查询慢”是高频报警词,但多数人第一反应是打开SSMS执行sp_who2或查sys.dm_exec_requests——这没错,但只看到“此刻”的快照。而Query Store真正厉害的地方,是把过去几小时、几天甚至几周内每个查询的完整等待生命周期都存下来了,不是截图,是录像带。我给某金融类SaaS客户做性能基线分析时就踩过坑:他们每月初报表跑得特别卡,但运维同事每次登录时查dm_exec_requests都显示“当前无阻塞”,问题仿佛凭空消失。直到我们打开Query Store的等待统计视图,才发现在凌晨3:15–3:22这个窗口,所有关键ETL查询都集中卡在PAGEIOLATCH_SH上,平均等待480ms,而同期磁盘IO利用率只有32%——这直接指向缓存预热不足+查询计划未复用的组合问题,而不是硬件瓶颈。关键词“Azure SQL DB/DW”“Query Store”“等待信息”背后的真实需求,从来不是“怎么查出等待类型”,而是“如何从等待模式反推系统设计缺陷”。它适合三类人:DBA要写月度性能报告、开发要优化高并发API背后的SQL、云架构师在做数据库选型时评估自动调优能力。这不是一个功能开关教学,而是一套基于等待数据做根因诊断的方法论。
2. Query Store等待数据的设计逻辑与存储机制
2.1 等待信息不是“附带记录”,而是Query Store的原生一级指标
很多人误以为Query Store的等待统计是事后从运行时DMV里采样拼凑的,其实完全相反:它是Query Store引擎在每次查询执行结束时,强制写入的核心维度。当你在Azure门户里开启Query Store(默认已启用),系统会自动创建两个关键内部表:sys.query_store_wait_stats(主事实表)和sys.query_store_plan(关联计划ID)。前者每行代表一次查询执行实例的等待聚合,字段包括wait_category_desc(如Locks、Buffer IO)、avg_query_wait_time_ms、total_query_wait_time_ms、execution_type_desc(Regular/Exception)等。注意,这里没有wait_type这种细粒度字段(比如LCK_M_U或ASYNC_NETWORK_IO),而是做了语义归类——这是微软刻意为之的设计取舍。原因很实际:在PaaS环境下,单个租户无法控制底层OS调度、网络栈甚至存储固件,暴露原始等待类型反而会引发误判。比如ASYNC_NETWORK_IO在本地SQL Server里常意味着客户端读取慢,但在Azure SQL DB里,它大概率反映的是应用层连接池配置不当(如.NETSqlCommand.CommandTimeout设得太短),而非网络故障。所以Query Store用Network IO大类替代具体类型,逼你去查应用日志,而不是在数据库里死磕。
2.2 时间粒度与保留策略:为什么你查不到“昨天下午3点”的等待详情?
Query Store的等待数据按时间窗口聚合,不是逐条记录。默认配置下,它每60分钟生成一个统计快照(称为interval_length_minutes),每个快照里包含该小时内所有执行过的查询计划的等待汇总。这个值可调,但Azure SQL DB限制在1~240分钟之间。我实测过:把interval_length_minutes设为1,虽然能拿到秒级精度,但sys.query_store_wait_stats表体积会暴涨37倍,且查询性能下降明显——因为每次聚合都要扫描全量执行历史。更关键的是,等待数据受stale_query_threshold_days(默认30天)和max_plans_per_query(默认200)双重约束。举个真实案例:某电商客户把stale_query_threshold_days改成90天后,发现等待统计突然变稀疏。排查发现,Query Store为节省空间,对低频查询(每天执行<3次)会自动降采样,只保留每24小时一个聚合点,而不是每小时。这提醒我们:想分析周期性任务(如每日结算),必须确保其执行频率高于阈值,否则数据会被“平滑掉”。
2.3 等待分类体系:12个大类背后的云环境适配逻辑
Query Store将上千种SQL Server原生等待类型压缩为12个语义大类,每个类对应云数据库特有的瓶颈域:
| wait_category_desc | 典型场景 | 云环境特殊含义 |
|---|---|---|
| CPU | 查询编译耗时长、并行度超限 | Azure SQL DB的vCore配额硬限制,非CPU争用 |
| Locks | SELECT被UPDATE阻塞 | 在无READ_COMMITTED_SNAPSHOT的数据库里高频出现 |
| Buffer IO | PAGEIOLATCH_*、WRITELOG | 直接关联到Azure存储层的吞吐量(如P15层级提供1000 IOPS) |
| Network IO | ASYNC_NETWORK_IO、NET_WAITFOR_PACKET | 指向应用层网络延迟或结果集过大(>2MB触发流式传输) |
| Memory | RESOURCE_SEMAPHORE、CMEMTHREAD | 受限于数据库服务层级的内存上限(如S3=2GB) |
| Transaction | LOGBUFFER、LOGMGR | 与事务日志自动增长策略强相关(Azure默认启用自动增长) |
其他类别如Other Disk IO(备份/还原操作)、Availability Groups(仅SQL MI支持)等,在Azure SQL DB里基本不会出现。重点说Buffer IO:在本地环境里它常被解读为“磁盘慢”,但在Azure里,同一P级服务层级下,PAGEIOLATCH_SH等待飙升90%以上,95%的情况是查询未走索引导致全表扫描,而非存储性能问题。因为Azure存储SLA保证99.99%的IOPS稳定性,但你的查询如果每次扫10GB堆表,再快的磁盘也扛不住。所以看到Buffer IO高,第一反应应该是检查执行计划里的Index Scan节点,而不是提工单问“我的磁盘是不是坏了”。
3. 实操解析:从等待数据定位真实瓶颈的四步法
3.1 第一步:确认Query Store状态与基础配置
别跳过这步!我见过太多人直接写查询却返回空结果,最后发现Query Store根本没开。在Azure SQL DB里,执行以下T-SQL验证:
-- 检查Query Store是否启用及运行状态 SELECT actual_state_desc, desired_state_desc, current_storage_size_mb, max_storage_size_mb, readonly_reason, interval_length_minutes, stale_query_threshold_days FROM sys.database_query_store_options; -- 必须看到actual_state_desc = 'ON',且readonly_reason = 0 -- 如果current_storage_size_mb接近max_storage_size_mb,需清理旧数据常见陷阱:readonly_reason非零时,Query Store会停止写入新数据。典型值65536表示存储满,1024表示自动清理失败。此时执行ALTER DATABASE [YourDB] SET QUERY_STORE CLEAR;会清空所有历史,但别慌——等待数据本身不参与CLEAR操作,它只删查询文本和执行计划,等待统计保留在sys.query_store_wait_stats里。不过为保险起见,建议先备份关键等待数据:
-- 导出最近7天的高等待查询(按总等待时间排序) SELECT qsq.query_id, qsq.object_name, qsqt.query_sql_text, qsws.wait_category_desc, qsws.avg_query_wait_time_ms, qsws.total_query_wait_time_ms, qsws.count_executions INTO #high_wait_queries FROM sys.query_store_query AS qsq JOIN sys.query_store_query_text AS qsqt ON qsq.query_text_id = qsqt.query_text_id JOIN sys.query_store_plan AS qsp ON qsq.query_id = qsp.query_id JOIN sys.query_store_runtime_stats AS qsrs ON qsp.plan_id = qsrs.plan_id JOIN sys.query_store_wait_stats AS qsws ON qsrs.runtime_stats_id = qsws.runtime_stats_id WHERE qsws.last_execution_time >= DATEADD(day, -7, GETUTCDATE()) ORDER BY qsws.total_query_wait_time_ms DESC;提示:
sys.query_store_wait_stats表默认不包含last_execution_time字段,需通过sys.query_store_runtime_stats关联获取时间戳。很多教程漏掉这步,导致你查到的数据全是“历史模糊时间”。
3.2 第二步:构建等待热点地图——识别Top 5等待类型与查询
单纯看“哪个等待类型数值最大”是误导。比如Network IO总等待时间排第一,但可能是1000个轻量查询各等1ms,而Locks排第三,却是单个查询卡了5秒——后者才是真瓶颈。所以必须分层分析:
-- 步骤1:按等待大类统计总等待时长(单位:秒),过滤掉占比<1%的噪音 WITH wait_summary AS ( SELECT wait_category_desc, SUM(total_query_wait_time_ms) / 1000.0 AS total_wait_seconds, COUNT(*) AS execution_count, AVG(avg_query_wait_time_ms) AS avg_wait_ms FROM sys.query_store_wait_stats qsws JOIN sys.query_store_runtime_stats qsrs ON qsws.runtime_stats_id = qsrs.runtime_stats_id WHERE qsrs.last_execution_time >= DATEADD(hour, -24, GETUTCDATE()) GROUP BY wait_category_desc ) SELECT wait_category_desc, total_wait_seconds, ROUND(total_wait_seconds * 100.0 / SUM(total_wait_seconds) OVER(), 2) AS pct_of_total, execution_count, avg_wait_ms FROM wait_summary WHERE total_wait_seconds > 0 ORDER BY total_wait_seconds DESC; -- 步骤2:针对最高占比的等待类型,找出具体罪魁查询 SELECT TOP 5 qsq.query_id, OBJECT_NAME(qsq.object_id) AS object_name, SUBSTRING(qsqt.query_sql_text, 1, 100) AS query_snippet, qsws.wait_category_desc, qsws.avg_query_wait_time_ms, qsws.total_query_wait_time_ms, qsws.count_executions, qsrs.avg_duration_ms, qsrs.avg_cpu_time_ms FROM sys.query_store_query AS qsq JOIN sys.query_store_query_text AS qsqt ON qsq.query_text_id = qsqt.query_text_id JOIN sys.query_store_plan AS qsp ON qsq.query_id = qsp.query_id JOIN sys.query_store_runtime_stats AS qsrs ON qsp.plan_id = qsrs.plan_id JOIN sys.query_store_wait_stats AS qsws ON qsrs.runtime_stats_id = qsws.runtime_stats_id WHERE qsws.wait_category_desc = 'Buffer IO' -- 替换为你的最高占比类型 AND qsrs.last_execution_time >= DATEADD(hour, -24, GETUTCDATE()) ORDER BY qsws.total_query_wait_time_ms DESC;实操心得:我在某物流平台项目里发现Buffer IO占比68%,但Top 5查询里第3名是个SELECT COUNT(*) FROM orders——这明显是业务方写的监控脚本,每分钟执行一次。我们没优化它,而是让运维加了NOLOCK提示并改用sys.dm_db_partition_stats近似估算,等待时间从2300ms降到12ms。这说明:等待分析的目标不是“消灭所有等待”,而是识别对业务SLA有实质影响的等待。
3.3 第三步:深度钻取——关联等待与执行计划变更
等待飙升往往伴随执行计划突变。Query Store最强大的地方,是能把等待数据和计划ID绑定。以下查询直接定位“同一个查询ID下,不同计划的等待差异”:
-- 查看query_id=123的所有历史计划,及其对应的Buffer IO等待对比 SELECT qsp.plan_id, qsp.is_forced_plan, qsp.last_execution_time, qsws.wait_category_desc, qsws.avg_query_wait_time_ms, qsws.total_query_wait_time_ms, qsws.count_executions, -- 关键:提取执行计划里的物理操作符 CAST(qsp.query_plan AS XML).value('(/ShowPlanXML/BatchSequence/Batch/Statements/StmtSimple/QueryPlan/RelOp/@PhysicalOp)[1]', 'VARCHAR(50)') AS physical_op, CAST(qsp.query_plan AS XML).value('(/ShowPlanXML/BatchSequence/Batch/Statements/StmtSimple/QueryPlan/RelOp/@EstimateRows)[1]', 'FLOAT') AS est_rows FROM sys.query_store_plan AS qsp JOIN sys.query_store_wait_stats AS qsws ON qsp.plan_id = qsws.plan_id WHERE qsp.query_id = 123 AND qsws.wait_category_desc = 'Buffer IO' AND qsp.last_execution_time >= DATEADD(day, -7, GETUTCDATE()) ORDER BY qsws.total_query_wait_time_ms DESC;注意CAST(qsp.query_plan AS XML)这步:Azure SQL DB的query_plan字段是VARBINARY(MAX),必须转XML才能XPath提取。如果报错“无法解析XML”,说明该计划被截断(Azure对单个计划大小限制为4MB),此时改用sys.dm_exec_query_plan动态管理视图补全:
-- 针对plan_id=456,获取完整执行计划XML SELECT query_plan FROM sys.dm_exec_query_plan(0x06000100...); -- 替换为实际plan_id的十六进制值注意:
sys.dm_exec_query_plan只能查当前缓存中的计划,而Query Store存的是历史计划。两者互补使用,才能覆盖全生命周期。
3.4 第四步:建立等待基线——用T-SQL自动生成健康报告
手动查数据效率低,我给客户部署了一套自动化基线检测脚本。核心逻辑是:计算每个等待大类的7日移动平均值,当单日值超过均值2倍且持续2小时,触发告警。以下是精简版实现:
-- 创建等待基线视图(简化版) CREATE OR ALTER VIEW dbo.v_query_store_wait_baseline AS WITH daily_wait AS ( SELECT CAST(qsrs.last_execution_time AS DATE) AS exec_date, qsws.wait_category_desc, SUM(qsws.total_query_wait_time_ms) / 1000.0 AS total_wait_sec, COUNT(*) AS exec_count FROM sys.query_store_wait_stats qsws JOIN sys.query_store_runtime_stats qsrs ON qsws.runtime_stats_id = qsrs.runtime_stats_id WHERE qsrs.last_execution_time >= DATEADD(day, -7, GETUTCDATE()) GROUP BY CAST(qsrs.last_execution_time AS DATE), qsws.wait_category_desc ), baseline AS ( SELECT wait_category_desc, AVG(total_wait_sec) AS avg_daily_wait_sec, STDEV(total_wait_sec) AS std_dev_wait_sec FROM daily_wait GROUP BY wait_category_desc ) SELECT dw.exec_date, dw.wait_category_desc, dw.total_wait_sec, b.avg_daily_wait_sec, ROUND((dw.total_wait_sec - b.avg_daily_wait_sec) / NULLIF(b.avg_daily_wait_sec, 0), 2) AS deviation_ratio, CASE WHEN dw.total_wait_sec > b.avg_daily_wait_sec * 2 THEN 'HIGH_RISK' WHEN dw.total_wait_sec > b.avg_daily_wait_sec * 1.5 THEN 'MEDIUM_RISK' ELSE 'NORMAL' END AS risk_level FROM daily_wait dw JOIN baseline b ON dw.wait_category_desc = b.wait_category_desc;然后用Azure Automation或本地SQL Agent定时执行:
-- 每小时运行一次,发邮件告警 IF EXISTS ( SELECT 1 FROM dbo.v_query_store_wait_baseline WHERE risk_level = 'HIGH_RISK' AND exec_date = CAST(GETUTCDATE() AS DATE) ) BEGIN EXEC msdb.dbo.sp_send_dbmail @profile_name = 'AzureSQLAlerts', @recipients = 'dba@company.com', @subject = 'ALERT: High Wait Deviation Detected', @body = 'Check v_query_store_wait_baseline view for details.'; END这套方案上线后,某客户将平均故障响应时间从47分钟缩短到8分钟。关键不是技术多炫,而是把“等待”从一个孤立指标,变成了可量化、可预测、可自动干预的运维信号。
4. 常见问题与实战避坑指南
4.1 问题1:“查不到等待数据”——90%是因为时间范围或权限问题
现象:执行SELECT * FROM sys.query_store_wait_stats返回空集,但Query Store明明开着。
根因排查顺序:
- 时间范围错误:默认
last_execution_time是UTC时间,而你的GETDATE()是本地时区。Azure SQL DB所有系统时间函数(GETUTCDATE()、SYSDATETIMEOFFSET())都返回UTC。正确写法:-- 错误:用GETDATE()会导致查不到最近1小时数据 WHERE qsrs.last_execution_time >= DATEADD(hour, -1, GETDATE()) -- 正确:统一用UTC WHERE qsrs.last_execution_time >= DATEADD(hour, -1, GETUTCDATE()) - 权限不足:
VIEW DATABASE STATE权限是硬性要求。即使你是db_owner,没显式授予权限也会失败。执行:GRANT VIEW DATABASE STATE TO [YourAppUser]; - Query Store未捕获该查询:某些系统查询(如
SELECT * FROM sys.dm_exec_sessions)或含OPTION (RECOMPILE)的查询,Query Store默认不记录。可通过sys.query_store_options的capture_mode参数确认,ALL模式才捕获全部。
实操心得:我在某医疗系统项目里遇到过“部分查询无等待数据”,最终发现是应用用了
SET ARITHABORT OFF,而Query Store对ARITHABORT敏感——相同SQL文本,ON和OFF被视为不同查询。解决方案是在连接字符串里强制加;ArithAbort=True。
4.2 问题2:“等待时间很高,但CPU/IO监控一切正常”——云环境特有幻觉
现象:Buffer IO平均等待450ms,但Azure门户的“CPU百分比”和“Data IO”图表都低于30%。
真相:这是典型的资源隔离幻觉。Azure SQL DB的vCore模型中,CPU、内存、IO是共享资源池,但监控图表只显示“分配给你的配额使用率”,而非“物理设备负载”。比如你买的是4 vCore,但底层物理服务器有64核,当其他租户突发IO请求时,你的PAGEIOLATCH_SH等待会飙升,而你的“Data IO”图表仍显示低负载——因为你的IO请求还没轮到调度队列前端。验证方法:查sys.dm_db_resource_stats视图的avg_data_io_percent字段,它反映的是实际物理IO压力,而非配额占用率。
-- 对比两个IO指标 SELECT avg_cpu_percent, avg_data_io_percent, -- 物理IO压力(关键!) avg_log_write_percent, end_time FROM sys.dm_db_resource_stats WHERE end_time >= DATEADD(minute, -60, GETUTCDATE()) ORDER BY end_time DESC;如果avg_data_io_percent持续>80%而avg_cpu_percent很低,说明你正遭遇IO争用,该升级服务层级(如从S6升到P1);如果两者都低,那等待高一定是查询自身问题(如缺少索引)。
4.3 问题3:“同一个查询,不同时间等待类型完全不同”——参数嗅探的隐形推手
现象:query_id=789在上午10点主要等Locks,下午3点却变成Memory,执行时间从200ms涨到8秒。
根因:参数嗅探(Parameter Sniffing)导致SQL Server为不同参数值生成了不同执行计划,而Query Store忠实地记录了每个计划的等待特征。比如:
- 参数
@status='Active'(返回10行)→ 选择索引查找 → 等待Locks(短事务锁) - 参数
@status='All'(返回10万行)→ 选择表扫描 → 等待Memory(需要大量工作内存)
解决方案不是禁用参数嗅探,而是用Query Store的强制计划功能锁定高效计划:
-- 查找针对@status='Active'的高效plan_id SELECT plan_id FROM sys.query_store_plan WHERE query_id = 789 AND is_forced_plan = 0; -- 强制使用该plan_id(假设为101) EXEC sp_query_store_force_plan 789, 101;注意:强制计划后,Query Store仍会收集新计划的等待数据,只是不自动使用它。这给你留出了测试窗口——如果新参数值真的需要不同计划,你可以随时
sp_query_store_unforce_plan。
4.4 问题4:“等待数据突然中断”——自动清理机制的隐性规则
现象:等待统计在某天凌晨2点后全部消失,sys.query_store_wait_stats行数归零。
真相:Query Store的自动清理(Auto-Cleanup)不仅删旧查询,还会根据size_based_cleanup_mode(默认AUTO)触发空间回收。当current_storage_size_mb达到max_storage_size_mb的90%时,它会删除最老的runtime_stats_interval(时间窗口)数据,而等待数据依附于这些时间窗口。查看清理日志:
-- 查看最近的清理事件 SELECT event_time, event_type, details FROM sys.fn_xe_file_target_read_file('query_store*.xel', null, null, null) WHERE event_type = 'query_store_disk_size_exceeded';预防措施:在Azure门户的Query Store设置里,把max_storage_size_mb设为足够大(如2048MB),并开启size_based_cleanup_mode = OFF(需SQL Server 2019+,Azure SQL DB已支持)。但更推荐主动管理:
-- 每周日凌晨1点,清理30天前的低频查询(执行次数<5次/天) EXEC sp_query_store_remove_query @query_id = ( SELECT TOP 1 query_id FROM sys.query_store_query WHERE last_execution_time < DATEADD(day, -30, GETUTCDATE()) AND query_id NOT IN ( SELECT DISTINCT query_id FROM sys.query_store_runtime_stats WHERE last_execution_time >= DATEADD(day, -30, GETUTCDATE()) GROUP BY query_id HAVING COUNT(*) >= 5 ) );4.5 问题5:“等待类型显示Other,但实际是严重问题”——云服务特有等待的破译
现象:wait_category_desc = 'Other'占比奇高(>40%),但无法定位具体原因。
真相:Other大类包含两类Azure专属等待:
HADR_SYNC_COMMIT:仅在启用了异地复制(Geo-Replication)的数据库中出现,表示等待异地副本确认提交。如果该值高,说明跨区域网络延迟大(如从东海岸到西海岸)。QDS_PERSIST_TASK_MAIN_LOOP_SLEEP:Query Store自身的后台持久化任务睡眠,正常值应<1%。如果飙升,表明Query Store写入压力过大,需调大max_storage_size_mb或减小interval_length_minutes。
诊断脚本:
-- 拆解Other大类的具体等待(需查询内部DMV,权限要求高) SELECT wait_type, waiting_tasks_count, wait_time_ms, max_wait_time_ms, signal_wait_time_ms FROM sys.dm_os_wait_stats WHERE wait_type IN ('HADR_SYNC_COMMIT', 'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP') AND wait_time_ms > 0;如果HADR_SYNC_COMMIT等待显著,优化方案不是调数据库,而是改应用:对非关键事务(如日志写入)关闭WAIT_ON_SYNC_COMPLETION,用异步提交换取性能。
5. 进阶技巧:等待数据与其他云服务的交叉分析
5.1 关联Azure Monitor指标,构建端到端性能视图
Query Store的等待数据是“数据库视角”,而Azure Monitor提供“基础设施视角”。两者结合才能闭环。例如,当Network IO等待飙升时:
- 查Query Store:确认是哪些查询(如
SELECT * FROM huge_table) - 查Azure Monitor:看
dtu_consumption_percent是否同步冲高?如果没冲高,说明是应用层问题(如客户端未启用连接池) - 查Application Insights:看对应API的
dependencyDuration是否匹配?如果API耗时8秒,而Query Store显示Network IO等了7.8秒,那问题在数据库;如果API耗时8秒但Network IO只等了200ms,那剩余7.8秒在序列化/网络传输环节。
实现自动关联:用Log Analytics的KQL查询:
// 合并Query Store等待与Azure SQL监控指标 AzureDiagnostics | where ResourceProvider == "MICROSOFT.SQL" and Category == "QueryStoreWaitStatistics" | join ( AzureMetrics | where ResourceProvider == "MICROSOFT.SQL" and MetricName == "dtu_consumption_percent" | extend _time = TimeGenerated ) on $left.startTime_s == $right._time | project startTime_s, wait_category_desc, total_query_wait_time_ms, Average, Resource5.2 利用Power BI可视化等待趋势,替代手工报表
我把Query Store等待分析做成Power BI模板,核心是三个动态切片器:
- 时间范围:支持小时/天/周粒度切换
- 等待大类:点击
Buffer IO自动下钻到Top查询 - 服务层级:对比S3/S6/P1的等待分布差异(证明升级价值)
关键DAX度量值:
// 计算偏离度(用于标红高风险) Wait Deviation = VAR CurrentAvg = AVERAGE('QueryStoreWait'[avg_query_wait_time_ms]) VAR BaselineAvg = CALCULATE( AVERAGE('QueryStoreWait'[avg_query_wait_time_ms]), DATESINPERIOD('Date'[Date], LASTDATE('Date'[Date]), -7, DAY) ) RETURN DIVIDE(CurrentAvg - BaselineAvg, BaselineAvg, 0)客户用这个看板,在季度资源评审会上,用一页PPT就证明了“从S6升级到P1可降低Buffer IO等待62%”,直接推动预算审批。
5.3 等待数据驱动的索引优化决策树
别再盲目建索引!用等待数据反推索引需求:
Locks高 +avg_duration_ms高 → 检查UPDATE/DELETE语句的WHERE条件,缺失索引导致锁升级Buffer IO高 + 执行计划含Index Scan→ 在WHERE/JOIN字段上建非聚集索引Memory高 +EstimateRows远大于ActualRows→ 更新统计信息,或加OPTIMIZE FOR提示
我给某在线教育平台做的索引优化,就是基于等待数据:发现Locks等待集中在UPDATE student_progress SET status='Completed',而WHERE条件是course_id和user_id。原有索引只有course_id,我们加了复合索引IX_student_progress_course_user ON student_progress(course_id, user_id),Locks等待从平均1200ms降到45ms。
最后分享一个小技巧:在Azure SQL DB里,用
sys.dm_db_missing_index_details查缺失索引时,务必加上WHERE database_id = DB_ID()过滤,否则会返回系统数据库的垃圾建议——这是Azure SQL DB的已知行为,文档里都没写清楚。