Skip to content

聚集索引:数据的「物理归宿」

你可能知道:主键会自动创建聚集索引。

但如果我问你:为什么建议用自增 ID 而不是 UUID 作为主键?聚集索引的叶子节点存的是什么?为什么有些表查询慢,加了聚集索引反而更慢?

这些问题,都和聚集索引的底层原理有关。

今天,我们来彻底理解聚集索引。

聚集索引到底是什么?

聚集索引的「聚集」,英文是 Clustered,意思是「使聚集成一团」

在 SQL Server 中,聚集索引的真正含义是:表数据按照索引键的顺序物理存储

换句话说,有聚集索引的表,数据本身就是 B+ 树的叶子节点。

聚集索引结构:

B+ 树结构:
                    [5 | 10 | 15]
                    /    |    \
                 ...    ...    ...

叶子节点 = 表数据本身:

数据页 1 (主键 1-5):
┌────┬────────┬────────┬────────┐
│ ID │ Name   │ Email  │ Score  │
├────┼────────┼────────┼────────┤
│ 1  │ 张三   │ z@...  │ 85     │
│ 2  │ 李四   │ l@...  │ 92     │
│ 3  │ 王五   │ w@...  │ 78     │
└────┴────────┴────────┴────────┘

数据页 2 (主键 6-10):
┌────┬────────┬────────┬────────┐
│ 6  │ 赵六   │ zh@... │ 88     │
└────┴────────┴────────┴────────┘

聚集索引的创建规则

创建时机

sql
-- 方式一:创建主键时自动创建聚集索引(默认)
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,  -- 聚集索引
    OrderDate DATETIME,
    Amount DECIMAL(18,2)
);

-- 方式二:显式指定聚集索引
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY NONCLUSTERED,  -- 非聚集主键
    OrderDate DATETIME,
    Amount DECIMAL(18,2),
    CLUSTERED INDEX IX_Orders_Date (OrderDate)  -- 聚集索引
);

聚集索引的选择原则

SQL Server 表只能有一个聚集索引,选择时考虑:

  1. 常用于范围查询 — 范围查询在聚集索引上非常高效
  2. 常用于排序 — 数据已经物理有序
  3. 值相对稳定 — 索引键变更代价很大(需要移动整行数据)
  4. 值唯一或接近唯一 — 减少叶子节点分裂
java
/**
 * 聚集索引键选择策略
 * 
 * 好的聚集索引键特点:
 * 1. 唯一或接近唯一
 * 2. 数值型(比较速度快)
 * 3. 顺序插入(减少页分裂)
 * 4. 宽度小(减少非聚集索引的存储空间)
 */
public class ClusteredIndexStrategy {
    
    /**
     * 评估聚集索引键的优劣
     * 
     * @param column 列信息
     * @return 评分和建议
     */
    public EvaluationResult evaluate(String column, ColumnStats stats) {
        int score = 0;
        StringBuilder reason = new StringBuilder();
        
        // 唯一性评分
        if (stats.cardinality == stats.rowCount) {
            score += 30;
            reason.append("完全唯一 ");
        } else if (stats.cardinality > stats.rowCount * 0.8) {
            score += 20;
            reason.append("接近唯一 ");
        } else {
            score -= 10;
            reason.append("唯一性较差 ");
        }
        
        // 数据类型评分
        if (isNumericType(column)) {
            score += 25;
            reason.append("数值类型性能好 ");
        } else if (isFixedLengthChar(column)) {
            score += 15;
            reason.append("定长字符类型 ");
        } else {
            score -= 5;
            reason.append("变长类型 ");
        }
        
        // 插入模式评分
        if (stats.insertPattern == InsertPattern.APPEND) {
            score += 25;
            reason.append("顺序插入无页分裂 ");
        } else if (stats.insertPattern == InsertPattern.RANDOM) {
            score -= 20;
            reason.append("随机插入导致页分裂 ");
        }
        
        // 宽度评分
        int widthPenalty = stats.dataTypeWidth / 4;
        score -= widthPenalty;
        if (widthPenalty > 5) {
            reason.append("宽度较大 ");
        }
        
        return new EvaluationResult(score, reason.toString());
    }
}

为什么 UUID 不适合作为聚集索引键?

这是面试常考题,答案涉及聚集索引的底层机制。

UUID 的问题

sql
-- 问题示例:UUID 主键
CREATE TABLE Orders_UUID (
    OrderID UNIQUEIDENTIFIER PRIMARY KEY,  -- UUID 聚集索引
    OrderDate DATETIME,
    Amount DECIMAL(18,2)
);

-- UUID 是随机分布的,每次插入都是随机位置
INSERT INTO Orders_UUID VALUES (NEWID(), '2024-01-01', 100);  -- 插入位置随机
INSERT INTO Orders_UUID VALUES (NEWID(), '2024-01-02', 200);  -- 插入位置又随机

性能影响

java
/**
 * UUID vs 自增 ID 的聚集索引性能对比
 */
public class ClusteredIndexComparison {
    
    /**
     * 自增 ID 的插入模式
     * 
     * 特点:总是插入到当前最大位置
     * 结果:顺序写入,利用预读机制
     */
    public void autoIncrementInsert() {
        System.out.println("=== 自增 ID ===");
        System.out.println("插入顺序: 1, 2, 3, 4, 5...");
        System.out.println("物理存储: [1,2,3,4,5...]");
        System.out.println("页利用率: 95%+(接近满页)");
        System.out.println("索引碎片: 几乎没有");
    }
    
    /**
     * UUID 的插入模式
     * 
     * 特点:随机分布在整个值空间
     * 结果:随机写入,页分裂严重
     */
    public void uuidInsert() {
        System.out.println("=== UUID ===");
        System.out.println("插入顺序: 随机分布");
        System.out.println("物理存储: [UUID_A, UUID_Z, UUID_M, UUID_K...]");
        System.out.println("页利用率: 60-70%(大量半空页)");
        System.out.println("索引碎片: 严重(外部碎片 > 30%)");
    }
}

数据说明问题

指标自增 IDUUID
插入速度非常快(顺序写入)慢(随机写入 + 页分裂)
页利用率95%+60-70%
索引大小较小较大
碎片率< 5%> 30%

解决方案

如果必须用 UUID,可以用 NEWSEQUENTIALID() 生成顺序 UUID:

sql
CREATE TABLE Orders_Sequential (
    OrderID UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    OrderDate DATETIME,
    Amount DECIMAL(18,2)
);

或者用 复合聚集索引

sql
-- 用时间戳作为引导列,UUID 作为第二列
CREATE TABLE Orders (
    ID UNIQUEIDENTIFIER DEFAULT NEWID(),
    CreatedDate DATETIME DEFAULT GETDATE(),
    Amount DECIMAL(18,2),
    CLUSTERED INDEX IX_Orders (CreatedDate, ID)
);

堆表:没有聚集索引的表

没有聚集索引的表称为堆表(Heap)

堆表的存储结构

堆表结构:

数据页 1 (按插入顺序):
┌────┬────────┬────────┐
│ ID │ Name   │ Email  │
├────┼────────┼────────┤
│ 3  │ 王五   │ w@...  │  ← 3号插入
│ 1  │ 张三   │ z@...  │  ← 1号插入
└────┴────────┴────────┘

数据页 2:
┌────┬────────┬────────┐
│ 2  │ 李四   │ l@...  │  ← 2号插入
│ 5  │ 赵六   │ zh@... │  ← 5号插入
└────┴────────┴────────┘

堆表的问题

sql
-- 查询堆表(无索引)
SELECT * FROM HeapTable WHERE ID = 100;

堆表的查询只能全表扫描,因为数据没有任何顺序。

非聚集索引在堆表上的定位符是 RID(Row ID)

  • 格式:文件号:页号:槽号
  • 例如:1:256:3 表示第 1 个数据文件的第 256 页的第 3 个槽
java
/**
 * 堆表 vs 聚集表 的非聚集索引结构差异
 */
public class HeapVsClusteredIndex {
    
    public void printDifference() {
        System.out.println("=== 堆表的非聚集索引 ===");
        System.out.println("索引结构: [索引键] → [RID]");
        System.out.println("定位方式: RID 直接定位物理位置");
        System.out.println("问题: 如果表发生行移动,RID 会失效");
        System.out.println();
        
        System.out.println("=== 聚集表的非聚集索引 ===");
        System.out.println("索引结构: [索引键] → [聚集索引键]");
        System.out.println("定位方式: 通过聚集键定位,再找数据");
        System.out.println("优势: 行移动不影响索引(RID 可能变,聚集键不变)");
    }
}

聚集索引的设计建议

sql
-- 推荐:时间序列表用时间作为聚集索引
CREATE TABLE EventLog (
    EventID BIGINT IDENTITY(1,1) PRIMARY KEY NONCLUSTERED,  -- 非聚集主键
    EventTime DATETIME NOT NULL,
    EventType NVARCHAR(50),
    EventData NVARCHAR(MAX),
    CLUSTERED INDEX IX_EventLog_Time (EventTime)  -- 时间聚集索引
);

-- 查询优势:按时间范围查询非常高效
SELECT * FROM EventLog 
WHERE EventTime BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY EventTime DESC;
java
/**
 * 聚集索引设计检查清单
 */
public class ClusteredIndexChecklist {
    
    public void printChecklist() {
        System.out.println("=== 聚集索引设计检查清单 ===");
        System.out.println();
        System.out.println("1. 选择唯一或高选择性的列");
        System.out.println("   - 低选择性列会导致数据分布不均");
        System.out.println();
        System.out.println("2. 优先考虑查询模式");
        System.out.println("   - 范围查询:适合聚集索引");
        System.out.println("   - 点查询:非聚集索引更合适");
        System.out.println();
        System.out.println("3. 考虑数据插入模式");
        System.out.println("   - 顺序插入:自增 ID、日期时间");
        System.out.println("   - 避免随机插入:UUID");
        System.out.println();
        System.out.println("4. 避免频繁更新的列");
        System.out.println("   - 更新聚集键会移动整行");
        System.out.println();
        System.out.println("5. 考虑复合聚集索引");
        System.out.println("   - 首列应该是最常用的查询条件");
        System.out.println("   - 遵循最左前缀原则");
    }
}

总结

聚集索引是 SQL Server 数据存储的核心:

  1. 聚集索引 = 数据的有序存储 — 表数据按索引键物理排序
  2. 聚集索引键的选择至关重要 — 影响插入性能和查询性能
  3. 避免用 UUID 作为聚集键 — 随机插入导致严重的页分裂和碎片
  4. 堆表查询效率低 — 任何查询都需要全表扫描

面试追问方向:

  • 如果表已经有聚集索引,还能再创建一个聚集索引吗?
  • UPDATE 语句修改聚集索引键会发生什么?
  • 堆表转聚集表,怎么做最快?

这些问题,值得你在实践中验证。

基于 VitePress 构建