ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL Server索引底层原理与性能优化实战指南

SQL Server索引底层原理与性能优化实战指南 写索引的人很多但大部分都在讲语法很少讲清楚 SQL Server 底层是怎么组织的。我之前接手过一个老系统数据库 200 多 G几个核心表才几十万行查询却慢到秒级超时。加索引、删索引折腾了很久最后才明白问题的根源不在索引数量而在索引结构和访问路径的设计。今天这篇就把 SQL Server 索引从物理结构、设计权衡到排查实战完整讲一遍不光是告诉你 create index 怎么写更要把背后的取舍讲透。1. 索引工作的底层逻辑1.1 先从页和区说起SQL Server 里数据存储的最小单位是页Page一页 8KB8 个连续页组成一个区Extent。表或索引的数据最终都会落到这些页上。理解这一点再谈索引思路就清楚得多——索引不是凭空加速而是帮你减少读取的页数。没有索引的时候SQL Server 只能做全表扫描。哪怕只要一行数据也得把整个表的所有页都过一遍。几十万行的表看着不大但堆表Heap扫描时一次要读的页数可能上千数据量一起来自然就慢。索引的作用就是构建一棵查找树让你能直接定位到目标页而不是逐页翻。页上还装着行的元数据比如行偏移、行长度这类信息。索引查找的本质是在树的每一层通过比较键值来收缩搜索范围最终落到叶子页上获取你需要的定位信息。树的层数决定了查找次数通常三层已经能支撑数百万行数据的快速定位。1.2 聚集索引与非聚集索引的本质差异很多人把主键和聚集索引画等号其实它们是两回事。聚集索引决定了表数据的物理存储顺序表的叶子页就是数据本身。索引键的顺序直接决定了数据在磁盘上的排列所以一个表只能有一个聚集索引。注意这里的“物理顺序”不是保证磁盘上绝对按插入顺序排列而是通过双向链表把叶子页串起来逻辑上形成一个有序链。做范围查询时只需要顺着链表顺序扫描相邻页大部分时候顺序读比随机读快得多。非聚集索引则是独立的结构它的叶子页存的不再是整行数据而是索引键值加一个定位器。这个定位器两种形态表上有聚集索引时它就是聚集索引键值表是堆时它就是行标识符 RID。查非聚集索引拿到定位器之后还要再回表取完整数据这一步叫书签查找在老版本里也叫 RID 查找。有个点值得单独说非聚集索引的叶子页里其实也包含聚集索引键。如果查询涉及的列都被索引覆盖住就不需要回表。这也就是覆盖索引能大幅提速的根本原因。但代价是索引体积变大、写入开销增加后面实操部分会再展开。1.3 索引查找为什么快索引查找Index Seek的执行路径是从根页出发逐层比较键值定位到叶子页。比如查where id 10086聚集索引一次就能定位到对应页只需要读取 3 层左右。全表扫描读完所有数据页可能要成百上千次 I/O差距自然拉出来了。但索引查找不是万能的。查询条件里如果用了函数、隐式转换、前导通配符这类写法优化器可能放弃索引查找退化成索引扫描Index Scan甚至全表扫描。这个在后面第 5 章单开一节仔细讲。2. 主键索引、唯一索引和普通索引怎么选2.1 主键为什么默认建聚集索引SQL Server 里定义主键时默认会生成一个唯一的聚集索引。主键约束同时承担两方面职责唯一性约束和物理存储排序。但你可以通过create primary key nonclustered显式指定主键用非聚集索引把聚集索引留给更合适的列。实际业务里我建议把聚集索引建在查询最常用的等值或范围列上比如订单表的创建时间。主键用自增列当然也行因为插入递增不会引发页分裂。但如果主键是随机生成的 GUID插入时会在索引中间位置不断引发页分裂写入性能会明显下滑。聚集索引键最好不要过长。所有非聚集索引的叶子页都会带上聚集索引键键越长非聚集索引体积就越大。如果你已经建了十几个非聚集索引聚集索引键每多一个字节整体存储压力和写入开销都会同步放大。常用实践是聚集索引键用 int 或 bigint而不是复合多列。2.2 唯一索引和主键索引的区别唯一索引保证索引键值的唯一性但它不一定是主键。主键索引在逻辑上也是唯一索引两者都要求值不重复。区别在于主键强调实体完整性同一张表只允许一个主键且逻辑上不允许为空而一张表可以有多个唯一索引并且允许有一个 NULL 值SQL Server 的默认实现是唯一索引下可以有单个 NULL。选择上的经验是业务上需要唯一约束但又不是主键的列比如用户表的手机号、身份证号就用唯一索引。它既能防重复数据又顺便给了优化器更多访问路径。再强调一个隐藏细节如果唯一索引没有指定聚集选项默认创建的是非聚集唯一索引。很多人以为create unique index会顺便变成聚集索引并不会默认全是非聚集。想建聚集唯一索引必须显式写成create unique clustered index。2.3 非聚集索引的叶子层内幕非聚集索引内部的行除了索引键值还携带行定位器。表是堆时定位器是 8 字节的 RID文件号、页号、槽号组合表有聚集索引时定位器是聚集索引键值。这带来一个重要推论查询非聚集索引后回表并不是先找聚集索引树再定位到数据页而是拿聚集索引键值去聚集索引里做一次查找。更值得注意的是非聚集索引叶子页内部的数据行会冗余一份聚集索引键。如果聚集索引键本身是个大字段比如 varchar(200)那么每个非聚集索引条目都要附带这份大键值索引膨胀得非常快。用大字段做主键的坑我见过不止一次性能问题排查到最后基本都在这里。2.4 索引回表与覆盖策略回表是索引性能的分水岭。执行计划里看到 Key Lookup书签查找时就要警惕了。每行都做一次随机 I/O 回表取回的数据行一多性能急剧下降。覆盖索引的套路是把 select 需要的列加进索引的非键列也就是 INCLUDE。注意 include 列不影响索引键的排序只存在叶子页上。所以对于select a, b from t where c ?这类查询建create index ix_t_c on t(c) include (a, b)就能让查询全程在索引内完成查询计划里连 Key Lookup 都不再出现。不要贪心把所有列都塞进 INCLUDE。索引也有体积塞太多列会让写入变慢、页缓存占用增加。成熟的习惯是只覆盖查询最频繁、返回字段最少的路径。3. 索引设计的关键权衡3.1 索引不是越多越好索引数量少查询容易走全表扫描索引数量多每次 insert/update/delete 时所有索引都要同步维护写入事务变慢。对一些高频写入的表索引多出来的开销往往是性能瓶颈。给一个经验参考OLTP 类型的单表非聚集索引数量一般控制在 5 个以内。OLAP 报表库可以适当多建一些毕竟写入频次低、查询复杂索引收益更明显。遇到既有高写入又有复杂查询的混合场景优先保证写入稳定再通过覆盖索引、过滤索引控制风险。3.2 复合索引的列顺序复合索引的列顺序直接决定索引能被多少查询复用。最基本原则等值条件放前面范围条件放后面。原因是 B 树只能在一个维度上高效定位如果第一列用了范围查询第二列的有序性就无法继续用于定位了。举例create index ix_order_status_time on orders(status, create_time)。查询where status 1 order by create_time可以直接用索引完成排序如果调换列顺序排序就变成 SORT 操作。SQL Server 虽然能走索引查找但 order by 部分的代价完全不一样。还有一个容易被忽略的坑如果查询里只用了第二列作为过滤条件而复合索引第一列没有约束优化器很难利用这个索引的键列做高效查找只能扫描。因此判断索引是否有效不能只看索引“覆盖”了某列而是要看键列的使用顺序是否匹配。3.3 唯一性差的列值慎加索引性别、状态这类只有两三个不同值的列选择性极差。加了索引优化器算出来走索引查找的代价可能根本不划算还是会选择全表扫描。此时索引不仅没有加速效果反而白白占用空间、拖慢写入。判断选择性时可以直接跑一条统计 SQL项目自用示例无外部依赖SELECT COUNT(DISTINCT status) AS unique_count, COUNT(*) AS total_count FROM orders;唯一值数除以总数结果越接近 1说明选择性越好越适合建索引。低于 0.1 的列基本不用考虑单列索引除非是配合其他高频列的复合索引前缀。不过唯一性差但查询场景特殊的列也有例外状态列如果几乎全是某个值少数异常值是查询热点此时用过滤索引Filtered Index不失为好方案。前 95% 的行都不参与索引索引体积小且维护成本低后续实操部分会给出具体语法。3.4 页分裂和碎片率索引键插入位置不连续时页空间不足就会触发页分裂产生碎片。频繁页分裂不仅让写入变慢还会让索引页的物理顺序和逻辑顺序错位范围扫描性能跟着下降。检测碎片率的常用脚本自用示例无外部依赖SELECT OBJECT_NAME(ps.object_id) AS table_name, i.name AS index_name, ps.avg_fragmentation_in_percent FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, LIMITED) ps JOIN sys.indexes i ON ps.object_id i.object_id AND ps.index_id i.index_id WHERE ps.avg_fragmentation_in_percent 30;碎片率在 5% 到 30% 之间一般用ALTER INDEX ... REORGANIZE重组超过 30%建议用REBUILD重建。重建会阻塞查询执行前要确认维护窗口。索引页填充因子FILLFACTOR在插入频繁的表上可以调低一些比如 80让每页预留空间减少页分裂副作用是索引体积变大读多写少的表不要改。4. 实操用 T-SQL 建出合理的索引4.1 基础创建语法标准创建语法实际执行时替换为你的表名、列名即可CREATE NONCLUSTERED INDEX IX_Orders_CreateTime ON dbo.Orders(CreateTime DESC) INCLUDE (OrderNo, Status) WHERE Status 1; -- 过滤索引示例按需使用这条语句建了一个在 CreateTime 上降序排列的非聚集索引同时把 OrderNo 和 Status 放进叶子页作为包含列。后面这三段分别是键列、INCLUDE 列、过滤条件。三个部分都可以按实际需求取舍。加上 WHERE 子句后这个索引只覆盖 Status 1 的行适合少数状态值作为热点查询的场景。如果你要建的是唯一索引写法为CREATE UNIQUE INDEX UX_Users_Phone ON dbo.Users(Phone) WHERE Phone IS NOT NULL;带过滤条件的唯一索引只对满足条件的行强制唯一很适合“只允许一个有效手机号但允许空值”这类业务。4.2 通过执行计划判断索引是否走了 Seek建完索引后验证方式不是看查询快没快而是要看执行计划里是不是 Index Seek。在 SSMS 里按 CtrlL 显示预估计划重点看三样操作类型是 Seek 还是 Scan、有没有 Key Lookup、SORT 操作是否消失。判断回表的指标执行计划里出现 Key Lookup 且行数较大时要么考虑改成覆盖索引要么查询只返回少量行时可以接受。但行数一旦上千回表代价就很明显。此时把 select 的列塞进 INCLUDE 里通常立竿见影。对于“SQL Server 字符串转数字”这类高频问题在查询条件里写WHERE CAST(phone AS BIGINT) 13800000000会导致索引失效因为列上套了函数。正确的做法是直接WHERE phone 13800000000让数据类型隐式转换发生在常量侧而不是列侧这样才能命中索引。4.3 索引失效场景逐条梳理索引失效的根源可以总结为优化器无法用有序的键值去快速定位。下面按常见场景列出来左侧通配符LIKE %abc无法用索引前缀定位等于被迫扫描所有叶子页LIKE abc%则可用。列上使用函数或表达式WHERE YEAR(create_time) 2024会让优化器走全表扫描改为WHERE create_time 2024-01-01 AND create_time 2025-01-01。隐式类型转换列是字符串查询条件却用数字SQL Server 会先转换列值再比较索引失效。调整参数类型或写清常量类型即可。OR条件跨列/跨索引WHERE a 1 OR b 2时很难用单个索引同时满足两边需要扫描或合并。必要时改用 UNION ALL 拆开。不等值条件与范围查询右侧失效WHERE col1 1 AND col2 5 AND col3 3复合索引col1, col2, col3中 col3 基本无法用于定位。默认排序方向冲突查询要求ORDER BY a DESC但索引是 ASC优化器可能额外做 SORT。建立索引时按业务排序方向设置 ASC/DESC。需要提醒的是“索引失效”不代表一定全表扫描有时也会退化为 Index Scan但性能同样不理想。排查时不要只看有没有用索引还要看是 Seek 还是 Scan后者说明定位能力没有被充分利用。5. 常见问题排查与维护实录5.1 排查慢查询的基本套路拿到一条慢 SQL我一般按下面顺序排查持续在用的习惯分享出来先看执行计划里最重的操作——是 Scan 还是 Seek有没有 Key Lookup。看表上已有索引确认没有哪个索引本来就能覆盖只是优化器没选。手动更新统计信息UPDATE STATISTICS dbo.Orders;再跑一次排除统计过期导致的错误估计。观察返回行数和逻辑读SET STATISTICS IO ON 看 Logical Reads对比走不同索引的差异。截取 SQL 文本确认没有函数包裹列、没有隐式转换、没有前导通配符。这个顺序每次都能帮我快速定位问题多数时候前两步就足够了。5.2 统计信息与索引的关联统计信息是优化器用来估算行数的依据。表数据变动大但统计没更新优化器按陈旧的信息选择索引很可能选错。默认的自动更新在多数量变化时会触发的阈值但并不是实时大表更新比例小时可能迟迟不更新。所以定期维护任务里统计信息更新和索引碎片维护要一起做。一般频率可按业务修改频率定每天一次或每周一次。维护语句示例按需替换UPDATE STATISTICS dbo.Orders;5.3 重建索引的两种方式重建用REBUILD重组用REORGANIZE。碎片率超过 30% 时用 REBUILD它会产生新的索引页、回收碎片碎片率低于 30% 时用 REORGANIZE代价低、不锁死表适合在线维护窗口。对大索引做 REBUILD 时建议加ONLINE ON需要对应版本支持企业版或开发版减少对业务的影响。如果版本不支持在线重建就得选业务低峰期操作。大批量数据导入后直接重建索引往往比分批插入过程中反复页分裂稳定得多。5.4 索引命名的规范化索引命名看似小事实际排查時非常影响效率。我自己惯用的规则主键PK_表名唯一索引UX_表名_列名普通索引IX_表名_列名覆盖索引IX_表名_列名_INC_包含列一张表几十个索引时只看名字就能大概猜到它的组成写脚本查缺补漏就不用逐个开属性页看了。5.5 缺失索引提示能不能直接信SQL Server 的 DMV 会提供缺失索引建议我把它当线索而不是最终结论。缺失索引组建议的语句往往只针对某一条查询忽略了这条索引对写入和其他查询的负面影响。直接把建议全部建上很容易造成索引泛滥。正确用法把 dm_db_missing_index_details 和 dm_db_missing_index_group_stats 结合看 user_seeks、user_scans、avg_user_impact 等指标再人工判断这些列的组合是否合理最后统一设计成少量复合索引而不是一个建议建一个索引。6. 索引优化的实践经验总结6.1 一套值得做日常巡检的 DMV 查询日常巡检不可能天天看每条 SQL。我习惯用下面这个 DMV 查询找出 Top 消耗的逻辑读语句可直接用于你的环境分析按库实际情况调整SELECT TOP 20 SUBSTRING(st.text, (qs.statement_start_offset/2) 1, ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text) ELSE qs.statement_end_offset END - qs.statement_start_offset)/2) 1) AS query_text, qs.execution_count, qs.total_logical_reads, qs.total_elapsed_time FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st ORDER BY qs.total_logical_reads DESC;拿到这些语句后逐个看执行计划重点关注反复出现的高成本操作。索引维护不是做完一次就完了业务查询模式一变原来的设计就可能失效固定节奏做巡检比出事再复盘省事得多。6.2 别过度设计前端索引很多性能问题是“为了快而快”造成的。比如 Status 明明只有三个值还是建了单列索引比如覆盖索引里塞了 30 个列。这些设计在测试环境看不出问题线上数据量大了以后索引维护开销和存储成本就会被放大。我给新人的建议是先让查询逻辑正确再用执行计划找真正的热点路径最后针对热点路径建最少量的索引。盲目仿照别人的索引脚本套在自己的表上是最容易踩的坑。6.3 用实际的例子复盘一次优化之前处理过一个订单表三百万行数据常用查询是按用户查最近 20 条订单。原表只有一个主键聚集索引查询每次都要回表慢在 Key Lookup 上。当时的处理先建立复合索引UserID, CreateTime DESC并 INCLUDEOrderNo, Amount让排序和回表字段全部在索引里解决然后看执行计划确认 Key Lookup 消失。效果是从 800 多毫秒降到 20 毫秒以内。改动很小但效果明显关键就在于覆盖了高频查询路径、用对了键列顺序。6.4 个人维护索引的习惯清单最后整理一份我常用的索引维护清单算是个人的例行检查项每周检查碎片率超过 30% 的在维护窗口重建低于 30% 的重组。每月检查统计信息更新时间超过一周未更新的手动更新。每次上线新功能回头看一眼新增查询的执行计划找有没有 Scan 或 Key Lookup。从不直接照搬缺失索引建议只作分析线索。建索引前必查已有索引能用 INCLUDE 解决的绝不多建新索引。索引这个东西单看语法一天就能学会真正的功力在于知道什么时候不该建、键列怎么排、碎片怎么维护。希望这篇从物理结构到实操排查的完整梳理能帮你少走一些弯路。如果你手上正好有慢查询建议先不要急着加索引按文章里的执行计划排查顺序走一遍定位到真正的问题再动手。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进