|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
深夜收到报表库的告警,一张 8000 万行的订单明细表,同样的区间查询昨天 400ms,今天变成 4.2s。执行计划没变,等待类型是 PAGE_LATCH 而不是锁等待,CPU 和 IO 都不忙——看平均值挺正常,就是慢。打开索引的物理统计一看,聚集索引的 avg_fragmentation_in_percent 已经 72%,avg_page_space_used_in_percent 只剩 58%。这就是典型的碎片病:数据物理上散成了一地,逻辑顺序和物理顺序对不上,页内还塞了一半空气。
一、具体的问题
碎片不是"空间不够",也不是"统计信息过期",而是 B 树叶子层的物理布局出了问题。长年累月的随机 INSERT、页拆分、DELETE 后留下的空洞,会让范围扫描(range scan)的预读失效、逻辑读翻倍。很多团队的应对方式是"月底停机全量 REBUILD 所有索引",结果问题反而更多:大表重建耗时长、日志暴涨、还把热表的缓存全冲掉了。
本文要回答三个问题:碎片到底伤不伤性能、伤到什么程度才值得动手;REORGANIZE 和 REBUILD 怎么选、什么版本能在线做;重建之后怎么让碎片长得慢一点。
二、核心原理
先纠正三个常见误区。
第一,碎片只影响顺序读,不影响单点查找。 等值查找走到那一页就读那几页,碎不碎片几乎无感;但区间扫描、排序、连接要顺着叶子链大量顺序读,物理上散落就会造成逻辑读暴涨和预读失效。所以"碎片高但都是点查"的场景,重建收益接近零,别白折腾。
第二,REORGANIZE 和 REBUILD 是两种东西,不是大小号。 REORGANIZE 是在线的叶子层整理(按页重排、压缩 LOB、顺带以低采样率更新统计),全程只持有短时锁,任何版本都能跑;REBUILD 是整棵树重建,默认(非企业版)会持有 Sch-M 锁把表锁死,企业版加 ONLINE = ON 才能在线。REBUILD 会全量更新统计信息,REORGANIZE 不会全量。低碎片用 REORGANIZE,高碎片才上 REBUILD。
第三,页密度往往比碎片率更值得看。 avg_page_space_used_in_percent 低,说明一页里一半是空气,同样 100 页的数据要读 170 页——这时即使碎片率只有 30%,代价也已经不小。反过来,碎片率高但页密度高(批量顺序插入场景),扫描也不慢。
修复的收益链条是:整理物理布局 → 页密度回升 → 逻辑读下降 → 预读恢复 → 查询变快。而让碎片长得慢,靠的是填充因子(FILLFACTOR)和插入模式的治理,不是靠更频繁的重建。
三、实例参考(动手步骤)
步骤 0:确认现状基线
先量化,再动手。用 sys.dm_db_index_physical_stats 拿物理画像:
- SELECT
- OBJECT_SCHEMA_NAME(ps.object_id) AS [schema],
- OBJECT_NAME(ps.object_id) AS [table],
- i.name AS [index],
- i.type_desc,
- ps.index_level,
- ps.avg_fragmentation_in_percent,
- ps.avg_page_space_used_in_percent,
- ps.fragment_count,
- ps.avg_fragment_size_in_pages,
- ps.page_count
- FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'SAMPLED') ps
- JOIN sys.indexes i
- ON i.object_id = ps.object_id AND i.index_id = ps.index_id
- WHERE ps.index_level = 0 -- 只看叶子层
- AND ps.page_count >= 1000 -- 小于 1000 页的 DMV 采样不可靠
- ORDER BY ps.avg_fragmentation_in_percent DESC;
复制代码
注意两个门槛:DETAILED 精确但很重,大表用 SAMPLED 就够;DMV 对小于 1000 页的索引统计不准确,小表碎片无所谓。
步骤 1:定位碎片大户与使用情况
碎片高但不被使用的索引,治理方式是删掉而不是重建。把物理统计和 sys.dm_db_index_usage_stats 关联,剔掉"零扫描"的:
- SELECT
- OBJECT_SCHEMA_NAME(i.object_id) + '.' + OBJECT_NAME(i.object_id) AS obj,
- i.name AS idx,
- s.user_seeks, s.user_scans, s.user_lookups,
- s.user_updates
- FROM sys.indexes i
- LEFT JOIN sys.dm_db_index_usage_stats s
- ON s.object_id = i.object_id AND s.index_id = i.index_id
- AND s.database_id = DB_ID()
- WHERE i.index_id > 1 AND OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
- AND s.user_seeks + s.user_scans + s.user_lookups < 10
- AND s.user_updates > 10000
- ORDER BY s.user_updates DESC;
复制代码
user_updates 远大于 user_seeks + user_scans 的索引,每次 UPDATE/DELETE 都在为它付拆页的代价,却几乎没人读它——这类是碎片和写入放大的双重制造者。
步骤 2:分诊——按碎片率选动作
微软官方的分诊线(再做微调):
- -- 碎片 10%~30%,页密度低于 75%:在线整理,REORGANIZE
- ALTER INDEX idx_order_detail_created ON dbo.order_detail
- REORGANIZE WITH (LOB_COMPACTION = ON);
- -- 碎片 > 30% 且表不大:重建(先看步骤 3 的在线方案)
- ALTER INDEX idx_order_detail_created ON dbo.order_detail
- REBUILD WITH (FILLFACTOR = 90, SORT_IN_TEMPDB = ON);
复制代码
不要一刀切全量重建。以我们那张 8000 万行的表为例,最后只动了碎片最高的 3 个索引,总耗时 41 分钟,而不是全库 6 小时。
步骤 3:在线重建与可恢复重建
企业版 2017+ 用可恢复在线重建,半夜跑不完可以挂着,不占窗口:
- ALTER INDEX idx_order_detail_created ON dbo.order_detail
- REBUILD WITH (
- ONLINE = ON,
- RESUMABLE = ON,
- MAX_DURATION = 30 MINUTES, -- 单次运行 30 分钟自动挂起
- MAXDOP = 4,
- SORT_IN_TEMPDB = ON
- );
- -- 查看挂起中的重建
- SELECT * FROM sys.index_resumable_operations;
- -- 第二天续跑
- ALTER INDEX idx_order_detail_created ON dbo.order_detail
- RESUME WITH (MAXDOP = 4);
复制代码
非企业版在线不了,REBUILD 前必须确认窗口和依赖:ONLINE = OFF 时是 Sch-M,会话一进来就排队(和 MDL 是一个道理)。兜底策略是大表先 REORGANIZE 撑一段时间,等迁移到企业版再做在线重建。
步骤 4:填充因子与页密度治理
重建一次性,FILLFACTOR 决定碎片长得快不快。给随机 UPDATE 多的表留 10%~20% 空隙:
- -- 查看当前填充因子
- SELECT i.name, i.fill_factor
- FROM sys.indexes i
- WHERE i.object_id = OBJECT_ID('dbo.order_detail') AND i.index_id > 0;
- -- 只对随机更新热点索引设 90,不要全库乱设
- ALTER INDEX idx_order_detail_created ON dbo.order_detail
- REBUILD WITH (FILLFACTOR = 90);
- -- 重建后验证页密度回升
- SELECT avg_page_space_used_in_percent, avg_fragmentation_in_percent
- FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.order_detail'),
- 2, 0, 'DETAILED');
复制代码
纯追加型(自增键、只插不改)的表 FILLFACTOR 保持 100,留空隙纯属浪费缓存。
步骤 5:维护窗口脚本
把分诊逻辑固化成一个每周任务,只动该动的索引:
- DECLARE @sql nvarchar(max) = N'';
- SELECT @sql = @sql +
- N'ALTER INDEX ' + QUOTENAME(i.name) + N' ON ' +
- QUOTENAME(OBJECT_SCHEMA_NAME(ps.object_id)) + N'.' +
- QUOTENAME(OBJECT_NAME(ps.object_id)) +
- CASE WHEN ps.avg_fragmentation_in_percent < 30
- THEN N' REORGANIZE;' ELSE N' REBUILD;' END + CHAR(13)
- FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ps
- JOIN sys.indexes i
- ON i.object_id = ps.object_id AND i.index_id = ps.index_id
- WHERE ps.index_level = 0 AND ps.page_count >= 1000
- AND i.is_disabled = 0 AND i.is_hypothetical = 0
- AND ps.avg_fragmentation_in_percent >= 10;
- EXEC sp_executesql @sql;
复制代码
生产上更建议直接用 Ola Hallengren 的 IndexOptimize,它自带分诊、在线选项、超时与日志,造轮子不如用社区打磨过的。
步骤 6:监控与告警落地
不要等用户报慢才查。建一张巡检表,每周快照对比:
- CREATE TABLE dbo.index_frag_snapshot (
- snap_date date,
- schema_name sysname,
- table_name sysname,
- index_name sysname,
- frag_pct float,
- page_density float,
- page_count bigint
- );
- -- 周一凌晨采样,环比涨幅 > 20 个百分点的索引发告警
- INSERT INTO dbo.index_frag_snapshot
- SELECT CONVERT(date, GETDATE()), OBJECT_SCHEMA_NAME(ps.object_id),
- OBJECT_NAME(ps.object_id), i.name,
- ps.avg_fragmentation_in_percent,
- ps.avg_page_space_used_in_percent, ps.page_count
- FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ps
- JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
- WHERE ps.index_level = 0 AND ps.page_count >= 1000;
复制代码
四、实操检查清单
- 先查 avg_fragmentation_in_percent 和 avg_page_space_used_in_percent 两个指标,且只统计 page_count >= 1000 的索引,小表不治理。
- 动手前关联 sys.dm_db_index_usage_stats,user_seeks + user_scans + user_lookups 长期为零的索引直接下线,别重建它。
- 点查为主的负载(碎片高但都是等值查找)不重建,确认负载里有区间扫描、排序、连接才值得动手。
- 碎片 10%~30% 用 REORGANIZE(在线、任何版本可用),超过 30% 才考虑 REBUILD。
- REBUILD 非企业版会锁表;企业版务必 ONLINE = ON,2017+ 再加 RESUMABLE = ON 和 MAX_DURATION。
- FILLFACTOR 只对随机 UPDATE 热点索引设 85~95,追加型索引保持 100,不要全库统一设一个值。
- 大批量 DELETE/UPDATE 后,及时补一次 REORGANIZE,别让空洞留到大促窗口。
- 每周对 LIMITED 模式快照入库,环比涨幅超过 20 个百分点的索引主动触发治理,不等业务报慢。
- REORGANIZE 带上 LOB_COMPACTION = ON,否则带大字段的索引只整理了行内页。
- 重建后用 DETAILED 模式复核页密度与碎片率,确认修复生效再收工。
几个容易踩的坑
一是把 REBUILD 当万能药。 碎片高但负载全是点查,重建后 P95 纹丝不动,白花了 6 小时窗口。先确认负载类型再动手,sys.dm_db_index_operational_stats 里的 range_scan_count 是判断依据。
二是非企业版在业务时段 REBUILD。 Sch-M 锁一挂,后面所有查询排队,等于一次"计划内事故"。在线重建不可用时,用 REORGANIZE 兜底或挪窗口。
三是 REORGANIZE 当成统计信息维护。 它确实会更新统计,但采样率很低,直方图可能严重失真。统计信息该用 UPDATE STATISTICS ... WITH FULLSCAN 单独维护,别指望顺带。
四是 FILLFACTOR 全库统一设 80。 缓存被空气占掉两成,内存命中率反而下降。它只该给随机更新热点用。
五是重建完不看页密度。 有一种情况是碎片率降了、密度还低——通常是填充因子设小了或又是批量删除造成的,得回头调参数,不是再重建一遍。
治理前后对比
| 指标 | 治理前 | 治理后 | | 聚集索引碎片率 | 72% | 1.2% | | 页密度 | 58% | 96.3% | | 区间查询 P95 | 4.2s | 410ms | | 逻辑读(单次区间查询) | 218,400 页 | 41,700 页 | | 维护窗口(全库) | 6h 停机全量重建 | 41min 仅动 3 个索引 | | 告警工单 | 3~5 起/周 | 0(连续 45 天) |
碎片治理的价值不在"重建了多狠",而在"动得有多准"。看负载、看密度、看使用统计,然后只修真正在流血的索引。 |
|