表空间(Tablespace)->段(Segment)->区(Extent)->页(Page)->(Row)行
表空间(ibd文件),一个mysql实例可以对应多个表空间,用于存储记录、索引等数据。
段,分为数据段(Leaf node segment)和索引段(Non-leaf node segment)、回滚段(Rollback segment),InnoDB是索引组织表,数据段就是B+数的叶子节点,索引段就是B+树的非叶子节点。段用来管理多个区。
区,表空间的单元结构,每个区的大小为1M,默认情况下,InnoDB存储引擎页大小为16KB,即一个区中共有64个连续的页。
页,InnoDB存储引擎磁盘管理的最小单元,每个页的大小默认为16KB。为了保证页的连续性,InnoDB存储引擎每次空磁盘申请4-5个区。
行,InnoDB存储引擎数据是按行进行存放的。Trx_id:每次对莫条记录进行改动时,都会把对应的事务id赋值给trx_id隐藏列。Roll_pointer:每次对某条引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
缓冲池以Page为单位,底层采用链表结构管理Page。根据状态可以将页分为三类:
缓冲池是通过LRU算法进行进行管理的,页的大小默认为16KB。LRU列表用来管理已经读取的页,数据库刚启动是LRU列表是空的,这时的页都在Free列表里。
InnoDB 1.0.x之后支持压缩页的功能,将原本16KB的页压缩为1KB、2KB、4KB和8KB,非16KB的页通过unzip_LRU列表进行管理。
在LRU列表中的页被修改后,该页被称为脏页(dirty page),这个时候数据库会通过checkpoint机制刷脏,而Flush列表即为脏页列表。脏页同时存在于LRU列表和Flush列表,LRU用来管理缓冲池的可用性,Flush用来管理刷脏,二者互不影响。
针对于非唯一二级索引,在5.x版本之前叫做插入缓冲区,8版本之后叫做更改缓冲区。在执行DML语句时,如果这些数据Page没有在Buffer Pool中,不会直接修改磁盘,而是将数据变更保存在更改缓冲区里面。等待以后数据被读取时,再将数据合并到Buffer Pool中,然后再刷新到磁盘。
Change Buffer的意义:
和聚簇索引不同,二级索引通常是不唯一的,并且插入和删除的顺序是随机的,可能会影响索引树中不相邻的二级索引页,要是每次都操作磁盘就会造成大量的磁盘IO。所以我们把操作合并在Change Bufer里面,减少磁盘IO。
自适应hash索引用于单条数据的查询优化,InnoDB会监控对表上索引页的查询,如果观察到可以用hash索引提升速度,那么就会自己建立hash索引,不需要人工干预,默认是开启的。
用来缓存日志(redo log、undo log),默认大小16M,日志缓冲区会定期刷新到磁盘,达到节省磁盘IO的目的。如果需要更新、插入、删除许多行的事务,可以增大该缓冲区节省磁盘IO。
innodb_log_buffer_size:缓冲区大小
innodb_flush_log_at_trx_commit:日志刷盘的时机,0表示每秒写入并刷盘,1表示每次事务提交时写入并刷盘,2表示每次事务提交后写入,并每秒刷盘。
InnoDB首先将重做日志放入这个缓冲区,然后按照一定的频率刷新到重做日志文件,该缓冲区不用设置很大,一般每一秒钟都会刷新日志文件,用户只需要保证每秒钟产生的事务量在这个大小之内即可。
下列三种情况会刷新重做日志:
系统表空间是用来保存修改缓冲区的。如果表不是在每个独立表空间或者通用表空间中创建,它也会包含表和索引数据。(在MySQL5.x版本中还包含InnoDB数据字典、undolog等)。
参数:
innodb_data_file_path:系统表空间的存储路径
每个表的独立表空间,包含单个表的数据和索引,存储在文件系统上的单个数据文件中,默认是开启的。开启后每个数据库都会有一个独立的文件夹(如下图,是三个不同数据库的文件夹):
每个表都会有一个独立的ibd文件(下图是learn_ssm数据库的表文件):
参数:innodb_file_per_table
通用表空间,需要自己手动创建,后续建表可以指定使用该表空间,一般很少用。
创建表空间语法:
CREATE TABLESPACE 表空间名称 ADD DATAFILE '表文件名' ENGINE = 'innodb';
指定表空间语法:
CREATE TABLE (xxx) ENGINE=innodb TABLESPACE 表空间名称
创建测试表空间:
创建成功之后,在mysql目录下可以找到test.ibd文件:
撤销表空间,MySQL实例在初始化时会自动创建两个默认的undo表空间(初始大小16M),用于存储undo log日志。
在MySQL目录下可以找到这两个文件:
主要用来存储用户创建的一些临时表。
双写缓冲区,InnoDB引擎在刷脏前,会先将页写入到双写缓冲区文件里面,便于系统异常时恢复数据。
可以在MySQL目录下找到两个双写缓冲区文件:
重做日志,用来实现事务的持久性,由重做日志缓冲(在内存)和重做日志文件(在磁盘)两部分组成。当事务提交之后,会把所有的修改信息都存到该日志中,用于在刷脏发生错误时,进行数据恢复。RedoLog是循环写入的,不会永久保存。
这是一个非常核心的线程,主要负责调度其他线程,将缓冲池的数据异步刷新到磁盘,保证数据一致性,包括刷脏、合并插入缓冲、UNDO页的回收等。
该线程主要负责AIO请求的回调处理。
线程类型 | 默认个数 | 职责 |
---|---|---|
Read thread | 4 | 负责读操作 |
Write thread | 4 | 负责写操作 |
Log thread | 1 | 将日志刷新到磁盘 |
Insert buffer thread | 1 | 将写缓冲区内容刷新到磁盘 |
用来回收已经使用并分配的undo页,从InnoDB1.1开始,purge操作可以独立到单独的线程来减轻Master Thread的工作。用户可以在配置文件添加如下命令来启动独立的purge线程:
[mysqld]
innodb_purge_thread=1
该线程是1.2.x版本引入的,单独用来做脏页刷新,减轻 Master Thread 的工作,减少阻塞。
事务时一组操作的集合,不可分割的工作单位,事务的四大特性:ACID
ACD是由redo log和undo log来保证的,I是由锁机制和MVCC来保证的。
持久性是有redo log来保证的。当缓冲区数据更改时,会把修改的数据保存到redo log buffer中,在事务提交的时候,会把redo log buffer刷到redo log文件里。
如果后面在刷脏的时候出错了,就可以通过redo log来恢复,因为redo log记录了当次数据的变化。
如果刷脏成功,那么redo log里的记录就没有意义了,所以每隔一段时间就会去清理redo log日志。
为什么每次事务提交都把redo log刷新到磁盘,而不是直接刷新数据?
可以直接刷新数据,但是存在严重的性能问题。因为一个事务通常会操作很多条记录,而这些记录所在的数据页都是随机的,会造成大量的随机磁盘IO。但是我们刷日志文件的话,日志文件都是顺序追加的,所以速度很快。
如果日志刷盘的时候失败了怎么办?
回滚日志,用来记录数据被修改前的信息,作用包含两个:提供回滚的MVCC,在多版本并发控制的时候,可以依据undo log来找到记录的历史版本。
redo log记录的是物理记录,即记录真实的数据,而undo log是逻辑记录,记录的是每一步的反向操作,可以认为执行delete的时候,undo log会有一条相反的insert操作,执行update操作的时候,会有一条相反的update操作。当事务回滚时,就会执行对应的反向操作,恢复成之前的数据,这样就会保证事务的原子性。
undo log销毁:一旦事务提交或者回滚,那么这份undo log也就不需要了,但是它不会立即去删除,还会去检查MVCC会不会用到该日志。
undo log存储:采用段的方式进行管理和存储,存放在rollback segment回滚段中,里面包含1024个undo log segment。
读取的是记录的最新版本,读取的时候保证其他并发事务不能修改当前记录,所以会对记录进行枷锁。如:
SELECT ... LOCK IN SHARE MODE # 共享锁
SELECT ... FOR UPDATE、 INSERT、 DELETE # 排他锁
这些锁都是一种当前读。
下面演示一下当前读:
首先我们打开两个MySQL连接,选择了learn_ssm数据库,展示了tb_user表的数据
然后我们使用begin分别开启两个事务
在第二个连接里面修改一条记录,这个时候事务还没有提交,我们在第一个连接里面查看,发现读取不到修改
当我们提交事务之后,发现第一个连接还是读取不到修改
这是因为InnoDB默认的隔离级别是可重复读,所以不会感知到其他事务的修改,这个时候我们在第一个连接里面加上当前读的锁,就可以得到最新的数据
这个就是当前读的作用,用来获取记录的最新版本。
简单的不加锁的select语句就是快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁所以它是非阻塞的。
全称 Multi-Version-Concurrency-Control,多版本并发控制。维护一个数据的多个版本,使得读写操作没有冲突,快照读给MVCC提供了非阻塞读功能。当我们在快照读的时候,就要通过MVCC来获取记录的历史版本。MVCC的实现还依赖于数据记录里的三个隐藏字段、undo日志和readView。
隐藏字段 | 作用 |
---|---|
DB_TRX_ID | 最近一次修改或插入该记录的事务的ID |
DB_ROLL_PTR | 回滚指针,指向这条记录的上一个版本,配合undo日志一起使用 |
DB_ROW_ID | 隐藏主键,如果这张表没有人为设置主键,那么就会生成这个字段作为记录的主键 |
我们可以使用ibd2sdi命令查看ibd文件信息,在这里我们可以看到这些隐藏字段:
首先进入数据库目录,然后输入 ibd2sdi tb_user.ibd 查看文件信息
在columns下面我们可以找到最近事务ID和回滚指针:
当insert的时候,产生的undo日志只在回滚时需要,所以在事务提交后,可以被立即删除。
而update、delete的时候,产生的undo日志在回滚和快照读的时候都需要,不会被立即删除。
在记录未被修改之前,回滚指针指向null。当我们在事务2里面对记录进行修改,InnnoDB会在undo日志里面保存修改之前的版本,然后用在记录的新版本里面,用回滚指针指向老版本记录的地址,事务id为修改记录的事务的id。
后面的事务3和4的修改也是同样的操作,这样一来我们就得到了一条链表,链表头节点为记录的最新版本,链表尾节点为记录的最老版本,我们可以通过这样一条链表进行记录的回滚操作。
读视图是快照读的时候,MVCC提取数据的依据,记录和维护当前未提交的事务id,包含如下字段:
字段 | 含义 |
---|---|
m_ids | 未提交事务id集合 |
min_trx_id | 最小未提交事务id |
max_trx_id | 预分配事务id,最大未提交事务id+1(因为事务id是自增的) |
creator_trx_id | readview创建者的事务id,也就是当前查询事务的id |
快照读是使用记录的事务id字段去读取的,具体读取规则如下:
当前记录的事务id等于creator_trx_id,也就是说这条记录是我们自己修改的,那么就可以读取。
否则,我们就找不超过max_rtx_id且已经提交了的事务(也就是小于min_trx_id的事务,和在min和max之间但是不在m_ids内的事务),就是我们要找的那条记录。
如果上述条件都不符合,那么我们就根据undo版本链表回滚记录,直到找到一个符合条件的记录。
因为是在undo日志版本链的记录是从新到旧的,所以我们找到的一定是提交的事务里,最新的那条记录。
不同隔离级别,生成readview的时机:
READ COMMITTED:每次查询都会生成,所以该隔离级别的每次查询都是快照读。
REPEATABLE READ:只在第一次查询的时候生成,后面的查询都是复用第一次,也是因为这个原因,保证了重复读。
下图是在 READ COMMITTED 隔离级别下的情况:
如果在刷脏的时候宕机,那么数据就不能恢复了,为了避免这个问题,当前事务数据库都采用了Write Ahead Log 策略,即当事务提交时先写重做日志,再修改页,宕机之后就可以通过重做日志来恢复。
Checkpoint目的是解决以下几个问题:
当数据库宕机时,不需要重做所有日志,因为Checkpoint之前的页已经刷回了磁盘,只需要对Checkpoint之后的重做日志进行恢复,大大缩短了恢复时间。
当LRU算法淘汰的页是脏页时,需要强制执行Checkpoint刷脏。
重做日志的设计是循环使用的,被重用都是不需要的部分,因此可以覆盖使用,若此时重做日志还需要使用,就必须强行产生Checkpoint刷脏。
InnoDB有两种Checkpoint:
Fuzzy Checkpoint发生的几种情况:
在可重复读的隔离级别下,分别开启两个事务A和B。在B事务里面插入一条记录的时候,A事务开启共享锁再去查询的时候,发现卡死了,直到B事务提交,A事务才继续执行,在B事务里面修改记录也一样会卡死。为什么A事务会卡死?
因为在事务修改的时候,会给对应的记录加上行锁,只有在事务提交之后才会释放锁。所以A事务去读取时,因为有锁,所以就一直卡在那了,直到事务B提交释放锁,事务A才能够上锁,进而读取数据。
在串行化隔离级别下,事务B插入一条主id=13的数据,然后事务A去查询id=13的数据,发现卡住了。然后如果这种情况发生在可重复度的隔离级别下,事务A不会卡住,会返回空记录,但是如果事务A开启共享锁去查的话,事务A也会卡住。为什么会发生这种情况?