innodb数据结构,缓冲,缓存
需要了解的操作系统知识
内存分页,为什么内存分页
虚拟内存技术
虚拟内存技术的具体实现:
虚拟内存技术一般是在页式管理(下面介绍)的基础上实现在装入程序时,不必将其全部装入到内存,而只需将当前需要执行的部分页面装入到内存,就可让程序开始执行;在程序执行过程中,如果需执行的指令或访问的数据尚未在内存(称为缺页)。则由处理器通知操作系统将相应的页面调入到内存,然后继续执行程序;另一方面,操作系统将内存中暂时不使用的页面调出保存在外存上,从而腾出更多空闲空间存放将要装入的程序以及将要调入的页面。
虚拟内存技术的特点:大的用户空间:通过把物理内存与外存相结合,提供给用户的虚拟内存空间通常大于实际的物理内存,即实现了两者的分离。如32位的虚拟地址理论上可以访问4GB,而可能计算机上仅有256M的物理内存,但硬盘容量大于4GB;部分交换:与交换技术相比较,虚拟存储的调入和调出是对部分虚拟地址空间进行的;
连续性:程序可以使用一系列相邻连续的虚拟地址来映射物理内存中不连续的大内存缓冲区;
安全性:不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。
04虚拟内存如何映射到物理内存?,简称MMU,虚拟内存不是直接送到内存总线,而是先给到MMU,由MMU来把虚拟地址映射到物理地址,程序只需要管理虚拟内存就好,映射的逻辑自然有其它模块自动处理。
MMU通过页表这个工具将虚拟地址转换为物理地址。32位的虚拟地址分成两部分(虚拟页号和偏移量),MMU通过页表找到了虚拟页号对应的物理页号,物理页号+偏移量就是实际的物理地址。
虚拟内存技术解决了什么问题?
使用物理内存有什么缺点?
这种方式下每个程序都可以直接访问物理内存,有两种情况:
1.系统中只有一个进程在运行:如果用户程序可以操作物理地址空间的任意地址,它们就很容易在不经意间破坏了操作系统,使系统出现各种奇奇怪怪的问题;
2.系统有多个进程同时在运行:如图,理想情况下可以使进程A和进程B各占物理内存的一边,两者互不干扰,但这只是理想情况下,谁能确保程序没有bug呢,进程B在后台正常运行着,程序员在调试进程A时有可能就会误操作到进程B正在使用的物理内存,导致进程B运行出现异常,两个程序操作了同一地址空间,第一个程序在某一地址空间写入某个值,第二个程序在同一地址又写入了不同值,这就会导致程序运行出现问题,所以直接使用物理内存会使所有进程的安全性得不到保证
那么内存里如何隔离应用程序呢?
直接的方法是内存划一个区间,规定这块区间别的内存不能访问。但是带来的问题也很明显,应用的内存不是固定的,这样导致过多分配和过少分配。
而内存分页,可以动态的内存分配,既可以隔离,又可以避免内存不必要的浪费。
问题:既然虚拟内存分页,还是要通过页表映射,那要是有一张表对单位内存每个字节都映射,不就也能解决了内存隔离的问题吗,可以是可以那这个映射的代价可就太大了。反而大大浪费了内存。所有映射的内存大小需要找到一个合适的值,操作系统默认为4kb.
磁盘空间分块
操作系统在与磁盘交互时,同样会对磁盘进行类似分页分处理
我们知道对于HDD来说,其硬件最小的读写单位是扇区(读写粒度 512字节)。而一般HDD的容量大,文件系统若按扇区为读写粒度并不方便(这种表述并不具体)。而且由于磁盘IO性能的瓶颈,细粒度的读写既低效,也会损害磁盘的寿命。因此文件系统对磁盘的读写是将多个扇区看做一个块(这个概念在windows中被称为簇)。文件系统以块大小为单位对磁盘进行读写。块大小一般为扇区的2的n次方倍。(不固定,现在也出现了大小为4kB的扇区)
磁盘没有虚拟磁盘技术,申请的内存,就会连续分配在磁盘上
磁盘io过程
磁盘io有两个比较慢的地方
1 系统调用
什么是系统调用?
Linux内核中设置了一组用于实现各种系统功能的子程序,称为系统调用。用户可以通过系统调用命令在自己的应用程序中调用它们。从某种角度来看,系统调用和普通的函数调用非常相似。区别仅仅在于,系统调用由操作系统核心提供,运行于核心态;而普通的函数调用由函数库或用户自己提供,运行于用户态。
随Linux核心还提供了一些C语言函数库,这些库对系统调用进行了一些包装和扩展,因为这些库函数与系统调用的关系非常紧密,所以习惯上把这些函数也称为系统调用。
为什么要用系统调用?
实际上,很多已经被我们习以为常的C语言标准函数,在Linux平台上的实现都是靠系统调用完成的,所以如果想对系统底层的原理作深入的了解,掌握各种系统调用是初步的要求。进一步,若想成为一名Linux下编程高手,也就是我们常说的Hacker,其标志之一也是能对各种系统调用有透彻的了解。
即使除去上面的原因,在平常的编程中你也会发现,在很多情况下,系统调用是实现你的想法的简洁有效的途径,所以有可能的话应该尽量多掌握一些系统调用,这会对你的程序设计过程带来意想不到的帮助。
系统调用是怎么工作的?
一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作"保护模式")。系统调用是这些规则的一个例外。其原理是进程先用适当的值填充寄存器,然后调用一个特殊的指令,这个指令会跳到一个事先定义的内核中的一个位置(当然,这个位置是用户进程可读但是不可写的)。在Intel CPU中,这个由中断0x80实现。硬件知道一旦你跳到这个位置,你就不是在限制模式下运行的用户,而是作为操作系统的内核–所以你就可以为所欲为。
进程可以跳转到的内核位置叫做sysem_call。这个过程检查系统调用号,这个号码告诉内核进程请求哪种服务。然后,它查看系统调用表(sys_call_table)找到所调用的内核函数入口地址。接着,就调用函数,等返回后,做一些系统检查,最后返回到进程(或到其他进程,如果这个进程时间用尽)。
调用性能问题
系统调用需要从用户空间陷入内核空间,处理完后,又需要返回用户空间。其中除了系统调用服务例程的实际耗时外,陷入/返回过程和系统调用处理程序(查系统调用表、存储\恢复用户现场)也需要花销一些时间,这些时间加起来就是一个系统调用的响应速度。系统调用不比别的用户程序,它对性能要求很苛刻,因为它需要陷入内核执行,所以和其他内核程序一样要求代码简洁、执行迅速。幸好Linux具有令人难以置信的上下文切换速度,使得其进出内核都被优化得简洁高效;同时所有Linux系统调用处理程序和每个系统调用本身也都非常简洁。
绝大多数情况下,Linux系统调用性能是可以接受的,但是对于一些对性能要求非常高的应用来说,它们虽然希望利用系统调用的服务,但却希望加快相应速度,避免陷入/返回和系统调用处理程序带来的花销,因此采用由内核直接调用系统调用服务例程,最好的例子就HTTPD——它为了避免上述开销,从内核调用socket等系统调用服务例程。
2 操作系统读取io设备数据,受限于磁盘io设备速度,这个阶段比较慢
操作系统缓冲区
一、什么是缓冲区
缓冲区(buffer),它是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区,显然缓冲区是具有一定大小的。
缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
二、为什么要引入缓冲区
我们为什么要引入缓冲区呢?
高速设备与低速设备的不匹配,势必会让高速设备花时间等待低速设备,我们可以在这两者之间设立一个缓冲区。
缓冲区的作用:
1.可以解除两者的制约关系,数据可以直接送往缓冲区,高速设备不用再等待低速设备,提高了计算机的效率。例如:我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。
2.可以减少数据的读写次数,如果每次数据只传输一点数据,就需要传送很多次,这样会浪费很多时间,因为开始读写与终止读写所需要的时间很长,如果将数据送往缓冲区,待缓冲区满后再进行传送会大大减少读写次数,这样就可以节省很多时间。例如:我们想将数据写入到磁盘中,不是立马将数据写到磁盘中,而是先输入缓冲区中,当缓冲区满了以后,再将数据写入到磁盘中,这样就可以减少磁盘的读写次数,不然磁盘很容易坏掉。
简单来说,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来存储数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。
缓冲区的刷新
下列情况会引发缓冲区的刷新:
缓冲区满时;
关闭文件。
可见,缓冲区满或关闭文件时都会刷新缓冲区,进行真正的I/O操作。
大家要仔细理解缓冲区刷新的意思,刷新字面上的意思是用刷子刷,把原来旧的东西变新了,这里就是改变的意思,例如像缓冲区溢出的时候,多余出来的数据会直接将之前的数据覆盖,这样缓冲区里的数据就发生了改变。
在io时,操作系统会把读取的磁盘块放入到缓冲区中,
由此可见,我们在io操作时,写入和读取的时候并不直接读取磁盘文件,而是操作的缓冲区,比如每次写入都先写到缓冲区中,最终一次flush进磁盘,减少磁盘的访问次数。
操作系统缓存(cache)
cache是一个非常大的概念。
一、
CPU的Cache,它中文名称是高速缓冲存储器,读写速度很快,几乎与CPU一样。由于CPU的运算速度太快,内存的数据存取速度无法跟上CPU的速度,所以在cpu与内存间设置了cache为cpu的数据快取区。当计算机执行程序时,数据与地址管理部件会预测可能要用到的数据和指令,并将这些数据和指令预先从内存中读出送到Cache。一旦需要时,先检查Cache,若有就从Cache中读取,若无再访问内存,现在的CPU还有一级cache,二级cache。简单来说,Cache就是用来解决CPU与内存之间速度不匹配的问题,避免内存与辅助内存频繁存取数据,这样就提高了系统的执行效率。
二、
磁盘也有cache,硬盘的cache作用就类似于CPU的cache,它解决了总线接口的高速需求和读写硬盘的矛盾以及对某些扇区的反复读取。
三、
操作系统从磁盘读取数据时,会把相邻的数据页缓存起来。
四、
局部性原理与磁盘预读
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,磁盘的存取速度往往是主存的几百分分之一,因此为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据是计算机科学中著名的局部性原理:
当一个数据被用到时,其附近的数据也通常会马上被使用。
程序运行期间所需要的数据通常比较集中。
由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高I/O效率。
预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
pageCache
Page cache是通过将磁盘中的数据缓存到内存中,从而减少磁盘I/O操作,从而提高性能。此外,还要确保在page cache中的数据更改时能够被同步到磁盘上,后者被称为page回写(page writeback)。一个inode对应一个page cache对象,一个page cache对象包含多个物理page。
对磁盘的数据进行缓存从而提高性能主要是基于两个因素:第一,磁盘访问的速度比内存慢好几个数量级(毫秒和纳秒的差距)。第二是被访问过的数据,有很大概率会被再次访问。
Page Cache
Page cache由内存中的物理page组成,其内容对应磁盘上的block。page cache的大小是动态变化的,可以扩大,也可以在内存不足时缩小。cache缓存的存储设备被称为后备存储(backing store),注意我们在block I/O一文中提到的:一个page通常包含多个block,这些block不一定是连续的。
读Cache
当内核发起一个读请求时(例如进程发起read()请求),首先会检查请求的数据是否缓存到了page cache中,如果有,那么直接从内存中读取,不需要访问磁盘,这被称为cache命中(cache hit)。如果cache中没有请求的数据,即cache未命中(cache miss),就必须从磁盘中读取数据。然后内核将读取的数据缓存到cache中,这样后续的读请求就可以命中cache了。page可以只缓存一个文件部分的内容,不需要把整个文件都缓存进来。
写Cache
当内核发起一个写请求时(例如进程发起write()请求),同样是直接往cache中写入,后备存储中的内容不会直接更新。内核会将被写入的page标记为dirty,并将其加入dirty list中。内核会周期性地将dirty list中的page写回到磁盘上,从而使磁盘上的数据和内存中缓存的数据一致。
Cache回收
Page cache的另一个重要工作是释放page,从而释放内存空间。cache回收的任务是选择合适的page释放,并且如果page是dirty的,需要将page写回到磁盘中再释放。理想的做法是释放距离下次访问时间最久的page,但是很明显,这是不现实的。下面先介绍LRU算法,然后介绍基于LRU改进的Two-List策略,后者是Linux使用的策略。
LRU算法
LRU(least rencently used)算法是选择最近一次访问时间最靠前的page,即干掉最近没被光顾过的page。原始LRU算法存在的问题是,有些文件只会被访问一次,但是按照LRU的算法,即使这些文件以后再也不会被访问了,但是如果它们是刚刚被访问的,就不会被选中。
Two-List策略
Two-List策略维护了两个list,active list 和 inactive list。在active list上的page被认为是hot的,不能释放。只有inactive list上的page可以被释放的。首次缓存的数据的page会被加入到inactive list中,已经在inactive list中的page如果再次被访问,就会移入active list中。两个链表都使用了伪LRU算法维护,新的page从尾部加入,移除时从头部移除,就像队列一样。如果active list中page的数量远大于inactive list,那么active list头部的页面会被移入inactive list中,从而位置两个表的平衡。
看接下来的innodb的 buffer pool的实现 基本跟操作系统的pageCache的思想一模一样,只不过面向的是innodb的数据页
mysql为了能够更加快速方便的读取数据,设计了自己的数据页,缓冲池等技术
mysql数据页
首先,我们需要知道,页(Pages)是 InnoDB 中管理数据的最小单元。Buffer Pool 中存的就是一页一页的数据。再比如,当我们要查询的数据不在 Buffer Pool 中时,InnoDB 会将记录所在的页整个加载到 Buffer Pool 中去;同样的,将 Buffer Pool 中的脏页刷入磁盘时,也是按照页为单位刷入磁盘的。
由于 MySQL 的真实数据是存储在磁盘, 因此在读写数据是会涉及磁盘 IO, 为了更高效率的读取, MySQL 设计页结构, 每次交互以页为单位读取到内存. 页的大小一般为 16KB
在操作系统中,我们知道为了跟磁盘交互,内存也是分页的,一页大小4KB。同样的在MySQL中为了提高吞吐率,数据也是分页的,不过MySQL的数据页大小是16KB。(确切的说是InnoDB数据页大小16KB)。
页(Page)是 Innodb 存储引擎用于管理数据的最小磁盘单位。常见的页类型有数据页、Undo 页、系统页、事务数据页等,本文主要分析的是数据页。默认的页大小为 16KB,每个页中至少存储有 2 条或以上的行记录,本文主要分析的是页与行记录的数据结构,有关索引和 B-tree 的部分在后续文章中介绍。
上图为 Page 数据结构,File Header 字段用于记录 Page 的头信息,其中比较重要的是 FIL_PAGE_PREV 和 FIL_PAGE_NEXT 字段,通过这两个字段,我们可以找到该页的上一页和下一页,实际上所有页通过两个字段可以形成一条双向链表。Page Header 字段用于记录 Page 的状态信息。接下来的 Infimum 和 Supremum 是两个伪行记录,Infimum(下确界)记录比该页中任何主键值都要小的值,Supremum (上确界)记录比该页中任何主键值都要大的值,这个伪记录分别构成了页中记录的边界。
User Records 中存放的是实际的数据行记录,具体的行记录结构将在本文的第二节中详细介绍。Free Space 中存放的是空闲空间,被删除的行记录会被记录成空闲空间。Page Directory 记录着与二叉查找相关的信息。File Trailer 存储用于检测数据完整性的校验和等数据
行记录
Innodb 存储引擎提供了两种格式的行记录:Compact 和 Redundant。
Compact 行记录
变长字段长度列表:逆序记录每一个列的长度,如果列的长度小于 255 字节,则使用一个字节,否则使用 2 个字节。该字段的实际长度取决于列数和每一列的长度,因此是变长的。
NULL 标志位:一个字节,表示该行是否有 NULL 值(此处有疑问,8位,最多只能表示 8 列?)
记录头信息:五个字节,其中 next_record 记录了下一条记录的相对位置,一个页中的所有记录使用这个字段形成了一条单链表。
列数据部分:除了记录每一列对应的数据外,还有隐藏列,它们分别是 Transaction ID、Roll Pointer 以及 row_id(当没有指定主键)。
注意:此处需要注意固定长度 CHAR 数据类型和变长 VCHAR 数据类型在 Compact 记录下为 NULL 时不占用任何存储空间。
Redundant 行记录
字段长度偏移列表:与 Compact 中的变长字段长度列表相同的是它们都是按照列的逆序顺序设置值的,不同的是字段长度偏移列表记录的是偏移量,每一次都需要加上上一次的偏移,同时对于 CHAR 的 NULL 值,会直接按照最大空间记录,而对于 VCHAR 的 NULL 值不占用任何存储空间。
注意:此处需要注意 VCHAR 类型和 CHAR 类型在建表时传入的参数是字符长度而不是字节长度,实际的字节长度需要跟编码方式相关联,例如 UTF-8 一个中文字符需要 3 字节来表示,这样 CHAR(10) 以 UTF-8 来表示的话,它的字节长度在 10 - 30 之间。
行溢出
我们知道数据页的大小是 16KB,Innodb 存储引擎保证了每一页至少有两条记录,如果一页当中的记录过大,会截取前 768 个字节存入页中,其余的放入 BLOB Page。
命令hexdump -Cv
一个字节,8为,2个十六进制数可表示,一行16字节
CREATE TABLE mytest
(
t1
varchar(10) DEFAULT NULL,
t2
varchar(10) DEFAULT NULL,
t3
char(10) DEFAULT NULL,
t4
varchar(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=COMPACT;
INSERT INTO test
.mytest
(t1
, t2
, t3
, t4
) VALUES (‘a’, ‘bb’, ‘bb’, ‘cc’);
其中supremum后面的02 02 01分别代表三列的长度
0000c070 73 75 70 72 65 6d 75 6d 02 02 01 00 00 00 10 ff |supremum........|
0000c080 ef 00 00 00 00 07 00 00 00 00 11 29 90 df 00 00 |...........)....|
0000c090 01 b7 01 10 61 62 62 62 62 20 20 20 20 20 20 20 |....abbbb |
0000c0a0 20 63 63 00 00 00 00 00 00 00 00 00 00 00 00 00 | cc.............|
0000c0b0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
这里说明了varchar和char的区别,我们设置的长度a列为10字节,但是实际上就占了a的一个字节,而bb列在内盘里确实占了10个字节
说明例子
03 02 01 /*变长字段长度列表,逆序*/
00 /*NULL标志位,第一行没有NULL值*/
00 00 10 00 2C/*Record Header,固定5字节长度*/
00 00 00 2B 68 00/*RowID InnoDB自动创建,6字节*/
00 00 00 00 06 05/*TransactionID*/
80 00 00 00 32 01 10/*Roll Pointer*/
61/*列1数据 'a' */
62 62/*列2数据 'bb' */
62 62 20 20 20 20 20 20 20 20/*列3数据 'bb' */
63 63 63/*列4数据 'ccc' */
第三行有NULL值,因此NULL表示为不再是00 而是06,转成二进制位00000110,为1的值代表第2列和第3列的数据为NULL。可以发现,第三行只存储了第1列和第4列非NULL的值。
与Redundant行记录格式相比:
不管是CHAR类型还是VARCHAR类型,在Compact格式下NULL值都是不占用任何存储空间的。
行溢出
接着我们再来深入分析下关于限制大小“65535”的一些容易混淆的概念。
1、“65535”不是单个varchar(N)中N的最大限制,而是整个表非大字段类型的字段的bytes总合。
2、不同的字符集对字段可存储的max会有影响,例如,UTF8字符需要3个字节存储,对于VARCHAR(255)CHARACTER SET UTF8列,会占用255×3 =765的字节。故该表不能包含超过65,535/765=85这样的列。GBK是双字节的以此类推。
3、可变长度列在评估字段大小时还要考虑存储列实际长度的字节数。例如,VARCHAR(255)CHARACTER SET UTF8列需要额外的两个字节来存储值长度信息,所以该列需要多达767个字节存储,其实最大可以存储65533字节,剩余两个字节存储长度信息。
4、BLOB、TEXT、JSON列不同于varchar、char等字段,列长度信息独立于行长存储,可以达到65535字节真实存储
5、定义NULL列会降低允许的最大列数。
InnoDB表,NULL和NOT NULL列存储大小是一样
MyISAM表,NULL列需要额外的空间记录其值是否为NULL。每个NULL需要一个额外的位(四舍五入到最接近的字节)。最大行长度计算如下:
row length = 1 + (sum of column lengths) + (number of NULL columns + delete_flag + 7)/8 + (number of variable-length columns)
静态表,delete_flag = 1,静态表通过在该行记录一个位来标识该行是否已被删除。
动态表,delete_flag = 0,该标记存储在动态行首,动态表具体可以根据
6、对于InnoDB表,NULL和NOT NULL列存储大小是一样
7、InnoDB允许单表最多1000个列
8、varchar主键只支持不超过767个字节或者768/2=384个双字节 或者767/3=255个三字节的字段 而GBK是双字节的,UTF8是三字节的
9、不用的引擎对索引的限制有区别
innodb每个列的长度不能大于767 bytes;所有组成索引列的长度和不能大于3072 bytes
myisam 每个列的长度不能大于1000 bytes,所有组成索引列的长度和不能大于1000 bytes
Mysql 5中
非空CHAR的最大总长度是255【字节】;非空VARCHAR的最大总长度是65533【字节】。
可空CHAR的最大总长度是254【字节】;可空VARCHAR的最大总长度是65532【字节】。
原因:非空标记需要占据一个字节,VARCHAR超过255需要用2个字节标记字段长度,不超过255用1个字节标记字段长度.
注意上边是 【字节】,不是【字符】。但mysql5字段定义时,是定义的【字符】数。比如varchar(10),你仅能存入10个英文字母或者汉字,尽管一个字符可能占多个字节。
一个字符可能占用多个字节,这由编码和存放的字符决定。比如UTF8(一种变长的unicode编码)中,一般一个汉字占据3个字节,一个英文字母占据一个字节。
所以,在UTF8的环境下,不允许定义 VARCHAR(65535),因为这远远超出了65535个字节的限制。
①compact
如果blob列值长度 <= 768 bytes,不会发生行溢出(page overflow),内容都在数据页(B-tree Node);如果列值长度 > 768字节,那么前768字节依然在数据页,而剩余的则放在溢出页(off-page),如下图:
上面讲的blob或变长大字段类型包括blob、text、varchar,其中varchar列值长度大于某数N时也会存溢出页,在latin1字符集下N值可以这样计算:innodb的块大小默认为16kb,由于innodb存储引擎表为索引组织表,树底层的叶子节点为一双向链表,因此每个页中至少应该有两行记录,这就决定了innodb在存储一行数据的时候不能够超过8k,减去其它列值所占字节数,约等于N。
溢出页
数据组织
索引是如何组织数据的?
一个问题?
InnoDB一棵B+树可以存放多少行数据?这个问题的简单回答是:约2千万。为什么是这么多呢?因为这是可以算出来的,要搞清楚这个问题,我们先从InnoDB索引数据结构、数据组织方式说起。
我们都知道计算机在存储数据的时候,有最小存储单元,这就好比我们今天进行现金的流通最小单位是一毛。在计算机中磁盘存储数据最小单元是扇区,一个扇区的大小是512字节,而文件系统(例如XFS/EXT4)他的最小单元是块,一个块的大小是4k,而对于我们的InnoDB存储引擎也有自己的最小储存单元——页(Page),一个页的大小是16K。
下面几张图可以帮你理解最小存储单元:
文件系统中一个文件大小只有1个字节,但不得不占磁盘上4KB的空间。
innodb的所有数据文件(后缀为ibd的文件),他的大小始终都是16384(16k)的整数倍。
磁盘扇区、文件系统、InnoDB存储引擎都有各自的最小存储单元。
数据表中的数据都是存储在页中的,所以一个页中能存储多少行数据呢?假设一行数据的大小是1k,那么一个页可以存放16行这样的数据。
如果数据库只按这样的方式存储,那么如何查找数据就成为一个问题,因为我们不知道要查找的数据存在哪个页中,也不可能把所有的页遍历一遍,那样太慢了。所以人们想了一个办法,用B+树的方式组织这些数据。如图所示:
我们先将数据记录按主键进行排序,分别存放在不同的页中(为了便于理解我们这里一个页中只存放3条记录,实际情况可以存放很多),除了存放数据的页以外,还有存放键值+指针的页,如图中page number=3的页,该页存放键值和指向数据页的指针,这样的页由N个键值+指针组成。当然它也是排好序的。这样的数据组织形式,我们称为索引组织表。现在来看下,要查找一条数据,怎么查?
如select * from user where id=5;
这里id是主键,我们通过这棵B+树来查找,首先找到根页,你怎么知道user表的根页在哪呢?其实每张表的根页位置在表空间文件中是固定的,即page number=3的页(这点我们下文还会进一步证明),找到根页后通过二分查找法,定位到id=5的数据应该在指针P5指向的页中,那么进一步去page number=5的页中查找,同样通过二分查询法即可找到id=5的记录:
现在我们清楚了InnoDB中主键索引B+树是如何组织数据、查询数据的,我们总结一下:
1、InnoDB存储引擎的最小存储单元是页,页可以用于存放数据也可以用于存放键值+指针,在B+树中叶子节点存放数据,非叶子节点存放键值+指针。
2、索引组织表通过非叶子节点的二分查找法以及指针确定数据在哪个页中,进而在去数据页中查找到需要的数据;
那么回到我们开始的问题,通常一棵B+树可以存放多少行数据?
这里我们先假设B+树高为2,即存在一个根节点和若干个叶子节点,那么这棵B+树的存放总记录数为:根节点指针数*单个叶子节点记录行数。
上文我们已经说明单个叶子节点(页)中的记录数=16K/1K=16。(这里假设一行记录的数据大小为1k,实际上现在很多互联网业务数据记录大小通常就是1K左右)。
那么现在我们需要计算出非叶子节点能存放多少指针,其实这也很好算,我们假设主键ID为bigint类型,长度为8字节,而指针大小在InnoDB源码中设置为6字节,这样一共14字节,我们一个页中能存放多少这样的单元,其实就代表有多少指针,即16384/14=1170。那么可以算出一棵高度为2的B+树,能存放1170*16=18720条这样的数据记录。
根据同样的原理我们可以算出一个高度为3的B+树可以存放:1170117016=21902400条这样的记录。所以在InnoDB中B+树高度一般为1-3层,它就能满足千万级的数据存储。在查找数据时一次页的查找代表一次IO,所以通过主键索引查询通常只需要1-3次IO操作即可查找到数据。
怎么得到InnoDB主键索引B+树的高度?
上面我们通过推断得出B+树的高度通常是1-3,下面我们从另外一个侧面证明这个结论。在InnoDB的表空间文件中,约定page number为3的代表主键索引的根页,而在根页偏移量为64的地方存放了该B+树的page level。如果page level为1,树高为2,page level为2,则树高为3。即B+树的高度=page level+1;下面我们将从实际环境中尝试找到这个page level。
我们学习了innodb文件系统的大的框架,知道了innodb文件系统是由一些log和每个表的ibd(16K的整数倍)等文件组成的。那么这些文件,里面是怎么样的呢?
表数据文件IBD
当你新建一个库时,首先文件系统上会多一个以库名命名的文件夹。里面有ibd、frm文件,每个表对应一个ibd文件。
那么当我们新建库时,innodb做了什么呢?会初始化一个名叫ibdata1的表空间文件,用来存储所有该库的表数据,以及一些系统表,列等系统信息。还会存储将来做事务时用来保证数据完整性的回滚段数据。
上一篇我们学过了,不同的表既可以共用一个ibd文件,也可以每个表自己一个ibd文件,默认是一个表一个。
但是需要注意的是,虽然是一个表一个ibd,但这个ibd里只存储了该表的B+树数据、索引、插入缓存等信息,其余的信息如列、属性等信息还是存储在默认的ibdata1里面的。
那么ibd里到底是什么数据呢?答案就是该表的所有索引数据。
相信很多人就要迷茫了,索引不就是相当于目录吗,那每行数据跑哪里去了。要是研究过B+ tree的人应该不会迷茫,没看过的可能还以为数据库是个TXT文件呢,一行数据占文本一行。其实不是的,索引里就包含了所有数据。
如果有迷茫的,还是先看这一篇,https://blog.csdn.net/tianyaleixiaowu/article/details/94552675 或者在网上找找B+ tree原理看看,后面也会讲。B+ tree的叶子节点,就会存放所有的数据。整个表,其实就是一棵B+ tree,一个ibd就是1-N个b+ tree。N等于你的索引数量。
当你新建一个表时,你会给表创建一个主键primary Key,然后这个key就带着整行数据占据着一块空间,作为B+ tree的一个叶子节点里元素,将来要找数据,就要靠这个主键了。你可以理解为一个key-value键值对,key就是主键,value就是整行数据。如果你根本就没创建主键(不推荐),那innodb也会给你分配一个RowId来作为将来找它的主键,虽然你看不到。
这棵拥有全量数据的b+ tree,就是将来提供数据的树了,一般来说,这棵树最大也就4层,3层就能存2千万数据了,4层就很多个亿了,将来通过主键查询时,通过2-4次IO就能找到数据行。这个索引树,我们给它起了个名字——聚簇索引。
是不是终于看到面试点了,谈谈聚簇索引和二级索引(非聚簇索引)。
二级索引就是你平时创建的那些索引了,可以建多个,建在一个列或者多个列上。这些索引也会构成B+ tree,和聚簇索引的区别就是它不需要存每行的详细数据,它的叶子节点只需要存primary key或(rowId)(当然还有主键索引所在磁盘的位置PageNo)。将来能让你通过这个索引找到数据行的ID就行了。要查数据时,就根据ID去聚簇索引那棵大树去查就是了,这就是回表。
最后,索引是方便查询的,索引列的数据不适合放大的,它占用的空间一多,那么B+ tree一层中能放的个数就越少。索引列一多,插入就越慢,如果没有索引,插入一行时只需要对主键进行排序即可。如果有很多列都有索引,那么插入时,就要做很多次排序。
数据文件格式
之前已经知道了,磁盘最小单位是512字节,操作系统是4KB,mysql里最小的是page(页面)有16K。现在也知道了ibd就是放索引树的,那总不能一个树就摊在一个txt文档里吧,所以必须还要有一种文件组织结构。所有的数据都放在page里,得用一种规则来把N个page连一起,让它们形成一些关联,才能将来好查询,要先找到page,再找到page内的数据。
文件格式包括段、簇、页面。
段
这是一个逻辑概念,并不是一个实际存在的文件。它是构成索引的基本元素,当你创建一个B+ tree索引时,会同时创建两个段。
他们是内节点段和叶子段,内节点段用来管理B+ tree里非叶子节点的数据,叶子段用来管理叶子节点的数据。叶子和非叶子应该知道是什么了,里面存的东西都是什么应该也清楚,如果还不知道,建议切蛋自尽。
内节点段负责管理那些非叶子节点的分裂啊、增长啊、删除啊,叶子端就负责行数据的相关动作。
簇
这玩意比段要低一级,段是个逻辑概念,段内部就是多个簇(Extent)组成的。一个簇是物理上连续分配的一段空间,(连续很重要)。每个段至少会有一个簇,在创建一个段时就会创建一个默认的簇,一个簇的大小默认是64个Page(页面),所以一个簇就是64*16K的硬盘空间。
当往段里写入数据后,就是往簇里写数据,簇可是硬盘空间。当一个簇已经放不下时,就会再来一个簇,等于又多了一块64*16K的连续硬盘空间。段可以无限大,注意,每个簇是一块连续的硬盘空间,但多个簇之间可不是连续的。
同样,两个段之间,在硬盘上也没有什么关系。
页面
每个簇里有64个页面,都会进行编号,页面就是最小的存储单元了。这个簇里的每个页面都是连续的一段空间,往里面写数据时,就会一个页面一个页面的写入,一个页面占满了,就去下一个页面。一个页面16K,放主键如int型能放好几千,放一行数据,譬如1K一行,能放十几行。这里就需要注意了,一行数据尽量不要过大,一旦跨page了,就会对性能产生影响。本来一个page就能查出来,结果每次要查2个page,那性能就丢了一倍。
索引的每个节点都是一个数据页,为什么一页最少两条数据?
如果只有一个数据,那就一个指针,单个指针不能成树,仅仅是链表,因此最少两条数据。
数据库buffer实现
Cache和Buffer是两个不同的概念,简单的说,Cache是加速“读”,而 buffer是缓冲“写”,前者解决读的问题,保存从磁盘上读出的数据,后者是解决写的问题,保存即将要写入到磁盘上的数据。在很多情况下,这两个名词并没有严格区分,常常把读写混合类型称为buffer cache,在Oracle Instance里同样有一块区域作为数据库缓冲区&&高速缓存。
buffer pool是什么
咱们在使用mysql的时候,比如很简单的select * from table;这条语句,具体查询数据其实是在存储引擎中实现的,大家都知道mysql数据其实是放在磁盘里面的,如果每次查询都直接从磁盘里面查询,这样势必会很影响性能,所以一定是先把数据从磁盘中取出,然后放在内存中,下次查询直接从内存中来取。但是一台机器中往往不是只有mysql一个进程在运行的,很多个进程都需要使用内存,所以mysql中会有一个专门的区域来处理这些数据,这个专门为mysql准备的区域,就叫buffer pool。
咱们以查询语句为例 1:在查询的时候会先去buffer pool(内存)中看看有没有对应的数据页,如果有的话直接返回 2:如果buffer pool中没有对应的数据页,则会去磁盘中查找,磁盘中如果找到了对应的数据,则会把该页的数据直接copy一份到buffer pool中返回给客户端 3:下次有同样的查询进来直接查找buffer pool找到对应的数据返回即可。
大家看到这里相信应该对buffer pool有了个大概的认识,有没有感觉有点缓存的感觉,当然buffer pool可没有缓存那么简单,内部结构还是比较复杂的,不过没关系,咱们继续往下看。
buffer pool数据管理
数据管理的基本单位
buffer pool毕竟是一种内存管理,数据当然不是按照一条一条的sql语句来管理的,而是按照数据页来管理的,innodb 引擎默认的数据页是16kb,而buffer pool启动的时候是默认的128M,所以是有8192个数据页的。而磁盘的数据管理也是用数据页为单位来管理的,所以每次查找数据的时候,先请求buffer pool,buffer pool中没有的话会到磁盘中找到对应的数据页,然后copy到buffer pool中给客户端返回。
大家可以看一看free链表的结构
free链表有一个基节点,记录了该free链表的唯一标志,该链表的尾节点地址,以及链表的总长度
基节点后面会有很多的控制块,控制块本身很小,只是存储了指向空闲数据页的指针而已,所以buffer pool在寻找空闲数据页的时候直接用free链表可以直接找到。
只要有一页数据空闲出来之后,直接把该数据页的地址追加到free链表即可。
flush链表
当然只是用free链表是解决不了所有问题的,比如:我们在执行update table test set field_a = 1;的时候,我们是先修改buffer pool里面对应的数据页,然后再更新磁盘中对应的数据页的,(当然这里会涉及到一个数据一致性的问题,mysql是用redo log解决的,这个不在咱们这篇文章的讨论范围之内)我们把buffer pool中对应修改的数据页同步修改到磁盘的时候,这个过程称之为"刷脏",刷脏是有一定策略的,可以用
select @@innodb_flush_log_at_trx_commit;
我们一般都不会设置实时写,这样很影响性能,所以一般都是延迟写的,那么就会引发一个问题,mysql是如何在buffer pool中找到被修改过的脏数据的呢?这里咱们就用上了flush链表了,其实和free链表比较像
flush链表上面维护的都是脏数据页的指针。刷脏的时候直接遍历flush链表去刷脏就可以了
lru链表
buffer pool是有一定空间限制的,默认是128M,总会有空间塞满的时候的,所以数据页是有淘汰机制的,淘汰机制就是lru(最近最少使用)。
lru原理其实也很简单,使用到过的数据页,直接移动到链表的头部,然后在buffer pool满了之后直接淘汰掉链表尾部的数据页就可以了。
lru链表的优化
其实简单的lru链表是存在一定的问题的,比如咱们在工作过程中,可能会用上 select * from test这样的语句来进行一些刷数据等需求,如果test表是非常大的,很有可能一下子把buffer pool占满,把之前的数据页全部都淘汰掉,然后其余的数据在线上业务正常执行的时候,又会回来重新把之前select * from test 占用的数据页重新慢慢淘汰掉,这一来一去是非常影响线上的性能的。
所以鉴于以上所在的问题,mysql的buffer pool是在lru的基础上进行了一些优化的。
buffer pool的lru链表把数据分为了热数据块和冷数据块,比例大概5:3的样子,每次新的数据页写入都会写入冷数据区。
但是如果这样的话那么热数据区永远都不会有数据,所以冷数据区写入的时候会另外记录上写入的时间,下次访问该数据区的时候如果时间间隔大于1s,那么就会放入热数据区,这样就不会淘汰掉大量的无辜数据。所以我们在执行select * from test这种语句刷新脚本的时候,只会占用冷数据的空间,而不会影响到热数据。
上面是读缓存,功能上类似操作系统对磁盘的cache
innodb写缓冲(change buffer)
情况一
假如要修改页号为4的索引页,而这个页正好在缓冲池内。
如上图序号1-2:
(1)直接修改缓冲池中的页,一次内存操作;
(2)写入redo log,一次磁盘顺序写操作;
这样的效率是最高的。
画外音:像写日志这种顺序写,每秒几万次没问题。
是否会出现一致性问题呢?
并不会。
(1)读取,会命中缓冲池的页;
(2)缓冲池LRU数据淘汰,会将“脏页”刷回磁盘;
(3)数据库异常奔溃,能够从redo log中恢复数据;
什么时候缓冲池中的页,会刷到磁盘上呢?
定期刷磁盘,而不是每次刷磁盘,能够降低磁盘IO,提升MySQL的性能。
画外音:批量写,是常见的优化手段。
情况二
假如要修改页号为40的索引页,而这个页正好不在缓冲池内。
此时麻烦一点,如上图需要1-3:
(1)先把需要为40的索引页,从磁盘加载到缓冲池,一次磁盘随机读操作;
(2)修改缓冲池中的页,一次内存操作;
(3)写入redo log,一次磁盘顺序写操作;
没有命中缓冲池的时候,至少产生一次磁盘IO,对于写多读少的业务场景,是否还有优化的空间呢?
这即是InnoDB考虑的问题,又是本文将要讨论的写缓冲(change buffer)。
画外音:从名字容易看出,写缓冲是降低磁盘IO,提升数据库写性能的一种机制。
什么是InnoDB的写缓冲?
在MySQL5.5之前,叫插入缓冲(insert buffer),只针对insert做了优化;现在对delete和update也有效,叫做写缓冲(change buffer)。
它是一种应用在非唯一普通索引页(non-unique secondary index page)不在缓冲池中,对页进行了写操作,并不会立刻将磁盘页加载到缓冲池,而仅仅记录缓冲变更(buffer changes),等未来数据被读取时,再将数据合并(merge)恢复到缓冲池中的技术。写缓冲的目的是降低写操作的磁盘IO,提升数据库性能。
画外音:R了狗了,这个句子,好长。
InnoDB加入写缓冲优化,上文“情况二”流程会有什么变化?
假如要修改页号为40的索引页,而这个页正好不在缓冲池内
加入写缓冲优化后,流程优化为:
(1)在写缓冲中记录这个操作,一次内存操作;
(2)写入redo log,一次磁盘顺序写操作;
其性能与,这个索引页在缓冲池中,相近。
画外音:可以看到,40这一页,并没有加载到缓冲池中。
是否会出现一致性问题呢?
也不会。
(1)数据库异常奔溃,能够从redo log中恢复数据;
(2)写缓冲不只是一个内存结构,它也会被定期刷盘到写缓冲系统表空间;
(3)数据读取时,有另外的流程,将数据合并到缓冲池;
不妨设,稍后的一个时间,有请求查询索引页40的数据。
此时的流程如序号1-3:
(1)载入索引页,缓冲池未命中,这次磁盘IO不可避免;
(2)从写缓冲读取相关信息;
(3)恢复索引页,放到缓冲池LRU里;
画外音:可以看到,40这一页,在真正被读取时,才会被加载到缓冲池中。
还有一个遗漏问题,为什么写缓冲优化,仅适用于非唯一普通索引页呢?
如果索引设置了唯一(unique)属性,在进行修改操作时,InnoDB必须进行唯一性检查。也就是说,索引页即使不在缓冲池,磁盘上的页读取无法避免(否则怎么校验是否唯一?),此时就应该直接把相应的页放入缓冲池再进行修改,而不应该再整写缓冲这个幺蛾子。
除了数据页被访问,还有哪些场景会触发刷写缓冲中的数据呢?
还有这么几种情况,会刷写缓冲中的数据:
(1)有一个后台线程,会认为数据库空闲时;
(2)数据库缓冲池不够用时;
(3)数据库正常关闭时;
(4)redo log写满时;
画外音:几乎不会出现redo log写满,此时整个数据库处于无法写入的不可用状态。
什么业务场景,适合开启InnoDB的写缓冲机制?
先说什么时候不适合,如上文分析,当:
(1)数据库都是唯一索引;
(2)或者,写入一个数据后,会立刻读取它;
这两类场景,在写操作进行时(进行后),本来就要进行进行页读取,本来相应页面就要入缓冲池,此时写缓存反倒成了负担,增加了复杂度。
什么时候适合使用写缓冲,如果:
(1)数据库大部分是非唯一索引;
(2)业务是写多读少,或者不是写后立刻读取;
可以使用写缓冲,将原本每次写入都需要进行磁盘IO的SQL,优化定期批量写磁盘。
画外音:例如,账单流水业务。
上述原理,对应InnoDB里哪些参数?
参数:innodb_change_buffer_max_size
介绍:配置写缓冲的大小,占整个缓冲池的比例,默认值是25%,最大值是50%。
画外音:写多读少的业务,才需要调大这个值,读多写少的业务,25%其实也多了。
参数:innodb_change_buffering
介绍:配置哪些写操作启用写缓冲,可以设置成all/none/inserts/deletes等。