mysql事务、锁、MVCC
- 事务
- 事务的四大特性
- 并发事务会引发的问题
- 事务的隔离级别
- 事务的状态
- 锁
- 锁的分类
- 行表
- 读写
- 意向
- AUTO-INC 锁
- 隐式锁
- 行级锁算法
- 锁结构
- MVCC(多版本并发控制)
- 一致性非锁定读
- 锁定读
- MVCC的实现
- 隐藏字段
- undolog
- ReadView
- 数据可见性算法
数据库为了满足现实生活中的模拟,一条语句通常是完不成的,比如转账,这些常常需要进行一系列操作的场景,被称之为事务,而 MySQL 则使用锁与 MVCC(还有日志)来支持事务
事务
事务 Transaction 中文直译是转账,这样理解起来会好一些
数据库里满足某些条件的一系列操作,这些操作要么全部执行,要么都不执行
无论你是否手动处理了事务,只要是对数据库进行任何 update 操作(update、delete、insert),都一定是在事务中进行的,这是数据库的设计规范之一
MySQL 在设计时写了一些事务的特性,比如部分回滚,MySQL 在大多数情况下执行事务时只会回滚出现错误的语句,在少数情况下才会回滚整个事务。比如隐式提交(create、drop 等语句),比如可以有保存点,调用 rollback 语句时可以回到哪个点
但是这些特性都是在命令行操作事务的时候才可能遇到,通过 jdbc 需要参考相应的文档看看他们是如何操作事务的,日常的工作中也不太可能用到这些特性
事务的四大特性
满足下面这些条件的一系列操作才叫事务,我们对这一系列操作做限制是为了保证其准确性
原子性:事务中的动作要么全部完成,要么全部不起作用。不能出现转账转一半的情况
一致性: 执行事务后,数据库从一个正确的状态变化到另一个正确的状态。这个一致性状态确实像是对数据的约束,比如数据库中的高考分数不能从600变成1000,这方面的约束一般都是在后代码中做,因为在数据库用触发器检查约束实在太消耗性能了
隔离性:多条事务并发访问数据库时,一个用户的事务不被其他事务所干扰。举个例子,执行两次转账操作,某个账户中的钱只扣了一次,这种情况就是不同的事务相互影响了。我们的事务隔离级别就是针对隔离性做文章
持久性: 一个事务被提交之后,它对数据库中数据的改变是永久性的,不管是终止了还是正常执行了。在该事务提交之后,就算数据库出现了故障,该事务的结果也不应该有任何影响
并发事务会引发的问题
事务的隔离性具体来说会出现什么问题呢
脏读:当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。读取这种中间态的数据的行为被称做脏读
不可重复读:A 事务内多次读同一数据,期间 B 事务完成修改并且提交,导致 A 事务前后读同一数据的结果不相同。这种行为与脏读的区别是,脏读读的是中间态的数据,而不可重复读读到的都是满足一致性、正确的数据
幻读:一个事务读取了几行数据,接着另一个并发事务插入了一些数据,在随后的查询中,第一个事务就会发现多了一些原本不存在的记录
幻读更注重修改数据,不可重复读更注重读取数据
以上都是读取数据会发生的问题,都是 SQL 规范中定义的并发事务会引发的问题。但是对于并发数据安全的问题,还有其他人有不同的见解,比如在 a critique of ANSI SQL isolation levels 《 ANSI SQL隔离级别批判》这篇论文中,作者定义了其他类型的错误:
更新丢失:多个事务先后提交,后提交的事务覆盖了先提交的事务做的修改,解决这个问题就需要使用到锁
脏写:一个事务修改了另外一个事务未提交的修改数据
事务的隔离级别
一些人设定了 SQL 的隔离级别标准,以下隔离级别安全度从低到高,并行度从高到低,注意,SQL 的标准与各个数据库的实现是不一样的。以下的底层实现都是 innodb 对标准的实现
读未执行:允许读取尚未提交的数据变更,所有读问题都可能发生;底层实现是读操作时不加锁,写数据时加写锁(因此防止更新丢失等写问题)
读已执行:不允许读取尚未提交的事务数据变更,可解决脏读;底层实现对写操作加互斥锁,读操作进行MVCC
可重复读(默认):完成事务之前不能读取其他事务的修改,可解决不可重复读和脏读(在 Innodb 中支持 NextKey Lock 来防止出现幻读的情况);底层实现对写操作加互斥锁,读操作执行MVCC
串行化:事务依次逐个执行,所有的问题都可以解决;底层对所有读写操作全部加互斥锁
事务的状态
我们为了更好的管理了解事务的实现,应该 先分清事务有什么状态,发生问题的时候以及出现错误的时候我们最终应该将事务从什么状态变成什么状态
- 活动的:事务对应的数据库操作正常执行中,因为事务是一系列操作的集合,执行起来会花费写时间,执行的这段时间就是活动的状态
- 部分提交的:数据库已经在内存中完成所有的修改操作了,此时就是部分提交部分提交状态
- 提交的:当处于部分提交状态的事务向磁盘写入所有的修改数据之后,我们的事务就执行完毕了,此时处于提交状态
- 失败的:某个操作出现了错误,人为的终止了事务,或者服务器内部出现了什么错误,导致事务执行失败
- 中止的:出现错误之后需要回滚到事务执行之前,也就是撤销失败的事务对数据库造成的影响,当回滚操作执行完毕之后,事务进入中止状态
只有中止态与提交态才算一个事务执行完了
锁
锁的分类
默认情况下 innodb 每次事务都会自动加行级锁(但是也支持表级锁),因此悲观锁是自动使用的
行表
行级锁:对修改的行进行加锁,InnoDB 支持(只有命中索引的时候才可以使用行级锁,未命中时会升级成表级锁,所以 mysql 的行锁是基于索引加载的)。锁的实现是根据每一行数据生成对应的数据结构,用串行访问该数据结构的方式来实现锁,和 java 中类似
表级锁:对当前操作的整张表加锁
读写
共享锁:读锁,可以并发读取数据。加了读锁之后,其他事务可以对数据加读锁,但是不能加写锁。注意,是不能加锁,而不是不能读写
排他锁:写锁,可以修改数据,直到这个写锁被释放之前,任何事务都不能对这个被锁对象再加任何锁,也不能上意向锁。注意,是不能加锁,而不是不能读写
意向
以下两种锁都是表级锁:
- 意向共享锁(IS):表示事务准备给数据行记入共享锁
- 意向排他锁(IX):表示事务准备给数据行加入排他锁
这两个锁是配合行级锁来使用的。比如现在有一种情况,某个事务已经对一张表中的某行数据上了行级锁,此时另外一个事务需要对这张表上表级锁,而行级锁又是针对行来加锁的,因此此时我们需要遍历该表中的每一条数据,看看该数据是否上了行级锁,遍历完毕才能加表级锁,非常的影响性能
为了解决这个问题,我们可以在对行的数据上锁的同时,对该表的上一个意向锁,告诉需要对该表上表级锁的事务该表已经上锁
但是还有行级别的意向锁,叫插入意向锁,一个事务在插入时被其他的事务阻塞了,也需要生成对应的数据结构来记录改事务的插入操作,会在等待时生成对应的锁结构。该锁仅仅用来记录该事务有过插入的操作(我们不能使用自旋锁的方式消耗 CPU 的性能,毕竟 MySQL 允许同时插入的事务数比 CPU 的性能多多了),并不会阻止别的事务继续获取该记录上的任何类型的锁
AUTO-INC 锁
在为表添加自增列的时候,程序每次插入一条记录时都会为该表上这个自增锁,该锁有两种实现方式
- 表级写锁,在执行插入语句时增加一个表级锁,这样其他的事务在写入数据时 就会被阻塞,从而保证一个语句中分配的递增值是连续的。注意,这个 AUTO-INC 表级写锁的作用范围只是单个插入语句,不是事务
- 轻量级锁,在插入时为该表分配一个轻量级锁,与 java 中的轻量级锁相似,在不产生多个线程同时增加数据时,该轻量级锁只做为表记使用,在插入语句执行完毕后,该锁直接释放
隐式锁
隐式锁不是锁,只是一种根据现有的条件判断其他的事务对没对该记录进行操作的一种算法
在内存中生成锁结构并且维护他们不是一件零成本的事情,我们应该尽可能的优化性能,MySQL 的设计者发现一般情况下执行插入语句的时候不需要在内存中生成锁结构的,他们之前的设计已经可以避免其他事务判断该记录是否上锁了:
- 聚集索引有一个隐藏列 trx_id,该id 用于记录最后一次修改该表的事务 id,如果其他的事务想要对其修改的话,需要先判断一下该表中的 trx_id 所对应的事务是否是当前活跃事务
- 非聚集索引有一个 page_max_trx_id 的属性代表了最后一次对该表修改的已经提交的事务,如果该值小于当前事务 id,则代表是安全的
MySQL 用这些优化尽可能的增加程序的性能
行级锁算法
行锁又衍生了其他几种算法锁,分别是 记录锁、间隙锁、临键锁
记录锁:单个行记录上的锁,记录锁的出现条件必须是精准命中索引并且索引是唯一索引
间歇锁:间隙锁,锁定一个范围,需要有索引才能执行(它的危害是将这个范围都上锁,没有数据的叫间隙,也会被锁上),当我们查询数据用范围查询而不是相等条件查询时,查询条件命中索引,并且没有查询到符合条件的记录,临键锁的触发条件也是查询条件命中索引,不过,临键锁有匹配到数据库记录
临键锁:又叫 next-key Lock,mysql 的行锁默认就是使用的临键锁,临键锁是由记录锁和间隙锁共同实现的,锁定一个范围,包含记录本身
间隙锁所锁定的区间是一个左开右闭的集合,而临键锁锁定是当前记录的区间和下一个记录的区间,当修改的右边大于索引中的最大值时,则锁定的右区间为正无穷(mysql 中的最大值与最小值都有相应的数据机构来表示的,即 infimum 与 supremum)
锁结构
对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?如果一个事务要获取10000条记录的锁,就要生成10000个这样的结构,开销就太大了,InnoDB 在对不同记录加锁时,如果符合下边这些条件,那么这些记录的锁就可以被放到一个锁结构中:
- 在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的
MySQL 的页有对应的的数据结构、事务有对应但是数据结构,那锁的数据结构是什么样子的?下图就是锁的内存结构,简单介绍一下
- 锁所在的事务信息:是一个指针,指向了生成该锁的事务
- 索引信息:对行级锁来说,需要记录一下加锁的记录属于哪个索引
- 表锁/行锁信息,如果是表锁,则结构为表信息、其它信息,如果是行锁,则为 spaceID (记录所在的表空间)、Page Number (记录所在的页号)、n_bits (一条记录对应着一个比特)
- type_mode,一个32比特的数,不同的位数记录了不同的信息。根据位数分为了如下几个信息:
– loke_mode (锁模式)
– lock_type (锁类型,LOCK_TABLE 表示表锁,LOCK_REC 表示行锁)
– rec_lock_type (行锁的具体类型,LOCK_ORIDNARY 表示 next-key 锁,LOCK_GAP 表示 gap 锁,LOCK_INSERT_INTENTION 表示插入意向锁,LOCK_REC_NOT_GAP 表示正经记录锁,LOCK_WAIT为1时,表示等待状态,没获得锁)
- 其它信息:为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表
- 比特位:如果是行锁结构的话,在该结构末尾还放置了一堆比特位。页面中的每条记录在记录头信息中都包含一个 heap_no 属性,伪记录 Infimum 的 heap_no 值为0,Supremum 的 heap_no 值为1,之后每插入一条记录,heap_no 值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个 heap_no(这个设计挺奇怪的,为什么不在主键上用区域的模式记录信息。这么记录不是异常占用空间吗,一万条记录一万个比特)
MySQL 发生死锁的时候,会找出事务执行过程中插入、更新或者删除的记录条数较少的事务,对这个事务进行回滚
MVCC(多版本并发控制)
是一种按照时间控制数据具有多个版本来保证安全的协议,这种协议可以有效减少加锁的次数以提高性能
如果读取的行正在执行 DELETE 或 UPDATE 操作,这时读取操作不会去等待行上锁的释放。相反地,InnoDB 存储引擎会去读取行的一个快照数据
一致性非锁定读
每个数据库对一致性非锁定读的实现都不同,有一类最简单的实现如下
每一行数据有创建版本与删除版本,利用这两个版本号来保证并发事务安全
- 增:将新插入的行的创建版本号设置为当前系统的版本号
- 删:将要删除的行的删除版本号设置为当前系统的版本号
- 改:将旧行的删除版本号设置为当前版本号,并将新行插入同时设置创建版本号为当前版本号
- 查:该行的创建版本号小于等于当前版本号并且该行的删除版本号大于当前版本或者为空时,返回找到的数据,否则不返回
而 MVCC 就是一致性非锁定读的实现之一,MVCC 的优点就是,即便数据已经上了写锁,还是可以根据历史记录拿到自己需要的数据
锁定读
又叫当前读,读取的是数据的最新版本,锁定读会对读取到的记录加锁,如果执行的是下列语句,就是锁定读
select … lock in share mode(只有该操作加S锁)
select … for update
insert、update、delete 操作
四大隔离级别的修改操作都是锁定读,当执行修改数据的操作时,对记录加 X 锁(排他锁),且其它事务不能加任何锁
同时 InnoDB 在实现可重复读时,如果执行的是当前读,则会对读取的记录使用Next-key Lock,来防止其它事务在间隙间插入数据,防止了其他的事务幻读
MVCC的实现
MVCC 的实现依赖于隐藏字段、Read View、undo log
隐藏字段
Innodb 中每一行数据都有三个隐藏字段:
DB_TRX_ID:表示最后一次插入或更新该行的事务 id。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除
回滚指针:指向该行的 undo log 。如果该行未被更新,则为空
DB_ROW_ID:如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该id来生成聚簇索引,也就是 InnoDB 的默认主键,该属性与 MVCC 无关,不过还是介绍一下
undolog
在 InnoDB 存储引擎中 undo log 分为两种:insert undo log 和 update undo log:
insert undo log :指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除
update undo log :update 或 delete 操作中产生的 undo log。该 undo log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。该链表就是版本链
ReadView
Read View 主要是用来做可见性判断,生成一次 ReadView 里面会包含以下字段:
- m_ids:里面保存了当前对本事务不可见的其他活跃事务,简单来说,储存了数据库中除了当前事务以外正在执行的其他事务。该属性可以通过全局的记录直接获取(事务的状态)
- max_trx_id:下一次事务对应的事务 ID,该属性用于代表最大的事务 ID,只要是比该属性大的事务 ID,都是在生成该 ReadView 后才加上的,因此都是不可见的
- min_trx_id:m_ids 里最小的值。只要是比该值小的,都是在生成该 ReadView 之前已经生成的,都是可见的
- creator_trx_id:每开启一个事务都会生成一个 ReadView,而 creator_trx_id 就是这个开启的事务的 id
数据可见性算法
既然版本链中记录了所有的记录版本,我们只需要判断我们应该读取版本链中哪一条数据即可,我们使用可见性算法来判断:
在访问某条记录时,只需要按照下边的步骤判断该记录在版本链中的某个版本(trx_id)是否可见:
1、trx_id < m_ids 列表中最小的事务 id
表明生成该版本的事务在生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
2、trx_id > m_ids 列表中最大的事务 id
表明生成该版本的事务在生成 ReadView 后才生成,所以该版本不可以被当前事务访问。
3、m_ids 列表中最小的事务 id < trx_id < m_ids 列表中最大的事务 id
此处比如 m_ids 为 [5,6,7,9,10]
①、若 trx_id 在 m_ids 中,比如是6,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问
②、若 trx_id 不在 m_ids 中,比如是8:说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问
一句话说:当 trx_id 在 m_ids 中,或者大于 m_ids 列表中最大的事务 id 的时候,这个版本就不能被访问
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录
而 RR 和读已执行的区别就是:
- RR 在第一次执行 select 语句的时候生成一次 ReadView,并在这个事务中只使用这个快照版本,防止了在快照读时会出现的幻读;如果执行的是当前读,会通过行锁来防止幻读
- 读已执行每次执行 select 语句时都生成一次 ReadView,因此处理不了不可重复读的问题,这么做的目的主要为了减少加锁,提高性能