索引原理与执行计划优化

索引原理与执行计划优化

索引原理与执行计划优化

4.1 B+Tree 索引内部结构

4.1.1 聚簇索引(Clustered Index)

SQL Server 表数据本身按聚簇索引键排列存储,聚簇索引就是数据。每张表只能有一个聚簇索引。

┌──────────────────── B+Tree 聚簇索引结构 ─────────────────────┐
│                                                               │
│  根节点(Root Page)                                          │
│  ┌──────────────────────────────────────┐                    │
│  │ KeyRange ≤1000 │ KeyRange ≤5000 │ >5000│ → 内部节点指针   │
│  └───────┬──────────────┬───────────────┘                    │
│          │              │                                     │
│  中间节点(Intermediate Pages)                               │
│  ┌────────────┐  ┌────────────┐                              │
│  │≤200 │≤600  │  │≤3000│≤4500 │                              │
│  └──┬────┬───┘  └──┬────┬────┘                              │
│     │    │         │    │                                    │
│  叶子节点(Leaf Pages = 实际数据行!)                         │
│  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐                        │
│  │行1   │ │行201 │ │行601 │ │行1001│   ←→  双向链表           │
│  │行2   │ │行202 │ │行602 │ │行1002│                         │
│  │...   │ │...   │ │...   │ │...   │                         │
│  └──────┘ └──────┘ └──────┘ └──────┘                        │
└───────────────────────────────────────────────────────────────┘
-- 创建测试表演示索引结构
CREATE TABLE dbo.Orders (
    OrderID        INT          NOT NULL IDENTITY(1,1),
    CustomerID     INT          NOT NULL,
    OrderDate      DATE         NOT NULL,
    Status         TINYINT      NOT NULL DEFAULT 1,
    TotalAmount    DECIMAL(12,2) NOT NULL,
    ShippingAddr   NVARCHAR(200) NULL,
    CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderID)  -- 聚簇索引
);

-- 查看表的索引信息
SELECT
    i.name              AS IndexName,
    i.type_desc         AS IndexType,
    i.is_unique,
    i.is_primary_key,
    STRING_AGG(c.name, ', ') WITHIN GROUP (ORDER BY ic.key_ordinal) AS KeyColumns
FROM   sys.indexes i
JOIN   sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
JOIN   sys.columns c        ON ic.object_id = c.object_id AND ic.column_id = c.column_id
WHERE  i.object_id = OBJECT_ID('dbo.Orders')
  AND  ic.is_included_column = 0
GROUP BY i.name, i.type_desc, i.is_unique, i.is_primary_key;

-- 查看索引占用的页数和大小
SELECT
    i.name         AS IndexName,
    i.type_desc    AS IndexType,
    p.rows,
    SUM(a.total_pages)  * 8 AS TotalKB,
    SUM(a.used_pages)   * 8 AS UsedKB,
    SUM(a.data_pages)   * 8 AS DataKB
FROM   sys.indexes i
JOIN   sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
JOIN   sys.allocation_units a ON p.partition_id = a.container_id
WHERE  i.object_id = OBJECT_ID('dbo.Orders')
GROUP BY i.name, i.type_desc, p.rows;

4.1.2 非聚簇索引(Non-Clustered Index)

非聚簇索引是独立于数据的 B+Tree 结构,叶子节点存储索引键值 + 聚簇索引键(书签)。

-- 创建普通非聚簇索引
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID
ON dbo.Orders (CustomerID)
WITH (FILLFACTOR = 90,         -- 页填充率,90%=留10%空间给插入
      SORT_IN_TEMPDB = ON,     -- 在 TempDB 构建,减少对生产文件的IO
      ONLINE = ON,             -- 在线创建,不阻塞 DML(Enterprise版)
      DATA_COMPRESSION = ROW); -- 行压缩

-- 覆盖索引(Covering Index):包含查询所需的所有列,避免 Key Lookup
CREATE NONCLUSTERED INDEX IX_Orders_CustomerDate_Cover
ON dbo.Orders (CustomerID, OrderDate)      -- 键列(用于查找/排序)
INCLUDE (TotalAmount, Status);             -- 包含列(存在叶子节点,不影响排序)
-- 此索引可以覆盖:
-- SELECT CustomerID, OrderDate, TotalAmount, Status
-- FROM Orders WHERE CustomerID = ? AND OrderDate BETWEEN ? AND ?

-- 过滤索引(Filtered Index):只对满足条件的行建索引(更小、更高效)
CREATE NONCLUSTERED INDEX IX_Orders_Active
ON dbo.Orders (OrderDate, CustomerID)
WHERE Status = 1   -- 只对活跃订单建索引
WITH (FILLFACTOR = 95);

4.1.3 列存储索引(Columnstore Index)

列存储索引将数据按列存储,使用 VertiPaq 压缩,特别适合 OLAP/数仓场景,查询速度可提升 10~100 倍

-- 非聚簇列存储索引(可与行存储索引共存)
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders_Analytics
ON dbo.Orders (OrderDate, CustomerID, TotalAmount, Status);

-- 聚簇列存储索引(整张表以列格式存储,无行存储)
-- 适合纯分析型表(历史数据、事实表)
CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactSales
ON dbo.FactSales
WITH (DROP_EXISTING = OFF);

-- 查看列存储索引状态
SELECT
    i.name,
    rg.state_desc,
    rg.total_rows,
    rg.deleted_rows,
    rg.size_in_bytes / 1024.0 / 1024 AS SizeMB
FROM sys.indexes i
JOIN sys.column_store_row_groups rg
    ON i.object_id = rg.object_id AND i.index_id = rg.index_id
WHERE i.object_id = OBJECT_ID('dbo.FactSales');

4.2 执行计划解读

4.2.1 获取执行计划

-- 方式1:SSMS 快捷键(Ctrl+M 开启实际执行计划,Ctrl+L 估计执行计划)

-- 方式2:在查询中请求 XML 执行计划
SET STATISTICS IO ON;      -- 显示逻辑读/物理读
SET STATISTICS TIME ON;    -- 显示CPU和耗时

-- 方式3:捕获到变量
DECLARE @PlanXML XML;
SELECT @PlanXML = query_plan
FROM sys.dm_exec_query_plan(
    (SELECT plan_handle FROM sys.dm_exec_requests WHERE session_id = @@SPID)
);
SELECT @PlanXML;

4.2.2 常见执行计划算子

算子 含义 好/坏
Clustered Index Seek 按聚簇索引键精确查找 ✅ 最优
Index Seek 按非聚簇索引精确查找 ✅ 很好
Key Lookup 非聚簇索引找到行后回聚簇索引取其他列 ⚠️ 大量时需优化(加覆盖索引)
Clustered Index Scan 扫描整张表 ❌ 大表时性能差
RID Lookup 堆表(无聚簇索引)的行标识符查找 ⚠️ 建议建聚簇索引
Hash Match (Join) 哈希连接,适合大表 中等
Nested Loops (Join) 嵌套循环,适合小表驱动大表+索引 视情况
Merge Join 归并连接,要求两侧已排序 ✅ 合适场景最优
Sort 排序,消耗内存/TempDB ⚠️ 大量时考虑索引排序
Spill to TempDB 内存不足溢出到TempDB ❌ 性能杀手
-- 分析 Key Lookup 的优化案例
-- 假设有索引:IX_Orders_CustomerID ON Orders(CustomerID)
-- 执行下面的查询会产生 Key Lookup
SELECT CustomerID, OrderDate, TotalAmount
FROM Orders
WHERE CustomerID = 1001;
-- 执行计划:Index Seek → Key Lookup(取 OrderDate, TotalAmount)

-- 优化:将 OrderDate, TotalAmount 加入 INCLUDE
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID_Cov
ON dbo.Orders (CustomerID)
INCLUDE (OrderDate, TotalAmount)
WITH (DROP_EXISTING = ON);
-- 现在变为:Index Seek(无需 Key Lookup)

4.2.3 读取 STATISTICS IO 输出

SET STATISTICS IO ON;
SELECT * FROM Orders WHERE CustomerID = 1001;
-- 输出:
-- Table 'Orders'. Scan count 1, logical reads 5, physical reads 0,
-- read-ahead reads 0, lob logical reads 0, lob physical reads 0.

-- logical reads:逻辑读(从 Buffer Pool 读取的页数)
-- physical reads:物理读(从磁盘读取,不在缓存中)
-- 优化目标:最小化 logical reads(减少页扫描)

4.3 统计信息与基数估计

4.3.1 统计信息原理

SQL Server 查询优化器依赖统计信息(Statistics)来估算每个操作符返回的行数(基数),从而选择最佳执行计划。

-- 查看统计信息列表
SELECT
    st.name         AS StatName,
    c.name          AS ColumnName,
    st.auto_created,
    st.is_incremental,
    sp.last_updated,
    sp.rows,
    sp.rows_sampled,
    sp.modification_counter
FROM   sys.stats st
JOIN   sys.stats_columns sc ON st.object_id = sc.object_id AND st.stats_id = sc.stats_id
JOIN   sys.columns c        ON sc.object_id = c.object_id AND sc.column_id = c.column_id
CROSS APPLY sys.dm_db_stats_properties(st.object_id, st.stats_id) sp
WHERE  st.object_id = OBJECT_ID('dbo.Orders');

-- 查看某列统计信息的直方图
DBCC SHOW_STATISTICS('dbo.Orders', 'IX_Orders_CustomerDate_Cover');

-- 手动更新统计信息
UPDATE STATISTICS dbo.Orders WITH FULLSCAN;        -- 全表扫描,最精确
UPDATE STATISTICS dbo.Orders WITH SAMPLE 30 PERCENT; -- 30%采样

-- 数据库级别:启用自动更新统计信息
ALTER DATABASE [YourDB] SET AUTO_UPDATE_STATISTICS ON;
ALTER DATABASE [YourDB] SET AUTO_UPDATE_STATISTICS_ASYNC ON;  -- 异步更新(推荐)

4.3.2 参数嗅探(Parameter Sniffing)

参数嗅探是 SQL Server 的一个优化机制,但在某些场景下会导致严重的性能问题。

-- 问题场景演示
-- 存储过程第一次执行时用参数 CustomerID=1(大客户,10000行)
-- 优化器选择了 Table Scan 的计划并缓存
-- 之后 CustomerID=9999(小客户,2行)复用了同一个计划
-- 本应 Index Seek 的查询变成了 Table Scan

CREATE PROCEDURE dbo.usp_GetCustomerOrders
    @CustomerID INT
AS
    SELECT CustomerID, OrderDate, TotalAmount
    FROM Orders
    WHERE CustomerID = @CustomerID;

-- 查看当前缓存的计划
SELECT
    qs.execution_count,
    qs.total_logical_reads / qs.execution_count AS AvgLogicalReads,
    qs.total_elapsed_time / qs.execution_count / 1000 AS AvgMs,
    qp.query_plan,
    qt.text
FROM   sys.dm_exec_procedure_stats ps
CROSS APPLY sys.dm_exec_query_plan(ps.plan_handle) qp
CROSS APPLY sys.dm_exec_sql_text(ps.sql_handle) qt
WHERE  OBJECT_NAME(ps.object_id) = 'usp_GetCustomerOrders';

-- 解决方案1:OPTION (RECOMPILE) 每次重新编译(适合参数分布差异大的场景)
CREATE PROCEDURE dbo.usp_GetCustomerOrders_v2
    @CustomerID INT
AS
    SELECT CustomerID, OrderDate, TotalAmount
    FROM Orders
    WHERE CustomerID = @CustomerID
    OPTION (RECOMPILE);

-- 解决方案2:OPTIMIZE FOR UNKNOWN(使用平均统计估算)
CREATE PROCEDURE dbo.usp_GetCustomerOrders_v3
    @CustomerID INT
AS
    SELECT CustomerID, OrderDate, TotalAmount
    FROM Orders
    WHERE CustomerID = @CustomerID
    OPTION (OPTIMIZE FOR (@CustomerID UNKNOWN));

-- 解决方案3:拆分存储过程(按参数特征走不同代码路径)
CREATE PROCEDURE dbo.usp_GetCustomerOrders_v4
    @CustomerID INT
AS
    DECLARE @RowEst INT;
    SELECT @RowEst = rowcnt
    FROM sysindexes
    WHERE id = OBJECT_ID('Orders') AND indid IN (0,1);
    -- 大客户走特定逻辑
    ...

4.4 索引维护策略

4.4.1 索引碎片检测与整理

-- 检测索引碎片(针对指定表或全库)
SELECT
    DB_NAME()                                    AS DatabaseName,
    SCHEMA_NAME(t.schema_id) + '.' + t.name      AS TableName,
    i.name                                       AS IndexName,
    ips.index_type_desc,
    ips.avg_fragmentation_in_percent,
    ips.page_count,
    ips.record_count,
    ips.avg_page_space_used_in_percent
FROM   sys.dm_db_index_physical_stats(
    DB_ID(), NULL, NULL, NULL, 'LIMITED'     -- DETAILED更准确但更慢
) ips
JOIN sys.tables t  ON ips.object_id = t.object_id
JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE  ips.database_id = DB_ID()
  AND  ips.page_count > 1000          -- 过滤小索引(维护小索引意义不大)
ORDER BY ips.avg_fragmentation_in_percent DESC;

-- 索引维护策略:
-- 碎片 < 5%:不处理
-- 碎片 5-30%:REORGANIZE(在线,不会阻塞,速度慢)
-- 碎片 > 30%:REBUILD(可在线ONLINE=ON,重建B+Tree)

-- 自适应索引维护脚本
DECLARE @TableName    NVARCHAR(256);
DECLARE @IndexName    NVARCHAR(256);
DECLARE @Frag         FLOAT;
DECLARE @SQL          NVARCHAR(MAX);

DECLARE idx_cursor CURSOR FOR
    SELECT
        SCHEMA_NAME(t.schema_id) + '.' + t.name,
        i.name,
        ips.avg_fragmentation_in_percent
    FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
    JOIN sys.tables t  ON ips.object_id = t.object_id
    JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
    WHERE ips.page_count > 1000
      AND ips.avg_fragmentation_in_percent > 5
      AND i.type > 0;  -- 排除堆

OPEN idx_cursor;
FETCH NEXT FROM idx_cursor INTO @TableName, @IndexName, @Frag;

WHILE @@FETCH_STATUS = 0
BEGIN
    IF @Frag > 30
        SET @SQL = N'ALTER INDEX ' + QUOTENAME(@IndexName) +
                   N' ON ' + @TableName + N' REBUILD WITH (ONLINE=ON, DATA_COMPRESSION=ROW)';
    ELSE
        SET @SQL = N'ALTER INDEX ' + QUOTENAME(@IndexName) +
                   N' ON ' + @TableName + N' REORGANIZE';

    PRINT @SQL;
    EXEC sp_executesql @SQL;
    FETCH NEXT FROM idx_cursor INTO @TableName, @IndexName, @Frag;
END;

CLOSE idx_cursor;
DEALLOCATE idx_cursor;

4.4.2 缺失索引与多余索引分析

-- 系统推荐的缺失索引(慎用!仅供参考,不能无脑添加)
SELECT TOP 20
    ROUND(d.avg_total_user_cost *
          d.avg_user_impact *
          (d.user_seeks + d.user_scans), 0) AS Score,
    d.avg_user_impact AS EstimatedImpact_Pct,
    d.user_seeks,
    d.user_scans,
    DB_NAME(id.database_id)                  AS DatabaseName,
    id.statement                             AS TableName,
    id.equality_columns,
    id.inequality_columns,
    id.included_columns,
    -- 生成建议的 CREATE INDEX 语句
    N'CREATE NONCLUSTERED INDEX IX_' + OBJECT_NAME(id.object_id) +
    N'_' + CAST(d.avg_user_impact AS VARCHAR) +
    N' ON ' + id.statement +
    ISNULL(N' (' + id.equality_columns + N')', N'') +
    ISNULL(N', ' + id.inequality_columns, N'') +
    N' INCLUDE (' + ISNULL(id.included_columns, '') + N')' AS SuggestedSQL
FROM   sys.dm_db_missing_index_details id
JOIN   sys.dm_db_missing_index_groups ig ON id.index_handle = ig.index_handle
JOIN   sys.dm_db_missing_index_group_stats d ON ig.index_group_handle = d.group_handle
WHERE  id.database_id = DB_ID()
ORDER BY Score DESC;

-- 查找从未使用的索引(可考虑删除)
SELECT
    SCHEMA_NAME(t.schema_id) + '.' + t.name AS TableName,
    i.name AS IndexName,
    i.type_desc,
    ius.user_seeks,
    ius.user_scans,
    ius.user_lookups,
    ius.user_updates,
    ius.last_user_seek,
    ius.last_user_scan
FROM   sys.indexes i
JOIN   sys.tables t ON i.object_id = t.object_id
LEFT JOIN sys.dm_db_index_usage_stats ius
    ON i.object_id = ius.object_id
    AND i.index_id = ius.index_id
    AND ius.database_id = DB_ID()
WHERE  i.type > 0
  AND  i.is_primary_key = 0
  AND  i.is_unique = 0
  AND  (ius.user_seeks IS NULL OR ius.user_seeks = 0)
  AND  (ius.user_scans IS NULL OR ius.user_scans < 10)
  AND  ISNULL(ius.user_updates, 0) > 100   -- 一直在被更新但从不被查询
ORDER BY ISNULL(ius.user_updates, 0) DESC;

4.5 小结

概念 关键结论
聚簇索引 表数据即索引;每表只能有1个;首选自增整数主键
非聚簇索引 单独B+Tree;叶子节点含聚簇键,需回表
覆盖索引 INCLUDE 将所需列加入叶子,消灭 Key Lookup
过滤索引 WHERE 条件缩小索引范围,减少大小,提高效率
列存储索引 OLAP 首选;压缩率高;批量扫描快
Key Lookup 非聚簇索引 Seek 后回表,大量出现需加 INCLUDE 优化
参数嗅探 RECOMPILE / OPTIMIZE FOR UNKNOWN 解决计划错误复用
碎片整理 5-30% REORGANIZE;>30% REBUILD WITH ONLINE=ON

下一章:事务、锁与并发控制

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有
  • 上一篇:
  • 下一篇: