从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 写法。

相关推荐
考虑考虑6 小时前
docker compose V2版本新属性
运维·后端·自动化运维
GreenTea8 小时前
7000 万 QPS、500 PB:OpenAI 如何用一个 Python 存储平台撑住 10 亿用户
后端·架构
Flynt9 小时前
Java 27 升级实测:默认值动得比新特性多,有个老参数会让 JVM 直接起不来
java·jvm·后端
Bs_MoneyMagnet9 小时前
基于springboot+vue的个人健康管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·vue3·springboot3·计算机毕业设计
GreenTea9 小时前
OpenAI Agents API 上手实测:一次调用把整个 agent loop 甩给 OpenAI
前端·后端·算法
Bs_MoneyMagnet11 小时前
基于springboot+vue的心理咨询预约与随访平台的设计与实现 源码+文档
vue.js·spring boot·后端·spring·毕业设计·旅游·计算机毕业设计
IT_陈寒12 小时前
Vue的响应式更新把我坑惨了,原来问题出在这
前端·人工智能·后端
陌シ未央ゞ13 小时前
基于BM25算法和RRF实现的混合索引(java版)
人工智能·spring boot·后端·算法
第五页的你13 小时前
SpringBoot基础设施配置(Redis序列化,JJWT新版)
后端
吃饱了得干活13 小时前
RabbitMQ 原理解析(下):存储、集群与可靠投递
后端·rabbitmq