共计 22863 个字符,预计需要花费 58 分钟才能阅读完成。
业务研发团队 唐蕴梦
什么是锁
锁是计算机协调多个进程或线程并发访问某一资源的机制。
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,删除版本号为未定义满足事务开始之前未删除),显然是不足够满足快照读的要求 **
事实上,在读取到满足上述两个条件的行时,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 */
锁结构
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)释放页面锁
删除加锁流程
删除加锁有个重要的问题是,删除并发的时候的加锁会有以下死锁问题
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,然后进行死锁检测和死锁解决,回滚当前事务或者挂起当前事务
行锁加锁流程
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 中
释放锁流程
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 函数进行加锁
死锁流程
构造 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
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
看出默认隔离级别为 Repeatable Read,对 user_id = 100000745 进行 count 并显示加一个 X 锁,使用 explain 看出使用了 uid 索引即 user_id 字段的索引
id = 1449731912 的数据 user_id = 100000745,可以看出在对 user_id 字段索引加了 X 锁之后,操纵相对应的主键索引时也会被阻塞,验证了对非主键索引加 X 锁的同时会对相应主键索引也加锁
同时在对 user_id 字段索引加了 X 锁之后,也不能插入 user_id 相同的新数据,验证了 innodb 再 RR 隔离级别下也是防止了幻读的现象,实际上当范围查找时也会再加一个间隙锁来保证不会有幻读
可以看出不使用 for update 之后变为非当前读,也没有进行加锁,可以进行插入操作。
以上两个图设置隔离级别为 RC 后,可以看出在 user_id = 100000745 进行 for update 查询后,还是能进行插入相同 user_id 值的列,说明只加了 X 锁并没有加间隙锁,同时因为 X 锁的原因,不能进行删除,验证了 innodb 引擎在 RC 隔离级别对于当前度也是会对读到的信息加锁,但没加间隙锁,会出现幻读。
以上两个图,在 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 表示,当加读写锁失败的时候,直接返回,而不是自旋等待。