怕什么真理无穷,进一寸有一寸的欢喜

0%

数据库ACID介绍及实现

Innodb作为现版本Mysql中默认的存储引擎,其事务相关知识是非常重要的,也是面试高频点,因此这篇将介绍下事务的ACID属性,具体实现方式及对ACID的理解

面试题

先抛出一点常见的面试题,如果这些面试题有不太熟悉的,可以看下下文中相关部分,带着问题去阅读

  1. 数据库的事务是什么,有哪些属性
  2. Mysql隔离级别
  3. ACID中为什么要强调一致性
  4. Mysql怎么解决不可重复读的问题
  5. Mysql在可重复读下会有幻读情况吗
  6. 可重复读的底层实现
  7. 怎么解决幻读的问题

事务介绍

事务:事务简单来说是一组SQL语句,要么同时执行,要么同时不执行,相当于买东西时候捆绑销售,要么全部买,要么全部不买。

而事务有四大金刚(特性),就是鼎鼎有名的ACID,下面分别介绍下ACID

ACID含义

  1. 原子性(Atomicity)

    原子性指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生

    因为之前原子是不可分割的单位,因此一般原子性或原子操作含义就是不可再拆分

    j = i++或Person p = new Person()这种虽然看着是一条语句,但在编译时及执行时,是会被拆分的,

  2. 一致性(Consistency)

    事务必须使数据库从一个一致性状态变换到另外一个一致性状态,保证数据的可靠性

    关于一致性的理解后面会详细介绍

  3. 隔离性(Isolation)

    事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

    就像自己在上厕所时,把门锁了,让其他人进不来,这样自己就不会被其他人打扰,虽然在Java中有自旋锁这种“不要脸”的会不时来看看门开了没

  4. 持久性(Durability)

    持久性指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响

    就像礼物给了出去,就是别人的,不能再要回来了

并发事务问题

对于同时运行的多个事务,当这些事务访问数据库中相同的数据时,如果没有采取必要的隔离机制,就会导致各种并发问题:

  • 更新丢失:多个事务选择同一行,最后的更新会覆盖之前的更新。在提交事务前,不能访问同一文件,可避免。

  • 脏读:对于两个事务T1、T2,T1读取了已经被T2更新还没有提交的字段,之后若T2回滚,T1读取的内容就是临时且无效

  • 不可重复读:对于两个事务T1、T2,T1读取了一个字段,然后T2更新了该字段,之后T1再次读取同一个字段,值就不同

  • 幻读:对于两个事务T1、T2,T1从一个表中读取了一个字段,然后T2在该表中插入了一些新的行,之后如果T1再次读取同一个表,就会多出几行

脏读与幻读相对比,脏读侧重于字段的更新,幻读侧重于行的插入

解决并发事务问题

数据库提供了不同的隔离级别来解决上面提到的脏读、不可重复度与幻读的问题

数据库的隔离级别:数据库系统必须具有隔离并发运行各个事务的能力,使它们不会相互影响,避免各种并发问题

数据库提供的4种事务隔离级别

隔离级别 描述
READ UNCOMMITTED(读未提交数据) 允许事务读取未被其他事务提交的变更,脏读、不可重复度和幻读的问题都会出现
READ COMMITTED(读已提交数据) 只允许事务读取已经被其他事务提交的变更,可以避免脏读,但不可重复读和幻读问题仍可能出现
REPEATABLE READ(可重复读) 确保事务可以多次从一个字段中读取相同的值,在这个事务持续期间,禁止其他事务对这个字段进行更新,可以避免脏读和不可重复读,但幻读的问题仍然存在
SERIALIZABLE(串行化) 确保事务可以从一个表中读取相同的行,在这个事务持续期间,禁止其他事务对该表执行插入,更新和删除操作,所有并发问题都可以避免,但性能十分低下

Oracle支持的2种事务级别:READ COMMITTED,SERIALIZABLE。Oracle默认的事务隔离级别为READ COMMITTED

Mysql支持4种事务级别,Mysql默认的事务隔离级别为REPETABLE READ

事务演示

查看当前的隔离级别

1
2
3
select @@tx_isolation;
8.0后改为select @@transaction_isolation;
show variables like 'tx_isolation';

更改隔离级别

1
set session transaction isolation level 待修改的级别【read uncommitted|read committed|repeatable read|serializable】;

事务的开启与结束

步骤1:开启事务

1
2
SET autocommit=0;
START TRANSACTION;#可选的

步骤2:编写事务中的sql语句(select insert update delete

语句1;

语句2;

【设置回滚点:

savepoint 回滚点名;】

步骤3:结束事务

1
2
3
commit;提交事务
rollback;回滚事务
[回滚到指定的地方:rollback to 回滚点名;]

savepoint 节点名;#设置保存点

数据库表设置

演示表为account,有id,name,salary三个值,建表语句如下所示

1
2
3
4
5
6
 CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(20) DEFAULT NULL,
`balance` decimal(10,0) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8

选择数据库,建立表,更改编码

1
set names gbk;

插入一条数据

1
insert into account values(1,'张三',100);

演示脏读

设置隔离级别为读未提交

1
set session transaction isolation level read uncommitted;
事务A 事务B
T1 SET autocommit=0; SET autocommit=0;
T2 select * from account where id = 1;
T3 update account set username=’赵六’ where id = 1;
T4 select * from account where id = 1;
T5 rollback;
T6 select * from account where id = 1;

这样,事务A在T2时刻看的数据username为张三,事务B在T3更改了数据后,事务A在T4查看,此时id=1的数据username为赵六,但当事务B在T5回滚事务后,事务A在T6看到是原来的数据,username为张三,这样便出现了脏读

演示不可重复读

设置隔离级别为读已提交

1
set session transaction isolation level read committed;
事务A 事务B
T1 SET autocommit=0; SET autocommit=0;
T2 select * from account where id = 1;
T3 update account set username=’赵六’ where id = 1;
T4 select * from account where id = 1;
T5 commit;
T6 select * from account where id = 1;

这样,事务A在T2时刻看的数据username为张三,事务B在T3更改了数据后,事务A在T4查看,此时id=1的数据username仍为张三,解决了脏读问题,但当事务B在T5提交事务后,事务A在T6看到是修改后的数据username为赵六,这样便出现了不可重复读

演示幻读

设置隔离级别为可重复读

1
set session transaction isolation level repeatable read;
事务A 事务B
T1 SET autocommit=0; SET autocommit=0;
T2 select * from account;
T3 insert into account values(2,’王五’,50);
T4 commit
T5 update account set balance=10;

当事务A在T2读取数据,只有1条,当事务B在T3插入一行数据后,事务A在T5更新的时候,会影响2条数据,出现了幻读。但是如果此时事务A去读取数据,仍然只有1条。原因是select读是快照读,MVCC解决了快照读的幻读问题。而update为当前读,在当前读下仍然会出现幻读情况。

同时,使用select * from account for update;这种当前读的select也会出现幻读。

ACID实现

介绍完ACID后,下面介绍ACID是如何被实现的

原子性(A)的实现

回忆一下:原子性为一个事务是不可分割的工作单位,操作要么一起做,要么都不做,若一个sql执行失败,已经执行的语句需要回滚。

而原子性的实现依靠的是undo log(回滚日志),实现原子性的关键为可以撤销成功执行的sql语句。当事务要对数据库进行修改时,InnoDB生成对应的undo log,若事务执行失败或调用了roll back,可以使用undo log来将数据修改为回滚前的状态。

undo log是逻辑日志,记录sql执行相关信息,属于存储引擎层。当发生回滚时,InnoDB根据undo log的内容做与之前相反的操作:对insert,进行delete;对delete,进行insert;对update,执行相反的update,将数据改回去。

持久性(D)的实现

回忆一下:持久性为事务一旦提交,对数据库的改变应该是永久性的,接下来的其他操作或故障不应该对其有影响。

持久性的实现依靠的是redo log(重做日志),与undo log回滚日志一样属于InnoDB的事务日志。

InnoDB提供缓存Buffer Pool,包含磁盘中部分数据页的映射,作为数据库的缓冲,减少IO次数。数据库读取数据的逻辑是:先在Buffer Pool中读取,若缓冲中没有,从磁盘读取后放入缓冲。当向数据库写数据,先写入缓冲,Buffer Pool中修改的数据定期刷新到磁盘中(刷脏)。Buffer Pool的优点是提高了读写效率,但问题是,若MySQL宕机,会导致Buffer Pool中数据没有刷新到磁盘,数据丢失,持久性也就无法保证。因此redo log被引入。

数据修改时,除了在Buffer Pool中修改,也会在redo log中记录;redo log默认在事务提交时刷盘,也有每秒刷盘等。MySQL宕机后,重启时可以读取redo log中的数据,对数据库进行恢复。而所有修改先写入redo log,再更新到Buffer Pool,这样可以保证持久性。

而redo log可以保证持久性的关键是它比刷脏要更快,不然刷脏很快也没有必要使用redo log,redo log更快的原因是

  • 刷脏是随机IO,修改数据随机;redo log是追加操作,为顺序IO(不用多个地方移动磁头)
  • 刷脏以数据页为单位,MySQL默认页是16kb,对页上做小修改要整页写入;redo log只包含真正需要写入的数据(修改量更少)

隔离性(I)的实现

回忆一下:隔离性为事务的内部操作与其他事务隔离,并发执行的事务之间不能相互干扰。最严格的隔离性,对应事务隔离级别中的串行化。

主要考虑读与写之间的隔离性

  • 事务A写操作对事务B写操作的影响:锁机制保证
  • 事务写操作对事务B读操作的影响:MVCC保证

锁使用的是行锁+间隙锁,之后再详细介绍,先可简单理解为给要操作的那个数据上了锁,其他事务无法对其有影响

并发事务会产生脏读,不可重复读和幻读问题,对应的,数据库依靠前面所说的四个隔离级别来消除这些影响,但隔离级别越高,虽然读取问题变少,但性能也变差,一般数据库默认可重复读(MySQL)或读已提高(Oracle)。

而隔离级别解决脏读、不可重复读或幻读,依靠的是MVCC,即Multi-Version Concurrency Control,多版本并发事务控制。MVCC的特点是不同事务读取到的数据可能是不同的(多版本的),因为读为快照读(读取不是最新的)。最大的优先是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要依靠的是数据隐藏列(标记位),undo logRead View(读视图)。

要实现多版本控制,需要保存有不同版本的数据,需要当前版本可以找到上一个版本的数据,和判断当前版本可以看到哪些版本的数据,分别依靠undo log,隐藏列与Read View。

隐藏列中包括

  • 最近修改事务ID
  • 回滚指针:配合undo log,指向此记录上一个版本
  • 隐含的自增ID
  • 实际还有删除flag隐藏字段,记录被更新或删除不是真的删除,而是flag变化

读取数据时,MySQL可以判断是否需要回滚并找到所需要的undo log,从而实现。

对MVCC有帮助的是undo log中记录了update 和 delete的日志,在快照读时有用。

当有新事务对数据进行了修改,新事务会有指针指向旧事务。

这样undo log形成一个单向链表,表头为最新的旧记录,表尾是最早的旧记录。

而Read View是在事务进行快照读的时候产生的读视图,维护了活跃事务的ID(自增,越新事务ID越大),用来判断当前版本的视图可以看到哪个版本的数据。其用来做数据的可见性判断,其可见性算法是,取出要修改数据的最新记录中的事务ID,与Read View中维护的活跃事务ID比较,若不符合可见性,则通过回滚指针找到undo log中上一条记录,直到找到符合可见性规则的(从表头到表尾)。

其判断的逻辑为:在Read View中维护了一系列生成时刻事务ID,其中有最小事务ID与已创建最大事务ID。

  • 如果数据中当前事务ID小于生成时最小事务ID,说明此记录是在Read View前面就出现了,最新的数据是可以被看到的;若大于等于进入下一个判断
  • 如果当前事务ID大于等于已创建最大事务ID,说明此记录是在Read View后才出现的,肯定对当前事务不可见;若小于,进入下一个判断
  • 判断数据中事务ID是否仍在活跃事务之中,如果在,代表Read View时刻,事务还在活跃,没有commit,修改的数据当前事务也看不到;如果不在,说明事务在Read View前已经commit了,修改的结果对当前事务是可见的

如果一个数据被删除了,将版本链上的数据复制一份,更新修改事务ID,然后将标志位更改为true,如果查询到了已经删除的数据(flag为true),则不返回数据。

因此可以看出快照读的结果非常依赖于Read View生成的时机。

在读已提交下,每个快照读都会生成并获取最新的Read View,可重复读下同一个事务的第一个快照才生成Read View,之后快照读取的仍为同一个Read View。

个人小总结为:若是快照读生成的时机越早,更新的次数越少,越有可能对其他事务修改的数据不可见。

而对于可重复读下的幻读问题,如果是标准的SQL规范下,是会存在幻读的,但在Mysql的Innodb引擎下,依靠行锁+间隙锁与MVCC可解决快照读的幻读问题

一致性(C)的实现

一致性为数据库完整性约束没有被破坏,事务执行的前后数据状态都是合法的。一致性是事务的最终目标,是依靠于原子性、隔离性和持久性来实现的。在数据库层面与应用层层面都需要来保障一致性。

有关一致性的理解

一致性既然是靠A,D,C来实现的(乌兹乌兹乌兹),为何还要将其单独放出来呢?因为A,D,C属于数据库级别的实现,是在数据库的功能下实现的,而一致性属于应用层,如转账的场景,A现有10元,给B转了50元,A的余额变为-40,这个转账过程是符合ADC过程的,但是不符合业务规定,即一致性是认为规定的应用层的约定,需要依靠程序+业务中某些规定来实现,因此一致性属性是非常重要也需要单独放出来的。

最后,再看下这些面试题,是否有了更清晰的认识呢~

  1. 数据库的事务是什么,有哪些属性
  2. Mysql隔离级别
  3. ACID中为什么要强调一致性
  4. Mysql怎么解决不可重复读的问题
  5. Mysql在可重复读下会有幻读情况吗
  6. 可重复读的底层实现
  7. 怎么解决幻读的问题

参考资料

深入学习MySQL事务:ACID特性的实现原理:https://www.cnblogs.com/kismetv/p/10331633.html

正确的理解MySQL的MVCC及实现原理:https://blog.csdn.net/SnailMann/article/details/94724197?depth_1-utm_source=distribute.pc_relevant.none-task&utm_source=distribute.pc_relevant.none-task

如何理解数据库事务中的一致性的概念?:https://www.zhihu.com/question/31346392