InnoDB索引部分代码阅读
InnoDB索引部分代码阅读
本次阅读的目的是要看下索引分裂的过程和MTR的关系。入口是innodb创建系统表空间的代码,位于innobase_start_or_create_for_mysql()。
1 | if (create_new_db) { |
由上面的代码可以看到,整个系统表空间的header初始化过程都使用的一个MTR来贯穿,以保证原子性。
InnoDB中的行数据类型
数据类型描述:dtype_t
InnoDB中的数据类型是指InnoDB所支持的SQL数据类型在内部代码的实现。这部分的内容主要在data0type.*
文件中。在InnoDB中所有的数据类型都以dtype_t
表示,该结构体的声明如下所示:
1 | struct dtype_t{ |
由上面的定义可以看到,在InnoDB的内部实现中,数据类型分为主类型(mtype)和精确类型(prtype)。主类型说明了一个数据类型的大类,例如该类型是数据类型还是字符串类型等,而精确类型则说明了例如是否为空,字符串的字符集等信息。在data0type.h
中有对prtype
各个字节含义的说明:
1 | MySQL用户创建的表具有以下约定: |
列类型描述:dict_col_t
dict_col_t
是对表中一列的元数据类型进行描述,包括:列的数据类型(dtype_t来定义),该列在表中的位置(第多少列,index),是否是参与索引排序的列,最大的索引前缀长度。dict_col_t
的定义如下,可以清楚的看到每个成员的功能:
1 | /** Data structure for a column in a table */ |
索引字段类型描述:dict_field_t
dict_field_t
用来描述索引的一个字段信息,这里要说明的是,表的信息是由列(dict_col_t)信息组成的,而索引信息是通过字段(dict_field_t)信息组成的。dict_field_t
的定义如下,可以看到每个字段都关联到一个数据列:
1 | /** Data structure for a field in an index */ |
B+树的查找
数据结构
page_cur_t
索引页游标结构
1 | /** Index page cursor */ |
####关键函数
row_ins_clust_index_entry
该函数用于聚簇索引的插入操作,会首先进行乐观插入,接着进行悲观插入。函数在[row 3280 @ row0ins.cc]。
函数的大致流程如下:
1 | row_ins_clust_index_entry(){ |
btr_attach_half_pages()
该函数是在索引页分裂时,向中间节点页上加一个node ptr记录,用来记录新加入的数据页。该函数导致递归分裂。递归过程如下所示:
1 | row_ins_index_entry_low |
btr_page_split_and_insert()
该函数是进行B+树分裂的主体函数。
btr_cur_search_to_nth_level()
该函数是用来检索B+树,并在指定位置放置一个cursor。
1 | ## 如果是要进行B树结构的调整,则是以下流程 |
MySQL5.0.15中对B+树的检索方式
WL#6326:InnoDB:fix index->lock contention
在InnoDB中,对于中间节点页latch的获取是受到index->lock保护的。
在WL#6326之前,index->lock以S和X两种模式来保护中间节点的latch,在这种情况下,检索B+树不会获取任何中间节点的lock。
在WL#6326之后,中间节点的受到block->lock的保护(s-latch或x-latch)。为了防止死锁,在获取中间节点的latch之前要先获得index->lock的latch。
(0)定义:B树的层数
(0.1)B树的叶子节点处于第0层;
(0.2)第L层节点的父亲节点在L+1层;
(0.3)index->lock处于(树高+1)层,也就是说,index->lock是root页的父亲。
在WL#6326中,index->lock有三种模式:
(1)当持有index->lock的S-latch的情况:
(1.1)页面latch必须以层数降序的方式来获取;(level n->level n-1->level … ->level 1)
(1.2)当要获取给定层上的一个节点页时,必须持有其父亲页的latch;
(1.3)如果已经持有一个中间节点页的latch,那么我们只能获取右兄弟页的latch;(防止死锁)
(1.4)中间节点页的latch释放必须遵循先释放孩子节点再释放父亲节点的顺序(这里主要是防止和index->lock SX-latch的情况发生死锁):
(1.4.1)要释放一个L层中间节点页的latch,必须保证所有小于L层的页锁已经释放,或者
(1.4.2)所有中间节点页的latch必须一次释放,释放的期间不能请求获取其他任何latch。
(1.5)从1.1和1.2可以推出,第一个获取latch一定是root页的。
(2)当持有index->lock的SX-latch的情况:
规则1.2和1.3在此模式下可以放宽,并整合到规则2.2中,规则1.4可以移除。这意味着,我们可以不获取某些层上数据页的latch,可以以一个更为宽松的规则获取latch。
(2.1)[和1.1相同]:页面的latch必须以层数降序的方式来获取;(level n->level n-1->level … ->level 1)
(2.2)当要获取L层上中间节点页的latch时,必须先持有其左兄弟节点的latch(L层),或着持有其祖先页的latch(> L层);
(2.3)[由2.1和2.2] 第一个获取的latch可以是任何中间节点页;
(3)当持有index->lock的X-latch的情况:
在获取索引的X-latch时,我们可以以任意方式获取中间节点的页的latch;
NOTE:WL#6326不会影响对叶子节点的加锁顺序:
读需要获取index->lock的S-latch,一旦到达叶子层,我们可以释放index->lock和中间节点页的latch。一旦到达叶子节点,我们可以放心地获取右兄弟节点的latch,释放老页的latch,进行从左往右的索引扫描。
对叶子节点修改(BTR_MODIFY_LEAF)要获取index->lock的S-latch;
如果发生页面分裂或合并(BTR_MODIFY_TREE)或进行页面分配,则要获取index->lock的X-latch。
几个例子
[1]
读写锁(read-write latch)
文档目标:InnoDB内建rw_lock的实现机制|使用方式。
读写锁是innodb用来保证资源的一致性的机制。看本部分代码的原因是在MA项目中日志应用和数据页临时拷贝都会应用到读写锁机制,在从机应用索引分裂的日志时,也会使用到。所以,搞清楚这部分代码可以在MA中使用innodb内建的读写锁。
读写锁结构:rw_lock_t
innodb中读写锁的实现是rw_lock_t
,该结构体中包含了一个读写锁的状态变量、条件变量等内容,下面看一下这个结构体:
1 |
|
可以看到,innodb中的RW锁就是基于经典的读写锁实现。在此基础上,innodb的RW锁还加入了等待队列,和可重入特性。
等待队列
首先看一下innodb中等待队列的实现介绍,下面摘自其代码注释:
在innodb中,等待队列是通过wait array数组实现的,其中wait array的每个数组元素都有一个信号量。当一个线程等待一个锁时,可以在wait array中申请一个元素空间(cell),并让自己在该元素上的信号量上等待。
当使用等待队列时,必须要保证持有该锁的线程知道有其他线程在等待这个锁,这样在释放锁时可以通知这些等待的现车结束等待。
为什么要实现一个等待队列?主要是为了使锁效率更高。
在5.0.30版本及以后,由于现代操作系统可以很高效的处理大量的信号量,不再需要在每个等待队列的cell里包含一个信号量。相反,现在在每个锁内部都加入了一个信号量,等待线程可以在此信号量上等待。现在innodb中保留全局等待队列(wait array)的原因是为了诊断和避免无限等待。error_monitor thread 可以通过扫描全局 wait array来避免等待线程错过某些信号量。
从代码上,看是有两个等待队列:一个是在rw_lock_t->event
上的队列,另一个是在sync_wait_array
的等待队列。但是这两个队列是共享一个条件变量的(即系统都是通过rw_lock_t->event
信号量来通知的),具体做法是sync_wait_array
中的每个成员会保存一个指向本成员所绑定锁的rw_lock_t->event
的指针。
之所以还保留sync_wait_array
的原因是error_monitor线程可以通过扫描全局的等待队列,来判断是否有等待线程错过某些信号量。
####InnoDB内建读写锁的使用
下面的所有代码都已经过测试,可以在MySQL中正确运行。
头文件包含(#include)
使用InnoDB内建的读写锁时需要包含下列头文件:
1 |
创建锁
创建并初始化一个锁可以用innodb中预先定义的宏。用于创建锁的宏有:
1 | /** |
例:
如何定义并初始化一个读写锁:
1 | //定义计数器类型 counter |
#####使用锁
读写锁的加锁和解锁都可以使用innodb定义的宏来实现,这些宏有:
1 | /** 加锁的操作 |
例:
使用读写锁控制的方式来读取上面定义count中的值,有两个写(writer)线程,两个读线程(read)。读线程会对count对象加s-lock来读取其中的count成员的值;写线程会相应的加x-lock,并执行count->count++;
1 | //第一个写线程 |
执行这四个线程后,count->count的值会正确的加到1000000,读写锁可以正常使用。
销毁读写锁
以上面的count对象为例,不能通过直接delete count来销毁读写锁,需要调用专门的宏函数来实现。因为读写锁中包含的信号量需要单独销毁,且由于所有的读写锁都会加入到全局的rw_lock_list
中,销毁时需要把读写锁从这个链表中移除。
用户销毁读写锁的宏函数为:
1 | /*销毁一个读写锁。 |
例:
以上面的代码为例,当要销毁count对象时,需要像下面这样:
1 | /** 销毁count对象*/ |
行锁及表锁部分代码阅读
行锁
行锁就是记录锁,与rw-latch不同,行锁是逻辑层的锁,是InnoDB用来实现2PL协议的组件,下面主要看一下其基本数据结构和维护过程。
行锁结构(lock_rec_t)
表示行锁结构的类型是lock_rec_t
(row76@lock0priv.h),其详细结构定义如下:
1 | /** Record lock for a page */ |
总的来说一个行锁是由表空间号(space_id)、页面号(page_no)和一个位图(bitmap)组成,位图是用来标记该行锁对应页面中的哪个记录(record),是用heap_no表示的,heap_no可以认为是一个记录在页面内的物理偏移。
事务锁(lock_t)
事务锁是一个事务执行过程中需要管理的锁,有表锁和行锁两种,这两种锁都是保存在一个lock_t
结构中。一个事务所有的lock_t对象都保存在了trx->lock
中。lock_t
结构如下所示:
1 | /** Lock struct; protected by lock_sys->mutex */ |
用于创建记录锁的RecLock类
RecLock
[row 607@lock0priv.h]类是用来创建记录锁的工具类。同时该类还有死锁检测和锁优先级的功能。下面主要看下它创建记录锁的逻辑。
InnoDB锁实现概述
下面的文字翻译自`row265@lock0priv.h`:
[1] 一个显式的(explicit)记录锁会锁定一个记录和该记录之前的gap。一个隐式的(implicit)记录锁(x-lock)不会影响gap,而只会锁定该记录。
[2] 如果一个事务更新或插入了一个条索引记录,则它拥有一个隐式的x-lock在所修改的记录上。对于二级索引来说,如果一个事务修改了聚簇索引的记录,并且对应二级索引所在数据页的PAGE_MAX_TRX_ID大于当前事务的ID(或者正在进行崩溃恢复),并且没有显式的non-gap锁请求在该二级索引上,那么该事务拥有该二级索引的隐式x-lock。
[3] 二级索引上锁定义的复杂性主要是因为它的实现方式:我们想通过查看一个聚簇索引记录的最新值(而不是历史版本)来判断在一个二级索引上是否有隐式的x-lock。可以向用户解释复杂的定义,以便在回答查询时访问路径中存在不确定性:我们可能会或可能不会访问聚簇索引记录,因此可能会或可能不会碰到x锁在那里。
[3] 值得一提的是,不同的事务可以同时对于某个gap拥有冲突的锁。这时因为GAP锁的语义仅仅是“禁止”:如果事务T1拥有某个gap的x-lock,那么另外的事务则不能在gap内插入或进行查询。但是T1拥有的gap-lock并不能让T1直接在gap内插入记录(意思是即使T1拥有该gap的锁,但是如果要在该gap内插入记录还是需要申请记录锁)。
[4] 一个显式的(explicit)锁可以加在一条用户记录或supremum记录上。加在supremum记录上的锁总被认为是gap锁,虽然锁上的gap位没有set。当我们要更新一条记录,并且记录的空间大小发生变化时,我们会将该记录锁临时地保存在该页上的infimum记录,infimum记录只有在这种情况下会被加锁。
[5] 另外,一个waiting record lock可以也是gap锁。一个waiting record lock请求只有在该lock队列中的其他冲突请求被满足后才能得到满足。
[6] 在4.0.5版本中,我们还加入了另一个显式锁类型:LOCK_REC_NOT_GAP,它只锁定单条记录,不包括记录之前的记录。加入该中锁类型的原因是要模拟Oracle-like的读提交(RC)隔离级别。
[7] RULE 1:如果有一个隐式的x-lock在一个记录上,并且在该记录锁的等待队列中有non-gap锁请求,那么持有该隐式x-lock的事务也相当于持有一个显式的non-gap x-lock。因此,只要锁被释放了,我们可以仅仅根据请求队列中的显式锁请求来授予锁。
[8] RULE 3:不同的事务不能同时对同一条记录加冲突的non-gap锁,但是他们可以拥有冲突的gap-lock。
[9] RULE 4:如果有一个waiting lock request在一个队列中,没有其他锁请求可以被插入到等待队列头部。在记录删除或页面分裂时,数据库本身可以为一个事务创建一个新的gap类型的锁,如果没有RULE4,那么waits-for graph可能会出现环,而数据库本身不会处理这种死锁,因为死锁检测仅在一个事务请求一个锁时才进行(而不是数据库本身为事务创建一个锁对象时)。
[10] 如果下一条记录(next record)上的其他事务没有明确的锁定请求,则允许插入该gap。 无论这些锁定请求是被授予还是等待,是否设置了gap bit都没有关系,除非是由另一个事务设置的等待进行插入的gap类型请求被忽略。 另一方面,另一个事务的隐式x锁定不会阻止插入,当使用Oracle样式的序列号生成器作为主键并且许多事务同时执行插入时,这允许更多的并发性。
[11] 一个事务只有在获得记录的x-lock或者没有其他事务请求该记录的non-gap锁的情况下才能对一个记录进行修改。
[12] 如果一个事务拥有一个记录的non-gap explicit锁或是隐式锁,亦或是其他事务没有对该记录加锁,这时该事务才能通过cursor读取该记录。
[13] 总的来说,一个隐式锁(implicit lock)可以被看做授予在某条记录上的x-lock;一个显式的锁(explicit lock)但是没有设置gap bit则可以被看做对记录和gap共同上锁;而设置gap bit的锁则只能用来对gap 加锁。不同的事务不能同时拥有某条记录上相冲突的锁,但可以同时拥有某个gap上相冲突的锁。授予关于某个记录的锁表明可以修改该记录,而gap类型的锁则表示禁止操作。
[14] NOTE:找出某个事务是否在辅助索引记录上具有隐式x锁定可能很麻烦。 我们可能必须查看相应聚簇索引记录的先前版本,以查明删除标记的辅助索引记录是否被活动事务标记为删除,而不是由已提交的事务标记。
[15] FACT A:如果一个事务插入了一行,则可以在任何时候删除该记录,并且一定不会发生锁等待。
[16] FACT B:如果一个事务用cursor读了一些记录(结果集),则他可以再次读这些记录,并且读的结果集和原来的相同如果该事务没有修改其中的记录。所以,不会发生幻读。如果结果集中,字母序最大的记录被移除了,则会发送锁等待,否则的话则不会。
[17] PROOF:当一个read cursor前进时,它会对每条读到的用户记录加s-lock,并对每个supremum加gap类型的s-lock。该cursor必须等待直到获取了上面的锁。所以,其他事务没办法获取该结果集中任何一条记录的x-lock,更无法修改记录。因为cursor推进时还会对每条记录上gap锁,所以其他事务也无法插入该cursor扫描过的记录。页面的分裂或合并,或者老版本的记录被purge,并不会影响此结果。因为当一条用户记录或supremum记录被移除时,下个记录会继承该记录的锁,并设置为gap类型。另外,如果一个supremum记录被插入,则它会继承它的后继记录(successor)的锁。当cursor再次被定位到结果集的头部时,cursor所能访问的记录要么是上次记录访问的,要么是新插入的supremum记录。cursor可以不用等待访问所有的记录,当cursor到达最大的记录时,他会通知结果集已经遍历完成。如果最大的记录已经被移除,则会发生等待,因为next record仅继承了gap类型的锁。
[18] 如果一个索引记录要被修改或新插入,则我们必须检查在该条记录上是否有锁在该条记录上,和下一条记录上是否有(gap lock)。当一个cursor开始读取记录时,我们会为每个读取的记录加一个s-lock,除了cursor在的初始记录。这是因为根据我们index tree索引传统,cursor会被放置在第一个可能匹配的记录之前的位置。这里有可优化的地方:如果一个记录是由在唯一键上的等值条件索引得到的,那么我们没必要对该记录加gap lock锁,而仅仅加记录锁就可以了。在当前版本下,next-key锁会对记录加x-lock锁,并同时阻止在该记录之前的插入操作。
[19] 每个页面上有两条特殊的记录:infimum和supremum记录。一个read cursor可以对supremum记录加锁,supremum记录不能被修改,但是对supremum加锁可以防止在页尾插入记录。
[20] next-key lock可以阻止幻读的产生,防止幻读可以实现可串行化。
[21] 如果我们想插入一条新的记录,那么需要检查什么?只需要检查同一个页面上的next record即可,因为supremum记录也是可以带有锁信息的。s-lock可以阻止插入,那么如果是一个x-lock呢?比如说一个insert…select语句会对选择的记录加s-lock,这时插入会被阻止。如果我们的事务拥有next record的x-lock但是有一个waiting s-lock请求在next record会发生什么?如果该s-lock是由一个read cursor请求的,且该read cursor是按照递增的方向访问索引的,那么我们不能立即插入。因为如果我们提交了更改,则read cursor可以看到新插入的记录。我们应当移动read cursor到next record之前的位置,这样它可以读取到新插入的记录。当然,这种方式实现起来很麻烦。当我们处于上述情况时,我们仅仅在next record上为我们的事务申请第二个x-lock,这时死锁检测机制会注意到我们的事务和read cursor的事务已经发生死锁。这似乎是一个OK的解决方案。
[22] 我们可以设一个约定:被授予的显式记录锁(explicit lock)阻止记录修改的同时会锁记录前面的gap。一个waiting explicit lock请求会锁定gap。隐式的记录x锁(implicit x-lock)派生于聚簇索引中的事务id,它只锁记录不锁gap。
[23] 我们应该如何存储update locks?如果一个search查询是通过unique key进行的,则我们可以仅仅修改记录的trx id。不然的话,我们要在记录上加一个x-lock。如果更新更改了聚簇索引记录的排序字段,则插入的新记录在锁定表中不需要记录锁定,则trx id就足够了。 二级索引记录也是如此。 搜索删除类似于更新。
[24] PROBLEM:如果有waiting lock request怎么办?如果一个事务正在等待更新一条记录,而这条记录被另外一个未提交的事务修改,那么正在修改这条记录的事务如何发送“结束等待”信号给等待的事务?如果我们约定一个事务在某一时刻只能等待一个锁,那我们如何保护这个锁如果lock wait ends?
[25] PROBLEM:对一个二级索引来说,如果仅仅是修改,而不是做插入,那么还有必要检查二级索引记录的trx id字段吗?一个二级索引被修改其实只是指set和reset该二级索引记录的delete flag。一个二级索引记录包含了能唯一确定聚簇索引的字段(用来做“回表”)。一个二级索引只有在其聚簇索引发生被修改时才能被修改。在我们要修改二级索引之前,需要检查对应聚簇索引上的trx id字段。所以,当要delete mark或unmark一个二级索引时,我们不关心该二级索引的trx id字段,只需要看锁表中是否有对应的(聚簇索引记录)锁即可。在对一个二级索引做SELECT时,trx id至关重要,我们必须要检查对应的聚簇索引。
[26]PROBLEM:当页面发生分裂或合并、亦或是一个记录被删除或被更新时如何更新记录的锁信息?如果记录的长度发生变化,我们会用delete and insert的方式进行更新操作。在这种情况下,我们如何重新获得记录的锁,或在这条记录上等待锁?一个记录锁是使用bitmap来记录对应的记录在页面内的heap no,当我们要移除一条记录时,可能仍会保留lock bits。如果页面重新组织了,我们可以新建一张表,用来记录记录新、老heap no的对应关系,并相应地变换锁中bitmap的信息。接着,我们把更新的记录的结束为止加入到表中。如果更新不需要重组页面,我们可以简单的移除lock的bits,并更新为记录当前的heap no。(如果我们用完了当前lock的bitmap位,我们可能需要重新分配一个lock。)
[27] 一个更为复杂的情况时当要重新插入更新的记录时,需要分裂数据页(而不是页面重组),这时索引树的结构会发生变换。这就引出了下面的问题。
[28] PROBLEM:如果一个supremum记录在页面合并的时候被移除,或者一个记录被purge掉了,那么waiting lock requests如何处理?如果页面向右分裂,我们可以简单的将lock request加到新的supremum记录上。如果一个记录被移除,我们可以将waiting lock request放置到该记录的下一条记录上。在这种情况下,下一条记录上可能已有其他锁请求存在,所以新的死锁检测必须判断是否可以进行加锁。
[29] PROBLEM:如果一个记录被插入了,它应该从它的下一条记录获取什么锁?在页面分裂时,一个supremum总会被插入,而且总可以插入成功,但是插入一条用户记录需要其upper neighbor上没有其他事务持有的任何锁或锁请求。解决方案:我们可以将upper neighbor的锁拷贝过来作为gap类型的锁,这样waiting locks会被转换称为在插入记录上已授予的gap类型。
[30] 对于行来所,InnoDB只会获取S或X类型的锁,对于表来说,InnoDB通常获取IS或IX,只有在需要锁表的情况下才会获取S或X锁。自增锁(AI)在语句级别的binlog中是需要的。
物理的LATCH和逻辑的锁如何配合保证事务的隔离性?
LATCH,即闩,大部分情况下是用来保证数据页访问的正确性。而锁(行锁、表锁)则是用来保证访问一条记录的正确性,是用来防止幻读等异常现象。这两者配合保证了事务执行的正确性。这里的问题是LATCH和锁如何交互的?首先,LATCH对一个事务(trx)来说应该是透明的,因为LATCH仅是为了保证页面的物理访问的正确性。但是一个事务是能感知到锁的存在的,而锁要依赖于页面中记录的位置信息。下面我们通过跟踪一条记录的插入过程,看下LATCH和记录锁如何交互。
btr_cur_optimistic_insert()函数是将一条记录以乐观方式插入到数据页上:
1 | //btr_cur_optimistic_insert的调用逻辑如下: |
btr_cur_optimistic_insert()函数的代码如下所示:
1 | /*************************************************************//** |
未完待续!
参考资料
[1] https://dev.mysql.com/worklog/task/?id=6363WL#6363 : InnoDB: implement SX-lock for rw_lock