3-1-1-6-MySQL数据存储模型
1、MySQL数据存储模型详解
MySQL数据页知识全解析:从概念到源码的底层原理
一、数据页的核心定位
数据页(InnoDB Page)是InnoDB存储引擎的最小存储单元,所有数据(数据行、索引条目)都以页为单位存储在磁盘上。其设计目标是平衡IO效率(减少磁盘寻道次数)与内存管理(将页加载到缓冲池处理)。
- 固定大小:由
innodb_page_size控制(默认16KB,可选4K/8K/32K/64K); - B+树节点:InnoDB的索引(聚簇/二级)均以页为单位组织,叶子节点存数据,非叶子节点存索引键+子页指针;
- 持久化单位:修改数据页时,先写Redo Log(WAL),再将脏页刷回磁盘。
二、数据页的底层结构(结合源码)
InnoDB通过结构体定义页的物理布局,核心结构分布在page0page.h、fil0fil.h、buf0buf.h等文件中。以下是页的通用结构(以默认16KB为例):
| 区域 | 大小 | 核心字段与作用 | 源码对应结构 |
|---|---|---|---|
| 文件头(FIL_HEADER) | 128字节 | - FIL_PAGE_OFFSET:页号(磁盘上的唯一标识); - FIL_PAGE_PREV/FIL_PAGE_NEXT:前后页指针(叶子节点形成双向链表); - FIL_PAGE_TYPE:页类型(如FIL_PAGE_INDEX索引页、FIL_PAGE_TABLESPACE表空间页)。 |
fil_header_t(fil0fil.h) |
| 页头(PAGE_HEADER) | 140字节 | - PAGE_LEVEL:页在B+树中的层级(0=叶子节点,1=一级非叶子节点,依此类推); - PAGE_ROW_FORMAT:行格式(COMPACT/DYNAMIC/COMPRESSED); - PAGE_MAX_TRX_ID:最近修改该页的事务ID(MVCC依赖)。 |
page_header_t(page0page.h) |
| 数据部分(PAGE_BODY) | ~15KB | 存储具体内容: - 非叶子节点(索引页):键值+子页指针(如聚簇索引的PRIMARY KEY+下一页页号); - 叶子节点:数据行(聚簇索引存完整数据,二级索引存KEY+PRIMARY KEY)。 |
无独立结构,按行格式排列 |
| 页尾(PAGE_TRAILER) | 8字节 | - PAGE_CHECKSUM:页校验和(防止磁盘损坏); - PAGE_LSN:最后修改的日志序列号(Redo Log同步依赖)。 |
page_trailer_t(page0page.h) |
关键差异:叶子节点 vs 非叶子节点
- 非叶子节点(索引页):仅存储索引键值和指向子节点的页号,用于快速定位数据所在的叶子页;
- 叶子节点:存储实际数据行(聚簇索引)或键值+主键(二级索引),叶子节点之间通过
FIL_PAGE_PREV/FIL_PAGE_NEXT形成双向链表(支持范围查询的顺序扫描)。
三、数据页的生命周期与管理
数据页的生命周期围绕缓冲池(Buffer Pool)展开,核心是减少磁盘IO:
1. 缓冲池中的页管理
InnoDB的缓冲池是一个内存哈希表+链表结构,管理页的加载、淘汰与修改:
- Free链表:存储未使用的空闲页(从磁盘加载或页合并后释放);
- LRU链表:采用近似LRU算法(分为
young和old区域),缓存活跃页(最近访问过的页留在young区,避免“缓冲池污染”); - 脏页管理:修改过的页标记为脏页(
buf_dirty_page_t),由page cleaner线程异步刷回磁盘(避免阻塞前台线程)。
2. 页的加载与淘汰
- 加载:当查询需要某页时,通过
buf_read_page_low函数从磁盘读取页到缓冲池:- 根据
FIL_PAGE_OFFSET计算磁盘位置; - 分配缓冲池中的页块(
buf_block_t); - 将磁盘页复制到内存,并初始化页头/页尾。
- 根据
- 淘汰:当缓冲池满时,优先淘汰
LRU old区的非脏页(脏页需先刷回磁盘)。
四、数据页的核心操作原理(源码级拆解)
数据页的核心操作(查询、分裂、合并、预读)均围绕B+树结构和缓冲池展开,以下是最关键的三个操作:
1. 页查询:B+树定位 + 缓冲池加载 + 页内查找
流程:
- B+树定位:从根节点开始,逐层向下查找,直到找到目标叶子页的页号(
FIL_PAGE_OFFSET); - 缓冲池加载:调用
buf_read_page_low将页从磁盘读入缓冲池; - 页内查找:
- 索引页:通过二分查找定位目标键值的子页指针;
- 数据页:按行格式遍历(或通过二级索引回表后查找)。
源码关联:btr_search_guess_on_hash(哈希搜索,若启用)、page_cur_search_with_match(页内二分查找)。
2. 页分裂:插入导致页满的处理
触发条件:向叶子节点页插入数据时,页剩余空间不足(无法容纳新行)。
设计目标:分裂后,原页保留前50%数据,新页存储后50%数据,并将新页指针插入父节点(保持B+树平衡)。
源码逻辑(buf_split_page_low,位于buf0buf.c):
- 检查可分裂性:确认页剩余空间是否足够分裂(至少保留
innodb_page_size/2的空间); - 分配新页:从
Free链表获取空闲页,初始化页头(PAGE_LEVEL与原页一致,FIL_PAGE_TYPE为索引页); - 复制数据:将原页的后半部分数据(从
half_size偏移开始)复制到新页; - 更新页结构:
- 原页:更新
PAGE_NEXT指向新页,PAGE_PREV不变(保持链表连续性); - 新页:
PAGE_PREV指向原页,PAGE_NEXT指向原页的下一页;
- 原页:更新
- 更新父节点:将新页的键值+页号插入父节点(非叶子节点),维持B+树的有序性;
- 标记脏页:原页与新页均标记为脏页,写入Redo Log(保证崩溃恢复)。
分裂类型:
- 平衡分裂:插入到页中间位置(如页已满,插入到第8KB处);
- 不对称分裂:插入到页末尾(如顺序插入,分裂后新页几乎为空,但需维持B+树结构)。
3. 页合并:删除导致页利用率低的优化
触发条件:
- 页利用率低于阈值(默认
innodb_online_alter_log_max_size相关,或手动触发); - 相邻页(前/后)有足够的空闲空间(合并后不会超过页大小)。
设计目标:合并两个低利用率的页,减少B+树的节点数量,提升查询效率。
源码逻辑(buf_merge_pages,位于buf0buf.c):
- 检查相邻页:通过
FIL_PAGE_PREV/FIL_PAGE_NEXT找到相邻页,计算合并后的空间利用率; - 合并数据:将利用率低的页的数据复制到另一个页,释放空闲页;
- 更新父节点:删除父节点中指向空闲页的索引条目,维持B+树的紧凑性;
- 标记脏页:合并后的页标记为脏页,写入Redo Log。
4. 页预读:提升范围查询性能
设计目标:提前将相邻页加载到缓冲池,避免后续查询的磁盘IO。
策略:
- 线性预读:当顺序访问页时(如
id递增查询),预读后续的innodb_read_ahead_threshold个页(默认56个); - 随机预读:当高频访问某页的相邻页时(如随机查询但集中在某区域),预读相邻页。
源码逻辑(buf_read_ahead_linear,位于buf0rea.c):
- 统计页的访问模式(顺序/随机);
- 若满足预读条件,调用
buf_read_page加载相邻页到缓冲池; - 将预读的页加入
LRU young区,优先保留。
五、数据页的性能影响与优化
数据页的设计直接影响InnoDB的插入性能、查询性能和IO效率,以下是常见优化方向:
1. 减少页分裂的开销
- 问题:频繁页分裂会导致IO增加(写新页)、锁竞争(父节点更新)、Redo Log膨胀;
- 优化:
- 批量插入:减少单次插入的页分裂次数(如
INSERT INTO t VALUES (1),(2),...,(100)); - 调整
innodb_page_size:若数据行较大(如平均8KB),将页大小设为16K或32K,减少分裂次数; - 优化索引:避免热点页(如
user_id=1的页被频繁插入),可通过分库分表或哈希分区分散写入。
- 批量插入:减少单次插入的页分裂次数(如
2. 控制页合并的性能波动
- 问题:删除大量数据后,页合并会占用大量IO,导致查询延迟升高;
- 优化:
- 避免频繁删除:用
UPDATE标记删除(如软删除),定期批量物理删除; - 使用分区表:按时间分区(如
ORDER BY create_time),DROP PARTITION代替DELETE,直接删除整个分区的数据页。
- 避免频繁删除:用
3. 提升缓冲池的页命中率
- 问题:页命中率低会导致频繁磁盘IO,降低查询性能;
- 优化:
- 增大
innodb_buffer_pool_size:建议设置为物理内存的50%-70%,确保常用页留在内存; - 使用合适的行格式:
DYNAMIC或COMPRESSED行格式可提高页利用率(减少行碎片); - 避免
SELECT *:只查询需要的列,减少页内数据传输量。
- 增大
六、源码层面的关键总结
| 核心概念 | 源码文件 | 关键结构/函数 |
|---|---|---|
| 页结构 | page0page.h |
page_t(页整体结构)、page_header_t(页头) |
| 文件元数据 | fil0fil.h |
fil_header_t(文件头)、fil_page_t(页元数据) |
| 缓冲池管理 | buf0buf.h/buf0buf.c |
buf_block_t(缓冲池页块)、buf_split_page_low(页分裂) |
| 预读机制 | buf0rea.c |
buf_read_ahead_linear(线性预读) |
七、实战案例:高并发插入的性能优化
场景:电商订单表(聚簇索引id),高并发插入时CPU飙升,SHOW ENGINE INNODB STATUS显示Page splits过高。
分析:频繁的页分裂导致大量IO和锁竞争。
优化方案:
- 批量插入:将单条插入改为批量插入(每100条一次);
- 调整页大小:将
innodb_page_size从16K改为8K(订单行平均4K,8K页可容纳更多行,减少分裂次数); - 监控分裂次数:通过
INFORMATION_SCHEMA.INNODB_METRICS监控buffer_page_splits指标,确认优化效果。
八、总结
数据页是InnoDB的“积木”,所有上层操作(索引、查询、事务)都基于页的设计。理解页的结构、生命周期与核心操作,能帮助我们快速定位性能瓶颈(如页分裂、预读失效),并做出针对性的优化。源码层面的学习则能让我们透过现象看本质,比如页分裂的具体逻辑、预读的触发条件,这些都是架构师必备的底层能力。
如果需要进一步深入某部分(如行格式与页的关系、Redo Log与脏页的交互),或想结合具体项目场景讨论优化,欢迎随时提出!
2、MySQL物理存储于逻辑模型的关系
要回答这两个问题,我们需要从「行格式的约束」和「页与B+树的映射关系」两个维度展开,结合InnoDB的底层设计逻辑和源码细节逐一拆解:
一、MySQL会不会出现「一行数据分属于两个页」的情况?
结论:绝对不会。这是InnoDB行格式的强约束,从设计上杜绝了「行跨页」的可能。
1. 行格式的长度限制
InnoDB的行格式(如COMPACT/DYNAMIC/COMPRESSED)对单行数据的最大长度有严格限制:
-
对于
COMPACT行格式(默认),单行最大长度 =innodb_page_size/ 2 - 20字节(20字节是行头的开销,存储变长字段长度、NULL标志位等)。例如,默认
innodb_page_size=16KB时,单行最大长度约为8KB - 20字节(约8184字节)。 -
对于
DYNAMIC/COMPRESSED行格式,单行最大长度同样不能超过页大小的一半(否则会触发「溢出页」机制,但不是「跨页」)。
2. 变长字段与溢出页的处理
如果某行包含超长字段(如TEXT/BLOB),InnoDB会将其存储到溢出页(Off-Page),但主行数据仍完整存放在原页中:
- 主行中仅保留一个
20字节的指针,指向溢出页的地址; - 溢出页单独存储超长字段的内容(可以是一个或多个页)。
这种方式既避免了「行跨页」,又保证了主行的连续性——一行数据的「逻辑完整性」始终由一个页承载。
3. 源码层面的验证
InnoDB在插入行时会先检查行长度:
- 源码文件:
row0ins.cc中的row_ins_check_foreign_constraint和row_ins_clust_index_entry函数; - 逻辑:计算行的总长度(包括行头、变长字段长度、NULL标志位等),如果超过
PAGE_HALF_SIZE(page)(页大小的一半),则直接报错Row size too large(错误码1397)。
二、页这种物理结构如何支撑B+树的逻辑结构?
B+树的逻辑特征是层次化、有序性、双向链表,而InnoDB通过页的物理属性(固定大小、内部排序、页间指针)将这些逻辑特征落地为物理实现。
1. 页与B+树节点的一一映射
InnoDB的B+树中,每个节点对应一个物理页:
- 叶子节点:存储实际数据行(聚簇索引)或索引键+主键(二级索引);
- 非叶子节点:存储索引键值 + 子节点页号(用于定位下一层节点);
- 根节点:特殊的非叶子节点,是B+树的入口。
2. 页内结构支撑B+树的「有序性」
无论是叶子节点还是非叶子节点,页内的数据/键值都是按顺序排列的(物理上连续,逻辑上有序):
- 非叶子节点:键值按升序排列,每个键值后紧跟子节点页号(例如:键
K1对应子页P1,键K2对应子页P2,且K1 < K2); - 叶子节点:数据行按聚簇索引键升序排列(或二级索引键升序排列)。
这种页内排序是B+树「快速查找」的基础——在页内可以通过二分查找快速定位目标键或数据(源码函数:page_cur_search_with_match,位于page0cur.cc)。
3. 页间指针支撑B+树的「层次化」与「双向链表」
页的文件头(FIL_HEADER)包含两个关键指针:
FIL_PAGE_PREV:指向当前页的前一个页;FIL_PAGE_NEXT:指向当前页的后一个页。
这些指针的作用:
- 非叶子节点:通过「键值+子页号」实现父节点到子节点的逻辑连接(例如:根节点通过子页号找到下一层节点,直到叶子节点);
- 叶子节点:通过
FIL_PAGE_PREV和FIL_PAGE_NEXT形成双向链表,支撑B+树的范围查询(例如:SELECT * FROM t WHERE id BETWEEN 10 AND 100,只需找到id=10的叶子页,然后沿着链表扫描到id=100即可,无需回溯到上层节点)。
4. 源码层面的B+树页交互示例
以查找键值K为例,B+树的物理执行流程:
- 加载根节点页:根据
FIL_PAGE_OFFSET从磁盘读取根节点页到缓冲池; - 页内二分查找:在根节点页内找到
K对应的子页号P(源码:page_cur_search_with_match); - 加载子页
P:递归加载子页P,重复步骤2,直到到达叶子节点页; - 叶子节点查找:在叶子节点页内找到
K对应的数据行(或范围扫描的起始位置)。
5. 页大小对B+树的影响
页的大小(innodb_page_size)直接决定了B+树的深度和性能:
- 页越大,每个节点能存储的键值越多,B+树的深度越浅(例如:16KB页的B+树深度约3层,可存储百万级数据;32KB页的深度约2层,可存储千万级数据);
- 更浅的深度意味着更少的磁盘IO(查找时只需访问更少的页),提升查询性能。
三、总结:页是B+树的「物理载体」
InnoDB通过行格式的约束杜绝了「行跨页」,确保单行数据的完整性;通过页内的排序支撑B+树的「有序性」,通过页间的指针支撑B+树的「层次化」和「双向链表」。最终,物理的页结构完美映射了B+树的逻辑结构,让B+树的高效查找、插入、删除成为可能。
延伸思考:为什么选择「页」作为存储单元?
- 减少IO次数:磁盘的最小IO单位是扇区(通常512字节),但页的大小(16KB)是扇区的整数倍,能减少磁盘寻道次数;
- 内存管理的便利性:页是缓冲池(Buffer Pool)的最小管理单元,方便实现LRU、预读等算法;
- 事务的支持:页是Redo Log和Undo Log的最小记录单元,保证事务的原子性和一致性。
如果需要进一步探讨「B+树的插入/删除如何通过页实现」(比如页分裂对B+树的影响),或结合具体场景(如范围查询的物理流程),欢迎随时提出!
3、MySQL物理存储与数据查询的处理链路
你的理解抓住了核心逻辑,但需要补充两个关键细节(页链表的适用范围、页内有序的本质),再用更精准的语言重构认知,结合B+树的物理结构和范围查询的真实流程,帮你彻底打通“存储→查询”的底层链路:
一、重新梳理MySQL的存储结构:「树状索引+链表页集合」
InnoDB的存储结构是B+树逻辑结构在物理层的映射,核心由两部分组成:
- 非叶子节点(索引页):构成B+树的上层索引层,每个页存储「索引键+子页号」,通过键值排序组织成树状结构(无前后页链表);
- 叶子节点(数据/二级索引页):构成B+树的下层数据层,每个页存储「实际数据行/二级索引键+主键」,通过双向链表(
FIL_PAGE_PREV/FIL_PAGE_NEXT)连成一个全局有序的页序列。
关键修正:页的“有序性”与“随机性”
- 页内:无论叶子还是非叶子节点,数据/键值都是逻辑有序的(通过页内的二分查找定位目标);
- 页间:
- 叶子节点:通过双向链表连成全局有序的页序列(比如所有叶子页按
id升序排列,页A→页B→页C对应id 1-100→id 101-200→id 201-300); - 非叶子节点:通过「键值+子页号」组织成树状结构(无物理链表,靠键值关联)。
- 叶子节点:通过双向链表连成全局有序的页序列(比如所有叶子页按
结论:更精准的概括
MySQL的存储结构可以理解为:
- B+树的上层(非叶子节点):用树状页结构快速定位数据所在的叶子页;
- B+树的下层(叶子节点):用双向链表连接的页集合支撑范围查询,每个页内逻辑有序(支持二分查找)。
二、范围查询的真实流程:「树定位+链表扫描+页内二分」
以SELECT \* FROM t WHERE id BETWEEN 1000 AND 2000(聚簇索引id)为例,物理执行流程如下:
1. 第一步:通过B+树定位起始叶子页
- 从根节点页开始,逐层向下查找:
- 根节点页(非叶子):通过二分查找找到
id=1000对应的子页号P1(假设P1是存储id 1-1000的叶子页); - 加载叶子页
P1:发现id=1000是P1的最后一个条目,需要找下一个页。
- 根节点页(非叶子):通过二分查找找到
2. 第二步:沿着叶子链表扫描后续页
- 从
P1的FIL_PAGE_NEXT指针找到下一个叶子页P2(存储id 1001-2000); - 继续沿着链表扫描,直到找到
id=2000所在的页Pn。
3. 第三步:每个页内通过二分查找定位起始位置
- 对于每个扫描到的叶子页(如
P2):- 不需要从头遍历页内所有数据;
- 通过页内的二分查找(源码:
page_cur_search_with_match)快速定位id≥1001的起始行; - 顺序读取该页内符合条件的行。
关键优化:避免全页扫描
范围查询的性能瓶颈往往是页内遍历,因此InnoDB做了两个优化:
- 页内二分查找:快速定位页内起始位置(时间复杂度
O(log n),n是页内行数); - 预读机制:提前将后续叶子页加载到缓冲池(减少磁盘IO)。
三、源码层面的验证:范围查询的核心函数
InnoDB的范围查询(BTR_SEARCH_RANGE)核心逻辑在btr0cur.c中的btr_cur_search_to_nth_level和btr_pcur_open_at_index_side函数:
- 树定位:
btr_cur_search_to_nth_level通过B+树找到起始叶子页的游标; - 链表扫描:
btr_pcur_open_at_index_side通过叶子页的FIL_PAGE_NEXT指针遍历后续页; - 页内查找:
page_cur_move_to_next函数在页内移动游标,结合二分查找定位起始行。
四、总结:存储结构与查询的完美匹配
InnoDB的存储设计完全围绕“高效查询”展开:
- 非叶子节点的树结构:用最少的页访问次数定位数据所在的叶子页(减少磁盘IO);
- 叶子节点的链表+页内有序:用最快的速度完成范围扫描(避免全表遍历)。
延伸思考:为什么叶子节点用双向链表?
- 支持正向/反向范围查询(比如
id BETWEEN 1000 AND 2000和id BETWEEN 2000 AND 1000都能高效执行); - 方便页合并(删除数据后,相邻页可以通过链表快速合并,减少B+树的碎片)。
如果需要进一步模拟具体数据页的内容(比如一个叶子页存了哪些数据,链表怎么连),或探讨聚簇索引vs二级索引的存储差异,欢迎随时提出!
4、具体数据页的内容
要理解具体数据页的内容和叶子页的链表连接方式,我们需要从页的物理结构、行数据存储格式、链表指针的作用三个维度展开,并结合聚簇索引和二级索引的具体例子,用「具象化的存储模型」+「源码映射」的方式讲透。
一、数据页的「物理结构」:从文件头到页尾的完整布局
InnoDB的叶子页(无论是聚簇索引还是二级索引)遵循统一的物理结构,核心区域包括:
文件头 → 页头 → 数据部分 → 页尾
以下是默认16KB叶子页的具体布局(结合源码page0page.h/fil0fil.h):
1. 文件头(FIL_HEADER,128字节)
存储页的元数据,关键字段:
FIL_PAGE_OFFSET:页号(磁盘唯一标识,如0x00000001表示第1页);FIL_PAGE_PREV:前一页页号(叶子页的双向链表前驱);FIL_PAGE_NEXT:后一页页号(叶子页的双向链表后继);FIL_PAGE_TYPE:页类型(叶子页为FIL_PAGE_INDEX,值为17855)。
2. 页头(PAGE_HEADER,140字节)
存储页的状态信息,关键字段:
PAGE_LEVEL:页在B+树中的层级(叶子节点固定为0);PAGE_ROW_FORMAT:行格式(如COMPACT=0,DYNAMIC=5);PAGE_MIN_REC_NUM:页中最小记录数(用于页合并判断);PAGE_MAX_TRX_ID:最近修改该页的事务ID(MVCC依赖)。
3. 数据部分(PAGE_BODY,~15KB)
核心区域,存储具体的行数据(聚簇索引)或索引键+主键(二级索引)。
数据的排列规则:
- 按索引键升序排序(物理上连续,逻辑上有序);
- 行格式严格遵循定义(如
COMPACT行的结构:行头 → 变长字段长度 → NULL标志位 → 数据内容)。
4. 页尾(PAGE_TRAILER,8字节)
存储校验信息:
PAGE_CHECKSUM:页的CRC校验和(防止磁盘损坏);PAGE_LSN:最后修改的Redo Log序列号(用于崩溃恢复)。
二、叶子页的「行数据存储」:以聚簇索引和二级索引为例
我们用电商订单表的具体场景,展示叶子页的行数据存储:
场景定义
订单表结构:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 聚簇索引键
user_id INT NOT NULL, -- 二级索引键
amount DECIMAL(10,2) NOT NULL,
create_time DATETIME NOT NULL,
remark TEXT -- 超长字段,存溢出页
) ROW_FORMAT=COMPACT;
1. 聚簇索引叶子页:存储完整数据行
聚簇索引的叶子页直接存储订单的完整数据,行格式为COMPACT:
- 行头(5字节):存储行状态(如是否删除)、簇指针(用于InnoDB的行移动);
- 变长字段长度(1字节):
remark是TEXT类型,变长字段总长度占1字节; - NULL标志位(1字节):标记
remark是否为NULL(此处假设非NULL,值为0x01); - 数据内容:
id:8字节(BIGINT);user_id:4字节(INT);amount:8字节(DECIMAL(10,2));create_time:5字节(DATETIME);remark:指向溢出页的20字节指针(因为TEXT超长,主行不存内容)。
示例行数据(十六进制简化):
0x01 0x01 0x08 0x04 0x08 0x05 ... 0x00 0x01 ...
(解释:行头0x01,变长长度0x01,NULL标志0x01,id=1,user_id=1,amount=100.00,create_time='2024-05-20',remark指针)
2. 二级索引叶子页:存储「索引键+主键」
如果为user_id创建二级索引:
CREATE INDEX idx_user_id ON orders(user_id);
二级索引的叶子页不存完整数据,仅存:
user_id:4字节(索引键);id:8字节(聚簇索引键,用于回表)。
示例行数据:
0x01 0x00 0x00 0x00 0x01 ... 0x00 0x00 0x00 0x00 0x00 0x01 ...
(解释:user_id=1,id=1)
三、叶子页的「双向链表连接」:如何串联所有数据页?
叶子页的双向链表是通过文件头的FIL_PAGE_PREV和FIL_PAGE_NEXT指针实现的,核心目标是支撑范围查询的顺序扫描。
1. 链表的构建规则
- 叶子页按索引键升序排列(如
orders.id从小到大); - 每个叶子页的
FIL_PAGE_NEXT指向下一个更大的叶子页; - 每个叶子页的
FIL_PAGE_PREV指向上一个更小的叶子页; - 第一个叶子页的
FIL_PAGE_PREV为FIL_NULL(0xFFFFFFFF); - 最后一个叶子页的
FIL_PAGE_NEXT为FIL_NULL。
2. 具体示例:订单表的叶子页链表
假设订单表有3个叶子页,存储的id范围分别是:
- 页1(
FIL_PAGE_OFFSET=100):id 1-100; - 页2(
FIL_PAGE_OFFSET=101):id 101-200; - 页3(
FIL_PAGE_OFFSET=102):id 201-300。
链表连接关系:
- 页1的
FIL_PAGE_NEXT=101(指向页2); - 页2的
FIL_PAGE_PREV=100(指向页1),FIL_PAGE_NEXT=102(指向页3); - 页3的
FIL_PAGE_PREV=101(指向页2)。
3. 范围查询时的链表使用
当执行SELECT * FROM orders WHERE id BETWEEN 150 AND 250:
- 定位起始页:通过B+树找到
id=150所在的页2; - 遍历链表:从页2开始,沿着
FIL_PAGE_NEXT找到页3; - 页内二分查找:
- 页2:二分查找找到
id≥150的起始行; - 页3:二分查找找到
id≤250的结束行;
- 页2:二分查找找到
- 返回结果:合并页2和页3的符合条件的行。
四、源码层面的链表操作验证
InnoDB的页链表操作核心函数在buf0buf.c和btr0btr.c中:
- 加载页时初始化链表:
buf_read_page_low函数会读取FIL_PAGE_PREV和FIL_PAGE_NEXT,并建立缓冲池中的页链接; - 页分裂时更新链表:
buf_split_page_low函数会将新页插入到原页的链表位置(原页的FIL_PAGE_NEXT指向新页,新页的FIL_PAGE_PREV指向原页,新页的FIL_PAGE_NEXT指向原页的下一页); - 页合并时更新链表:
buf_merge_pages函数会删除被合并的页的链表指针,将前驱和后继直接连接。
五、总结:叶子页的「存储+链表」模型
叶子页的设计可以概括为:
- 存储层:按索引键升序存储行数据(聚簇索引存完整数据,二级索引存键+主键);
- 连接层:通过
FIL_PAGE_PREV/FIL_PAGE_NEXT形成双向链表,串联所有叶子页; - 查询层:范围查询时,先通过B+树定位起始页,再沿着链表扫描后续页,每个页内用二分查找快速定位起始位置。
延伸思考:为什么叶子页要用双向链表?
- 范围查询效率高:无需回溯到上层节点,直接沿着链表扫描;
- 页合并高效:删除数据后,相邻页可以通过链表快速合并,减少B+树碎片;
- 支持反向查询:可以沿着
FIL_PAGE_PREV指针反向扫描(如ORDER BY id DESC)。
如果需要模拟一个真实叶子页的二进制内容(比如页1的16KB数据具体怎么排列),或探讨溢出页与叶子页的交互,欢迎随时提出!
5、叶子页具体存储内容样例
要模拟真实叶子页的二进制内容,我们需要严格遵循InnoDB的物理存储规范,结合具体的表结构和行数据,逐字节拆解页的各个部分(文件头→页头→数据部分→页尾)。以下是一个16KB聚簇索引叶子页的完整模拟(以电商订单表为例),包含字节偏移、内容解释、十六进制值三部分。
一、前置条件:表结构与行格式
我们使用之前的订单表结构,行格式为COMPACT:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 聚簇索引键(8字节)
user_id INT NOT NULL, -- 用户ID(4字节)
amount DECIMAL(10,2) NOT NULL, -- 订单金额(8字节)
create_time DATETIME NOT NULL, -- 创建时间(5字节,无微秒)
remark TEXT -- 备注(超长,存溢出页,20字节指针)
) ROW_FORMAT=COMPACT;
二、叶子页的16KB二进制布局模拟
InnoDB叶子页的总大小为16KB(16384字节),按物理结构分为4个区域。以下是页1(存储id=1~100的订单)的具体内容:
1. 文件头(FIL_HEADER):偏移0~127字节(128字节)
存储页的元数据,关键字段的十六进制值与解释如下:
| 偏移范围 | 字段 | 十六进制值 | 解释 |
|---|---|---|---|
| 0~3 | FIL_PAGE_OFFSET | 0x00000001 | 页号:第1页 |
| 4~7 | FIL_PAGE_PREV | 0xFFFFFFFF | 前一页指针:FIL_NULL(第一个叶子页,无前置页) |
| 8~11 | FIL_PAGE_NEXT | 0x00000065 | 后一页指针:第101页(0x65=101) |
| 12~15 | FIL_PAGE_LSN | 0x0000000000000000 | 最后修改的Redo Log序列号(初始状态) |
| 16~19 | FIL_PAGE_TYPE | 0x45BF | 页类型:FIL_PAGE_INDEX(索引页,叶子节点也是索引页) |
| 20~127 | 其他元数据 | 0x0000... | 保留字段,填充0 |
2. 页头(PAGE_HEADER):偏移128~267字节(140字节)
存储页的状态信息,关键字段的十六进制值与解释如下:
| 偏移范围 | 字段 | 十六进制值 | 解释 |
|---|---|---|---|
| 128~129 | PAGE_LEVEL | 0x0000 | B+树层级:0(叶子节点) |
| 130~131 | PAGE_ROW_FORMAT | 0x0000 | 行格式:COMPACT(0) |
| 132~133 | PAGE_SPACE | 0x0000 | 表空间ID(默认0) |
| 134~135 | PAGE_MIN_REC_NUM | 0x0000 | 页中最小记录数(0,未达到阈值) |
| 136~143 | PAGE_MAX_TRX_ID | 0x0000000000000000 | 最近修改该页的事务ID(无修改,为0) |
| 144~267 | 其他状态信息 | 0x0000... | 保留字段,填充0 |
3. 数据部分(PAGE_BODY):偏移26816365字节(16KB)
核心区域,存储按id升序排列的行数据。我们模拟前2行(id=1和id=2)的内容,后续行以此类推(每行结构相同,id递增)。
行格式说明(COMPACT)
每行的结构为:行头(5字节)→ 变长字段长度(1字节)→ NULL标志位(1字节)→ 数据内容。
第1行:id=1的订单
偏移:268~319字节(52字节),十六进制值与解释如下:
| 偏移范围 | 字段 | 十六进制值 | 解释 |
|---|---|---|---|
| 268~272 | 行头 | 0x0100000000 | 行状态(0x01=正常行)+ 簇指针(0x00000000,用于行移动) |
| 273 | 变长字段长度 | 0x01 | 变长字段数量:1(仅remark是变长字段) |
| 274 | NULL标志位 | 0x01 | 标记NULL字段:remark非NULL(第0位为1) |
| 275~282 | id(BIGINT) | 0x0000000000000001 | 订单ID:1 |
| 283~286 | user_id(INT) | 0x00000001 | 用户ID:1 |
| 287~294 | amount(DECIMAL) | 0x0000000000000190 | 订单金额:100.00(DECIMAL(10,2)存储为8字节整数,100.00=10000) |
| 295~299 | create_time(DATETIME) | 0x000000000000283A | 创建时间:2024-05-20 10:00:00(DATETIME存储为5字节,值为0x283A) |
| 300~319 | remark(溢出指针) | 0x00000000000000000001 | 溢出页指针:溢出页号1,页内偏移0(remark文本存于溢出页) |
第2行:id=2的订单
偏移:320~371字节(52字节),结构与第1行一致,仅id及相关字段递增:
| 偏移范围 | 字段 | 十六进制值 | 解释 |
|---|---|---|---|
| 320~324 | 行头 | 0x0100000000 | 正常行 |
| 325 | 变长字段长度 | 0x01 | 1个变长字段(remark) |
| 326 | NULL标志位 | 0x01 | remark非NULL |
| 327~334 | id(BIGINT) | 0x0000000000000002 | 订单ID:2 |
| 335~338 | user_id(INT) | 0x00000002 | 用户ID:2 |
| 339~346 | amount(DECIMAL) | 0x00000000000001F4 | 订单金额:200.00(10000→20000,即0x1F4) |
| 347~351 | create_time(DATETIME) | 0x000000000000283B | 创建时间:2024-05-20 10:00:01 |
| 352~371 | remark(溢出指针) | 0x00000000000000000002 | 溢出页号2,页内偏移0 |
后续行的规律
- 每行长度固定为52字节(COMPACT格式下,该表每行无额外变长字段,长度一致);
- 按
id升序排列,id=3的行偏移为372字节,依此类推,直到id=100的行结束于268 + 100×52 - 1 = 5467字节; - 若
id=101的行需插入,会触发页分裂(见下文“页尾与分裂预留”)。
4. 页尾(PAGE_TRAILER):偏移16366~16383字节(8字节)
存储页的校验与日志信息:
| 偏移范围 | 字段 | 十六进制值 | 解释 |
|---|---|---|---|
| 16366~16379 | PAGE_CHECKSUM | 0xABCD1234 | 页的CRC校验和(防止磁盘损坏) |
| 16380~16383 | PAGE_LSN | 0x0000000000000000 | 最后修改的Redo Log序列号(初始状态) |
三、关键细节说明
-
行长度一致性:
该表每行无额外变长字段(
remark存溢出页,不占行内变长空间),因此每行长度固定为52字节,便于页内二分查找。 -
溢出页指针:
remark字段是TEXT类型,InnoDB将其存于溢出页,主行仅保留20字节的指针(指向溢出页的页号和页内偏移)。 -
页分裂预留:
页尾预留了部分空间(如
id=100后仍有空间),但当页满时(如插入id=101),会触发页分裂:- 分配新页(页101);
- 将原页(页1)的后半部分数据(
id=51~100)复制到新页; - 更新链表指针:页1的
FIL_PAGE_NEXT指向页101,页101的FIL_PAGE_PREV指向页1,FIL_PAGE_NEXT指向原页101。
四、总结:叶子页的二进制本质
叶子页的二进制内容是物理存储的“有序行集合”,核心特征:
- 全局有序:按索引键(
id)升序排列,支持二分查找; - 结构固定:每行遵循
COMPACT格式,长度一致(或可变但有规律); - 链表关联:通过文件头的
FIL_PAGE_PREV/FIL_PAGE_NEXT与相邻页连接,支撑范围查询。
延伸思考:如何验证真实页内容?
可以使用InnoDB的工具(如innodb_ruby)解析物理文件:
-
导出表空间的物理文件(
ibdata1或独立表空间orders.ibd); -
使用
innodb_space工具查看页内容:innodb_space -f orders.ibd -p orders -s 1 show_page输出结果会包含文件头、页头、数据部分的详细信息,与你模拟的内容一致。
如果需要模拟二级索引叶子页的二进制内容(如idx_user_id的页),或探讨页分裂后的二进制变化,欢迎随时提出!
6、溢出页机制详解
要理解溢出页与叶子页的交互机制,我们需要从触发条件、存储映射、读写流程、管理细节四个维度展开,结合行格式约束和InnoDB的物理存储规则,用「具象化流程+源码映射」的方式讲透两者的协作逻辑。
一、前置知识:溢出页的触发条件
溢出页(Off-Page)是InnoDB为解决单行数据过长问题设计的补充存储结构。其核心触发条件是:
当行数据(含变长字段、NULL标志位、行头)的总长度超过「页大小的一半 - 20字节」时,InnoDB会将超长字段的内容剥离到独立的溢出页,叶子页仅保留指向溢出页的20字节指针。
- 页大小的影响:默认
innodb_page_size=16KB时,单行最大允许长度约8KB - 20字节(约8184字节);若页大小设为32KB,单行最大长度约16KB - 20字节。 - 行格式的差异:
COMPACT/DYNAMIC/COMPRESSED行格式均遵循此规则,但DYNAMIC格式会将变长字段完全存储到溢出页(而非部分存储)。
二、溢出页与叶子页的存储映射
叶子页与溢出页的交互本质是「指针引用」+「内容分离」:
- 叶子页:存储超长字段的元信息(如字段类型、溢出页指针);
- 溢出页:存储超长字段的完整内容(如
TEXT/BLOB的实际字符)。
1. 叶子页的存储结构(以TEXT字段为例)
假设订单表的remark字段是TEXT类型,叶子页中remark的存储方式是20字节的指针,结构如下:
| 偏移 | 长度(字节) | 内容 | 解释 |
|---|---|---|---|
| 0 | 4 | 溢出页号 | 指向存储remark内容的第一个溢出页(如0x00000001表示第1个溢出页) |
| 4 | 4 | 溢出页内偏移 | 指向溢出页中remark内容的起始位置(如0x00000000表示页开头) |
| 8 | 4 | 溢出页数量 | 存储remark内容用了多少个溢出页(如0x00000003表示3个溢出页) |
| 12 | 4 | 预留 | 填充0 |
| 16 | 4 | 字段类型标识 | 标识remark是TEXT类型(如0x00000005) |
总结:叶子页通过20字节指针,准确定位remark内容的存储位置和分布情况。
2. 溢出页的存储结构
溢出页本身是普通的InnoDB页(类型为FIL_PAGE_SDI或FIL_PAGE_INDEX),但内容是连续的超长字段数据,并包含链表指针连接多个溢出页:
- 文件头:与普通叶子页一致(存储页号、前后页指针);
- 数据部分:存储超长字段的连续内容(如
remark的字符序列); - 链表指针:通过
FIL_PAGE_NEXT指向下一个溢出页(若有多个),形成溢出页链表。
示例:若remark内容需要3个溢出页存储:
- 页1(
FIL_PAGE_OFFSET=1001):存储remark的前16KB内容,FIL_PAGE_NEXT=1002; - 页2(
FIL_PAGE_OFFSET=1002):存储remark的中间16KB内容,FIL_PAGE_NEXT=1003; - 页3(
FIL_PAGE_OFFSET=1003):存储remark的最后部分,FIL_PAGE_NEXT=FIL_NULL(无后续页)。
三、交互流程:插入→查询→更新
我们用插入一条含长remark的订单记录为例,拆解溢出页与叶子页的完整交互:
1. 插入流程:分离超长字段到溢出页
步骤1:判断行长度
插入remark='这是一条非常长的备注...'(假设总长度超过8KB)时,InnoDB会计算行总长度:
- 行头(5字节)+ 变长长度(1字节)+ NULL标志位(1字节)+
id(8字节)+user_id(4字节)+amount(8字节)+create_time(5字节)+remark(假设8195字节)= 8227字节,超过8184字节的阈值。
步骤2:分配溢出页
从缓冲池的Free链表获取3个空闲页(页1001、1002、1003),用于存储remark内容:
- 将
remark内容分割为3段(16KB、16KB、剩余部分),分别写入页1001、1002、1003; - 初始化溢出页链表:页1001的
FIL_PAGE_NEXT=1002,页1002的FIL_PAGE_NEXT=1003,页1003的FIL_PAGE_NEXT=FIL_NULL。
步骤3:叶子页存储指针
将remark的20字节指针写入叶子页的remark列位置:
- 指针内容:
溢出页号=1001、页内偏移=0、溢出页数量=3、字段类型=TEXT。
步骤4:完成插入
将叶子页的其他字段(id、user_id等)写入,更新页头/页尾,提交事务(写Redo Log)。
2. 查询流程:从叶子页到溢出页的拼接
当执行SELECT * FROM orders WHERE id=1时,InnoDB会:
步骤1:定位叶子页
通过B+树找到id=1所在的叶子页(页X)。
步骤2:读取叶子页的指针
从叶子页的remark列读取20字节指针,获取溢出页号=1001、页内偏移=0、溢出页数量=3。
步骤3:加载溢出页
根据指针加载页1001、1002、1003到缓冲池(若未命中),并沿着FIL_PAGE_NEXT指针遍历溢出页链表。
步骤4:拼接溢出内容
将3个溢出页的内容按顺序拼接,得到完整的remark字符串。
步骤5:返回结果
将叶子页的主行数据与拼接后的remark组合,返回给客户端。
3. 更新流程:修改溢出字段的处理
当执行UPDATE orders SET remark='新的超长备注...' WHERE id=1时:
情况1:长度不变或缩短
直接修改对应溢出页的内容(若长度缩短,无需调整溢出页数量)。
情况2:长度变长,超出原溢出页容量
- 分配新的溢出页(如页1004);
- 将原溢出页的多余内容复制到页1004;
- 更新叶子页的指针:
溢出页号=1001、溢出页数量=4; - 修改页1003的
FIL_PAGE_NEXT=1004,页1004的FIL_PAGE_NEXT=FIL_NULL。
四、源码层面的关键验证
InnoDB对溢出页的处理核心函数分布在row0row.c和page0page.c中:
- 插入溢出字段:
row_ins_overflow函数负责分配溢出页、分割内容、写入指针; - 读取溢出字段:
row_sel_get_overflow_data函数负责根据叶子页指针加载溢出页、拼接内容; - 溢出页链表管理:
buf_page_set_next/buf_page_get_next函数负责维护溢出页的FIL_PAGE_NEXT指针。
五、性能影响与设计建议
溢出页的交互虽然解决了长字段存储问题,但会带来性能开销:
- 查询延迟增加:需要额外IO加载溢出页,尤其是当溢出页数量多(如
TEXT内容很大)时; - 内存占用增加:溢出页会被加载到缓冲池,占用宝贵的内存资源;
- 索引限制:无法对溢出字段建立普通索引(仅能建立全文索引)。
设计建议:
- 避免高频查询字段用
TEXT/BLOB:将经常查询的字段(如user_id、create_time)放在主行,超长字段(如remark)用溢出页; - 垂直拆分表:将超长字段拆分到单独的表(如
order_remark),通过主键关联; - 限制溢出页数量:对
TEXT字段设置合理的长度限制,避免使用过多溢出页。
总结:溢出页与叶子页的协作本质
溢出页与叶子页的交互是「主行轻量化」与「内容分离存储」的平衡:
- 叶子页存储核心数据的有序性(支撑B+树的快速查找);
- 溢出页存储非核心的长内容(解决单行长度限制问题);
- 两者通过20字节指针和溢出页链表实现协作,既保证了主行的紧凑性,又支持长字段的存储。
如果需要模拟溢出页的物理内容(比如页1001存储的remark前16KB内容),或探讨全文索引对溢出字段的处理,欢迎随时提出!
7、页内二分查找流程解析
要理解页内二分查找的落地过程,我们需要结合具体场景、源码函数和字节级的行解析,用「从加载页到定位行的完整流程」讲透InnoDB如何利用页内有序性实现高效查找。
一、前置场景与表结构
我们继续使用订单表orders的场景,聚焦聚簇索引叶子页的页内查找:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 聚簇索引键(8字节,升序排列)
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
create_time DATETIME NOT NULL,
remark TEXT
) ROW_FORMAT=COMPACT; -- 行格式为COMPACT
假设叶子页Page1(FIL_PAGE_OFFSET=100)存储了id=1~100的订单,每行长度固定为52字节(COMPACT格式下无额外变长字段),按id升序排列。
二、查找目标:id=50的订单
我们的目标是通过页内二分查找快速定位id=50的行,而非全页扫描。
三、完整流程:从加载页到二分查找
1. 第一步:加载叶子页到缓冲池
通过B+树定位到id=50所在的叶子页Page1(页号100),调用buf_read_page_low函数将页从磁盘读取到缓冲池(细节见之前的讨论)。
2. 第二步:初始化页内查找游标
InnoDB用page_cur_t结构表示页内查找的游标,核心字段:
buf_block_t* block:指向缓冲池中的页块;byte* rec:指向当前查找的行;ulint offset:当前行的字节偏移量。
初始化时,游标指向页的数据部分起始位置(offset=268,即文件头+页头之后的位置)。
3. 第三步:执行页内二分查找(源码核心:page_cur_search_with_match)
InnoDB的页内查找核心函数是page_cur_search_with_match(位于page0cur.cc),其逻辑是标准的二分查找,适配InnoDB的页结构。
函数原型与关键参数
/** 在页内查找匹配的记录
* @param cur 页内查找游标
* @param key 目标键值(此处为id=50)
* @param match_mode 匹配模式(此处为PAGE_CUR_GE,找大于等于目标键的行)
*/
void page_cur_search_with_match(
page_cur_t* cur, -- 页内游标
const rec_t* key, -- 目标键(id=50)
page_cur_mode_t match_mode -- 匹配模式
);
二分查找的具体步骤(以id=50为例)
我们以id=50为例,拆解二分查找的字节级操作:
步骤1:计算页内行的数量
页数据部分总大小为16365 - 268 + 1 = 16098字节(偏移268到16365),每行52字节,因此总行数=16098 / 52 ≈ 309行(实际是100行,此处简化为计算逻辑)。
源码中通过page_get_n_recs(cur->block->page)获取页内行数n_recs(此处n_recs=100)。
步骤2:初始化二分查找的边界
low:页内第一行的偏移量(268);high:页内最后一行的偏移量(268 + (100-1)*52 = 268 + 49 * 52 = 268 + 2548 = 2816字节)。
步骤3:第一次二分查找
-
计算中间位置:
mid = (low + high) / 2 = (268 + 2816) / 2 = 1542字节; -
解析
mid偏移处的行:该行是第
(1542 - 268)/52 + 1 = (1274)/52 +1 ≈24.5+1=25.5→第25行(id=25); -
比较该行
id=25与目标id=50:25 < 50,因此调整low=mid + 52(下一行的偏移量),缩小查找范围到[mid+52, high]。
步骤4:第二次二分查找
- 新的
low=1542+52=1594,high=2816; - 中间位置
mid=(1594+2816)/2=2205字节; - 解析该行:第
(2205-268)/52+1≈(1937)/52+1≈37.25+1=38.25→第38行(id=38); - 比较
38 < 50,调整low=2205+52=2257。
步骤5:重复直到找到目标
经过约7次二分查找(log2(100)≈7),最终定位到:
- 目标行的偏移量:
268 + (50-1)*52 = 268 + 49 * 52 = 268 + 2548 = 2816字节; - 解析该行:
id=50,匹配成功!
源码中的关键逻辑(简化版)
page_cur_search_with_match函数的核心循环:
while (low <= high) {
// 1. 计算中间行的偏移量
ulint mid = (low + high) / 2;
// 2. 解析中间行的记录(rec_t*)
rec_t* mid_rec = page_get_rec_at_offset(cur->block->page, mid);
// 3. 提取中间行的id(COMPACT格式,id在第275~282字节)
ulint mid_id = mach_read_from_8(mid_rec + 275); // 从行数据中读取id
// 4. 比较并调整边界
if (mid_id < target_id) {
low = mid + rec_get_size(mid_rec); // 下一行的偏移量(rec_get_size计算行长度)
} else if (mid_id > target_id) {
high = mid - rec_get_size(mid_rec);
} else {
// 找到目标行,设置游标并退出
cur->rec = mid_rec;
cur->offset = mid;
return;
}
}
// 未找到,设置游标到最接近的行(如PAGE_CUR_GE则指向第一个大于target_id的行)
四、关键细节:如何从行数据中提取id?
COMPACT行格式下,id(BIGINT)的位置是固定的:
- 行头(5字节)→ 变长长度(1字节)→ NULL标志位(1字节)→
id(8字节,偏移275字节)。
源码中用mach_read_from_8函数从行的275偏移处读取8字节的id值(mach_read_from_*是InnoDB的内存读取工具函数)。
五、性能对比:二分查找vs全页扫描
- 全页扫描:需要遍历100行,每次解析行数据,耗时
O(n); - 二分查找:仅需7次比较,每次定位中间行,耗时
O(log n)。
对于16KB的页(存储100行),二分查找的速度是全页扫描的14倍以上(100/7≈14)。
六、扩展:其他匹配模式的查找
page_cur_search_with_match支持多种匹配模式,比如:
PAGE_CUR_GE:找大于等于目标键的行(用于范围查询的起始位置);PAGE_CUR_EQ:找等于目标键的行(精确查找);PAGE_CUR_LE:找小于等于目标键的行(用于反向范围查询)。
例如,执行SELECT * FROM orders WHERE id >= 50时,查找模式为PAGE_CUR_GE,二分查找会定位到id=50的行,并返回该行及后续所有行。
七、总结:页内二分查找的本质
InnoDB的页内二分查找是「有序性」与「二进制搜索」的完美结合:
- 有序性:页内的行按索引键升序排列,是二分查找的前提;
- 二进制搜索:通过不断缩小查找范围,快速定位目标行,时间复杂度
O(log n); - 源码适配:
page_cur_search_with_match函数通过解析行偏移和行数据,实现了高效的二分查找。
延伸思考:如果行格式是DYNAMIC,二分查找会有变化吗?
DYNAMIC行格式与COMPACT的核心差异是变长字段存储到溢出页,但页内行的排序逻辑不变(仍按索引键升序排列)。因此,二分查找的流程完全一致,仅需调整行数据的解析方式(从溢出页加载变长字段,但不影响索引键的比较)。
如果需要模拟DYNAMIC行格式的页内查找,或探讨二级索引的页内查找差异,欢迎随时提出!
8、行格式类型及特点详解
InnoDB的行格式(Row Format)定义了数据行在磁盘上的物理存储结构,直接影响空间利用率、查询性能、字段支持能力。InnoDB支持4种核心行格式:REDUNDANT、COMPACT、DYNAMIC、COMPRESSED。以下是每种格式的具体定义、特点、适用场景,结合底层存储细节和源码逻辑说明:
一、行格式的核心差异维度
在选择行格式前,需明确几个关键对比项:
- 空间利用率:行头的冗余度、变长字段的存储方式;
- 溢出字段处理:对
TEXT/BLOB等超长字段的存储策略; - 支持的MySQL版本:不同版本默认格式不同;
- 索引特性:是否支持更长索引键、索引压缩;
- 性能开销:压缩/解压的CPU消耗、溢出页的IO开销。
二、具体行格式详解
1. REDUNDANT(冗余格式)
- 定位:InnoDB早期(MySQL 5.0前)的默认行格式,现已很少使用(仅用于兼容旧系统)。
- 核心特点:
- 行头冗余:行头包含大量过时信息(如
info_bits、length_of_row等),占用5字节(COMPACT仅2字节); - 变长字段存储:每个变长字段(如
VARCHAR)都需要存储完整长度(即使长度很小),例如VARCHAR(255)需用2字节存长度,即使实际只存1字节; - NULL标志位:用固定长度的位图,不支持稀疏存储;
- 溢出字段:超长字段存到溢出页,主行留20字节指针,但处理逻辑更原始;
- 空间浪费:相比COMPACT,空间利用率低10%-20%。
- 行头冗余:行头包含大量过时信息(如
- 适用场景:兼容MySQL 4.x的旧系统,或需要保留旧数据格式的场景。
2. COMPACT(紧凑格式)
- 定位:MySQL 5.0+的默认行格式(除非显式指定其他格式),兼顾空间效率与兼容性。
- 核心特点:
- 行头紧凑:行头仅2字节,存储行状态(如是否删除)、簇指针(用于行移动)等必要信息;
- 变长字段优化:变长字段的长度存储采用1或2字节(取决于字段最大长度:≤255用1字节,否则用2字节),例如
VARCHAR(100)仅需1字节存长度; - NULL标志位:用位图存储(每列1位,0表示非NULL,1表示NULL),大幅节省空间(比如10列仅需1.25字节);
- 溢出字段处理:超长字段(如
TEXT)存到溢出页,主行留20字节指针(包含溢出页号、页内偏移、溢出页数量); - 空间利用率:比REDUNDANT高15%-30%,是通用的平衡选择。
- 源码映射:行结构定义在
row0row.h的row_struct,变长长度解析用rec_get_var_length函数。 - 适用场景:大多数OLTP场景,兼容性好,空间效率适中。
3. DYNAMIC(动态格式)
- 定位:MySQL 5.7.9+的默认行格式(若使用InnoDB且未指定
ROW_FORMAT),针对大量变长字段/大字段优化。 - 核心特点:
- 变长字段改进:对变长字段,若实际长度超过页大小的一半(如16KB页的8KB),会直接将整个字段存到溢出页,主行不留任何内容(COMPACT会留20字节指针);
- 更紧凑的主行:主行仅存储溢出字段的引用(而非指针),减少主行长度;
- 支持压缩:可与
COMPRESSED结合,实现页压缩; - 溢出页链表:多个溢出页通过
FIL_PAGE_NEXT形成链表,支持大字段的分块存储; - 空间利用率:比COMPACT高10%-20%,尤其适合
VARCHAR(1000)、TEXT等字段。
- 源码映射:变长字段处理逻辑在
row0ins.c的row_ins_overflow,主行仅存溢出引用。 - 适用场景:
- 存储大量变长字段(如JSON、XML);
- 大字段(如
TEXT/BLOB)较多的表; - 需要更高空间利用率的OLTP场景。
4. COMPRESSED(压缩格式)
- 定位:DYNAMIC或COMPACT的压缩版本,针对存储空间敏感的场景。
- 核心特点:
- 页压缩:使用Zlib算法压缩页数据(默认压缩比为50%-70%),减少磁盘占用;
- 行级加密:支持
AES加密(需开启innodb_encrypt_tables),加密在压缩后进行; - 额外开销:压缩/解压需要CPU资源,查询时需解压页数据,可能影响性能;
- 兼容性:基于DYNAMIC或COMPACT,继承其变长字段和溢出页处理逻辑。
- 源码映射:压缩逻辑在
page0zip.c的page_zip_compress,解压在page_zip_decompress。 - 适用场景:
- 存储成本敏感(如日志表、归档表);
- 需要加密的敏感数据(如用户隐私字段);
- 磁盘空间有限,且CPU资源充足的场景。
三、行格式对比总结
| 特性 | REDUNDANT | COMPACT | DYNAMIC | COMPRESSED |
|---|---|---|---|---|
| 默认版本 | MySQL 4.x | MySQL 5.0+ | MySQL 5.7.9+ | 依赖DYNAMIC/COMPACT |
| 行头大小 | 5字节 | 2字节 | 2字节 | 2字节 |
| 变长字段存储 | 完整长度 | 1/2字节 | 溢出则存引用 | 溢出则存引用 |
| NULL标志位 | 固定位图 | 位图 | 位图 | 位图 |
| 溢出字段处理 | 主行留指针 | 主行留指针 | 主行留引用 | 主行留引用 |
| 空间利用率 | 低 | 中 | 高 | 极高 |
| 支持压缩 | 否 | 否 | 是 | 是 |
| 适用场景 | 兼容旧系统 | 通用OLTP | 大字段/变长字段 | 存储敏感/空间敏感 |
四、如何设置行格式?
可通过ALTER TABLE修改行格式(需重建表,建议在低峰期操作):
-- 修改为COMPACT格式
ALTER TABLE orders ROW_FORMAT=COMPACT;
-- 修改为DYNAMIC格式(MySQL 5.7.9+)
ALTER TABLE orders ROW_FORMAT=DYNAMIC;
-- 修改为COMPRESSED格式(基于DYNAMIC)
ALTER TABLE orders ROW_FORMAT=COMPRESSED;
五、选择建议
- 通用场景:优先选
COMPACT(默认,兼容性好); - 大字段/变长字段多:选
DYNAMIC(更紧凑的主行,减少空间); - 存储/加密需求:选
COMPRESSED(节省空间+加密); - 兼容旧系统:选
REDUNDANT(仅必要时用)。
延伸思考:行格式与索引的关系
- 索引键长度:
DYNAMIC格式支持更长的索引键(因主行更紧凑,B+树节点能存更多键); - 索引压缩:
COMPRESSED格式支持索引压缩(减少索引页空间,提升查询性能); - 溢出字段索引:无法对溢出字段建立普通索引(仅能建全文索引)。
通过理解行格式的底层差异,可根据业务场景选择最优格式,在空间利用率、查询性能、功能支持间取得平衡。如果需要模拟不同行格式的页结构,或探讨行格式对二级索引的影响,欢迎随时提出!
9、MySQL中变长字段类型分类及底层存储
要理解MySQL中变长字段的底层存储,需先明确变长字段的定义(长度不固定的字段),再结合行格式差异(COMPACT/DYNAMIC)拆解存储逻辑。以下是具体变长字段类型、底层存储机制的详细说明,结合源码和场景验证:
一、MySQL中的变长字段类型
变长字段的核心特征是存储内容长度不固定,MySQL中典型的变长字段包括:
| 类型 | 描述 | 长度范围 | 字符集影响 |
|---|---|---|---|
VARCHAR(N) |
可变长度字符串 | 0 ≤ N ≤ 65535 | 长度计算:字符数×字符集字节数(如UTF-8最多3N字节) |
VARBINARY(N) |
可变长度二进制数据 | 0 ≤ N ≤ 65535 | 按字节计算长度 |
TEXT |
长文本(最大65535字节) | 0 ≤ 长度 ≤ 65535 | 存储字符串,支持字符集 |
BLOB |
长二进制数据(最大65535字节) | 0 ≤ 长度 ≤ 65535 | 存储二进制,无字符集 |
MEDIUMTEXT |
中等长度文本(最大16MB) | 0 ≤ 长度 ≤ 16777215 | 同TEXT |
LONGBLOB |
长二进制数据(最大4GB) | 0 ≤ 长度 ≤ 4294967295 | 同BLOB |
JSON |
JSON文档(MySQL 5.7+) | 0 ≤ 长度 ≤ 1GB | 底层存储为UTF-8字符串 |
XML |
XML文档(MySQL 5.7+) | 0 ≤ 长度 ≤ 1GB | 底层存储为UTF-8字符串 |
二、变长字段的底层存储机制
变长字段的存储核心是“长度前缀+内容”,但超长字段(如TEXT/BLOB)会额外用溢出页存储。不同行格式(COMPACT/DYNAMIC)的存储策略差异显著:
1. 短变长字段:VARCHAR/VARBINARY
VARCHAR和VARBINARY是短变长字段(长度≤65535字节),存储逻辑一致:长度前缀 + 实际内容。
(1)长度前缀的存储
长度前缀用于标记字段内容的实际长度,字节长度取决于字段的最大可能长度:
- 若字段最大长度≤255字节(如
VARCHAR(100),UTF-8下最大300字节,但MySQL按定义的长度判断):长度前缀用1字节(0~255); - 若字段最大长度>255字节(如
VARCHAR(1000),UTF-8下最大3000字节):长度前缀用2字节(0~65535)。
源码映射:长度前缀的解析函数是rec_get_var_length(row0row.c),通过字段的max_length判断前缀长度。
(2)内容的存储
实际内容紧跟在长度前缀后,存储在主行中。例如:
VARCHAR(100)(UTF-8)存储“hello”:长度前缀1字节(0x05),内容5字节(68 65 6C 6C 6F),共6字节;VARBINARY(100)存储二进制数据0xAA 0xBB:长度前缀1字节(0x02),内容2字节,共3字节。
(3)NULL值的处理
若字段值为NULL,主行中仅存储NULL标志位(1位),不占用长度前缀和内容空间。
2. 超长变长字段:TEXT/BLOB/JSON/XML
TEXT、BLOB及衍生类型是超长字段(长度可能超过页大小的一半,如16KB页的8KB),存储逻辑分COMPACT和DYNAMIC两种行格式:
(1)COMPACT格式:主行存“20字节指针”
COMPACT格式中,超长字段的内容不存主行,主行仅存20字节的物理指针,指向存储内容的溢出页。
指针结构:
- 溢出页号(4字节):第一个存储内容的溢出页;
- 页内偏移(4字节):内容在溢出页的起始位置;
- 溢出页数量(4字节):存储内容用了多少个溢出页;
- 字段类型标识(4字节):标记字段是
TEXT/BLOB等; - 保留字段(4字节):填充0。
源码映射:指针的写入函数是row_ins_overflow(row0ins.c),读取时用rec_get_field_overflow_ptr解析。
(2)DYNAMIC格式:主行存“5字节简化引用”
DYNAMIC格式优化了指针设计,主行仅存5字节的轻量级引用,替代COMPACT的20字节指针:
- 字段号(1字节):标记表中的字段(如
remark对应字段号5); - 首溢出页号(4字节):指向存储内容的第一个溢出页。
溢出页通过链表连接(FIL_PAGE_NEXT指针),每个溢出页头部存储:
- 字段号(1字节):确认属于哪个字段;
- 内容(剩余空间):存储超长字段的部分内容;
- 下一页指针(4字节):指向后续溢出页。
源码映射:
- 主行引用写入:
rec_set_field_ref(row0row.h); - 溢出页链表遍历:
row_sel_concat_overflow_data(row0sel.c),将多个溢出页内容拼接成完整数据。
3. 示例对比:TEXT字段的存储差异
假设remark字段是TEXT类型,内容需3个16KB溢出页存储:
- COMPACT格式:主行存20字节指针(
页号=2001、偏移=0、数量=3、类型=TEXT); - DYNAMIC格式:主行存5字节引用(
字段号=5、首溢出页号=2001),溢出页2001→2002→2003通过链表连接。
三、源码层面的关键逻辑
1. 长度前缀解析
rec_get_var_length函数(row0row.c)解析短变长字段的长度前缀:
ulint rec_get_var_length(
const rec_t* rec, -- 主行记录
ulint offset -- 字段的偏移量
) {
// 1. 读取长度前缀的字节
byte len_byte = rec[offset];
// 2. 判断长度前缀的字节数(根据字段的max_length)
if (field->max_length <= 255) {
return len_byte; -- 1字节前缀
} else {
return (len_byte << 8) | rec[offset + 1]; -- 2字节前缀
}
}
2. 溢出页处理(DYNAMIC格式)
row_sel_concat_overflow_data函数(row0sel.c)拼接溢出页内容:
void row_sel_concat_overflow_data(
const rec_t* rec, -- 主行记录
ulint field_no, -- 字段号
byte** buf, -- 输出缓冲区
ulint* buf_len -- 输出长度
) {
// 1. 从主行读取简化引用
ulint field_no = rec_get_field_no(rec, offset);
ulint first_page = rec_get_field_ref(rec, field_no);
// 2. 遍历溢出页链表
ulint total_len = 0;
buf_block_t* current = buf_page_get(first_page, ...);
while (current != NULL) {
// 3. 复制溢出页内容到缓冲区
memcpy(*buf + total_len, current->page.data + offsetof(overflow_page_t, data), current->page.len - offsetof(overflow_page_t, data));
total_len += current->page.len - offsetof(overflow_page_t, data);
current = buf_page_get(current->page.next_page, ...);
}
// 4. 设置输出参数
*buf_len = total_len;
}
四、总结:变长字段的存储本质
| 字段类型 | 存储策略 | 核心优势 |
|---|---|---|
VARCHAR/VARBINARY |
长度前缀+内容(主行) | 短字段高效,无溢出开销 |
TEXT/BLOB(COMPACT) |
主行存20字节指针+溢出页 | 兼容旧格式,简单直接 |
TEXT/BLOB(DYNAMIC) |
主行存5字节引用+溢出页链表 | 主行紧凑,空间利用率高,查询性能好 |
关键结论:
变长字段的存储核心是“长度前缀标记内容长度”,超长字段通过溢出页解决主行空间限制。DYNAMIC格式通过简化引用+链表优化了溢出字段的存储和查询,是MySQL 5.7+的默认选择。
如果需要模拟VARCHAR的长度前缀存储,或探讨JSON类型的底层存储细节,欢迎随时提出!
10、MySQL字段类型定义经验
在MySQL表字段设计中,字段类型的选择直接影响存储效率、查询性能、功能扩展性及兼容性。以下是针对不同字段类型的核心注意事项,结合存储逻辑、性能影响、常见坑点总结的最佳实践:
一、数值类型:精度、范围与存储空间
数值类型的核心是匹配业务数值的范围,避免“大马拉小车”(用BIGINT存INT范围的值)或“小马拉大车”(用INT存超过范围的值)。
1. 整数类型(INT/BIGINT/TINYINT/MEDIUMINT)
-
范围匹配:
TINYINT(1字节,-128127/0255):适合状态字段(如status:0=未支付,1=已支付);MEDIUMINT(3字节,-8388608~8388607):适合中等范围计数(如click_count点击量);INT(4字节,-2147483648~2147483647):默认选择,适合大部分计数(如user_id);BIGINT(8字节,-9223372036854775808~9223372036854775807):适合超大范围(如order_id分布式ID)。
-
UNSIGNED属性:
若数值非负,用
UNSIGNED扩展范围(如INT UNSIGNED范围0~4294967295),但不要用于有符号数值(如负数状态)。 -
ZEROFILL属性:
自动填充前导零(如
INT ZEROFILL存储123→00000000123),仅用于格式化显示,不影响存储,不建议滥用。
2. 浮点与定点类型(FLOAT/DOUBLE/DECIMAL)
-
精度要求:
FLOAT(4字节,单精度):近似值,适合科学计算(如温度);DOUBLE(8字节,双精度):近似值,适合工程计算(如坐标);DECIMAL(M,D)(M总位数,D小数位):精确值,必须用于金额、税率等需要准确计算的场景(如amount DECIMAL(10,2)存储100.00元)。
-
存储效率:
DECIMAL的存储空间随M/D增大而增加(如DECIMAL(10,2)占5字节,DECIMAL(20,2)占10字节),需平衡精度与空间。
二、字符串类型:变长、定长与溢出处理
字符串类型的核心是匹配业务字符串的长度特性,避免浪费空间或触发溢出页。
1. 定长字符串(CHAR)
- 适用场景:长度固定的字符串(如
password_hash CHAR(60)存储BCrypt哈希,uuid CHAR(36)存储UUID)。 - 优势:存储紧凑,查询时无需计算长度,性能略快于VARCHAR。
- 注意:若实际值长度小于定义长度,会填充空格(检索时自动去除),避免用CHAR存变长内容(如用户名)。
2. 变长字符串(VARCHAR)
- 适用场景:长度不固定的短字符串(如
username VARCHAR(50)、email VARCHAR(100))。 - 长度设置:
- 避免过度分配:如用户名最长50字符,就设
VARCHAR(50),而非VARCHAR(255)(UTF-8下VARCHAR(255)占765字节,可能超过行最大长度); - UTF-8编码注意:
VARCHAR(N)的字节长度=N×字符集字节数(如UTF-8是3N),需确保总长度不超过行限制(如16KB页的行最大8KB,VARCHAR(2000)UTF-8占6000字节,没问题;VARCHAR(3000)占9000字节,需用TEXT)。
- 避免过度分配:如用户名最长50字符,就设
- 性能影响:查询时需计算长度,比CHAR略慢,但空间利用率更高。
3. 超长字符串(TEXT/BLOB/JSON/XML)
- 适用场景:
TEXT:长文本(如remark TEXT、content TEXT);BLOB:长二进制(如avatar BLOB存储头像);JSON:JSON文档(如config JSON存储用户配置,MySQL 5.7+支持);XML:XML文档(如xml_data XML,逐渐被JSON替代)。
- 存储逻辑:
- 超长字段会剥离到溢出页(COMPACT格式存20字节指针,DYNAMIC格式存5字节引用+链表);
- 查询时需加载溢出页,影响性能,尽量避免在高频查询字段中使用。
- 优化建议:
- 用
VARCHAR替代短TEXT(如VARCHAR(8000)UTF-8占24000字节,仍在16KB页内); - 对JSON字段,用
JSON_EXTRACT()函数查询,避免加载完整内容。
- 用
三、日期时间类型:时区、范围与精度
日期时间类型的核心是匹配业务的时间需求,避免时区混乱或范围不足。
1. DATETIME vs TIMESTAMP
- DATETIME:
- 范围:1000-01-01 00:00:00 ~ 9999-12-31 23:59:59;
- 存储:8字节;
- 时区:不依赖时区,存储的是绝对时间;
- 适用场景:出生日期、合同有效期等绝对时间。
- TIMESTAMP:
- 范围:1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07 UTC;
- 存储:4字节;
- 时区:依赖时区,存储的是UTC时间,检索时转换为当前时区;
- 适用场景:创建时间、更新时间等相对时间(如
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP)。
2. 其他时间类型
DATE:仅日期(如birthday DATE),占3字节;TIME:仅时间(如duration TIME存储时长),占3字节;YEAR:年份(如graduation_year YEAR),占1字节。
四、空间类型:地理数据存储
空间类型用于存储地理坐标、多边形等数据,核心是配合空间索引:
- 类型:
GEOMETRY(几何对象)、POINT(点)、LINESTRING(线)、POLYGON(多边形); - 存储:用Well-Known Binary(WKB)格式存储,占较多空间;
- 索引:需创建空间索引(
SPATIAL INDEX),但查询性能不如普通索引; - 适用场景:地图应用中的位置存储(如
location POINT存储经纬度)。
五、枚举与集合:有限选项的取舍
枚举(ENUM)和集合(SET)用于有限选项的字段,但扩展性差:
- ENUM:单选,如
gender ENUM('男','女','其他');- 优势:存储紧凑(1字节存枚举值),可读性好;
- 劣势:修改枚举值需重建表,不适合选项可能变化的场景。
- SET:多选,如
hobby SET('篮球','足球','读书');- 优势:存储紧凑(1字节存最多8个选项);
- 劣势:扩展性差,查询时需用
FIND_IN_SET()函数,性能略低。
- 替代方案:若选项可能变化,用
VARCHAR存储,或单独建选项表(如hobby表,用外键关联)。
六、其他关键注意事项
1. NULL vs NOT NULL
- 尽量用
NOT NULL:NULL值需额外存储标志位,查询时需判断,性能略慢; - 默认值:用
DEFAULT设置合理默认值(如email VARCHAR(100) NOT NULL DEFAULT ''),避免NULL。
2. 字符集与排序规则
- 统一字符集:表字段字符集建议用
utf8mb4(支持Emoji和所有Unicode字符),避免乱码; - 排序规则:用
utf8mb4_general_ci(通用不区分大小写)或utf8mb4_unicode_ci( Unicode标准排序),根据业务需求选择。
3. 索引与字段类型
- 字段类型影响索引大小:
INT索引比VARCHAR索引小,性能更好; - 前缀索引:对长字符串(如
VARCHAR(255)),可建前缀索引(如INDEX idx_username (username(20))),节省索引空间,但需平衡查询精度。
4. 兼容性与版本
- 新类型支持:JSON、空间类型等需要MySQL 5.7+,若用低版本需规避;
- 类型迁移:修改字段类型需重建表(如
ALTER TABLE t MODIFY COLUMN c INT),建议在低峰期操作。
总结:字段类型设计的核心原则
- 匹配业务需求:选最贴合业务场景的类型(如金额用DECIMAL,绝对时间用DATETIME);
- 平衡空间与性能:短字段用定长(CHAR),长字段用变长(VARCHAR),超长字段用TEXT/BLOB;
- 避免扩展性问题:枚举/集合尽量不用,除非选项稳定;
- 统一字符集与索引:用utf8mb4,合理设置索引,提升查询性能。
通过遵循这些原则,可以设计出存储高效、查询快速、扩展性好的MySQL表结构。如果需要具体场景的字段类型设计示例(如用户表、订单表),欢迎随时提出!
本文来自博客园,作者:哈罗·沃德,转载请注明原文链接:https://www.cnblogs.com/panhua/p/19210465
浙公网安备 33010602011771号