定位Hash JOIN内存等待需查sys.dm_os_wait_stats中PAGEIOLATCH_、RESERVED_MEMORY_ALLOCATION_EXT、SOS_SCHEDULER_YIELD三者叠加且PAGEIOLATCH_占比超15%,再结合执行计划Warning="1"及tempdb分配争用综合判定。

查 sys.dm_os_wait_stats 看是否真卡在 Hash 溢出上
Hash JOIN 慢,90% 不是算法本身慢,而是内存不够被迫写 tempdb,引发 I/O 和闩锁争用。直接看等待类型比猜执行计划更准。
重点盯这四个值:
-
PAGEIOLATCH_SH或PAGEIOLATCH_EX飙升 → 说明在读写 tempdb 数据页 -
RESERVED_MEMORY_ALLOCATION_EXT高 → 大量线程抢查询内存,Hash 构建表阶段就卡住了 -
SOS_SCHEDULER_YIELD同时高 → 内存压力大导致任务频繁让出 CPU - 三者叠加且
PAGEIOLATCH_*占总等待 >15% → 基本锁定是 Hash spill 导致的连锁反应
别只跑一次快照,连续采样 3–5 分钟再对比趋势——刚重启后 sys.dm_os_wait_stats 是清零的,单次结果没意义。
从 sys.dm_exec_query_stats 找出实际溢出的语句
不是所有带 HASH 关键字的 SQL 都有问题,得找真正申请了大量内存、又没用完的那几条。
运行这个查询(注意时间窗口和内存阈值):
SELECT qs.sql_handle, qs.total_grant_kb, qs.total_used_grant_kb,
qs.total_ideal_grant_kb, st.text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
WHERE st.text LIKE '%HASH%'
AND qs.total_grant_kb > 102400 -- 只看授予超 100MB 的
AND qs.last_execution_time > DATEADD(minute, -10, GETDATE())
ORDER BY qs.total_grant_kb DESC;拿到 sql_handle 后,用 sys.dm_exec_query_plan 提取 XML 执行计划,搜索 <relop. match>,确认两点:</relop.>
- 属性里有没有
Warning="1"(溢出标志) -
GrantedMemory和UsedMemory差值是否巨大(比如授了 500MB,只用了 80MB,说明估算严重偏差)
验证 tempdb 分配是否成了瓶颈
Hash 溢出后写入 tempdb,所有线程都往同一 SGAM/PFS 页面分配空间,极易触发 PAGELATCH_UP(PFS 争用)或 PAGELATCH_EX(GAM 争用)——这才是“内存等待 LATCH”的真实来源。
查 sys.dm_db_session_space_usage:
- 看
internal_objects_alloc_page_count是否在语句执行期间陡增 - 结合
sys.dm_io_virtual_file_stats看 tempdb 数据文件的io_stall_read_ms/io_stall_write_ms是否远高于其他库
如果 tempdb 是单数据文件、且没按 CPU 核数配置(比如 8 核却只配 1 个 tempdb 文件),争用会直接放大 Hash 的性能问题。
别只盯着 Hint,先砍构建表的数据量
加 OPTION (HASH JOIN) 很少能解决问题,反而容易固化错误路径。真正可控的是“构建表”本身:
- 把
WHERE条件塞进子查询:用INNER JOIN (SELECT * FROM orders WHERE order_date >= '2025-01-01') o,而不是把过滤留在外层 - 检查连接字段类型是否一致(
INT对BIGINT会隐式转换,导致索引失效+估算崩坏) - 确认统计信息是否过期:对构建表运行
DBCC SHOW_STATISTICS,看ModificationCount是否远超行数 20% - 补覆盖索引:如果构建表要读 5 列,但只有
id有索引,那就建INCLUDE所有需列的非聚集索引,减少回表带来的内存开销
Hash JOIN 的失控点永远不在“要不要用”,而在“给它喂了多少数据”。估算偏差 3 倍,内存需求就可能翻 10 倍——这点在执行计划里藏得最深,也最容易被忽略。

















