Yangming's Blog

beware the barrenness of a busy life

从四个方面了解InnoDB

09 Jul 2019 » InnoDB, MySQL

InnoDB首先是MySQL的一个可拔插存储引擎,提供事务型存储特性。在一个经典的RDBMS架构中,分为如下几个部分:

adb

MySQL即主要提供了Relational Query Procesor的功能,InnoDB就是上图的Transaction Storage Manager部分。那么本文就从四个方面阐述我所了解到的InnoDB,希望想了解InnoDB的人,看到这篇文章能有所收获。

Access Method

AccessMethod可以理解为数据在外存的组织形式,也可以简单理解为索引。在PostgreSQL内核中自带有6种,InnoDB目前有三种Btree,Rtree(spatial index),倒排索引(Full-Text Search Index)。

这里以最常用的Btree为例进行介绍。

文件组织方式

那么我们首先了解一下InnoDB的外存文件组织,在外存中,按照表空间(space)进行组织;当启动了innodb_file_per_table参数后,每个数据表对应一个文件(参看系统表:INFORMATION_SCHEMA.INNODB_SYS_DATAFILES)。而对于全局共享的对象,需要放在共享的表空间space0(全局变量`:innodb_data_file_path,默认为ibdata1)中。ibdata1中除了每个表空间都有的对象(ibuf_bitmap、inode等)外,其中还有如下信息:

  • Change buf
  • Transaction sys
  • Dictionary
  • Rollback seg
  • Double write buf

除了space0是共享的以外,我们还可以通过create tablespace创建General Tablespace,同样是全局共享的。

InnoDB Instrinsic Tables:InnoDB引擎内部的表,没有undo和redo,用户不可创建,只供引擎内部使用。

宏观上物理与逻辑的对应

image-20190726103704241

物理上,在每个space中,有若干个文件或者磁盘分区;每个file分为若干个segment,其中有LeafNodeSegment、NonLeafNodeSegment、rollbacksegment三种类型。每个segment是按照extent为单位进行伸缩,每个extent中有若干个固定大小的page,如下 图:

  • 对于uncompressed的表空间,page是16Kb;
  • 对于compressed的表空间,page是1~16Kb。

逻辑上,由若干page组织成一个Btree Index,分为聚簇(一级)索引和二级索引,二级索引的val为聚簇索引的key。

两个文件:

ib_buffer_poolSET GLOBAL innodb_buffer_pool_dump_at_shutdown=ON;,MySQL退出的时候将buffer中的spaceid+pageid缓存下来:

# cat ib_buffer_pool | head -n 3
17,2
17,1
17,3

ibtmp1: 由参数innodb_temp_data_file_path控制。临时表的独立表空间。

IO最小单元——Page

在InnoDB的代码中,有三个需要区分的名称:

  • frame是内存地址,指向具体的数据;
  • page是frame指向数据的状态信息,其中是需要写回到磁盘的数据;
  • Block代表一个Control Block(buf_block_t),对于每个frame对应一个ControlBlock结构进行控制信息管理,但这些信息不写回内存。

在InnoDB中有多种页类型,下面展示了FIL_PAGE_INDEX这个类型的结构。

image-20190726104115317

上图左侧是一个物理Page的结构,前面有三个头部信息:文件头部信息,索引头部信息,段头部信息;另外,还有两个虚拟系统记录:

  • infimum,该记录表示比该页所有记录都小。
  • supremum,该记录表示比改业所有记录都大。

文件头部信息包含了前向和后向的Page的偏移,以及该页最后一次修改的LSN。

索引头部信息包含了PageDirectory数组的大小,而PageDirectory则是从Page尾部开始分配。PageDirectory中维护了该页部分元组的偏移,在每个元组中通过n_owned字段,表示该记录前向有几个字段没在PageDirectory中。

dict_index_t->n_uniq:在dict_index_t->n_fields中,从前向后,足够用来判断键唯一的列数。

n_owned

在InnoDB中,表是按照B+Tree的方式存储。每个页中,都有一个PageDirectory(如下图)。其中保存了该页内的record的偏移量。由于不是每个record在PageDirectory中都有一个slot(这称为sparse slots),因此每个record中就有了一个n_owned变量,保存了该recored所own的record数,类似于前向元组数。

读写最小单元——Record

在innobase/include/rem0rec.ic;列出了record中各个值的偏移,如下图

image-20190726105136952

在上述Page中,我们可以看到每个Record记录了其后向Record的偏移,可以看出page中的record按照链表的形式组织起来。注意这个偏移是真实Record的数据部分的起始位置,如上图所示。另外,在每个Record中都有两(三)个隐藏列:

  • DB_TRX_ID:区别于GTID按照事务提交顺序排列,这是按照事务创建顺序排列。非锁定读的事务不会产生。
  • DB_ROLL_PTR:指向该记录的前一个版本的undolog record。
  • (DB_ROW_ID:如果没有主键,会创建一个。)

对于记录中的变长字段,InnoDB采用overflow page的方式进行存储,这也分为4中类型,如下:

image-20190726105556341

默认的rowformat由参数innodb_default_row_format控制,默认值是DYNAMIC

  • REDUNDANT:对于VARBINARY,VARCHAR,BLOB和 TEXT类型等类型,当长度超过767byte时,超过的部分会存储在额外的溢出页中。
  • COMPACT:和REDUNANT类似,只是溢出也的存储更加紧凑,节省20%的空间。
  • DYNAMIC:和上两个不同,当列值超过40byte时,那么行中只存储一个指针,指向附加页;然后每个附加页内还有一个指针指向后续数据,指针大小20byte。
  • COMPRESSED:存储方式和DYNAMIC相似,只是该方式支持压缩表。在系统表空间中不可用,因此该参数不能设置为默认。

REDUNDANTCOMPACT 索引键值最大为 767 byte,然而 DYNAMICCOMPRESSED 支持最大为 3072 byte。

注意,在主从复制中,如果innodb_default_row_format在master上设置为DYNAMIC ,在slave上设置为 COMPACT ,执行如下DDL操作,主上成功,从会失败。

CREATE TABLE t1 (c1 INT PRIMARY KEY, c2 VARCHAR(5000), KEY i1(c2(3070)));

Btree的操作

在InnoDB中,Btree是B+tree的变种,有以下特征:

  • 分为三类结构:Root、Internal、Leaf

  • Leaf的level=0,往上递增

  • 除root层,每层都由两个prev和next指针,指向Brother

  • 在节点内部,按照key值组织成一个单向链表

  • 链表的头部永远是Infimum,表示比所有key都小;尾部永远是Supremum,表示比所有key都大。

  • Non-Leaf节点中的记录叫node_pointer(对应叶子节点的key最小值,叶子节点的指针)
  • 没有固定的M值,不是通过M值来判断是否分裂;因为每个Btree的Page都是固定大小(16K),其中由链表维护一个已用的Record,另外还有一个Heap空间,等待分配;在page_cur_parse_insert_rec进行插入,当Heap空间不够插入一个Record时,那么就插入失败,进行分裂。

Btree的创建

创建一级索引,就是重建一个表。这里主要讨论创建二级索引。

版本<5.5,创建一个索引相当于重建一个表(CopyTable)。

版本>=5.5,加入了FastIndexCreate特性,但只对二级索引有效(Inplace);索引中只有发起createindex时刻的数据,create index时只能读不能写。

版本>=5.6.7,加入了Online Create Index的特性,创建二级索引的时候可读可写(还是会短暂block一下,但是已经影响很小了);对于创建索引过程中对表进行的修改,放在RowLog(不是redolog)中;如果创建过程中,MySQL故障了;故障恢复时,会丢弃未完成的Index。

Btree的插入

在插入的时候,当前page放不下了。那么,按照next指针找到下一个page,如果下一个page已经满了;这时就需要进行节点分裂,大概有4步:

  1. 创建一个新的page
  2. 确定要分裂的page以及要分裂的位置。
  3. 移动记录
  4. 修改next和prev指针等节点信息。

通过如下监控信息,可以统计InnoDB中节点分裂的次数。

SET GLOBAL innodb_monitor_enable = index_page_splits;
SET GLOBAL innodb_monitor_enable = index_page_reorg_attempts;
select * from INFORMATION_SCHEMA.INNODB_METRICS where name = 'index_page_reorg_attempts'\G
select * from INFORMATION_SCHEMA.INNODB_METRICS where name = 'index_page_splits'\G

当节点分裂后,在父节点中插入一个node_ptr,父节点也需要分裂是;还是递归的调用节点分裂函数,直到不产生分裂为止;如果分裂到根节点,那么根节点产生一个新的根节点,老根节点因为满了,还是会一分为二;最终提升树高。

Btree的删除

首先Btree的删除不等于SQL的delete,SQL的delete只是标记删除,执行的是delete_mark操作;具体的Btree的删除,由purge线程调度执行(或者是rollback)。

在BtreePage中有一个MERGE_THRESHOLD,默认是0.5;当BtreePage由于delete或者update(新记录的大小比历史记录小)使得容量小于0.5;那么就会通过前向和后向指针查看相邻节点是否也可以merge。

可以的话,就会和相邻节点进行合并;将后续节点的数据复制到前面的节点中,那么另一个节点就成空节点了,可以用来放新的数据。

通过如下监控,查看btree的merge操作统计。

SET GLOBAL innodb_monitor_enable = index_page_merge_successful;
select * from INFORMATION_SCHEMA.INNODB_METRICS where name = 'index_page_merge_successful'\G

当merge到最后,发现自己没有左右邻居节点时,那么将子节点的内容,复制到父节点上;减少树高。注意删除的时候需要将Btree进行rebalance。

If this merge-split behavior occurs frequently, it can have an adverse affect on performance. To avoid frequent merge-splits, you can lower the MERGE_THRESHOLD value so that InnoDB attempts page merges at a lower “page-full” percentage.

Buffer Manager

在InnoDB中,有如下一些缓冲区;大类上和PgSQL相似都有一个放数据页的BufferPool,和一个放日志记录的LogBuffer。在CHECKPOINT的调度下,进行BufferPool刷盘;每次事务commit进行LogBuffer刷盘。

image-20190726110915433

除了这两个之外,还有为了减小二级索引的写放大,引入的Change Buffer机制;为了避免数据部分写,引入的DoubleWrite Buffer。

另外,还有存储元数据目录与其他内部结果的Memory Pool,由参数innodb_additional_mem_pool_size控制,默认8MB;这个如果不够就会动态申请,这会在日志中写warning记录。

Change Buffer

image-20190726111320366

Change Buffer是二级索引变更的缓存,其不仅仅是一个内存结构,内存中的changebuffer需要确保外存的changebuffer能够全部load进内存;因此,ChangeBuffer同样可通过Recovery恢复。

ChangeBuffer也是一个Btree,Key为(spaceid,pageno,counter)三元组,其中counter在每个page有一次变更后加一。Value为该page上的操作,之前其中只有insert操作,后来也支持了delete/update/purge操作,成为ChangeBuffer;但是命名上没有改变,在代码中还是叫InsertBuffer;

注意只是当非唯一的二级索引的块不在缓存中时,才会缓存相关操作。

索引具有唯一约束时,修改索引需要读取数据确认是否存储重复值,因此,此时必须要读取indexpage,所以不用Change Buffer。

当ChangeBuffer满了或者之前缺失的二级索引页被读取到内存中时,会按照changeBuffer中缓存的操作,merge该页的修改:

  1. 随机选择changeBuffer中一个随机页。
  2. 随机打开该页中的一个cursor。
  3. 按照该cursor,读取之后的至多8个页。
  4. 异步发起IO请求;当读取完成后,调用回调函数,执行相应的change。

Buffer Pool

存放表和索引的数据,由innodb_buffer_pool_size设置,默认是128MB;推荐配置为系统物理内存的80%。

刷脏

任何BufferPool都逃不过一个刷脏的问题;在数据库中,为了保证恢复及时;那么每间隔一段时间,会在日志中写入一个检查点。

CHECKPOINT在DBMS都是同一个概念,其为redo日志中的一条记录,内容为CHECKPOINTLSN,其表示在CHECKPOINT_LSN之前的脏页已经从缓冲区写入磁盘了。而完成CHECKPOINT的方式主要有两种类型:

  • sharp checkpoint: 只将commited的事务修改的页进行刷盘,并且记下最新Commited的事务的LSN。这样恢复的时候,redo日志从CHECKPOINT发生的LSN开始恢复即可。由于所有刷盘的数据都是在同一个点(CHECKPOINT LSN)之后,所以称之为sharp。

  • fuzzy checkpoint:如果脏页滞留到一定时间,就可能会刷盘。

在InnoDB中,除了shutdown的时候,正常时候都是fuzzy CHECKPOINT。刷盘前,能够多次修改,这样省去了很多IO。BufferPool中的页由三个list维护,分别是:

  • free_list:可用的页
  • LRU_list:最近使用的页
  • flush_list:按照LSN的顺序组织的脏页(即,最近修改的页)。

由于bufferpool是有限的,不能只是等满了才进行页换出;所以,InnoDB会持续地进行Page Clean,InnoDB中的换出有两种情况。

  • BufferPool满了之后,基于LRU_list,进行页面置换。

  • 基于flush_list,其中按照修改的先后顺序排列,选择最早更改的脏页(LSN)进行换出。

    为了避免将热数据换出,所以选择了最早更改的脏页。

    另外,由于事务日志(即,redo/wal日志)是固定大小的,redo日志是循环使用的。当最早的日志记录相应的页一直没有刷盘,如果此时发生了日志重用,那么更改就没有持久化(违反D);因此,当这种情况发生时,InnoDB需要夯住,进行刷盘(同样这也是为什么选择最早更改的脏页的一个原因)。

为了避免checkpoint的频繁刷脏,pagecleaner和用户线程会按照一些阈值点,进行提前刷脏。和这相关是一个Page Cleaner线程组,其分为两个角色协调者和工作者,如下:

image-20190726113541342

coordinator持续设置标记位触发worker进行刷盘,自己触发后也会参与刷盘;各自认领不同bufferpool对应的list进行清理。worker结束后,设置标记位通知coordinator该轮清理完成。

综上,当InnoDB执行fuzzy CHECKPOINT的时候,其会找到flush_list中的最早更改的脏页的LSN,将其作为CHECKPOINT的start,写入事务日志头中(参见源码:log_checkpoint_marginlog_checkpoint)。

而当InnoDB停机时,做法就是sharp checkpoint。首先,停止数据更新;然后,将脏页刷盘;最后,将当前的LSN写入事务日志头中。

另外,在Percona版本的XtraDB中,提供了一种基于代价的 adaptive CHECKPOINT;以及InnoDB后来也有了adaptive flushing

AHI

image-20200106154013692

在Buffer Pool中,缓存了IndexPage。在二级索引中,存储的是一级索引的键;因此每次查询需要两个索引查询。为了减少寻路开销,打开参数innodb_adaptive_hash_index后,可以启动AHI功能。

每次查询后,将tuple与page的映射关系存储在一个HashTable中,那么后续查询可以通过内存的HashTable进行定位,如上图,提高检索性能。现在在5.7中,避免锁的竞争,将AHI分成几块。

Doublewrite Buffer(disk)

通过innodb_doublewrite参数打开dwbuffer,默认打开。InnoDB的页大小是16k,但是OS每次是按照4K写入,因此可能存在16K只写了一部分的情况下,系统crash了。为了避免这一问题,设计了doublewrite_buffer。

dwbuffer可以看做是存在于系统表空间中的一个短期的日志文件,它包含了100个页。当InnoDB刷页时,会先将页顺序写入到dwbuffer中,并将dwbuffer刷盘;然后,将页刷到真正的数据文件中。

当recovery时,InnoDB检查dwbuffer中页和其本来位置的页的内容;如果通过检查页的checksum,发现数据表中的页是不一致的,那么从dwbuffer中恢复。

性能上,尽管每次写页的时候需要写两次;但是由于将dwbuffer的写是顺序的,并且不会每个page调用一次fsync,而是一起fsync;整体性能比原来损失经验值是5%。

如果你不在乎数据丢失,或者OS级别保证了不会有部分写,那么可以innodb_doublewrite=0 关闭double write。

Log Buffer

InnoDB的表发生变更的时候,首先将变更存储在Log Buffer中,然后写入到Redo日志中。其由innodb_log_buffer_size设置,默认16MB;当大事务中的insert/update/delete比较多时,将提高该参数可以减少磁盘IO;通过观察系统统计innodb_log_waits,可以得知是否需要调大LogBuffer。

SELECT name, subsystem, status FROM INFORMATION_SCHEMA.INNODB_METRICS;

关于LogBuffer的更详细介绍,参看下节InnoDB日志管理。

Lock Manager

在MySQL中,事务的并发控制是通过InnoDB实现的;而在MySQL层中,会有一个MDL维护元数据信息,主要用在DDL场景中。但是DML中的锁,都在InnoDB实现;可以分为两部分:事务锁和线程锁。

image-20190726115031799

显式事务锁

一般从两个维度描述一个锁:粒度和力度。在InnoDB中,从粒度上分为表锁和行锁;在不同的粒度上,又根据力度的不同分为不同类型。但都是在一个结构中表示lock_t,根据is_record_lock(提取type_mode的标记位)来判断锁的粒度:表还是行。

type_mode是一个无符号的32位整型,从低位排列,第1字节为lock_mode,有如下5中类型;

/* Basic lock modes */
enum lock_mode {
	LOCK_IS = 0,	/* intention shared */
	LOCK_IX,	/* intention exclusive */
	LOCK_S,		/* shared */
	LOCK_X,		/* exclusive */
	LOCK_AUTO_INC,	/* locks the auto-inc counter of a table in an exclusive mode */
	LOCK_NONE,	/* this is used elsewhere to note consistent read */
	LOCK_NUM = LOCK_NONE, /* number of lock modes */
	LOCK_NONE_UNSET = 255
};

第2字节为lock_type;

/** Lock types */
/* @{ */
#define LOCK_TABLE	16	/*!< table lock */
#define	LOCK_REC	32	/*!< record lock */
#define LOCK_TYPE_MASK	0xF0UL

再高的字节为行锁的类型标记:

#define LOCK_ORDINARY	0	/*!< this flag denotes an ordinary
				next-key lock in contrast to LOCK_GAP
				or LOCK_REC_NOT_GAP */
#define LOCK_GAP	512	
#define LOCK_REC_NOT_GAP 1024	
#define LOCK_INSERT_INTENTION 2048 
#define LOCK_PREDICATE	8192	/*!< Predicate lock */
#define LOCK_PRDT_PAGE	16384	/*!< Page lock */

表锁

在MySQL中,表锁有排他X和共享S两种力度。

当我们要对某个page中的一行记录进行锁定时,需要对上层的table加意向锁——IS/IX,意为该事务中有意向对表中的某些行加X、S锁。意向锁是InnoDB存储引擎自己维护的,用户无法手动添加意向锁。

注意意向锁是表级别的锁,和表锁X/S有相应的兼容性判断如下:

-ISIXSX
IS兼容(compatible)兼容兼容不兼容(conflict)
IX兼容兼容不兼容不兼容
S兼容不兼容兼容不兼容
X不兼容不兼容不兼容不兼容

另外,还有一种特殊的表锁:Auto-Inc Lock,当有AUTO_INCREMENT列时,插入数据时会有这个锁,由参数innodb_autoinc_lock_mode控制自增长的控制算法。

image-20190726121911562

默认地,innodb_autoinc_lock_mode=1,此时对于任何insert-like的语句都需要获取AI锁。

当innodb_autoinc_lock_mode=2,这是对于已知行数的simple insert,那么可以预留一段空间,在语句执行期间不需要AI lock,而未知行数的插入需要去AI表锁。

innodb_autoinc_lock_mode=0是为了和前向版本行为兼容的参数。

行锁

在InnoDB事务中,DDL不能放在事务中。因此,事务锁主要就是DML引起的行锁,默认的存储引擎InnoDB实现的就是行锁,有X/S两种模式,以及如下四种类型:

  • Record Lock:基于主键锁定某个记录

  • Gap Lock:要求隔离级别是RR,并且innodb_locks_unsafe_for_binlog=0;这时,如果查询走非唯一索引或者查询是范围读,那么会加GapLock。

    innodb_locks_unsafe_for_binlog

    该参数的作用和将隔离级别设置为 READ COMMITTED相同,是一个将要废弃的参数。

  • Next-Key Lock:前提是启用了GapLock,其是Record Lock和该Record之前区间的Gap Lock的结合;否则,只是recordLock。

    当给一个record加x/s锁时,其实是给该record加recordlock,加上该record之前的一个gap的gaplock;即给一个左开右闭的区间加了锁。避免幻读。

    当查询的索引具有唯一性时,Next-Key Lock降级为Record Lock。

  • Insert Intention Lock:Insert语句的特殊的GapLock;gap锁存在的唯一目的是防止有其他事务进行插入,从而造成幻读。假如利用gap锁来代替插入意向锁,那么两个事务则不能同时对一个gap进行插入。因此为了更高的并发性所以使用插入意向gap锁;插入意向锁的使得insert同一个间隙的不同键值的查询之间不阻塞,提高并发;但是还是会阻塞update、delete操作。

    当多个事务在同一区间(gap)插入位置不同的多条数据时,事务之间不需要互相等待

监控视图

select * from information_schema.innodb_trx\G; -- 查看当前的事务信息
select * from information_schema.innodb_locks\G; --查看当前的锁信息
select * from information_schema.innodb_lock_waits\G; --- 查看当前的锁等待信息
--可以联表查,查找自己想要的结果。
select * from sys.innodb_lock_waits\G; -- 查看当前的锁等待信息
show engine innodb status\G;
---还可以通过当前执行了执行了什么语句
select * from  performance_schema.events_statements_current\G; 
show full processlist;

MVCC:ReadView

InnoDB中一方面通过锁来进行并发控制(一致性锁定读,select for update/select for shared/update where / delete where);另外,在默认情况下。事务第一次读的时候会通过undo空间提供的多版本,构建一个readview,提供一致性非锁定读(这就是RR级别下,可重复读的实现方式,比如,mysqldump --single-transaction时,就是基于RR级别的读快照进行导出),这样能够提高读写之间的并发,读不阻塞写。

具体地,是通过ReadView机制实现的,如下图:

image-20190726122527556

ReadView是在某一时刻(语句开始,或者事务开始)获取可以看做是三个信息的组合:

  • up_limit:获取时刻已经提交事务的TID最大值,小于等于该ID的数据是可见的。
  • low_limit:获取时刻当前活跃事务的TID最大值,大于等该ID的数据是不可见的。
  • ids:正在活跃的事务ID集合,该集合中ID对应的数据不可见。

对于每个RECORD,其中有一个回滚段指针;通过该指针可以构建该record的历史版本链。那么定位到某个元组,则通过该链,遍历直到找到第一个可见的数据版本,就是最新可见的数据。

隐式线程锁

区别于事务锁是和Transaction相关的并发控制,对象是record或者table;线程锁的对象是buf_page中的page,即buf_pool->page_hash;page_hash是hash_table_t类型的hash表;其中的sync_obj就是该hash表中的元素的锁,放在一个union类型中,有两种类型:mutex和rw_lock。

mutex,实际上是基于Futex机制实现的FutexMutex(Linux Fast userspace mutex);用在内存共享结构的串行访问上,如下例子:

  • Dictionary mutex(Dictionary header)
  • Transaction undo mutex,Transaction system header的并发访问,在修改indexpage前,在Transaction system的header中写入一个undo log entry。
  • Rollback segment mutex,Rollback segment header的并发访问,当需要在回滚段中添加一个新的undopage时,需要申请这个mutex。
  • lock_sys_wait_mutex:lock timeout data
  • lock_sys_mutex:lock_sys_t
  • trx_sys_mutex:trx_sys_t
  • Thread mutex:后台线程调度的mutex
  • query_thr_mutex:保护查询线程的更改
  • trx_mutex:trx_t
  • Search system mutex
  • Buffer pool mutex
  • Log mutex
  • Memory pool mutex
  • ……

rw_lock(sync0rw.h),读写操作的并发访问,其中有两种锁粒度:index和block。有三种力度:S,X,SX(5.7新加的)。主要用在如下些场景中:

  • Secondary index tree latch ,Secondary index non-leaf 和 leaf的读写
  • Clustered index tree latch,Clustered index non-leaf 和 leaf的读写
  • Purge system latch,Undo log pages的读写,
  • Filespace management latch,file page的读写
  • 等等

这里主要介绍rwlock的机制。

rw_lock

在MySQL中主要就是针对Btree的并发访问,这里简单讨论一下rw_lock在Btree读写中的操作:

  • 对于读操作,在Index上加s_latch,在定位的block上加s_latch。
  • 如果是写操作,在5.7之后,在index上加SX即可,在需要修改的page上加X。

rw_lock基于如下结构实现的自旋锁。多个readthread可以持有一个s模式的rw_lock。但是,x模式的rw_lock只能被一个writethread持有;为了避免writethread被多个readthread饿死,writethread可以通过排队的方式阻塞新的readthread,每排队一个writethread将lockword减X_LOCK_DECR(新的SX锁等待时,减X_LOCK_HALF_DECR)

wl6363中,标明了加了SX锁后lock_word不同取值的意思;其中lock_word=0表示加了xlock;lock_word= 0x20000000没有加锁;

在5.7中,rw_lock共有三种类型。

enum rw_lock_type_t {
	RW_S_LATCH = 1,
	RW_X_LATCH = 2,
	RW_SX_LATCH = 4,
	RW_NO_LATCH = 8
};
/*
 LOCK COMPATIBILITY MATRIX
    S SX  X
 S  +  +  -
 SX +  -  -
 X  -  -  -
 */

思考?这个新加的SX锁,从功能上可以由一个S锁加一个X锁代替,S锁住index,X锁住修改的Page;这样别的线程更新index时,遍历的时候检查page上有没有加X锁,这样索引变更的时候也能进行读取,那么,为什么要加呢?

在对Btree操作时,针对如下Btree的不同操作,对Btree的Index(内存dict_cache中的dict_index_t结构)或者Page(buf_pool->page)加不同模式的rw_lock。

/** Latching modes for btr_cur_search_to_nth_level(). */
enum btr_latch_mode {
	/** Search a record on a leaf page and S-latch it. */
	BTR_SEARCH_LEAF = RW_S_LATCH,
	/** (Prepare to) modify a record on a leaf page and X-latch it. */
	BTR_MODIFY_LEAF	= RW_X_LATCH,
	/** Obtain no latches. */
	BTR_NO_LATCHES = RW_NO_LATCH,
	/** Start modifying the entire B-tree. */
	BTR_MODIFY_TREE = 33,
	/** Continue modifying the entire B-tree. */
	BTR_CONT_MODIFY_TREE = 34,
	/** Search the previous record. */
	BTR_SEARCH_PREV = 35,
	/** Modify the previous record. */
	BTR_MODIFY_PREV = 36,
	/** Start searching the entire B-tree. */
	BTR_SEARCH_TREE = 37,
	/** Continue searching the entire B-tree. */
	BTR_CONT_SEARCH_TREE = 38
};

该变量作为btr_cur_search_to_nth_level的参数latch_mode(无符号32位整型)的低字节;而高字节放一些标记位,对Btree进行检索。那么,btr_cur_search_to_nth_level基本就是作为任何Btree的操作定位cursor的入口,可以在检索的同事加锁,然后返回一个cursor;返回之后的cursor,也可以在升级或降级latch的类型。

Log Manager

数据库日志是保证事务的ACID的重要机制,按照数据恢复的一般算法——ARIES,数据库的日志一般有两种:REDO和UNDO;另外,MySQL还有一个BinLog,但其不属于InnoDB。

ARIES的三个原则

  • 日志先写:Write ahead logging
  • 重做历史:Rpeating history during Redo
  • 记录变更:Logging change during Undo

而按照数据库日志存储内容,一般分为三种日志类型:

  • 纯物理日志:记录数据页的物理字节位置和内容。
  • 纯逻辑日志:记录更改的语句。
  • 物理的逻辑日志(Physiological Log):记录物理页中更改的逻辑,这里的逻辑不是SQL逻辑,而是物理页中的变更操作。

现代DBMS中,一般采用的是Physiological方式。其日志体积更小,恢复更快,并且解决了逻辑日志的非幂等性。

逻辑日志的非幂等性

比如,逻辑日志中有一个insert a in A,其需要更新数据和索引页。但是crash的时候,可能只更新了data,没有更新index。那么,重新执行insert语句,就不能保证正确性。

Mini-Transaction(MTR)

MTR是保证InnoDB对若干个page变更的原子性的机制,一个mtr中包含了两类信息:

  • 对若干page的修改的logrecord

  • 相应index/tablespace/page上的lock,或者buffer-fixes(对bufferpage的引用),见如下枚举类型。

    /** Types for the mlock objects to store in the mtr memo; NOTE that the
    first 3 values must be RW_S_LATCH, RW_X_LATCH, RW_NO_LATCH */
    enum mtr_memo_type_t {
    #ifndef UNIV_INNOCHECKSUM
    	MTR_MEMO_PAGE_S_FIX = RW_S_LATCH,
      
    	MTR_MEMO_PAGE_X_FIX = RW_X_LATCH,
      
    	MTR_MEMO_PAGE_SX_FIX = RW_SX_LATCH,
      
    	MTR_MEMO_BUF_FIX = RW_NO_LATCH,
    #endif /* !UNIV_CHECKSUM */
      
    #ifdef UNIV_DEBUG
    	MTR_MEMO_MODIFY = 32,
    #endif /* UNIV_DEBUG */
      
    	MTR_MEMO_S_LOCK = 64,
      
    	MTR_MEMO_X_LOCK = 128,
      
    	MTR_MEMO_SX_LOCK = 256
    };
    

由于MTR是一个原子操作,只有在mtr.commit()时,会将用户线程的mtr复制到logbuffer中(mtr.commit()时,还会释放持有的锁)。因此不存在完成一半的mtr,也就不存在mtr的rollback。

为了避免mtr获取indexpage的锁时,发生死锁;

  • 一个mtr中,只能变更一个index;
  • 只能前向遍历:获取下一个page的锁时,必须释放前一个page的锁。

🤔 死锁的发生条件,如何避免?

Redo LOG

在InnoDB中,其redo日志就是一种Physiological的日志。其中记录了数据页上的所有变更操作。每个记录的形式如下:

innodb-redo-rec

redo日志是按照磁盘扇区大小(512byte)的块,存储日志记录,而不是是page大小,redolog位于$innodb_log_group_home_dir/ib_logfile中,可能有多个文件。

mysql> show global variables like '%innodb_log_file%';
+---------------------------+----------+
| Variable_name             | Value    |
+---------------------------+----------+
| innodb_log_file_size      | 50331648 |
| innodb_log_files_in_group | 2        |
+---------------------------+----------+
2 rows in set (0.01 sec)

和PostgreSQL的max_wal_size类似,ib_logfile的大小有上限(innodb_log_file_size * innodb_log_files_in_group < 512GB);如果设置的过小,为了确保重用redo日志时,被重用的redo日志对应的页已经刷盘,那么可能会频繁的刷脏,这时可以调大redo的整体大小;业务update的吞吐量CHECKPOINT回收redo文件都和这个相关。

因为,我们recovery的时候需要重做这些记录,但是我们并不知道crash的时候相应记录的修改有没有落盘,因此可能会多次执行,所以其中记录保证幂等性(idempotent)。其实当recovery的时候,和PostgreSQL类似会比较日志的lsn与页面的lsn的大小,只会重做lsn大的日志。

双一保证

默认将innodb_flush_log_at_trx_commit设置为1,保证日志Write Ahead;除非系统层面保证了数据不会丢失,如battery backed raid card

另外还有上层MySQL的sync_binlog;也需要设置为1,保证binlog落盘。

为了提高整体的吞吐量,InnoDB采用组提交的方式,从而减少刷盘的次数(LogBuffer)。

这里理解就是服务的吞吐量而不是响应时间,因为如果组没有满,就不刷盘的话,是不是就影响了前面事务的响应时间。在PostgreSQL中通过commit_siblings配置一个组提交启用的下限,避免系统负载比较低的时候的刷盘等待。

LogBuffer的写入/写出

image-20190729194135905

log_t* log_sys是redo日志系统的关键全局变量。通过log_sys中的三个锁,来确保log_sys的并发访问正确性:

  • mutex:对log_sys这个共享结构的变更的互斥
  • write_mutex:日志有序刷盘的保证。
  • log_flush_order_mutex:数据有序写入到flush_list中的保证。

mtr_commit时,需要写该mtr对应的日志和数据。首先获取mutex,用户线程将redo record,按照buf_free写入到LogBuffer中,并更新buf_free值;然后获取log_flush_order_mutex,释放mutex,将修改的数据页,按照LSN写入到flush_list中,然后释放log_flush_order_mutex。

当满足LogBuffer刷盘条件,触发刷盘时,首先获取mutex,进行双Buffer切换;然后获取write_mutex,释放mutex,此时一个Buffer继续接受用户线程的写入,另一个在write_mutex的保护下将日志刷盘。

另外,数据的刷盘,即flush_list和LRU_list是通过计算一些阈值;根据阈值的紧急程度,执行对应的刷盘操作,preflush还是checkpoint,异步还是同步等。

综上,log_sys主要完成了三个功能:

  • mtr_commit的mtr_buf_t到logbuffer的日志数据拷贝,这里涉及ulint为单位的两个偏移量:redo record可拷贝写入的buffer的位置(buf_free);redo缓冲区将要向磁盘刷盘的位置(buf_next_to_write)。
  • 然后是LogBuffer的刷盘,这里涉及lsn为单位的三个偏移量:write_lsn、current_flush_lsn、flushed_lsn。
  • 然后是flush_list的刷盘,这里涉及几个阈值的计算。

(关于这一节的代码细节,有另一个blog)

Undo LOG

InnoDB中可以有专门的UNDO表空间(5.6之后可以启用独立undo表空间,之前是放在系统表空间ibdata0中)。在ibdata0中,存储一个trx_sys结构,其中维护了事务相关的信息,就包括了所有的128个回滚段,如下图。

image-20190718154613462

innodb_rollback_segments定了了rollback segment的个数([1,128]),默认128个。每个rseg中,有1024个slot(用了存放undo log page);

128个rseg只有后96个是给用户表使用的,并且每个undo log只能同时给一个事务使用,因此整体的事务并发上限为96*1024。

注意是回滚段,段存在于表空间中;那么有如下位置映射:

  • rollback seg0位于ibdata1中
  • [1,32],放在ibtmp1临时表空间中。

  • [33,+),如果没有开启独立表空间,那么用户回滚段都在ibdata1这个系统表空间中。

image-20190718161534628

如上图,一个事务如果只对应一个UNDOpage(实际上可能不止),那么最多支持96*1024个事务并发。

在InnoDB中,Undo日志记录了行的旧值,当需要找到旧版本的数据时,需要按照undo链进行寻找。有数据变更的事务都需要一个undo record,包含三项信息:

  • Primary_Key_Value:包括页号和物理位置。
  • Old_trx_id:更新该行的事务号
  • Old_values_on_that_row:更新前的数据值。

在MySQL中,事务默认是只读事务;如果后期发现有临时表的写入,就分配临时表的rseg;若判断为读写事务,则开始分配事务ID和普通rseg

对于Insert插入的新数据,没有任何老事务可能会读取该新行,所以一般在事务结束后,就将undo log删除,这部分是insert_undo。而对于Update和delete的旧数据,需要进行保留,在InnoDB中将其归为一类:update_undo

回滚段是资源是有限的,系统有purge线程定期回收回滚段;每个回滚段上有一个引用计数(trx_ref_count),如果计数为0,表示没有事务在使用,那么purge线程就对其进行回收;回收时,会将该rseg标记为skip_allocation,表示该段暂缓分配。

Crash Recovery

一般恢复分为三步,首先扫描数据;然后基于Redo进行重做;最后基于Undo进行回滚。

在InnoDB中,分为4步:

  1. dwbuffer:首先恢复 Doublewrite Buffer中的数据

  2. Scan:从磁盘中读取redo日志记录,插入按照LSN排序的红黑树中。

  3. Redo:recv_recovery_from_checkpoint_start,将重做redo记录,并脏页插入到flush_list中;另外,undo记录也是受redo保护的(临时表除外临时表不记redo),也可以从redo中恢复。

  4. Undo:dict_boot初始化数据字典子系统;trx_sys_init_at_db_start初始化事务子系统,undo段的初始化在此完成;

    image-20190529105306507

    对于Active的事务,进行回滚;

    对于Prepare的事务,如果对应的binlog已经提交,那么提交,否则回滚。

参考文章

Undo漫游

binlog文档

worklog-mysql-5223

binlog-group-commit

InnoDB Page Merging and Page Splitting