从10分钟到10秒:一个真实慢查询的SQL Server优化全记录
一个真实案例,一条报表查询从 600秒 压缩到 10秒以内。
没有改表结构,没有加硬件,只靠读懂执行计划和几步精准手术。
一、背景:报表系统的"定时炸弹"
事情发生在去年双十一大促前的压测阶段。
业务方反馈:每天早上的销售汇总报表,跑到第8分钟就超时断连,SSMS 里手动执行也要 10分钟以上 。随着订单表数据量突破 8000万行,这个问题已经从"偶尔慢"变成了"必现慢"。
涉及的表结构(简化后):
sql
-- 订单表,约 8000万行
Orders (
OrderID BIGINT PRIMARY KEY,
CustomerID INT,
OrderDate DATETIME,
Status TINYINT,
TotalAmount DECIMAL(18,2),
RegionCode VARCHAR(10)
-- 索引:PK_CL_Orders_OrderID(聚集索引)
-- 索引:IX_Orders_OrderDate(非聚集)
-- 索引:IX_Orders_CustomerID(非聚集)
)
-- 客户表,约 50万行
Customers (
CustomerID INT PRIMARY KEY,
CustomerName NVARCHAR(100),
CustomerLevel TINYINT,
RegistrationDate DATE
-- 索引:PK_CL_Customers_CustomerID
)
-- 订单明细表,约 2.4亿行
OrderDetails (
DetailID BIGINT IDENTITY PRIMARY KEY,
OrderID BIGINT,
ProductID INT,
Quantity INT,
UnitPrice DECIMAL(18,2)
-- 索引:PK_CL_OrderDetails_DetailID
-- 索引:IX_OrderDetails_OrderID(非聚集)
)
原始查询(已脱敏简化):
sql
SELECT
c.CustomerLevel,
DATEADD(MONTH, DATEDIFF(MONTH, 0, o.OrderDate), 0) AS OrderMonth,
COUNT(DISTINCT o.OrderID) AS OrderCount,
SUM(od.Quantity * od.UnitPrice) AS TotalRevenue
FROM Orders o
INNER JOIN Customers c
ON o.CustomerID = c.CustomerID
INNER JOIN OrderDetails od
ON o.OrderID = od.OrderID
WHERE o.OrderDate >= '2024-01-01'
AND o.OrderDate < '2025-01-01'
AND o.Status = 4 -- 已完成订单
AND c.CustomerLevel IN (2, 3, 4)
GROUP BY
c.CustomerLevel,
DATEADD(MONTH, DATEDIFF(MONTH, 0, o.OrderDate), 0);
需求很简单:统计2024年各客户等级、各月的订单量和总收入。
但执行时间:623秒。
二、第一步:看执行计划
在 SSMS 中打开"实际执行计划"(Ctrl + M),跑一遍查询。
执行计划的关键信息如下:
lua
|-- Hash Match (Aggregate)
|-- Parallelism (Gather Streams)
|-- Hash Match (Aggregate)
|-- Hash Match (Join) [Orders ↔ Customers] ★
|-- Clustered Index Scan (Orders) ★★★ 预估行数严重偏差
|-- Index Seek (Customers)
|-- Hash Match (Join) [结果 ↔ OrderDetails] ★
|-- ...
|-- Clustered Index Scan (OrderDetails) ★★★ 2.4亿行全扫
三个致命信号:
| 信号 | 含义 |
|---|---|
| Clustered Index Scan on Orders | 8000万行全表扫描,虽然 OrderDate 有索引,但优化器没选 |
| Clustered Index Scan on OrderDetails | 2.4亿行全表扫描,JOIN 没走 IX_OrderDetails_OrderID |
| Hash Match 嵌套 | 三层 Hash Join,内存压力大,大量数据溢出到 TempDB |
三、第二步:诊断根因
根因 1:统计信息严重过期
sql
-- 查看统计信息更新时间
SELECT
obj.name AS TableName,
stat.name AS StatName,
stat.stats_id,
sp.last_updated,
sp.rows,
sp.rows_sampled
FROM sys.stats stat
CROSS APPLY sys.dm_db_stats_properties(stat.object_id, stat.stats_id) sp
INNER JOIN sys.objects obj ON stat.object_id = obj.object_id
WHERE obj.name IN ('Orders', 'OrderDetails', 'Customers')
ORDER BY obj.name, stat.name;
结果:Orders 表的 OrderDate 列统计信息上次更新时只有 1200万行 ,现在已经是 8000万行。优化器以为筛选 OrderDate >= '2024-01-01' 会返回 6000万行(占全表 75%),所以认为 全表扫描比走索引更便宜。
但实际上,2024年的数据只有约 1800万行(占 22%),走索引才是正确选择。
根因 2:GROUP BY 中的函数阻止了索引利用
scss
DATEADD(MONTH, DATEDIFF(MONTH, 0, o.OrderDate), 0)
这个写法虽然巧妙,但 对索引列 OrderDate 做了函数包裹 ,导致即使走了索引,也无法做 Index Seek + Stream Aggregate,只能先扫描再 Hash 聚合。
根因 3:OrderDetails 缺少覆盖索引
IX_OrderDetails_OrderID 只包含 OrderID 一列。JOIN 后还需要 Quantity 和 UnitPrice,所以优化器干脆选择 Clustered Index Scan,一次性拿到所有列。
四、第三步:逐个击破
手术 1:更新统计信息(立竿见影)
sql
UPDATE STATISTICS Orders WITH FULLSCAN;
UPDATE STATISTICS OrderDetails WITH FULLSCAN;
UPDATE STATISTICS Customers WITH FULLSCAN;
效果 :执行时间从 623秒 → 280秒。
优化器开始选择
IX_Orders_OrderDate做 Index Seek,但 OrderDetails 仍然全扫。
手术 2:改写 GROUP BY,避免对索引列做函数运算
引入计算列 + 索引:
sql
-- 添加持久化计算列
ALTER TABLE Orders
ADD OrderMonth AS DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1) PERSISTED;
-- 在计算列上建索引(包含 GROUP BY 所需的其他列)
CREATE NONCLUSTERED INDEX IX_Orders_Month_Covering
ON Orders (OrderMonth, Status)
INCLUDE (CustomerID, OrderID);
改写查询:
vbnet
SELECT
c.CustomerLevel,
o.OrderMonth,
COUNT(DISTINCT o.OrderID) AS OrderCount,
SUM(od.Quantity * od.UnitPrice) AS TotalRevenue
FROM Orders o
INNER JOIN Customers c
ON o.CustomerID = c.CustomerID
INNER JOIN OrderDetails od
ON o.OrderID = od.OrderID
WHERE o.OrderMonth >= '2024-01-01'
AND o.OrderMonth < '2025-01-01'
AND o.Status = 4
AND c.CustomerLevel IN (2, 3, 4)
GROUP BY
c.CustomerLevel,
o.OrderMonth;
效果 :执行时间从 280秒 → 95秒。
Orders 表现在走 Index Seek + Stream Aggregate,Hash Match 从三层减到两层。
手术 3:为 OrderDetails 建覆盖索引
scss
CREATE NONCLUSTERED INDEX IX_OrderDetails_OrderID_Covering
ON OrderDetails (OrderID)
INCLUDE (Quantity, UnitPrice);
效果 :执行时间从 95秒 → 22秒。
OrderDetails 从 Clustered Index Scan 变为 Index Seek + Key Lookup(因为 INCLUDE 覆盖了所有需要的列,实际是纯 Index Seek)。
手术 4:消除 COUNT(DISTINCT) 的 Hash 开销
COUNT(DISTINCT o.OrderID) 在聚合时强制走了 Hash Match (Aggregate)。但 OrderID 是主键,在 GROUP BY CustomerLevel, OrderMonth 的组合下,每个 OrderID 只属于一个分组,所以 DISTINCT 是多余的。
scss
COUNT(o.OrderID) AS OrderCount -- 去掉 DISTINCT
效果 :执行时间从 22秒 → 9.8秒。
五、最终成果
| 阶段 | 执行时间 | 关键操作 |
|---|---|---|
| 原始查询 | 623秒 | 全表扫描 + 三层 Hash Join |
| 更新统计信息 | 280秒 | 优化器重新评估行数 |
| 计算列 + 覆盖索引 | 95秒 | 消除函数包裹,Index Seek |
| OrderDetails 覆盖索引 | 22秒 | 消除 2.4亿行全扫 |
| 去掉多余 DISTINCT | 9.8秒 | 消除 Hash Aggregate |
| 最终提升 | 63倍 | 623s → 9.8s |
六、复盘:三个关键经验
1. 统计信息是优化器的"眼睛"
优化器不傻,它只是"看不清"。
大多数"索引失效"的问题,根子都在统计信息过期。养成定期更新统计信息的习惯,尤其是大批量数据变更之后。
sql
-- 生产环境建议用 SAMPLE 而非 FULLSCAN,减少锁表时间
UPDATE STATISTICS Orders WITH SAMPLE 50 PERCENT;
2. 函数包裹索引列 = 主动放弃索引
任何对索引列的函数运算(YEAR()、DATEADD()、CAST()、CONVERT()),都会阻止 Index Seek。
解决方案优先级:
- 改写 SQL,把函数移到等号右边(参数侧)
- 使用计算列 + PERSISTED
- 使用索引视图
3. 覆盖索引是"性价比最高的优化"
不需要改 SQL,不需要改表结构,只需要加一个 INCLUDE 列,就能把 Index Scan 变成 Index Seek。
判断标准: 执行计划中如果看到 Key Lookup 或 RID Lookup 的代价很高,就说明需要覆盖索引。
七、附:优化前后执行计划对比
sql
【优化前】
Clustered Index Scan (Orders, 8000万行)
→ Hash Match Join
→ Clustered Index Scan (OrderDetails, 2.4亿行)
→ Hash Match Join
→ Index Seek (Customers)
→ Hash Match Aggregate (含 DISTINCT)
→ Hash Match Aggregate (GROUP BY)
【优化后】
Index Seek (Orders, IX_Orders_Month_Covering, ~1800万行)
→ Merge Join (Orders ↔ Customers, 已排序)
→ Index Seek (OrderDetails, IX_OrderDetails_OrderID_Covering)
→ Stream Aggregate (GROUP BY, 无 DISTINCT)
从 Hash 主导 变成 Seek + Merge Join + Stream Aggregate,这就是 63 倍差距的本质。
写在最后
SQL Server 的优化器非常强大,但它不是魔法------它需要准确的统计信息、合理的索引设计和"对索引友好"的 SQL 写法。