自动数据库索引压缩合并

自动数据库索引压缩合并

一直以来,我们都习惯在 SQL Server数据库实例中部署一项固定任务:夜间或每周执行一次索引维护作业。我们先检查索引碎片率,碎片率介于5%~30%时执行索引重组(REORGANIZE),高于30%则执行索引重建(REBUILD),并且寄希望于该作业能够在业务日开始前执行完毕。当然,索引管理也没有一套通用的标准方案。有人也会使用Ola Hallengren的索引与统计信息维护脚本https://ola.hallengren.com/sql-server-index-and-statistics-maintenance.html

微软在2026年年初推出了一项新功能,该功能目前还在公开预览阶段:自动数据库索引压缩合并(automatic index compaction) 。数据库引擎会在数据发生变更时,于后台自动完成索引压缩合并工作,无需人工维护作业、无需预留维护窗口、也不用维护脚本。

它能够解决哪些问题?

自动索引压缩,是微软针对索引维护作业这一常规运维任务给出的合适解法。

传统索引维护作业存在如下痛点:

  • CPU 和 I/O 开销巨大:索引重建需要读取并重写整个索引
  • 执行耗时长,而可用的维护窗口持续缩短
  • 会生成大量事务日志,对日志备份与 Always On 复制链路造成影响
  • 可能阻塞业务应用:离线重建会锁定整张表;即便是在线重建,在任务起始与结束阶段也需要短暂的排他锁
  • 需要专人部署、监控,作业失败时还要排查修复

还有一个不太容易被察觉的问题:多年来,这类维护作业一直在针对错误指标做优化

使用前提

该功能目前处于公开预览,只支持部署在以下数据库服务:

  • Azure SQL 数据库
  • Azure SQL 托管实例(需开启Always-up-to-date更新策略)
  • Microsoft Fabric 中的 SQL 数据库

本地部署版 SQL Server(包括 SQL Server 2025)暂不支持该功能。

无需额外安装组件。该功能依托数据库引擎已在运行的后台线程(ADR 清理线程)实现;并且 Azure SQL 数据库、Azure SQL 托管实例、Fabric SQL 数据库均默认开启加速数据库恢复(Accelerated Database Recovery,ADR)。

内部碎片与外部碎片

谈及索引碎片时,实际上包含两种完全不同的现象。二者可通过 sys.dm_db_index_physical_stats 里两个不同字段进行度量,成因各不相同,最重要的是二者带来的性能开销差异巨大。

数据文件的组织形式

想要理解碎片,先要搞清楚 SQL Server 如何看待数据文件。

对普通程序而言,文件就是操作系统提供给程序的字节数组:字节0、字节1、字节2,依此类推。程序读取文件就是请求一段连续范围:文件偏移量 + 读取长度。

SQL Server 将这一字节数组切分为8KB大小,8KB大小就是一个数据页 。第N号页,对应的字节范围就是 N × 8192 至 (N+1) × 8192 − 1。页ID不只是一个标识,它是文件内的地址。

文件底层的所有存储层(NTFS簇、SSD、SAN卷)都由存储栈做地址转换并对数据库屏蔽。

下文中提到的物理顺序 ,均指数据文件内部的数据页排列顺序,而非硬件磁盘上的实际存放位置。

碎片形态

1. 内部碎片:数据页没有填满

内部碎片指索引的数据页中存在空闲空间。可通过avg_page_space_used_in_percent(平均页空间使用率百分比)衡量,该指标也叫页密度。

内部碎片从何产生?

  1. delete语句删除操作,尤其是零散删除:数据行被移除,但数据页仍然保留。
  2. 页拆分:当一个数据页已满,在键值区间中间位置执行插入,或是更新操作导致行长度变大时,会把约一半的数据行迁移到新分配的数据页上。拆分后的两个页面的填充程度大约都只有 50%。
  3. 会缩短变长列长度的更新操作。
  4. 难以紧凑存放的大行长行:一行数据 5000 字节,意味着每页只能存一行,每页会固定有约 38% 的空间被浪费。

注意:存在一部分空闲空间一般属于正常现象,有的甚至是刻意预留的:填充因子(fill factor)预留的空间就是人为制造的内部碎片,目的是承接后续的插入操作,避免触发页拆分。

内部碎片是什么表现?

复制代码
CREATE DATABASE DemoFrag
GO

ALTER DATABASE DemoFrag SET RECOVERY SIMPLE
GO

USE DemoFrag
GO

CREATE TABLE dbo.SplitDemo (
    Id      int NOT NULL CONSTRAINT PK_SplitDemo PRIMARY KEY CLUSTERED,
    Payload varchar(8000) NOT NULL
);

-- 2 lines about 4000 bytes per page
INSERT INTO dbo.SplitDemo VALUES
(1, REPLICATE('A', 4000)), (2, REPLICATE('B', 4000));

页面几乎已满:

为什么它会产生开销?

因为同样的数据行分散到了多于必要数量的数据页上,SQL Server 的所有操作都以数据页为单位:缓冲池缓存的是数据页,而不是数据行。填充密度为 40% 的数据页,会浪费其所占内存的 60%。无论是逻辑读还是物理读,

1.读取相同数据都需要处理更多的数据页。

2.备份文件体积更大

3.数据库完整性校验耗时更久。

重点在于:内部碎片会随数据存在于各处,包括内存中。它会持续占用内存、I/O 和 CPU 资源,而外部碎片则并非如此。

2. 外部碎片:页偏移量不再遵循逻辑顺序

在B树的叶子层级,数据页按照索引键的顺序串联成链表,每个页面的页头中保存两个指针(m_nextPage、m_prevPage)。这条链表就是逻辑顺序,它本身不会出错:根据索引定义,该顺序就是键值的排序顺序。

**外部碎片(也叫逻辑碎片)**指的是:顺着这条链表遍历页,不再等同于按顺序读取磁盘文件。数据页在逻辑上有序,但它们在文件内的偏移位置是无序的。它分为两种形态:

  • 空洞:索引的数据页不处于连续偏移位置,中间夹杂着其他对象的数据页或者空闲页。
  • 乱序:页的偏移位置是连续的,但链表的前后顺序和物理位置顺序不一致。

页236和页247之间的空间就是属于test表的空洞

avg_fragmentation_in_percent字段只统计这两种碎片,不做区分。

fragment_count 和 avg_fragment_size_in_pages 这两个指标才是反映实际开销:它们用来度量连续页块的长度。

外部碎片从何而来?

  1. 多个对象同时扩容:数据库引擎按照分配请求的先后顺序分配区,因此不同对象的页在文件里交错分布,这是数据库运行中的常态。
  2. 页拆分(Page Split):新页会在能找到空闲空间的位置分配,很少分配在原页旁边。
  3. 页释放:大量删除操作,以及索引自动压缩本身,都会造成页释放。

具体效果是怎样的?

复制代码
USE DemoFrag
GO
CREATE TABLE dbo.SplitDemo (
    Id      int NOT NULL CONSTRAINT PK_SplitDemo PRIMARY KEY CLUSTERED,
    Payload varchar(8000) NOT NULL
);
-- 每页存放两行,每行约4000字节:第1、2行在1个页,3、4行在下一个页
INSERT INTO dbo.SplitDemo 
VALUES (1, REPLICATE('A', 4000)), (2, REPLICATE('B', 4000)), 
       (3, REPLICATE('C', 4000)), (4, REPLICATE('D', 4000))

-- 查看行位置(文件号:页号:槽号)
SELECT Id, sys.fn_PhysLocFormatter(%%physloc%%) AS physical_location
FROM dbo.SplitDemo;

表的前两行数据位于376号页,后两行位于378号页。

那377号页里面存了什么?为什么页不是连续的?

因为这张表在Id列上建有聚集索引,所以表以B树数据结构存储。我们查看一下这个B树的分配情况

复制代码
SELECT allocated_page_page_id, page_type_desc, page_level, previous_page_page_id, next_page_page_id
FROM sys.dm_db_database_page_allocations(DB_ID(), OBJECT_ID('dbo.SplitDemo'),1, NULL, 'DETAILED');

很明显可以看到377是索引中间节点页面

页面内部数据

复制代码
SELECT * FROM sys.dm_db_page_info (10, 1, 376, DEFAULT);
复制代码
SELECT * FROM sys.dm_db_page_info (10, 1, 378, DEFAULT);

执行下面操作进而引发外部碎片的页拆分:

复制代码
-- 触发页拆分
UPDATE dbo.SplitDemo SET Payload = REPLICATE('B', 4100) WHERE Id = 2;

-- 查看页填充密度
SELECT index_level, page_count, avg_page_space_used_in_percent, avg_fragmentation_in_percent, record_count, ghost_record_count, version_ghost_record_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.SplitDemo'),1, NULL, 'SAMPLED')
WHERE alloc_unit_type_desc = 'IN_ROW_DATA';

页拆分之后:

第1行数据位于376号页面

第2行数据位于379号页面

第3行数据位于378号页面

第4行数据位于378号页面

数据库引擎请求新空间,新页面分配在文件的后面,第二行数据复制到第379页,相应的页头指针也要修改,所以一次页拆分往往涉及到几个页面

为什么如今外部碎片带来的开销已经很小?

预读(read-ahead)机制可以很好的降低外部碎片产生的性能损耗。扫描索引时,数据库引擎会通过大规模I/O预取数据页,一次I/O读取文件中一段连续范围:一个起始偏移量、一个长度,最大可达512KB。索引页连续时,一次I/O就能读取64个数据页;如果不连续,同样一批表数据页就要通过多次更小的I/O读取。

  • 零散I/O会在什么时候产生明显负面影响?必须同时满足三个条件:
  1. 数据为冷数据:页如果已经在缓冲池中,完全不会产生物理I/O;
  2. 查询操作是大范围扫描:索引查找是依靠页ID遍历B树,并不关心数据页的物理顺序;
  3. 每多一次物理I/O都要付出可观耗时。

从用户最直接的视角来看,如果页面号距离相差太大,一次预读可能覆盖不到所需要的结果页面(假如预读只读取了page178到page242 总共64个页面),但是结果页面需要page247,那么就需要再增加一次物理读把所需页面读进内存。

在SSD、NVMe以及ESSD云盘云存储上,新增一次物理I/O通常只需要数十微秒,而且文件下层的物理存放位置本身就是不固定的(SSD固态硬盘控制器、SAN存储局域网可以随意放置数据块)。

外部碎片指标至今仍被关注是因为还有另一个原因:它是一个很好的信号指标。外部碎片增多往往源于页拆分,而页拆分又会降低页填充密度(增加内部碎片)、放大数据库事务日志的写入量。

为什么索引碎片的影响不像以前那么大了?

逻辑碎片在机械硬盘时代是个难题,对存在碎片的索引执行有序扫描时,预读机制会被拆分成多次小规模I/O,每一次地址跳转都需要移动磁头。而在SSD、NVMe和云存储上,非顺序读取带来的性能损耗几乎微乎其微。另外,我们过去费心维护的"物理顺序",仅仅是数据库数据文件内部数据页的顺序;在这一层之下,文件系统、SSD控制器以及存储层可以随意存放数据块。

对应关系:

  • 内部碎片 = 页填充密度 (AIC功能可以处理这种问题)

  • 外部碎片 = 页的逻辑顺序 (AIC功能无办法处理,甚至可能造成更多外部碎片)

索引自动压缩AIC的工作原理是什么?

索引自动压缩并不是一个独立的隐藏维护任务,它的功能由一个已有的后台线程额外承担:PVS清理器是加速数据库恢复(ADR) 的其中一个组件。要提醒的是,即使不开启ADR功能PVS清理器线程默认也会启动。

核心原理

PVS清理器会定期访问近期发生过修改(插入、更新、删除)的数据页,清理过时的行版本(如果开启了ADR功能)。 当开启索引自动压缩时,该清理器还会检查当前访问页是否存在空闲空间(fill factor填充因子预留的空间除外)。 如果有空闲空间,它会把下一页的数据行迁移到当前页,只要能容纳数据行就会持续不断执行,并且会对几组连续的页面重复执行该操作。 如果某个页面因此变为空页,就会释放该页面。

达到的效果: 数据页数量减少、页填充密度提升,存储空间、I/O、CPU以及缓冲池的资源消耗随之下降。

索引自动压缩的运行机制开销极低,因为它只处理近期被修改过的页面;索引重建或索引重组则会处理全部页面,二者不一样。和索引重组类似,索引自动压缩操作也会获取短期的页排他锁来迁移数据行。如果无法立刻获取锁,就先跳过该页面,后续再尝试处理。

一条命令即可开启,无需重启数据库,不需要独占访问。压缩操作在几分钟内就会启动。

复制代码
-- Enable the feature
ALTER DATABASE [SQL-DB-1] SET AUTOMATIC_INDEX_COMPACTION = ON;

-- Check if it's enabled
SELECT name, is_automatic_index_compaction_on FROM sys.databases;

开启该功能前有一个重点需要注意

PVS清理器线程只处理开启功能之后发生修改的数据页。如果你的索引当前页填充密度已经很低,可能需要先执行一次索引重组或索引重建来修复存量问题。在此之后,自动压缩机制会持续维持索引的紧凑状态,无需人工干预。

演示流程

  1. 创建一张带聚集主键的表,并插入500万行数据
  2. 采集基准指标:页数量、页填充密度、碎片程度
  3. 删除三分之二的数据行,删除操作分散在整张表中,以此模拟索引膨胀
  4. 再次采集指标,然后等待数据库引擎执行索引自动压缩

我们先建表,插入5万行数据:

复制代码
CREATE TABLE dbo.Demo (
    Id      int IDENTITY CONSTRAINT PK_Demo PRIMARY KEY CLUSTERED,
    Payload char(400) NOT NULL DEFAULT REPLICATE('X', 400)
);

INSERT INTO dbo.Demo (Payload)
SELECT TOP (50000) REPLICATE('X', 400)
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

采集基准指标:

复制代码
SELECT index_level, page_count, avg_page_space_used_in_percent,
       avg_fragmentation_in_percent, record_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.Demo'),1, NULL, 'SAMPLED')
WHERE alloc_unit_type_desc = 'IN_ROW_DATA';

此时内部碎片水平较低。 得到的结果如下:

当前数据库中的页面类似于下面这样,比较稠密

人为制造索引膨胀

我们删除表中三分之二的数据行,删除记录均匀分布在整张表里。没有任何数据页变为空页,每个页面大约只保留三分之一的数据行,页填充密度随之下降:

复制代码
DELETE FROM dbo.Demo WHERE Id % 3 <> 0;

如果没有开启索引自动压缩AIC,页面数量维持在2778(单纯delete删除不会释放非空数据页),页密度降至约31%(94.93%的三分之一),数据行只剩下16666行。

再次采集指标

当前数据库中的页面类似于下面这样,比较稀疏

这时候开启索引自动压缩AIC功能,等待数据库引擎完成后台处理,再次核查指标 数据库在全程正常对外提供服务的同时,由后台数据库引擎完成索引压缩合并工作。 可以看到页面数量减少到929,页密度增加约94%,数据行依然是16666行。

目前的一些限制

  • 自动数据库索引压缩合并功能不会更新统计信息。索引重建会更新统计信息,如果你原本依靠索引重建任务更新统计信息,仍然需要保留统计信息更新任务。

  • 它无法降低外部碎片(逻辑碎片),正如演示中所见,外部碎片甚至可能升高。对绝大多数业务负载而言,不会产生可观测的性能影响。

  • 它不会重新生成填充因子预留的空闲空间,只有索引重建任务才会。对于需要将填充因子设置为小于100%以减少页拆分的业务场景,仍然需要不定期执行索引重建任务。

  • 它不会收缩数据文件。数据的已使用空间会减少,但数据文件的已分配大小保持不变。

  • 该功能仅适用于行内数据(IN_ROW_DATA)分配单元中B树索引的叶子层级。堆表、大对象(LOB)数据、行溢出数据、已压缩的列存储行组以及内存优化表均不在支持范围内。

  • 禁用页锁(ALLOW_PAGE_LOCKS = OFF)的索引无法使用该功能。

  • 目前我们依然需要 Ola Hallengren 脚本或是自研的运维方案,Ola Hallengren脚本原本是同时维护索引 + 统计信息。由于自动数据库索引压缩合并功能并不维护统计信息,如果不执行单独的统计信息维护作业,后续很可能会出现性能问题。

参考

https://learn.microsoft.com/en-us/sql/relational-databases/indexes/automatic-index-compaction?view=fabric-sqldb

本文版权归作者所有,未经作者同意不得转载。

相关推荐
做个文艺程序员1 天前
MinIO第06篇:分布式集群部署、纠删码调优与生产级性能优化
性能调优·minio·高可用·安全漏洞·分布式部署·纠删码
ly76892 天前
Redis 内存优化与性能调优:编码转换、内存碎片与慢查询的定位链路
redis·性能调优·内存优化·慢查询·编码转换
gwf2163 天前
QP状态机详解:Reset→Init→RTR→RTS与建链握手过程
性能调优·rdma·qp状态机·linux内核驱动·ib_modify_qp·cm建链·irdma
gwf2165 天前
Completion Queue(CQ)与中断处理:轮询、中断聚合与错误码 —— 面向AI集群的驱动级深度剖析
驱动开发·性能调优·pcie·rdma·ai集群·中断聚合·cq
AI智讯中枢11 天前
高性能 C++ 实战 (五):perf+FlameGraph 火焰图生产实战,精准定位 CPU / 缓存 / 锁瓶颈,避坑 + 完整实操案例
linux·c++·性能调优·性能分析·perf·flamegraph·火焰图
Web3&Basketball20 天前
Agent外传审计实战:3类失准事故拦截脚本
python·大模型·agent·性能调优·推理优化
hanchenxing20 天前
MySQL深分页优化:游标分页 vs 延迟关联 vs 子查询,实测对比MySQL
性能调优·分页优化
liferecords22 天前
第 6 讲 · KSYS 实战:用数据定位瓶颈,而不是靠猜
性能调优·性能分析·鲲鹏·ksys·devkit
gwf2161 个月前
NVMe/RDMA传输层协议深度解析:RDMA原理、Queue Pair映射、内核实现与性能全栈剖析
linux内核·ssd·nvme·性能调优·rdma·存储协议·nvme/rdma