数据库中事务和隔离级别解析

什么是数据库中的事务,可以说事务就是一组原子性的SQL查询,独立的工作单元。我们的事务内的语句,要么全部执行成功,要么全部执行失败!
事务要满足ACID特性,可以通过Commit提交一个事务,也可以使用Rollback进行回滚!下面我们就介绍一下事务的ACID特性。

ACID特性

原子性(actomicity)

一个事务必须被视为一个不可分割的最小单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性。

举个例子,我们的银行账号有100元,去商家消费,下面开启一个事务:

  • 查询我们的账户有多少钱——返回100元
  • 对我们的账户消费减去50元
  • 商家的账户的增加50元

我们可以把这一组操作看成是不可分割的,如果有出现第二步失败,第三步成功,那我们银行不就亏了50?所以我们的原子性就是为了保证每一步都成功,不然就失败都不执行!

这里再多提一下一个非常重要的知识点: MySQL 怎么保证原子性的?

我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在MySQL中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用回滚日志中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。

一致性(consistency)

数据库总是从一个一致性的状态转换到另外一个一致性的状态。

我们同样以上面为例子,也就是说,如果其中的一步失败了,我们的事务就不会提交,做的修改也就不会保存到数据库中!可以说,我们的一致性就是为了事务前后的数据能够对的上。

隔离性(isolation)

一个事务所做的修改在最终提交以前,对其它事务是不可见的。

以上面为例,也就是当我们上面账号消费减去50,但是因为事务还没有提交,所以在另一个事务来看,我们的账号还是100。但是隔离性远不止这么简单,我们后面会有针对隔离性讨论的隔离级别!

持久性(durability)

持久性也就是我们的事务一旦提交就会永久保存到数据库中。哪怕系统崩溃,修改的数据也不会丢失!系统发生奔溃可以用重做日志(Redo Log)进行恢复,从而实现持久性。与回滚日志记录数据的逻辑修改不同,重做日志记录的是数据页的物理修改。

隔离级别

我们上面在介绍了事务的隔离性,但是那是考虑在单线程情况下。如果在并发环境下,事务的隔离性很难保证,会出现很多并发一致性的问题,如脏读、不可重复读、幻读。我们先介绍一下这些问题

并发下一致性问题

脏读

一个事务允许读取另一个未提交事务的修改的数据。比如我们的事务A对age字段修改为20(假设原来是10),然后这个时候事务B进来了读取到了age = 20,但是我们的事务A回滚了,那么事务B读取到就是脏数据!

不可重复读

不可重复读指在一个事务内多次读取同一数据集合。在这一事务还未结束前,另一事务也访问了该同一数据集合并做了修改,由于第二个事务的修改,第一次事务的两次读取的数据可能不一致。比如A事务读取了age = 10,但是这个时候事务B对age 进行了修改age = 20。如果事务A再一次查询的话,那么读取的结果就会与第一次结果不同!

这样虽然能够理解,但是我们可能会觉得不可重复读不是挺好的吗,实时更新了数据。但其实不然,我们可以想象一下,如果我们事务A读取了age的值为10,准备更新当前age为它的两倍,那么就是20!但是在我们读取了age为10之后,事务B修改了age为50,那我们通过10计算出来的结果岂不是错的吗,应该是100而不是20了。所以不可重复读会推翻我们之前的计算,那为了一致性,事务A只能重新执行了!

幻读

幻读本质上也属于不可重复读的情况,事务A 读取某个范围的数据,事务B在这个范围内插入新的数据,事务A 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。

可能这样子对不可重复读和幻读容易混乱,因为两者很是类似。

但其实不可重复读重点在于update和delete,而幻读的重点在于insert。

比如我们的不可重复读,我们把要读那些数据用锁锁出,其他事务无法对其进行修改,这样就可以实现可重复读。但是我们要新增的数据却是没有加锁的,这样就会导致我们在原来的事务里面两次查询尽管加了锁却还是发现结果不一样,就好像幻觉一样,所以叫幻读!

读锁与写锁

我们的事务隔离级别是通过锁来是实现,所以在了解隔离级别之前,我们要来了解一下下面会用的两个基本锁,读与与。

读锁又叫共享锁,是共享的,相互不阻塞,多个客户在同一时刻可以同时读取同一个资源而不相互干扰。

写锁又叫排它锁,是排他的,也就是说一个写锁会阻塞其他的写锁和读锁,确保在给定时间内只有一个用户能执行写入并防止其他用户读取正在写入的同一资源。

事务的隔离级别

因为这些并发一致性的问题,所以我们的数据库中提出了事务的隔离级别来解决这些问题,我们的数据库的锁也是为了隔离级别而存在的!

隔离级别 脏读(Dirty Read) 不可重复读(NonRepeatable Read) 幻读(Phantom Read)
未提交读(Read uncommitted) 可能 可能 可能
已提交读(Read committed) 不可能 可能 可能
可重复读(Repeatable read) 不可能 不可能 可能
可串行化(Serializable ) 不可能 不可能 不可能
  • 未提交读(Read Uncommitted):允许脏读,也就是可能读取到其他会话中未提交事务修改的数据
  • 提交读(Read Committed):只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不重复读)
  • 可重复读(Repeated Read):可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读
  • 串行读(Serializable):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞

已提交读(Read committed)

在RC级别里,数据的读取是不加锁的,但是数据的写入、修改和删除是需要加锁的。

这里我们是通过行锁里面的记录锁(Record Locks)来实现的,这里分别介绍一下行锁和记录锁。

关于行锁和表锁

表锁是MySQL中最基本的锁策略,并且是开销最小的策略。表锁会锁定整张表,一个用户在对表进行写操作前需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他读取的用户才能获取读锁,读锁之间不相互阻塞。

行锁可以最大程度地支持并发,同时也带来了最大开销。InnoDB 和 XtraDB 以及一些其他存储引擎实现了行锁。行锁只在存储引擎层实现,而服务器层没有实现。

关于记录锁

我们的记录锁就是行锁的一种,所谓记录锁指的就是锁定一个单个行记录上的索引,而不是记录本身。

我们通过记录锁就可以锁定我们要修改或者要删除的数据,这样在解锁之前别的事务就无法访问到该数据,从而解决了我们的并发一致性的第一个问题——脏读。

可重复读(Repeatable read)

我们在上面介绍了并发的一致性问题,不可重复读,反过来,就可以理解我们的可重复读。我们的RR级别的隔离也就是为了解决这一个问题,解决后的应该如下:

我们RR级别的实现,上面也提到了一下,就是在我们sql第一次读取数据后,就将这些数据加锁,这样其他事务就无法修改这些数据,就可以实现可重复读了,也就解决了我们的不可重复读的问题。但是我们只能锁住已有的数据,对与后来insert的数据我们是无法锁住的,所以就避免不了幻读(在上面介绍过了这个问题)。

MVCC多版本并发控制

虽然我们的提交读解决了脏读问题,可重复读解决了不可重复读的问题。但是这两种隔离级别都是采用了悲观锁的机制来解决这两个问题的,对于我们成熟的数据库来说,出于性能的考虑,都会使用以乐观锁为理论基础的MVCC(多版本并发控制)来避免这两种问题。

在InnoDB中,会在每行数据后添加两个额外的隐藏的值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。 在实际操作中,存储的并不是时间,而是事务的版本号,每开启一个新事务,事务的版本号就会递增。 在可重读Repeatable reads事务隔离级别下:

  • SELECT时,读取创建版本号<=当前事务版本号,删除版本号为空或>当前事务版本号。
  • INSERT时,保存当前事务版本号为行的创建版本号
  • DELETE时,保存当前事务版本号为行的删除版本号
  • UPDATE时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行

通过MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。

我们的MVCC是可以解决幻读的读问题的。比如我们在下面的事务C中添加了一条teacher_id=1的数据commit,RR级别中应该会有幻读现象,事务A在查询teacher_id=1的数据时会读到事务C新加的数据。但其实不会,通过MVCC我们解决了这个读的幻读现象,而且我们没加锁同时也解决了我们的事务B的不重复读问题变成可重复读!

快照读

我们通过MVCC是可以解决幻读的读问题,因为我们的查询都是查询之前的快照,我们的插入操作是不影响的。所谓快照,也就是指的是我们读取的并不是最新一手的数据,而是之前的遗留数据。基本上我们的select语句就是快照读!

当前读

我们说了快照读所读取的并不是最新一手的数据,那么这在一些对于数据的时效特别敏感的业务中,就很可能出问题。所以我们就需要当前读来获取实时的数据。但是我们的MVCC并不能当前读的幻读问题。我们当前读的幻读而是留给我们下面要介绍的临键锁来解决!

临键锁解决幻读

我们的临键锁其实就是我们的上面的记录锁+间隙锁(gap)来实现的!

所谓间隙锁就是锁定索引之间的间隙,但是不包括索引本身。而我们的临键锁就是二者的整合,不仅有索引的间隙,也包括当前的索引。例如一个索引包含以下值:10, 11, 13, and 20,那么就需要锁定以下区间:

(10, 11]
(11, 13]
(13, 20]

通过临键锁我们解决问题可以用如下流程图表示:

可串行化(Serializable )

读加共享锁,写加排他锁,读写互斥。使用的悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差。如果你的业务并发的特别少或者没有并发,同时又要求数据及时可靠的话,可以使用这种模式。

参考资料

https://tech.meituan.com/2014/08/20/innodb-lock.html

https://github.com/CyC2018/CS-Notes/blob/master/notes/数据库系统原理.md#record-locks

https://www.nowcoder.com/discuss/447742

posted @ 2020-07-30 00:52  CryFace  阅读(270)  评论(0编辑  收藏  举报