MySQL事务相关面试题…

何为事务?

事务是逻辑上的一组操作,要么都执行,要么都不执行。

举一个简单的例子:

事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:

  1. 将小明的余额减少 1000 元
  2. 将小红的余额增加 1000 元。

事务会把这两个操作就可以看成逻辑上的一个整体,这个整体包含的操作要么都成功,要么都要失败。

这样就不会出现小明余额减少而小红的余额却并没有增加的情况。

何为数据库事务?

数据库事务在我们日常开发中接触的最多了。如果你的项目属于单体架构的话,你接触到的往往就是数据库事务了。

平时,我们在谈论事务的时候,如果没有特指分布式事务,往往指的就是数据库事务

那数据库事务有什么作用呢?

简单来说:数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行。

1
2
3
4
5
6
# 开启一个事务
START TRANSACTION;
# 多条 SQL 语句
SQL1,SQL2...
## 提交事务
COMMIT;

事务
另外,关系型数据库(例如:MySQL、SQL Server、Oracle 等)事务都有 ACID 特性:
ACID

何为ACID特性?

原子性(Atomicity)

事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;

一致性(Consistency)

执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;

隔离性(Isolation)

并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;

持久性(Durabilily)

一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

数据事务的实现原理呢?(InnoDB)

我们这里以 MySQL 的 InnoDB 引擎为例来简单说一下。

MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。

MySQL InnoDB 引擎通过 锁机制、MVCC 等手段来保证事务的隔离性( 默认支持的隔离级别是 REPEATABLE-READ )。

保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。

并发事务带来哪些问题?

在典型的应用程序中,多个事务并发运行,经常会操作相同的数据来完成各自的任务(多个用户对同一数据进行操作)。并发虽然是必须的,但可能会导致以下的问题。

脏读(Dirty read)

当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
20210421222850

脏写

脏写是指事务回滚了其他事务对数据项的已提交修改,比如下面这种情况,在事务1对数据A的回滚,导致事务2对A的已提交修改也被回滚了。
20210421222913

丢失修改(Lost to modify)

指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。 这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。
20210421222943

不可重复读(Unrepeatableread)

指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。 这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
20210421223002

幻读(Phantom read)

幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
20210421223014

不可重复读和幻读区别

不可重复读的重点是修改比如多次读取一条记录发现其中某些列的值被修改,幻读的重点在于新增或者删除比如多次读取一条记录发现记录增多或减少了

事务隔离级别有哪些?

SQL 标准定义了四个隔离级别:

READ-UNCOMMITTED(读取未提交)

最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。

READ-COMMITTED(读取已提交)

允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。

REPEATABLE-READ(可重复读)

对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。

SERIALIZABLE(可串行化)

最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。

MySQL中的事务隔离级别的操作

1
2
3
4
5
6
7
8
#查询隔离级别
SELECT @@transaction_isolation
SHOW variables like '%transaction_isolation%';
#设置隔离级别
SET transaction_isolation='READ-UNCOMMITTED';#读未提交
SET transaction_isolation='READ-COMMITTED';#读已提交
SET transaction_isolation='REPEATABLE-READ';#可重复读
SET transaction_isolation='SERIALIZABLE';#串行化
隔离级别 脏读 不可重复读 幻读
读未提交(READ-UNCOMMITTED)
读已提交(READ-COMMITTED) ×
可重复读(REPEATABLE-READ) × ×
串行化(SERIALIZABLE) × × ×

MySQL 的默认隔离级别是什么?

MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过SELECT @@tx_isolation;命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;

1
2
3
4
5
6
mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+

MySQL InnoDB 的 REPEATABLE-READ(可重读)并不保证避免幻读,需要应用使用加锁读来保证。而这个加锁读使用到的机制就是 Next-Key Locks

因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是 READ-COMMITTED(读取提交内容) ,但是你要知道的是 InnoDB 存储引擎默认使用 REPEATABLE-READ(可重读) 并不会有任何性能损失。

InnoDB 存储引擎在 分布式事务 的情况下一般会用到 SERIALIZABLE(可串行化) 隔离级别。

事务隔离级别的实现–并发控制技术

并发控制技术是实现事务隔离性的关键,实现方式有多种,并发控制策略可以分为两类:

  • 乐观并发控制: 对于并发执行可能冲突的操作,假定其不会真的冲突,允许并发执行,直到真正发生冲突时才去解决冲突,比如让事务回滚。
  • 悲观并发控制: 对于并发执行可能冲突的操作,假定其必定发生冲突,通过让事务等待(锁)或者中止(时间戳排序)的方式使并行的操作串行执行。

基于封锁的并发控制

封锁粒度

MySQL 中提供了两种封锁粒度:行级锁以及表级锁

应该尽量只锁定需要修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高。

但是加锁需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销。因此封锁粒度越小,系统开销就越大。

在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡。

锁的种类

1.读写锁

  • 排它锁(Exclusive),简写为 X 锁,又称写锁。加了X锁,其他事务什么锁都不能加。
  • 共享锁(Shared),简写为 S 锁,又称读锁。加了S锁其他事务可以加S锁,不能加X锁。

2.意向锁(Intention Locks)

使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。

意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定:

  • 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁;
  • 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。

通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败。

各种锁的兼容关系如下:

- X IX S IS
X × × × ×
IX × ×
S ×
IS ×

解释如下:

  • 任意 IS/IX 锁之间都是兼容的,因为它们只是表示想要对表加锁而不是真正加锁
  • S 锁只与 S 锁和 IS 锁兼容,也就是说事务 T 想要对数据行加 S 锁,其它事务可以已经获得对表或者表中的行的 S 锁。
三级封锁与两段锁协议

三级封锁协议

三级封锁协议就是对锁使用的规定,来解决事务并发一致性问题。

a.一级封锁-解决丢失更新

事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。

可以解决丢失更新问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。

b.二级封锁-解决脏读

在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。

可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。

c.三级封锁-解决不可重复读

在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。

可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变。

两段锁协议

加锁解锁分为两个阶段进行。

可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。

事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。

1
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)

但不是必要条件,例如以下操作不满足两段锁协议,但是它还是可串行化调度。

1
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)
MySQL隐式与显示锁定

MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。

InnoDB 也可以使用特定的语句进行显示锁定:

1
2
SELECT ... LOCK In SHARE MODE; #S锁
SELECT ... FOR UPDATE; #X锁

基于时间戳的并发控制

核心思想:对于并发可能冲突的操作,基于时间戳排序规则选定某事务继续执行,其他事务回滚。

系统会在每个事务开始时赋予其一个时间戳,这个时间戳可以是系统时钟也可以是一个不断累加的计数器值,当事务回滚时会为其赋予一个新的时间戳,先开始的事务时间戳小于后开始事务的时间戳。

每一个数据项Q有两个时间戳相关的字段:

W-timestamp(Q):成功执行write(Q)的所有事务的最大时间戳
R-timestamp(Q):成功执行read(Q)的所有事务的最大时间戳

具体排序方式就是:

  1. 假设事务T发出read(Q),T的时间戳为TS
    a. 若TS(T) < W-timestamp(Q),则T需要读入的Q已被覆盖。此
    read操作将被拒绝,T回滚。
    b. 若TS(T) >= W-timestamp(Q),则执行read操作,同时把
    R-timestamp(Q)设置为TS(T)与R-timestamp(Q)中的最大值
  2. 假设事务T发出write(Q)
    a.若TS(T) < R-timestamp(Q),write操作被拒绝,T回滚。
    b.若TS(T) < W-timestamp(Q),则write操作被拒绝,T回滚。
    c.其他情况:系统执行write操作,将W-timestamp(Q)设置
    为TS(T)。

基于时间戳排序和基于锁实现的本质一样: 对于可能冲突的并发操作,以串行的方式取代并发执行,因而它也是一种悲观并发控制。它们的区别主要有两点:

  • 基于锁是让冲突的事务进行等待,而基于时间戳排序是让冲突的事务回滚。
  • 基于锁冲突事务的执行次序是根据它们申请锁的顺序,先申请的先执行;而基于时间戳排序是根据特定的时间戳排序规则。

基于有效性检查的并发控制

核心思想:事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。

基于有效性检查的事务执行过程会被分为三个阶段:

  1. 读阶段: 数据项被读入并保存在事务的局部变量中。所有write操作都是对局部变量进行,并不对数据库进行真正的更新。
  2. 有效性检查阶段: 对事务进行有效性检查,判断是否可以执行write操作而不违反可串行性。如果失败,则回滚该事务。
  3. 写阶段: 事务已通过有效性检查,则将临时变量中的结果更新到数据库中。

有效性检查通常也是通过对事务的时间戳进行比较完成的,不过和基于时间戳排序的规则不一样。

该方法允许可能冲突的操作并发执行,因为每个事务操作的都是自己工作空间的局部变量,直到有效性检查阶段发现了冲突才回滚。因而这是一种乐观的并发策略。

基于多版本并发控制(MVCC)与快照隔离

什么是MVCC?

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现读已提交可重复读这两种隔离级别。而读未提交隔离级别总是读取最新的数据行,无需使用 MVCC可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

可以认为MVCC是行级锁的一个变种,但是在很多情况下又避免了加锁,所以效率比较高。

MySQL的InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列实现:

  • 创建版本号:指示创建一个数据行的快照时的系统版本号;
  • 删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。

其中系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。事务版本号:事务开始时的系统版本号。

MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。
20210421225359

实现过程

以下实现过程针对可重复读隔离级别。

当开始一个事务时,该事务的版本号肯定大于当前所有数据行快照的创建版本号,理解这一点很关键。数据行快照的创建版本号是创建数据行快照时的系统版本号,系统版本号随着创建事务而递增,因此新创建一个事务时,这个事务的系统版本号比之前的系统版本号都大,也就是比所有数据行快照的创建版本号都大。

1.SELECT

①只查找版本早于当前事务版本的数据行(行的系统版本号小于等于事务的系统版本号),这样可以保证要么数据行是之前存在的,要么就是自己这个事务自己修改的。

②查找行的删除版本号要么大于当前事务版本号,要么未定义。这样可以保证这个数据行没有被删除的。

2.INSERT

将当前系统版本号作为数据行快照的创建版本号。

3.DELETE

将当前系统版本号作为数据行快照的删除版本号。

4.UPDATE

将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。

快照读与当前读

1.快照读

使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。

1
select * from table ...;

2.当前读

读取的是最新的数据,不去读快照,需要加锁。以下第一个语句需要加 S 锁,其它都需要加 X 锁。

1
2
3
4
5
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;
MVCC➕Next-key-Lock 防止幻读

InnoDB存储引擎在 REPEATABLE-READ 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:

1、执行普通 select,此时会以 MVCC 快照读的方式读取数据

在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。 所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”。

2、执行 select…for update/lock in share mode、insert、update、delete 等当前读

在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!InnoDB 使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读。

参考

事务

数据库之事务与实现原理

评论