11_SQLServer 索引与 SQL 优化 ------ 聚集索引、非聚集索引、覆盖索引、碎片维护与执行计划
系列定位 :《SQL Server 数据库全栈运维实战系列》第 11 篇,与《MySQL 数据库全栈运维实战系列》同构对标(对标 MySQL 11 对应篇目)。适用版本 :SQL Server 2019/2022(2017 及以下差异随文标注)。官方文档:Microsoft Learn SQL Server 2019/2022 文档、索引体系(Indexes)、重新组织与重新生成索引(Reorganize and Rebuild)章节。
一、索引原理全景(官方:Clustered Index Design)
SQL Server 索引和 InnoDB 一样是 B 树结构 ,但它明确区分两种组织方式:聚集索引 决定数据行物理怎么存,非聚集索引 是独立的 B 树挂在数据上。没有聚集索引的表叫堆(Heap),数据行无序存放。

图 1
| 概念/对象 | 说明 | 类比 MySQL |
|---|---|---|
| 聚集索引 Clustered | 叶子节点就是数据行,一张表只能一个 | InnoDB 主键聚簇索引 |
| 非聚集索引 Nonclustered | 独立 B 树,叶子存键值 | InnoDB 二级索引 |
| 堆 Heap | 无聚集索引的表 | 无聚簇表的堆组织 |
| 键查找 Key Lookup | 非聚集索引命中后回表取列 | InnoDB 回表 |
| 覆盖索引 | 索引含查询所需全部列 | 覆盖索引 |
| INCLUDE 列 | 索引叶子附加的非键列 | InnoDB 二级索引自动含主键 |
| 碎片 | 页填充不均、逻辑乱序 | InnoDB 碎片 / OPTIMIZE |
一张表只有一个聚集索引:因为数据行只能按一种顺序物理排列。建表时不指定主键,SQL Server 默认把它建成聚集索引的键(和 InnoDB 一样,主键即聚簇)。
二、聚集索引 vs 非聚集索引 vs 回表
-- 解释:建聚集索引(通常就是主键)
CREATE CLUSTERED INDEX CX_order_id ON appsch.t_order(order_id);
GO
-- 解释:建非聚集索引
CREATE NONCLUSTERED INDEX IX_order_cust ON appsch.t_order(customer_id);
GO

图 2
| 索引类型 | 叶子节点存什么 | 回表代价 |
|---|---|---|
| 聚集索引 | 完整数据行 | 无需回表 |
| 非聚集索引(键查找) | 聚集键值 | 需回表取其余列,贵 |
| 覆盖非聚集索引 | 查询所需全部列 | 无需回表,最快 |
回表是性能大敌 :非聚集索引命中后如果还缺列,优化器要做 Key Lookup,行数一大就从 Seek 退化成接近全表扫。解法是用
INCLUDE把需要的列塞进索引叶子。
三、最左前缀与复合索引(官方:Index Design)
-- 解释:复合索引(customer_id, create_time)------最左前缀原则
CREATE NONCLUSTERED INDEX IX_order_cust_time
ON appsch.t_order(customer_id, create_time);
GO
-- 解释:能命中此索引的 WHERE:
-- WHERE customer_id = 88
-- WHERE customer_id = 88 AND create_time >= '2026-10-01'
-- 不能命中:WHERE create_time >= '2026-10-01'(跳过了最左列 customer_id)
| 查询条件 | 复合索引 (a,b,c) 是否命中 |
|---|---|
WHERE a = 1 |
命中 |
WHERE a = 1 AND b = 2 |
命中 |
WHERE a = 1 AND b = 2 AND c = 3 |
命中 |
WHERE b = 2 |
不命中(跳过 a) |
WHERE a = 1 AND c = 3 |
只用到 a |
索引键长度红线:非聚集索引键总宽 ≤ 1700 字节(旧版 900),聚集索引键 ≤ 16 键列、总宽 128 键值。超了建索引报错,宽列用 INCLUDE 而不是放进键。
四、覆盖索引与 INCLUDE 列
-- 解释:把高频查询要返回的列 INCLUDE 进索引,彻底消除键查找
CREATE NONCLUSTERED INDEX IX_order_cust_cover
ON appsch.t_order(customer_id)
INCLUDE (create_time, pay_amount, order_status);
-- 解释:现在 SELECT customer_id, create_time, pay_amount, order_status
-- WHERE customer_id=88 走这个索引直接出结果,不用回表
GO
| 写法 | 说明 | 类比 MySQL |
|---|---|---|
| 列放键里 ON (a,b) | 参与排序与定位,最左前缀生效 | 复合索引列 |
| 列放 INCLUDE | 只存叶子供返回,不参与定位 | InnoDB 二级索引叶子自带主键 |
设计口诀:高频过滤列放键(享受 Seek 与排序),高频返回列放 INCLUDE(消除回表)。这是覆盖索引的标准做法。
五、执行计划与 SARGable 查询
-- 解释:SARGable(可走索引)写法:条件列上不要包函数
-- 好:范围直接比较,能 Seek
WHERE order_status = 1 AND create_time >= '2026-10-01'
-- 坏:列上套函数,索引失效退化为 Scan
WHERE YEAR(create_time) = 2026
WHERE SUBSTRING(order_no,1,4) = 'SO20'
WHERE pay_amount + 100 > 500
GO
| 反模式 | 为什么坏 | 改写 |
|---|---|---|
WHERE YEAR(create_time)=2026 |
列包函数,索引失效 | create_time >= '2026-01-01' AND < '2027-01-01' |
WHERE col LIKE '%abc' |
前导通配,无法定位 | 全文索引或调整前缀 |
WHERE col = @p 参数嗅探 |
偶发计划劣化 | 加查询提示或查询存储纠正 |
| 隐式类型转换 | 列类型与参数不匹配,失效 | 显式转类型对齐 |
六、索引维护与碎片清理(官方:Reorganize and Rebuild)
-- 解释:查某表索引碎片率(10%~30% 重组,30% 以上重建)
SELECT OBJECT_NAME(ips.object_id) AS table_name,
i.name AS index_name,
ips.avg_fragmentation_in_percent,
ips.page_count
FROM sys.dm_db_index_physical_stats(DB_ID('AppDB'), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 10
ORDER BY avg_fragmentation_in_percent DESC;
GO
-- 解释:碎片 10%~30%:重组(在线,轻量,整理页逻辑顺序)
ALTER INDEX IX_order_cust ON appsch.t_order REORGANIZE;
GO
-- 解释:碎片 30% 以上:重建(重建整个 B 树,2019 企业版 ONLINE)
ALTER INDEX IX_order_cust ON appsch.t_order REBUILD WITH (ONLINE = ON);
GO
-- 解释:更新统计信息(统计信息过期会导致优化器选错计划)
UPDATE STATISTICS appsch.t_order IX_order_cust WITH FULLSCAN;
GO

图 3
| 操作 | 碎片区间 | 是否在线 | 类比 MySQL |
|---|---|---|---|
| REORGANIZE | 10%~30% | 总是在线 | 轻量整理 |
| REBUILD | >30% | 企业版 ONLINE,标准版离线 | OPTIMIZE TABLE |
| UPDATE STATISTICS | 统计过期 | 在线 | ANALYZE TABLE |
七、缺失索引与索引使用 DMV
-- 解释:缺失索引建议(优化器认为缺的索引,按收益排序)
SELECT TOP 10
ROUND(s.avg_total_user_cost * s.avg_user_impact * (s.user_seeks + s.user_scans),0) AS 收益,
mid.equality_columns, mid.inequality_columns, mid.included_columns
FROM sys.dm_db_missing_index_group_stats s
JOIN sys.dm_db_missing_index_groups g ON s.group_handle = g.index_group_handle
JOIN sys.dm_db_missing_index_details mid ON g.index_handle = mid.index_handle
ORDER BY 收益 DESC;
GO
-- 解释:现有索引使用情况(找出从没被用、占空间的索引)
SELECT OBJECT_NAME(i.object_id) AS table_name, i.name AS index_name,
s.user_seeks, s.user_scans, s.user_updates
FROM sys.indexes i
JOIN sys.dm_db_index_usage_stats s ON i.object_id = s.object_id AND i.index_id = s.index_id
WHERE s.user_seeks = 0 AND s.user_scans = 0
ORDER BY s.user_updates DESC;
-- 解释:user_updates 很高却零 seek 的索引,是纯写负担,考虑删掉
GO
| DMV | 作用 |
|---|---|
| sys.dm_db_missing_index_details | 优化器建议加的索引 |
| sys.dm_db_index_usage_stats | 索引实际被用了多少次 |
| sys.dm_db_index_physical_stats | 索引碎片与页数 |
别照单全收缺失索引 :缺失索引 DMV 是建议不是圣旨,要权衡写放大(
user_updates)。一条高频写的表,加太多索引反而拖慢写入。
八、列存储索引(2019 数据仓库利器)
-- 解释:列存储索引------分析型大表聚合查询提速几十倍(2019 行存批处理)
CREATE CLUSTERED COLUMNSTORE INDEX CCI_order ON appsch.t_order_big;
-- 解释:OLTP 小表别用,列存适合数仓大表 GROUP BY 聚合
GO
| 索引类型 | 适用场景 | 类比 MySQL |
|---|---|---|
| 行存储 B 树 | OLTP 点查、小范围 | InnoDB B+Tree |
| 列存储 | 数仓大表聚合扫描 | MySQL 无原生列存 |
| 过滤索引 | 只索引某子集(如未处理单) | MySQL 部分索引 |
九、踩坑记录(实测汇总)
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | 索引列上套函数 | 查询全表扫 | 改写为范围比较,让条件 SARGable |
| 2 | 只建在最右列的复合索引 | 跳过最左列不命中 | 高频过滤列放最左前缀 |
| 3 | 高频返回列没 INCLUDE | 键查找回表慢 | 高频返回列加进 INCLUDE |
| 4 | 索引键太宽超 1700 字节 | 建索引报错 | 宽列移到 INCLUDE |
| 5 | 从不维护碎片 | 大表越用越慢 | 碎片 10%~30% 重组,30%+ 重建 |
| 6 | 统计信息过期 | 优化器选错计划 | UPDATE STATISTICS FULLSCAN |
| 7 | 盲信缺失索引 DMV | 写放大拖慢更新 | 权衡 user_updates,删零使用索引 |
| 8 | 一张表建了二十个索引 | 写入巨慢、占空间 | 合并冗余索引,删零 seek 索引 |
| 9 | 隐式类型转换 | 索引失效 | 参数与列类型显式对齐 |
| 10 | 堆表主键没建聚集索引 | 非聚集索引都存宽 RID 更慢 | 给高频主键建聚集索引 |
| 11 | 参数嗅探计划劣化 | 偶发慢查询 | 靠 Query Store 自动计划校正 |
十、要点速记
- 一张表一个聚集索引,主键默认即聚簇;没建就是堆。
- 消除回表靠 INCLUDE:过滤列进键,返回列进叶子。
- 最左前缀:复合索引从最左列开始才生效。
- 碎片分级维护:10%~30% REORGANIZE,30%+ REBUILD。
- SARGable:条件列不包函数、不前置通配,索引才走 Seek。
十一、参考资料(官方正式文档)
本文章属于《SQL Server 数据库全栈运维实战系列》性能调优篇。欢迎收藏、交流。