从10分钟到10秒:一个真实慢查询的SQL Server优化全记录

从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 后还需要 QuantityUnitPrice,所以优化器干脆选择 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

解决方案优先级:

  1. 改写 SQL,把函数移到等号右边(参数侧)
  2. 使用计算列 + PERSISTED
  3. 使用索引视图

3. 覆盖索引是"性价比最高的优化"

不需要改 SQL,不需要改表结构,只需要加一个 INCLUDE 列,就能把 Index Scan 变成 Index Seek。

判断标准: ​ 执行计划中如果看到 Key LookupRID 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 写法。

相关推荐
IT爱学堂1 小时前
Go开发疑难杂症终结者通关指南
后端
大白801 小时前
你的数据库密码还在用明文?SQL Server 凭据管理的 3 种现代方案
后端
凤山老林1 小时前
Spring Boot 集成 ShardingSphere-Encrypt 实现字段级实时脱敏
java·spring boot·后端·数据脱敏
vipxieliang1 小时前
ValidX v1.2.0 更新日志
java·后端
Zane19941 小时前
只改一个方向的引用,循环引用就能立刻被回收?一文讲透 weakref 弱引用
后端·python
长大19881 小时前
窗口函数用不好反而更慢?SQL Server中OVER子句的4个性能陷阱
后端
大黄评测1 小时前
MERGE语句有Bug?SQL Server官方不推荐的背后真相与替代方案
后端
长大19881 小时前
TempDB 爆满导致系统卡死?SQL Server TempDB 瓶颈诊断与根治方案
后端
lichenyang4532 小时前
从「房间」到「实时通知」:用 NestJS + Socket.IO 实现团队邀请的完整工程实践
前端·后端