InnoDB存储原理

1. B+ Tree 与物理数据页的关系

1.1 B+ Tree 索引的物理存储

InnoDB 中,B+ Tree 索引的每个节点就是一个物理数据页(16KB)。非叶子节点存储索引键和子页指针,叶子节点存储完整的数据行。

flowchart TD
    subgraph BTree[B+ Tree 物理存储]
        ND1["非叶子节点页
索引页
存储键+指针"] ND2["非叶子节点页
索引页
存储键+指针"] ND3["非叶子节点页
索引页"] L1["叶子节点页
数据页
Row 1"] L2["叶子节点页
数据页
Row 2"] L3["叶子节点页
数据页
Row 3"] L4["叶子节点页
数据页
Row 4"] end ND1 --> ND2 ND1 --> ND3 ND2 --> L1 ND2 --> L2 ND3 --> L3 ND3 --> L4 L1 -.-> L2 L2 -.-> L3 L3 -.-> L4 style ND1 fill:#e3f2fd,stroke:#1565c0 style ND2 fill:#e3f2fd,stroke:#1565c0 style ND3 fill:#e3f2fd,stroke:#1565c0 style L1 fill:#c8e6c9,stroke:#2e7d32 style L2 fill:#c8e6c9,stroke:#2e7d32 style L3 fill:#c8e6c9,stroke:#2e7d32 style L4 fill:#c8e6c9,stroke:#2e7d32
-- B+ Tree 节点 = 物理数据页 (16KB)

-- 非叶子节点(索引页)
-- 存储结构:[主键值, 页指针]
-- 每行约 12 字节
-- 每页可存:16KB / 12 ≈ 1365 个键

-- 叶子节点(数据页)
-- 存储结构:[主键, 所有列数据]
-- 每行大小取决于字段类型和数量
-- 每页可存:几百到几千行不等

-- 示例:B+ Tree 高度为 3
-- 根节点 -> 1365 个子节点 -> 1365² 行数据
-- 约可存储:1365 × 1365 × 1000 ≈ 18 亿行

1.2 索引查找与磁盘IO

每次通过索引查找数据,都需要将数据页从磁盘读取到 Buffer Pool。磁盘IO是数据库的主要性能瓶颈。

flowchart TD
    A[查询 id=100] --> B[加载根节点页]
    B --> C{页在Buffer Pool?}
    C -->|否| D[磁盘IO: 读取ibd文件]
    D --> E[放入Buffer Pool]
    C -->|是| E
    E --> F[在内存中查找]
    F --> G[定位到子页指针]
    G --> H[加载叶子节点页]
    H --> I{页在Buffer Pool?}
    I -->|否| J[磁盘IO: 读取ibd]
    I -->|是| K
    J --> K[放入Buffer Pool]
    K --> L[找到目标行]
    L --> M[返回结果]

    D -->|1次IO| B
    J -->|2次IO| H
    M --> N[可能需要回表]

    style D fill:#ffcdd2,stroke:#c62828
    style J fill:#ffcdd2,stroke:#c62828
    style E fill:#c8e6c9,stroke:#2e7d32
    style K fill:#c8e6c9,stroke:#2e7d32
-- 索引查找的磁盘IO过程:

-- 1. 加载根节点页(第1次IO)
--    - 从ibd文件读取根节点页到Buffer Pool
--    - 根据查询键值确定下一级子页

-- 2. 加载中间节点页(第2次IO)
--    - 继续定位,直到叶子节点

-- 3. 加载叶子节点页(第3次IO)
--    - 叶子节点存储实际数据行
--    - 在页内查找目标记录

-- 4. 回表(如需)(第4次IO)
--    - 如果是覆盖索引,无需回表
--    - 否则需要根据主键再查一次

-- 性能指标:
-- - 磁盘随机IO延迟:~10ms
-- - 内存访问延迟:~100ns
-- - 速度差异:10万倍
-- - 因此:减少磁盘IO是优化关键

1.3 索引写入与页分裂

当插入新数据时,如果目标叶子节点已满,InnoDB 会进行页分裂操作,将一半数据移动到新页面。

flowchart TD
    A[插入新记录] --> B{叶子节点已满?}
    B -->|否| C[直接插入]
    B -->|是| D[页分裂]
    D --> E[创建新叶子节点]
    E --> F[移动一半数据到新节点]
    F --> G[更新父节点指针]
    G --> H[插入新记录]
    C --> I[可能触发父节点分裂]
    H --> I
    I --> J[结束]

    style B fill:#fff3e0,stroke:#e65100
    style D fill:#ffcdd2,stroke:#c62828
    style E fill:#ffcdd2,stroke:#c62828
    style F fill:#ffcdd2,stroke:#c62828
    style G fill:#c8e6c9,stroke:#2e7d32
    style C fill:#c8e6c9,stroke:#2e7d32
-- 页分裂详解:

-- 触发条件:叶子节点数据页满(16KB)
-- 分裂过程:
--   1. 分配新叶子节点页
--   2. 将原页一半记录移动到新页
--   3. 在父节点添加新指针
--   4. 插入新记录

-- 页分裂的性能影响:
--   - 需要分配新页面
--   - 需要修改多个数据页
--   - 可能引发连锁分裂(向上传播)
--   - 导致页稀疏,空间利用率下降

-- 优化建议:
--   - 使用自增主键,顺序插入避免分裂
--   - 避免随机主键(UUID等)
--   - 批量插入减少分裂次数

1.4 B+ Tree 操作与物理IO对照表

操作类型 B+ Tree 动作 物理IO 说明
点查 定位叶子节点 树高度次 如 WHERE id = 100
范围查询 定位起点 + 顺序扫描 树高度 + 扫描页数 如 WHERE id > 100
顺序扫描 从最左叶子开始 全表页数 无WHERE条件
插入 查找 + 可能的分裂 树高度 + 分裂次数 可能触发多次IO
更新 查找 + 修改叶子 树高度 + 脏页刷新 涉及Redo Log
删除 查找 + 标记删除 树高度 + 可能的合并 后台Purge清理

2. 数据页结构详解

2.1 数据页 16KB 内部结构

flowchart TD
    subgraph Page[InnoDB 数据页 16KB]
        FH["File Header
38字节
页ID、checksum"] PH["Page Header
56字节
页目录信息"] INF["Infimum
13字节
虚拟记录"] SUP["Supremum
13字节
虚拟记录"] UR["User Records
用户记录区
(实际数据)"] FS["Free Space
空闲空间"] PD["Page Directory
页目录
稀疏索引"] FT["File Trailer
8字节
校验信息"] end FH --> PH PH --> INF INF --> SUP SUP --> UR UR --> FS FS --> PD PD --> FT
-- 16KB 数据页结构分解:

-- 1. File Header (38字节)
--    - 页ID (space ID + page number)
--    - 上一页/下一页指针(双向链表)
--    - 页类型 (INDEX/SYSTEM/UNDO等)
--    - LSN (日志序列号)
--    - Checksum (数据完整性校验)

-- 2. Page Header (56字节)
--    - 已记录数 (PAGE_N_DIR_SLOTS)
--    - 空闲空间起始位置 (PAGE_FREE)
--    - 第一个被删除记录的位置
--    - 页面中记录数量

-- 3. Infimum + Supremum (各13字节)
--    - 虚拟记录,作为页内记录边界
--    - Infimum: 比任何用户记录都小
--    - Supremum: 比任何用户记录都大

-- 4. User Records (用户数据区)
--    - 实际存储的行数据
--    - 变长字段使用额外空间存储长度

-- 5. Free Space (空闲区)
--    - 未使用的空间
--    - 新记录从这里分配

-- 6. Page Directory (页目录)
--    - 稀疏索引,指向记录组
--    - 二分查找加速定位

-- 7. File Trailer (8字节)
--    - 页尾校验
--    - 检测页面是否损坏

2.2 数据页内查找机制

-- 数据页内查找过程:

-- 1. Page Directory 二分查找
--    - 将页内记录分组,每组4-8条
--    - 每组一个目录项(2字节指针)
--    - 二分查找定位到组

-- 2. 顺序查找精确定位
--    - 在目标组内顺序查找
--    - 组内记录数少,扫描开销小

-- 示例:
--    - 假设页内有1000条记录
--    - 每组8条,需要125个目录项
--    - 二分查找:log2(125) = 7次
--    - 组内查找最多8次
--    - 总计:约15次比较定位记录

-- 对比:
--    - 无页目录:1000次线性比较
--    - 有页目录:15次比较
--    - 性能提升:60倍

3. ibd 文件存储结构

3.1 ibd 文件物理布局

flowchart TD
    subgraph IBD[ibd 文件结构]
        H1["Space Header
第1页
表空间头信息"] H2["FSP Header
第2页
空闲空间管理"] XDES["XDES
第3-64页
区描述符"] IBUF["Insert Buffer
B+ Tree
索引页"] DICT["Data Dictionary
B+ Tree
数据字典"] IDX["Index B+ Tree
索引+数据
主要存储区"] UNDO["Undo Log
回滚日志
历史版本"] end H1 --> H2 H2 --> XDES XDES --> IBUF XDES --> DICT XDES --> IDX XDES --> UNDO
-- ibd 文件页分配结构:

-- 第1页:Space Header (Tablespace Header)
--   - 表空间ID、标志位
--   - 第一个可用Extent地址
--   - 所有数据页的bitmap

-- 第2页:FSP Header (File Space Header)
--   - 空闲Extent链表
--   - 完全使用Extent链表
--   - 碎片Extent信息

-- 第3-64页:XDES (Extent Descriptor)
--   - 每个Extent描述符256字节
--   - 每个Extent (64页) 占4字节
--   - 描述每个Extent的状态

-- 索引页区域:
--   - B+ Tree 非叶子节点
--   - B+ Tree 叶子节点(数据)
--   - 按Extent分配和管理

-- 特殊页:
--   - Insert Buffer B+ Tree
--   - Data Dictionary B+ Tree
--   - Undo Log 页

3.2 区(Extent)与段(Segment)

flowchart TD
    subgraph Pages[数据页]
        P1["页 1"] --> P2["页 2"]
        P2 --> P3["页 3"]
        P3 --> P64["页 64"]
    end

    subgraph Extents[区]
        E1["Extent 1"] --> E2["Extent 2"]
        E2 --> E3["Extent N"]
    end

    style P1 fill:#e3f2fd,stroke:#1565c0
    style E1 fill:#c8e6c9,stroke:#2e7d32
-- Extent(区)结构:
--   - 1 Extent = 64 连续页 = 1MB
--   - 顺序IO优化:减少磁盘寻道
--   - 空间预分配:提高写入性能

-- Segment(段)结构:
--   - Leaf Node Segment:叶子节点段
--   - Non-Leaf Node Segment:非叶子节点段
--   - 每个索引由两个Segment组成

-- Extent 分配策略:
--   1. 碎片页分配 (0-32页)
--      - 按需分配单页
--      - 空间利用率高
--      - 随机IO较多

--   2. 完整区分配 (>32页)
--      - 整区1MB分配
--      - 顺序IO优化
--      - 空间可能浪费

-- 查看表空间使用:
SELECT
    name,
    total_pages,
    total_pages * 16384 / 1024 / 1024 AS size_mb
FROM information_schema.innodb_tablespaces
WHERE name = 'database/table_name';

3.3 表空间类型

-- InnoDB 表空间类型:

-- 1. 系统表空间 (System Tablespace)
--    文件:ibdata1, ibdata2, ...
--    默认路径:innodb_data_home_dir
--    存储内容:
--    - data dictionary (数据字典)
--    - undo log (回滚日志)
--    - change buffer (变更缓冲)
--    - doublewrite buffer (双写缓冲)
--    - 所有表的元数据和索引

-- 2. 独立表空间 (File-Per-Table)
--    文件:database/table_name.ibd
--    优点:
--    - 方便单表备份
--    - 支持表级压缩
--    - 删除表可立即回收空间
--    - I/O隔离(可单独flush)
--    配置:innodb_file_per_table = ON

-- 3. 通用表空间 (General Tablespace)
--    文件:自定义 .ibd 文件
--    优点:
--    - 多表共享表空间
--    - 减少文件系统碎片
--    - 支持大表空间
CREATE TABLESPACE ts1 ADD DATAFILE 'ts1.ibd';
CREATE TABLE t1 (id INT) TABLESPACE ts1;

-- 4. 临时表空间 (Temporary Tablespace)
--    文件:ibtemp0, ibtemp1
--    用途:临时表、排序结果
--    特点:重启后重建

-- 5. Undo 表空间
--    文件:undo_001, undo_002
--    用途:事务回滚

4. Buffer Pool 与数据页读写

Buffer Pool 核心概念

指标 数值 说明
内存访问 ~100ns 纳秒级,极快
磁盘IO ~10ms 毫秒级,相差10万倍
建议配置 50-70% 可用内存比例
页大小 16KB 与数据页一致

Buffer Pool 工作原理

flowchart TD
    subgraph Read[读取流程]
        R1[查询数据] --> R2{命中Buffer Pool?}
        R2 -->|Yes| R3[直接返回 ~100ns]
        R2 -->|No| R4[磁盘读取 ~10ms]
        R4 --> R5[放入Buffer Pool]
        R5 --> R3
    end

    subgraph Write[写入流程]
        W1[修改数据] --> W2[修改Buffer Pool页]
        W2 --> W3[标记为脏页]
        W3 --> W4[后台刷新到磁盘]
    end

    style R3 fill:#c8e6c9,stroke:#2e7d32
    style R4 fill:#ffcdd2,stroke:#c62828
    style W2 fill:#fff3e0,stroke:#e65100

LRU 淘汰策略

区域 比例 说明
New Sublist ~37% 热数据区,最近访问
Old Sublist ~63% 冷数据区,较早访问
midpoint 位置 新页插入的分界点

Buffer Pool 配置参数

-- 核心配置参数

-- 1. Buffer Pool 大小(建议50-70%可用内存)
SET GLOBAL innodb_buffer_pool_size = 4 * 1024 * 1024 * 1024;  -- 4GB

-- 2. Buffer Pool 实例数(减少锁竞争)
SET GLOBAL innodb_buffer_pool_instances = 4;

-- 3. 冷数据区比例(默认37%)
SET GLOBAL innodb_old_blocks_pct = 37;

-- 4. 预热时间(访问后多久进入热区)
SET GLOBAL innodb_old_blocks_time = 1000;  -- 1000ms

-- 5. 脏页刷新策略
SET GLOBAL innodb_max_dirty_pages_pct = 75;  -- 超过比例强制刷新

-- 6. 查看状态
SHOW ENGINE INNODB STATUS;
SHOW VARIABLES LIKE 'innodb_buffer_pool%';

Buffer Pool 性能监控

指标 计算公式 理想值
命中率 (1 - Key_reads/Key_requests) * 100% > 99%
脏页比例 Pages_dirty / Pages_total * 100% < 10%
读取效率 Key_reads / Key_requests < 0.01

脏页刷新机制

flowchart TD
    A[数据修改] --> B[修改Buffer Pool页]
    B --> C{innodb_flush_log_at_trx_commit?}
    C -->|1| D[立即写Redo Log]
    C -->|2| E[每秒写Redo Log]
    C -->|0| F[延迟写Redo Log]
    D --> G[后台刷新线程]
    E --> G
    F --> G
    G --> H{刷新策略}
    H -->|Page Cleaner| I[刷新脏页到ibd]
    I --> J[干净页]

    style B fill:#fff3e0,stroke:#e65100
    style I fill:#c8e6c9,stroke:#2e7d32
-- 脏页刷新机制:

-- 刷新策略 (innodb_flush_log_at_trx_commit):
--    1. = 1 (默认,最安全)
--       每次事务提交同步写Redo Log
--       最多丢失1个事务
--    2. = 2
--       每次事务提交写OS缓存
--       每秒刷新到磁盘
--       最多丢失1秒数据
--    3. = 0
--       不立即写Redo Log
--       每秒刷新
--       最多丢失1秒数据(危险!)

-- 刷新时机:
--    a. 脏页比例超过阈值
--       innodb_max_dirty_pages_pct = 75%
--    b. Redo Log空间不足
--    c. 后台Page Cleaner线程定时刷新
--    d. 正常关闭数据库

-- 查看Buffer Pool状态:
SHOW ENGINE INNODB STATUS;
-- 查看:Buffer pool size, Pages dirty, Pages read/write

-- 性能影响:
--    - 脏页过多:恢复时间长
--    - 刷新过慢:磁盘IO突增
--    - 刷新过快:IO争用

预读机制

-- InnoDB 预读 (Read Ahead):

-- 1. 线性预读 (Linear Read Ahead)
--    条件:顺序访问同一Extent的多个页
--    触发:innodb_read_ahead_threshold = 56 (默认)
--    行为:预读整个Extent (64页)
--    示例:全表扫描时自动触发

-- 2. 随机预读 (Random Read Ahead)
--    条件:同一Extent内多个页被访问
--    触发:innodb_random_read_ahead = ON
--    行为:预读整个Extent
--    注意:默认关闭,可能增加IO

-- 预读触发示例:
--    SELECT * FROM users ORDER BY id;
--    -- 顺序扫描,触发线性预读

--    SELECT * FROM users WHERE id IN (1,100,200,300);
--    -- 可能触发随机预读

-- 配置优化:
--    innodb_read_ahead_threshold = 64
--    innodb_random_read_ahead = OFF
--    innodb_read_io_threads = 4

5. 空间管理与回收

空间分配流程

-- InnoDB 空间分配流程:

-- 1. 请求分配新页
--    a. 检查Fragment Array (前32页)
--    b. 有碎片页则分配单页
--    c. 无碎片页则分配完整Extent

-- 2. Extent 分配
--    a. 从FSP_FREE链表中获取
--    b. 标记为Fragment Array使用
--    c. 32页后标记为完整Extent

-- 3. 数据页分配
--    a. 从Fragment Array分配
--    b. 初始化页头
--    c. 加入LRU链表

-- 4. 索引结构维护
--    a. 更新B+ Tree结构
--    b. 维护Page Directory
--    c. 更新数据字典

-- 查看表空间统计:
SELECT
    table_name,
    round(table_rows / 10000, 1) AS rows_w,
    round(data_length / 1024 / 1024, 1) AS data_mb,
    round(index_length / 1024 / 1024, 1) AS index_mb
FROM information_schema.tables
WHERE engine = 'InnoDB';

空间回收机制

-- 空间回收机制:

-- 1. 删除记录的逻辑删除
--    - 记录标记为"已删除"
--    - 空间标记为可复用
--    - 真正删除由Purge线程处理

-- 2. Purge 机制
--    - 后台线程定期清理已删除记录
--    - 清理Undo Log
--    - 释放空间到Free List
--    - innodb_purge_threads = 4

-- 3. 页面合并
--    - 当相邻页利用率低时合并
--    - 释放多余页到Free Extent
--    - 减少碎片

-- 4. 手动回收空间
--    OPTIMIZE TABLE table_name;
--    -- 重建表,整理碎片

--    ALTER TABLE t ENGINE=InnoDB;
--    -- 在线重建表

--    ALTER TABLE t TABLESPACE=innodb_file_per_table;
--    -- 移动到独立表空间

-- 注意事项:
--    - ibd文件不会自动缩小
--    - DELETE只标记删除,不释放空间
--    - TRUNCATE会重建表,释放空间

常见问题与优化

-- 常见空间问题:

-- 问题1:ibd文件过大
-- 原因:大量删除后空间未回收
-- 解决:OPTIMIZE TABLE重建表

-- 问题2:碎片过多
-- 原因:随机插入导致页分裂频繁
-- 解决:使用自增主键,定期优化

-- 问题3:Buffer Pool命中率低
-- 原因:数据量超过Buffer Pool大小
-- 解决:增加Buffer Pool,优化查询

-- 监控SQL:
--    -- 查看表大小
--    SELECT table_name,
--           round(data_length/1024/1024,2) as mb
--    FROM information_schema.tables
--    WHERE table_schema = 'db';

--    -- 查看索引统计
--    SHOW INDEX FROM table_name;

--    -- 查看InnoDB状态
--    SHOW ENGINE INNODB STATUS\G

--    -- 查看脏页比例
--    SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty';