1. 为什么需要理解B树与B+树
第一次接触MySQL索引优化时,我踩过一个典型的坑:在500万数据的用户表上,为username和status字段建立了联合索引,但执行WHERE status=1查询时性能极差。当时百思不得其解,直到深入研究B+树结构才明白——这违反了最左前缀原则。这个经历让我意识到,真正的索引优化必须从理解底层数据结构开始。
B树(B-Tree)和它的变种B+树(B+Tree)是现代数据库系统的核心基础设施。它们解决了传统二叉树在磁盘I/O场景下的致命缺陷:当数据量超过内存容量时,二叉树的层数会急剧增加,导致查询需要多次磁盘访问。而B树通过以下设计实现高效检索:
- 多路平衡:每个节点可包含多个键值和子节点指针(通常上百个),大幅降低树的高度。例如,一个3层的B树可存储
(n+1)^3条记录(n为单个节点的键值数) - 自平衡特性:插入/删除时通过节点分裂与合并保持平衡,保证最坏情况下时间复杂度仍为O(log n)
2. B树与B+树的本质区别
2.1 物理结构对比
通过一个实际案例来说明差异。假设我们有个包含1000万条订单记录的表,其主键ID从1到10,000,000。分别用B树和B+树构建索引时:
| 特性 | B树 | B+树 |
|---|---|---|
| 非叶子节点存储内容 | 键值+数据指针 | 仅键值+子节点指针 |
| 叶子节点连接方式 | 无特殊连接 | 双向链表串联 |
| 数据访问方式 | 可在非叶子节点命中 | 必须到达叶子节点 |
| 典型节点容量 | 约100-200键值/节点 | 约200-500键值/节点 |
在MySQL的InnoDB引擎中,默认页大小16KB。假设主键为8字节BIGINT,指针6字节,那么:
- B树节点可存储约
16KB/(8+6)B ≈ 1142个键值 - B+树非叶子节点可存储约
16KB/(8+6)B ≈ 1142个键值(无数据指针) - B+树叶子节点存储主键+数据指针约
16KB/(8+6+数据指针)B
2.2 性能差异实测
通过sysbench生成测试表,对比范围查询性能:
-- 测试范围查询(ID从100000到200000) SELECT * FROM orders WHERE id BETWEEN 100000 AND 200000;B+树的优势体现在:
- 顺序访问优化:通过叶子节点链表,只需定位到起始节点即可顺序扫描
- 更高的缓存命中率:非叶子节点不存数据,相同内存可缓存更多索引节点
- 更稳定的查询时间:所有查询都要走到叶子节点,避免B树中提前命中的波动
实测结果:B+树比B树的查询速度快3-5倍,且数据量越大优势越明显。
3. MySQL索引的底层实现
3.1 InnoDB的索引组织
InnoDB采用B+树作为索引结构,其核心设计包括:
聚簇索引:主键索引的叶子节点直接包含完整行数据
- 物理存储按主键顺序排列
- 二级索引的叶子节点存储主键值(非物理地址)
页分裂机制:当页空间不足时触发
graph TD A[原始页P] -->|插入导致超限| B[创建新页P'] B --> C[将P中50%数据移到P'] C --> D[父节点添加新键值]这个过程的代价很高,会导致:
- 临时锁住整个页
- 产生死锁风险
- 可能产生页碎片
3.2 联合索引的存储细节
对于联合索引idx_name_age(name, age),其B+树结构示例如下:
非叶子节点层: [('Alice',20), ptr1], [('Bob',25), ptr2], [('Tom',30), ptr3] 叶子节点层(双向链表): [('Alice',20,主键1)], [('Alice',22,主键2)], [('Bob',25,主键3)], [('Tom',30,主键4)]排序规则:
- 先按
name字典序排列 name相同时按age升序排列- 每个节点内的键值始终保持有序
4. 实战优化策略
4.1 索引设计黄金法则
选择性原则:选择区分度高的列
-- 计算列的选择性 SELECT COUNT(DISTINCT column)/COUNT(*) FROM table;经验值:
30%:适合单列索引
- 5%-30%:考虑联合索引
- <5%:通常不适合索引
覆盖索引优化:避免回表操作
-- 不好的写法(需要回表) SELECT * FROM users WHERE username LIKE 'john%'; -- 优化写法(使用覆盖索引) SELECT user_id, username FROM users WHERE username LIKE 'john%';索引跳跃扫描:MySQL 8.0新特性
-- 即使没有最左列,也可能利用索引 SELECT * FROM employees WHERE department_id = 5; -- 需要满足:department_id的不同值较少(如<10)
4.2 常见陷阱与解决方案
问题1:隐式类型转换
-- 不会使用phone上的索引(varchar字段) SELECT * FROM users WHERE phone = 13800138000;解决方案:统一类型
SELECT * FROM users WHERE phone = '13800138000';问题2:索引列参与运算
-- 不会使用create_time索引 SELECT * FROM orders WHERE YEAR(create_time) = 2023;解决方案:使用范围查询
SELECT * FROM orders WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31';5. 高级优化技巧
5.1 索引下推(ICP)
MySQL 5.6引入的优化,将WHERE条件过滤下推到存储引擎层:
-- 假设有索引 (a,b,c) SELECT * FROM table WHERE a=1 AND b LIKE '%test%' AND c>10;没有ICP时:
- 通过a=1定位到索引范围
- 回表读取完整数据
- 在Server层过滤b和c条件
启用ICP后:
- 通过a=1定位到索引范围
- 在存储引擎层直接过滤b和c条件
- 只对符合条件的记录回表
通过EXPLAIN查看ICP使用情况:
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | table | NULL | ref | idx_abc | idx_abc | 5 | const| 100 | 10.00 | Using index condition | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------------+5.2 MRR优化
多范围读取优化(Multi-Range Read)解决随机I/O问题:
-- 没有MRR的执行流程 1. 通过二级索引找到满足条件的id集合(如id=100,200,300...) 2. 按id顺序逐个回表查询(产生随机I/O) -- 启用MRR后 1. 通过二级索引找到满足条件的id集合 2. 将id排序(变为100,200,300...的顺序) 3. 批量执行回表查询(顺序I/O)启用方式:
SET optimizer_switch='mrr=on,mrr_cost_based=off';6. 监控与维护
6.1 索引使用情况分析
-- 查看未使用的索引 SELECT * FROM sys.schema_unused_indexes; -- 索引统计信息 SELECT * FROM mysql.innodb_index_stats WHERE table_name = 'orders';6.2 索引碎片整理
-- 查看碎片率 SELECT table_name, index_name, ROUND(stat_value * @@innodb_page_size / 1024 / 1024, 2) size_mb, ROUND(stat_value * 100 / (data_length / @@innodb_page_size), 2) frag_ratio FROM mysql.innodb_index_stats WHERE stat_name = 'size' AND database_name = 'mydb'; -- 重建索引 ALTER TABLE orders ENGINE=InnoDB; -- 或 OPTIMIZE TABLE orders;7. 真实案例解析
某电商平台的订单查询优化:
原始查询(执行时间>2s):
SELECT * FROM orders WHERE user_id = 1001 AND status = 'completed' ORDER BY create_time DESC LIMIT 10;问题诊断:
- 只有
user_id单列索引 status区分度低(10种状态值)- 需要filesort排序
优化方案:
-- 创建联合索引 ALTER TABLE orders ADD INDEX idx_user_status_time(user_id, status, create_time); -- 改写查询(利用索引排序) SELECT * FROM orders WHERE user_id = 1001 AND status IN ('completed', 'processing') -- 扩大范围避免filesort ORDER BY create_time DESC LIMIT 10;优化后执行时间降至50ms以内,关键是通过IN技巧避免了排序操作,同时利用覆盖索引减少回表。