85

业务研发团队 唐蕴梦

什么是锁

锁是计算机协调多个进程或线程并发访问某一资源的机制。

Mysql锁

  • 行锁
    开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
  • 页锁
    开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
  • 表锁
    开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。

MySQL的表级锁有两种模式:表共享读锁(Table Read Lock)和表独占写锁(Table Write Lock)。

事务

事务是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。

事务的四个特性条件

  • 原子性:一组事务,要么全部成功;要么撤回。
  • 一致性 :满足模式锁指定的约束,比如银行转账前后总金额应该不变。事务结束时,所有的内部数据结构(如B树索引)也都必须是正确的。
  • 隔离性:事务独立运行。一个事务所做的修改在最终提交之前,对其它事务是不可见的。事务的100%隔离,需要牺牲速度。
  • 持久性:软、硬件崩溃后,InnoDB数据表驱动会利用日志文件重构修改,或者通过数据库备份和恢复来保证。

在默认情况下,MySQL每执行一条SQL语句,都是一个单独的事务。

事务并发的问题

  • 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做"脏读"。
  • 不可重复读(Non-Repeatable Reads):一个事务读取某些数据,在它结束读取之前,另一个事务可能完成了对数据行的更改。当第一个事务试图再次执行同一个查询,服务器就会返回不同的结果。
  • 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。

事务隔离级别

事务隔离级别 脏读 不可重复读 幻读
读未提交(read-uncommitted)
不可重复读(read-committed)
可重复读(repeatable-read)
串行化(serializable)

mysql默认的事务隔离级别为repeatable-read

  • 读未提交 :事务可以读取到其他事务未提交的数据,此时若A事务读取到B事务未提交的修改,后B回滚就会产生脏读。
  • 不可重复读:事务只能读取到其他事务提交的数据,不会产生脏读,但若事务B提交在A的两次查询间就会产生不可重复读。
  • 可重复读:可重复读的隔离级别下使用了MVCC机制,A事务中读取的是记录的快照版本,而非最新版本,B事务的更新是创建了一个新版本来更新,不同事务的读和写是分离的
  • 串行化:mysql中事务隔离级别为serializable时会锁表,因此不会出现幻读的情况,这种隔离级别并发性极低。

事务加锁方式

  • 一次性锁协议,事务开始时,即一次性申请所有的锁,之后不会再申请任何锁,如果其中某个锁不可用,则整个申请就不成功,事务就不会执行,在事务尾端,一次性释放所有的锁。一次性锁协议不会产生死锁的问题,但事务的并发度不高。
  • 两阶段锁协议,整个事务分为两个阶段,前一个阶段为加锁,后一个阶段为解锁。在加锁阶段,事务只能加锁,也可以操作数据,但不能解锁,直到事务释放第一个锁,就进入解锁阶段,此过程中事务只能解锁,也可以操作数据,不能再加锁。两阶段锁协议使得事务具有较高的并发度,因为解锁不必发生在事务结尾。它的不足是没有解决死锁的问题,因为它在加锁阶段没有顺序要求。如两个事务分别申请了A, B锁,接着又申请对方的锁,此时进入死锁状态。

Innodb的事务隔离

在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。

MySQL/InnoDB定义的4种隔离级别:

  • Read Uncommited

    可以读取未提交记录。
    
  • Read Committed (RC)
    当前读操作RC隔离级别保证对读取到的记录加锁 (记录锁),存在幻读现象。使用MVCC,但读取数据时读取自身版本和最新版本,以最新为主,可以读已提交记录,存在不可重复读现象。
  • Repeatable Read (RR)
    当前读操作RR隔离级别保证对读取到的记录加锁 (记录锁),同时保证对读取的范围加锁,新的满足查询条件的记录不能够插入 (间隙锁),不存在幻读现象。使用MVCC保存两个事物操作的数据互相隔离,不存在不可重复读现象。
  • Serializable
    从MVCC并发控制退化为基于锁的并发控制。不区别快照读与当前读,所有的读操作均为当前读,读加读锁 (S锁),写加写锁 (X锁)。

Serializable隔离级别下,读写冲突,因此并发度急剧下降,在MySQL/InnoDB下不建议使用。

InnoDB的MVCC多版本并发控制

MVCC是一种多版本并发控制机制。锁机制可以控制并发操作,但是其系统开销较大,而MVCC可以在大多数情况下代替行级锁,使用MVCC,能降低其系统开销。

MVCC是通过保存数据在某个时间点的快照来实现的。 不同存储引擎的MVCC、不同存储引擎的MVCC实现是不同的,典型的有乐观并发控制和悲观并发控制。

InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的,这两个列,分别保存了这个行的创建时间,一个保存的是行的删除时间。这里存储的并不是实际的时间值,而是系统版本号(可以理解为事务的ID),没开始一个新的事务,系统版本号就会自动递增,事务开始时刻的系统版本号会作为事务的ID。IN

  • INSERT
    InnoDB为新插入的每一行保存当前系统版本号作为版本号.
  • UPDATE
    InnoDB执行UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间。
  • DELETE
    InnoDB会为删除的每一行保存当前系统的版本号(事务的ID)作为删除标识
  • SELECT
    InnoDB会根据以下两个条件检查每行记录,需要同时满足以下两个条件:

    • InnoDB只会查找版本早于当前事务版本的数据行(也就是行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的.
    • 行的删除版本要么未定义要么大于当前事务版本号,这可以确保事务读取到的行,在事务开始之前未被删除。
**注意:
在SELECT时,只满足上述两个条件也是不能达到快照读的要求的,比如在RR的隔离级别下会有如下情况

启动1号事务、启动2号事务、1号事务更新x行并提交事务(此时x行的修改版本号为1,删除版本号为未定义)、2号事务读取x行

安装如上步骤,如果只满足上述两个条件的话,显然2号事务时可以读取到1号事务所做的更新(x行修改版本号为1满足小于2,删除版本号为未定义满足事务开始之前未删除),显然是不足够满足快照读的要求**

clipboard.png

事实上,在读取到满足上述两个条件的行时,InnoDB还会进行二次检查,如上图所示

活跃事务列表:RC隔离级别下,在语句开始时从全局事务表中获取活跃(未提交)事务构造Read View,RR隔离级别下,在事务开始时从全局事务表中获取活跃事务构造Read View

  • 1.取当前行的修改事务ID,和Read View中的事务ID做比较,若小于最小的ID或小于最大ID但不在列表中,转2步骤,若大于最大ID,转3步骤
  • 2.满足进入此步骤的条件,即可说明,最后更新当前行的事务,在构造Read View时已经提交,则返回当前行的数据
  • 3.满足进入此步骤的条件,即可说明,最后更新当前行的事务,在构造Read View时还未创建或者还未提交,则取undo log中的记录的事务ID,重新进入步骤1,重复此操作

至此,通过上述步骤,可以实现真正的快照读。

上述策略的结果就是,在读取数据的时候,InnoDB几乎不用获得任何锁,每个查询都通过版本检查,只获得自己需要的数据版本,从而大大提高了系统的并发度。 这种策略的缺点是,每行记录都需要额外的存储空间,更多的行检查工作和一些额外的维护工作。

上述更新前建立undo log,根据各种策略读取时非阻塞就是MVCC,undo log中的行就是MVCC中的多版本,这个可能与我们所理解的MVCC有较大的出入,一般我们认为MVCC有下面几个特点:

  • 每行数据都存在一个版本,每次数据更新时都更新该版本
  • 修改时Copy出当前版本随意修改,各个事务之间无干扰
  • 保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃copy(rollback)

就是每行都有版本号,保存时根据版本号决定是否成功,听起来含有乐观锁的味道,而Innodb的实现方式是:

  • 事务以排他锁的形式修改原始数据
  • 把修改前的数据存放于undo log,通过回滚指针与主数据关联
  • 修改成功(commit)啥都不做,失败则恢复undo log中的数据(rollback)

二者最本质的区别是,当修改数据时是否要排他锁定,如果锁定了还算不算是MVCC?

Innodb的实现真算不上MVCC,因为并没有实现核心的多版本共存,undo log中的内容只是串行化的结果,记录了多个事务的过程,不属于多版本共存。但理想的MVCC是难以实现的,当事务仅修改一行记录使用理想的MVCC模式是没有问题的,可以通过比较版本号进行回滚;但当事务影响到多行数据时,理想的MVCC据无能为力了。

比如,如果Transaciton1执行理想的MVCC,修改Row1成功,而修改Row2失败,此时需要回滚Row1,但因为Row1没有被锁定,其数据可能又被Transaction2所修改,如果此时回滚Row1的内容,则会破坏Transaction2的修改结果,导致Transaction2违反ACID。

理想MVCC难以实现的根本原因在于企图通过乐观锁代替二段提交。修改两行数据,但为了保证其一致性,与修改两个分布式系统中的数据并无区别,而二提交是目前这种场景保证一致性的唯一手段。二段提交的本质是锁定,乐观锁的本质是消除锁定,二者矛盾,故理想的MVCC难以真正在实际中被应用,Innodb只是借了MVCC这个名字,提供了读的非阻塞而已。

Innodb事务锁

锁模式

  • 共享锁(S):又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
  • 排他锁(X):又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。

另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。

  • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。

意向锁仅仅用于表锁和行锁的共存使用。如果我们的操作仅仅涉及行锁,那么意向锁不会对我们的操作产生任何影响。在任一操作给表A的一行记录加锁前,首先要给该表加意向锁,如果获得了意向锁,然后才会加行锁,并在加行锁时判断是否冲突。如果现在有一个操作要获得表A的表锁,由于意向锁的存在,表锁获取会失败(如果没有意向锁的存在,加表锁之前可能要遍历整个聚簇索引,判断是否有行锁存在,如果没有行锁才能加表锁)。   

同理,如果某一操作已经获得了表A的表锁,那么另一操作获得行锁之前,首先会检查是否可以获得意向锁,并在获得意向锁失败后,等待表锁操作的完成。也就是说:1.意向锁是表级锁,但是却表示事务正在读或写某一行记录;2.意向锁之间不会冲突, 因为意向锁仅仅代表要对某行记录进行操作,在加行锁时,会判断是否冲突;3.意向锁是InnoDB自动加的,不需用户干预。

锁类型

  • 间隙锁(Gap Lock),只锁间隙。表现为锁住一个区间(注意这里的区间都是开区间,也就是不包括边界值)。
  • 记录锁(Record Lock),只锁记录。表现为仅仅锁着单独的一行记录。
  • Next-Key锁(源码中称为Ordinary Lock),同时锁住记录和间隙。从实现的角度为record lock+gap lock,而且两种锁有可能只成功一个,所以next-key是半开半闭区间,且是下界开,上界闭。一张表中的next-key锁包括:(负无穷大,最小的第一条记录],(记录之间],(最大的一条记录,正无穷大)。
  • 插入意图锁(Insert Intention Lock),插入操作时使用的锁。在代码中,插入意图锁实际上是Gap锁上加了一个LOCK_INSERT_INTENTION的标记。也就是说insert语句会对插入的行加一个X记录锁,但是在插入这个行的过程之前,会设置一个Insert intention的Gap锁,叫做Insert intention锁。
  • 乐观锁 : 总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现。version方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
  • 悲观锁: 总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起。可以依靠数据库实现,如行锁、读锁和写锁等,都是在操作之前加锁。

一致性非锁定读

InnoDB使用MVCC来实现一致性非锁定读,在read-committed和repeatable-read两种事务隔离级别下使用,且效果不同,具体如下。

read-committed

在读已提交的隔离级别下,事务在一致性非锁定读始终读取当前最新的数据快照,即当其他事务提交更新后快照更新也会读取最新的,也就是出现不可重复读。

repeatable-read

在可重复读的隔离级别下,事务始终读取事务开始时的快照版本。

一致性锁定读

一致性锁定读有两种实现方式,一种是加X锁,一种是加S锁

select ... for update 显示的使用加X锁的方式读取

select ... lock in share mode 显示的使用加S锁的方式读取

自增长与锁

innodb_autoinc_lock_mode有3种配置模式:0、1、2,

  • 0:涉及auto-increment列的插入语句加的表级AUTO-INC锁,只有插入执行结束后才会释放锁,即事务在进行插入时获取自增长值时先加锁,后插入,插入完释放
  • 1:对于可以事先确定插入行数的语句(包括单行和多行插入),使用互斥量操作自增值,分配连续的确定的auto-increment值,对于插入行数不确定的插入语句仍使用表级AUTO-INC锁。这种模式下,事务回滚,auto-increment值不会回滚,换句话说,自增列内容会不连续。
  • 2:对于所有的插入操作使用互斥量操作自增值,来一个插入分配一个auto-increment值,此时一个批量插入的自增长值就可能不连续,且在sql语句级的主从同步可能会出现问题

锁升级

InnoDB行锁是通过给索引上的索引项加锁来实现的,这一点MySQL与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!

InnoDB目前处理死锁的方法是:将持有最少行级排它锁的事务回滚。如果是因为死锁引起的回滚,可以考虑在应用程序中重新执行。

在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。

死锁

通常来说,死锁都是应用设计的问题,通过调整业务流程、数据库对象设计、事务大小,以及访问数据库的SQL语句,绝大部分死锁都可以避免。介绍几种避免死锁的常用方法。

  • (1)在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。
  • (2)在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能。比如两个会话读取前十个用户的信息,每次读取一个,那么我们可以规定他们从第一个用户开始读,而不是倒序,这样不会死锁。
  • (3)在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。 (4) 选择合理的事务大小,小事务发生锁冲突的几率也更小;   

如果出现死锁,可以用SHOW INNODB STATUS命令来确定最后一个死锁产生的原因。返回结果中包括死锁相关事务的详细信息,如引发死锁的SQL语句,事务已经获得的锁,正在等待什么锁,以及被回滚的事务等。

死锁的发生与否,并不在于事务中有多少条SQL语句,死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。而使用本文上面提到的,分析MySQL每条SQL语句的加锁规则,分析出每条语句的加锁顺序,然后检查多个并发SQL间是否存在以相反的顺序加锁的情况,就可以分析出各种潜在的死锁情况,也可以分析出线上死锁发生的原因。

问题:

按索引项来加锁的话,不同索引相同行,会不会同时获得不同的锁却操作同一行

  • 聚簇索引也会加锁,也就是主键会加锁,这样就防止并发修改了

自增锁,是语句级的锁,如果当前事务先获取锁,却后执行完,在从库按语句复制的话,会不会出现ID不一致

InnoDB的锁实现

lock0types.h   事务锁系统的类型定义,包含了 lock_mode定义

lock0priv.ic   锁模块内部的一些方法,被用于除了lock0lock.cc的三个文件里,

lock_get_type_low 获取锁是表锁还是行锁

lock_clust_rec_some_has_impl 检查一行数据上是否有隐示的x锁
lock_rec_get_n_bits 获取一个记录锁的锁位图的bit数目
lock_rec_set_nth_bit 设置第n个记录锁bit位为true
lock_rec_get_next_on_page 获取当前page上的下一个记录锁
lock_rec_get_next_on_page_const 
lock_rec_get_first_on_page_addr 获取当前page上第一个记录锁
lock_rec_get_first_on_page
lock_rec_get_next  返回当前记录上的下一个显示锁请求的锁
lock_rec_get_next_const
lock_rec_get_first 获取当前记录上的第一个显示锁请求的锁
lock_rec_get_nth_bit    Gets the nth bit of a record lock.
lock_get_mode  获取一个锁的 lock_mode
lock_mode_compatible  判断两个lock_mode是否兼容
lock_mode_stronger_or_eq 判断lock_mode 1 是否比 lock_mode 2更强
lock_get_wait 判断一个锁是不是等待锁
lock_rec_find_similar_on_page  查找一个合适的锁结构在当前事务当前页面下???找到的话就不用新创建锁结构???
lock_table_has  检查一个事务是否有指定类型的表锁,只能由当前事务调用

lock0priv.h  锁模块内部的结构和方法

struct lock_table_t   表锁结构
struct lock_rec_t   行锁结构
struct lock_t 锁通用结构
static const byte lock_compatibility_matrix[5][5]  锁的兼容关系
static const byte lock_strength_matrix[5][5] 锁的强弱关系
enum lock_rec_req_status 记录锁请求状态
struct RecID  记录锁ID
class RecLock   记录锁类
add_to_waitq   入队一个锁等待
create   为事务创建一个锁并初始化
is_on_row  Check of the lock is on m_rec_id.
lock_alloc 创建锁实例
prepare 做一些检查个预处理为创建一个记录锁
mark_trx_for_rollback 收集需要异步回滚的事务
jump_queue   跳过所有低优先级事务并添加锁,如果能授予锁,则授予,不能的话把其他都标记异步回滚
lock_add   添加一个记录锁到事务锁列表和锁hash表中
deadlock_check 检查并解决死锁
check_deadlock_result  检查死锁检查的结果
is_predicate_lock  返回时不是predictate锁
init 按照要求设置上下文
lock_get_type_low 返回行锁还是表锁
lock_rec_get_prev 获取一个记录上的前一个锁 

锁类型

在 Innodb 内部用一个 unsiged long 类型数据表示锁的类型, 最低的 4 个 bit 表示 lock_mode, 5-8 bit 表示 lock_type, 剩下的高位 bit 表示行锁的类型。

  • lock_type

5-8 bit 位标识 lock_type 目前只使用了两个,第5位标识是表锁,第6位标识是行锁

#define LOCK_TABLE  16  /*!< table lock */ //表锁
#define LOCK_REC    32  /*!< record lock */ //记录锁
  • lock_mode

lock描述了锁的基本模式,目前有5种模式,IS、IX、S、X、AI

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
};

以下是锁的基本模式的兼容关系和强弱关系

/* LOCK COMPATIBILITY MATRIX
 *    IS IX S  X  AI
 * IS +  +  +  -  +
 * IX +  +  -  -  +
 * S  +  -  +  -  -
 * X  -  -  -  -  -
 * AI +  +  -  -  -
 *
 * Note that for rows, InnoDB only acquires S or X locks.
 * For tables, InnoDB normally acquires IS or IX locks.
 * S or X table locks are only acquired for LOCK TABLES.
 * Auto-increment (AI) locks are needed because of
 * statement-level MySQL binlog.
 * See also lock_mode_compatible().
 */
static const byte lock_compatibility_matrix[5][5] = {
 /**         IS     IX       S     X       AI */
 /* IS */ {  TRUE,  TRUE,  TRUE,  FALSE,  TRUE},
 /* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  TRUE},
 /* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
 /* X  */ {  FALSE, FALSE, FALSE, FALSE,  FALSE},
 /* AI */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE}
};

/* STRONGER-OR-EQUAL RELATION (mode1=row, mode2=column)
 *    IS IX S  X  AI
 * IS +  -  -  -  -
 * IX +  +  -  -  -
 * S  +  -  +  -  -
 * X  +  +  +  +  +
 * AI -  -  -  -  +
 * See lock_mode_stronger_or_eq().
 */
static const byte lock_strength_matrix[5][5] = {
 /**         IS     IX       S     X       AI */
 /* IS */ {  TRUE,  FALSE, FALSE,  FALSE, FALSE},
 /* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE},
 /* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
 /* X  */ {  TRUE,  TRUE,  TRUE,  TRUE,   TRUE},
 /* AI */ {  FALSE, FALSE, FALSE, FALSE,  TRUE}
};
  • record_lock_type

剩下的高位标识行锁的模式,对于表锁这些位都是空的

目前record_lock_type有以下值

#define LOCK_WAIT   256 /*!< Waiting lock flag; when set, it  //锁等待
                means that the lock has not yet been
                granted, it is just waiting for its
                turn in the wait queue */
/* Precise modes */
#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 /*!< when this bit is set, it means that the
                lock holds only on the gap before the record;
                for instance, an x-lock on the gap does not
                give permission to modify the record on which
                the bit is set; locks of this type are created
                when records are removed from the index chain
                of records */
#define LOCK_REC_NOT_GAP 1024   /*!< this bit means that the lock is only on
                the index record and does NOT block inserts
                to the gap before the index record; this is
                used in the case when we retrieve a record
                with a unique key, and is also used in
                locking plain SELECTs (not part of UPDATE
                or DELETE) when the user has set the READ
                COMMITTED isolation level */
#define LOCK_INSERT_INTENTION 2048 /*!< this bit is set when we place a waiting
                gap type record lock request in order to let
                an insert of an index record to wait until
                there are no conflicting locks by other
                transactions on the gap; note that this flag
                remains set when the waiting lock is granted,
                or if the lock is inherited to a neighboring
                record */
#define LOCK_PREDICATE  8192    /*!< Predicate lock */
#define LOCK_PRDT_PAGE  16384   /*!< Page lock */

锁结构

clipboard.png

  • lock_sys

首先是锁系统结构,在Innodb启动的时候初始化,在Innodb结束的时候释放

主要保存着锁的hash表,以及相关事务、线程的一些信息

/** The lock system struct */
struct lock_sys_t{
    char        pad1[CACHE_LINE_SIZE];  /*!< padding to prevent other
                        memory update hotspots from
                        residing on the same memory
                        cache line */
    LockMutex   mutex;          /*!< Mutex protecting the
                        locks */
    hash_table_t*   rec_hash;       /*!< hash table of the record
                        locks */
    hash_table_t*   prdt_hash;      /*!< hash table of the predicate
                        lock */
    hash_table_t*   prdt_page_hash;     /*!< hash table of the page
                        lock */
    char        pad2[CACHE_LINE_SIZE];  /*!< Padding */
    LockMutex   wait_mutex;     /*!< Mutex protecting the
                        next two fields */
    srv_slot_t* waiting_threads;    /*!< Array of user threads
                        suspended while waiting for
                        locks within InnoDB, protected
                        by the lock_sys->wait_mutex */
    srv_slot_t* last_slot;      /*!< highest slot ever used
                        in the waiting_threads array,
                        protected by
                        lock_sys->wait_mutex */
    ibool       rollback_complete;
                        /*!< TRUE if rollback of all
                        recovered transactions is
                        complete. Protected by
                        lock_sys->mutex */
    ulint       n_lock_max_wait_time;   /*!< Max wait time */
    os_event_t  timeout_event;      /*!< Set to the event that is
                        created in the lock wait monitor
                        thread. A value of 0 means the
                        thread is not active */
    bool        timeout_thread_active;  /*!< True if the timeout thread
                        is running */
};

lock_sys_create .   Creates the lock system at database start.
lock_sys_close     Closes the lock system at database shutdown
  • lock_t

无论是行锁还是表锁都使用lock_t结构保存,其中用一个union来分别保存行锁和表锁不同的数据,分别为lock_table_t和lock_rec_t

struct lock_t {
    trx_t*      trx;        /*!< transaction owning the lock */
    UT_LIST_NODE_T(lock_t)
            trx_locks;  /*!< list of the locks of the transaction */

    dict_index_t*   index;      /*!< index for a record lock */

    lock_t*     hash;       /*!< hash chain node for a record lock. The link node in a singly linked list, used during hashing. */

    union { //表锁和记录锁不同的数据
        lock_table_t    tab_lock;/*!< table lock */   //表锁结构体
        lock_rec_t  rec_lock;/*!< record lock */  //记录锁结构体
    } un_member;            /*!< lock details */ 

    ib_uint32_t type_mode;  /*!< lock type, mode, LOCK_GAP or LOCK_REC_NOT_GAP, LOCK_INSERT_INTENTION, wait flag, ORed */  //锁类型

    /** Remove GAP lock from a next Key Lock */
    void remove_gap_lock()   //移除一个next-key锁的gap锁
    {
        ut_ad(!is_gap());
        ut_ad(!is_insert_intention());
        ut_ad(is_record_lock());
        type_mode |= LOCK_REC_NOT_GAP;
    }

    /** Determine if the lock object is a record lock.
    @return true if record lock, false otherwise. */
    bool is_record_lock() const   //判断是否是记录锁
    {
        return(type() == LOCK_REC);
    }

    /** Determine if it is predicate lock.
    @return true if predicate lock, false otherwise. */
    bool is_predicate() const
    {
        return(type_mode & (LOCK_PREDICATE | LOCK_PRDT_PAGE));
    }

    bool is_waiting() const
    {
        return(type_mode & LOCK_WAIT);
    }

    bool is_gap() const
    {
        return(type_mode & LOCK_GAP);
    }

    bool is_record_not_gap() const
    {
        return(type_mode & LOCK_REC_NOT_GAP);
    }

    bool is_insert_intention() const
    {
        return(type_mode & LOCK_INSERT_INTENTION);
    }

    ulint type() const {
        return(type_mode & LOCK_TYPE_MASK);
    }

    enum lock_mode mode() const
    {
        return(static_cast<enum lock_mode>(type_mode & LOCK_MODE_MASK));
    }

    /** Get lock hash table
    @return lock hash table */
    hash_table_t* hash_table() const
    {
        return(lock_hash_get(type_mode));
    }

    /** Get tablespace ID for the lock
    @return space ID */
    ulint space() const
    {
        return(un_member.rec_lock.space);
    }

    /** Get page number of the lock
    @return page number */
    ulint page_number() const
    {
        return(un_member.rec_lock.page_no);
    }

    /** Print the lock object into the given output stream.
    @param[in,out]  out the output stream
    @return the given output stream. */
    std::ostream& print(std::ostream& out) const;

    /** Convert the member 'type_mode' into a human readable string.
    @return human readable string */
    std::string type_mode_string() const;

    const char* type_string() const
    {
        switch (type_mode & LOCK_TYPE_MASK) {
        case LOCK_REC:
            return("LOCK_REC");
        case LOCK_TABLE:
            return("LOCK_TABLE");
        default:
            ut_error;
        }
    }
};
 
 
/** A table lock */
struct lock_table_t {
    dict_table_t*   table;      /*!< database table in dictionary
                    cache */
    UT_LIST_NODE_T(lock_t)
            locks;      /*!< list of locks on the same
                    table */
    /** Print the table lock into the given output stream
    @param[in,out]  out the output stream
    @return the given output stream. */
    std::ostream& print(std::ostream& out) const;
};


 
 
/** Record lock for a page */
struct lock_rec_t {
    ib_uint32_t space;      /*!< space id */
    ib_uint32_t page_no;    /*!< page number */
    ib_uint32_t n_bits;     /*!< number of bits in the lock
                    bitmap; NOTE: the lock bitmap is
                    placed immediately after the
                    lock struct */

    /** Print the record lock into the given output stream
    @param[in,out]  out the output stream
    @return the given output stream. */
    std::ostream& print(std::ostream& out) const;
};
  • bitmap

Innodb 使用位图来表示锁具体锁住了那几行,在函数 lock_rec_create 中为 lock_t 分配内存空间的时候,会在对象地址后分配一段内存空间(当前行数 + 64)用来保存位图。n_bits 表示位图大小。

/* Make lock bitmap bigger by a safety margin */
n_bits = page_dir_get_n_heap(page) + LOCK_PAGE_BITMAP_MARGIN;
n_bytes = 1 + n_bits / 8;

lock = static_cast<lock_t*>(
    mem_heap_alloc(trx->lock.lock_heap, sizeof(lock_t) + n_bytes));

显示锁和隐示锁

explicit lock 显示锁
implicit lock 隐示锁

InnoDB增加隐示锁的目的是在INSERT的时候不加锁

具体实现为

  • 1.在insert的时候,不进行加锁
  • 2.在当前读访问到一行的时候,判断是否有隐示锁且事务是活跃事务,有的话先转为显示锁

锁流程

lock system 开始启动 申请lock_sys_t结构,初始化结构体

lock system 结束关闭 释放lock_sys_t结构的元素,释放结构体

插入加锁流程

https://www.colabug.com/32979...

问题:为什么有GAP也能插入(有GAP是不能插入的),插入意向锁什么时候加(插入之前尝试加插入意向锁,冲突加等待,不冲突直接插数据), 有什么用,唯一键冲突如何处理的(需要检测冲突会先尝试给行加S|next-key lock,加成功再检测)

和加锁有关的流程大概如下

  • 1)对表加IX锁
  • 2)对修改的页面加X锁
  • 3)如果需要检测唯一键冲突,尝试给需要加的唯一键列加一个S|next-key lock锁,可能会产生锁等待
  • 4)判断是否插入意向锁冲突,冲突加等待的插入意向锁,不冲突直接插入数据
  • 5)释放页面锁

clipboard.png

clipboard.png

删除加锁流程

删除加锁有个重要的问题是,删除并发的时候的加锁会有以下死锁问题

  • 1)事务1获取表IX锁
  • 2)事务1获取页面X锁
  • 3)事务1获取第n行的x|not gap 锁
  • 4)事务1删除第n行
  • 5)事务1释放页面X锁
  • 6)事务2获取页面X锁
  • 7)事务2尝试获取第n行的x|not gap锁,发现冲突,等待
  • 8)事务2释放页面X锁
  • 9)事务1释放第n行的锁,提交事务
  • 10)释放第n行锁的时候,检查到事务2有一个等待锁,发现可以加锁了,唤醒事务2,成功加锁
  • 11)事务3获取页面X锁
  • 12)事务3尝试删除第n行,发现第n行被删除(注意,此时记录还在还没被从页面刷出),尝试获取第n行的next-key lock,发现事务2有一个x|gap锁冲突,等待
  • 13)事务3释放页面X锁
  • 14)事务2获取页面X锁,检查页面是否改动,重新检查第n行数据,发现第n行数据被删除,尝试获取第n行的next-key lock,发现有事务3已经在等待这个锁了,事务2冲突,进入等待
  • 15)死锁

表锁加锁流程

lock_table

  • 1)检查当前事务是否拥有更强的表锁,有的话直接返回成功,否则继续往下走
  • 2)遍历表的锁列表,判断是否有冲突的锁,如果没有转3,有转4
  • 3)直接创建一个表锁,放入事务的lock list中,放入table的 lock list中,加锁成功
  • 4)如3步骤,创建等待的表锁,加入list,然后进行死锁检测和死锁解决,回滚当前事务或者挂起当前事务

行锁加锁流程

clipboard.png

  • lock_rec_lock

主要的参数是 mode(锁类型),block(包含该行的 buffer 数据页),heap_no(具体哪一行)。就可以确定加什么样的锁,以及在哪一行加。

加锁流程主要是lock fast和lock slow,首先进入lock fast进行快速加锁,如果快速加锁失败则进入lock slow开始正常加锁流程,可能有锁冲突检查、死锁检查等流程

  • lock fast
  • 1.获取需要加锁的页面上第一个record lock
  • 2.判断获取的锁是不是空,是转3,否转4
  • 3.如果需要加的是隐示锁直接返回成功,否则,创建一个创建一个RecLock对象然后创建一个锁返回成功
  • 4.判断当前页面上是否只有一个锁,且这个锁是当前事务的,且这个锁模式和需要加的模式一样,且bitmap的大小够用,满足前述条件转5,否则转6
  • 5.可以快速加锁,直接设置bitmap进行加锁,返回成功
  • 6.快速加锁失败,返回失败并进入lock slow流程
  • lock slow
  • 1.调用lock_rec_has_expl函数判断当前事务是不是有更强的锁,满足转2,不满足转3

lock_rec_has_expl函数遍历rec_hash,获取事务编号是当前事务的锁,同时满足以下五个条件就判定为有更强的锁

    • 1)不是一个插入意向锁
    • 2)不是一个等待中的锁
    • 3)根据强弱关系矩阵判断满足更强
    • 4)不是lock_rec_not_gap类型,或者要加的锁是lock_rec_not_gap类型或者heap_no是上界
    • 5)不是lock_gap类型,或者要加的锁是lock_gap类型或者heap_no是上界
    • 2.有更强的锁,直接返回成功,什么都不需要做
    • 3.如果没有更强的锁,调用lock_rec_other_has_conflicting判断是否有锁冲突需要等待,如果有转4,没有转5。lock_rec_other_has_conflicting函数遍历rec_hash,拿出对应行上的每一个锁,调用 lock_rec_has_to_wait 进行冲突判断

      • 1)如果当前锁和要加的锁是同一个事务的,直接返回,没有冲突
      • 2)根据兼容矩阵判断当前锁和要加的锁是否兼容,如果兼容,直接返回,没有冲突
      • 3)如果要加的是lock_gap或者heap_no是页面上界,且不是lock_insert_intention的话,可以直接返回,没有冲突,因为非插入意向锁的gap锁是不用等待的,都不冲突
      • 4)如果要加的锁不是插入意向锁lock_insert_intention,且当前锁是一个gap锁,直接返回,没有冲突
      • 5)如果要加的锁是gap锁,且当前锁是lock_rec_not_gap锁,直接返回,没有冲突
      • 6)如果当前锁是一个插入意向锁,直接返回没有冲突
      • 7)不满足上述条件,返回冲突
ps:为什么经过2步骤判断锁不兼容还需要往下走5个判断,是因为锁类型lock_mode/lock_type/rec_lock_type三种标记位同时有,如lock_x|lock_gap, lock_s|lock_rec_not_gap 这两个锁虽然lock_mode不兼容,但不冲突
  • 4.调用add_to_waitq,入队一个锁等待

    • 1)调用creat,创建lock_t,高优先级事务不放入rec_hash表,非高优先级放入
    • 2)如果是高优先级事务,调用jump_queue,如果加锁成功直接返回,jump_queue大概为跳过所有优先级比当前锁低的等待锁,加入等待队列中
    • 3)调用deadlock_check进行死锁检测
  • 5.判断是否是隐示锁,是的话直接返回成功,什么都不做,不是的话调用lock_rec_add_to_queue入队一个锁

    • 1)type_mode|=lock_rec,判断heap_no是否是页面上届,是的话,type_mode不能是lock_rec_not_gap
    • 2)遍历rec_hash判断当前行上是否有等待的锁,没有转3,有转4
    • 3)如果没有,且当前锁不是一个lock_wait,寻找当前页面上有没有相似的锁(当前事务的锁且锁类型和要加的锁一样),有的话直接设置标记位,没有转4
    • 4)创建一个锁lock_t,设置bit位,设置lock_type等信息,添加到rec_hash表中和事务的lock_list中

释放锁流程

clipboard.png

lock_rec_dequeue_from_page

  • 1.把当前锁从全局hash表中删除
  • 2.把当前锁从事务锁list中删除
  • 3.调用lock_rec_grant函数,尝试给等待锁加锁

    • 1)遍历锁hash表中当前页面上的锁,对于每个等待锁,调用lock_rec_has_to_wait_in_queue函数判断是否还需要等待

lock_rec_has_to_wait_in_queue

       - 遍历当前锁所在页面的所有非等待锁
       - 对于每个锁,根据heap_no判断当前锁需要加锁的行是否被锁上
       - 如果被锁上,判断要加的锁是否需要等待
- 2)对于不需要等待的锁,调用lock_grant进行加锁
       - 移除lock的lock_wait状态
       - 置空lock的wait_lock
       - 调用que_thr_end_lock_wait和lock_wait_release_thread_if_suspended唤醒等待中的线程

lock_table_dequeue

  • 1.调用lock_table_remove_low

    • 1)如果是自增长锁,将锁从事务的autoinc_locks list中移除
    • 2)从事务锁列表将锁移除
    • 3)从表的锁列表将锁移除
  • 2.遍历当前表的表锁列表,判断等待的锁是否能加锁,能的话调用lock_grant函数进行加锁

死锁流程

clipboard.png

构造wait-for graph

构造一个有向图,图中的节点代表一个事务,图的一个边A->B代表着A事务等待B事务的一个锁

具体实现是在死锁检测时,从当前锁的事务开始搜索,遍历当前行的所有锁,判断当前事务是否需要等待现有锁释放,是的话,代表有一条边,进行一次入栈操作

死锁检测

有向图判断环,用栈的方式,如果有依赖等待,进行入栈,如果当前事务所有依赖的事务遍历完毕,进行一次出栈

回滚事务选择

如果发现循环等待,选择当前事务和等待的事务其中权重小的一个回滚,具体的权重比较函数是 trx_weight_ge, 如果一个事务修改了不支持事务的表,那么认为它的权重较高,否则认为 undo log 数加持有的锁数之和较大的权重较高。

DeadlockChecker::search()

  • 1)将当前要加的等待锁设置为wait_lock,start_lock
  • 2)遍历wait_lock锁所在位置上的所有锁
  • 3)判断wait_lock锁是否需要等待遍历到的锁lock,不需要等待就跳过,
  • 4)如果需要等待,将lock push进栈,同时将lock设置为wait_lock,搜索深度+1,重复上述过程
  • 5)如果要等待,判断lock和start_lock是否是一个事务,是的话,发生死锁,选择当前事务和start_lock的事务权重小的回滚
  • 6)如果要等待,判断搜索深度是否已经过深,超过阈值,认为发生死锁,直接回滚了start_lock的事务

等待与唤醒
锁的等待以及唤醒实际上是线程的等待和唤醒,调用函数 lock_wait_suspend_thread 挂起当前线程,配合 OS_EVENT 机制,实现唤醒和锁超时等功能

锁分裂、合并、迁移

分裂

索引页面分裂导致的锁分裂

  • 1)普通锁,会跟随着记录位置的移动,锁一起移动到新的位置,加锁信息保持不变
  • 2)gap锁,会给新页面和老页面的上下界最大最小值上根据情况调整gap锁,目的保持gap锁上的区间保持不变

合并

索引页面合并导致的锁合并

  • 合并和分裂基本一致

迁移

插入和删除记录时的GAP锁的迁移

  • 1.在有GAP锁的间隙里插入记录时会出现GAP锁的迁移

主要是出现在当前事务拥有一个记录的GAP锁,又在这个记录前插入记录时

set global tx_isolation='repeatable-read';

create table t1(c1 int primary key, c2 int unique) engine=innodb;
insert into t1 values(1,1);
begin;
# supremum 记录上加 LOCK_X|LOCK_GAP 锁住(1~)
select * from t1 where c2=2 for update;
# 发现插入(3,3)的间隙存在GAP锁,因此给(3,3)加LOCK_X | LOCK_GAP锁。这样依然锁住了(1~)
insert into t1 values(3,3);
    for (lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);
         lock != NULL;
         lock = lock_rec_get_next(heap_no, lock)) {  //遍历当前行的所有锁

        if (!lock_rec_get_insert_intention(lock)
            && (heap_no == PAGE_HEAP_NO_SUPREMUM
            || !lock_rec_get_rec_not_gap(lock))) {  //如果不是插入意向锁 且 heap是上界或者不是一个非GAP锁

            lock_rec_add_to_queue(  //添加一个GAP的且mode和lock一致的锁到下一行
                LOCK_REC | LOCK_GAP | lock_get_mode(lock),
                block, heir_heap_no, lock->index,
                lock->trx, FALSE);
        }
    }
  • 2.删除拥有GAP锁的记录时会出现GAP锁的迁移

如有记录 1 3 5

  • 1)事务A对记录3加GAP锁,阻止1-3的间隙插入
  • 2)事务B对记录3加X锁,GAP锁和X锁不冲突,加锁成功,事务B删除记录3,提交
  • 3)此时当后台线程刷盘时,发现记录3已经删除,将从此页面将3记录删除,但发现3上还有个GAP锁,就会把这个GAP锁继承给这个记录后面的记录5

clipboard.png

Innodb在RR和RC隔离下的加锁实例分析

例子:select * from meng_hinata where id = 10 for update

组合一:id列是主键,RC隔离级别

在主键id=10列加上X锁

组合二:id列是二级唯一索引,RC隔离级别

在唯一索引id=10列上加X锁,在主键索引上对应列加X锁

组合三:id列是二级非唯一索引,RC隔离级别

在二级索引上所有id=10列加上X锁,这些列对应的主键索引列加上X锁

组合四:id列上没有索引,RC隔离级别

在聚簇索引上扫描,所有列上加X锁,此处有个优化,不满足的列在加锁后,判断不满足即可释放锁,违背二阶段加锁

组合五:id列是主键,RR隔离级别

在主键id=10列上加X锁

组合六:id列是二级唯一索引,RR隔离级别

在唯一索引id=10列上加X锁,在主键索引上对应列加X锁

组合七:id列是二级非唯一索引,RR隔离级别

在二级索引上查找id=10列,找到则加上X锁和GAP锁,然后对应的聚簇索引列加上X锁,最后一个不满足的列只会加上GAP锁

组合八:id列上没有索引,RR隔离级别

在聚簇索引上扫描,所有列加上X锁和GAP锁

测试

Innodb默认事务隔离级别为RR

clipboard.png

看出默认隔离级别为Repeatable Read,对user_id = 100000745进行count并显示加一个X锁,使用explain看出使用了uid索引即user_id字段的索引

clipboard.png

id = 1449731912的数据user_id = 100000745,可以看出在对user_id字段索引加了X锁之后,操纵相对应的主键索引时也会被阻塞,验证了对非主键索引加X锁的同时会对相应主键索引也加锁

clipboard.png

同时在对user_id字段索引加了X锁之后,也不能插入user_id相同的新数据,验证了innodb再RR隔离级别下也是防止了幻读的现象,实际上当范围查找时也会再加一个间隙锁来保证不会有幻读

clipboard.png

clipboard.png

可以看出不使用for update之后变为非当前读,也没有进行加锁,可以进行插入操作。

clipboard.png

clipboard.png

以上两个图设置隔离级别为RC后,可以看出在user_id = 100000745 进行for update查询后,还是能进行插入相同user_id值的列,说明只加了X锁并没有加间隙锁,同时因为X锁的原因,不能进行删除,验证了innodb引擎在RC隔离级别对于当前度也是会对读到的信息加锁,但没加间隙锁,会出现幻读。

clipboard.png

clipboard.png

以上两个图,在RC隔离级别下。

首先事务A对无索引的列进行查找并加锁,会扫描全表,注意并没有加表锁,而是对所有行都加了X锁,但是没加间隙锁,事务B还是可以插入。

此时事务A再扫描全表并加X锁,发现被阻塞,提交事务B后继续运行

事务A对全表加X锁后,事务B再次尝试插入,同样成功,但无法删除数据,说明还是没用表锁,对所有行加了X锁。

InnoDB锁同步机制

InnoDB条件变量核心数据结构为os_event_t,类似pthread_cont_t。如果需要创建和销毁则分别使用os_event_create和os_event_free函数。需要等待某个条件变量,先调用os_event_reset,然后使用os_event_wait,如果需要超时等待,使用os_event_wait_time替换os_event_wait即可

InnoDB自旋互斥锁的实现主要在文件 sync0sync.cc 和sync0sync.ic 中,头文件sync0sync.h 定义了核心数据结构ib_mutex_t。使用方法很简单,mutex_create创建锁,mutex_free释放锁,mutex_enter尝试获得锁,如果已经被占用了,则等待。mutex_exit释放锁,同时唤醒所有等待的线程,拿到锁的线程开始执行,其余线程继续等待。

InnoDB读写锁的核心实现在源文件sync0rw.cc 和sync0rw.ic 中,核心数据结构rw_lock_t 定义在sync0rw.h 中。使用方法与InnoDB 自旋互斥锁很类似,只不过读请求和写请求要调用不同的函数。加读锁调用rw_lock_s_lock, 加写锁调用rw_lock_x_lock,释放读锁调用rw_lock_s_unlock, 释放写锁调用rw_lock_x_unlock,创建读写锁调用rw_lock_create,释放读写锁调用rw_lock_free。函数rw_lock_x_lock_nowait和rw_lock_s_lock_nowait表示,当加读写锁失败的时候,直接返回,而不是自旋等待。


AI及LNMPRG研究
7.2k 声望12.8k 粉丝

一群热爱代码的人 研究Nginx PHP Redis Memcache Beanstalk 等源码 以及一群热爱前端的人