1、事务基础概念
1.1、什么是事务
引用百度百科上的一段话: 事务(Transaction),一般是指要做的或所做的事情。在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(Unit)。事务通常由高级数据库操纵语言或编程语言(如 SQL,C 或 Java)书写的用户程序的执行所引起,并用形如 begin transaction 和 end transaction 语句(或函数调用)来界定。事务由事务开始(begin transaction)和事务结束(end transaction)之间执行的全体操作组成。
通俗的来讲,就是我们要做一件事,但是这件事分成几个步骤,要么这件事做完,要么这件事不做才算,只有开始和结束两个状态,没有中间状态。
1.2、事务的四个特性(ACID)
原子性(atomicity)。一个事务是一个不可分割的工作单位,事务中包括的操作要么都做,要么都不做。
一致性(consistency)。事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
隔离性(isolation)。一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。(你插入数据不能影响我查询数据,在我事务还没结束的时候必须保证我事务开始和结束的数据是一样的,也就是一致性。)
持久性(durability)。持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
1.3、事务并发产生的问题
更新丢失:两个事务同时修改同一条数据,然后后面一个事务提交覆盖了前面一个事务的提交,如果第一个事务在事务还没结束时再次查询就会发现自己的更新丢失了。
借用一个例子: 设想银行发生丢失更新现象,例如一个用户账号中有10 000元人民币,他用两个网上银行的客户端分别进行转账操作。第一次转账9000人民币,因为网络和数据的关系,这时需要等待。但是这时用户操作另一个网上银行客户端,转账1元,如果最终两笔操作都成功了,用户的账号余款是9999人民币,第一次转的9000人民币并没有得到更新,但是在转账的另一个账号却会收到这9000元,这导致的结果就是钱变多,而账不平。
脏读:脏读读的是未提交数据。A 事务可以读取到 B 事务中未提交的数据。
不可重复读:不可重复读读取到的是已提交数据。不可重复读在一个事务中对某个集合的数据进行多次读取,然后另外一个事务同时对这个数据集合进行删除和修改操作,导致当前事务多次读取的数据是不一致的。
幻读:事务 A 按照一定条件进行数据读取, 期间事务 B 插入了相同搜索条件的新数据,事务 A 再次按照原先条件进行读取时,发现了事务 B 新插入的数据。
PS:简单来说,事务的执行不能脱离事务的四个特性。
1.4、事务的隔离级别
- 读未提交(READ UNCOMMITTED):顾名思义,就是一个事务可以读取另一个未提交事务的数据。
- 读已提交 (READ COMMITTED):就是一个事务读取到其他事务提交后的数据。解决了脏读问题。
- 可重复读(REPEATABLE READ):就是一个事务对同一份数据读取到的相同,不在乎其他事务对数据的修改。解决了脏读、不可重复读问题(MySQL中幻读使用Next-Key Lock算法解决了)。
- 序列化读(串行读)(SERIALIZABLE):事务串行化执行,隔离级别最高,牺牲了系统的并发性。解决了脏读、幻读、不可重复读问题。
1.5、MySQL 事务
以下内容都是 InnoDB 数据库引擎的相关概念。
1.5.1、MySQL 零散概念
1.5.1.1、MySQL 锁概念
行级锁:共享锁(S锁)和排它锁(X锁)
两种锁的兼容性:
代码语言:markdown复制 X S
X 不兼容 不兼容
S 不兼容 兼容
X锁和任意锁都不兼容
意向共享锁(IS):事务想要获取表中某几行的共享锁 意向排它锁(IX):事务想要获取表中某几行的排它锁
兼容性:
代码语言:text复制 IS IX S X
IS 兼容 兼容 兼容 不兼容
IX 兼容 兼容 不兼容 不兼容
S 兼容 不兼容 兼容 不兼容
X 不兼容 不兼容 不兼容 不兼容
可以发现意向锁之间相互兼容,而 X 锁还是与任意锁都不兼容,意向排它锁和 S 锁不兼容(想要对细粒度上锁必须先将粗粒度上锁)。
快照读:简单的 select 操作,没有 lock in share mode 或 for update ,快照读不会加任何的锁,而且由于 MySQL 的一致性非锁定读的机制存在,任何快照读也不会被阻塞。
当前读:读取的是记录数据的最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发的修改这条记录。也就是 insert ,update ,delete ,select..in share mode 和 select..for update ,当前读会在所有扫描到的索引记录上加锁,不管它后面的 where 条件到底有没有命中对应的行记录。
一致性的非锁定读:是指 InnoDB 通过多版本控制的方式来读取当前执行时间数据库中行的数据。如果读取的行正在进行 delete 或者 update 操作,这是读取操作不会因此会等待锁的释放,相反的,InnoDB 会去读取行的一个快照数据。
多版本并发控制(MMVC):快照数据是当前行数据的一个历史版本,每行记录可能有多个版本。一个行记录可能不止一个快照数据,一般称这种技术为行多版本技术,由此带来的并发控制,也称之为多版本并发控制。
对于读已提交和可重复读的隔离级别中,InnoDB默认使用都是一致性的非锁定读,但是对于快照的定义却不相同。在读已提交中,一致性的非锁定读总是读取最新的快照数据。而对于可重复读来说,读的总是事务开始时的行数据版本。
行锁的三种算法:
- Record Lock(单个行记录上的锁):单条索引记录上加锁,Record Lock 锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么 InnoDB 会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。所以说当一条sql没有走任何索引时,那么将会在每一条聚集索引后面加 X 锁,这个类似于表锁,但原理上和表锁应该是完全不同的。
- Gap Lock(间隙锁,锁定一个范围,但不包括记录本身):在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录本身。
- Next-Key Lock( Record Lock Gap Lock ,锁定一个范围,包括记录本身):Next-Key Lock 是结合了 Gap Lock 和Record Lock 的一种锁定算法,在 Next-KeyLock 算法下,InnoDB 对于行的查询都是采用这种锁定算法。例如一个索引有10,11,13 和 20 这四个值,那么该索引可能被 Next-Key Locking 的区间为:(-∞,10]、[10,11]、[11,13]、[13,20]、[20, ∞)
锁升级:锁升级(Lock Escalation)是指将当前锁的粒度降低。举例来说,数据库可以把一个表的1000个行锁升级为一个页锁,或者将页锁升级为表锁。如果在数据库的设计中认为锁是-种稀有资源,而且想避免锁的开销,那数据库中会频繁出现锁升级现象。
1.5.1.2、如何手动加锁
-- mysql 加共享锁 select ... lock in share mode;
-- mysql 加排它锁 select ... for update;
1.5.1.3、脏页与脏数据
脏页指的是在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的,当然在刷新到磁盘之前,日志都已经被写人到了重做日志文件中。
而所谓脏数据是指事务对缓冲池中行记录的修改,并且还没有被提交(commit)。脏读读的就是脏数据。
1.5.1.4、redo 与 undo
redo:
redo 也叫做重做日志,它用来实现事务的持久性,其由两部分组成:重做日志缓冲(易丢失)和重做日志文件(持久)。
在事务提交时,必须先将该事务的所有日志文件写到重做日志文件进行持久化,待事务的commit操作完成才算完成。
为了确保每次日志都写入重做日志文件,在每次将重做日志缓冲写人重做日志文件后,InnoDB 存储引擎都需要调用-次 fsync操作。由于重做日志文件打开并没有使用 O_DIRECT 选项,因此重做日志缓冲先写人文件系统缓存。为了确保重做日志写入磁盘,必须进行一次 fsync 操作。由于 fsync 的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。
InnoDB 存储引擎允许用户手工设置非持久性的情况发生,以此提高数据库的性能。即当事务提交时,日志不写人重做日志文件,而是等待一个时间周期后再执行 fsync 操作。由于并非强制在事务提交时进行一次 fsync 操作,显然这可以显著提高数据库的性能。但是当数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的事务。
参数 innodb_flush_log_at_trx_commit 用来控制重做日志刷新到磁盘的策略。该参数的默认值为1,表示事务提交时必须调用一次 fsync 操作。还可以设置该参数的值为0和2。0表示事务提交时不进行写人重做日志操作,这个操作仅在 master thread中完成,而在 master thread 中每1秒会进行一次重做日志文件的 fsync 操作。2表示事务提交时将重做日志写人重做日志文件,但仅写人文件系统的缓存中,不进行 fsync 操作。在这个设置下,当 MySQL 数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失。而当操作系统宕机时,重启数据库后会丢失未从文件系统缓存刷新到重做日志文件那部分事务。
undo:
帮助事务回滚以及 MVCC 功能。
如果用户执行的事务或者语句由于某种原因失败了,又或者用户一条 rollback 语句请求回滚,就可以利用 undo 信息将数据回滚到修改前的样子。
用户通常对 undo 有这样的误解:undo 用于将数据库物理地恢复到执行语句或事务之前的样子,但事实并非如此。undo 是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。
当 InnoDB 存储引擎回滚时,它实际上做的是与先前相反的工作。对于每个 insert ,InnoDB 存储引擎会完成一个 delete ;对于每个 delete ,InnoDB 存储引擎会执行一个 insert ;对于每个 update ,InnoDB 存储引擎会执行一个相反 update ,将修改前的行放回去。
除了回滚操作,undo 的另一个作用是 MVCC ,即在 InnoDB 存储引擎中 MVCC 的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过 undo 读取之前的行版本信息,以此实现非锁定读取。
需要特别注意的是,事务在undo log segment 分配页并写人 undo log 的这个过程同样需要写人重做日志。当事务提交时,InnoDB 存储引擎会做以下两件事情:
- 将 undo log 放入列表中,以供之后的 purge 操作
- 判断 undo log 所在的页是否可以重用,若可以分配给下个事务使用
事务提交后并不能马上删除 undo log 及 undo log 所在的页。这是因为可能还有其他事务需要通过 undo log 来得到行记录之前的版本。故事务提交时将 undo log 放人一个链表中,是否可以最终删除 undo log 及 undo log 所在页由 purge 线程来判断。
此外,若为每一个事务都分配一个单独的 undo 页会非常浪费存储空间,特别是对于 OLTP 的应用类型。因为在事务提交时,可能并不能马上释放页。假设某应用的删除和更新操作的 TPS(transaction per second)为1000,为每个事务分配一个undo 页,那么一分钟就需要1000*60个页,大约需要的存储空间为1GB。若每秒的 purge 页的数量为20,这样的设计对磁盘空间有着相当高的要求。因此,在 InnoDB 存储引擎的设计中对 undo 页可以进行重用。具体来说,当事务提交时,首先将undo log 放人链表中,然后判断 undo 页的使用空间是否小于3/4,若是则表示该 undo 页可以被重用,之后新的 undo log 记录在当前 undo log 的后面。由于存放 undo log 的列表是以记录进行组织的,而 undo 页可能存放着不同事务的 undo log,因此 purge 操作需要涉及磁盘的离散读取操作,是个比较缓慢的过程。
1.6、MySQL 隔离级别演示
光说不练假把式,接下来就来看下具体的代码演示吧。尝试一下各种隔离级别之间是否会出现上述的那种问题。
先查询下 MySQL 的默认隔离级别:
代码语言:sql复制SELECT @@global.transaction_isolation;
--------------------------------
| @@global.transaction_isolation |
--------------------------------
| REPEATABLE-READ |
--------------------------------
MySQL 的默认隔离级别就是可重复读。
注意:以下操作大部分省略了 use data_test 、设置隔离级别的操作、退出的操作,知道就好了。
1.6.1、读未提交
先从第一个隔离级别开始,读未提交,它出现的问题是一个事务可以读取到另外一个事务中未提交的数据。
分别开两个窗口,设置全局隔离级别为读未提交:
代码语言:text复制set global transaction isolation level read uncommitted;
然后再查询一下发现隔离级别已经变成为读未提交了。
代码语言:sql复制SELECT @@global.transaction_isolation;
--------------------------------
| @@global.transaction_isolation |
--------------------------------
| READ-UNCOMMITTED |
--------------------------------
A窗口
代码语言:c#复制# 开启事务
start transaction;
# 向测试表插入一条数据
insert into user_name(name) values('haha2');
然后打开另外一个窗口登录进去(设置了全局隔离级别记得登录进去过的要退出重登,因为可能会出现缓存之类的导致隔离级别失效。)
B窗口
代码语言:sql复制start transaction;
select * from user_name;
# 然后发现在A窗口中未提交的数据,在B窗口中也是可以读取的到的。
---- -------
| id | name |
---- -------
| 2 | haha1 |
| 3 | haha2 |
---- -------
1.6.2、读已提交
当前隔离级别在A事务未完成前可以读到B事务已经提交的数据。
A窗口
代码语言:text复制# 开启事务
start transaction;
# 查询Id为1的数据判断是否已存在
select * from user_name where id = 1;
# 结果
Empty set (0.00 sec)
B窗口
代码语言:sql复制# 开启事务
start transaction;
# 插入一条id为1的数据
insert into user_name values(1,'haha1');
commit;
A窗口
代码语言:sql复制# 查询Id为1的数据判断是否已存在
select * from user_name where id = 1;
# 结果
---- -------
| id | name |
---- -------
| 1 | haha1 |
---- -------
然后在 A 事务结束前居然和第一次读到的数据不一样,违背了事务的4特性之一的 “一致性” 。不过一般来说这个不可重复读是可以接受的,因为读到的是已提交的数据,本身不会产生很大的影响,因此很多厂商的默认隔离级别就是设置成了 “读已提交” ,比如 “Oracle” 、“SQL Server” 。
1.6.3、可重复读
A窗口
代码语言:sql复制start transaction;
select * from user_name where id > 2;
---- ------
| id | name |
---- ------
| 3 | test |
| 4 | 56 |
---- ------
B窗口
代码语言:sql复制start transaction;
insert into user_name values(7,'end');
commit;
select * from user_name where id > 2;
---- ------
| id | name |
---- ------
| 3 | test |
| 4 | 56 |
| 7 | end |
---- ------
A窗口
代码语言:sql复制insert into user_name values(5,'mei');
select * from user_name where id > 2;
---- ------
| id | name |
---- ------
| 3 | test |
| 4 | 56 |
| 5 | mei |
---- ------
发现没,我在B事务插入了一条数据提交,然后A事务插入一条数据再次查询发现和预想中的数据出现了一致,说明幻读的问题解决了。可以自己去尝试一下在两个事务之间不断的插入、删除、修改,然后再查询看一下自己事务的数据是否是符合之前操作的数据。
1.6.4、序列化读(串行读)
A窗口
代码语言:scss复制# 开启事务
start transaction;
insert user_name(name) values('haha2');
B窗口
代码语言:c#复制# 开启事务
start transaction;
select * from user_name;
然后你会发现 B 窗口居然卡住没出结果,然后等 A 窗口把事务提交后,B 窗口立马结果就出来了,这个就是串行读,就是事务排队,一个一个来,你想操作表(不管查询还是啥操作),你必须等我做完才能干你的事。
1.7、MySQL 隔离级别原理
事务的隔离性都是通过锁来控制的,而原子性、一致性、持久性都是通过数据库的 redo log 和 undo log 来完成的,redo log称之为重做日志,用来保证事务的原子性和持久性。undo log 用来保证事务的唯一性。
1.7.1、读未提交
读未提交因为是未加锁的状态,所以可以读到事务未提交的数据。
1.7.2、读已提交
读已提交是解决了脏读问题。在其他事务对数据进行 DML 操作时,会对记录加上 X 锁,然后其他事务在读的时候因为加了 X锁,因为加的锁不兼容,所以需要等待锁的释放,但是 MySQL 中还有一个操作就是非锁定的一致性读,所以读的时候因为被锁住而去读的快照。所以就解决了脏读问题。但是在读已提交时,因为当前读,所以读的数据都是最新的数据,所以导致每次读的数据可能不一致,这就产生了不可重复读问题。
1.7.3、可重复读
在可重复读中解决了不可重复读和幻读的问题。在重复读中,读的快照是事务开始前的行数据版本,所以多次重复读读的数据都是一致的,在进行修改、删除操作时,会进行更新快照,合并事务自己的操作(MVCC解决了不可重复读问题),但是当 insert 一条数据时,数据还是会出现不一致的现象。在MySQL中使用了 Next-Key Lock 去解决这个问题。
1.7.4、序列化读
在 select 中加上一个 S 锁,如果后续存在 DML 操作就加上 X 锁,其他事务在 X 锁没释放的时候就会等待锁释放后继续操作。
参考文档
MySQL技术内幕 InnoDB存储引擎 第2版
我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!