MySQL深入理解, part 1

数据库要塞满了

标题起得很像书名,实际是记录我基于书+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_SLOTS2BPage Directory 中 Slot 的数量
PAGE_HEAP_TOP2B空闲空间起始偏移量
PAGE_N_HEAP2B页内记录总数(含虚拟记录)
PAGE_FREE2B被删除记录的链表头(可复用空间)
PAGE_GARBAGE2B已删除记录占用的字节数
PAGE_LAST_INSERT2B最后插入记录的偏移量
PAGE_DIRECTION2B插入方向(递增/递减/随机)
PAGE_N_DIRECTION2B同一方向连续插入的记录数
PAGE_N_RECS2B真实记录数(不含虚拟记录)
PAGE_MAX_TRX_ID8B修改此页的最大事务ID
PAGE_LEVEL2B当前页在B+树中的层级(叶子=0)
PAGE_INDEX_ID8B所属索引的ID
PAGE_BTR_SEG_LEAF10B叶子节点段信息
PAGE_BTR_SEG_TOP10B非叶子节点段信息

顾名思义,这里记录的是当前页的各项运行时元信息

  • File Header
字段大小说明
FIL_PAGE_SPACE_OR_CHKSUM4B校验和
FIL_PAGE_OFFSET4B页号,在表空间中的唯一编号
FIL_PAGE_PREV4B上一页的页号
FIL_PAGE_NEXT4B下一页的页号
FIL_PAGE_LSN8B最后修改此页的 LSN
FIL_PAGE_TYPE2B页类型(数据页/undo页/系统页等)
FIL_PAGE_FILE_FLUSH_LSN8B仅系统页使用
FIL_PAGE_ARCH_LOG_NO4B所属表空间 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,性能劣化会更加明显。