vlambda博客
学习文章列表

MySql - InnoDB 之 Buffer Pool | 重要数据结构

Buffer PoolInnoDBMySqlDatabase

Buffer Pool 是 InnoDB 最重要的优化功能,通过缓冲读写热点数据,提高 InnoDB 整体性能。

对于基于磁盘的存储数据库系统Disk-base Database System,最重要的目的就是高效地存取数据。但由于 CPU 和磁盘速度之间存在难以逾越的鸿沟,为了弥补二者之间的速度差异,必须使用缓冲池技术来加速数据的存取。因此,缓冲池Buffer Pool 是 InnoDB 最为重要的部分。


也因为引入这一中间层,使得 InnoDB 对数据库内存的管理变得相对更为复杂。缓冲池主要包括以下特性:LRU List、Free List、Fulsh List、Fulsh 策略、Double write buffer、预读预写、预热、动态扩展、压缩页内存管理、并发控制、多线程工作等。

重要对象基本概念

Buffer Pool Instance

InnoDB 1.0.x 版本开始,缓冲池可以分为多个缓冲池实例Buffer Pool Instance,每个页面根据哈希值平均分配到不同的缓冲池实例中去。每个实例资源独立,拥有自己的锁、信号量、物理块、逻辑链表,页哈希表等,这样就可以通过减少缓冲池内部的资源竞争以提高引擎整体的性能。相关参数为:

  • innodb_buffer_pool_instances :记为 n,缓冲池实例数量。
  • innodb_buffer_pool_size :记为 m,所有缓冲池总大小。
单个缓冲池实例大小为 n/m,如果 m 小于 1G,则 n 将被重置为 1 以防止有太多小的实例导致性能下降。 所有实例的物理块在数据库启动时被分配,直至数据库关闭时 这些内存才被释放。

buf_pool_t

每个缓冲池实例都会有一块与之对应的  buf_pool_t   数据结构,称为缓冲池控制体。缓冲池控制体用于存储该缓冲池实例的控制信息,如缓冲池实例的锁、实例编号、页哈希表等等信息,还存储了各种逻辑链表的链表根节点。Zip Free 这个二维数组也在其中。部分代码为:
struct buf_pool_t { ... ulint instance_no; // 缓冲池实例编号 ulint curr_pool_size; // 缓冲池实例大小 buf_chunk_t *chunks; // 缓冲池实例的物理块列表 hash_table_t *page_hash; // 页哈希表 UT_LIST_BASE_NODE_T(buf_page_t) free; // 空闲链表 UT_LIST_BASE_NODE_T(buf_page_t) LRU; // LRU 链表 UT_LIST_BASE_NODE_T(buf_page_t) flush_list; // Flush 链表 BufListMutex free_list_mutex; // 空闲链表的互斥锁 BufListMutex LRU_list_mutex; // LRU 链表的互斥锁 BufListMutex flush_state_mutex; // Flush 链表的互斥锁 ...}

Page

数据页Page 是 InnoDB 中最小的数据管理单位,默认为 16KB,InnoDB 1.2.x 版本开始可以修改页大小为 4K、8K、16K,引擎首次启动之后便无法再更改页大小。

如果对表进行压缩,则对应的数据页称为压缩页,压缩页大小在建表时指定,支持 1K、2K、4K、8K、16K,压缩为 16K 虽然没有节约空间但对 blob、varchar、text 类型有一定好处。从压缩页中读取数据需要先解压形成解压页再读取,解压页与数据库默认页大小相同。如果压缩页大小指定为 4K 但数据页无法压缩到 4K 以下,则会对数据页进行一次页分裂操作。

正常情况下,缓冲池会同时缓存压缩页及其解压页,当空闲列表不够用时会根据系统是实时负载决定淘汰策略:如果系统瓶颈在 IO 上就淘汰解压页,否则两者都淘汰。

脏页Dirty Page 指缓冲池中数据被修改了但是还没落盘的数据页。无论普通数据页还是压缩页只要发生数据更新都可以称为脏页,脏页的会被链接到 Flush 链表中。每隔一段时间或者系统空闲时会有部分脏页被更新到磁盘中,在脏页被剔除出缓冲池的时候必定会进行落盘操作。

每个数据页都会有与之对应的数据页控制体,用于存储数据页相关的各项数据和指向数据页的指针,数据页控制体由两种数据结构一起组成,分别为 buf_page_t 和 buf_block_t

buf_block_t

struct buf_block_t { buf_page_t page; // 另一个控制块 buf_page_t 的指针,必须作为第一个数据成员 byte *frame; // 数据页指针,指向真正的的数据页 BPageMutex mutex; // 页锁 ...}

数据页的控制体之一,描述少量数据页的信息。第一个数据成员就是另一个数据页控制块指针,必须作为第一个数据成员以随时转换成另一个数据页控制块。第二个数据成员 frame 是指向所属数据页的指针。

buf_page_t

struct buf_page_t { ... page_id_t id; // page id page_size_t size; // page 大小 ib_uint32_t buf_fix_count; // 用于并发控制 buf_io_fix io_fix; // 用于并发控制 buf_page_state state; // 页状态 lsn_t newest_modification; // 最新 lsn,即最近修改的 lsn lsn_t oldest_modification; // 最老 lsn,即第一条修改 lsn ...}
缓冲池中每一个数据页都会有一个块与之对应的  buf_page_t  数据结构,称为数据页控制体。该控制体存储了绝大部分数据页信息,包括页 ID、页大小、页状态、最新 lsn、最老 lsn 以及压缩页的所有信息等。压缩页信息包括压缩页大小、压缩页指针。

Buffer Chunk

Buffer Chunk 就是每个缓冲池实例中的物理块,是缓冲池中最小的物理存储单位。一个缓冲池实例中存在至少一个物理块,物理块的默认大小为 128MB,因此默认缓冲池大小最小同样为 128MB,物理块最小为 1MB,且在 MySql 8.0 中物理块大小可以动态调整生效。物理块在引擎启动阶段申请完毕,直到数据库关闭才会完全释放。
页是 InnoDB 内存最小的数据管理单位,但连续的内存存储单位是物理块。
每块数据块包含两个区域,一个是以  buf_block_t  为元素的控制块数组,另一个是以数据页为元素的数据页数组,控制块数组占用 Chunk 前部分,数据页数组占用 Chunk 后部分。每个数据页必定存在与之对应的控制块,但控制块不一定有与之对应的数据页。数据块中几乎包含所有数据页,仅有  BUF_BLOCK_ZIP_PAGE  和  BUF_BLOCK_ZIP_DIRTY  类型数据页除外。数据页并不都是存储用户数据,控制信息、行锁、自适应哈希也存在于数据页中。

逻辑链表

逻辑链表的节点是  buf_page_t  控制体,引入各类型逻辑链表使得数据页的管理更方便系统。

Free List

空闲链表Free List 是由所有未使用的数据页构成的链表,在数据块分配内存的时候予以初始化,所有数据页都是空闲页。缓冲池中如果需要引入新的数据页,则直接从空闲链表中获取即可。InnoDB 会保证存在足够的 Free List 节点以供使用,空闲节点不足时将从 LRU List 和 Flush List 中淘汰一定量的节点以补充库存。

LRU List

LRU List 是缓冲池中最重要的数据结构,基本所有读入的数据页都缓冲于其上。LRU 链表顾名思义根据最近最少使用算法Least Recently Used对节点进行淘汰,但在这里所使用的是优化后的 LRU 算法。

Flush List

缓冲池中所有脏页都会挂载在 Flush List 中,以等待数据落盘。LRU List 中的页被修改后也会被放入 Flush List 中,被修改过后的压缩页也会被放入 Flush List 中。在数据更改被刷入磁盘前,数据很有可能会被修改多次,在数据页控制体中记录了最新修改的 lsn(newset_modification) 和最老修改的 lsn(newest_modification)。进入 Flush list 的节点按照进入的顺序进行排序,最新加入的数据页放在链表头部。数据页在进入 Flush List 时对 Flush List 加锁以保证节点进入的顺序。刷数据时从链表尾开始写入。

Unzip LRU List

与 LRU 链表类似,但是是专门用于存储压缩页解压而出的解压页。

Zip Clean List

Debug 模式下才有,专门用于存储压缩页。正常模式下压缩页存放在 LRU 链表中。

Zip Free

是由 5 个链表构成的二维数组,分别是 1K、2K、4K、8K 和 16K 的碎片链表,专门用于存储从磁盘读入的压缩页,引擎使用 Buddy 伙伴系统专门管理该结构。

Mutex

在  buf_pool_t  中为几个逻辑链表维护了几个互斥锁,用来保护各链表的并发访问:
名称
类型 目标
lru_list_mutex
互斥量
LRU 列表
free_list_mutex
互斥量
Free 列表
flush_list_mutex
互斥量

Flush 列表

hash_lock 读写锁 Page hash
buffer block mutex  互斥量
buf_block_t
buf_fix_count 信号量 buf_page_t
io_fix
状态量
buf_page_t
rw_lock(BPageMutex)
读写锁
数据页


List Mutex

所有数据页都在空闲列表、LRU 列表和 Flush 列表上,因此必须先获取这几个列表的锁才能进行 IO 操作。

hash_lock

在 MySql 5.6 版本之前,对页哈希表的操作使用一个 Page Hash 级别的锁。而后优化为 slot 级别的 hash_lock,即页哈希表有多少个 slot 就有多少个 hash_lock,以尽量减少锁冲突。

在获取 hash_lock 并访问到数据页的后,就会直接放开 hash_lock。

page block mutex

buffer block mutex 是 buf_block_t 上的锁,用于保护 buf_page_t 上的 io_fix、state、buf_fix_count 等变量, 引入这个 mutex 是为了减少早期版本直接使用缓冲池级别锁的开销。

buf_fix_count 与 io_fix

io_fix 表示当前的 page frame 正在进行的 IO 操作状态,主要有 BUF_IO_READ、BUF_IO_WRITE、BUF_IO_PIN、BUF_IO_NONE。

buf_fix_count 表示当前这个控制块被引用了多少次, 每次访问一个 page 的时候, 都会对 buf_fix_count++,最后在 mtr:commit() 的最后资源释放阶段, 会对这个buf_fix_count--,进行资源的释放.

page frame rw_lock

数据页实体上的读写锁,而非数据页控制体上的锁。在访问数据页的时候,会对数据页加 s lock,在准备写数据页时会加 sx lock,确认修改时加 x lock。但像后台线程在刷脏的时候,对数据页加 x 锁会极大地影响数据页的访问,因此 InnoDB 通过设置 page block mutex、io_fix、buf_fix_count 对 rw_lock 层层保护。

在判断一个页能否被 flush 的时候,会先通过判断 io_fix 状态以减少直接获取 page frame rw_lock 的操作。

通常访问一个数据页的加锁流程是:
  1. 获取 hash_lock,获取 page block lock 之后释放 hash_lock。

  2. 判断并修改 io_fix 和 buf_fix_count,然后放开 page block lock。

  3. 获取 page frame rw_lock。

Page Hash 与 Zip Hash

读入缓冲池的页面由 LRU 链表串联起来,但如果每次查询页面都去遍历 LRU 链表的话是不可想象的。利用哈希表在 O(1) 时间复杂度查询和定位数据的特性,InnoDB 为每个缓冲池实例维护了页哈希表,通过  space_id  和  page_id  来定位与读取数据。
LRU 列表中的数据页将被添加到 Page Hash 中,Unzip LRU List 列表中的数据页将被添加到 Zip Hash 中。

Double Write Buffer

双写缓冲Double Write Buffer  主要是为了解决数据页半写的问题。由于 Linux 操作系统磁盘管理机制的页大小是 4K 与引擎默认的 16K 数据页大小不一致,在写入一个数据页的时候,可能会发生写入未满 16K 而突然断电的情况。如果文件管理系统能够保证原子写入就不会有半写问题,或者将引擎默认页大小改为 4K,但 16K 数据页的默认设置本来就是最佳实践了。
在磁盘和内存中都设置有双写缓冲,大小都是 2M,即 128 个数据页,不占用数据块的空间。它分为两部分,一部分供 batch write 使用,即批量刷脏;另一部分供 single page write 使用,即单页刷脏。batch write 大小默认为  innodb_doublewrite_batch_size = 120
在进行批量刷脏操作时,会先写入到内存的双写缓冲中。在内存双写缓冲区写满的时候会使用同步 IO 一次性将数据刷到磁盘双写缓冲区中,使用同步 IO 以保证安全写入。接着再使用异步 IO 把各个数据页写回自己的表空间,直接返回  buf_dblwr_add_to_batch  表示写成功。不过此时后续对磁盘双写缓冲区的写请求依然会被阻塞,只有在确认异步操作都成功后才会清空系统表空间双写缓冲上的内容,后续请求才能被继续执行。这样做的目的是:如果在异步写回数据页的时候,系统断电发生了数据页半写,由于系统表空间中双写缓冲区的数据页是完整的,重新拷贝即可。异步 IO 请求完成后,会检查数据页的完整性以及完成 change buffer 相关操作,接着 IO helper 线程会调用  buf_flush_write_complete  把数据页从 Flush 列表中删除。