标题起得很像书名,实际是记录我基于书+AI辅助下对MySQL的学习总结。
之前我记录过一篇B树的文章,总结起来就是B树是为了最大化磁盘效率而设计的数据结构。B+树在此基础上修改了节点定义,将实际数据全部下沉到最底层的叶子节点,中间节点只保留索引键,从而实现更清晰的层次结构和更高的扇出比。使用InnoDB引擎的MySQL,底层正是B+树。
软件架构
MySQL大概可以分为三层,最上层是寻常数据库软件都有的连接管理和客户端交互逻辑。中间层用于SQL解析与优化,还有实现SQL内置函数和其他比如存储过程等特色功能。而第三层是存储引擎层。存储引擎以类似插件的形式存在,并通过约定好的接口进行交互。因此底层可以采用不同的结构在磁盘上组织数据。像上面提到的,也是现在行业中最常用的InnoDB引擎,就是采用B+树组织管理数据。
因此很多时候,不管是面试题还是技术文章,聊到MySQL的特性或优缺点时,有些说的是第二层的行为(如查询优化器的决策),有些说的是存储引擎的限制,但往往被笼统地冠以"MySQL的问题"。
数据量瓶颈
常听见别人说MySQL数据量达到2000万行就该分库分表了,否则性能会明显下降。这个说法是有据可查的,我们从底层数据结构入手来理解。下面是InnoDB中数据页的布局,不管是存储聚簇索引的中间节点页,还是实际存放行数据的叶子节点页,都是这个结构。默认页大小是16KB。其中除了用户记录和空闲空间,其余部分的大小基本固定,合计128字节。
┌─────────────────────────────┐ ← 高地址
│ File Trailer (8B) │
├─────────────────────────────┤
│ Page Directory (可变大小) │ ← 从高地址向低地址增长
├─────────────────────────────┤
│ 空闲空间 │
├─────────────────────────────┤
│ 用户记录 (User Records) │ ← 从低地址向高地址增长
├─────────────────────────────┤
│ Infimum & Supremum (26B) │
├─────────────────────────────┤
│ Page Header (56B) │
├─────────────────────────────┤
│ File Header (38B) │
└─────────────────────────────┘ ← 低地址
从上到下分别是
- File Trailer:存储该页的校验信息,4字节校验和 + 4字节日志序列号(LSN),用于崩溃恢复时验证页的完整性
- Page Directory:稀疏索引,每隔4~8条记录设置一个slot记录该组末尾记录的偏移量,具体每个slot管辖的记录数由对应记录header中的n_owned字段标明,用于在页内做二分查找
- 空闲空间:为用户记录预留的空间
- 用户记录:该页中实际存储行数据的区域
- Infimum & Supremum(26字节):两条固定的虚拟记录,各占13字节。Infimum 是页内最小记录,Supremum 是最大记录,构成链表的头尾哨兵,遍历时无需处理边界条件。
- Infimum → 记录1 → 记录2 → … → 记录n → Supremum
- Page Header
| 字段 | 大小 | 说明 |
|---|---|---|
| PAGE_N_DIR_SLOTS | 2B | Page Directory 中 Slot 的数量 |
| PAGE_HEAP_TOP | 2B | 空闲空间起始偏移量 |
| PAGE_N_HEAP | 2B | 页内记录总数(含虚拟记录) |
| PAGE_FREE | 2B | 被删除记录的链表头(可复用空间) |
| PAGE_GARBAGE | 2B | 已删除记录占用的字节数 |
| PAGE_LAST_INSERT | 2B | 最后插入记录的偏移量 |
| PAGE_DIRECTION | 2B | 插入方向(递增/递减/随机) |
| PAGE_N_DIRECTION | 2B | 同一方向连续插入的记录数 |
| PAGE_N_RECS | 2B | 真实记录数(不含虚拟记录) |
| PAGE_MAX_TRX_ID | 8B | 修改此页的最大事务ID |
| PAGE_LEVEL | 2B | 当前页在B+树中的层级(叶子=0) |
| PAGE_INDEX_ID | 8B | 所属索引的ID |
| PAGE_BTR_SEG_LEAF | 10B | 叶子节点段信息 |
| PAGE_BTR_SEG_TOP | 10B | 非叶子节点段信息 |
顾名思义,这里记录的是当前页的各项运行时元信息
- File Header
| 字段 | 大小 | 说明 |
|---|---|---|
| FIL_PAGE_SPACE_OR_CHKSUM | 4B | 校验和 |
| FIL_PAGE_OFFSET | 4B | 页号,在表空间中的唯一编号 |
| FIL_PAGE_PREV | 4B | 上一页的页号 |
| FIL_PAGE_NEXT | 4B | 下一页的页号 |
| FIL_PAGE_LSN | 8B | 最后修改此页的 LSN |
| FIL_PAGE_TYPE | 2B | 页类型(数据页/undo页/系统页等) |
| FIL_PAGE_FILE_FLUSH_LSN | 8B | 仅系统页使用 |
| FIL_PAGE_ARCH_LOG_NO | 4B | 所属表空间 ID |
中间节点除了上述固定的128字节meta信息,剩余空间全部用于存储索引Entry,每个Entry的结构大致如下。
typedef struct {
uint8_t record_header[5]; // 记录头部
uint8_t key[KEY_SIZE]; // 主键值,bigint = 8字节
uint8_t page_no[6]; // 子页号,InnoDB用6字节表示
} BTreeEntry;
record_header各字段含义如下:
bit 0-1 : 预留
bit 2 : delete_mask → 是否被删除
bit 3 : min_rec_mask → 是否是该层最小记录
bit 4-7 : n_owned → 该槽拥有的记录数(Page Directory用)
bit 8-12 : heap_no → 记录在页内的堆序号
bit 13-15 : record_type → 0普通 1非叶子 2 Infimum 3 Supremum
bit 16-31 : next_record → 下一条记录的偏移量(链表指针)
KEY_SIZE取决于建表时主键的数据类型,比如
id INT PRIMARY KEY -- 4字节
id BIGINT PRIMARY KEY -- 8字节
主键数据类型也决定了每个16KB页中能容纳多少个entry,由此可以推算:
16KB = 16384 Bytes
16384 - 128 = 16256 // 每个页中除了用户记录和page directory,剩下部分占用128字节
// 主键为bigint时,每个entry占据19字节,
// 前面提到page directory记录偏移量,但它并不是每个entry都有,而是每隔4~8才有一个,这里取为k
// 那么一个页中可以记录的数据就是
n × 19 + (ceil(n/k) + 2) × 2 ≤ 16256
如果k=4:
n × 19 + (ceil(n/4) + 2) × 2 ≤ 16256,解得 n = 833
如果k=8:
n × 19 + (ceil(n/8) + 2) × 2 ≤ 16256,解得 n = 844
因此,一个16KB的中间节点页大约可以容纳833到844条entry。InnoDB默认使用3层B+树,前两层均为中间节点,第三层才是叶子节点。以此计算,叶子节点总数为 833 × 833 或 844 × 844,约为 693,889 ~ 712,336 个。
叶子节点存放的是完整的行数据,因此每页能放多少行取决于每行的实际大小。套用同样的公式:
n × l + (ceil(n/k) + 2) × 2 ≤ 16256 // l代表每行数据大小
k=4, l=512字节, n = 31
k=8, l=512字节,n = 31
k=4, l=1024字节, n = 15
k=8, l=1024, n = 15
可以看出k的取值对结果影响很小,真正起决定作用的是每行数据的大小。每行512字节时每页约存31行,每行1024字节时约存15行。结合前面得到的叶子节点总数:
n = 15, 每行数据1024B
693,889 × 15 = 10,408,335 ≈ 1,040 万
712,336 × 15 = 10,685,040 ≈ 1,068 万
n = 31 每行数据512B
693,889 × 31 = 21,510,559 ≈ 2,151 万
712,336 × 31 = 22,082,416 ≈ 2,208 万
回到最初的问题:主键用bigint、每行平均512字节时,数据量达到2000+万行,3层B+树就会撑满,被迫增长到4层。
层数膨胀为什么对性能影响这么大?InnoDB的buffer pool会将根节点页常驻内存,因为它是所有查询的入口,访问极其频繁。在这个前提下,3层B+树的一次点查只需要2次磁盘IO(第2层中间节点 + 第3层叶子节点),变成4层后就需要3次磁盘IO,增幅50%
而叶子节点的数量也同步从约70万暴增为:
833³ = 833 × 833 × 833 = 693,889 × 833 = 578,109,937 ≈ 5.78 亿个叶子页
844³ = 844 × 844 × 844 = 712,336 × 844 = 601,211,584 ≈ 6.01 亿个叶子页
数据库按需分配页,不会提前把5亿个叶子节点全部创建出来,但理论上限的扩大意味着数据可以继续增长很久——代价是每次查询都要多付出一次磁盘IO。对单次点查来说是50%的损耗,一旦查询本身涉及范围扫描或多表join,性能劣化会更加明显。