一. 事务transanction的四个基本要素
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败,它具有以下四个基本要素。
ACID:原子性(Atomicity)、
一致性(Correspondence)、
隔离性(Isolation)、
持久性(Durability)
1、原子性:事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。如果事务在执行过程中发生错误,事务会被回滚(Rollback)到事务开始前的状态;
2、一致性:事务开始前和结束后,数据库的完整性约束没有被破坏。比如A向B转账,不可能A被扣了钱,B却没收到转账。要保证最后现实逻辑与数据库操作后的数据一致
3、隔离性:事务的隔离性意味着并发的事务之间是相互隔离的。即一个事务的内部操作及正在操作的数据必须封锁起来,不被企图进行修改的其他事务看到。
4、持久性:事务完成后,事务对数据库的所有更新将被保存起来,不会回滚。
一致性是事务的最终目的,原子性、隔离性、持久性都是为了实现一致性!!事务的执行流程如下图所示:下文会具体解释相关日志和该执行流程
二. 原子性
在了解原子性实现原理之前,我们先要了解一下MySQL的WAL技术,WAL全称为Write-Ahead Logging,预写日志系统。其主要是指MySQL在执行写操作的时候并不是立刻更新到磁盘上,而是先记录在日志中,并更新内存,之后在系统空闲的时候将其更新到磁盘中。日志主要分为undo log、redo log、binlog。
原子性的实现主要依靠的是Undo log日志。原子性的体现主要是在sql在执行过程中发生错误而发生回滚上。回滚是要回到执行前的一个状态,那么怎么回到执行前的状态呢?我们是不是就得将执行前的状态记录下来。Undo log 就是实现这个功能的一个日志。
这个回滚日志Undo log,记录的东西非常简单:
- 比如如果在缓存页里执行了一个insert语句,那么此时undo log必须记录了插入数据的主键ID,回滚的时候就可以从缓存页里把这条数据给删除了;
- 如果在缓存页里执行了一个delete语句,那么undo log必须记录下来被删除的数据,回滚的时候就得重新插入一条数据
- 如果在缓存页里执行了一个update语句,那么起码要把更新之前的那个值记录下来,回滚的时候重新update一下,把之前更新的旧值更新回去。
- 如果在缓存页里执行了一个select语句,因为没有改变buffer pool,因此不需要任何undo log。
Redo log用来记录某数据块被修改后的值,可以用来恢复未写入 data file 的已成功事务更新的数据;Undo log是用来记录数据更新前的值,保证数据更新失败能够回滚。
三. 持久性
我们需要先来了解下InnoDB是怎么来读写数据的。我们知道数据库的数据都是存放在磁盘中的,但是磁盘I/O的成本是很大的,如果每次读写数据都要访问磁盘,数据库的效率就会非常低。为了解决这个问题,InnoDB提供了 Buffer Pool 作为访问数据库数据的缓冲。
Buffer Pool 是位于内存的,包含了磁盘中部分数据页的映射。当需要读取数据时,InnoDB会首先尝试从Buffer Pool中读取,读取不到的话就会从磁盘读取后放入Buffer Pool;当写入数据时,会先写入Buffer Pool的页面,并把这样的页面标记为dirty,并放到专门的flush list上,这些修改的数据页会在后续某个时刻被刷新到磁盘中(这一过程称为刷脏,由其他后台线程负责) 。
通过前面的介绍,我们知道InnoDB使用 Buffer Pool 来提高读写的性能。但是 Buffer Pool 是在内存的,是易失性的,如果一个事务提交了事务后,MySQL突然宕机,且此时Buffer Pool中修改的数据还没有刷新到磁盘中的话,就会导致数据的丢失,事务的持久性就无法保证。为了解决这个问题,InnoDB引入了 redo log来实现数据修改的持久化。根据我们在上面所介绍的WAL机制,先写日志,再写磁盘,有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个 能力称为crash-safe。
redo log是由两部分组成的:一是内存中的重做日志缓冲(redo log buffer);二是用来持久化的重做日志文件(redo log file)。我们的数据最开始是在内存之中的,当我们提交事务的时候,redo log会有三种提交方式,来把内存的数据写到磁盘当中,这三种方式可以设置的
- 从用户空间写到日志空间里去,然后每秒钟从日志空间写到操作系统内存中,然后调用fsync()写到磁盘当中(不会丢失数据,效率较低)
- 从内存空间写到操作系统内存,然后调用fsync()直接写到磁盘当中(效率最高,会丢失一些数据)
- 从内存空间写到操作系统内存,然后每秒调用fsync()直接写到磁盘当中(不会丢失数据,效率较低)
事务提交的时候,写入redo log 相比于直接刷脏的好处主要有三点:
- 刷脏是随机I/O,但写redo log 是顺序I/O,顺序I/O比随机I/O快。
- 刷脏是以数据页(Page)为单位的,即使一个Page只有一点点修改也要整页写入;而redo log中只包含真正被修改的部分,数据量非常小,无效IO大大减少。
- 刷脏的时候可能要刷很多页的数据,无法保证原子性(例如只写了一部分数据就失败了),而redo log buffer 向 redo log file 写log block,是按512个字节,也就是一个扇区的大小进行写入,扇区是写入的最小单位,因此可以保证写入是必定成功的。
四. 隔离性
最后我们来看面试被问的最多的隔离性。当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non- repeatable read)、幻读(phantomread)的问题,为了解决这些问题,就有了“隔离级别”的概念。
- 脏读:如果一个事务A对数据进行了更改,但是还没有提交,而另一个事务B就可以读到事务A尚未提交的更新结果。这样,当事务A进行回滚时,那么事务B开始读到的数据就是一笔脏数据。
- 不可重复读:同一个事务对同一个数据进行读取操作,读取到的结果不同。例如,事务B在事务A的更新操作前读到的数据,跟在事务A提交此更新操作后读到的数据,可能不同。
- 幻读:就是一个事务读到另一个事务新增加并提交的数据(insert)。在同一个事务中,对于同一组数据读取到的结果不一致。比如,事务A 新增了一条记录,事务B 在 事务A 提交前后各执行了一次查询操作,发现后一次比前一次多了一条记录。幻读出现的原因就是由于事务并发新增记录而导致的。
不可重复读和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于删除或新增!!
下面我们来看为了解决这些问题出现的隔离级别。首先要知道,隔离得越严实,效率就会越低。因此很多时候,我们都要 在二者之间寻找一个平衡点。SQL标准的事务隔离级别包括:读未提交(read uncommitted)、 读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。 具体解释如下:
- 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
- 读已提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一 致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突 的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
数据库的隔离性就是通过加锁和MVCC来实现的。从上面可以看到,可重复读的隔离级别会出现幻读的问题,而MySQL的默认隔离级别是可重复读,并且解决了幻读的问题。简单来说,MySQL的默认隔离级别解决了脏读、幻读、不可重复读的问题。我们先来看数据库并发场景有哪些
数据库并发场景有三种,分别为:
- 读-读:不存在任何问题,也不需要并发控制
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写:有线程安全问题,可能存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
写-写操作的线程安全是通过加锁来实现的,具体可以看我之前总结的一篇文章:MySQL锁总结(全面简洁 + 图文详解)_李孛欢的博客-CSDN博客 。但是加锁的操作会严重影响数据库的性能和并发量,因此出现了MVCC---多版本并发控制。MVCC是一种用来解决读-写冲突的无锁并发控制,MVCC在数据库中的实现,就是为了解决读(快照读)写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。MVCC可以为数据库解决以下问题:
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
下面我们来看看MVCC的具体实现原理,参考:阿里P7要求这么低吗?老哥给你讲清楚什么是MySQL的MVCC_哔哩哔哩_bilibili
什么是当前读和快照读?
- 当前读
像select lock in share mode(共享锁),select for update;update,insert,delete(排他锁)这些操作都是一种当前读。就是它读取的是记录的最新版本,读取时要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
- 快照读
像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
隐式字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段
- DB_TRX_ID
6byte,最近修改(修改/插入)事务ID(自增):记录创建这条记录/最后一次修改该记录的事务ID - DB_ROLL_PTR
7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里) - DB_ROW_ID
6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本。
如上所示,undo log和回滚指针将最新记录和历史记录连接起来形成了一个版本链。那么我读取数据的时候到底选择哪个版本呢? 这就要用read view来决定了。
read view 由下面这四部分组成。所谓活跃的事务ID就是还没有commit的事务id。那么readview是如何判断的呢?如下图所示:
这里的trx_id是每一个版本的事务id,如前面的图所示,张三的trx_id为1,李四的为2。
- 第一种情况说明,当前这条记录是我自己创建的,当然可以读取。
- 第二种情况就是trx_id小于最小的活跃事务id,min_trx_id,那就说明trx_id是已经commit了,那么该条数据可以被访问。
- 第三种情况如果trx_id > max_trx_id,读取不到,因为已经超出了版本链。
- 第四种情况,如果trx_id在min和max之间,就要看trx_id是不是在m_ids中,如果在,就不可访问,因为还没有commit。
了解了MVCC的原理,我们来看看RC和RR是怎么通过MVCC实现的
RC、RR级别下的InnoDB快照读有什么不同?
正是Read View生成的时机不同,从而造成RC、RR级别下的快照读的结果不同。
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
- 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
- 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View,这样也解决了快照读的幻读问题。当前读的幻读问题是通过间隙锁解决的。