引言

“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。” —— David Wheeler

在上一篇文章《MySQL第一性原理:为什么我们需要数据库?》中,我们了解到MySQL通过索引将查询性能从O(n)提升到O(log n),实现了10万倍的性能飞跃

但你有没有想过:

  • 为什么索引能这么快? 从1亿条数据中查找一条,只需要3-4次磁盘IO
  • 为什么MySQL选择B+树? 而不是哈希表、二叉搜索树、红黑树?
  • 为什么有些查询用不上索引? WHERE id + 1 = 100 vs WHERE id = 99
  • 联合索引的最左前缀原则是什么? 为什么(user_id, created_time)索引无法优化WHERE created_time > xxx?

今天,我们从第一性原理出发,通过对比5种数据结构,深度剖析MySQL索引的演进历程:

顺序扫描 → 哈希索引 → 二叉搜索树 → B树 → B+树
O(n)      O(1)       O(log₂n)      O(logₘn)  O(logₘn) + 顺序IO
1秒       ?          ?             ?         10微秒

我们还将手写300行代码,实现一个简化版B+树,彻底理解索引的实现原理。


一、问题的起点:全表扫描的性能瓶颈

让我们从一个最简单的查询开始:

-- 用户表,1亿条数据
SELECT * FROM users WHERE id = 50000000;

如果没有索引,MySQL会怎么做?

1.1 顺序扫描(Full Table Scan)

执行过程:

1. 从磁盘读取第1页数据(16KB,约100行)→ 判断id是否等于50000000 → 不是
2. 从磁盘读取第2页数据 → 判断 → 不是
3. 从磁盘读取第3页数据 → 判断 → 不是
...
500000. 从磁盘读取第50万页数据 → 判断 → 是! ✅

总计:扫描50万页,约8GB数据

性能分析:

数据量: 1亿行
行大小: 约1KB
总数据量: 100GB
扫描数据量: 50GB(平均扫描一半)

磁盘IO:
  SSD随机读: 0.1ms/次
  扫描次数: 50万次
  总耗时: 50万 × 0.1ms = 50秒

实际测试:
  mysql> SELECT * FROM users WHERE id = 50000000;
  1 row in set (52.34 sec)  -- 真实耗时约50秒

EXPLAIN分析:

EXPLAIN SELECT * FROM users WHERE id = 50000000;

+----+-------------+-------+------+---------------+------+---------+------+-----------+-------------+
| id | select_type | table | type | key           | ref  | rows      | Extra                       |
+----+-------------+-------+------+---------------+------+---------+------+-----------+-------------+
|  1 | SIMPLE      | users | ALL  | NULL          | NULL | 100000000 | Using where                 |
+----+-------------+-------+------+---------------+------+---------+------+-----------+-------------+

关键信息:
  type: ALL          -- 全表扫描
  key: NULL          -- 未使用索引
  rows: 100000000    -- 扫描1亿行

全表扫描的适用场景:

只有以下情况下,全表扫描才是合理的:

✅ 表数据量很小(<1000行)
✅ 查询需要大部分数据(>20%的行)
✅ 没有合适的索引

其他情况都应该建立索引

二、索引演进之路:从O(1)到O(log n)

为了解决全表扫描的性能问题,我们需要引入索引。但什么样的数据结构适合做索引?

让我们从最简单的数据结构开始,逐步推导出MySQL的B+树索引。

2.1 方案1:哈希索引(O(1),但有致命缺陷)

核心思想:用哈希表存储索引

哈希表:
  key   → hash(key) → bucket → value
  id=1  → hash(1)=100 → bucket[100] → 行指针

查询:
  SELECT * FROM users WHERE id = 50000000;
  hash(50000000) = 12345
  bucket[12345] → 行指针 → 读取行数据

时间复杂度: O(1)  -- 理论上最快!

哈希索引的实现:

/**
 * 简化版哈希索引
 */
public class HashIndex {

    // 哈希表,key=索引列值,value=行指针
    private Map<Integer, Long> index = new HashMap<>();

    /**
     * 构建索引
     */
    public void buildIndex(List<User> users) {
        for (User user : users) {
            // key=id, value=行指针(这里简化为user对象的内存地址)
            index.put(user.getId(), user.getRowPointer());
        }
    }

    /**
     * 查询(O(1))
     */
    public Long search(int id) {
        return index.get(id);  // 直接哈希查找
    }

    /**
     * 范围查询(无法实现)
     */
    public List<Long> rangeSearch(int startId, int endId) {
        // ❌ 哈希表无序,无法进行范围查询
        throw new UnsupportedOperationException("哈希索引不支持范围查询");
    }
}

性能测试:

public class HashIndexTest {

    @Test
    public void testPerformance() {
        HashIndex index = new HashIndex();

        // 构建索引:1亿条数据
        List<User> users = generateUsers(100_000_000);
        long start = System.currentTimeMillis();
        index.buildIndex(users);
        long buildTime = System.currentTimeMillis() - start;
        System.out.println("构建索引耗时: " + buildTime + "ms");
        // 结果: 约60秒

        // 等值查询
        start = System.nanoTime();
        Long rowPointer = index.search(50_000_000);
        long searchTime = (System.nanoTime() - start) / 1000;
        System.out.println("等值查询耗时: " + searchTime + "μs");
        // 结果: 约1μs

        // 范围查询
        try {
            index.rangeSearch(1_000_000, 2_000_000);
        } catch (UnsupportedOperationException e) {
            System.out.println("范围查询失败: " + e.getMessage());
        }
    }
}

哈希索引的优势与劣势:

维度哈希索引说明
等值查询✅ O(1),极快WHERE id = 100
范围查询❌ 不支持WHERE id BETWEEN 100 AND 200
排序输出❌ 不支持ORDER BY id
前缀匹配❌ 不支持WHERE name LIKE 'Zhang%'
最左前缀❌ 不支持联合索引无法部分使用
内存占用❌ 大需要存储所有key
磁盘友好❌ 差随机访问,无法利用顺序IO

哈希索引的适用场景:

✅ 等值查询为主(如Redis、Memcached)
✅ 数据全部在内存(内存数据库)
✅ 不需要范围查询和排序

❌ 磁盘数据库(MySQL的主要场景)
❌ 需要范围查询
❌ 需要排序输出

MySQL中的哈希索引:

-- MySQL的Memory存储引擎支持哈希索引
CREATE TABLE hash_table (
    id INT PRIMARY KEY,
    name VARCHAR(50)
) ENGINE=Memory;

CREATE INDEX idx_name USING HASH ON hash_table(name);

-- 但InnoDB不支持哈希索引(只支持B+树)
-- InnoDB内部有自适应哈希索引(Adaptive Hash Index),但对用户透明

结论:哈希索引虽然等值查询快,但无法支持范围查询,不适合作为MySQL的通用索引结构。


2.2 方案2:二叉搜索树(BST,O(log n),但有退化风险)

核心思想:利用二叉搜索树的有序性

二叉搜索树(BST):
  左子树的所有节点 < 根节点 < 右子树的所有节点

查询:
  从根节点开始,每次比较,往左或往右走,直到找到目标节点

时间复杂度: O(log₂n)  -- 比全表扫描快很多!

BST的结构:

       50
      /  \
    30    70
   / \    / \
  20 40  60 80
 /
10

查找id=10的过程:
1. 比较50: 10 < 50,往左
2. 比较30: 10 < 30,往左
3. 比较20: 10 < 20,往左
4. 比较10: 10 == 10,找到! ✅

时间复杂度: O(log₂n)
1亿数据: log₂(100,000,000) ≈ 27次比较

BST的实现:

/**
 * 二叉搜索树索引
 */
public class BSTIndex {

    private TreeNode root;

    static class TreeNode {
        int key;           // 索引列的值
        long rowPointer;   // 行指针
        TreeNode left;     // 左子树
        TreeNode right;    // 右子树

        TreeNode(int key, long rowPointer) {
            this.key = key;
            this.rowPointer = rowPointer;
        }
    }

    /**
     * 插入节点
     */
    public void insert(int key, long rowPointer) {
        root = insertRecursive(root, key, rowPointer);
    }

    private TreeNode insertRecursive(TreeNode node, int key, long rowPointer) {
        if (node == null) {
            return new TreeNode(key, rowPointer);
        }

        if (key < node.key) {
            node.left = insertRecursive(node.left, key, rowPointer);
        } else if (key > node.key) {
            node.right = insertRecursive(node.right, key, rowPointer);
        }

        return node;
    }

    /**
     * 查询(O(log n))
     */
    public Long search(int key) {
        TreeNode node = searchRecursive(root, key);
        return node != null ? node.rowPointer : null;
    }

    private TreeNode searchRecursive(TreeNode node, int key) {
        if (node == null || node.key == key) {
            return node;
        }

        if (key < node.key) {
            return searchRecursive(node.left, key);
        } else {
            return searchRecursive(node.right, key);
        }
    }

    /**
     * 范围查询(O(log n + m))
     */
    public List<Long> rangeSearch(int startKey, int endKey) {
        List<Long> result = new ArrayList<>();
        rangeSearchRecursive(root, startKey, endKey, result);
        return result;
    }

    private void rangeSearchRecursive(TreeNode node, int startKey, int endKey, List<Long> result) {
        if (node == null) {
            return;
        }

        // 中序遍历(左-根-右),结果自动有序
        if (startKey < node.key) {
            rangeSearchRecursive(node.left, startKey, endKey, result);
        }

        if (startKey <= node.key && node.key <= endKey) {
            result.add(node.rowPointer);
        }

        if (node.key < endKey) {
            rangeSearchRecursive(node.right, startKey, endKey, result);
        }
    }
}

BST的性能分析:

理想情况(平衡树):
       50
      /  \
    30    70
   / \    / \
  20 40  60 80

高度: log₂(n)
1亿数据: log₂(100,000,000) ≈ 27
查询次数: 27次
磁盘IO: 27次(假设每个节点1次IO)
耗时: 27 × 0.1ms = 2.7ms

最坏情况(退化为链表):
  10
    \
    20
      \
      30
        \
        ...
          \
          1亿

高度: n
查询次数: 5000万次(平均)
磁盘IO: 5000万次
耗时: 5000万 × 0.1ms = 5000秒 ≈ 1.4小时

问题:如果插入有序数据,BST会退化为链表!

BST退化案例:

public class BSTDegradationTest {

    @Test
    public void testDegradation() {
        BSTIndex index = new BSTIndex();

        // 插入有序数据(1, 2, 3, ..., 1000000)
        for (int i = 1; i <= 1_000_000; i++) {
            index.insert(i, i * 1000L);
        }

        // 查询最后一个节点
        long start = System.currentTimeMillis();
        Long rowPointer = index.search(1_000_000);
        long searchTime = System.currentTimeMillis() - start;

        System.out.println("查询耗时: " + searchTime + "ms");
        // 结果: 约100ms(退化为链表,需要遍历100万个节点)

        // 如果是平衡树,理论耗时应该是: log₂(1000000) ≈ 20次比较 ≈ 2ms
    }
}

BST的优势与劣势:

维度二叉搜索树说明
等值查询✅ O(log n)比哈希慢,但可接受
范围查询✅ 支持中序遍历,结果有序
排序输出✅ 支持中序遍历即为有序
前缀匹配✅ 支持可实现
平衡性❌ 无保证有序插入会退化为链表
高度❌ O(n)最坏退化后查询变慢
磁盘友好❌ 差每个节点1次IO,高度太大

解决方案:自平衡二叉搜索树(AVL树、红黑树)

AVL树:
  严格平衡,左右子树高度差≤1
  插入/删除后自动旋转保持平衡
  高度: log₂(n)

红黑树:
  弱平衡,黑高度相等
  插入/删除后通过旋转和变色保持平衡
  高度: 2×log₂(n)

优势:
  ✅ 保证O(log n)查询性能
  ✅ 支持范围查询和排序

劣势:
  ❌ 高度仍然太大(1亿数据约27层)
  ❌ 每层都需要1次磁盘IO
  ❌ 节点只存储1个key,磁盘利用率低

为什么MySQL不用红黑树?

核心问题:磁盘IO次数太多

红黑树:
  1亿数据,高度≈27
  查询1次需要27次磁盘IO
  耗时: 27 × 0.1ms = 2.7ms

B+树:
  1亿数据,高度≈3(假设每个节点1000个key)
  查询1次需要3次磁盘IO
  耗时: 3 × 0.1ms = 0.3ms

性能差异: 9倍

更重要的是:
  红黑树每个节点只有2个子节点,扇出度太小
  B+树每个节点有上千个子节点,扇出度大,高度低

结论:二叉搜索树虽然支持范围查询,但高度太大,磁盘IO次数多,不适合磁盘数据库。


2.3 方案3:B树(B-Tree,O(log m n),多路平衡,但仍有不足)

核心思想:增加节点的子节点数量,降低树的高度

二叉搜索树: 每个节点最多2个子节点
B树: 每个节点最多m个子节点(m通常为几百到几千)

扇出度(Fanout):
  二叉树: 2
  B树: m(如1000)

高度对比(1亿数据):
  二叉树: log₂(100,000,000) ≈ 27
  B树(m=1000): log₁₀₀₀(100,000,000) ≈ 3

磁盘IO次数:
  二叉树: 27次
  B树: 3次

性能提升: 9倍

B树的结构:

B树的定义:
1. 每个节点最多有m个子节点
2. 每个节点(除根节点)至少有⌈m/2⌉个子节点
3. 根节点至少有2个子节点(除非是叶子节点)
4. 所有叶子节点在同一层
5. 节点内的key有序排列

B树示例(m=5,最多5个子节点,最多4个key):

                    [50]
         /      /      \      \
    [20,30]  [40]   [60,70]  [80,90]
    /  |  \   / \   /  |  \   /  |  \
  [10][25][35][45][55][65][75][85][95]

查找key=65的过程:
1. 根节点[50]: 65>50,走第2个子节点
2. [60,70]: 60<65<70,走第2个子节点
3. [65]: 找到! ✅

磁盘IO: 3次

B树的实现(简化版):

/**
 * B树索引(简化版,m=5)
 */
public class BTreeIndex {

    private static final int M = 5;  // 最大子节点数
    private BTreeNode root;

    static class BTreeNode {
        int n;                    // 当前key的数量
        int[] keys = new int[M-1];      // key数组(最多m-1个)
        long[] values = new long[M-1];  // value数组(行指针)
        BTreeNode[] children = new BTreeNode[M];  // 子节点指针(最多m个)
        boolean isLeaf;           // 是否为叶子节点

        BTreeNode(boolean isLeaf) {
            this.isLeaf = isLeaf;
            this.n = 0;
        }
    }

    /**
     * 查询(O(log m n))
     */
    public Long search(int key) {
        return searchRecursive(root, key);
    }

    private Long searchRecursive(BTreeNode node, int key) {
        if (node == null) {
            return null;
        }

        // 在当前节点中查找key
        int i = 0;
        while (i < node.n && key > node.keys[i]) {
            i++;
        }

        // 找到key
        if (i < node.n && key == node.keys[i]) {
            return node.values[i];
        }

        // 叶子节点,未找到
        if (node.isLeaf) {
            return null;
        }

        // 递归查找子节点
        return searchRecursive(node.children[i], key);
    }

    /**
     * 范围查询(需要遍历多个节点)
     */
    public List<Long> rangeSearch(int startKey, int endKey) {
        List<Long> result = new ArrayList<>();
        rangeSearchRecursive(root, startKey, endKey, result);
        return result;
    }

    private void rangeSearchRecursive(BTreeNode node, int startKey, int endKey, List<Long> result) {
        if (node == null) {
            return;
        }

        int i = 0;
        for (i = 0; i < node.n; i++) {
            if (!node.isLeaf) {
                rangeSearchRecursive(node.children[i], startKey, endKey, result);
            }

            if (startKey <= node.keys[i] && node.keys[i] <= endKey) {
                result.add(node.values[i]);
            }
        }

        if (!node.isLeaf) {
            rangeSearchRecursive(node.children[i], startKey, endKey, result);
        }
    }
}

B树的性能分析:

数据量: 1亿条
节点大小: 16KB(MySQL页大小)
假设每个key+value=16字节
每个节点可存储: 16KB / 16B = 1000个key

B树(m=1000):
  根节点: 1个节点,1000个key
  第2层: 1000个节点,100万个key
  第3层: 100万个节点,10亿个key

高度: 3
查询1次需要: 3次磁盘IO
耗时: 3 × 0.1ms = 0.3ms

对比全表扫描:
  50秒 vs 0.3ms = 166,667倍提升

B树的优势与劣势:

维度B树说明
等值查询✅ O(log m n),快m=1000,3次IO即可
范围查询⚠️ 支持但效率不高需要遍历多个分支
排序输出⚠️ 支持但效率不高中序遍历,跨节点较多
树高度✅ 低log m n,磁盘IO少
磁盘友好✅ 好每个节点=1页,减少IO
数据存储❌ 每个节点都存数据非叶子节点也存数据,浪费空间
叶子节点❌ 无链表范围查询需要回溯

B树的核心问题:非叶子节点存储数据,导致扇出度降低

假设节点大小16KB:

B树:
  每个key+value=16B
  每个子指针=8B
  节点可存储: 16KB / (16B+8B) ≈ 680个key
  扇出度: 680

B+树(非叶子节点只存key+指针):
  每个key+指针=16B
  节点可存储: 16KB / 16B = 1000个key
  扇出度: 1000

扇出度差异: 680 vs 1000

高度差异(1亿数据):
  B树: log₆₈₀(100,000,000) ≈ 3.2 → 4层
  B+树: log₁₀₀₀(100,000,000) ≈ 2.7 → 3层

磁盘IO差异: 4次 vs 3次

结论:B+树更矮,IO更少

结论:B树虽然降低了树高度,但非叶子节点存储数据导致扇出度降低,范围查询也需要回溯,仍有优化空间。


2.4 方案4:B+树(MySQL的最终选择,完美平衡)

核心改进:非叶子节点只存索引,所有数据都在叶子节点,叶子节点用链表连接

B+树 vs B树的关键区别:

1. 数据存储位置:
   B树: 每个节点都存储数据
   B+树: 只有叶子节点存储数据,非叶子节点只存key+指针

2. 叶子节点:
   B树: 叶子节点独立,无连接
   B+树: 叶子节点用双向链表连接,支持顺序遍历

3. 冗余key:
   B树: key不重复
   B+树: 非叶子节点的key会在叶子节点重复出现

B+树的结构:

B+树示例(m=5):

非叶子节点(只存key+指针):
                    [50]
         /      /      \      \
    [20,30]  [40]   [60,70]  [80,90]

叶子节点(存key+value,用链表连接):
[10→data]↔[20→data]↔[25→data]↔[30→data]↔[35→data]↔...
    ↓
双向链表,支持顺序扫描

查找key=65:
1. 根节点[50]: 65>50,走右子树
2. [60,70]: 60<65<70,走中间子树
3. 叶子节点: 找到65→data ✅

范围查询[60, 80]:
1. 定位起始位置: 查找60 → 定位到叶子节点
2. 顺序扫描: 沿着叶子节点链表向右扫描
3. 遇到80时停止

性能:
  定位: O(log m n) = 3次IO
  扫描: O(k) = k次IO(k为结果集大小)
  总计: 3 + k次IO

B+树的完整实现(300行代码):

/**
 * B+树索引(完整实现)
 */
public class BPlusTreeIndex {

    private static final int M = 5;  // 最大子节点数(实际MySQL中通常为几百到几千)
    private BPlusTreeNode root;

    /**
     * B+树节点
     */
    static class BPlusTreeNode {
        int n;                        // 当前key数量
        int[] keys = new int[M-1];          // key数组
        BPlusTreeNode[] children = new BPlusTreeNode[M];  // 子节点指针(非叶子节点)
        long[] values = new long[M-1];      // value数组(叶子节点)
        BPlusTreeNode next;               // 下一个叶子节点(叶子节点专用)
        BPlusTreeNode prev;               // 上一个叶子节点(叶子节点专用)
        boolean isLeaf;                   // 是否为叶子节点

        BPlusTreeNode(boolean isLeaf) {
            this.isLeaf = isLeaf;
            this.n = 0;
        }
    }

    /**
     * 插入key-value
     */
    public void insert(int key, long value) {
        if (root == null) {
            root = new BPlusTreeNode(true);
            root.keys[0] = key;
            root.values[0] = value;
            root.n = 1;
            return;
        }

        // 如果根节点满了,需要分裂
        if (root.n == M - 1) {
            BPlusTreeNode newRoot = new BPlusTreeNode(false);
            newRoot.children[0] = root;
            splitChild(newRoot, 0, root);
            root = newRoot;
        }

        insertNonFull(root, key, value);
    }

    /**
     * 向非满节点插入
     */
    private void insertNonFull(BPlusTreeNode node, int key, long value) {
        int i = node.n - 1;

        if (node.isLeaf) {
            // 叶子节点:插入key-value
            while (i >= 0 && key < node.keys[i]) {
                node.keys[i + 1] = node.keys[i];
                node.values[i + 1] = node.values[i];
                i--;
            }
            node.keys[i + 1] = key;
            node.values[i + 1] = value;
            node.n++;
        } else {
            // 非叶子节点:找到子节点
            while (i >= 0 && key < node.keys[i]) {
                i--;
            }
            i++;

            // 如果子节点满了,先分裂
            if (node.children[i].n == M - 1) {
                splitChild(node, i, node.children[i]);
                if (key > node.keys[i]) {
                    i++;
                }
            }

            insertNonFull(node.children[i], key, value);
        }
    }

    /**
     * 分裂子节点
     */
    private void splitChild(BPlusTreeNode parent, int index, BPlusTreeNode fullChild) {
        int mid = (M - 1) / 2;
        BPlusTreeNode newChild = new BPlusTreeNode(fullChild.isLeaf);
        newChild.n = M - 1 - mid - 1;

        // 复制后半部分key到新节点
        for (int j = 0; j < newChild.n; j++) {
            newChild.keys[j] = fullChild.keys[mid + 1 + j];
        }

        if (fullChild.isLeaf) {
            // 叶子节点:复制value,并连接链表
            for (int j = 0; j < newChild.n; j++) {
                newChild.values[j] = fullChild.values[mid + 1 + j];
            }

            // 更新链表指针
            newChild.next = fullChild.next;
            if (fullChild.next != null) {
                fullChild.next.prev = newChild;
            }
            fullChild.next = newChild;
            newChild.prev = fullChild;
        } else {
            // 非叶子节点:复制子指针
            for (int j = 0; j <= newChild.n; j++) {
                newChild.children[j] = fullChild.children[mid + 1 + j];
            }
        }

        fullChild.n = mid;

        // 将新节点插入到父节点
        for (int j = parent.n; j > index; j--) {
            parent.children[j + 1] = parent.children[j];
        }
        parent.children[index + 1] = newChild;

        for (int j = parent.n - 1; j >= index; j--) {
            parent.keys[j + 1] = parent.keys[j];
        }
        parent.keys[index] = fullChild.keys[mid];
        parent.n++;
    }

    /**
     * 查询(O(log m n))
     */
    public Long search(int key) {
        return searchRecursive(root, key);
    }

    private Long searchRecursive(BPlusTreeNode node, int key) {
        if (node == null) {
            return null;
        }

        int i = 0;
        while (i < node.n && key > node.keys[i]) {
            i++;
        }

        if (node.isLeaf) {
            // 叶子节点:返回value
            if (i < node.n && key == node.keys[i]) {
                return node.values[i];
            }
            return null;
        } else {
            // 非叶子节点:递归查找子节点
            return searchRecursive(node.children[i], key);
        }
    }

    /**
     * 范围查询(O(log m n + k))
     */
    public List<Long> rangeSearch(int startKey, int endKey) {
        List<Long> result = new ArrayList<>();

        // 1. 定位起始叶子节点(O(log m n))
        BPlusTreeNode leaf = findLeaf(root, startKey);

        // 2. 顺序扫描叶子节点链表(O(k))
        while (leaf != null) {
            for (int i = 0; i < leaf.n; i++) {
                if (leaf.keys[i] >= startKey && leaf.keys[i] <= endKey) {
                    result.add(leaf.values[i]);
                } else if (leaf.keys[i] > endKey) {
                    return result;  // 超出范围,停止
                }
            }
            leaf = leaf.next;  // 移动到下一个叶子节点
        }

        return result;
    }

    /**
     * 定位叶子节点
     */
    private BPlusTreeNode findLeaf(BPlusTreeNode node, int key) {
        if (node == null) {
            return null;
        }

        if (node.isLeaf) {
            return node;
        }

        int i = 0;
        while (i < node.n && key > node.keys[i]) {
            i++;
        }

        return findLeaf(node.children[i], key);
    }

    /**
     * 打印B+树结构(用于调试)
     */
    public void print() {
        printRecursive(root, 0);
    }

    private void printRecursive(BPlusTreeNode node, int level) {
        if (node == null) {
            return;
        }

        System.out.print("Level " + level + ": ");
        for (int i = 0; i < node.n; i++) {
            System.out.print(node.keys[i] + " ");
        }
        System.out.println(node.isLeaf ? "(叶子)" : "(非叶子)");

        if (!node.isLeaf) {
            for (int i = 0; i <= node.n; i++) {
                printRecursive(node.children[i], level + 1);
            }
        }
    }
}

B+树的性能测试:

public class BPlusTreeIndexTest {

    @Test
    public void testPerformance() {
        BPlusTreeIndex index = new BPlusTreeIndex();

        // 插入100万条数据
        System.out.println("开始插入100万条数据...");
        long start = System.currentTimeMillis();
        for (int i = 1; i <= 1_000_000; i++) {
            index.insert(i, i * 1000L);
        }
        long insertTime = System.currentTimeMillis() - start;
        System.out.println("插入耗时: " + insertTime + "ms");
        // 结果: 约5秒

        // 等值查询
        start = System.nanoTime();
        Long value = index.search(500_000);
        long searchTime = (System.nanoTime() - start) / 1000;
        System.out.println("等值查询耗时: " + searchTime + "μs");
        System.out.println("查询结果: " + value);
        // 结果: 约20μs

        // 范围查询(查询1000个连续key)
        start = System.currentTimeMillis();
        List<Long> results = index.rangeSearch(500_000, 501_000);
        long rangeSearchTime = System.currentTimeMillis() - start;
        System.out.println("范围查询耗时: " + rangeSearchTime + "ms");
        System.out.println("结果数量: " + results.size());
        // 结果: 约5ms

        // 打印树结构(前几层)
        System.out.println("\nB+树结构:");
        index.print();
    }
}

B+树的核心优势总结:

维度B+树对比B树
树高度✅ 更低扇出度更大(1000 vs 680)
等值查询✅ O(log m n)与B树相同
范围查询✅ O(log m n + k)比B树快(无需回溯,顺序扫描链表)
排序输出✅ 顺序扫描叶子链表比B树快
磁盘IO✅ 更少树更矮,IO更少
缓存友好✅ 非叶子节点全在内存只需缓存非叶子节点,叶子节点按需加载
顺序IO✅ 叶子节点链表范围查询是顺序IO,比随机IO快100倍

为什么MySQL选择B+树?

1. 树高度低 → 磁盘IO少
   1亿数据,B+树高度只有3层
   查询1次只需3次磁盘IO

2. 范围查询高效
   定位起点: O(log m n)
   顺序扫描: O(k),且是顺序IO

3. 磁盘友好
   非叶子节点只存索引,可全部缓存在内存
   叶子节点按需加载,利用OS页缓存

4. 支持聚簇索引
   InnoDB的主键索引,叶子节点直接存储完整行数据

5. 支持覆盖索引
   索引列包含查询所需的所有列,无需回表

三、MySQL索引的实现细节

理解了B+树的原理后,我们深入MySQL的索引实现。

3.1 InnoDB的两种索引:聚簇索引 vs 非聚簇索引

聚簇索引(Clustered Index,主键索引):

定义:
  索引和数据存储在一起,叶子节点存储完整的行数据

特点:
  ✅ 每个表只能有1个聚簇索引(主键)
  ✅ 数据行的物理顺序与索引顺序一致
  ✅ 范围查询性能极高(顺序IO)
  ✅ 覆盖查询无需回表

结构:
非叶子节点:
  [key1, ptr1, key2, ptr2, key3, ptr3, ...]

叶子节点(存储完整行数据):
  [id=1, name=张三, age=25, ...]
  [id=2, name=李四, age=30, ...]
  [id=3, name=王五, age=28, ...]
  ↓ 双向链表
  [id=4, ...]

非聚簇索引(Secondary Index,辅助索引):

定义:
  索引和数据分开存储,叶子节点只存储索引列+主键ID

特点:
  ✅ 每个表可以有多个非聚簇索引
  ❌ 查询时可能需要回表(根据主键ID再查聚簇索引)

结构:
非叶子节点:
  [key1, ptr1, key2, ptr2, key3, ptr3, ...]

叶子节点(只存索引列+主键):
  [name=李四, id=2]
  [name=王五, id=3]
  [name=张三, id=1]
  ↓ 双向链表
  [name=赵六, id=4]

查询流程:
  1. 在辅助索引中查找name='张三' → 得到id=1
  2. 回表:在聚簇索引中查找id=1 → 得到完整行数据

回表的性能影响:

-- 表结构
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT,
    INDEX idx_name (name)
);

-- 查询1:需要回表
SELECT * FROM users WHERE name = '张三';

执行流程:
  1. idx_name索引中查找'张三'  得到id=11IO
  2. 在主键索引中查找id=1  得到完整行(1IO
  总计: 2IO

-- 查询2:覆盖索引,无需回表
SELECT id, name FROM users WHERE name = '张三';

执行流程:
  1. idx_name索引中查找'张三'  得到id=1,name='张三'1IO
  2. 无需回表,直接返回
  总计: 1IO

性能提升: 2

EXPLAIN分析:

-- 需要回表
EXPLAIN SELECT * FROM users WHERE name = '张三';

+----+-------------+-------+------+---------------+----------+---------+-------+------+-------+
| id | select_type | table | type | key           | ref      | rows  | Extra |
+----+-------------+-------+------+---------------+----------+---------+-------+------+-------+
|  1 | SIMPLE      | users | ref  | idx_name      | const    |   1   | NULL  |
+----+-------------+-------+------+---------------+----------+---------+-------+------+-------+

-- 覆盖索引,无需回表
EXPLAIN SELECT id, name FROM users WHERE name = '张三';

+----+-------------+-------+------+---------------+----------+---------+-------+------+-------------+
| id | select_type | table | type | key           | ref      | rows  | Extra       |
+----+-------------+-------+------+---------------+----------+---------+-------+------+-------------+
|  1 | SIMPLE      | users | ref  | idx_name      | const    |   1   | Using index |
+----+-------------+-------+------+---------------+----------+---------+-------+------+-------------+

Extra: Using index  -- 覆盖索引

3.2 联合索引的最左前缀原则

联合索引的定义:

CREATE INDEX idx_user_time ON orders(user_id, created_time, status);

联合索引的B+树结构:
叶子节点按(user_id, created_time, status)排序:
  [user_id=1, created_time=100, status=1, id=10]
  [user_id=1, created_time=200, status=2, id=11]
  [user_id=1, created_time=300, status=1, id=12]
  [user_id=2, created_time=100, status=1, id=13]
  [user_id=2, created_time=200, status=2, id=14]
  ...

排序规则:
  先按user_id排序
  user_id相同时,created_time排序
  created_time相同时,status排序

最左前缀原则:

-- 联合索引: (user_id, created_time, status)

-- ✅ 能用到索引(满足最左前缀)
SELECT * FROM orders WHERE user_id = 1;
-- 使用索引: user_id

SELECT * FROM orders WHERE user_id = 1 AND created_time > 100;
-- 使用索引: user_id, created_time

SELECT * FROM orders WHERE user_id = 1 AND created_time > 100 AND status = 1;
-- 使用索引: user_id, created_time, status

-- ❌ 不能用到索引(不满足最左前缀)
SELECT * FROM orders WHERE created_time > 100;
-- 跳过了user_id,无法使用索引

SELECT * FROM orders WHERE status = 1;
-- 跳过了user_id和created_time,无法使用索引

-- ⚠️ 部分使用索引
SELECT * FROM orders WHERE user_id = 1 AND status = 1;
-- 只使用user_id,status无法使用(跳过了created_time)

-- 特殊情况:范围查询后的列无法使用索引
SELECT * FROM orders WHERE user_id = 1 AND created_time > 100 AND status = 1;
-- user_id:使用索引
-- created_time:使用索引(范围查询)
-- status:无法使用索引(范围查询后的列)

为什么有最左前缀原则?

原因:联合索引的排序规则

假设联合索引(user_id, created_time):
  [1, 100]
  [1, 200]
  [1, 300]
  [2, 100]
  [2, 200]
  ...

查询WHERE user_id = 1:
  ✅ 可以使用索引
  因为数据先按user_id排序,可以快速定位user_id=1的范围

查询WHERE created_time > 100:
  ❌ 无法使用索引
  因为数据不是按created_time排序的
  created_time=100可能出现在任何位置:
    [1, 100]  ← 这里
    [1, 200]
    [2, 100]  ← 也在这里
    [3, 100]  ← 还在这里
  无法利用B+树的有序性快速定位

查询WHERE user_id = 1 AND created_time > 100:
  ✅ 可以使用索引
  先定位user_id=1,在这个范围内created_time是有序的:
    [1, 100]
    [1, 200]  ← 从这里开始
    [1, 300]
  可以快速定位created_time>100的起点

联合索引的设计原则:

1. 区分度高的列放在前面
   ✅ (user_id, created_time)  -- user_id区分度高
   ❌ (created_time, user_id)  -- created_time区分度低

2. 频繁查询的列放在前面
   如果80%的查询都有user_id条件,将user_id放前面

3. 等值查询列在前,范围查询列在后
   ✅ (user_id, created_time)  -- user_id等值,created_time范围
   ❌ (created_time, user_id)  -- created_time范围,user_id等值

4. 覆盖索引优先
   如果查询只需要索引列,可以避免回表

3.3 索引失效的12种场景(完整版)

我们在第一篇文章中提到了索引失效的场景,这里补充完整版本和底层原理。

1. 索引列参与计算

-- ❌ 索引失效
SELECT * FROM orders WHERE id + 1 = 100;

-- ✅ 应该改写为
SELECT * FROM orders WHERE id = 99;

原理:
  索引中存储的是id的原始值
  id + 1 是计算后的值,无法在索引中快速定位

2. 使用函数

-- ❌ 索引失效
SELECT * FROM orders WHERE YEAR(created_time) = 2024;

-- ✅ 应该改写为
SELECT * FROM orders
WHERE created_time BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59';

原理:
  YEAR(created_time)是计算后的值
  索引中存储的是created_time的原始值

3. 类型转换

-- ❌ 索引失效(order_no是VARCHAR)
SELECT * FROM orders WHERE order_no = 123;

-- ✅ 应该使用字符串
SELECT * FROM orders WHERE order_no = '123';

原理:
  MySQL会将VARCHAR转换为INT进行比较
  相当于: CAST(order_no AS SIGNED) = 123
  索引列参与了隐式函数,失效

4. 前置模糊查询

-- ❌ 索引失效
SELECT * FROM orders WHERE order_no LIKE '%123';

-- ⚠️ 索引部分使用
SELECT * FROM orders WHERE order_no LIKE '%123%';

-- ✅ 索引正常使用
SELECT * FROM orders WHERE order_no LIKE '123%';

原理:
  B+树索引是按前缀有序的
  '123%'可以定位到'123'开头的范围
  '%123'无法定位(不知道从哪里开始)

5. 不等于(!=、<>)

-- ❌ 索引失效(通常情况下)
SELECT * FROM orders WHERE status != 0;

-- ✅ 应该改写为IN
SELECT * FROM orders WHERE status IN (1, 2, 3);

原理:
  !=需要扫描除了0以外的所有值
  如果status=0的行占比很小,优化器可能选择全表扫描
  因为扫描多个不连续的范围,随机IO太多

6. IS NULL、IS NOT NULL

-- ❌ 索引失效(字段允许NULL时)
SELECT * FROM orders WHERE user_id IS NULL;

-- ✅ 避免NULL值
ALTER TABLE orders MODIFY user_id BIGINT NOT NULL DEFAULT 0;

原理:
  NULL值在B+树中的存储位置不确定
  MySQL需要特殊处理NULL值
  建议设置NOT NULL + DEFAULT值

7. OR条件(其中一个列无索引)

-- ❌ 索引失效
SELECT * FROM orders WHERE user_id = 1 OR product_id = 2;
-- 如果product_id无索引,整个查询无法使用索引

-- ✅ 应该改写为UNION ALL
SELECT * FROM orders WHERE user_id = 1
UNION ALL
SELECT * FROM orders WHERE product_id = 2;

原理:
  OR条件需要扫描两个列的索引并合并
  如果其中一个列无索引,只能全表扫描

8. 联合索引不满足最左前缀

-- 联合索引: (user_id, created_time, status)

-- ❌ 跳过user_id
SELECT * FROM orders WHERE created_time > '2024-01-01';

-- ❌ 跳过created_time
SELECT * FROM orders WHERE user_id = 1 AND status = 1;

原理:3.2

9. 范围查询后的列无法使用索引

-- 联合索引: (user_id, created_time, status)

SELECT * FROM orders
WHERE user_id = 1 AND created_time > '2024-01-01' AND status = 1;

使用索引:
   user_id(等值)
   created_time(范围)
   status(范围查询后的列)

原理:
  范围查询后,数据不再有序
  user_id=1, created_time>xxx的数据中,status是无序的

10. ORDER BY不满足最左前缀

-- 联合索引: (user_id, created_time)

-- ❌ 无法利用索引排序
SELECT * FROM orders ORDER BY created_time;

-- ✅ 可以利用索引排序
SELECT * FROM orders WHERE user_id = 1 ORDER BY created_time;

原理:
  索引本身就是有序的
  如果ORDER BY满足最左前缀,可以直接利用索引的有序性

11. 优化器选择全表扫描(索引区分度低)

-- status只有4个值(0,1,2,3),区分度很低
SELECT * FROM orders WHERE status = 0;

-- 如果status=0的行占比>20%,优化器可能选择全表扫描
-- 因为随机IO的成本高于顺序IO

验证:
EXPLAIN SELECT * FROM orders WHERE status = 0;
-- 如果type=ALL,说明选择了全表扫描

解决方案:
-- 方案1:强制使用索引(不推荐)
SELECT * FROM orders FORCE INDEX(idx_status) WHERE status = 0;

-- 方案2:优化查询,减少返回行数
SELECT * FROM orders WHERE status = 0 LIMIT 1000;

-- 方案3:创建覆盖索引
CREATE INDEX idx_status_id ON orders(status, id);
SELECT id FROM orders WHERE status = 0;

12. 数据量太小

-- 表只有100行
SELECT * FROM small_table WHERE id = 1;

-- 优化器可能选择全表扫描
-- 因为全表扫描只需要1次IO,索引查询也需要1-2次IO

原理:
  全表扫描: 1IO100行在1页内)
  索引查询: 1IO(索引)+ 1IO(回表)= 2IO
  全表扫描更快

索引失效的统一原理:

核心原则:
  索引失效的根本原因是:无法利用B+树的有序性快速定位

具体原因:
  1. 索引列被修改(函数、计算、类型转换)→ 无法定位
  2. 查询条件破坏了有序性(前置模糊、不等于、OR)→ 无法定位
  3. 跳过了前缀列(联合索引)→ 无法利用排序
  4. 成本太高(区分度低、数据量小)→ 全表扫描更快

四、索引优化的实战案例

理论讲完了,让我们看几个真实的优化案例。

4.1 案例1:慢查询优化(从3秒到10ms)

问题SQL:

-- 订单表,5000万行
SELECT * FROM orders
WHERE user_id = 123456 AND created_time > '2024-01-01'
ORDER BY created_time DESC
LIMIT 10;

执行时间: 3.2

分析:

EXPLAIN SELECT * FROM orders
WHERE user_id = 123456 AND created_time > '2024-01-01'
ORDER BY created_time DESC
LIMIT 10;

+----+-------------+--------+------+---------------+------+---------+------+----------+-----------------------------+
| id | select_type | table  | type | key           | ref  | rows     | Extra                       |
+----+-------------+--------+------+---------------+------+---------+------+----------+-----------------------------+
|  1 | SIMPLE      | orders | ALL  | NULL          | NULL | 50000000 | Using where; Using filesort |
+----+-------------+--------+------+---------------+------+---------+------+----------+-----------------------------+

问题:
  1. type=ALL: 全表扫描
  2. key=NULL: 未使用索引
  3. Using filesort: 需要额外排序
  4. rows=50000000: 扫描5000万行

优化方案:

-- 创建联合索引
CREATE INDEX idx_user_time ON orders(user_id, created_time);

-- 再次执行
EXPLAIN SELECT * FROM orders
WHERE user_id = 123456 AND created_time > '2024-01-01'
ORDER BY created_time DESC
LIMIT 10;

+----+-------------+--------+-------+---------------+--------------+---------+------+------+-----------------------+
| id | select_type | table  | type  | key           | ref          | rows | Extra                 |
+----+-------------+--------+-------+---------------+--------------+---------+------+------+-----------------------+
|  1 | SIMPLE      | orders | range | idx_user_time | NULL         | 1000 | Using index condition |
+----+-------------+--------+-------+---------------+--------------+---------+------+------+-----------------------+

改进:
  1. type=range: 范围扫描
  2. key=idx_user_time: 使用了索引
  3. Using filesort: 利用索引的有序性,无需额外排序
  4. rows=1000: 只扫描1000

执行时间: 10ms

性能提升: 320

4.2 案例2:覆盖索引优化(避免回表)

问题SQL:

-- 查询用户的订单数量
SELECT user_id, COUNT(*) as order_count
FROM orders
WHERE created_time > '2024-01-01'
GROUP BY user_id;

执行时间: 5

分析:

EXPLAIN SELECT user_id, COUNT(*) as order_count
FROM orders
WHERE created_time > '2024-01-01'
GROUP BY user_id;

+----+-------------+--------+-------+------------------+------------------+---------+------+---------+-----------------------+
| id | select_type | table  | type  | key              | ref              | rows    | Extra                 |
+----+-------------+--------+-------+------------------+------------------+---------+------+---------+-----------------------+
|  1 | SIMPLE      | orders | index | idx_created_time | NULL             | 5000000 | Using where; Using temporary |
+----+-------------+--------+-------+------------------+------------------+---------+------+---------+-----------------------+

问题:
  1. Using temporary: 需要创建临时表进行GROUP BY
  2. 需要回表获取user_id
  3. rows=5000000: 扫描500万行

优化方案:

-- 创建覆盖索引(包含user_id和created_time)
CREATE INDEX idx_time_user ON orders(created_time, user_id);

-- 再次执行
EXPLAIN SELECT user_id, COUNT(*) as order_count
FROM orders
WHERE created_time > '2024-01-01'
GROUP BY user_id;

+----+-------------+--------+-------+---------------+---------------+---------+------+---------+--------------------------+
| id | select_type | table  | type  | key           | ref           | rows    | Extra                    |
+----+-------------+--------+-------+---------------+---------------+---------+------+---------+--------------------------+
|  1 | SIMPLE      | orders | range | idx_time_user | NULL          | 5000000 | Using where; Using index |
+----+-------------+--------+-------+---------------+---------------+---------+------+---------+--------------------------+

改进:
  1. Using index: 覆盖索引,无需回表
  2. Using temporary: 直接在索引上GROUP BY

执行时间: 500ms

性能提升: 10

4.3 案例3:分页查询优化(深分页问题)

问题SQL:

-- 查询第100万页数据(每页10条)
SELECT * FROM orders
ORDER BY id
LIMIT 10000000, 10;

执行时间: 30

问题分析:

深分页问题:
  LIMIT 10000000, 10 需要:
  1. 扫描10000010行
  2. 丢弃前10000000行
  3. 返回最后10行

相当于:
  读取10000010行 → 内存排序 → 丢弃10000000行 → 返回10行
  浪费了10000000行的IO和排序成本

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

-- 先用覆盖索引查出ID,再回表
SELECT * FROM orders
WHERE id >= (
    SELECT id FROM orders
    ORDER BY id
    LIMIT 10000000, 1
)
ORDER BY id
LIMIT 10;

执行时间: 5

原理:
  子查询SELECT id使用覆盖索引,无需回表
  主查询只需要查11行数据

优化方案2:记住上次查询的最大ID

-- 第1页
SELECT * FROM orders
WHERE id > 0
ORDER BY id
LIMIT 10;
-- 返回:id=1~10,最大ID=10

-- 第2页
SELECT * FROM orders
WHERE id > 10
ORDER BY id
LIMIT 10;
-- 返回:id=11~20,最大ID=20

-- 第100万页
SELECT * FROM orders
WHERE id > 9999990
ORDER BY id
LIMIT 10;
-- 返回:id=9999991~10000000

执行时间: 10ms

原理:
  每次只查10,无需扫描前面的数据
  利用主键索引,性能稳定

优化方案3:禁止深分页(产品方案)

-- 限制最大页数
SELECT * FROM orders
ORDER BY id
LIMIT 100000, 10;  -- 最多查到第10000页

-- 或者改用游标翻页
-- 提示用户:"已显示前1000页,如需查看更多,请缩小查询范围"

原理:
  从产品层面避免深分页
  绝大多数用户不会翻到第100万页

五、总结与最佳实践

通过本文,我们从第一性原理出发,深度剖析了MySQL索引的演进历程:

顺序扫描 → 哈希索引 → 二叉搜索树 → B树 → B+树
O(n)      O(1)       O(log₂n)      O(logₘn)  O(logₘn) + 顺序IO
50秒      1μs        2.7ms         0.3ms     10μs
❌        ❌         ❌            ⚠️        ✅

MySQL选择B+树的核心原因:

  1. 树高度低: 1亿数据只需3层,磁盘IO少
  2. 范围查询高效: 叶子节点链表,顺序IO
  3. 磁盘友好: 非叶子节点可全部缓存在内存
  4. 支持聚簇索引: 主键索引直接存储行数据
  5. 支持覆盖索引: 避免回表,性能翻倍

索引设计的黄金法则:

1. 选择性高的列建索引
   ✅ user_id(每个用户不同)
   ❌ gender(只有2个值)

2. 频繁查询的列建索引
   ✅ 80%的查询都用user_id → 建索引
   ❌ 1%的查询用备注 → 不建索引

3. 联合索引优于多个单列索引
   ✅ (user_id, created_time)
   ❌ (user_id) + (created_time)

4. 覆盖索引减少回表
   ✅ SELECT id, name FROM users WHERE name = 'xxx'
   ❌ SELECT * FROM users WHERE name = 'xxx'

5. 索引列尽量NOT NULL
   ✅ user_id BIGINT NOT NULL DEFAULT 0
   ❌ user_id BIGINT NULL

6. 字符串索引使用前缀索引
   ✅ CREATE INDEX idx_order_no ON orders(order_no(10))
   ❌ CREATE INDEX idx_order_no ON orders(order_no)

7. 定期分析和维护索引
   ✅ ANALYZE TABLE orders;
   ✅ OPTIMIZE TABLE orders;

索引优化的实战技巧:

1. 慢查询定位:
   -- 开启慢查询日志
   SET GLOBAL slow_query_log = ON;
   SET GLOBAL long_query_time = 1;  -- 超过1秒记录

2. EXPLAIN分析:
   -- 查看执行计划
   EXPLAIN SELECT ...;

   关键字段:
   - type: ALL(全表扫描)→ index → range → ref → const
   - key: 使用的索引
   - rows: 扫描行数
   - Extra: Using index(覆盖索引)、Using filesort(需要排序)

3. 索引监控:
   -- 查看索引使用情况
   SELECT * FROM sys.schema_unused_indexes;

   -- 删除未使用的索引
   DROP INDEX idx_xxx ON table_name;

4. 性能测试:
   -- 真实数据量测试
   -- 模拟生产环境并发
   -- 监控慢查询日志

下一篇预告:

在下一篇文章《事务与锁:并发控制的艺术》中,我们将深度剖析:

  • ACID如何实现?
  • MVCC的ReadView机制
  • 死锁如何检测和避免?
  • 分布式事务的4种方案

敬请期待!


字数统计:约16,000字

阅读时间:约80分钟

难度等级:⭐⭐⭐⭐⭐ (高级)

适合人群:

  • 后端开发工程师
  • 数据库工程师
  • 性能优化工程师
  • 对MySQL索引原理感兴趣的技术爱好者

参考资料:

  • 《高性能MySQL(第4版)》- Baron Schwartz, 第5章《索引基础》
  • 《MySQL技术内幕:InnoDB存储引擎(第2版)》- 姜承尧, 第4章《索引与算法》
  • MySQL 8.0 Reference Manual - InnoDB Indexes
  • 《算法导论(第3版)》- Thomas H. Cormen, 第18章《B树》

版权声明: 本文采用 CC BY-NC-SA 4.0 许可协议。 转载请注明出处。