原子性(Atomicity)
一致性(Consistency)
隔离性(Isolation)
同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。(其对数据库的影响和他们串行执行时一样)
持久性(Durability)
1、脏读:某个事务已更新一份数据,另一个事务读取这个数据的过程中,前一个事务发生了回滚,则后一个事务所读取的数据就是脏数据。
2、不可重复读:一个事务范围内,多次查询某个数据,却得到不同的结果,这可能是两次查询过程中插入了一个事务更新了原有的数据。
3、幻读:事务A读取与搜索条件相匹配的若干行。事务B以插入或删除行等方式来修改事务A的结果集,然后再提交。(事务A中读取不到这个修改后的结果)
小结:不可重复读和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表。
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读取未提交 read-uncommitted(最低的隔离级别):允许读取尚未提交的数据变更。 | 是 | 是 | 是 |
读取已提交 read-committed:允许读取并发事务已经提交的数据。 | 否 | 是 | 是 |
可重复读 repeatable-read:对同一字段的多次读取结果都是一致的,除非数据是被本身事务所修改。 | 否 | 否 | 是 |
可串行化 serializable(最高的隔离级别):完全服从ACID的隔离级别,所有事务依次逐个执行,事物之间不会产生干扰。 | 否 | 否 | 否 |
MySQL默认的事务隔离级别为repeatable-read
1.脏读
开启两个窗口,其中关闭自动提交事务 set autocommit = 0切换隔离级别为read uncommitted
然后修改其中一张表的数据,但不提交事务,发现查询结果被改变;调用rollback后,结果恢复正常;
2.不可重复读
//切换隔离级别为read committed;
//其中一个窗口修改数据,先不提交,另一个窗口开启一个事务,做一次select *查询;
//第一个窗口提交事务,另一个窗口再做select *查询;
//会发现两次查询结果不同;
start transaction;//开启事务;
set session transaction isolation level read committed;
3.幻读
切换隔离级别为RR;
窗口1先开启事务,做一次select *查询,窗口2开启事务后,然后插入一条数据;
窗口2提交事务,窗口1再做一次查询,会发现此时并没有读取到这条新的记录,然后如果窗口1去做和窗口2一样的操作,会报错id已存在(幻读表现);这时对事务
1做rollback,再去窗口1查询,就能查到这条多出来的记录了;
//也就是说事务1在查询的时候,不受事务2的影响,这也是结果产生幻读的关键;
涉及到的原理,在文章最后会展开来讲,这里只是简单概述。
1.原子性
MySQL数据库事务的原子性是通过undo log实现的。(undo log还实现MVCC)
undo log实现原理:
事务开始之前,会先复制一份数据到undo log中,然后进行数据的操作;如果出现了错误或者用户执行了rollback语句,系统可以利用undo log中的备份将数据
恢复到事务开始之前的状态;
存放的是唯一键值(insert_undo类型)或者是(update_undo类型)唯一键值+old column的记录;(逻辑日志,相当于数据的一个备份日志)
2.一致性
原子性+隔离性+持久性来实现;
3.隔离性(重点)
隔离性实现原理:锁+MVCC;
3.1.读未提交:不加锁;
3.2.读已提交:读取的时候不加锁,写入、修改、删除的时候加排他锁;(解决脏读)
如下图是一个脏读场景,事务T2读取了T1未提交的数据。
//解决方案:read-committed:数据的读取不加锁,数据的写入、修改、删除需要加行锁,可以克服脏读,但无法避免不可重复读
使用加锁策略后,T1写数据x时,先获取了x的锁,导致T2的读操作等待,T1进行数据回滚后,释放锁,T2可以继续读取原来数据,不存在读取到脏数据的可能。 (write的锁没有释放之前,read读取不到数据)
MVCC(解决不可重复读)
下图是一个不可重复读的场景。由于T1的更新操作,导致T2两次读取的数据不一致。单纯加行锁是无法解决的,T2先读取x值,T1之后经过加锁、解锁步骤,更新x的值,
提交事务。T2再读的话,读出来的是T1更新后的值,两次读取结果不一致。
读的时候加共享锁,也就是其他事务可以并发读,但是不能写。写的时候加排它锁,其他事务不能并发写也不能并发读;(解决幻读)
//通过对读取到的每一行输出加锁,可能导致大量的超时问题;(比如以id为主键,对id做范围查询,例如查询id<=8的数据,那么我们插入一条id=9的数据,是可以插入的;)(临键锁)
4.持久性
MySQl数据库事务的持久性是通过redo log实现的。
事务的所有修改操作(增、删、改),数据库都会生成一条redo日志记录到redo log.区别于undo log记录SQL语句,redo log记录的是事务对数据库的哪个数据页
做了什么修改,属于物理日志(大小固定)。
//补充:binlog属于追加文件,可以不断开启新文件来存放数据内容;
redo日志应用场景:数据库系统直接崩溃,需要进行恢复,一般数据库都会使用按时间点备份的策略,首先将数据库恢复到最近备份的时间点状态,之后读取该时
间点之后的redo log记录,重新执行相应记录,达到最终恢复的目的。
主要日志:redo、undo、binlog;
主机Master把日志写到bin log二进制日志中,从机Slave去监控bin log日志,然后把数据写入到Relay log(中继日志)中,再读取到从机中;
1、undo(逻辑日志)
//原理:
在操作任何数据之前,首先将数据备份到一个地方(这个地方就是unod log),然后进行数据的修改。如果出现了错误或者用户执行了rollback,那就可以运用undo log中的备份来吧数据恢复到事务开始之前的状
态;
//数据类型:
undo日志用于存放数据修改被修改前的值,假设修改t表中 id=2的行数据,把Name='B' 修改为Name = 'B2' ,那么undo日志就会用来存放Name='B'的记录,如果这个修改出现异常,可以使用undo日志来实现回滚
操作,保证事务的一致性。
//1.insert undo log(事务回滚)
代表事务在 insert 新记录时产生的 undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃
//2.update undo log(事务回滚、快照读)
事务在进行 update 或 delete 时产生的 undo log ; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除
//逻辑概念理解:
事务的所有修改操作(增、删、改)的相反操作都会写入undo log,比如事务执行了一条insert语句,那么undo log就会记录一条相应的delete语句。所以undo log是一个逻辑文件,记录的是相应的SQL语句;一旦由
于故障,导致事务无法成功提交,系统则会执行undo log中相应的撤销操作,达到事务回滚的目的。
2、redo(物理日志)
//原理:
和undo日志相反,redo log记录的是新数据的备份;在事务提交前,只要将redo log持久化即可,不需要将数据持久化;
//写入特点:
Redo log 以顺序的方式写入文件,写满时则回溯到第一个文件,进行覆盖写。
//crash-safe
有了redo log日志,那么在数据库进行异常重启的时候,可以根据redo log日志进行恢复,也就达到了crash-safe。
//为什么不直接写到Mysql中去?
事务在提交写入磁盘前,会先写到redo log里面去。
1.这是因为数据写到Mysql中去,需要找到磁盘的Mysql的对应的页,涉及磁盘的随机I/O访问,涉及磁盘随机I/O访问是非常消耗时间的一个过程,相比这个时间,先写入redo log(顺序写、循环写),后面再找合适
的时机刷盘,能大大提升效率。(不用一边写一边等待)
2.另外,如果Mysql 进程异常重启了,系统会自动去检查redo log,将未写入到Mysql的数据从redo log恢复到Mysql中去。
redo和undo事务的提交的流程实例介绍:
Undo + Redo事务的简化过程
假设有A、B两个数据,值分别为1,2,开始一个事务,事务的操作内容为:把1修改为3,2修改为4,那么实际的记录如下(简化):
A.事务开始.
B.记录A=1到undo log.
C.修改A=3.
D.记录A=3到redo log.
E.记录B=2到undo log.
F.修改B=4.
G.记录B=4到redo log.
H.将redo log写入磁盘。//做redo log的持久化,事务就可以提交了
I.事务提交
undo log
和redo log
并不是直接写到磁盘,而是写入log buffer
。再等待合适的时机同步到OS buffer
,再由操作系统决定刷新到磁盘的时间。如图:
(log buffer在用户空间中,而os buffer在内核空间中)
MySQL主要由三种日志刷新策略,默认为第一种。三种策略,安全性依次下降,效率依次上升
(参数中分别对应1、2、0)
每次事务提交写入OS buffer,并调用fsync刷新到磁盘
每次提交写入OS buffer,然后每秒调用fync刷新到磁盘
每秒写入OS buffer,并调用fsync刷新到磁盘
//定期再将内存中修改后的数据刷新到磁盘
binlog是属于MySQL Server层面的,又称为归档日志,属于逻辑日志,是以二进制的形式记录的是这个语句的原始逻辑,依靠binlog是没有crash-safe能力的;
从库生成两个线程,一个I/O线程,一个SQL线程;
i/o线程去请求主库的binlog,并将得到的binlog日志写到relay log(中继日志)文件中;
主库会生成一个log dump 线程,用来给从库i/o线程传binlog;
SQL线程,会读取relay log文件中的日志,并解析成具体操作,来实现主从的操作一致;
//主机只能有1台,从机可以有多台;
//最大问题是延时;
- 所有已经提交的事务的数据仍然存在。
- 所有没有提交的事务的数据自动回滚。//很明显,binlog不具备crash-safe的能力。
redo log的存在使得数据库具有crash-safe能力,即如果Mysql 进程异常重启了,系统会自动去检查redo log,将未写入到Mysql的数据从redo log恢复到Mysql中去。 、
当数据库发生异常重启时,系统会自动定位到上次checkpoint的位置,同时,每个数据页中也存在一个LSN,当redo log中的LSN大于数据页中的LSN时,说明重启前redo log中的数据未完全写入数据页中,那么将从数据页中记录的LSN开始,从redo log中恢复数据。
比如redolog 的LSN 是 13000,数据库页的LSN是 10000,那么说明重启前有部分数据未完全刷入到磁盘的数据页中,那么系统将会恢复redo log 中LSN从10000开始到13000的记录到数据页中。
从库生成两个线程,一个I/O线程,一个SQL线程;
i/o线程去请求主库的binlog,并将得到的binlog日志写到relay log(中继日志)文件中;
主库会生成一个log dump 线程,用来给从库i/o线程传binlog;
SQL线程,会读取relay log文件中的日志,并解析成具体操作,来实现主从的操作一致;
//主机只能有1台,从机可以有多台;
//最大问题是延时;
1.redo log是属于innoDB层面,binlog属于MySQL Server层面的,这样在数据库用别的存储引擎时可以达到一致性的要求。
2.binlog是逻辑日志,而redo log是物理日志,大小固定;
3.redo log是循环写,日志空间大小固定;binlog是追加写,是指一份写到一定大小的时候会更换下一个文件,不会覆盖。
4.binlog可以作为恢复数据使用,主从复制搭建;redo log作为异常宕机或者介质故障后的数据恢复使用。
执行流程:
1)执行器调用存储引擎接口,存储引擎将修改更新到内存中后,将修改操作写到redo log里面,此时redo log处于prepare状态;
2)存储引擎告知执行器执行完毕,执行器开始将操作写入到bin log中,并把 binlog 写入磁盘,写完后提交事务;
3)提交事务后,存储引擎将redo log的状态置为commit。
binlog 存在于Mysql Server层中,主要用于数据恢复;当数据被误删时,可以通过上一次的全量备份数据加上某段时间的binlog将数据恢复到指定的某个时间点的数据。
redo log存在于InnoDB引擎中,InnoDB引擎是以插件形式引入Mysql的,redo log的引入主要是为了实现Mysql的crash-safe能力。
假设redo log和binlog分别提交,可能会造成用日志恢复出来的数据和原来数据不一致的情况。
1)假设先写redo log再写binlog,即redo log没有prepare阶段,写完直接置为commit状态,然后再写binlog。那么如果写完redo log后Mysql宕机了,重启后系统自动用redo log 恢复出来的数据就会比binlog记录的数据多出一些数据,这就会造成磁盘上数据库数据页和binlog的不一致,下次需要用到binlog恢复误删的数据时,就会发现恢复后的数据和原来的数据不一致。
2)假设先写binlog再写redolog。如果写完bin log后Mysql宕机了,那么binlog上的记录就会比磁盘上数据页的记录多出一些数据出来,下次用binlog恢复数据,就会发现恢复后的数据和原来的数据不一致。
由此可见,redo log和binlog的两阶段提交是非常必要的。
锁的分类:
- 按照粒度划分:行锁、表锁、页锁
- 按照锁级别划分:共享锁、排他锁
- 按照使用方式划分:悲观锁、乐观锁
- 按照区间划分:记录锁、间隙锁、临键锁(了解)
按照粒度划分:MySQL有三种锁的级别:页级、表级、行级。
//全表加锁;
select * from student where name = 'tom' for update; //name列不是主键;
行级锁:开销大,加锁慢;会出现死锁;锁de 粒度最小,发生锁冲突的概率最低,并发度也最高。
InnoDB引擎绝大多数情况下使用的是行级锁(给索引加锁,如果没有索引,那么会给所有数据加锁);
MyISAM只支持表锁;
//行锁;给索引上锁;
select * from student where id > 10 for update;//id列是主键;
按照锁级别划分:共享锁(读锁)、排他锁(写锁)。
共享锁又称为读锁,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
排他锁又称为写锁,排他锁就是不能与其他锁并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的锁,包括共享锁和排他锁。获取排他锁的事务是可以对数据读取和修改。(注意:如果此时MySql扫描的是全表,比如不是对索引列用where条件查询,则此时锁的是整张表)
注意:对于select语句,InnoDB不会加任何锁,也就是可以多个并发去进行select的操作,不会有任何的锁冲突,因为根本没有锁。对于insert,update,delete操作,InnoDB会自动给涉及到的数据加排他锁,只有查询select需要我们手动设置排他锁。
按照使用方式划分:悲观锁、乐观锁。
悲观锁(Pessimistic Lock)
悲观锁的特点是先获取锁,再进行业务操作,即“悲观”的认为获取锁是非常有可能失败的,因此要先确保获取锁成功再进行业务操作。通常来讲在数据库上的悲观锁需要数据库本身提供支持,即通过常用的select … for update操作来实现悲观锁。当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。select for update获取的行锁会在当前事务结束时自动释放,因此必须在事务中使用。
这里需要注意的一点是不同的数据库对select for update的实现和支持都是有所区别的,例如oracle支持select for update no wait,表示如果拿不到锁立刻报错,而不是等待,MySQL就没有no wait这个选项。另外MySQL还有个问题是select for update语句执行中所有扫描过的行都会被锁上,这一点很容易造成问题。因此如果在MySQL中用悲观锁务必要确定走了索引,而不是全表扫描。
乐观锁(Optimistic Lock)
乐观锁,也叫乐观并发控制,它假设多用户并发的事务在处理时不会彼此互相影响。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,那么当前正在提交的事务会进行回滚。
乐观锁的特点先进行业务操作,不到万不得已不去拿锁。即“乐观”的认为拿锁多半是会成功的,因此在进行完业务操作需要实际更新数据的最后一步再去拿一下锁就好。
实现步骤:
乐观锁在数据库上的实现完全是逻辑的,不需要数据库提供特殊的支持。一般的做法是在需要锁的数据上增加一个版本号,或者时间戳,然后按照如下方式实现:
因此在业务操作进行前获取需要锁的数据的当前版本号,然后实际更新数据时再次对比版本号确认与之前获取的相同,即可确认这之间没有发生并发的修改。如果不同则认为获取锁失败,需要回滚整个业务操作。
按照区间划分:记录锁、间隙锁、临键锁。
//行锁在 InnoDB 中是基于索引实现的,所以一旦某个加锁操作没有使用索引,那么该锁就会退化为表锁。
1.记录锁(Record Locks)
记录锁就是为某行记录加锁,它封锁该行的索引记录:
-- id 列为主键列或唯一索引列
SELECT * FROM table WHERE id = 1 FOR UPDATE;//加行锁;
UPDATE SET age = 50 WHERE id = 1;//或者这样写
id 为 1 的记录行会被锁住。
需要注意的是:id 列必须为唯一索引列或主键列,否则上述语句加的锁就会变成临键锁。
同时查询语句必须为精准匹配(=),不能为 >、<、like等,否则也会退化成临键锁。
2.间隙锁(Gap Locks)
间隙锁基于非唯一索引,它锁定一段范围内的索引记录。间隙锁基于下面将会提到的Next-Key Locking 算法,请务必牢记:使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。
SELECT * FROM table WHERE id BETWEN 1 AND 10 FOR UPDATE;
即所有在(1,10)区间内的记录行都会被锁住,所有id为 2、3、4、5、6、7、8、9的数据行的插入会被阻塞,但是1和10两条记录行并不会被锁住。(前提是1和10)
3.临键锁(Next-Key Locks):临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁。
每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。
该表中 age 列潜在的临键锁有:
(-∞, 10],
(10, 24], //其实就是间隙锁(10,24)+记录锁24;
(24, 32],
(32, 45],
(45, +∞],
-- 根据非唯一索引列 UPDATE 某条记录
UPDATE table SET name = Vladimir WHERE age = 24;
-- 或根据非唯一索引列 锁住某条记录
SELECT * FROM table WHERE age = 24 FOR UPDATE;
不管执行了上述 SQL 中的哪一句,之后如果在事务 B 中执行以下命令,则该命令会被阻塞:
INSERT INTO table VALUES(100, 26, 'Ezreal');
很明显,事务 A 在对 age 为 24 的列进行 UPDATE 操作的同时,也获取了 (24, 32] 这个区间内的临键锁。
那最终我们就可以得知,在根据非唯一索引对记录行进行UPDATE \ FOR UPDATE \ LOCK IN SHARE MODE 操作时,InnoDB 会获取该记录行的临键锁,并同时获取该记录行下一个区间的间隙锁。
即事务 A在执行了上述的 SQL 后,最终被锁住的记录区间为 (10, 32]。实际上就是(10,24]+(24,32];
//总结:
InnoDB 中的行锁的实现依赖于索引,一旦某个加锁操作没有使用到索引,那么该锁就会退化为表锁。
记录锁存在于包括主键索引在内的唯一索引中,锁定单条索引记录。(精准匹配)
间隙锁存在于非唯一索引中,锁定开区间范围内的一段间隔,它是基于临键锁实现的。(未匹配上索引,由临键锁降级而得,区间的大小由最近的两个索引位置决定)
//这里匹配和未匹配的意思,就是指在指定的范围中,是否由对应值匹配;
临键锁存在于非唯一索引中,该类型的每条记录的索引上都存在这种锁,它是一种特殊的间隙锁,锁定一段左开右闭的索引区间。(匹配上)
快照读:
提高读-写或写-读场景的并发性,只对写操作进行加锁,读操作读取当前事务的可见版本(不加锁),通过MVCC实现,每个事务读取的是当前事务的可见版本(视图)。
//详解快照图在RC和RR下的区别:
在RC隔离级别下,每个快照读都会生成并获取最新的Read View,而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View(也即每次查询的版本数据都
是一样的);(所以在RC隔离级别下,存在不可重复读的问题)
当前读:(当前读实际上是一种加锁的操作,是悲观锁的实现)
读和写都加锁,读取的是记录的最新版本,通过next-key临键锁(行记录锁+间隙锁)实现。当我们开启一个事务,并且在事务中查询时加select...lock in share mode或for update时,以及insert、update、
delete操作,会使用next-key锁定查询范围里对应的行数,并且该锁是排他锁,其他事务排队等待。
只支持读已提交和可重复读两个隔离级别;
MVCC中的读指的就是快照读;
mvcc的实现原理主要依赖于三部分:隐藏于记录的三个字段;undo log;read view;
DB_TRX_ID//6字节 最近一次修改的事务ID
DB_ROLL_PTR//7字节 回滚指针,指向这条记录的上一个版本,用于配合undo log(是一个地址,指向上一个版本)
DB_ROW_ID//6字节 隐藏主键,如果数据表没有主键,那么innodb会自动生成一个6字节的row_id
Read View 就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID (当每个事务开启时,都会被分
配一个 ID , 这个 ID 是递增的,所以越新的事务,ID 值越大)
所以我们知道 Read View 主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最
新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(即当前事务 ID )取出来,与系统当前其他活跃事务的 ID 去对比(由 Read View 维护),如果 DB_TRX_ID 跟 Read
View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特
定条件的 DB_TRX_ID , 那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本
read view的可见性规则:
Read View中的三个全局属性:
//trx_list
一个数值列表
用于维护 Read View 生成时刻系统 正活跃的事务 ID 列表
//up_limit_id
lower water remark
是 trx_list 列表中事务 ID 最小的 ID
//low_limit_id
hight water mark
ReadView 生成时刻系统尚未分配的下一个事务 ID ,也就是 目前已出现过的事务 ID 的最大值 + 1
为什么是 low_limit ? 因为它也是系统此刻可分配的事务 ID 的最小值
//比较规则:
首先比较 DB_TRX_ID < up_limit_id , 如果小于,则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断
接下来判断 DB_TRX_ID >= low_limit_id , 如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains (DB_TRX_ID),如果在,则代表在 Read View 生成时刻,你这个事务还在活跃,还没有 Commit,你修改的数据,我当前事务也是看不见的;如果不
在,则说明,你这个事务在 Read View 生成之前就已经 Commit 了,你修改的结果,我当前事务是能看见的