春招实习的整理了一大堆笔记,现在记忆又有些模糊了,刚好趁着秋招重新梳理一遍MySQL事务相关的知识,逐渐恢复高产吧👾。

事务相关的一些基础概念

事务是指满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

ACID

A是原子性,表示事务是不可分割的,要么全部成功要么全部回滚。C是一致性,表示事务在执行前后都保持状态的一致,侧重的是业务的逻辑。I是隔离型,表示事务在提交之前,其他事务是不可见的。D是持久性,表示事务一旦提交,就会永远保存在数据库中,即使系统崩溃也不会丢失事务的执行结果。其中一致性是依赖于其他三种特性的。

  • 原子性:事务被视为不可分割的最小单元,事务的所有操作要么全部成功,要么全部失败回滚。
  • 一致性:数据库在事务执行前后都保持一致性状态,侧重点是业务逻辑和规则
  • 隔离性:一个事务所做的修改在最终提交以前,对其他事务是不可见的。
  • 持久性:一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢。

ACID的实现方式

其中原子性是通过undo log回滚日志来实现的,每条数据的变化都会产生一条记录,比如插入一条数据undolog就会记录一条删除该信息的语句。这样回滚的时候就可以执行这条语句达到未修改前的状态。 隔离性这是用各种锁策略和多版本并发控制MVCC来实现的。持久性这是使用redo log重做日志实现的,但事务提交的时候会把所有的修改信息放在redo log中并持久化到磁盘,当电脑重启后就会执行redo log的内容来恢复数据。一致性这是依赖于上面三种特性来实现的。

  • 原子性:undo log称之为回滚日志,每条数据的变化(insert/update/delete)都会产生一条记录,并且日志持久化到磁盘,undo log用来记录数据修改前的信息,比如说要插入一条记录,那么undo log就会记录一条删除该信息的语句,这样你需要回滚的时候那么undo log就会执行删除你之前插入的那条记录,达到没有修改前的状态。
  • 隔离性:由各种锁策略和mvcc实现。
  • 持久性:redo log叫重做日志,当事务提交的时候会把所有的修改信息存放在redo log中,并且会把redo log持久化到磁盘,当电脑重启后就会重新执行redo log的修改信息来恢复数据,这样就能够达到持久化的目的。
  • 一致性依赖于其他三种特性:原子性,隔离性,持久性。

ACID 之间的关系

事务的 ACID 特性概念很简单,但不好理解,主要是因为这几个特性不是一种平级关系:

  • 只有满足一致性,事务的结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对数据库崩溃的情况。

隔离级别

事务的隔离级别有读未提交,表示别的事务可以看见一个事务还未提交的修改。读已提交表示,别的事务只能读取已经提交的事务。可重复度表示在同一个事物中多次读取同样的数据结果是一样的,是默认的隔离级别。可串行化则是强制事务串行执行,需要加锁实现,效率比较低。

  • 读未提交(READ UNCOMMITTED):事务中的修改,即使没有提交,对其他事务也是可见的。

  • 读已提交(READ COMMITTED):一个事务只能读取已经提交的事务所做的修改。

  • 可重复读(REPEATABLE READ):保证在同一个事务中多次读取同样数据的结果是一样的。 也就是某个事务在执行的过程中,不允许其他事务进行update操作,但允许其他事务进行insert或delete操作,造成某个事务前后多次读取到的数据总量不一致的现象,从而产生幻读。MySQL默认级别。

  • 可串行化(SERIALIZABLE):强制事务串行执行。需要加锁实现,而其它隔离级别通常不需要。

隔离级别 脏读 不可重复读 幻读
读未提交
读已提交 ×
可重复读 × ×
可串行化 × × ×
  • 脏读:不同事务下,当前事务可以读取到另外事务未提交的数据。事务A在查询的时候,事务B修改了查询结果,然后B发生了回滚。
  • 不可重复度:同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。事务A在执行读取操作,由整个事务A比较大,前后读取同一条数据需要经历很长的时间 。而在事务A第一次读取数据,比如此时读取了小明的年龄为20岁,事务B执行更改操作,将小明的年龄更改为30岁,此时事务A第二次读取到小明的年龄和之前的数据不一样了,系统不可以读取到相同的数据。
  • 幻读:同一事务前后多次读取,数据总量不一致。事务A在执行读取操作,需要两次统计数据的总量,前一次查询数据总量后,此时事务B执行了新增数据的操作并提交后,这个时候事务A读取的数据总量和之前统计的不一样,就像产生了幻觉一样,平白无故的多了几条数据,成为幻读。

锁类型

  • 共享锁/读锁(S Lock):针对同一份数据,多个读操作可以同时进行而不会互相影响

  • 排他锁/写(X Lock):当前写操作没有完成前,它会阻断其他写锁和读锁。

  • 意向锁的存在是为了协调行锁和表锁的关系,如果事务A对某一行上锁,其他事务就不可能修改这一行。这与“事务B锁住整个表就能修改表中的任意一行”形成了冲突。有了意向锁之后,前面例子中的事务A在申请行锁(写锁)之前,数据库会自动先给事务A申请表的意向排他锁。当事务B去申请表的写锁时就会失败,因为表上有意向排他锁之后事务B申请表的写锁时会被阻塞。

    • 意向共享锁(IS Lock):事务想要获得一张表中某几行的读锁,同时给该事务申请表的意向共享锁
    • 意向排他锁(IX Lock):事务想要获得一张表中某几行的写锁,同时给该事务申请表的意向排他锁

为什么意向锁是表级锁呢?

当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定(行锁);

  • 如果意向锁是行锁,则需要遍历每一行数据去确认;
  • 如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。

意向锁怎么支持表锁和行锁并存?

  • 首先明确并存的概念是指数据库同时支持表、行锁,而不是任何情况都支持一个表中同时有一个事务A持有行锁、又有一个事务B持有表锁,因为表一旦被上了一个表级的写锁,肯定不能再上一个行级的锁。
  • 如果事务A对某一行上锁,其他事务就不可能修改这一行。这与“事务B锁住整个表就能修改表中的任意一行”形成了冲突。所以,没有意向锁的时候,让行锁与表锁共存,就会带来很多问题。于是有了意向锁的出现,如q1的答案中,数据库不需要在检查每一行数据是否有锁,而是直接判断一次意向锁是否存在即可,能提升很多性能。

  • 有了意向锁之后,前面例子中的事务A在申请行锁(写锁)之前,数据库会自动先给事务A申请表的意向排他锁。当事务B去申请表的写锁时就会失败,因为表上有意向排他锁之后事务B申请表的写锁时会被阻塞。

MVCC 多版本并发控制

MVCC主要是为了提高数据库的并发性能,即使存在读写冲突,也可以做到不加锁非阻塞地读取数据。隔离级别中的读已提交和可重复读都是用MVCC实现的快照读,不过这种方式读到的不一定是最新的数据。

  • 多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式:
    • MVCCMySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

    • 用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
    • 写写冲突由乐观锁或者悲观锁来解决。

基础概念

  • 版本号
    • 系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
    • 事务版本号:事务开始时的系统版本号
  • 隐藏的列:MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号:
    • 创建版本号:保存了行的事务版本号,指示创建一个数据行的快照时的系统版本号;
    • 删除版本号:保存了行的回滚指针,如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。
  • Undo 日志
    • update undo log:事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除
    • insert undo log:代表事务在insert新记录时产生的undo log, 因为insert操作只对当前事务本身可见,所以在事务提交后可以被立即丢弃,而不需要purge操作。
    • MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

可重复读隔离级别实现过程

MVCC主要是为了提高数据库的并发性能,即使存在读写冲突,也可以做到不加锁非阻塞地读取数据。隔离级别中的读已提交和可重复度都是用MVCC实现的快照读,不过这种方式读到的不一定是最新的数据。MVCC是通过Undo日志和每行数据隐藏的两个版本号实现的。undolog日志会通过回滚指针把一个数据行(Record)的所有快照连接起来。然后创建版本号的话会记录当前事务的版本号,删除版本号的话会记录当前行的回滚指针。在进行select快照读时,要读取数据行的创建版本号必须小于当前的创建版本号,并且它的删除版本号也必须是未定义或者大于当前的创建版本号。

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

  • SELECT
    • 多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。
    • 把没有对一个数据行做修改的事务称为 T,T 所要读取的数据行快照的创建版本号必须小于等于 T 的版本号,因为如果大于 T 的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。除此之外,T 所要读取的数据行快照的删除版本号必须是未定义或者大于 T 的版本号,因为如果小于等于 T 的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。
  • INSERT:将当前系统版本号作为数据行快照的创建版本号。

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

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

快照读与当前读

⭐️ 在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

在可重复读级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。

对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在MVCC中:

  • MVCC 的 SELECT 操作是快照中的数据,不需要进行加锁操作。 — 快照读
  • MVCC 其它会对数据库进行修改的操作(INSERT、UPDATE、DELETE)需要进行加锁操作,从而读取最新的数据。可以看到 MVCC 并不是完全不用加锁,而只是避免了 SELECT 的加锁操作。 — 当前读

⭐️:在进行 SELECT 操作时,可以强制指定进行加锁操作。以下第一个语句需要加 读锁,第二个需要加 写锁。

select * from table where ? lock in share mode;
select * from table where ? for update;

事务的隔离级别实际上都是定义的当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”的隔离性,就需要通过加锁来实现了,可重复读会自动给涉及数据加排他锁。

三种行锁

Record Lock 记录锁

  • 单条索引记录上加锁,id 列必须为唯一索引列主键列。
  • 如果表没有设置索引,InnoDB 会自动在主键上创建隐藏的聚簇索引,因此 Record Locks 依然可以使用。

Gap Lock 间隙锁

  • 每次锁住的不光是需要使用的数据,还会锁住这些数据附近的数据。
  • 间隙锁存在于非唯一索引中,锁定开区间范围内的一段间隔。
  • 例如当一个事务执行以下语句,其它事务就不能在 (10,20) 中插入 15。
SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;

Next-Key Lock 临键锁

  • 临键锁存在于 非唯一索引中,该类型的每条记录的索引上都存在这种锁,它是一种特殊的间隙锁,锁定一段 左开右闭 的索引区间。通过临建锁可以解决 幻读 的问题。

  • 如果查询中包含唯一索引,则会退化为记录锁。因为唯一索引能确定记录行数,其他索引不能确定行数,有可能在其他事务中添加这个索引的数据导致幻读。

小结

  • InnoDB 中的行锁的实现依赖于索引,一旦某个加锁操作没有使用到索引,那么该锁就会退化为表锁
  • 记录锁存在于包括主键索引在内的唯一索引中,锁定单条索引记录。
  • 间隙锁存在于非唯一索引中,锁定开区间范围内的一段间隔,它是基于临键锁实现的。
  • 临键锁存在于非唯一索引中,该类型的每条记录的索引上都存在这种锁,它是一种特殊的间隙锁,锁定一段左开右闭的索引区间。

隔离级别的实现方式

InnoDB使用不同的锁策略(Locking Strategy)以及MVCC机制来实现不同的隔离级别。读未提交是通过事务在读数据时候不加锁,在修改数据的时候加行级共享锁来实现的。读与提交和可重复读都是通过MVCC实现的快照读,不过读已提交每次查询都会创建新的read view,可重复读则一直使用第一次创建的read view。另外读已提交在进行当前读时一般只使用记录锁(除了外键约束检查和重复键检查),而可重复读的当前读使用的锁会根据是否存在唯一索引来判断,唯一索引使用记录锁,非唯一索引则使用临键锁避免幻读。可串行化模式下所有selct语句都会被隐式的加上行级共享锁。

  • 读未提交:
    • 事务在读数据的时候不对数据加锁。事务在修改数据的时候只对数据增加行级共享锁。
  • 读已提交:
    • 普通的select是快照读,这是一种不加锁的一致性读,底层使用MVCC实现。
    • 加锁的select, update, delete等语句,除了在外键约束检查(foreign-key constraint checking)以及重复键检查(duplicate-key checking)时会封锁区间,其他时刻都只使用 记录锁
  • 可重复读:
    • 普通select是快照读,这是一种不加锁的一致性读,底层使用MVCC实现可重复读。
    • 可重复读场景下,无论第几次查询均使用事务开启时创建的read view,但可以读到后续自身事务创建的数据。
    • 加锁的select(select…in share mode/select…for update),update,delete等语句,它们的锁,依赖于它们是否在唯一索引上使用了唯一的查询条件,或者范围查询条件:
      • 在唯一索引上使用唯一的查询条件,会使用 记录锁
      • 范围查询条件为非唯一值时,会使用 临键锁,锁住索引记录之间的范围,避免范围间插入记录,以避免产生幻读,以及避免不可重复读。
  • 串行化:
    • 这种事务隔离级别下,所有select语句会被隐式的转化为select…in share mode。这会导致,如果有未提交的事务正在修改某些行,所有读取这些行的操作都会被阻塞。

可重复读级别能解决幻读吗🤔

在快照读读情况下,mysql通过mvcc来避免幻读。 在当前读读情况下,mysql通过next-key来避免幻读。单纯的mvcc只能解决本事务读方面的幻读问题,但是如果本事务发起更新update操作,那么幻读问题就会再现。本质上没有真正地解决幻读问题。不过我觉得不能把不能把快照读和当前读得到的结果不一样这种情况认为是幻读,这是两种不同的使用。

发生幻读的一种说法

实际上这种说法不太站得住脚

Mysql官方给出的幻读解释是:只要在一个事务中,第二次select多出了row就算幻读。

单纯的mvcc只能解决本事务读方面的幻读问题,但是如果本事务发起更新update操作,那么幻读问题就会再现,本质上没有真正地解决幻读问题,如以下这种情况:

A事务先select,B事务insert确实会加一个gap锁,但是如果B事务commit,这个gap锁就会释放(释放后A事务可以随意操作),A事务再select出来的结果在MVCC下还和第一次select一样,接着A事务不加条件地update,这个update会作用在所有行上(包括B事务新加的),A事务再次select就会出现B事务中的新行,并且这个新行已经被update修改了,实测在RR级别下确实如此。 如果这样理解的话,Mysql的RR级别确实防不住幻读。

结论

在快照读读情况下,mysql通过mvcc来避免幻读。 在当前读读情况下,mysql通过next-key来避免幻读。 select * from t where a=1;属于快照读 select * from t where a=1 lock in share mode;属于当前读 不能把快照读和当前读得到的结果不一样这种情况认为是幻读,这是两种不同的使用。所以我认为mysql的rr级别是解决了幻读的。

如引用一问题所说,T1 select 之后 update,会将 T2insert 的数据一起更新,那么认为多出来一行,所以防不住幻读。看着说法无懈可击,但是其实是错误的,InnoDB 中设置了快照读和当前读两种模式,如果只有快照读,那么自然没有幻读问题,但是如果将语句提升到当前读,那么 T1select 的时候需要用如下语法: select * from t for update (lock in share mode) 进入当前读,那么自然没有 T2 可以插入数据这一回事儿了。