4. 等待统计与性能瓶颈定位

SQL Server 的性能问题,本质上很少是“CPU 不够快”这么简单。一个查询变慢,往往是因为它在某个环节上“等”了太久——等磁盘把数据页读进来、等内存授予到位、等锁释放、等 CPU 时间片。等待统计(Wait Statistics)就是把这些“等待”按类型、按时间量化出来,让我们从“感觉慢”走到“知道慢在哪里”。

理解等待统计,先要建立一个核心公式:响应时间 = 工作时间 + 等待时间。工作时间是 CPU 真正执行指令的时间,等待时间则是线程处于挂起状态、等待某种资源就绪的时间。SQL Server 为每一次等待都记录了等待类型(wait_type)、等待次数(waiting_tasks_count)、累计等待毫秒数(wait_time_ms)和最大单次等待(max_wait_time_ms)。这些数据通过 sys.dm_os_wait_stats 暴露出来,是定位瓶颈的第一手材料。

关键认知:等待本身不是错误。任何正常运行的数据库都在等待。真正有价值的是“等待的分布”——哪一类等待占据了总等待时间的大头,它指向的就是当前最紧的资源。

一、从累计等待到“当前谁在等”

sys.dm_os_wait_stats 是自 SQL Server 启动以来的累计值,适合看长期趋势,但不适合直接判断“此刻”的瓶颈。更实用的做法是取两次快照做差值,观察一段时间窗口内新增的等待。下面这段脚本是常用的采集方式:

-- 第一次快照
SELECT wait_type, waiting_tasks_count, wait_time_ms
INTO #w1 FROM sys.dm_os_wait_stats;

WAITFOR DELAY '00:00:30';   -- 观察 30 秒

-- 第二次快照并计算差值
SELECT w2.wait_type,
       w2.waiting_tasks_count - w1.waiting_tasks_count AS wait_cnt,
       w2.wait_time_ms - w1.wait_time_ms               AS wait_ms
FROM sys.dm_os_wait_stats w2
JOIN #w1 w1 ON w1.wait_type = w2.wait_type
WHERE w2.wait_time_ms - w1.wait_time_ms > 0
ORDER BY wait_ms DESC;

要判断“当前谁在等”,则用 sys.dm_os_waiting_tasks,它列出此刻正在等待的会话、等待类型、阻塞它的会话(blocking_session_id)以及等待资源描述。把这两者结合:累计差值告诉你“哪类等待在增长”,实时视图告诉你“是哪些语句、被谁挡住”。

二、常见等待类型与资源映射

等待类型有几百种,但生产环境中反复出现的核心类型并不多。下面这张表把等待类型与它指向的资源瓶颈对应起来,是排障时最常查的对照关系:

等待类型指向的瓶颈典型成因
PAGEIOLATCH_SH / EX磁盘 I/O数据页不在缓冲池,需从磁盘读取
WRITELOG日志 I/O事务提交时日志写入慢,日志盘压力大
CXPACKET / CXCONSUMER并行查询并行执行中线程间同步,常伴随并行计划
SOS_SCHEDULER_YIELDCPU线程主动让出 CPU,CPU 压力高的信号
RESOURCE_SEMAPHORE内存(查询授予)内存授予不足,查询排队等内存
LCK_M_*锁被其他事务的锁阻塞
PAGELATCH_*内存中的页闩锁热点页(如自增索引末尾页)争用

需要特别区分 PAGEIOLATCH 和 PAGELATCH:前者带 IO,说明页不在内存、要读盘,是 I/O 问题;后者不带 IO,说明页已在内存,但多个线程争抢同一页的闩锁,是并发争用问题。名字只差几个字母,排查方向完全不同。

三、把等待映射到四大资源

等待统计的价值在于它能同时覆盖 CPU、I/O、内存、锁四类瓶颈,而不需要分别去猜。下图展示了从等待类型到资源瓶颈的映射关系,以及每一类对应的进一步排查入口:

等待类型 → 资源瓶颈 → 排查入口 sys.dm_os_wait_stats 差值快照 + waiting_tasks CPU 瓶颈 SOS_SCHEDULER_YIELD / CXPACKET 排查:高 CPU 语句、并行度 缺失索引、参数嗅探 MAXDOP / 成本阈值 I/O 瓶颈 PAGEIOLATCH / WRITELOG 排查:缓冲池命中率、读延迟 日志盘写入、检查点 大表扫描、缺失索引 内存瓶颈 RESOURCE_SEMAPHORE 排查:内存授予等待、排序/哈希 max server memory 计划缓存膨胀 锁 / 闩锁瓶颈 LCK_M_* / PAGELATCH_* 排查:阻塞链、隔离级别 热点页、自增键争用 事务粒度、锁升级 判断原则 看占比,不看绝对值 排除良性等待 结合实时等待会话 再定位到具体语句

四、排除“良性等待”,聚焦真实瓶颈

直接看 sys.dm_os_wait_stats 的累计值会被大量无害等待淹没。例如 CHECKPOINT_QUEUE、LAZYWRITER_SLEEP、SQLTRACE_BUFFER_FLUSH、BROKER_TASK_STOP 等,都是后台任务在“正常休息”,不代表性能问题。排查时应先把这些良性等待过滤掉,只看真正消耗响应时间的类型。

另一个重要概念是“等待占比”。假设 30 秒窗口内总等待 60 万毫秒,其中 PAGEIOLATCH_SH 占 35 万毫秒,那 I/O 就是当前最突出的矛盾;如果 SOS_SCHEDULER_YIELD 占 30 万毫秒,则 CPU 压力更值得关注。占比比绝对值更能说明问题,因为不同服务器的负载规模差异很大。

一个实用技巧:把等待时间除以等待次数,得到“平均单次等待”。如果 PAGEIOLATCH_SH 平均每次等待只有 1~2 毫秒,说明磁盘响应尚可;如果平均达到 20 毫秒以上,磁盘子系统很可能已经吃力。

五、从等待回到具体语句

等待统计只告诉你“哪类资源紧张”,要落到“哪条语句”,还需要结合实时会话。下面这段查询把当前等待会话、等待类型和正在执行的语句关联起来:

SELECT r.session_id,
       r.blocking_session_id,
       r.wait_type,
       r.wait_time,
       r.wait_resource,
       SUBSTRING(t.text, 1, 120) AS running_sql
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.session_id > 50
  AND r.wait_type IS NOT NULL
ORDER BY r.wait_time DESC;

如果 blocking_session_id 不为 0,说明是锁等待,顺着这个 ID 就能找到阻塞源头;如果 wait_resource 显示为某个数据页或键值,则能进一步定位到具体对象。这样,等待统计就从“资源级”下钻到了“语句级”,形成完整的定位链路。

六、定位之后做什么

等待统计本身不解决问题,它负责把问题指出来。CPU 等待高,就去查高 CPU 语句、并行计划和缺失索引;I/O 等待高,就去看缓冲池命中率、大表扫描和日志写入;内存授予等待高,就检查排序/哈希操作和内存配置;锁等待高,就梳理事务粒度和隔离级别。每一次优化之后,重新采集一次等待差值,对比目标等待类型是否下降——这就是验证优化是否有效的客观依据。

把等待统计当作性能排障的“入口仪表盘”:先用它确定方向,再用更细的 DMV 和工具深入,最后用等待差值验证效果。这条路径比凭经验猜测要可靠得多。