InnoDB存储引擎底层拆解:从页、事务到锁,如何撑起MySQL数据库高效运转(上)

发布于:2025-08-29 ⋅ 阅读:(15) ⋅ 点赞:(0)

目录

Page页**

B+树查询

如何加快记录的查询?

索引**

聚簇索引(主键)

二级索引(非主键)

联合索引——多列

bufferPool*

Free链表

flush链表

Flush链表刷新方式有如下两种:

LRU链表

针对LRU链表方案缺点的优化

redoLog*

redo简单日志类型

redo复杂日志类型(以MLOG_COMP_REC_INSERT为例:)

redo日志组

MTR(Mini-Transacti on)

redo日志缓冲区——log buffer

redo日志刷盘和日志文件组

undoLog*

事务**

MVCC

锁*


        你在电商平台点击“提交订单”按钮的瞬间,背后发生了什么?

        数据库需要在毫秒级内完成:查询商品库存、校验用户余额、生成订单记录、扣减库存、最终提交事务……这些操作看似“丝滑”,实则由MySQL的核心存储引擎——​​InnoDB​​默默支撑。

        但你可能从未想过:

  • 为什么InnoDB能在磁盘中快速找到一条记录?是靠怎样的“数据地图”?

  • 为什么它能同时处理上万并发事务,还能保证数据不丢、不乱?

  • 那些耳熟能详的“B+树索引”“Buffer Pool缓存”“redo log日志”……它们究竟如何协作?

        这篇文章,我们从InnoDB最底层的​​页(Page)​​开始,一步步拆解它的核心设计:B+树如何优化查询、Buffer Pool如何减少磁盘IO、redo/undo日志如何保障事务、事务与锁如何协同工作……最终还原这个“数据库隐形引擎”的底层逻辑。

Page**

        为了避免一条一条读取磁盘数据,InnoDB采取页的方式,作为磁盘和内存之间交互的基本单位。一个页的大小一般是16KB

        InnoDB为了不同的目的而设计了多种不同类型的页。比如:存放表空间头部信息的页、存放undo日志信息的页等等。我们把存放表中数据记录的页,称为索引页or数据页

往页中存储的数据 也称作:“记录”

记录的头信息包括以下:

记录是按照主键从小到大的顺序形成了一个单向链表。记录被删除后next_record会有影响查询也只能以头节点开始逐一向后查询,但是如果数据量很大,那么性能就无法保证了。针对这个问题,InnoDB采取了图书目录的解决方案,即:Page Directory

分组规则如下所示:

① 对于Infimum记录所在的分组只能有1条记录。② 对于Supremum记录所在的分组只能在1~8条记录之间。③ 剩下的其他记录所在的分组只能在4~8条记录之间。

分组步骤如下:① 初始情况下,一个数据页中只有Infimum记录和Supremum记录这两条,所以分为两个组。② 之后每当插入一条记录时,都会从页目录中找到对应记录的主键值比待插入记录的主键值大,并且差值最小的槽,然后把该槽对应的n_owned加1。③ 当一个组中的记录数等于8时,当再插入一条记录的时候,会将组中的记录拆分成两个组(一个组中4条记录,另一个组中5条记录)。并在拆分过程中,会在PageDirectory中新增一个槽,并记录这个新增分组中最大的那条记录的偏移量。

B+树查询

【B树&B+树的插入和删除图文详解】B树和B+树的插入、删除图文详解 - nullzx - 博客园

【与B树相同点】

一个节点可以存储多个元素。

与B树一样,叶子节点是有序的。

每个节点中的元素,也都按照从小到大的顺序排列,即:左小右大。

所有叶子节点都位于同一层,或者说根节点到每个叶子节点的高度都相同。

【与B树不同点】

B+树的叶子节点是有单向指针的,其中:MySQL中采用的是双向指针。

B+树的非叶子节点的元素是与叶子节点有冗余的。

如何加快记录的查询?

当记录越来越多,创建的页也会越来越多,如果仅通过链表方式遍历查询,性能会出现很大问题。如何解决呢?采用B+树结构,即:

叶子节点里存储完整的数据(数据页),非叶子节点存储主键索引(索引页)

索引**

聚簇索引(主键)

        聚簇索引的 B + 树叶子节点直接存储了完整的行数据。这意味着,InnoDB 表的数据实际上是按照聚簇索引的顺序进行物理存储的。

二级索引(非主键)

        二级索引的 B + 树叶子节点存储的是索引列的值以及对应的聚簇索引键值

当通过二级索引进行查询时,首先在二级索引的 B + 树中找到对应的叶子节点,获取到聚簇索引键值。然后,使用这个聚簇索引键值在聚簇索引的 B + 树中再次查找,才能获取到完整的行数据。这个过程称为 “回表”。例如,执行SELECT * FROM table WHERE email = 'example@mail.com',先在email的二级索引 B+树中找到对应的叶子节点,得到聚簇索引键(如id值),再通过这个id值在聚簇索引B+树中获取完整的行数据。

联合索引——多列

目录项记录的唯一性

        为了让新插入的记录能找到自己在哪个页中,就需要保证B+树同一层内节点的目录项记录除页号字段外是唯一的。所以二级索引的内节点的目录项记录的内容实际是有3部分构成的:索引列的值(c2)+主键值(c1)+页号(pageNo)。这样,如果c2列的值相同,那么可以接着比较主键值。所以,归其根源,我们可以认为,为c2列建立的二级索引其实相当于为(c2,c1)列建立了一个联合索引。

bufferPool*

为了缓存磁盘中的页,MySQL服务器启动时就向操作系统申请了一片连续的内存空间,他们给这片内存起名为Buffer Pool(缓冲池)。默认Buffer Pool只有128M,可以在启动服务器的时候配置innodb_buffer_pool_size(单位为字节)启动项来设置自定义缓冲池大小。Buffer Pool对应的一片连续的内存被划分为若干个页面,默认也是16KB,该页面称为缓冲页。为了更好的管理Buffer Pool中的这些缓冲页,InnoDB为每个缓冲页都创建了控制块,它与缓冲页是一一对应的。

Free链表

Buffer Pool的初始化过程中,是先向操作系统申请连续的内存空间,然后把它划分成若干个【控制块&缓冲页】对儿。当插入数据的时候,为了能够知道哪些缓冲页是空闲且可分配的,MySQL把所有空闲的缓冲页对应的控制块作为一个节点放到一个链表中,这个链表便称之为free链表。

flush链表

如果我们修改了Buffer Pool中某个缓冲页的数据,那么它就与磁盘上的页不一致了,这样的缓冲页也被称之为脏页(dirty page)。为了性能问题,我们每次修改缓冲页后,并不着急立刻把修改刷新到磁盘上,而是将被修改过的缓冲页对应的控制块作为节点加入到这个链表中,该链表也被称为flush链表。

Flush链表刷新方式有如下两种:

【1】从flush链表中刷新一部分页面到磁盘

后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是否繁忙。——即:BUF_FLUSH_LIST

有时后台线程刷新脏页的进度比较慢,导致用户准备加载一个磁盘页到Buffer Pool中时没有可用的缓冲页。此时,就会尝试查看LRU链表尾部,看是否存在可以直接释放掉的未修改缓冲页。如果没有,则不得不将LRU链表尾部的一个脏页同步刷新到磁盘(与磁盘交互是很慢的,这会降低处理用户请求的速度)。——即:BUF_FLUSH_SINGLE_PAGE

【2】从LRU链表的冷数据中刷新一部分页面到磁盘

后台线程会定时从LRU链表的尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth来指定,如果在LRU链表中发现脏页,则把它们刷新到磁盘。——即:BUF_FLUSH_LRU

控制块里会存储该缓冲页是否被修改的信息,所以在扫描LRU链表时,可以很轻松地获取到某个缓冲页是否是脏页的信息。

LRU链表

线性预读:如果顺序访问某个区(extent,一个区默认64个页)的页面超过了innodb_read_ahead_threshold(默认56)的值,就会触发一次异步读取下一个区中全部的页到Buffer Pool中的请求。

随机预读:如果开启了随机预读功能(默认:innodb_random_read_ahead=OFF),如果某个区(extent)有13个连续的页面都已经被加载到了Buffer Pool中,无论这些页面是不是顺序读取的,都会触发一次异步读取本区全部的页到Buffer Pool中的请求。

针对LRU链表方案缺点的优化

  • 针对预读的优化①InnoDB规定,当磁盘上的某个页在初次加载(只是加载,没有涉及读取)到BufferPool中的某个缓冲页时,该缓冲页对应的控制块会被放到old区域的头部。这样预读页就只会在old区域,不会影响young区域中使用比较频繁的缓冲页。
  • 针对全表扫描的优化①虽然首次加载放到的是old区域的头部,但是由于是全表扫描,会对加载的数据进行访问,那么第一次访问的时候,就会将该页放到young区域的头部。这样仍然会把那些使用频率比较高的页面给“排挤”下去。②那怎么办呢?由于全表扫描有一个特点,就是它对某个页的频繁访问且总耗时很短。所以,针对这种情况,InnoDB规定,在对某个处于old区域的缓冲页进行第一次访问时,就在它对应的控制块中记录下这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内(即:innodb_old_blocks_time,默认为1000,单位为ms),那么该页面就不会从old区域移动到young区域的头部,否则将它移动到young区域的头部。

chunk和BufferPool实例

redoLog*

什么是redo日志

如果我们只在内存的BufferPool中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交的事务在数据库中所做的更改也就丢失了。针对这种问题,怎么处理呢?

【方案1】在事务提交时,把该事务修改的所有页面都刷新到磁盘,刷新成功了才提示事务提交成功刷新一个完整的数据页太浪费了虽然我们只修改了一条记录,但是会将这条记录所在的页(16KB)都刷新到磁盘上,会造成大量磁盘I/O的浪费。②随机I/O刷新起来比较慢一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,并且该事务修改的这些页面可能并不相邻。这就意味着将某个事务修改的BufferPool中的页面刷新到磁盘时,需要进行很多的随机I/O。而随机I/O要比顺序I/O慢,尤其是机械硬盘。

【方案2】在事务提交时,只需要把修改的内容记录一下就好了例如:“将第0号表空间第100号页面中偏移量为1000处的值更新为2。”

redo简单日志类型

在对页面的修改是极其简单的情况下,redo日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值、具体修改后的内容是什么就好了,比如操作MaxRowId

MLOG_1BYTE:表示在页面的某个偏移量处写入1字节的redo日志类型。

MLOG_2BYTE:表示在页面的某个偏移量处写入2字节的redo日志类型。

MLOG_8BYTE:表示在页面的某个偏移量处写入8字节的redo日志类型。

MLOG_WRITE_STRING:表示在页面的某个偏移量处写入一个字节序列。

redo复杂日志类型(以MLOG_COMP_REC_INSERT例:

redo日志组

在执行语句的过程中产生的redo日志,被InnoDB划分成了若干个不可分割的组。比如:更新MaxRowID属性时产生的redo日志为一组,是不可分割的;向聚簇索引/二级索引对应B+树的页面中插入一条记录时产生的redo日志为一组,是不可分割的等等。InnoDB认为,比如向某个索引对应的B+树中插入一条记录的过程必须是原子的,不能说插入了一半之后就停止了。否则就会形成一棵不正确的B+树。所以他们规定在执行这些需要保证原子性的操作时,必须以组的形式来记录redo日志。在进行恢复时,针对某个组中的redo日志,要么把全部的日志都恢复,要么一条也不恢复。

MTR(Mini-Transacti on)

对底层页面进行一次原子访问的过程被称为一个Mini-Transaction(MTR)。

事务、语句、MTR、redo日志之间的关系,如下所示:① 1个事务可以包含N条SQL语句② 1条SQL语句可以包含N个MTR③ 1条MTR可以包含N条redo日志

redolog block

为了更好地管理redo日志,InnoDB把通过MTR生成的redo日志都放在了大小为512字节的页中,把用来存储redo日志的页称为block。

与Buffer Pool类似,写入redo日志时也不能直接写到磁盘中,实际上在服务器启动时就向操作系统申请了一大片称为redo log buffer(redo日志缓冲区)的连续内存空间,也可以将其简称为log buffer。这片内存空间被划分成若干个连续的redo log block。

其中,用innodb_log_buffer_size指定log buffer的大小。该启动选项的默认值为16MB。

redo日志缓冲区——log buffer

向log buffer中写入redo日志的过程是顺序写入的。其中,buf_free是一个全局变量,该变量指明后续写入的redo日志应该写到log buffer中的哪个位置。

一个MTR执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以并不是每生成一条redo日志就将其插入到log buffer中,而是将每个MTR运行过程中产生的日志先暂时存到一个地方,当该MTR结束的时候,再将过程中产生的一组redo日志全部复制到log buffer中

不同的事务是可能并发执行的,所以T1、T2的MTR可能是交替执行的。

redo日志刷盘和日志文件组

MTR运行过程中产生的一组redo日志在MTR结束时会被复制到log buffer中。可是这些日志总在内存里也不是办法,在一些情况下它们会被刷新到磁盘中:

**重点面试题:redolog 在哪些情况刷盘?

①log buffer空间不足50%的时候②事务提交的时候③ 后台有线程,大约以每秒1次的频率将log buffer中的redo日志刷新到磁盘。④ 正常关闭服务器时⑤ 做checkpoint时

在MySQL的数据目录中,默认有名称为:ib_logfile0和ib_logfile1的两个文件,logbuffer中的日志在默认情况下就是刷新到这两个磁盘文件中,也可以通过下一页中的配置参数对其进行调节和修改:

查看redo日志相关配置信息

datedir:查看数据目录所在位置。

innodb_log_group_home_dir指定了redo日志文件所在目录,默认值为当前的数据目录。

redo日志文件格式

lsn(logsequence number)

lsn是一个全局变量,用来记录当前总共已经写入的redo日志量。

lsn初始值为8704,也就是说,一条redo日志什么也没写入的时候,lsn的值就是8704。

redo日志刷新到磁盘

flush链表

刷入磁盘

mtr1和mtr2生成的redo日志虽然已经写到磁盘上的log file中了,但是它们修改的脏页仍然留在Buffer Pool中,所以它们的redo日志不可以被覆盖。随着系统运行,如果页a从Buffer Pool中刷到了磁盘上,那么页a对应的控制块就会从flush链表中移除掉。而且,它的redo日志占用的空间就可以被覆盖掉了。

chec kpoint

InnoDB通过全局变量checkpoint_lsn,来表示当前系统中可以被覆盖的redo日志总量是多少。这个变量的初始值也是8704(因为lsn的初始值就是8704)。比如,现在页a被刷新到了磁盘上,mtr1生成的redo日志就可以被覆盖了,所以进行一个增加checkpoint_lsn的操作。我们把这个过程称为执行一次checkpoint。

redo日志文件组中各个lsn值的关系,如下图所示:

innodb_flush_log_at_trx_commit

为了保证事务的持久性,用户线程在事务提交时,需要将该事务执行过程中产生的所有redo日志都刷新到磁盘中。

这个规则我们可以通过系统变量innodb_flush_log_at_trx_commit来进行配置修改,该变量有如下3个可选值:0:在事务提交时,不立即向磁盘同步redo日志,这个任务交给后台线程来处理;1:在事务提交时,需要将redo日志同步到磁盘。(默认值)2:在事务提交时,需要将redo日志写到操作系统的缓冲区中,但并不需要保证将日志真正刷新到磁盘。如果操作系统挂掉了,则数据丢失。

undoLog*

剩下内容将在12h 内持续更新····

事务**

MVCC

*


网站公告

今日签到

点亮在社区的每一天
去签到