注:本文主要论述了MySQL事务的ACID特性及数据库并发场景下容易产生的脏读、不可重复读、幻读问题,四种隔离级别的详细介绍等。
创作不易,希望大家一键三连支持!!!♥♥♥
创作不易,希望大家一键三连支持!!!♥♥♥
创作不易,希望大家一键三连支持!!!♥♥♥
一. MySQL事务
1.1 定义
事务
就是一组原子性的SQL查询
(一个独立的工作单元),事务内
的语句,要么全部执行成功
,要么全部执行失败
,没有介于两者的中间状态。
1.2 特性
事务有4大特性,称为ACID
特性:原子性
(atomicity)、一致性
(consistency)、隔离性
(isolation)、持久性
(durability)。
原子性:一个事务
必须被视为一个不可分割
的最小工作单元
,整个事务中的所有操作
要么全部提交成功
,要么全部提交失败
,对于一个事务来说,不可能只执行其中的一部分操作。
注:
(1) MySQL的原子性是通过undo log来实现的。它是InnoDB存储引擎特有
的,实现机制为:把所有对数据的修改(增/删/改)都写入日志(undo log)。
(2)undo log
是逻辑日志
,可以理解为:记录和事务操作相反的SQL语句,事务执行insert语句,undo log就记录delete语句。它以追加的方式写入日志,不会覆盖之前的日志。
(3)若一个事务中的部分操作成功
,但部分操作
由于断电、系统崩溃等原因导致无法成功执行
,那么就能通过回溯日志
,将已执行成功的操作撤销,从而达到全部操作失败
的目的。
一致性:事务
应确保数据库的状态
从一个一致状态
转变为另一个一致状态
。无论事务
是提交
还是回滚
,都不会破坏数据的完整性
。如A给B充值50元,如果事务提交成功,A的账户必然少50元,而B的账户必然会多50元;而如果提交失败了,那么将会全部不执行,则A和B的账户余额均不会变。其中A和B的账户余额变动必然是一个完整的过程
(不会出现A少30元,B多30元的中间状态),整个过程必须一致
。
隔离性:通常来说,一个事务
所做的修改
在最终提交以前
,对其他事务
是不可见
的(后续在考虑事务隔离级别时会遇到在提交前可见的情况!)
持久性:一旦事务提交
,则其所做的修改
就会永久保存到数据库
中。此时即使系统崩溃,修改的数据也不会丢失。
注:
(1)MySQL的持久性是通过redo log来实现的。它也是InnoDB存储引擎特有
的,实现机制为:当发生数据修改(增/删/改)时,InnoDB会先把记录写到redo log中,并更新内存,同时InnoDB会在何时时机将记录刷写到磁盘中。
redo log
是物理日志
,记录
的是在某个数据页做了什么修改
,而不是SQL语句
的形式。它有固定大小
,是循环写
的方式写入日志
,空间用完
后会覆盖之前的日志
。
undo log
和redo log
并非直接写入磁盘,而是先写入log buffer
,再等待由操作系统
决定何时刷写到磁盘
中,具体流程如下:
从上图我们不免能发现两种log文件
都从log buffer
到OS buffer
,再到磁盘
。故其中间过程还是可能因各种原因导致日志丢失。为此MySQL提供了3种持久化的方式:涉及到一个参数innodb_flush_log_at_trx_commit
,该参数控制InnoDB将logbuffer中的数据写入OS buffer
,并刷写
到磁盘
的时间
点,取值为0/1/2,默认是1,三个值分别代表如下:
MySQL默认设置的方式1,也就是每次提交后直接写入OS buffer,并且调用系统函数fsync()把日志写到磁盘上
。就保证数据一致性的角度
来说,这种方式是最安全
的。但安全大多数时候意味着效率偏低。每次提交都直接写入OS buffer并且写到磁盘,无疑会导致单位时间内IO的次数过多而效率低下。除此之外,还有方式0
和方式2
。基本上都是每秒写入磁盘一次
,所以效率都比方式1更高。但是方式0
是把数据先写入log buffer再写入OS buffer再写入磁盘
,而方式2
是直接写入OS buffer,再写入磁盘
,少了一次数据拷贝的过程(从log buffer到OS buffer),所以方式2比方式0更加高效。
了解了undo log和redo log的作用和实现机制之后,那么这两个日志具体是怎么让数据库从异常的状态恢复到正常状态的呢?(回答
持久性中即使系统崩溃,修改的数据也不会丢失的原因
?)
数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个crash recovery的过程:首先读取redo log,把成功提交但是还没来得及写入磁盘的数据重新写入磁盘,保证了持久性。再读取undo log将还没有成功提交的事务进行回滚,保证了原子性。crash recovery结束后,数据库恢复到一致性
状态,可以继续被使用
一个实现了
ACID特性
的DB
,相比没有实现ACID的DB,通常会需要更强的CPU处理能力
、更大内存
及更多磁盘空间
。
1.3 隔离级别
SQL定义了4种隔离级别,每种级别
都规定了一个事务中所做的修改
,哪些在事务内
和事务间
是可见
的,哪些是不可见
的。较低级别的隔离
通常可以执行更高的并发
,系统的开销也更低
。
如下介绍了4大隔离级别:
READ UNCOMMITTED(未提交读):事务中的修改
,即使未提交
,对其他事务
也都是可见的
,常常会引发脏读
(一个事务读取到另一个事务还未提交的数据
)。
READ COMMITTED(提交读):一个事务从开始到提交之前
,做的任何修改对其他事务都是不可见的
。也叫不可重复读
,因为两次执行同样的查询,得到的结果可能不一样。
REPEATABLE READ(可重复读):保证在同一个事务中多次读取同样的记录的结果是一致的
。该级别解决了脏读
的问题,但无法解决幻读
(某个事务在读取某个范围内的记录时,另一事务又在该范围内插入了新的记录,当之前的事务再次读取该范围内的记录时,会产生幻行
)。该级别同时也是MySQL的默认事务隔离级别
。
SERIALIZABLE(可串行化):最高的隔离级别。通过强制事务串行执行
,避免了幻读
问题。该级别会在读取的每一行数据上都加锁
,故可能导致大量超时
和锁争用
。实际场景只有在非常需要确保数据一致性且可以接受没有并发的情况下才去使用。
RC
级别解决了脏读
问题,RR
级别解决了不可重复读
问题,SERIALIZABLE
级别可以解决幻读
问题。
下面将依次介绍脏读、不可重复读、幻读:
脏读(一个事务读取到了另一个事务还未提交的数据):
上图可以看到,开启A、B两个事务,其中B事务在A事务还未提交之前进行了查询,得到了A事务还未提交的更新。
不可重复读(一个事务读到了另一个事务已提交的更新数据):
如上图,事务B在T2和T5都执行了查询工资的操作,但是两次得到的结果不一样,同一事务种的同样多次查询,每次都返回不一样的结果,这在开发中绝不允许。
幻读(一个事务读到了另一个事务已提交的新增数据):
如上图,事务B在同一个事务中执行两次统计操作之间,A事务insert了一条记录,导致得到的结果不一样,好像发生了幻觉。还有一种情况是事务B更新了表中所有记录的某一字段,之后事务A又插入了一条记录,事务B再去查询发现有一条记录没有被更新,这也是幻读。
脏读、不可重复读的区别?
脏读
:指读到
了其他事务未提交
的数据
.
不可重复读
:读到
了其他事务已提交
的数据(update)
.
不可重复读和幻读的区别?
不可重复读与幻读都是读到其他事务已提交
的数据
,但是它们针对点不同.
不可重复读
:update(更新)
幻读
:delete,insert(插入/删除)
1.4 MySQL的默认RR隔离级别
为了证明RR级别并不能解决幻读问题,我们如下演示一个实例:
首先创建一张简单表test1,包含两个字段:id和salary,均为int类型。
create table test1(id int,salary int);
之后向表中插入3条记录:
insert into test1(id,salary) values(1,8800);
insert into test1(id,salary) values(2,9500);
insert into test1(id,salary) values(3,6600);
查看表中所有记录:
select * from test1;
之后我们开启两个事务A和B,具体事务流程如下:
按照之前的结论,会出现幻读
现象,也就是事务A
在T5时间段
的查询select
会看到事务B提交的新增数据
,而查询结果却如下图所示:
和预期结果并不一致,没有出现幻读?
事实上虽然MySQL在RR隔离级别下虽然没有解决幻读问题,但是它却采用了另外一种机制即MVCC(Multiversion Concurrency Control,多版本并发控制)去解决了select普通查询
下的幻读现象
。
实现方式为:事务开始
时,第一条select
语句查询结果集
会生成一个快照
,并且这个事务结束前
,同样的select语句
返回的都是这个快照的结果
,而不是最新的查询结果,这就是MySQL在RR隔离级别对普通select语句
使用的快照读。
快照读和MVCC有什么关系?
MVCC
是多版本并发控制,快照
就是其中的一个版本
。所以可以说MVCC实现了快照读
,具体的实现方式
涉及到MySQL的隐藏列
。MySQL会给每个表自动创建三个隐藏列如下:
DB_TRX_ID:事务ID
,记录操作(增、删、改)该数据事务
的事务ID
DB_ROLL_PTR:回滚指针
,记录上一个版本的数据在undo log中的位置
DB_ROW_ID:隐藏ID
,创建表没有合适的索引作为聚簇索引时,会用该隐藏ID创建聚簇索引
由于undo log中记录了各个版本的数据,并且通过DB_ROLL_PTR(回滚指针
采用了类似链表
的数据结构,即表头
是当前最新的历史记录
,表尾
是当前最旧的历史记录
)可以找到各个历史版本,并且由DB_TRX_ID决定使用哪个版本(快照)。所以相当于undo log实现了MVCC,MVCC实现了快照读。
这么看来,MySQL在RR隔离级别下利用快照读,解决了幻读的问题,事实是如此么?
接下来我们再来看一个例子:
事务A
在T2的时候生成快照
,事务B
在T3的时候插入一条id为5的数据
,然后提交
。在T5的时候事务A把id为5的salary更新成5678
,按照上例,此时事务A是看不到id为5的这条数据的,所以更新也不会成功
,并且在T6的时候查询,和T2时候一样,只有id=1~4的共4条数据。
但是,执行的结果
却是上图所示
,不仅看到了id为5的记录,而且其salary还被成功的改成了5678。即使事务A成功commit之后,再次查询还是这样。
这其实是MySQL
对insert、update和delete语句
所使用的当前读(current read)。因为涉及到数据的修改
,所以MySQL必须拿到最新的数据
才能修改,所以涉及到数据的修改会用当前读。由于事务A读到了事务B已提交的新增数据
,所以就产生了
前文所说的幻读
。
那么在RR隔离级别是怎么解决幻读的呢?
是通过间隙锁(Gap Lock)来解决的。InnoDB支持行锁,并且行锁是锁住索引。而间隙锁用来锁定索引记录间隙,确保索引记录的间隙不变
。间隙锁
是针对事务隔离级别为RR或以上级别
而设的,间隙锁和行锁一起组成了Next-Key Lock。当InnoDB扫描索引记录的时候,会首先对索引记录加上行锁,再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙插入记录。这样就有效地防止了幻读的发生
。
默认情况
下,InnoDB
工作在RR的隔离级别
下,并且以Next-Key Lock的方式对索引行进行加锁。当查询的索引具有唯一性(主键、唯一索引)时
,Innodb存储引擎会对Next-Key Lock进行优化,将其降为行锁
,仅仅锁住索引本身
,而不是范围(除非锁定不存在的值)。若是普通索引,则会使用Next-Key Lock将记录和间隙一起锁定。
使用快照读
的查询语句:
select * from ...
使用当前读
的语句:
select * from ... lock in share mode
select * from ... for update
insert into table ...
update table set ...
delete table where ...