1. 一个跑了二十分钟的查询,让我重新审视索引选择
先说一个真实场景。前一阵子帮一个朋友排查生产库的慢查询,现象很简单:一个订单汇总报表,数据量一千多万行,以前跑三十秒左右,最近突然变成二十分钟,而且每天跑一次,用户天天催。我把执行计划抓出来,第一步看的就是SQL Server的索引选择。结果发现,两张大表做Hash Join时,优化器把只有几万行的小表设成了探测输入,却把大表整个扫描了一遍去生成哈希表。表面上看是连接顺序问题,根子却出在统计信息和索引可用性上——优化器做索引选择时,严重依赖统计信息里的分布数据来估算每列的选择性,而这张大表的统计信息已经三个月没更新,直方图和真实数据分布早就对不上了。
这类问题我处理过太多次,所以我一直认为,SQL Server的索引选择不是一个"建几个索引就完事"的简单话题,而是一整套从查询优化器决策机制、统计信息维护、索引设计取舍到执行计划分析的综合体系。只有吃透这个体系,你才能回答那些长期困扰开发人员的灵魂拷问:为什么我建了索引,查询还是慢?为什么同一个查询有时快有时慢?为什么优化器就是不选我想要的那个索引?
这篇文章不是我凭空编出来的理论,而是我这些年排查线上数据库慢查询时反复用到的一套方法。我会从优化器选择索引的底层逻辑讲起,然后给出一套可复用的诊断链路,再拆解几个最容易让索引选择失效的典型场景,最后用一个真实的生产优化案例把完整过程串起来。无论你是正在调接口性能的后端开发,还是刚接手SQL Server维护的DBA,这套思路都能直接拿去用。
2. 查询优化器到底是怎么"挑"索引的:成本估算、选择性、统计信息的三角关系
要理解索引选择,先要理解做"选择"的这个角色——查询优化器(Query Optimizer)。它的工作方式可以类比成你出门前用导航软件选路线:导航会收集当前路况数据,估算每条路的通行时间,然后挑一条它认为最快到达的路线。SQL Server的优化器也一样,它会基于当前的"路况信息"——也就是统计信息(Statistics)——来估算执行计划中每个步骤的代价,最后选它认为总代价最小的那一个计划。
2.1 统计信息:优化器的"眼睛"
统计信息在SQL Server里的角色极其重要。它记录了每个字段的分布情况,最核心的数据结构是直方图(Histogram)和密度(Density)。直方图把字段值分成若干个区间,记录每个区间有多少行;密度则反映不同值的数量。优化器拿到一个查询条件,比如WHERE Status = 'Active',它就去统计信息里查"Active"这个值在直方图里落在哪个区间、这一区间大概有多少行,从而估算出这条查询会返回多少行,也就是"选择性"(Selectivity)。
选择性是索引选择的关键指标。通常来说,能过滤掉90%以上数据量的条件,就适合走索引的Seek操作;只能过滤10%的,优化器可能会觉得干脆把整张表扫描一遍反而更划算。但这个判断完全依赖统计信息的数据准确性——如果统计信息过期,直方图显示某个值只有一行,实际却有一百万行,优化器就会选错索引。
2.2 成本模型的"代数学"
优化器并不会真的执行查询来决定快慢,它只是做"代数题"。它会给全表扫描(Table Scan / Clustered Index Scan)、索引查找(Index Seek)、Key Lookup(书签查找)、Hash Join、Nested Loop Join、Sort等操作分别估算IO代价和CPU代价,然后加总。这些估算值的背后是SQL Server内建的一套成本模型,包含了对随机I/O和顺序I/O的差异、内存中的算子开销等因素的建模。
一个经典的成本判断场景是:非聚簇索引上找到100行满足条件的记录,但查询要返回的列不全在索引中,每行都需要通过主键再到聚簇索引上做一次Key Lookup。如果满足条件的行数很少(比如十几行),优化器认为Key Lookup的随机I/O可以接受,所以会走"非聚簇索引Seek + Key Lookup";如果满足条件的行数非常大(比如几十万行),优化器会认为每次都要随机I/O太贵了,不如直接全表扫描聚簇索引,顺序读一遍反而快。这个"临界点"没有绝对的数字,取决于行宽、页大小、缓存命中率等因素,但方向是明确的——优化器永远在计算"哪种读法更便宜"。
2.3 为什么优化器会"选错"索引
很多人一遇到慢查询就骂"优化器是傻子",其实优化器绝大多数时候是被蒙蔽了。常见的蒙蔽手段有:
- 统计信息过期:数据量早就变了,但统计信息还是老样子;
- 参数嗅探(Parameter Sniffing):第一次用某个参数值编译出来的计划被缓存,后面遇到不同分布的值仍然复用旧计划;
- 索引缺失导致没有"更好的选择":优化器只能在现有索引中做选择,你没有给它更多备选方案,它只能在矮子里拔将军;
- 升序键问题(Ascending Key Problem):新插入的数据一直超过直方图记录的最大值,而统计信息还没更新,优化器会产生"0行"估算,选出的计划自然离谱。
提示:判断一个计划"选错"了,不是看它有没有走某个索引,而是要看它在当前统计信息和成本模型下的估算行数是否严重偏离实际行数。有时候逻辑读很高,但优化器估算的也是这个数,那不一定是优化器的问题,可能是统计信息的问题。
3. 索引选择的完整诊断链路:从慢查询到最优索引
这部分我直接给一套可复用的排查链路。我自己处理线上慢查询时,基本就按这几步来,很少空跑。
3.1 第一步:抓执行计划,看实际选择的索引
抓执行计划是排查的第一步,也是最基础的一步。如果是临时排查一个查询,最简单的方法是在SSMS里选中SQL语句,按下Ctrl+M开启"包含实际执行计划",然后执行,在执行完的结果面板里就能看到图形化执行计划。更严谨的做法是用SET STATISTICS IO ON和SET STATISTICS TIME ON两个开关,把每条语句的逻辑读次数、物理读次数、CPU时间和耗时打出来。
SET STATISTICS IO ON; SET STATISTICS TIME ON; -- 你的查询 SELECT o.OrderId, c.Name, o.TotalAmount FROM dbo.Orders o INNER JOIN dbo.Customers c ON o.CustomerId = c.Id WHERE o.OrderDate >= '2025-01-01' AND o.Status = 'Active';看执行计划时,重点关注三件事:第一,每条表访问操作是Index Seek、Index Scan还是Table Scan;第二,Seek后面的谓词(Predicate)是落在了索引键上,还是落在了普通列上;第三,有没有Key Lookup或RID Lookup,以及它们的行数有多大。执行计划里的每一个算子旁边会显示估计行数,你可以对比实际行数,如果差距非常大,那基本可以断定统计信息或者参数嗅探出了问题。
3.2 第二步:用动态管理视图看索引的使用率
光看单条查询还不够,要系统排查一个库里哪些索引在空转、哪些查询缺索引,可以借助两个核心的DMV。
第一个是sys.dm_db_missing_index_details和它的几个关联视图。SQL Server在每次查询编译时会记录它"想要"但没找到的索引信息。虽然这些信息在服务器重启后会清空,但在一台稳定运行了较久的库上仍然很有参考价值。
SELECT TOP 20 d.statement, d.equality_columns, d.inequality_columns, d.included_columns, s.unique_compiles, s.user_seeks, s.user_scans, s.avg_user_impact FROM sys.dm_db_missing_index_details d CROSS APPLY sys.dm_db_missing_index_group_stats s ON d.index_group_handle = s.group_handle WHERE d.database_id = DB_ID() ORDER BY s.avg_user_impact DESC;第二个是sys.dm_db_index_usage_stats,它能告诉你库里每个索引被使用了多少次,以及被更新了多少次。这个视图特别适合用来发现"僵尸索引"——建了一堆索引,但user_seeks和user_scans长期是0,反而是user_updates很高,说明每次写操作都要付出维护索引的代价,这些索引应该在确认没有特殊用途后考虑删除。
SELECT OBJECT_NAME(s.object_id) AS TableName, i.name AS IndexName, s.user_seeks, s.user_scans, s.user_lookups, s.user_updates FROM sys.dm_db_index_usage_stats s INNER JOIN sys.indexes i ON s.object_id = i.object_id AND s.index_id = i.index_id WHERE s.database_id = DB_ID() AND OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1 ORDER BY s.user_updates DESC;3.3 第三步:分析等待类型和查询特征
索引选择问题有时候不会直接体现在某一条SQL的执行计划上,而是表现在系统的整体等待上。我通常习惯在排查完单条SQL后,再看一眼sys.dm_os_wait_stats或者用现成的脚本看历史等待。
PAGEIOLATCH_SH(或PAGEIOLATCH_UP)等待占比高,通常说明磁盘I/O是瓶颈,而索引选择不当导致的大量扫描会让这个等待异常突增。PAGE_LATCH_*等待突增,可能是查询在做大量页分裂或页访问竞争,和索引键设计有关。CXPACKET等待高,通常出现在并行计划中,可能也和优化器选择了一个需要大量并行扫描的执行计划有关。
当你发现某类等待持续偏高,就可以顺藤摸瓜找到相关SQL,然后回到第一步去抓执行计划。等待统计本身不直接告诉你索引应该怎么建,但它会告诉你哪里该深入排查。
3.4 第四步:综合设计新的索引方案
诊断完问题,最后一步才是设计索引。这里我的原则是:先尽量复用现有索引,不要急着新建。很多索引问题其实可以通过改写SQL来解决,比如把WHERE YEAR(OrderDate) = 2025改成WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01',让条件成为一个可以Seek的范围,而不是无法使用索引的函数表达式。确认SQL无法改写或改写收益有限后,才去考虑新增索引。新增索引时要综合考虑等值条件、范围条件、排序字段和SELECT中需要回表的列,用合理的顺序填充索引键和INCLUDE列。
4. 索引类型与设计取舍:聚簇、非聚簇、覆盖、过滤到底怎么选
这一节我把SQL Server里几种常用索引的选择思路捋一遍。很多人背概念没问题,但一到实际设计就犯难,核心原因是没有把"查询访问模式"和"索引结构"对应起来。
4.1 聚簇索引:表的"物理排序",不要随便建
聚簇索引决定表的物理存储顺序,一个表只能有一个。它的叶子节点就是数据本身,所以聚簇索引的Seek实际上就是直接定位到数据行,不需要额外的回表操作。选择聚簇索引键时,最理想的几个特征:唯一、递增、宽度小、不会被频繁更新。自增主键就是最经典的选择。反之,用GUID做主键是一个常见的反面案例——由于GUID是随机的,插入时会造成大量的页拆分和碎片,数据量大了以后性能会肉眼可见地下降。
如果业务表本身没有合适的主键,可以考虑用一个自增的BIGINT列做代理键。但这并不绝对,有些表使用"业务唯一键"作为聚簇索引其实更合理,但要评估好插入模式和查询模式。在OLTP环境里,我的经验是不太建议把聚簇索引建在有很多重复值的列上,比如状态列、类型列,因为重复值多会导致插入时频繁存在叶子节点的位置竞争和页分裂。
4.2 非聚簇索引:索引键顺序决定"能不能Seek"
非聚簇索引的叶子节点不包含完整数据行,只包含索引键和聚簇索引键(或RID)。设计一个非聚簇索引,索引键列的顺序非常关键。一个通用原则是:
- 等值条件的列放在前面;
- 范围条件的列次之;
- 影响排序或分组的列再往后放;
- 最后考虑把SELECT中需要的其他列放进INCLUDE。
为什么等值列要在前面?因为等值列可以使用索引的前缀(Leftmost Prefix)规则,让优化器能Seek到精确的起点。如果你把范围条件放在最前面,后面的等值列就无法参与Seek定位,只能做过滤器(Predicate),索引利用效率大打折扣。
4.3 覆盖索引:避免Key Lookup的"银弹"
当非聚簇索引包含了查询所需要的全部列时,就不需要回表了,这个索引就是覆盖索引(Covering Index)。覆盖索引是OLTP里消除Key Lookup最直接的手段,但要注意它不是万能的:INCLUDE列越多,索引占用的空间越大,写维护成本越高,所以只把高频查询需要的列放进去。
我用一个实际例子来说明。有一张订单表,频繁执行这样一个查询:按客户ID查最近订单的金额和状态。
SELECT OrderId, OrderDate, TotalAmount, Status FROM dbo.Orders WHERE CustomerId = 12345 ORDER BY OrderDate DESC;如果只有非聚簇索引(CustomerId),那么找到CustomerId=12345的所有行后,每次都要通过聚簇索引键回表取OrderDate、TotalAmount、Status,然后排序。遇到一个客户有几万条订单,回表次数就非常可观了。但如果我们把索引设计成:
CREATE INDEX IX_Orders_CustomerId_Covering ON dbo.Orders (CustomerId, OrderDate DESC) INCLUDE (TotalAmount, Status);那么OrderDate已经在索引里,而且索引用DESC排序可以直接反向扫描,TotalAmount和Status在INCLUDE里,整个查询完全不用回表,ORDER BY也省去了额外的Sort算子。
4.4 过滤索引:给"小部分行"一个专用索引
过滤索引(Filtered Index)是SQL Server 2008之后引入的功能,它允许你只给表中满足某一条件的行建立索引。比如订单表里只有不到1%的订单状态是"待支付",而系统高频查询就是扫这张"待支付清单",这时候完全可以建一个过滤索引:
CREATE INDEX IX_Orders_Pending_Customer ON dbo.Orders (CustomerId, CreatedDate) WHERE Status = 'Pending';过滤索引的好处是体积小、维护成本低、查询命中精准。但要注意,过滤条件必须写对,使用它的查询也必须包含完全一致的条件,否则优化器无法匹配,索引就不会被用到。这算是过滤索引在"索引选择"上的一个特殊约束。
4.5 列存储索引:分析型查询的"另一条路"
如果你的SQL是典型的大范围聚合查询,比如对整月数据做SUM、COUNT、GROUP BY,传统的行存储索引再优化也有上限,这时候可以考虑列存储索引(Columnstore Index)。列存储索引把数据按列压缩存储,配合批处理执行模式,在扫描上亿行做聚合时的性能提升是数量级的。
不过列存储索引不太适合频繁更新、单行定位的OLTP查询。现在SQL Server也提供了可更新的聚集列存储索引,但它更适合"批量写入、读多写少"的混合场景。在设计列存储索引前,你要先判断查询模式:是"大量行、少列"的聚合,还是"少量行、多列"的点查。
下面用一个表格把这几种索引的选择要点放在一起,方便平时翻看:
| 索引类型 | 适用场景 | 优势 | 注意事项 |
|---|---|---|---|
| 聚簇索引 | OLTP表主键访问、范围扫描 | 数据即索引,无需回表 | 键值应唯一、递增、窄 |
| 非聚簇索引 | 各种查询条件的辅助索引 | 灵活组合覆盖不同查询 | 键列顺序影响Seek能力 |
| 覆盖索引 | 高频固定查询,避免回表 | 消除Key Lookup | INCLUDE列不宜过多 |
| 过滤索引 | 只查表中少数状态行的场景 | 体积小、维护成本低 | 查询条件必须与过滤条件匹配 |
| 列存储索引 | 大数据量聚合分析 | 高压缩比,扫描聚合性能强 | 不适合单行点查和高频更新 |
5. 索引选择翻车现场:参数嗅探、隐式转换、函数包裹
这一节全是实战中踩过的坑。前几年我排查过的慢查询里,相当大一部分不是索引设计本身的问题,而是SQL写法或执行计划缓存造成的索引选择异常。
5.1 参数嗅探:慢的问题被"缓存"了
参数嗅探指的是SQL Server在第一次编译一个参数化SQL时,用了当时传入的参数值去估算和选择执行计划,然后把计划缓存起来。后续执行时,即使参数值变了,只要参数化文本一样,就会复用同一个缓存计划。如果第一次传入的参数值是选择性很高的值(比如查一个只返回几行的客户),优化器选择了"非聚簇索引Seek + Key Lookup"计划;第二次换成一个返回几十万行的客户,计划还是那个计划,于是几十万次Key Lookup直接把库拖垮。
这是索引选择最容易让人"冤枉优化器"的场景。排查方法:用下面的语句找到缓存中该SQL的编译时间、执行次数和平均耗时,再查看具体计划:
SELECT qs.plan_handle, qs.execution_count, qs.total_elapsed_time / qs.execution_count AS avg_elapsed_ms, 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 statement_text FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st ORDER BY qs.total_elapsed_time DESC;常见解决方案包括:
- 在不频繁执行且对计划准确性要求高的查询上加
OPTION (RECOMPILE),让每次执行都重新编译、重新选择索引; - 用
OPTION (OPTIMIZE FOR UNKNOWN)让优化器使用平均选择性而不是某一个具体参数值; - 把动态SQL改为存储过程,并确保存储过程内没有会抑制参数化的写法;
- 如果问题出在统计信息过期,先更新统计信息,再清掉相关缓存计划。
5.2 隐式转换:让索引"看得见摸不着"
隐式转换是让索引选择失效的一个高频原因。当SQL中比较的两边数据类型不一致时,SQL Server会按优先级把一侧转为另一侧,而这个转换如果发生在索引列上,优化器就没法使用它对索引做Seek。
最典型的例子:表里的CustomerId是INT,但应用传入的参数被实体框架或ORM映射成了VARCHAR:
SELECT * FROM dbo.Orders WHERE CustomerId = '12345';上面这个查询里,CustomerId列会被隐式转换为VARCHAR再比较,索引键上做了CONVERT,于是Seek变成了Scan(更准确说是Index Scan,因为无法对转换后的表达式做精确匹配)。排查方式是在执行计划里找CONVERT_IMPLICIT提示,它通常出现在谓词附近。修复方式很简单:把参数类型改成和列一致,或者在SQL里显式转换参数一侧而不是列一侧。
5.3 函数包裹列:写了等值条件也走不了索引
和隐式转换类似,如果一个查询在索引列上套了函数,比如:
SELECT * FROM dbo.Orders WHERE DATEPART(YEAR, OrderDate) = 2025;或者
SELECT * FROM dbo.Orders WHERE CONVERT(DATE, OrderDate) = '2025-01-01';优化器无法对函数的输出建立B树索引定位,所以即便OrderDate上有一个性能很好的索引,也只能遍历所有索引行再做函数计算,索引选择完全失灵。正确的做法是把函数从列上拆掉,改成范围条件:
SELECT * FROM dbo.Orders WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01';这种改写在语义上和原来等价,但能让优化器正确利用OrderDate上的索引,对日期时间列尤其有效。
5.4 统计信息过期与碎片:沉默的"计划毒药"
最后必须提一下统计信息过期。SQL Server在数据变化量达到一定阈值后会自动更新统计信息,但阈值计算在数据量大的表上会变得保守,有时候长期不触发自动更新也是正常的。判断统计信息是否过期,可以用DBCC SHOW_STATISTICS来查看直方图,重点看RANGE_HI_KEY、RANGE_ROWS和EQ_ROWS,和实际数据的分布比对一下有没有明显偏差。
如果统计信息确实过期,更新统计信息通常能立刻改善优化器的索引选择:
UPDATE STATISTICS dbo.Orders WITH FULLSCAN;但要注意,频繁全表扫描去更新统计信息在大表上本身代价很高,所以在生产环境要利用维护窗口执行,或者根据数据量选择WITH SAMPLE采样比例。而索引碎片的问题通常影响的是扫描效率和页密度,不代表索引"选错"了,但碎片严重时本来该选Seek的查询因为扫描路径太慢,也可能在成本计算中改变优化器对某一方案的评估权重,所以定期做索引重建或重组仍然是必要的。
我把这几类翻车场景整理成一个速查表,现场排查时可以对照看:
| 翻车类型 | 典型特征 | 排查手段 | 推荐解决方式 |
|---|---|---|---|
| 参数嗅探 | 相同SQL不同参数时快时慢 | 查看缓存计划与首次编译参数 | OPTION (RECOMPILE) / OPTIMIZE FOR UNKNOWN |
| 隐式转换 | 计划中出现CONVERT_IMPLICIT | 核对参数与列的数据类型 | 统一数据类型,显式转换参数侧 |
| 函数包裹 | 等值条件走全表扫描 | 检查谓词中列上是否有函数 | 改写为范围条件 |
| 统计信息过期 | 估算行数严重偏离实际行数 | DBCC SHOW_STATISTICS对比直方图 | UPDATE STATISTICS 或调整自动更新阈值 |
6. 一个生产环境索引选择优化的完整复盘
前面把原理和坑讲完了,最后用一个我实际经历过的生产案例把整个过程串起来。表结构和部分字段做了简化,但思路是真实的。
6.1 案例背景与慢查询症状
这是一个B2C商城项目的订单查询接口。服务端有个接口支持按客户、状态、时间范围、金额范围组合查询订单列表,表Orders数据量大约2800万行,OrderItems约8000万行。问题是从某个大促之后,接口经常超时,数据库服务器的PAGEIOLATCH_SH等待持续走高,监控平台显示部分查询单次耗时超过20秒。
6.2 诊断过程:从执行计划到索引使用统计
我按前面说的诊断链路走了一遍。先抓了一条典型的慢SQL执行计划:
SELECT o.OrderId, o.OrderDate, o.TotalAmount, c.CustomerName FROM dbo.Orders o INNER JOIN dbo.Customers c ON o.CustomerId = c.CustomerId WHERE o.CustomerId = @CustomerId AND o.OrderDate >= @StartDate AND o.OrderDate < @EndDate ORDER BY o.OrderDate DESC;计划显示:Orders表走了非聚簇索引IX_Orders_CustomerId上的Seek,但每行都伴随一次Key Lookup回表,OrderDate的过滤是在Index Scan层面完成的,整个查询的逻辑读高达40万次。原因是约95%的订单状态是"已完成",而该索引上没有OrderDate作为键,SQL Server从客户ID的索引入口拿到该客户大量订单后,还要逐行回表取OrderDate和TotalAmount,最终在内存里做过滤和排序。
再看sys.dm_db_index_usage_stats,表上总共有8个索引,其中两个索引的user_seeks为0,只有一个旧索引偶尔被用到,属于典型的僵尸索引。而sys.dm_db_missing_index_details里明确列出了优化器一直想要的INCLUDE(orderdate, totalamount)列,说明这个库已经很久没有人按照执行计划的"建议"去优化索引了。
6.3 优化方案:重新设计两个关键索引
我没有直接按缺失索引建议一股脑全建,而是把所有高频查询的参数组合整理了一遍,最终只动了两处。
第一处,重建覆盖索引。把原来的单列索引改造成多列覆盖索引:
DROP INDEX IX_Orders_CustomerId ON dbo.Orders; CREATE INDEX IX_Orders_CustomerId_OrderDate ON dbo.Orders (CustomerId, OrderDate DESC) INCLUDE (TotalAmount, Status, PaymentMethod, ShippingAddressId);这个索引让按照客户+时间范围查询的SQL可以在索引里完成全部定位、排序和字段读取,完全避免了Key Lookup,ORDER BY OrderDate DESC也能直接利用索引的有序性,不再额外生成Sort算子。
第二处,针对组合筛选场景设计过滤索引。由于"已完成"订单占绝对大头,而业务查询里频繁使用的是"待支付"和"已发货"这类状态,通过过滤索引能显著缩小扫描范围:
CREATE INDEX IX_Orders_Status_PendingTime ON dbo.Orders (Status, CreatedDate DESC) INCLUDE (CustomerId, TotalAmount) WHERE Status IN ('Pending', 'Shipped');被删除的是那两个长时间没有被访问的僵尸索引,其中有一个是在低区分度列上建的复合索引,删除后写操作的维护开销降了不少。
6.4 优化结果:从20秒到1.5秒
在同样的数据量下,重新收集统计信息后测试,接口的主要SQL从20秒左右降到1.5秒左右,逻辑读从40万次降到不到2000次。整个库的PAGEIOLATCH_SH等待占比在观察一周后明显下降,CPU压力也随之降低。后续大促期间,接口没有再出现过超时告警。
这次复盘可以浓缩成几条教训:第一,索引选择不是优化器单方面的事,它只会在你给它的索引集合里选,所以要保证每个关键查询都有"足够好的备选索引";第二,新增索引之前先用DMV盘点现有索引的使用情况,删除无效索引、改造低效索引优于不断扩大索引数量;第三,任何索引优化做完之后都要更新统计信息并重新测试执行计划,否则旧的计划缓存会让你误以为优化没有效果。
7. 索引选择优化的个人经验清单
最后,分享几条我在实际维护SQL Server数据库时沉淀下来的经验。说"经验"而不是"最佳实践",是因为每条都曾经踩过坑,属于拿时间换来的教训。
第一,不要在凌晨拍脑袋建索引。索引选择优化是一个"死于增量"的过程——每多一个索引,写路径就多一分负担。没有慢查询证据支撑的索引,本质上都是猜测。所以我的流程永远是"先抓慢查询,再分析执行计划,然后设计或改造索引,最后压测验证",顺序不能反。
第二,参数化查询和存储过程的参数类型一定要和列类型对齐。很多看起来是索引选择失败的问题,根因只是一个小小的隐式转换。在写ORM映射或定义存储过程参数时,仔细核对表的字段类型,能省掉后面大量排查时间。
第三,建立一个周期性的索引维护机制。统计信息更新和索引碎片整理不能全靠手动。对于OLTP库,我一般建议把索引碎片超过30%的做重建、5%到30%的做重组,碎片低于5%的可以不动,统计信息则根据数据变化量设置合理的更新计划,大表可以用足够的采样比例来均衡代价与准确性。
第四,学会正确使用查询提示,但不要滥用。OPTION (RECOMPILE)、OPTION (OPTIMIZE FOR UNKNOWN)、WITH (FORCESEEK)这些提示在特定场景下很有效,但它是"人肉干预优化器"的方式,只适合用在确实需要固定计划的少数高频查询上。如果一个系统到处都挂着FORCESEEK,说明你的索引设计大概率出了问题,而不是优化器太蠢。
有段时间我特别热衷于给每张表堆索引,觉得索引越多越快。后来在一次大促前的容量评估中,我发现多个冗余索引不仅没有帮到查询,反而让每次INSERT和UPDATE的开销涨了不少,磁盘空间也白白浪费。从那以后我给自己定了一条规矩:任何新索引都要回答清楚"它服务的具体查询是什么",答不上来就不建。索引选择优化这件事,方法再多,最终还是要回到"降低真实查询成本"这个本质上来。