https://mp.weixin.qq.com/s/UxawgHGembMKK2lA33ZQDA,写的非常棒!
注:InnoDB中MVCC是基于锁和多版本实现的,还有其他的实现MVCC的并发控制机制,例如基于Timestamp Ordering(TO)的方式,有兴趣的同学可以去详细了解。
「第四部分 锁类型」
锁的概念、名词很多,如果没有对锁构建出一个完整的世界观,那么理解起来就会比较有阻碍,接下来我们把这些锁给分一下类。
按照锁的粒度进行划分可以分为:表锁和行锁。
这里就不讨论页锁了,页锁是 BDB(BerkeleyDB) 存储引擎中才有的概念,我们这里主要讨论 InnoDB 存储引擎。
按照兼容性可以把锁划分为:共享锁和排他锁。
被加上共享锁的资源,能够和其他人进行共享,而如果被加上了排他锁,其他人在拿不到这把锁的情况下是无法进行任何操作的。
按照锁的实现,这里的实现就是 InnoDB 中具体的锁的种类了,分别有:
即使按照这种分类来对锁进行了划分,看到了这么多的锁的名词可能仍然会有点懵。比如我SELECT ... FOR UPDATE 的时候到底加的是什么锁?
我们应该透过现象看本质,本质是什么?本质是锁到底加在了什么对象上,而这个很好回答:
根据作用不同,这些锁又分为以下几种:
意向锁(Intention Locks)
意向锁是一种表级锁,它有以下两种类型:
例如,SELECT ... FOR SHARE在对应记录行上加锁之前会在对应表上加的意向共享锁,而SELECT .. FOR UPDATE则先会对表加意向排他锁。
意向锁只会和表级别的锁之间发生冲突,而不会和行级锁发生冲突。因为意向锁的主要目的是为了表明有事务即将、或者正在锁定某一行。
记录锁(Record Locks)
InnoDB记录锁的锁定对象是对应那行数据所对应的索引,而不是具体的行。也就是说,锁信息并不保存在真正的行数据上。
当一张表没有定义主键时,InnoDB 会创建一个隐藏的RowID,并以此 RowID 来创建聚簇索引。
间隙锁(Gap Locks)
官方文档描述:
Gap Lock的唯一目的就是阻止其他事务插入到间隙中。Gap Lock可以同时存在,不同的事务可以同时获取相同的Gap Lock,并不会互相冲突。Gap Lock也是可以显示的被禁止的,只要将事务的隔离级别降低到 READ COMMITTED。
值得注意的是,持有GAP的锁与其他非LOCK_INSERT_INTENTION的锁都是兼容的,也就是说,GAP锁就是为了防止插入的。
临键锁(Next-Key Locks)
临键锁(Next-Key Locks)实际上是记录锁和间隙锁的组合。换句话说,临键锁会给对应的索引加上记录锁,并且外加锁定一个区间。这样其他事务就无法向这个区间内新增数据。
在RR隔离级别下,InnoDB 使? Next-Key Locks防?幻读。
幻读:在一个事务内,先后执行了两次相同的查询,第一次查询出来 5 条数据,但是第二次再查,查出了 7 条数据,这就是幻读。
插入意向锁(Insert Intention Locks)
插入意向锁是间隙锁的一种,在执行INSERT语句时需要加的锁,也是用来避免幻读异常的锁。插入意向锁相互之间不冲突,因此并发的事务在相同的index gap内插入不同的行不需要相互等待。
但实际上,在InnoDB的实现中,在不存在冲突的时候,该锁并不会真正的被创建并加入到锁管理器中去,我们在介绍 INSERT 语句的加锁流程时会做更具体的分析。
自增锁(AUTO-INC Locks)
自增锁是一种表级锁,如果一张表存在自增字段 AUTO_INCREMENT,那么事务向该表新增主数据时就会持有自增锁,当语句执行完之后就会释放。在该事务释放自增锁之前,其他的事务不能向该表执行INSERT语句。
实现时,在dict_table_t结构体中有一个autoinc_mutex和autoinc成员变量,用来维护自增字段的值。只在分配时加个mutex即可很快就释放。
因此,在statement格式下不不能保证批量量插?操作的binlog复制安全性,因为相同一批INSERT语句分配的自增ID每次可能不相同。
「第五部分 关键结构体」
锁模式
用一个32位的变量表示:
uint32_t lock_t::type_mode;

0-3四个bit定义了锁的模式,即这把锁是一把意向共享锁、意向排它锁、共享锁、排它锁还是一把自增锁。
第4位表示这把锁是否是表锁,1代表是表锁,0代表不是。
第5位表示这把锁是否是记录锁,1代表是,0代表不是。
以此类推。
· LOCK_GAP:只锁间隙
· LOCK_REC_NO_GAP:只锁记录
· LOCK_ORDINARY: 锁记录和记录之前的间隙
· LOCK_INSERT_INTENTION: 插入意向锁,用于insert时检查锁冲突
在代码实现上,不同模式的锁的表示方法如下表所示
|
锁模式
|
表示方法
|
| 记录锁 |
LOCK_X | LOCK_REC_NO_GAP |
| 间隙锁 |
LOCK_X | LOCK_GAP |
| next-key锁 |
LOCK_X | LOCK_ORDINARY |
| 插入意向锁 |
LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION
|
全局锁管理器(lock_sys_t)
struct lock_sys_t { locksys::Latches latches; /** 哈希的key是由记录的space id和page no来生成的,value为lock_t */ hash_table_t *rec_hash; Lock_mutex wait_mutex; srv_slot_t *waiting_threads;}
锁对象(lock_t)
-
行锁表锁共用lock_t;
-
一个lock_t要么是行锁要么是表锁。
struct lock_t { /** 拥有这个锁对象的事务 */ trx_t *trx;
/** 该事务持有的锁链表 */ UT_LIST_NODE_T(lock_t) trx_locks;
/** 记录锁的索引 */ dict_index_t *index;
/** 记录锁的哈希节点,即接入哈希链的节点,一个锁对象可能被多个事务创建 */ lock_t *hash; /** 表锁和记录锁采用union方式管理,节省空间,因为一个锁对象要么是行锁要么是表锁 */ union { /** 表锁 */ lock_table_t tab_lock;
/** 行锁 */ lock_rec_t rec_lock; }; /** 表示锁的类型和模式 */ uint32_t type_mode; };
行锁(lock_rec_t)
struct lock_rec_t { uint32_t m_space; /** 数据所在表空间id */ uint32_t m_page_no; /** 数据所在页号 */ uint32_t n_bits; /** lock bitmap的位数 */};
表锁(lock_table_t)
struct lock_table_t { dict_table_t *table; /** 锁住的表 */ UT_LIST_NODE_T(lock_t) locks; /** 同一个表上的表锁链表 */};
NOTES:
-
表锁和记录锁共用数据结构lock_t;
-
行锁以page为单位进行管理,同一个事务在同一个page上的所有行锁只创建一个lock_t,具体要看某一个记录上是否有锁,要用该记录在page中唯一标识的heap no到bitmap查询该位是否为1;
-
lock_t结构体中不包含bitmap成员变量,但是在申请内存的时候,会在申请sizeof(lock_t)这块内存的基础上,额外多要一块bitmap大小的内存。紧邻lock_t存放,每个bit代表页内一行数据,使用heap_no对应。
行锁ID(RecID)
struct RecID { uint32_t m_space; /** 表空间ID */ uint32_t m_page_no; /** 页面号 */ uint32_t m_heap_no; /** 页上的heap no */ ulint m_fold; /** hashed key value,通过m_fold找到在lock_sys_t::rec_hash对应lock_t链表,该链表上的所有lock_t拥有相同的space id和page no,某一行上的冲突通过bitmap判断 */}
事务锁管理器(trx_lock_t)
struct trx_lock_t { std::atomic blocking_trx; /** 阻塞住该事务的事务 */ std::atomic wait_lock; /** 等待的锁对象 */ UT_LIST_BASE_NODE_T(lock_t) trx_locks; /** 该事务请求的所有锁链表 */ lock_pool_t table_locks; /** 该事务请求的所有表锁,包括自增锁 */ std::atomic n_rec_locks; /** 请求的行锁的个数 */}
事务(trx_t)
struct trx_t { ulint isolation_level; mutable TrxMutex mutex; bool abort; trx_id_t id; /** 事务ID */ trx_id_t no; /** 事务串行化号:事务进入COMMIT_IN_MEMORY状态前最大的事务ID */ ReadView *read_view; /*!< 事务中使用的一致读取视图 */ UT_LIST_NODE_T(trx_t) trx_list; /** 事务链表 */ trx_lock_t lock; /** 事务锁管理器 */ time_t start_time; lsn_t commit_lsn; THD *mysql_thd; /*!< MySQL处理该事务的线程 */}
全局事务管理器(trx_sys_t)
struct trx_sys_t { TrxSysMutex mutex; MVCC *mvcc; /** 管理读取视图 */ std::atomic m_max_trx_id; std::atomic m_min_active_id; trx_ut_list_t mysql_trx_list; /** 事务链表 */ /** std::vector of rollback segments */ Rsegs_Vector m_rsegs;}
这些数据结构之间的关系如下图所示:

「第六部分 加锁模式及加锁流程」
6.1 当前读加锁模式
回顾前文所说,MVCC中当前读操作需要读取到最新的一致的数据,因此需要对数据行加锁,对于加在行上的锁,其本质是将锁加在了这行数据对应的索引上。在一个支持MVCC并发控制的系统中,一条SQL究竟加什么锁还与以下因素密切相关:
-
当前系统的隔离级别(RC,RR,Serializable)
-
索引(是否有索引,是否是主键索引,是否是二级唯一索引)
-
是等值查询还是范围查询
-
是否修改了其他索引数据
-
查询计划(是走索引还是走全表扫描,是否有索引下推)
RR+主键列索引
-
如果有对应的主键记录就在记录上加记录锁(lock_x | lock_rec_not_gap)
-
如果没有对应的主键记录就在后面的记录上加间隙锁(lock_x | lock_gap),防止其他事务插入这条记录。
-
UPDATA和DELETE与SELECT FOR UPDATE的区别在与,在更新包含索引列的情况下,前者需要更新二级索引,需要对涉及索引上加记录锁(lock_x | lock_rec_not_gap)。
-
与等值当前读不同的是,范围读需要在主键索引上加next-key locks,防止其他事务在查询范围内插入新的数据。其他一样。
-
以下是不同范围更新的加锁情况,其中supremum表示索引的上界。
RR+唯一索引
通过二级唯一索引更新记录,首先会在WHERE条件使用到的二级索引上,如果是等值查询加Record类型的X锁,如果是范围查询,需要加Next-key类型的X锁。然后通过二级索引找到primary key,并在primary key上加Record类型的X锁(之所以不是Next-key,是因为查询条件是二级索引)。最后对满足范围条件的最后一条记录的下一条记录加间隙锁(lock_x | lock_gap),如果是supremum加next-key lock(lock_x|lock_ordinary)。
RR+非唯一索引
等值和范围查询都需要在对应索引上加next-key locks, 因为不唯一,即使使用等值查询,其它事务仍然有可能插入满足查询条件的新纪录。
在InnoDB中,通过二级索引更新记录,首先会在WHERE条件使用到的二级索引上加Next-key类型的X锁,以防止查找记录期间的其它插入/删除记录,然后通过二级索引找到primary key并在primary key上加Record类型的X锁(之所以不是Next-key,是因为查询条件是二级索引,若WHERE条件使用到的是primary key上的范围查询,就会上Next-key类型的X锁),之后更新记录并检查更新字段是否是其它索引中的某列,如果存在这样的索引,通过update的旧值到二级索引中删除相应的entry,此时X锁类型为Record。最后对最后一条满足条的记录的下一条记录上加间隙锁,防止有数据插入。
我们以一个二级非唯一索引上的更新操作为例:
mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL AUTO_INCREMENT, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_a_b` (`a`,`b`), KEY `idx_b` (`b`)) ENGINE=InnoDB;
mysql> update set c = c + 1 where b = 62;
代码执行流程:
该SQL以二级非唯一索引作为查询条件,因此,首先需要找到的二级索引上的相应列加next_key lock(lock_x | lock_ordinary),
接着对找到的主键索引记录加记录锁(lock_x | lock_rec_not_gap),
更新结束后,对最后一条记录的下一条记录(在这里是b = 63的那条数据)加间隙锁(lock_x | lock_gap),防止有数据插入。
RR+无索引
在Repeatable Read隔离级别下,如果进行全表扫描的当前读(可能是没有索引,也可能是有索引,但是查询计划并没有走索引),那么会对主键索引上的所有记录加上next key locks,对supremum加上next key locks,对满足条件的记录的索引加上记录锁(lock_x | lock_rec_not_gap),杜绝所有的并发 更新/删除/插入 操作。
当然,也可以通过触发semi-consistent read,来缓解加锁开销与并发影响,但是semi-consistent read本身也会带来其他问题,不建议使用。semi-consistent read在下面的章节中介绍。
RC
-
RC下所有语句都不会加next_key locks和gap locks,只会加record locks。
-
如果查询范围是主键索引,对相应主键记录加LOCK_X|LOCK_REC_NOT_GAP,
-
如果是更新操作,并且更新包括其他索引列,还需要在涉及索引上加LOCK_X|LOCK_REC_NOT_GAP锁。
-
如果查询条件是二级非唯一索引,且是更新操作,并且更新包括其他索引列,那么会依次对二级索引和对应主键行,索引数据加LOCK_X|LOCK_REC_NOT_GAP锁。这里需要注意的是,查询条件对应的数据会直接加显式锁,其他的二级索引上得记录锁是隐式锁,这在下面介绍隐式锁的章节中有所介绍。
RC+无索引查询
若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL的约束。
提前释放锁执行路径:
Sql_cmd_update::update_single_table->ha_innobase::unlock_row->row_unlock_for_mysql->lock_rec_unlock
6.2 当前读加锁流程
函数lock_rec_lock中是确定上锁模式之后具体的上锁的流程,首先通过lock_rec_lock_fast函数判断是否可以快速上锁,
如果不能快速加锁成功的话,调用lock_rec_lock_slow上锁:
6.3 semi-consistent read
简单来说,semi-consistent read的作用是减少更新同一行记录时的锁冲突,减少锁等待。
一个update语句,如果读到一行上没有锁,读取最新版本的数据并加锁;如果读到一行已经加锁的记录,此时InnoDB返回记录最近提交的版本,由MySQL上层判断此版本是否满足update的where条件。
semi-consistent read只会发生在read committed隔离级别下,或者是参数innodb_locks_unsafe_for_binlog=ON。且仅仅针对于update操作。
6.4 insert加锁流程
我们在前文介绍锁类型的时候提到了插入意向锁,它的作用是在事务执行insert语句时防止幻读,插入意向锁模式:LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION。
|
请求锁\持有锁
|
GAP
|
Insert Intention
|
Record
|
Next-key
|
| GAP |
|
|
|
|
| Insert Intention |
x |
|
|
x |
| Record |
|
|
x |
x |
| Next-key |
|
|
x |
x |
当向某个数据页中插入一条记录时,总是会调用 lock_rec_insert_check_and_lock 函数进行锁检查,会去检查当前插入位置的下一条记录上是否存在锁对象。
如果下一条记录上存在锁对象,就需要判断该对象是否锁住了gap。如果gap被锁住了,判定和插入意向锁冲突,当前插入操作需要等待。但是插入意向锁之间并不互斥,这意味着同一个gap里可能有多个申请插入意向锁的会话。如果不存在冲突,InnoDB使用了隐式锁来优化这一场景的性能。
隐式锁
当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB会跳过加锁环节,这种机制称为隐式锁。隐式锁是InnoDB实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。另外,隐式锁是针对被修改的B+ tree记录,因此都是记录类型的锁,不可能是间隙锁或Next-Key类型锁。
InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚集索引的B+tree中。假设只有主键索引,则在进行插入时,行数据的trx_id被设置为当前事务id;假设存在二级索引,则在对二级索引进行插入时,需要更新所在page的max_trx_id。
在函数lock_rec_insert_check_and_lock中,加锁流程如下。
dberr_t lock_rec_insert_check_and_lock(){ ...
/* 插入意向锁模式 */ const ulint type_mode = LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION;
const lock_t *wait_for = lock_rec_other_has_conflicting(type_mode, block, heap_no, trx);
/* 如果与插入意向锁有冲突,创建一个插入意向锁,加到事务锁列表中去,插入等待队列中 */ if (wait_for != nullptr) { RecLock rec_lock(thr, index, block, heap_no, type_mode); trx_mutex_enter(trx); err = rec_lock.add_to_waitq(wait_for); trx_mutex_exit(trx); } switch (err) { ... case DB_SUCCESS: /* 如果没有冲突,直接更新最大事务号,返回成功 */ page_update_max_trx_id(block, buf_block_get_page_zip(block), trx->id, mtr); ... }...}
隐式锁转换为显式锁
将记录上的隐式锁转换为显示锁是由函数lock_rec_convert_impl_to_expl完成的:
主键索引上判断隐式锁是否存在
对于主键,只需要通过查看记录隐藏列trx_id是否是活跃事务就可以判断隐式锁是否存在。对于对于二级索引会相对比较麻烦,先通过二级索引页上的max_trx_id进行过滤,如果无法判断是否活跃则需要通过应用undo日志回溯老版本数据,才能进行准确的判断。
二级索引上判断隐式锁是否存在
lock_sec_rec_some_has_impl函数中:先可以通过PAGE_MAX_TRX_ID进行判断,如果当前PAGE_MAX_TRX_ID的值小于当前活跃事务的最新ID,说明修改这条记录的事务已经提交,则不存在隐式锁,反之则可能存在隐式锁,需要通过聚集索引进行判断,其判断过程由函数row_vers_impl_x_locked_low完成:
二级索引在判断出隐式锁存在后,也是调用lock_rec_convert_impl_to_expl_for_trx函数将隐式锁转化为显示锁,并将其加入到lock hash table中。
一个并发场景
从上面的分析可知,INSERT操作首先判断是否有跟插入意向锁冲突的事务,如果没有冲突会在对应的位置更新事务号信息,这两个操作之间是有时间差的,如果在这之间执行 select ... lock in share mode 语句,由于此时记录还不存在,所以也不存在活跃事务,不会触发隐式锁转换,这条语句会返回 0 条记录,并加上 GAP 锁;而 insert 语句继续写数据,不加任何锁,在 insert 事务提交之后,select ... lock in share mode 就能查到 1 条记录,这岂不是幻读问题吗?
insert调用栈:row_ins_step -> row_ins -> row_ins_index_entry_step -> row_ins_index_entry -> row_ins_clust_index_entry -> row_ins_clust_index_entry_low -> btr_cur_optimistic_insert -> btr_cur_ins_lock_and_undo -> lock_rec_insert_check_and_lock。
在函数row_ins_clust_index_entry_low中会开启一个mini transaction。
每个 mini-transaction 会遵守下面的几个规则:
因此,该并发导致的幻读不会发生:
-
执行 insert 语句,对要操作的页加 RW-X-LATCH,然后判断是否有和插入意向锁冲突的锁,如果有,加插入意向锁,进入锁等待;如果没有,直接写数据,不加任何锁,结束后释放 RW-X-LATCH。
-
执行 select ... lock in share mode 语句,对要操作的页加 RW-S-LATCH,如果页面上存在 RW-X-LATCH 会被阻塞,没有的话则判断记录上是否存在活跃的事务,如果存在,则为 insert 事务创建一个排他记录锁,并将自己加入到锁等待队列,最后也会释放 RW-S-LATCH。
INSERT当前读判断记录是否存在
INSERT当前读判断记录是否存在的加锁流程在RR和RC下相同。
6.5 锁唤醒与回滚
锁冲突进入等待后,事务挂起,等待被唤醒,触发锁唤醒的主要场景有:锁授予唤醒、死锁唤醒(事务回滚)、超时唤醒(语句回滚)。
如果某一个操作因为锁等待超时而回滚,此时事务持有在表级上的锁并不会被释放(具体流程见lock_cancel_waiting_and_release),这是合理的,因为我们并不知道在该操作之前,事务是不是就已经获得了该表上的锁,如果是,那么释放这个表锁会使得,该事务还未结束,其他DDL操作就能够正常进行,从而引起一致性错误。
此外,一个事务中途的操作出现任何异常,已经持有的行锁不会释放(semi consistent read除外),严格按照两段锁协议。
CATS事务调度算法
CATS(Contention Aware Transaction Schedule)是MySQL 8.0的一个新特性,相关论文发表在VLDB 2018。它的作用是在高冲突场景下,一个事务在释放一个锁后决定这把锁给哪个等待的事务。CATS将等待的事务按照权重排序,权重最高的事务拥有最高的优先级获得等待的锁。事务的权重取决于等待这个事务的事务数量。换句话说,一个事务有越多的等待事务,那么它获取锁的优先级越高。
回滚流程
回滚有两种:一种是事务级的回滚,回滚整个事务;第二种是使用savepoint部分回滚,可以回滚到事务中间的某一个状态。ROLLBACK SAVEPOINT *savepoint_name*
第一种没什么好说的,回滚整个事务,释放所有获得的锁,第二种回滚不会结束事务,InnoDB不会释放内存中的行锁,这是满足两段锁协议的,能够有效减少死锁的发生。
如果回滚的操作中有INSERT操作,结果会有点不同,INSERT操作会在下一条满足条件的记录上加上gap锁,如果下一条记录是supremum,加next-key锁,防止有新的事务插入这条数据,引起幻读异常。因此,使用savepoint回滚INSERT操作时,事务并不会释放supremum上的锁。
原创 腾讯数据库技术 腾讯数据库技术 2021-11-16