数据库跑了一段时间之后,明明表结构没变、数据量也没爆炸,查询速度却肉眼可见地慢了下来,这种情况十有八九和索引碎片有关。SQL Server这类关系型数据库中,索引的底层是数据页,增删改操作会让数据页不断分裂、腾挪,时间一长,逻辑顺序和物理顺序就对不上了,扫描索引要读更多的页,I/O开销自然水涨船高。这篇文章就来系统地聊聊碎片是怎么产生的、怎么判断索引健康状况,以及重新组织和重新生成这两种主流的整理手段该怎么选、怎么用。

索引碎片是怎么产生的,又该怎么判断严重程度
要理解碎片,得先知道索引的存储结构。以SQL Server为例,索引由一颗B树构成,叶子层的数据按索引键的逻辑顺序排列,而数据页在磁盘上的物理位置并不保证连续。当一个数据页写满了又有新数据插入时,数据库引擎会执行页分裂,把原页大约一半的数据挪到一个新页上。新页往往分配在离原页很远的物理位置,这就产生了外部碎片。页分裂之后两个页各只装了一半数据,这就是内部碎片。
判断碎片程度靠的是系统视图sys.dm_db_index_physical_stats,它返回的avg_fragmentation_in_percent字段就是碎片率。看一个典型的查询:
SELECT
ips.object_id,
OBJECT_NAME(ips.object_id) AS 表名,
i.name AS 索引名,
ips.index_id,
ips.avg_fragmentation_in_percent AS 碎片率,
ips.page_count AS 数据页数
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') AS ips
JOIN sys.indexes AS i
ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 10
AND ips.page_count > 1000
ORDER BY ips.avg_fragmentation_in_percent DESC;
这个查询里有两个细节值得注意。一是最后一个参数LIMITED,它只扫描叶子层页,速度快但信息少;SAMPLED抽样统计,DETAILED全量扫描信息最全但最耗时,生产库上要谨慎使用。二是加了page_count > 1000的过滤条件,因为小表只有几个数据页,碎片率再高也没多少实际影响,不值得为它耗费维护成本。
重新组织与重新生成:两种整理方式的原理与取舍
整理碎片有两条路:ALTER INDEX ... REORGANIZE(重新组织)和ALTER INDEX ... REBUILD(重新生成)。重新组织是在线操作,逐页整理叶子层的物理顺序,把逻辑上连续的页搬回一起,同时按填充因子压缩页内空间。它的好处是随时可以中断、不长时间锁表、事务日志增量小,代价是整理效果有限,对付重度碎片力不从心。
重新生成则干脆利落:直接新建一份索引,切换过去后删掉旧的。它能把碎片彻底清零,还能顺便更新统计信息、修改填充因子,一步到位。但重建过程要么锁表(企业版支持ONLINE选项缓解),要么需要足够的空间放下新旧两份索引,日志量也大。基本语法如下:
-- 重新组织:在线、轻量 ALTER INDEX IX_Orders_CustomerID ON dbo.Orders REORGANIZE; -- 重新生成:彻底重建,企业版可加 ONLINE = ON ALTER INDEX IX_Orders_CustomerID ON dbo.Orders REBUILD WITH (FILLFACTOR = 85, ONLINE = ON); -- 对整张表的所有索引一次性重建 ALTER INDEX ALL ON dbo.Orders REBUILD;
微软官方给出的经验阈值是:碎片率在5%到30%之间用重新组织,超过30%用重新生成,低于5%基本不用管。这个阈值可以当起点但不能死守,如果一个索引有上千万行,重建一次要几个小时,那可能把阈值放宽到40%才更划算;反过来,频繁写入的热点表可以整理得更勤一些。
搭建自动化的索引维护方案
手工一条条执行维护语句显然不现实,正经的做法是写一段动态SQL,遍历所有超标索引自动选择整理方式。核心思路就是读取碎片视图,按阈值分支处理:
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql = @sql +
CASE
WHEN avg_fragmentation_in_percent > 30
THEN 'ALTER INDEX ' + QUOTENAME(i.name)
+ ' ON ' + QUOTENAME(OBJECT_SCHEMA_NAME(ips.object_id))
+ '.' + QUOTENAME(OBJECT_NAME(ips.object_id))
+ ' REBUILD; '
WHEN avg_fragmentation_in_percent > 10
THEN 'ALTER INDEX ' + QUOTENAME(i.name)
+ ' ON ' + QUOTENAME(OBJECT_SCHEMA_NAME(ips.object_id))
+ '.' + QUOTENAME(OBJECT_NAME(ips.object_id))
+ ' REORGANIZE; '
END
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') AS ips
JOIN sys.indexes AS 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 > 10
AND i.name IS NOT NULL; -- 跳过堆表
EXEC sp_executesql @sql;
脚本里用QUOTENAME包住对象名是为了兼容带空格或特殊字符的命名,i.name IS NOT NULL把没有聚集索引的堆表排除掉,堆表的处理方式是ALTER TABLE ... REBUILD,逻辑略有不同。把这段逻辑放进SQL代理作业,安排在业务低峰期(比如凌晨两三点)每周跑一次,就能让索引长期保持健康。如果不想自己维护脚本,社区方案Ola Hallengren的维护脚本也是成熟的选择,功能更全,还兼顾了统计信息更新和完整性检查。
维护索引时容易踩的几个坑
第一个坑是过度维护。有的团队把重建作业设成每天执行,结果碎片确实没了,但每次重建都会重置填充因子、刷掉统计信息的采样依赖,还产生海量日志,反而拖累系统。碎片整理本身是有成本的,判断标准永远是它带来的收益是否大于代价,而不是把碎片率压到零。
第二个坑是忽视填充因子。重建时如果不指定FILLFACTOR,默认填满整个页,下次插入马上又触发页分裂。对于写入频繁的表,设置80%到90%的填充因子,给页留出增长空间,能明显延缓碎片产生速度。填充因子只在重建(以及重新组织压缩时)生效,所以每次维护都要显式带上这个选项。
第三个坑是忘了统计信息这一层。碎片影响的是物理I/O效率,而统计信息影响的是执行计划的准确性,两者是不同维度的问题。重新生成会顺带更新统计信息,但重新组织不会,所以只做REORGANIZE的库还要另外安排统计信息更新,否则可能出现碎片清理干净了、执行计划还是走偏的情况。把碎片管理和统计信息维护放进同一个作业体系统一规划,才算真正把索引这摊子事理顺了。