B+树与InnoDB索引深度解析:数据库索引的底层原理与工程实践

B+树与InnoDB索引深度解析:数据库索引的底层原理与工程实践

文章标签: #java #数据结构 #B+树 #MySQL #InnoDB #索引优化 #数据库

首发地址 csdn 青山师https://blog.csdn.net/zixiao217

转载请注明出处!

目录


引言:B+树的技术本质

B+树(B-Plus Tree)不是"一种普通的多路平衡查找树",而是面向磁盘存储系统优化的有序索引结构 。它的设计目标从来不是在内存中快速查找,而是在最小化磁盘IO次数的前提下,同时支持高效的范围查询和有序遍历。

核心认知:

复制代码
磁盘IO的代价模型:
- 内存访问:100ns(纳秒级)
- SSD随机读取:100μs(微秒级)
- HDD随机读取:10ms(毫秒级)
- 相差10万倍!

B+树的设计哲学:
1. 磁盘页对齐:每个节点 = 一个磁盘页(通常4KB/16KB)
2. 多路分支:扇出(Fanout)极大(通常数百到数千)
3. 数据局部性:叶子节点链表连接,支持顺序扫描
4. 查询稳定性:所有查询路径长度相同(必到叶子)

质量差异的根源:
- 差的索引设计:频繁回表、全表扫描、随机IO → 查询性能差
- 好的索引设计:覆盖索引、范围扫描、顺序IO → 查询性能优

关键洞察 :B+树索引的效果不取决于"是否建了索引",而取决于索引结构是否匹配查询模式和数据分布特征。


理论基础:为什么数据库索引需要B+树

1. 磁盘IO的物理限制

磁盘的机械特性
复制代码
HDD的物理结构:
- 盘片(Platter):多个同心圆轨道
- 磁头(Head):悬浮在盘片上方读写数据
- 寻道时间(Seek Time):移动磁头到目标轨道,约4-10ms
- 旋转延迟(Rotational Latency):等待目标扇区转到磁头下,约2-5ms
- 传输时间(Transfer Time):实际读写数据,约0.1ms/4KB

总随机读取延迟 ≈ 寻道时间 + 旋转延迟 + 传输时间 ≈ 10ms

SSD的物理结构:
- NAND闪存单元
- 无机械部件,随机读取约100μs
- 但仍比内存慢1000倍
- 写入前需擦除块(Block),有写放大问题

关键理解

  • 磁盘IO的瓶颈不在"带宽",而在"延迟"
  • 每次IO操作的开销固定,与读取数据量关系不大(在页大小范围内)
  • 因此:减少IO次数比减少读取数据量更重要
预读原理(Read-Ahead)
复制代码
操作系统预读策略:
- 顺序读取时,OS会预读后续若干页到PageCache
- 预读命中率通常 > 90%
- 随机读取时,预读失效,每次IO都是独立寻址

B+树的利用:
- 叶子节点链表连接 → 范围查询变为顺序读取
- 顺序IO的吞吐量是随机IO的100倍以上
- 一次磁盘预读可以加载多个相邻叶子节点

2. 为什么不是二叉查找树

复制代码
假设:1000万条记录,每条记录1KB

AVL树/红黑树:
- 二叉结构,每个节点2个子节点
- 树高 h = log₂(10^7) ≈ 24
- 每次查询需要24次磁盘IO(最坏情况)
- 24 × 10ms = 240ms(仅磁盘IO时间)

B+树(m=1170):
- 多路结构,每个节点最多1170个子节点
- 树高 h = log₁₁₇₀(10^7) ≈ 2.8
- 每次查询只需3次磁盘IO
- 3 × 10ms = 30ms

性能差距:8倍!

工程启示

  • 二叉树在内存中表现优异,但在磁盘场景下树高太高
  • B+树通过增加扇出降低树高,将磁盘IO次数控制在常数级别(通常2-4次)

3. 为什么不是哈希表

复制代码
哈希索引的特点:
- 等值查询:O(1),非常快
- 范围查询:不支持(哈希函数破坏有序性)
- ORDER BY:不支持
- 前缀匹配:不支持
- 磁盘友好:不支持(哈希冲突需要随机访问)

B+树的优势:
- 等值查询:O(log n),略慢但可接受
- 范围查询:O(log n + k),顺序遍历
- ORDER BY:天然支持(叶子节点有序)
- 前缀匹配:支持(LIKE 'prefix%')
- 磁盘友好:节点按页存储,顺序读取

关键洞察:数据库是通用系统,需要同时支持等值查询、范围查询、排序、分组等多种操作。哈希表虽然等值查询快,但牺牲了其他能力。

4. 为什么不是B树

复制代码
B树 vs B+树的核心差异:

B树:
- 所有节点(包括非叶子)存储数据指针
- 非叶子节点存储key + data指针
- 查找可能在非叶子节点提前终止
- 范围查询需要中序遍历(跨层随机访问)

B+树:
- 非叶子节点只存储key,不存储数据
- 所有数据在叶子节点
- 查找必须到叶子节点(路径长度稳定)
- 范围查询只需顺序遍历叶子链表

B+树的决定性优势

复制代码
假设:InnoDB页大小16KB,key为8字节(bigint),指针6字节

B树非叶子节点:
- 每个key需要8字节key + 6字节指针 + 数据指针(如6字节)= 20字节
- 每页可存储:16384 / 20 ≈ 819个key
- 扇出:819

B+树非叶子节点:
- 每个key只需要8字节key + 6字节指针 = 14字节
- 每页可存储:16384 / 14 ≈ 1170个key
- 扇出:1170

扇出提升:1170 / 819 ≈ 1.43倍
树高降低:log₁₁₇₀(10^9) ≈ 3层 vs log₈₁₉(10^9) ≈ 3.2层

更重要的是范围查询:
- B树中序遍历:跨层访问,每次节点切换都是随机IO
- B+树顺序遍历:链表连接,预读友好,顺序IO

B+树核心原理深度解析

1. B+树的形式化定义

复制代码
一棵m阶B+树满足以下性质:

1. 每个节点最多有m个子节点(最多m-1个key)
2. 根节点至少有2个子节点(如果非叶子)
3. 非根非叶子节点至少有⌈m/2⌉个子节点
4. 所有叶子节点在同一层(平衡性)
5. 叶子节点通过指针相连(支持范围查询)
6. 非叶子节点只存储key,作为路由信息
7. 所有数据记录存储在叶子节点
8. 叶子节点中的key有序排列

2. 节点结构详解

复制代码
非叶子节点(Internal Node):
+------------------+-------------------------------------------+
| 指针0 | Key0 | 指针1 | Key1 | ... | Key(n-1) | 指针n |
+------------------+-------------------------------------------+

约束:
- 指针i指向的子树中所有key满足:Key(i-1) < key ≤ Key(i)
- n = key数量,子节点数量 = n + 1
- 非叶子节点不存储实际数据

叶子节点(Leaf Node):
+-------+-------+-------+-------+-------+-------+--------+
| Key0  | Data0 | Key1  | Data1 | ...   | Key(n)| Data(n)| -> next
+-------+-------+-------+-------+-------+-------+--------+

约束:
- Key0 < Key1 < ... < Key(n)
- Data可以是行数据(聚簇索引)或主键值(二级索引)
- 包含指向下一个叶子节点的指针(双向链表)

3. 树高与数据量的数学关系

复制代码
设m为阶数(最大子节点数),h为树高

最大记录数计算:
- 第1层(根):1个节点,最多m-1个key
- 第2层:m个节点,最多m(m-1)个key
- 第3层:m²个节点,最多m²(m-1)个key
- ...
- 第h层(叶子):m^(h-1)个节点

总记录数 N ≤ (m-1) × (1 + m + m² + ... + m^(h-1))
            = (m-1) × (m^h - 1) / (m - 1)
            = m^h - 1

因此:h ≤ log_m(N + 1)

最小记录数计算(保证空间利用率≥50%):
- 根节点至少1个key
- 其他非叶子节点至少⌈m/2⌉-1个key
- 叶子节点至少⌈m/2⌉-1个key

总记录数 N ≥ 2 × (⌈m/2⌉)^(h-1) - 1
因此:h ≤ log_⌈m/2⌉((N+1)/2) + 1

实际计算

复制代码
InnoDB实际参数:
- 页大小:16KB
- 主键类型:BIGINT(8字节)
- 页指针:6字节(InnoDB的Page Number)
- 页头/页尾开销:约128字节

可用空间:16384 - 128 = 16256字节

非叶子节点每个key占用:8 + 6 = 14字节
可存储key数:16256 / 14 ≈ 1161
取整后:每个非叶子节点约1170个子节点(扇出)

叶子节点每个记录占用:
- 假设每行数据平均1KB
- 每个叶子节点可存储:16KB / 1KB ≈ 16条记录

数据量与树高关系:

| 树高 | 非叶子节点数 | 叶子节点数 | 最大记录数 | IO次数 |
|------|------------|-----------|-----------|--------|
| 1    | 0          | 1         | 16        | 1      |
| 2    | 1          | 1170      | 18,720    | 2      |
| 3    | 1171       | 1,368,900 | 21,902,400| 3      |
| 4    | 1,370,071  | ~16亿     | ~268亿    | 4      |

结论:
- 绝大多数业务表(百万到千万级)树高为2-3层
- 查询只需2-3次磁盘IO
- 即使十亿级数据,树高也不超过4层

4. B+树操作算法详解

查找操作
复制代码
算法:B+树查找
输入:目标key
输出:对应的data,或null

步骤:
1. 从根节点开始
2. 如果当前节点是叶子节点:
   a. 在叶子节点中二分查找key
   b. 找到返回data,未找到返回null
3. 如果当前节点是非叶子节点:
   a. 找到第一个大于目标key的索引i
   b. 进入第i个子节点
   c. 重复步骤2-3

时间复杂度:O(log_m n)
- 树高:O(log_m n)
- 每层内部查找:O(log m)(二分查找)
- 总复杂度:O(log_m n × log m) = O(log n)
- 由于m是常数(几百到几千),简化为O(log_m n)
插入操作
复制代码
算法:B+树插入
输入:key, value
输出:无

步骤:
1. 找到目标叶子节点
2. 在叶子节点中按序插入(key, value)
3. 如果叶子节点key数 ≤ m-1,结束
4. 如果叶子节点key数 > m-1,分裂叶子节点:
   a. 取mid = ⌈(m-1)/2⌉
   b. 原节点保留前mid个key
   c. 新节点存储后(m-1-mid)个key
   d. 将新节点插入叶子链表
   e. 将新节点的第一个key提升到父节点
5. 如果父节点也溢出,递归分裂,直到根节点
6. 如果根节点分裂,树高+1

分裂示例(m=4,即最多3个key):
插入前:[10, 20, 30]
插入40:[10, 20, 30, 40] → 溢出!

分裂:
- 原节点:[10, 20]
- 新节点:[30, 40]
- 提升到父节点:30

父节点插入30后:[..., 30, ...]
如果父节点也溢出,继续向上分裂

时间复杂度:
- 查找叶子:O(log_m n)
- 叶子插入:O(m)(需要移动元素保持有序)
- 分裂:O(m)每层,最多O(m × log_m n)
- m为常数,总复杂度:O(log_m n)
删除操作
复制代码
算法:B+树删除
输入:key
输出:是否删除成功

步骤:
1. 找到目标叶子节点
2. 删除key和对应的value
3. 如果叶子节点key数 ≥ ⌈(m-1)/2⌉,结束
4. 如果叶子节点key数 < ⌈(m-1)/2⌉(下溢):
   a. 尝试从左兄弟借用:
      - 如果左兄弟key数 > ⌈(m-1)/2⌉
      - 将左兄弟最大的key移到当前节点
      - 更新父节点的key
   b. 否则尝试从右兄弟借用:
      - 如果右兄弟key数 > ⌈(m-1)/2⌉
      - 将右兄弟最小的key移到当前节点
      - 更新父节点的key
   c. 否则与左兄弟或右兄弟合并:
      - 将当前节点所有key移到兄弟节点
      - 删除当前节点
      - 删除父节点中对应的分隔key
      - 如果父节点也下溢,递归处理
5. 如果根节点只剩1个子节点,降低树高

借用示例:
删除前:
父节点:[20, 40]
左兄弟:[5, 10, 15](3个key,最少2个)
当前节点:[25](1个key,最少2个,下溢!)
右兄弟:[30, 35](2个key,刚好最少)

从左兄弟借用:
- 左兄弟不能借出(借出后只剩2个,等于最少,可以借)
- 等等,左兄弟有3个,最少2个,可以借1个
- 将左兄弟最大的15移到当前节点
- 当前节点变为:[15, 25]
- 左兄弟变为:[5, 10]
- 父节点更新:15替代20成为新的分隔key

合并示例:
父节点:[20, 40]
左兄弟:[5, 10](2个key,最少2个,不能借)
当前节点:[25](下溢)
右兄弟:[30](1个key,最少2个,不能借)

与右兄弟合并:
- 当前节点 + 父节点分隔key(30) + 右兄弟 = [25, 30, 35]
- 等等,需要重新检查...

实际上叶子节点合并不需要包含父节点key:
- 当前节点 [25],右兄弟 [30, 35](假设)
- 合并为 [25, 30, 35]
- 删除父节点中的分隔key(30)
- 父节点变为:[20, 40] → 删除30 → [20, 40] 
- 等等,父节点key是[20, 40],分隔当前节点和右兄弟的是哪个?
- 应该是40?不对...

让我重新理清:
父节点:[20, 40]
子节点:[...], [25], [30, 35], [...]
对应关系:
- key ≤ 20 → 子节点0
- 20 < key ≤ 40 → 子节点1(当前节点 [25])
- key > 40 → 子节点2([30, 35])

不对,这里有个逻辑错误。如果当前节点是 [25],那它应该在20和40之间。
但30和35也在这个范围?

实际上应该是:
父节点:[20, 30]
子节点:[...], [25], [30, 35], [...]
- key ≤ 20
- 20 < key ≤ 30 → [25]
- key > 30 → [30, 35]

这样更合理。当前节点 [25] 下溢,右兄弟 [30, 35] 也不能借(假设最少2个)。
合并:当前节点 + 右兄弟 = [25, 30, 35]
父节点删除分隔key 30,变为 [20]

时间复杂度:
- 查找叶子:O(log_m n)
- 删除:O(m)
- 借用/合并:O(m)每层,最多O(m × log_m n)
- 总复杂度:O(log_m n)

B+树完整Java实现与源码分析

1. 节点定义与类结构设计

java 复制代码
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * B+树完整实现
 * 支持:插入、删除、精确查找、范围查询
 * 
 * @param <K> 键类型,必须可比较
 * @param <V> 值类型
 */
public class BPlusTree<K extends Comparable<K>, V> {
    
    /**
     * B+树的阶数(Order)
     * 每个节点最多ORDER个子节点,最多ORDER-1个key
     * 实际生产环境(如InnoDB)中ORDER通常几百到几千
     */
    private static final int ORDER = 4;
    
    /**
     * 节点最小key数(根节点除外)
     * 保证空间利用率 ≥ 50%
     */
    private static final int MIN_KEYS = (ORDER - 1) / 2;
    
    /**
     * B+树节点抽象基类
     * 所有节点共享:key列表、父节点引用
     */
    public abstract class Node {
        // 节点中的key列表(始终保持有序)
        List<K> keys;
        
        // 父节点引用(根节点为null)
        Node parent;
        
        Node() {
            this.keys = new ArrayList<>();
            this.parent = null;
        }
        
        /**
         * 判断是否为叶子节点
         */
        abstract boolean isLeaf();
        
        /**
         * 判断节点是否已满(key数达到ORDER-1)
         */
        boolean isFull() {
            return keys.size() >= ORDER - 1;
        }
        
        /**
         * 判断节点是否下溢(key数低于最小值)
         * 根节点特殊处理:至少1个key即可
         */
        boolean isUnderflow() {
            return keys.size() < MIN_KEYS;
        }
        
        /**
         * 判断节点是否可以从兄弟节点借用key后仍满足最小key数
         */
        boolean canLend() {
            return keys.size() > MIN_KEYS;
        }
    }
    
    /**
     * 内部节点(非叶子节点)
     * 存储:key列表 + 子节点指针列表
     * 约束:children.size() == keys.size() + 1
     */
    public class InternalNode extends Node {
        // 子节点列表
        List<Node> children;
        
        InternalNode() {
            super();
            this.children = new ArrayList<>();
        }
        
        @Override
        boolean isLeaf() { 
            return false; 
        }
        
        /**
         * 根据key找到应该进入的子节点索引
         * 算法:找到第一个大于key的位置i,进入第i个子节点
         * 时间复杂度:O(log n) 使用二分查找,当前实现为线性查找(ORDER小)
         */
        int findChildIndex(K key) {
            int i = 0;
            // 线性查找,适合ORDER较小的情况
            // 生产环境应使用Collections.binarySearch
            while (i < keys.size() && key.compareTo(keys.get(i)) >= 0) {
                i++;
            }
            return i;
        }
        
        /**
         * 获取左兄弟节点
         */
        InternalNode getLeftSibling(Node child) {
            int index = children.indexOf(child);
            if (index > 0) {
                return (InternalNode) children.get(index - 1);
            }
            return null;
        }
        
        /**
         * 获取右兄弟节点
         */
        InternalNode getRightSibling(Node child) {
            int index = children.indexOf(child);
            if (index < children.size() - 1) {
                return (InternalNode) children.get(index + 1);
            }
            return null;
        }
    }
    
    /**
     * 叶子节点
     * 存储:key列表 + value列表 + 相邻叶子节点指针
     * 所有实际数据存储在叶子节点
     */
    public class LeafNode extends Node {
        // 与keys一一对应的数据值
        List<V> values;
        
        // 指向下一个叶子节点(支持范围查询和顺序遍历)
        LeafNode next;
        
        // 指向前一个叶子节点(支持双向遍历)
        LeafNode prev;
        
        LeafNode() {
            super();
            this.values = new ArrayList<>();
            this.next = null;
            this.prev = null;
        }
        
        @Override
        boolean isLeaf() { 
            return true; 
        }
        
        /**
         * 在叶子节点中查找key的索引
         * 使用Collections.binarySearch实现二分查找
         * 时间复杂度:O(log n)
         */
        int findKeyIndex(K key) {
            int index = Collections.binarySearch(keys, key);
            return index;
        }
        
        /**
         * 获取左兄弟叶子节点
         */
        LeafNode getLeftSibling() {
            if (parent == null) return null;
            InternalNode p = (InternalNode) parent;
            int index = p.children.indexOf(this);
            if (index > 0) {
                return (LeafNode) p.children.get(index - 1);
            }
            return null;
        }
        
        /**
         * 获取右兄弟叶子节点
         */
        LeafNode getRightSibling() {
            if (parent == null) return null;
            InternalNode p = (InternalNode) parent;
            int index = p.children.indexOf(this);
            if (index < p.children.size() - 1) {
                return (LeafNode) p.children.get(index + 1);
            }
            return null;
        }
    }
    
    // 根节点
    private Node root;
    
    // 叶子节点链表头(用于范围查询的入口)
    private LeafNode head;
    
    // 当前存储的记录数
    private int size;
    
    // 树高(用于监控)
    private int height;
    
    public BPlusTree() {
        this.root = null;
        this.head = null;
        this.size = 0;
        this.height = 0;
    }
    
    public int size() { return size; }
    public int height() { return height; }
    public boolean isEmpty() { return size == 0; }
}

2. 查找操作实现

java 复制代码
/**
 * B+树查找操作
 * 时间复杂度:O(log_m n)
 * 
 * 算法流程:
 * 1. 从根节点开始
 * 2. 在非叶子节点中,找到目标key应该进入的子节点
 * 3. 重复步骤2直到到达叶子节点
 * 4. 在叶子节点中二分查找目标key
 */
public V search(K key) {
    if (root == null || key == null) {
        return null;
    }
    
    // 1. 找到目标叶子节点
    LeafNode leaf = findLeafNode(key);
    
    // 2. 在叶子节点中二分查找
    int index = Collections.binarySearch(leaf.keys, key);
    
    // 3. 返回结果
    if (index >= 0) {
        return leaf.values.get(index);
    }
    
    return null;
}

/**
 * 查找目标key所在的叶子节点
 * 这是B+树查找的核心路径,从根到叶子的遍历
 */
private LeafNode findLeafNode(K key) {
    Node node = root;
    
    // 从根节点开始,逐层向下直到叶子
    while (!node.isLeaf()) {
        InternalNode internal = (InternalNode) node;
        
        // 找到应该进入的子节点索引
        // 原理:key小于keys[i]时,进入第i个子节点
        int childIndex = internal.findChildIndex(key);
        
        node = internal.children.get(childIndex);
    }
    
    return (LeafNode) node;
}

/**
 * 范围查询:[startKey, endKey]之间的所有记录
 * 时间复杂度:O(log_m n + k),k为结果集大小
 * 
 * 算法流程:
 * 1. 找到startKey所在的叶子节点(O(log_m n))
 * 2. 从该节点开始顺序遍历叶子链表(O(k))
 * 3. 收集在范围内的记录
 */
public List<V> rangeSearch(K startKey, K endKey) {
    List<V> result = new ArrayList<>();
    
    if (root == null || startKey == null || endKey == null) {
        return result;
    }
    
    // 确保范围有效
    if (startKey.compareTo(endKey) > 0) {
        return result;
    }
    
    // 1. 找到起始key所在的叶子节点
    LeafNode leaf = findLeafNode(startKey);
    
    // 2. 从该叶子节点开始顺序遍历
    while (leaf != null) {
        for (int i = 0; i < leaf.keys.size(); i++) {
            K currentKey = leaf.keys.get(i);
            
            // 跳过小于startKey的记录
            if (currentKey.compareTo(startKey) < 0) {
                continue;
            }
            
            // 超过endKey,结束查询
            if (currentKey.compareTo(endKey) > 0) {
                return result;
            }
            
            // 在范围内,加入结果
            result.add(leaf.values.get(i));
        }
        
        // 通过叶子节点链表跳到下一个节点
        // 这是B+树范围查询高效的关键:顺序IO而非随机IO
        leaf = leaf.next;
    }
    
    return result;
}

3. 插入操作实现

java 复制代码
/**
 * B+树插入操作
 * 时间复杂度:O(log_m n)
 * 
 * 算法流程:
 * 1. 找到目标叶子节点
 * 2. 在叶子节点中按序插入
 * 3. 如果叶子节点溢出,分裂叶子节点
 * 4. 如果分裂传播到根节点,树高+1
 */
public void insert(K key, V value) {
    if (key == null) {
        throw new IllegalArgumentException("Key cannot be null");
    }
    
    // 空树处理
    if (root == null) {
        LeafNode leaf = new LeafNode();
        leaf.keys.add(key);
        leaf.values.add(value);
        root = leaf;
        head = leaf;
        size = 1;
        height = 1;
        return;
    }
    
    // 1. 找到要插入的叶子节点
    LeafNode leaf = findLeafNode(key);
    
    // 2. 检查key是否已存在,存在则更新value
    int index = Collections.binarySearch(leaf.keys, key);
    if (index >= 0) {
        // key已存在,更新value
        leaf.values.set(index, value);
        return;
    }
    
    // 3. 计算插入位置(保持有序)
    // binarySearch返回(-insertionPoint - 1)
    int insertPos = -index - 1;
    
    // 4. 插入新记录
    leaf.keys.add(insertPos, key);
    leaf.values.add(insertPos, value);
    size++;
    
    // 5. 检查是否需要分裂
    if (leaf.keys.size() > ORDER - 1) {
        splitLeaf(leaf);
    }
}

/**
 * 分裂叶子节点
 * 当叶子节点key数超过ORDER-1时触发
 * 
 * 分裂策略:
 * - 取mid = keys.size() / 2
 * - 原节点保留前mid个key
 * - 新节点存储后(keys.size() - mid)个key
 * - 新节点的第一个key提升到父节点作为分隔key
 */
private void splitLeaf(LeafNode leaf) {
    int mid = leaf.keys.size() / 2;
    
    // 创建新叶子节点
    LeafNode newLeaf = new LeafNode();
    
    // 新节点存储后半部分
    newLeaf.keys.addAll(leaf.keys.subList(mid, leaf.keys.size()));
    newLeaf.values.addAll(leaf.values.subList(mid, leaf.values.size()));
    
    // 维护双向链表
    newLeaf.next = leaf.next;
    newLeaf.prev = leaf;
    if (leaf.next != null) {
        leaf.next.prev = newLeaf;
    }
    leaf.next = newLeaf;
    
    // 原节点保留前半部分
    leaf.keys.subList(mid, leaf.keys.size()).clear();
    leaf.values.subList(mid, leaf.values.size()).clear();
    
    // 将新叶子节点的第一个key提升到父节点
    K midKey = newLeaf.keys.get(0);
    insertIntoParent(leaf, midKey, newLeaf);
}

/**
 * 将分裂产生的新节点插入父节点
 * 如果父节点也溢出,递归分裂
 */
private void insertIntoParent(Node leftChild, K key, Node rightChild) {
    // 如果左子节点是根节点,需要创建新根
    if (leftChild == root) {
        InternalNode newRoot = new InternalNode();
        newRoot.keys.add(key);
        newRoot.children.add(leftChild);
        newRoot.children.add(rightChild);
        leftChild.parent = newRoot;
        rightChild.parent = newRoot;
        root = newRoot;
        height++;
        return;
    }
    
    // 获取父节点
    InternalNode parent = (InternalNode) leftChild.parent;
    
    // 找到左子节点在父节点中的位置
    int childIndex = parent.children.indexOf(leftChild);
    
    // 在父节点中插入key和右子节点
    parent.keys.add(childIndex, key);
    parent.children.add(childIndex + 1, rightChild);
    rightChild.parent = parent;
    
    // 检查父节点是否溢出
    if (parent.keys.size() > ORDER - 1) {
        splitInternal(parent);
    }
}

/**
 * 分裂内部节点
 * 与叶子节点分裂类似,但不需要维护value和链表
 */
private void splitInternal(InternalNode node) {
    int mid = node.keys.size() / 2;
    
    // 被提升到父节点的key(中间位置)
    K midKey = node.keys.get(mid);
    
    // 创建新内部节点
    InternalNode newNode = new InternalNode();
    
    // 新节点存储后半部分(不包含midKey)
    newNode.keys.addAll(node.keys.subList(mid + 1, node.keys.size()));
    newNode.children.addAll(node.children.subList(mid + 1, node.children.size()));
    
    // 更新子节点的父指针
    for (Node child : newNode.children) {
        child.parent = newNode;
    }
    
    // 原节点保留前半部分(不包含midKey)
    node.keys.subList(mid, node.keys.size()).clear();
    node.children.subList(mid + 1, node.children.size()).clear();
    
    // 将midKey提升到父节点
    insertIntoParent(node, midKey, newNode);
}

4. 删除操作实现

java 复制代码
/**
 * B+树删除操作
 * 时间复杂度:O(log_m n)
 * 
 * 算法流程:
 * 1. 找到包含key的叶子节点
 * 2. 删除key和value
 * 3. 如果叶子节点下溢,借用或合并
 * 4. 如果合并传播到根节点,可能降低树高
 */
public boolean delete(K key) {
    if (root == null || key == null) {
        return false;
    }
    
    // 1. 找到包含该key的叶子节点
    LeafNode leaf = findLeafNode(key);
    
    // 2. 在叶子节点中查找并删除
    int index = Collections.binarySearch(leaf.keys, key);
    if (index < 0) {
        return false; // key不存在
    }
    
    leaf.keys.remove(index);
    leaf.values.remove(index);
    size--;
    
    // 3. 如果是根节点且为空,树变空
    if (leaf == root && leaf.keys.isEmpty()) {
        root = null;
        head = null;
        height = 0;
        return true;
    }
    
    // 4. 检查是否需要处理下溢
    // 根节点至少可以有1个key,其他节点至少MIN_KEYS个
    if (leaf != root && leaf.keys.size() < MIN_KEYS) {
        handleLeafUnderflow(leaf);
    }
    
    return true;
}

/**
 * 处理叶子节点下溢
 * 策略:先借用,后合并
 */
private void handleLeafUnderflow(LeafNode node) {
    // 尝试从左兄弟借用
    LeafNode leftSibling = node.getLeftSibling();
    if (leftSibling != null && leftSibling.canLend()) {
        borrowFromLeftLeaf(node, leftSibling);
        return;
    }
    
    // 尝试从右兄弟借用
    LeafNode rightSibling = node.getRightSibling();
    if (rightSibling != null && rightSibling.canLend()) {
        borrowFromRightLeaf(node, rightSibling);
        return;
    }
    
    // 无法借用,需要合并
    if (leftSibling != null) {
        mergeLeafLeft(node, leftSibling);
    } else if (rightSibling != null) {
        mergeLeafRight(node, rightSibling);
    }
}

/**
 * 从左兄弟叶子节点借用最大的key
 */
private void borrowFromLeftLeaf(LeafNode node, LeafNode leftSibling) {
    // 从左兄弟取出最大的key和value
    int borrowIndex = leftSibling.keys.size() - 1;
    K borrowedKey = leftSibling.keys.remove(borrowIndex);
    V borrowedValue = leftSibling.values.remove(borrowIndex);
    
    // 插入到当前节点开头
    node.keys.add(0, borrowedKey);
    node.values.add(0, borrowedValue);
    
    // 更新父节点中的分隔key
    // 父节点中分隔leftSibling和node的key应该更新为node现在的第一个key
    InternalNode parent = (InternalNode) node.parent;
    int nodeIndex = parent.children.indexOf(node);
    if (nodeIndex > 0) {
        parent.keys.set(nodeIndex - 1, node.keys.get(0));
    }
}

/**
 * 从右兄弟叶子节点借用最小的key
 */
private void borrowFromRightLeaf(LeafNode node, LeafNode rightSibling) {
    // 从右兄弟取出最小的key和value
    K borrowedKey = rightSibling.keys.remove(0);
    V borrowedValue = rightSibling.values.remove(0);
    
    // 插入到当前节点末尾
    node.keys.add(borrowedKey);
    node.values.add(borrowedValue);
    
    // 更新父节点中的分隔key
    // 父节点中分隔node和rightSibling的key应该更新为rightSibling现在的第一个key
    InternalNode parent = (InternalNode) node.parent;
    int nodeIndex = parent.children.indexOf(node);
    if (nodeIndex < parent.keys.size()) {
        parent.keys.set(nodeIndex, rightSibling.keys.get(0));
    }
}

/**
 * 与左兄弟叶子节点合并
 */
private void mergeLeafLeft(LeafNode node, LeafNode leftSibling) {
    // 将当前节点所有内容移到左兄弟
    leftSibling.keys.addAll(node.keys);
    leftSibling.values.addAll(node.values);
    
    // 维护链表
    leftSibling.next = node.next;
    if (node.next != null) {
        node.next.prev = leftSibling;
    }
    
    // 从父节点中删除对node的引用
    InternalNode parent = (InternalNode) node.parent;
    int nodeIndex = parent.children.indexOf(node);
    parent.children.remove(nodeIndex);
    
    // 删除父节点中对应的分隔key
    if (nodeIndex > 0) {
        parent.keys.remove(nodeIndex - 1);
    } else {
        parent.keys.remove(0);
    }
    
    // 检查父节点是否下溢
    if (parent != root && parent.keys.size() < MIN_KEYS) {
        handleInternalUnderflow(parent);
    } else if (parent == root && parent.keys.isEmpty()) {
        // 根节点为空,降低树高
        root = leftSibling;
        root.parent = null;
        height--;
    }
}

/**
 * 与右兄弟叶子节点合并
 */
private void mergeLeafRight(LeafNode node, LeafNode rightSibling) {
    // 将右兄弟所有内容移到当前节点
    node.keys.addAll(rightSibling.keys);
    node.values.addAll(rightSibling.values);
    
    // 维护链表
    node.next = rightSibling.next;
    if (rightSibling.next != null) {
        rightSibling.next.prev = node;
    }
    
    // 从父节点中删除对rightSibling的引用
    InternalNode parent = (InternalNode) node.parent;
    int rightIndex = parent.children.indexOf(rightSibling);
    parent.children.remove(rightIndex);
    
    // 删除父节点中对应的分隔key
    if (rightIndex > 0) {
        parent.keys.remove(rightIndex - 1);
    } else {
        parent.keys.remove(0);
    }
    
    // 检查父节点是否下溢
    if (parent != root && parent.keys.size() < MIN_KEYS) {
        handleInternalUnderflow(parent);
    } else if (parent == root && parent.keys.isEmpty()) {
        root = node;
        root.parent = null;
        height--;
    }
}

/**
 * 处理内部节点下溢
 * 策略与叶子节点类似:借用或合并
 */
private void handleInternalUnderflow(InternalNode node) {
    if (node == root) {
        if (node.keys.isEmpty() && !node.children.isEmpty()) {
            root = node.children.get(0);
            root.parent = null;
            height--;
        }
        return;
    }
    
    InternalNode parent = (InternalNode) node.parent;
    int index = parent.children.indexOf(node);
    
    // 尝试从左兄弟借用
    if (index > 0) {
        InternalNode leftSibling = (InternalNode) parent.children.get(index - 1);
        if (leftSibling.canLend()) {
            borrowFromLeftInternal(node, leftSibling, parent, index);
            return;
        }
    }
    
    // 尝试从右兄弟借用
    if (index < parent.children.size() - 1) {
        InternalNode rightSibling = (InternalNode) parent.children.get(index + 1);
        if (rightSibling.canLend()) {
            borrowFromRightInternal(node, rightSibling, parent, index);
            return;
        }
    }
    
    // 合并
    if (index > 0) {
        mergeInternalLeft(node, (InternalNode) parent.children.get(index - 1), parent, index);
    } else {
        mergeInternalRight(node, (InternalNode) parent.children.get(index + 1), parent, index);
    }
}

/**
 * 从右兄弟内部节点借用
 * 注意:内部节点借用需要包含父节点的分隔key
 */
private void borrowFromRightInternal(InternalNode node, InternalNode rightSibling, 
                                     InternalNode parent, int index) {
    // 父节点的分隔key下移
    K parentKey = parent.keys.get(index);
    node.keys.add(parentKey);
    
    // 右兄弟的第一个子节点移到当前节点
    Node borrowedChild = rightSibling.children.remove(0);
    node.children.add(borrowedChild);
    borrowedChild.parent = node;
    
    // 右兄弟的第一个key上移到父节点
    K upKey = rightSibling.keys.remove(0);
    parent.keys.set(index, upKey);
}

/**
 * 与右兄弟内部节点合并
 */
private void mergeInternalRight(InternalNode node, InternalNode rightSibling,
                                InternalNode parent, int index) {
    // 父节点的分隔key下移
    K parentKey = parent.keys.remove(index);
    node.keys.add(parentKey);
    
    // 合并右兄弟的所有key和子节点
    node.keys.addAll(rightSibling.keys);
    node.children.addAll(rightSibling.children);
    for (Node child : rightSibling.children) {
        child.parent = node;
    }
    
    // 从父节点删除右兄弟
    parent.children.remove(index + 1);
    
    // 检查父节点
    if (parent != root && parent.keys.size() < MIN_KEYS) {
        handleInternalUnderflow(parent);
    } else if (parent == root && parent.keys.isEmpty()) {
        root = node;
        root.parent = null;
        height--;
    }
}

// 为简洁省略borrowFromLeftInternal和mergeInternalLeft
// 原理与右侧对称

5. 插入过程可视化追踪

复制代码
以ORDER=4(最多3个key)为例,演示插入过程:

初始状态:空树

Step 1:插入10
[10]  <-- 叶子节点(也是根)

Step 2:插入20
[10, 20]

Step 3:插入5
[5, 10, 20]

Step 4:插入30(需要分裂)
分裂前:[5, 10, 20, 30]  -- 超过ORDER-1=3,溢出!

分裂过程:
- mid = 4 / 2 = 2
- 原节点保留前2个:[5, 10]
- 新节点存储后2个:[20, 30]
- 提升新节点的第一个key(20)到父节点

    [20]
   /    \
[5,10] [20,30]

树高变为2

Step 5:插入15
查找路径:15 < 20 → 左子树
左叶子:[5, 10, 15](3个key,刚好满)

    [20]
   /    \
[5,10,15] [20,30]

Step 6:插入25
查找路径:25 > 20 → 右子树
右叶子:[20, 25, 30](3个key,刚好满)

    [20]
   /    \
[5,10,15] [20,25,30]

Step 7:插入1(左叶子分裂)
左叶子插入1:[1, 5, 10, 15] → 溢出!

分裂:
- 原节点:[1, 5]
- 新节点:[10, 15]
- 提升10到父节点

      [10, 20]
     /    |    \
  [1,5] [10,15] [20,25,30]

Step 8:插入35(右叶子分裂)
右叶子插入35:[20, 25, 30, 35] → 溢出!

分裂:
- 原节点:[20, 25]
- 新节点:[30, 35]
- 提升30到父节点

      [10, 20, 30]
     /    |     |    \
  [1,5] [10,15] [20,25] [30,35]

父节点现在有3个key,达到ORDER-1,已满但未溢出

Step 9:插入40
查找路径:40 > 30 → 最右子树
最右叶子:[30, 35, 40](3个key,刚好满)

      [10, 20, 30]
     /    |     |    \
  [1,5] [10,15] [20,25] [30,35,40]

Step 10:插入45(引发级联分裂)
最右叶子:[30, 35, 40, 45] → 溢出!

分裂叶子:
- 原节点:[30, 35]
- 新节点:[40, 45]
- 提升40到父节点

父节点接收40:[10, 20, 30, 40] → 溢出!

分裂父节点:
- 原父节点:[10, 20]
- 新父节点:[40]
- 提升30到更高层(创建新根)

           [30]
          /    \
      [10,20]  [40]
      /   |    /    \
  [1,5][10,15][20,25][30,35][40,45]

等等,这里有个问题。分裂父节点时,子节点如何分配?

重新梳理:
原父节点keys:[10, 20, 30, 40](4个key,溢出)
原父节点children:[node0, node1, node2, node3, node4](5个子节点)

分裂点mid = 4 / 2 = 2
- midKey = keys[2] = 30
- 左半部分keys:[10, 20],children:[node0, node1, node2]
- 右半部分keys:[40],children:[node3, node4]

新根节点:[30]
左子树:[10, 20],包含[node0, node1, node2]
右子树:[40],包含[node3, node4]

最终结果:

          [30]
         /    \
    [10, 20]  [40]
    /   |   \  /   \
 [1,5][10,15][20,25][30,35][40,45]

树高变为3!

InnoDB索引实现深度剖析

1. InnoDB页结构详解

复制代码
InnoDB存储引擎以页(Page)为单位管理磁盘空间
默认页大小:16KB(可配置为8KB、32KB、64KB)

页的结构:
+-------------------+  <-- 偏移量0
| File Header (38B) |  <-- 文件头,包含页号、上一页、下一页、页类型等
+-------------------+
| Page Header (56B) |  <-- 页头,包含记录数、空闲空间指针等
+-------------------+
| Infimum Record    |  <-- 虚拟最小记录(比任何记录都小)
+-------------------+
| User Records      |  <-- 实际的用户数据记录(按主键有序排列)
| ...               |
+-------------------+
| Free Space        |  <-- 空闲空间
+-------------------+
| Page Directory    |  <-- 页目录(稀疏索引,用于页内二分查找)
+-------------------+
| File Trailer (8B) |  <-- 文件尾,校验和
+-------------------+  <-- 偏移量16383(16KB)

关键字段说明:
- File Header:
  * FIL_PAGE_SPACE: 表空间ID
  * FIL_PAGE_OFFSET: 页号(32位,最大64TB表空间)
  * FIL_PAGE_PREV: 上一页指针(B+树叶子链表)
  * FIL_PAGE_NEXT: 下一页指针
  * FIL_PAGE_TYPE: 页类型(数据页、索引页、Undo页等)

- Page Header:
  * PAGE_N_DIR_SLOTS: 页目录槽数量
  * PAGE_HEAP_TOP: 堆顶指针(User Records顶部)
  * PAGE_N_HEAP: 记录数
  * PAGE_FREE: 删除记录链表头
  * PAGE_GARBAGE: 已删除记录总字节数
  * PAGE_LAST_INSERT: 最后插入位置
  * PAGE_DIRECTION: 最后插入方向(优化顺序插入)
  * PAGE_N_DIRECTION: 同一方向连续插入次数
  * PAGE_N_RECS: 实际记录数

- Page Directory(页目录):
  * 将记录分组,每组4-8条记录(默认8条)
  * 记录每组的最后一条记录的偏移量(槽)
  * 页内查找时先二分查找槽,再线性查找组内
  * 时间复杂度:O(log(n/8) + 8) ≈ O(log n)

2. 记录格式(Row Format)

复制代码
InnoDB记录格式(Compact/Redundant/Dynamic/Compressed):

Compact格式(MySQL 5.1+默认):
+-------------------+
| 变长字段长度列表   |  <-- 逆序存储,每个变长字段占1-2字节
+-------------------+
| NULL值列表        |  <-- 位图,每个可为NULL的字段占1位
+-------------------+
| 记录头信息 (5B)   |  <-- 删除标记、最小记录标记、记录类型等
+-------------------+
| 列1数据           |
| 列2数据           |
| ...               |
+-------------------+
| 隐藏列:row_id    |  <-- 无主键时自动生成(6字节)
| 隐藏列:trx_id    |  <-- 事务ID(6字节)
| 隐藏列:roll_ptr  |  <-- 回滚指针(7字节)
+-------------------+

记录头信息(5字节):
- 删除标记(1bit)
- 最小记录标记(1bit)
- 记录类型(4bit):0=普通记录,1=B+树节点指针,2=Infimum,3=Supremum
- 下一记录偏移量(16bit):形成记录链表
- 当前记录组记录数(4bit)

变长字段长度列表:
- 字段长度 < 255字节:1字节存储
- 字段长度 >= 255字节:2字节存储
- NULL值不占用数据空间(只在NULL值列表标记)

3. 聚簇索引(Clustered Index)

复制代码
聚簇索引定义:
- InnoDB表的主键索引就是聚簇索引
- 叶子节点存储完整的行数据(所有列)
- 表数据按主键顺序物理存储
- 一个表只能有一个聚簇索引

聚簇索引结构:

        [10, 30, 50]              <-- 非叶子节点(只存主键值)
       /     |      \
    [5,10]  [20,30]  [40,50]      <-- 叶子节点(数据页)
    数据页   数据页   数据页
    (整行)  (整行)  (整行)

特点:
1. 数据即索引,索引即数据
2. 主键查询最快(只需遍历索引)
3. 顺序插入性能最优(减少页分裂)
4. 更新主键代价大(需要移动数据行)

主键选择策略:
- 优先使用自增整数(BIGINT AUTO_INCREMENT)
  * 顺序插入,减少页分裂
  * 占用空间小(8字节)
  * 比较速度快
- 避免使用UUID
  * 随机插入导致频繁页分裂
  * 占用空间大(16字节)
  * 主键值无序,页利用率低

无主键时的处理:
1. 选择第一个非空唯一索引作为主键
2. 如果没有唯一索引,隐式创建6字节的row_id
3. row_id全局自增,但用户不可见

4. 二级索引(Secondary Index)

复制代码
二级索引定义:
- 非主键索引
- 叶子节点存储:索引列值 + 主键值
- 不存储完整行数据

二级索引结构:

        ["Alice", "Bob", "Charlie"]    <-- 非叶子节点(存索引列值)
       /            |             \
    [索引页]      [索引页]       [索引页]   <-- 叶子节点
    (name, id)   (name, id)    (name, id)
                  ↓
              回表到聚簇索引
                  ↓
            根据id查找完整数据

回表(Bookmark Lookup)过程:
1. 在二级索引中查找到匹配的索引列值
2. 获取对应的主键id
3. 拿着id去聚簇索引查完整数据行

代价分析:
- 一次二级索引查询 = 1次二级索引树遍历 + 1次聚簇索引树遍历
- 如果二级索引返回N条记录,可能需要N次回表
- N很大时,回表开销巨大,可能触发全表扫描

5. 联合索引与最左前缀原则

复制代码
联合索引定义:
- 多个列组成的索引
- 列顺序至关重要
- 先按第一列排序,第一列相同再按第二列排序,依此类推

示例:
CREATE INDEX idx_name_age ON user(name, age);

索引结构:

        [("Alice", 20), ("Bob", 25), ("Charlie", 30)]
       /                    |                      \
    [叶子节点]            [叶子节点]             [叶子节点]
    (name, age, id)      (name, age, id)       (name, age, id)

先按name排序,name相同再按age排序

最左前缀原则:
查询条件必须从联合索引的最左边开始匹配,才能使用索引

可以用索引的情况:
-- 使用第1列
WHERE name = 'Alice'

-- 使用第1、2列
WHERE name = 'Alice' AND age = 20

-- 使用第1列(第2列用于排序)
WHERE name = 'Alice' ORDER BY age

-- 使用第1列(第3列不能使用,但第2列的范围查询后第3列也不能用)
WHERE name = 'Alice' AND age > 20

不能用索引的情况:
-- 缺少最左列
WHERE age = 20

-- 跳过中间列
WHERE name = 'Alice' AND gender = 'M'

-- 范围查询后的列
-- 索引(name, age, gender)
WHERE name = 'Alice' AND age > 20 AND gender = 'M'
-- gender列无法使用索引(因为age是范围查询)

原理:
B+树按(name, age)排序,没有name条件就无法定位到某个分支
age是范围查询时,gender在age相同的范围内有序,但age不同则无序

6. 覆盖索引(Covering Index)

复制代码
覆盖索引定义:
- 查询的所有字段都在索引中,不需要回表
- 二级索引的叶子节点存储(索引列 + 主键)

覆盖索引示例:
-- 索引:idx_name_age(name, age)

-- 查询1:覆盖索引(只需要name和age)
SELECT name, age FROM user WHERE name = 'Alice';
-- 不需要回表,只读索引页

-- 查询2:覆盖索引(name、age、id都在索引中)
SELECT id, name, age FROM user WHERE name = 'Alice';
-- 二级索引叶子节点包含主键id,不需要回表

-- 查询3:不覆盖(需要gender,不在索引中)
SELECT name, age, gender FROM user WHERE name = 'Alice';
-- 需要回表到聚簇索引查gender

覆盖索引的优势:
1. 减少IO:不需要回表,减少50%以上的磁盘IO
2. 减少数据访问量:只读索引页,不读数据页
3. 顺序IO:索引是有序的,可以顺序读取
4. 减少CPU:不需要根据主键再次查找

如何设计覆盖索引:
-- 常见查询是SELECT id, name, age WHERE name = ?
-- 可以建覆盖索引:
CREATE INDEX idx_cover ON user(name, age);
-- 这样(name, age)查询完全覆盖

7. 索引下推(Index Condition Pushdown, ICP)

复制代码
ICP定义:
- MySQL 5.6引入的优化
- 将WHERE条件下推到存储引擎层,在索引遍历过程中过滤
- 减少回表次数

无ICP的执行流程:
-- 索引:idx_name_age(name, age)
SELECT * FROM user WHERE name LIKE 'A%' AND age = 20;

1. 在存储引擎层:找到所有name以'A'开头的记录
2. 回表到Server层:返回完整数据
3. 在Server层:过滤age=20的记录

问题:大量不需要的记录被回表,浪费IO

有ICP的执行流程:
1. 在存储引擎层:找到name以'A'开头的记录
2. **在存储引擎层**:检查age=20,不满足则不返回
3. 只回表符合条件的记录

效果:
- 减少回表次数30%-50%
- 减少Server层的数据处理量
- 性能提升显著(特别是大表)

ICP的使用条件:
- 索引是二级索引(主键索引不需要ICP)
- 查询使用索引范围扫描
- 剩余WHERE条件可以在索引列上评估

查看ICP是否使用:
EXPLAIN SELECT * FROM user WHERE name LIKE 'A%' AND age = 20;
-- Extra列显示"Using index condition"表示使用了ICP

8. MRR(Multi-Range Read)优化

复制代码
MRR定义:
- MySQL 5.6+引入
- 优化二级索引范围查询的回表过程
- 将随机IO转化为顺序IO

无MRR的执行流程:
SELECT * FROM user WHERE name BETWEEN 'A' AND 'C';

1. 在二级索引中找到name='A', 'AA', 'AB', ...的所有记录
2. 按索引顺序回表:
   - id=100 → 回表
   - id=5 → 回表(随机IO!)
   - id=200 → 回表(随机IO!)
   - ...

问题:二级索引按name排序,但id是无序的,回表是随机IO

有MRR的执行流程:
1. 在二级索引中收集所有需要回表的主键id
2. 将id排序
3. 按id顺序批量回表(顺序IO!)
4. 最后按name排序返回结果

效果:
- 将随机IO转化为顺序IO
- 减少磁盘寻道时间
- 提升范围查询性能20%-50%

开启MRR:
SET optimizer_switch='mrr=on,mrr_cost_based=off';

索引优化实战案例

案例1:用户表索引设计

sql 复制代码
-- 表结构
CREATE TABLE user (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    phone VARCHAR(20),
    age INT,
    gender TINYINT COMMENT '0:女, 1:男',
    status TINYINT DEFAULT 1 COMMENT '0:禁用, 1:正常',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_username (username),
    INDEX idx_email (email),
    INDEX idx_phone (phone),
    INDEX idx_status_create_time (status, create_time)
) ENGINE=InnoDB;

-- 问题分析:
-- 1. username、email、phone都是唯一或高区分度字段,适合单独索引
-- 2. status区分度低(只有2个值),单独建索引效果差
-- 3. (status, create_time)联合索引可以覆盖按状态和时间排序的查询

-- 优化建议:
-- 删除低区分度索引
DROP INDEX idx_status ON user;

-- 添加覆盖索引
-- 常见查询:SELECT id, username, email WHERE username = ?
-- 已有idx_username,但(username)本身不包含email
-- 如果查询频率高,可以考虑:
-- CREATE INDEX idx_username_cover ON user(username, email);
-- 但需要权衡索引大小和写入性能

案例2:订单表索引优化

sql 复制代码
-- 表结构
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    order_no VARCHAR(32) NOT NULL,
    status TINYINT NOT NULL COMMENT '0:待支付, 1:已支付, 2:已发货, 3:已完成, 4:已取消',
    amount DECIMAL(10,2) NOT NULL,
    create_time DATETIME NOT NULL,
    pay_time DATETIME,
    INDEX idx_user_id (user_id),
    INDEX idx_order_no (order_no),
    INDEX idx_status (status),
    INDEX idx_create_time (create_time)
) ENGINE=InnoDB;

-- 问题分析:
-- 1. user_id查询:用户查询自己的订单列表
-- 2. order_no查询:根据订单号查订单详情
-- 3. status查询:按状态统计(区分度低,可能全表扫描)
-- 4. create_time查询:按时间范围查询

-- 优化方案:
-- 1. 用户订单列表查询:SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC
-- 优化:(user_id, create_time)联合索引,避免排序和回表
CREATE INDEX idx_user_time ON orders(user_id, create_time);

-- 2. 用户待支付订单:SELECT * FROM orders WHERE user_id = ? AND status = 0
-- 优化:(user_id, status, create_time)可以覆盖
-- 但索引过大,权衡后建议使用(user_id, status)
CREATE INDEX idx_user_status ON orders(user_id, status);

-- 3. 订单号查询:SELECT * FROM orders WHERE order_no = ?
-- order_no是唯一的,可以改为唯一索引
ALTER TABLE orders DROP INDEX idx_order_no,
ADD UNIQUE INDEX uk_order_no (order_no);

-- 4. 按时间统计:SELECT COUNT(*) FROM orders WHERE create_time BETWEEN ? AND ?
-- create_time索引可以支持,但如果数据量大,考虑分区表

-- 删除冗余索引
DROP INDEX idx_status;
DROP INDEX idx_create_time;

-- 最终索引方案:
-- PRIMARY KEY (id)
-- UNIQUE KEY uk_order_no (order_no)
-- KEY idx_user_time (user_id, create_time)
-- KEY idx_user_status (user_id, status)

案例3:索引失效分析与优化

sql 复制代码
-- 表结构
CREATE TABLE article (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    content TEXT,
    author_id BIGINT NOT NULL,
    category_id INT NOT NULL,
    status TINYINT DEFAULT 1,
    publish_time DATETIME,
    INDEX idx_author (author_id),
    INDEX idx_category (category_id),
    INDEX idx_publish_time (publish_time),
    INDEX idx_category_status (category_id, status)
) ENGINE=InnoDB;

-- 场景1:函数操作导致索引失效
-- 问题SQL:
SELECT * FROM article WHERE YEAR(publish_time) = 2024;
-- 索引失效!对列做了函数操作

-- 优化方案:
SELECT * FROM article 
WHERE publish_time >= '2024-01-01' 
AND publish_time < '2025-01-01';
-- 使用范围查询,索引生效

-- 场景2:隐式类型转换
-- 问题SQL:
SELECT * FROM article WHERE author_id = '12345';
-- author_id是BIGINT,与字符串比较会隐式转换,索引失效

-- 优化方案:
SELECT * FROM article WHERE author_id = 12345;
-- 使用正确的类型

-- 场景3:LIKE以%开头
-- 问题SQL:
SELECT * FROM article WHERE title LIKE '%MySQL%';
-- 索引失效,无法使用B+树有序性

-- 优化方案:
-- 方案A:使用全文索引(MySQL 5.6+)
ALTER TABLE article ADD FULLTEXT INDEX ft_title (title);
SELECT * FROM article WHERE MATCH(title) AGAINST('MySQL');

-- 方案B:使用搜索引擎(Elasticsearch)

-- 场景4:OR条件导致索引失效
-- 问题SQL:
SELECT * FROM article 
WHERE author_id = 100 OR category_id = 5;
-- 如果category_id没索引,可能全表扫描

-- 优化方案:
SELECT * FROM article WHERE author_id = 100
UNION ALL
SELECT * FROM article WHERE category_id = 5;
-- 分别走两个索引,然后合并结果

-- 场景5:不满足最左前缀
-- 问题SQL:
SELECT * FROM article WHERE status = 1;
-- 索引是(category_id, status),缺少最左列category_id

-- 优化方案:
-- 单独建status索引(但区分度低,可能没必要)
-- 或者根据实际查询调整索引顺序

-- 场景6:索引列参与计算
-- 问题SQL:
SELECT * FROM article WHERE id + 1 = 100;
-- 索引失效

-- 优化方案:
SELECT * FROM article WHERE id = 99;

案例4:大表分页优化

sql 复制代码
-- 问题:深度分页性能差
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- 需要扫描1000010条记录,丢弃前1000000条,性能极差

-- 优化方案1:使用覆盖索引+子查询
SELECT * FROM orders 
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 1000000, 1)
ORDER BY id LIMIT 10;
-- 子查询只查id,走覆盖索引
-- 外层查询利用id主键快速定位

-- 优化方案2:使用游标/书签
-- 上一页最后一条记录的id是last_id
SELECT * FROM orders 
WHERE id > last_id 
ORDER BY id LIMIT 10;
-- 直接定位,性能与页码无关

-- 优化方案3:反范式化,记录分页位置
-- 创建分页辅助表
CREATE TABLE page_marker (
    page_no INT PRIMARY KEY,
    start_id BIGINT NOT NULL,
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 预计算每页的起始id

索引算法对比分析

1. B+树 vs B树 vs 哈希 vs LSM树

复制代码
对比维度分析:

| 特性 | B+树 | B树 | 哈希索引 | LSM树 |
|------|------|-----|---------|-------|
| 等值查询 | O(log n) | O(log n) | O(1) | O(log n) |
| 范围查询 | O(log n + k) | O(log n + k) | 不支持 | O(log n + k) |
| 排序支持 | 天然支持 | 天然支持 | 不支持 | 需额外排序 |
| 插入性能 | O(log n),可能分裂 | O(log n),可能分裂 | O(1) | O(1)(内存) |
| 删除性能 | O(log n) | O(log n) | O(1) | O(1)(标记) |
| 空间利用率 | ≥50% | ≥50% | 50%-70% | 高(批量合并) |
| 磁盘友好 | 是(页对齐) | 是(页对齐) | 否(随机访问) | 是(顺序写) |
| 并发控制 | 较复杂(页锁) | 较复杂 | 简单 | 简单(批量) |
| 适用场景 | 通用OLTP | 文件系统 | 缓存、等值查询 | 写密集型、日志 |
| 代表产品 | MySQL InnoDB | MongoDB(旧) | Redis、Memcache | RocksDB、HBase |

关键差异详解:

1. B+树 vs B树:
   - B+树非叶子节点不存数据,扇出更大
   - B+树叶子节点链表连接,范围查询更高效
   - B树查询可能提前终止(非叶子命中),路径长度不稳定
   - B+树所有查询路径长度相同(必到叶子),性能稳定

2. B+树 vs 哈希:
   - 哈希等值查询更快,但不支持范围和排序
   - 哈希冲突处理复杂(链地址法、开放寻址)
   - 哈希扩容成本高(rehash)
   - 数据库需要通用索引结构,B+树更全面

3. B+树 vs LSM树:
   - LSM树写放大低(顺序写),适合写密集型
   - B+树读性能更稳定(没有多版本合并)
   - LSM树适合时序数据、日志场景
   - B+树适合OLTP事务型业务

2. B-link树:高并发优化

复制代码
B-link树改进:
- 兄弟节点之间增加横向指针
- 支持并发操作时的"右移"策略
- 删除时不需要立即合并,可以延迟

结构:
        [20, 40]
       /   |   \
    [5,10]-->[15,20]-->[25,30]-->[35,40]-->[45,50]
    
并发优势:
- 读操作不需要加锁(MVCC)
- 写操作只锁单个节点
- 分裂时不需要锁父节点(原子操作)

代表实现:PostgreSQL的B-tree索引

3. Fractal树:写优化

复制代码
Fractal树(Tokutek/TokuDB):
- 缓冲更新,批量I/O
- 消息缓冲区(Message Buffer)
- 减少随机写,提升写入性能

结构特点:
- 每个节点包含一个缓冲区
- 插入/删除/更新先写入缓冲区
- 缓冲区满时批量下推到子节点
- 减少磁盘随机写次数

适用场景:
- 写密集型应用
- 大数据量(TB级)
- 需要高压缩率

缺点:
- 读性能略低于B+树
- 实现复杂度高
- 社区支持不如InnoDB

性能分析与基准测试

1. 理论性能分析

复制代码
时间复杂度分析:

查找操作:
- 最佳情况:O(log_m n)(树高)
- 最坏情况:O(log_m n)(必到叶子)
- 平均情况:O(log_m n)
- 注意:每层内部用二分查找,O(log m),但由于m是常数,通常忽略

插入操作:
- 最佳情况:O(log_m n)(不分裂)
- 最坏情况:O(m × log_m n)(从叶子到根全分裂)
- 平均情况:O(log_m n)(分裂概率低)

删除操作:
- 最佳情况:O(log_m n)(不借用/合并)
- 最坏情况:O(m × log_m n)(从叶子到根全合并)
- 平均情况:O(log_m n)

范围查询:
- O(log_m n + k),k为结果集大小
- 找到起始位置:O(log_m n)
- 顺序遍历:O(k)

空间复杂度:
- O(N × (key_size + value_size + pointer_overhead))
- 每个记录只存储一次(在叶子节点)
- 非叶子节点额外开销:通常 < 1%总空间

2. InnoDB索引实际性能数据

复制代码
测试环境:
- CPU: Intel Xeon E5-2680 v4
- 内存: 64GB DDR4
- 磁盘: NVMe SSD (Samsung PM1725a)
- MySQL: 8.0.32
- 数据量: 1000万条记录
- 表结构: id BIGINT PK, name VARCHAR(50), age INT, INDEX idx_name(name)

性能测试结果:

| 操作类型 | 平均耗时 | 磁盘IO次数 | 说明 |
|---------|---------|-----------|------|
| 主键等值查询 | 0.2ms | 2-3次 | 聚簇索引,无需回表 |
| 二级索引等值查询+回表 | 0.5ms | 4-6次 | 二级索引+聚簇索引 |
| 覆盖索引等值查询 | 0.3ms | 2-3次 | 只读索引页 |
| 范围查询(100条) | 1.2ms | 3-5次 | 顺序读取叶子链表 |
| 范围查询+回表(100条) | 3.5ms | 8-12次 | 随机回表IO |
| 插入(无分裂) | 0.3ms | 3-4次 | 写数据页+更新索引 |
| 插入(页分裂) | 2.5ms | 6-10次 | 分裂+重平衡 |
| 顺序批量插入 | 5000条/s | - | 顺序写,预读友好 |
| 随机批量插入 | 2000条/s | - | 随机写,频繁分裂 |

影响因素:
1. 树高:每增加一层,延迟增加约0.1ms(SSD)
2. 页分裂:分裂操作耗时是普通插入的5-8倍
3. 回表:每次回表增加2-3次IO
4. 缓存命中率:Buffer Pool命中率>95%时,性能提升10倍

3. 索引设计对性能的影响

复制代码
场景对比:1000万用户表,查询活跃用户(status=1)

方案A:无索引
SELECT * FROM user WHERE status = 1;
- 全表扫描
- 耗时:2-5秒
- IO:100万+次

方案B:status单独索引
CREATE INDEX idx_status ON user(status);
- 索引选择性差(50%数据命中)
- MySQL可能选择全表扫描
- 耗时:2-5秒(甚至更慢,因为还要读索引)

方案C:(status, create_time)联合索引
CREATE INDEX idx_status_time ON user(status, create_time);
SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 10;
- 索引覆盖WHERE和ORDER BY
- 只需读取索引的前10条
- 耗时:0.5ms
- IO:2-3次

方案D:覆盖索引
CREATE INDEX idx_status_cover ON user(status, create_time, id, username);
SELECT id, username FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 10;
- 完全覆盖查询字段,无需回表
- 耗时:0.3ms
- IO:2-3次

性能差距:方案D比方案A快10000倍!

常见陷阱与最佳实践

陷阱1:在区分度低的列上建索引

复制代码
问题:
- gender(性别)、status(状态)等只有2-3个取值的列
- 索引选择性(Cardinality / Row Count)接近0
- MySQL优化器认为全表扫描更快,放弃使用索引

示例:
-- 表有1000万行,status只有2个值
SELECT * FROM orders WHERE status = 1;
-- 可能全表扫描,索引失效

解决方案:
-- 方案1:使用联合索引提高区分度
CREATE INDEX idx_status_time ON orders(status, create_time);
-- status=1的数据按create_time排序,联合索引可以精确定位小范围

-- 方案2:如果必须使用status单独过滤,确保结果集很小(<20%)
SELECT * FROM orders WHERE status = 1 LIMIT 100;
-- 小结果集时优化器可能选择索引

-- 方案3:使用分区表
CREATE TABLE orders (...) PARTITION BY LIST(status) (...);
-- 按status分区,避免全表扫描

陷阱2:忽略索引维护成本

复制代码
问题:
- 频繁UPDATE的列如果建了索引,每次更新都要维护B+树
- 索引越多,写入性能越差

示例:
-- last_login频繁更新,但有索引
UPDATE user SET last_login = NOW() WHERE id = 1;
-- 每次更新都要调整B+树索引

解决方案:
-- 方案1:删除不必要的索引
DROP INDEX idx_last_login;

-- 方案2:如果是查询需要,考虑用覆盖索引或冗余字段
-- 例如:last_login查询频率不高,可以忍受全表扫描

-- 方案3:写多读少的场景,使用LSM树存储(如TokuDB)

陷阱3:联合索引顺序错误

复制代码
问题:
- 联合索引(a, b, c),查询条件是WHERE b=2 AND c=3
- 完全用不上索引

示例:
CREATE INDEX idx_wrong ON user(age, gender, city);
-- 查询:
SELECT * FROM user WHERE gender = 'M' AND city = 'Beijing';
-- 不走索引!缺少最左列age

解决方案:
-- 方案1:根据实际查询调整索引顺序
-- 常见查询是WHERE gender = ? AND city = ?
CREATE INDEX idx_gender_city ON user(gender, city);

-- 方案2:如果age也常用,建两个索引
CREATE INDEX idx_age ON user(age);
CREATE INDEX idx_gender_city ON user(gender, city);
-- 权衡索引数量和写入性能

-- 方案3:使用索引提示(不推荐长期用)
SELECT * FROM user USE INDEX(idx_wrong) WHERE ...;

陷阱4:认为索引越多越好

复制代码
问题:
- 索引过多占用大量磁盘空间
- 每次INSERT/UPDATE/DELETE都要维护所有索引
- 增加优化器选择成本

示例:
-- 一张表建了15个索引
-- 每次INSERT需要维护15个B+树
-- 写入性能严重下降

解决方案:
-- 方案1:定期分析索引使用情况
SELECT 
    OBJECT_SCHEMA, 
    OBJECT_NAME, 
    INDEX_NAME,
    COUNT_FETCH,
    COUNT_INSERT,
    COUNT_UPDATE,
    COUNT_DELETE
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_db'
ORDER BY COUNT_FETCH DESC;

-- 方案2:删除未使用的索引
-- pt-duplicate-key-checker工具检查冗余索引

-- 方案3:索引总数控制
-- 一般单表索引不超过5-7个
-- 根据读写比例调整:读多写少可以多建,写多读少要少建

陷阱5:大偏移量分页

复制代码
问题:
- LIMIT 1000000, 10需要扫描1000010条记录
- 丢弃前1000000条,浪费大量IO

示例:
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- 慢查询!

解决方案:
-- 方案1:使用覆盖索引+子查询
SELECT * FROM orders 
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 1000000, 1)
ORDER BY id LIMIT 10;

-- 方案2:使用游标/书签
-- 上一页最后id是last_id
SELECT * FROM orders 
WHERE id > last_id 
ORDER BY id LIMIT 10;

-- 方案3:限制最大页码
-- 业务层限制只能翻到100页

-- 方案4:使用搜索引擎(Elasticsearch)做分页

陷阱6:隐式类型转换和函数操作

复制代码
问题:
- 对索引列做函数操作或隐式类型转换,导致索引失效

示例:
-- 隐式类型转换
WHERE phone = 13800138000  -- phone是VARCHAR
-- MySQL会将phone转为数字,索引失效

-- 函数操作
WHERE YEAR(create_time) = 2024
-- 对create_time做函数操作,索引失效

-- 列参与计算
WHERE id + 1 = 100
-- 索引失效

解决方案:
-- 确保类型一致
WHERE phone = '13800138000'

-- 改写范围查询
WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'

-- 将计算移到等号右侧
WHERE id = 99

最佳实践清单

复制代码
索引设计原则:
1. 选择性原则:优先在高区分度列上建索引
2. 最左前缀原则:联合索引的列顺序要根据查询模式设计
3. 覆盖索引原则:常见查询尽量使用覆盖索引
4. 少即是多原则:单表索引数控制在5-7个以内
5. 写平衡原则:频繁更新的列谨慎建索引

查询优化原则:
1. 避免SELECT *,只查需要的列
2. 避免大偏移量分页,使用游标或覆盖索引
3. 避免在WHERE中对索引列做函数操作
4. 避免隐式类型转换
5. 使用EXPLAIN分析查询计划

维护原则:
1. 定期分析表(ANALYZE TABLE)更新统计信息
2. 监控慢查询日志,优化慢SQL
3. 定期检查冗余索引并清理
4. 大表考虑分区或分表
5. 定期OPTIMIZE TABLE整理碎片(注意锁表)

面试题与参考答案

Q1:为什么InnoDB选择B+树而不是B树作为索引结构?

参考答案

InnoDB选择B+树而非B树,核心原因有四点:

1. IO次数更少(扇出更大)

  • B+树非叶子节点只存key不存数据指针,同样16KB页可以存更多key
  • 假设key为8字节,指针6字节:B+树非叶子节点可存约1170个key
  • B树每个节点还要存数据指针(6字节),只能存约819个key
  • B+树扇出大43%,树高更低,磁盘IO次数更少

2. 范围查询效率

  • B+树叶子节点通过双向链表连接,范围查询只需顺序遍历
  • B树需要中序遍历,涉及跨层访问,每次节点切换都是随机IO
  • 顺序IO吞吐量是随机IO的100倍以上

3. 查询稳定性

  • B+树所有查询都到叶子节点,路径长度相同(稳定O(log n))
  • B树可能在非叶子节点提前命中,路径长度不一致,性能不可预测

4. 全表扫描效率

  • B+树只需遍历叶子节点链表即可完成全表扫描
  • B树需要遍历整棵树(所有节点),IO次数更多

Q2:聚簇索引和非聚簇索引有什么区别?

参考答案

聚簇索引(Clustered Index)

  • 叶子节点存储完整的数据行(所有列)
  • 表数据按主键顺序物理存储
  • InnoDB的主键索引就是聚簇索引
  • 一个表只能有一个聚簇索引
  • 主键查询只需一次索引查找

非聚簇索引(Secondary Index)

  • 叶子节点存储:索引列值 + 主键值
  • 不存储完整数据行
  • 一个表可以有多个非聚簇索引
  • 查询需要"回表":先查二级索引获取主键,再查聚簇索引获取数据
  • 除非使用覆盖索引(查询字段都在索引中)

性能差异

  • 主键查询:1次索引遍历(2-3次IO)
  • 二级索引查询:2次索引遍历(4-6次IO),除非覆盖索引

Q3:联合索引的最左前缀原理是什么?

参考答案

原理

B+树按联合索引的列顺序排序。先按第一列排序,第一列相同再按第二列排序,依此类推。这种有序性决定了只有从最左边开始匹配,才能利用索引的定位能力。

示例

索引(a, b, c)的排序逻辑:

复制代码
(1, 2, 3)
(1, 2, 5)
(1, 3, 1)
(2, 1, 4)
(2, 2, 2)

能用索引的情况

  • WHERE a = 1:通过a=1定位到(1,,)的起始位置
  • WHERE a = 1 AND b = 2:先定位a=1,再在a=1范围内定位b=2
  • WHERE a = 1 ORDER BY b:a=1的数据天然按b排序

不能用索引的情况

  • WHERE b = 2:没有a条件,不知道从哪里开始找
  • WHERE a = 1 AND c = 3:a=1定位后,c在b不同值之间无序

特殊情况

  • WHERE a = 1 AND b > 2 AND c = 3:a和b能用索引,c不能用(b是范围查询后c无序)

Q4:什么情况下索引会失效?

参考答案

1. 对索引列做函数操作

sql 复制代码
WHERE YEAR(create_time) = 2024  -- 失效
WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'  -- 生效

2. 隐式类型转换

sql 复制代码
WHERE phone = 13800138000  -- phone是VARCHAR,失效
WHERE phone = '13800138000'  -- 生效

3. LIKE以%开头

sql 复制代码
WHERE name LIKE '%Alice%'  -- 失效
WHERE name LIKE 'Alice%'   -- 生效(范围查询)

4. 使用!=、<>

sql 复制代码
WHERE status != 0  -- MySQL 8.0前通常失效,8.0+可能走索引但效率低

5. OR条件

sql 复制代码
WHERE a = 1 OR b = 2  -- 如果b没索引,可能全表扫描

6. 不满足最左前缀

sql 复制代码
-- 索引(a,b,c)
WHERE b = 2  -- 失效

7. 索引列参与计算

sql 复制代码
WHERE id + 1 = 100  -- 失效
WHERE id = 99       -- 生效

8. 数据类型不匹配

sql 复制代码
WHERE int_column = '123'  -- 可能失效

Q5:覆盖索引有什么好处?什么情况下会形成覆盖索引?

参考答案

好处

  1. 减少回表IO:不需要再到聚簇索引查数据,减少50%以上的磁盘IO
  2. 减少数据访问量:只读索引页,不读数据页
  3. 避免随机IO:索引是有序的,可以顺序读取;回表通常是随机IO
  4. 减少CPU消耗:不需要根据主键再次查找

形成条件

查询的所有字段都在二级索引中。二级索引的叶子节点存储(索引列 + 主键id),因此:

sql 复制代码
-- 索引:idx_name(name)
SELECT name FROM user WHERE name = 'Alice';  -- 覆盖
SELECT id, name FROM user WHERE name = 'Alice';  -- 覆盖(id在叶子节点)
SELECT * FROM user WHERE name = 'Alice';  -- 不覆盖(需要回表)

-- 索引:idx_name_age(name, age)
SELECT name, age FROM user WHERE name = 'Alice';  -- 覆盖
SELECT id, name, age FROM user WHERE name = 'Alice';  -- 覆盖
SELECT name, age, gender FROM user WHERE name = 'Alice';  -- 不覆盖

Q6:B+树的高度通常是多少?如何计算?

参考答案

计算方法

InnoDB页大小16KB,假设:

  • 主键类型:BIGINT(8字节)
  • 页指针:6字节(InnoDB的Page Number)
  • 页头/页尾开销:约128字节

非叶子节点可用空间:16384 - 128 = 16256字节

每个key占用:8 + 6 = 14字节

每个非叶子节点可存储key数:16256 / 14 ≈ 1161

取整后扇出(m)≈ 1170

树高计算

复制代码
树高1层(根即叶子):
- 最多记录数:约16条(假设每行1KB)

树高2层:
- 1个根节点(非叶子) + 1170个叶子节点
- 最多记录数:1170 × 16 ≈ 18,720条

树高3层:
- 1个根 + 1170个非叶子 + 1170²个叶子
- 最多记录数:1170² × 16 ≈ 21,902,400条(约2100万)

树高4层:
- 最多记录数:1170³ × 16 ≈ 256亿条

实际场景

  • 绝大多数业务表(百万到千万级):树高2-3层
  • 查询只需2-3次磁盘IO(0.2-0.5ms)
  • 即使十亿级数据,树高也不超过4层

Q7:为什么主键推荐用自增整数而不是UUID?

参考答案

1. 插入性能

  • 自增整数顺序插入,新记录总是追加到当前叶子节点末尾
  • 页满时才分裂,分裂频率低
  • UUID随机插入,可能插入到任意位置,导致频繁页分裂和磁盘碎片
  • 顺序插入性能是随机插入的2-3倍

2. 空间占用

  • BIGINT:8字节
  • UUID:36字符(ASCII)或16字节(BINARY)
  • UUID作为主键,二级索引也存储主键值,索引整体更大

3. 页利用率

  • 顺序插入保持页填充率高(通常90%+)
  • 随机插入导致页利用率低(可能50-70%)
  • 低利用率意味着更多页,更多IO

4. 查询性能

  • 整数比较比字符串比较快
  • 整数占用空间小,缓存命中率更高

UUID的适用场景

  • 分布式系统需要全局唯一ID
  • 需要避免主键被猜测(安全场景)
  • 可以使用有序UUID(如UUIDv7)缓解随机插入问题

Q8:索引下推(ICP)是什么?有什么作用?

参考答案

定义

索引下推(Index Condition Pushdown)是MySQL 5.6引入的优化,将WHERE条件下推到存储引擎层,在索引遍历过程中过滤数据,减少回表次数。

作用示例

sql 复制代码
-- 索引:idx_name_age(name, age)
SELECT * FROM user WHERE name LIKE 'A%' AND age = 20;

无ICP

  1. 存储引擎找到所有name以'A'开头的记录
  2. 全部回表到Server层
  3. Server层过滤age=20

有ICP

  1. 存储引擎找到name以'A'开头的记录
  2. 在存储引擎层检查age=20
  3. 只回表age=20的记录

效果

  • 减少回表次数30%-50%
  • 减少Server层数据处理能力要求
  • 提升范围查询性能

使用条件

  • 二级索引范围扫描
  • 剩余WHERE条件可以在索引列上评估
  • Extra列显示"Using index condition"

Q9:大表深度分页(LIMIT 1000000, 10)如何优化?

参考答案

问题分析
LIMIT offset, count需要扫描offset+count条记录,然后丢弃前offset条。当offset很大时,性能极差。

优化方案

方案1:覆盖索引+子查询

sql 复制代码
SELECT * FROM orders 
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 1000000, 1)
ORDER BY id LIMIT 10;
  • 子查询只查id,走覆盖索引
  • 外层查询用id主键快速定位

方案2:游标/书签

sql 复制代码
-- 上一页最后id是last_id
SELECT * FROM orders 
WHERE id > last_id 
ORDER BY id LIMIT 10;
  • 直接定位,性能与页码无关
  • 适合"下一页"场景

方案3:限制最大页码

  • 业务层限制只能翻到100页
  • 避免用户无意义地深度翻页

方案4:使用搜索引擎

  • Elasticsearch等搜索引擎针对分页优化
  • 支持高效的深度分页

Q10:如何查看和分析SQL是否使用了索引?

参考答案

1. EXPLAIN分析

sql 复制代码
EXPLAIN SELECT * FROM user WHERE name = 'Alice';

关键字段:

  • type:访问类型(system > const > eq_ref > ref > range > index > ALL)
  • key:实际使用的索引
  • rows:扫描的行数(估算)
  • Extra:额外信息
    • Using index:覆盖索引
    • Using index condition:索引下推
    • Using where:Server层过滤
    • Using filesort:需要排序(可能没走索引排序)
    • Using temporary:需要临时表

2. EXPLAIN ANALYZE(MySQL 8.0.18+)

sql 复制代码
EXPLAIN ANALYZE SELECT * FROM user WHERE name = 'Alice';
  • 显示实际执行时间和行数
  • 比EXPLAIN更准确

3. 慢查询日志

sql 复制代码
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

-- 分析慢查询
mysqldumpslow -s t /var/lib/mysql/slow.log

4. Performance Schema

sql 复制代码
-- 查看索引使用情况
SELECT 
    OBJECT_SCHEMA,
    OBJECT_NAME,
    INDEX_NAME,
    COUNT_FETCH,
    COUNT_INSERT,
    COUNT_UPDATE,
    COUNT_DELETE
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_db';

5. 优化器追踪

sql 复制代码
-- 查看优化器决策过程
SET optimizer_trace="enabled=on";
SELECT * FROM user WHERE name = 'Alice';
SELECT * FROM information_schema.OPTIMIZER_TRACE;

此文原创,转载请注明出处。

相关推荐
tongluowan0079 小时前
数据结构 Bitmap(位图)完整详解
开发语言·数据结构·bitmap
代码中介商9 小时前
排序算法完全指南(五):快速排序深度详解
数据结构·算法·排序算法
小学鸡!9 小时前
IoTDB数据库导入导出数据
数据库·iotdb
尚雷55809 小时前
Oracle ASM 存储更换与整体迁移实战整理
数据库·oracle·ocr·asm
Nontee9 小时前
如何用 MySQL 实现一个可重入的锁?
数据库·mysql
麦聪聊数据9 小时前
数据服务轻量化:基于API架构的企业数据统一交付与消费方案
数据库·架构
小碗羊肉10 小时前
【Redis | 第三篇】缓存(Cache)
数据库·redis·缓存
OceanBase数据库官方博客10 小时前
常州公积金采用OceanBase,三年稳定运行并实现智慧服务新范式
数据库·oceanbase
纽扣66710 小时前
【算法进阶之路】链表终极进阶:合并 K 个有序链表 + 复制带随机指针的链表(含双解法)
数据结构·算法·链表