最近做的工作与数据库操作有一些关系,这里将数据库的一些基础知识总结一下以备忘。

为什么需要ACID?

读书时学过关系型数据库必须具备四个特性:原子性(atomicity,或称不可分割性)、一致性(consistency)、隔离性(isolation,又称独立性)、持久性(durability)。为啥呢?

读了一些资料,我觉得可以这样理解。关系型数据库内部存储着一堆数据资料,当然为了让这堆数据资料产生价值,必须要允许通过某些方式操作(查询、修改、删除)这些数据。当然这些操作不能毫无规则地进行,必须要遵守一定的规则,从而使数据库存储的数据始终是一致的。于是人们发现了事务这个概念,事务其实就是单个数据逻辑单元组成的对象操作集合。事务的执行遵循ACID特性,通过事务的执行,即可使数据库从一个一致的状态转换到另一个一致的状态。

事务为啥要遵守原子性?一个事务是一连串的操作组成,增删改查的集合。原子性就要求一个事务要么全部执行成功,要么就不执行,不允许只执行一半,因此原子性是达成一致性的必要条件。但是只要保证了原子性就可以保证一致性了吗?显然不是,所以原子性是一致性的一个必要条件,但是不充分条件。

事务为啥要遵守持久性?假设现在原子性保证了,一个事务未提交的时候,发生了错误就执行rollback,那么事务就不会提交了。但是当我们事务执行成功了,执行commit指令之后,遇到了错误会怎么样?正常情况下执行commit后会让事务刷盘,进行持久化操作。进行刷盘操作时是需要一定时间的,在这个刷盘过程中出现宕机、停电、系统崩溃等等可以中断刷盘的操作,那么这个过程理论上有可能导致一半数据刷盘成功,另一半没有刷进去,这显然不是期望的。持久性保证了一旦提交事务commit之后,事务一定会持久化到数据库中。即使刷盘过程中宕机了,导致只有一半数据刷盘成功。当数据库下一次重启的时候,会根据提交日志进行重放,将另一半的数据也进行写入。同样持久性是事务一致性的充分条件,但是还无法构成必要条件。

事务为啥要隔离性?隔离性说的是多个并发事务实际上都是独立事务上下文,多个事务上下文之间彼此隔离,互不干扰。但是多个事务如果对共享数据进行查看、删除、修改,如果不加以控制,就会出现线程安全问题。如何解决这个线程安全问题?很自然会想到用锁的方案,但在数据库里直接用排它锁必然导致性能大打折扣。为了兼顾效率,前人已经为数据库设计了四种不同级别的锁,即四种隔离级别:读未提交、读已提交、可重复读、串行化。

至此,我们终于明白为啥关系型数据库必须遵守ACID特性。

何为事务隔离级别?

很多书中都讲到关系型数据库存在4个事务隔离级别:由低到高依次为Read uncommitted 、Read committed 、Repeatable read 、Serializable,这4个级别可以逐个解决脏读、不可重复读、幻读这几个问题。

隔离级别 脏读(Dirty Read) 不可重复读(NonRepeatable Read) 幻读(Phantom Read)
未提交读(Read uncommitted) 可能 可能 可能
已提交读(Read committed) 不可能 可能 可能
可重复读(Repeatable read) 不可能 不可能 可能
可串行化(Serializable ) 不可能 不可能 不可能

级别越高,数据越安全,但性能越低。

何为脏读、不可重复读、幻读?

1.脏读: 脏读就是指当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。

2.不可重复读: 是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的,因此称为是不可重复读。(即不能读到相同的数据内容) 例如,一个编辑人员两次读取同一文档,但在两次读取之间,作者重写了该文档。当编辑人员第二次读取文档时,文档已更改。原始读取不可重复。如果只有在作者全部完成编写后编辑人员才可以读取文档,则可以避免该问题。

3.幻读: 是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象 发生了幻觉一样。 例如,一个编辑人员更改作者提交的文档,但当生产部门将其更改内容合并到该文档的主复本时,发现作者已将未编辑的新材料添加到该文档中。如果在编辑人员和生产部门完成对原始文档的处理之前,任何人都不能将新材料添加到文档中,则可以避免该问题。

何为MVCC?

继续接上文。我们MySQL中InnoDB的默认隔离级别是REPEATABLE-READ。这个是如此实现的?拍脑袋一想,也许它是通过行锁来实现的吧。已有人好奇地做了相关实验,事实证明并不是通过行锁来实现,而是用Multiversion Concurrency Control来实现的。

大多数的MySQL事务型存储引擎,如InnoDB都不止使用简单的行加锁机制,都和MVCC-多版本并发控制一起使用。锁机制可以控制并发操作,但是其系统开销较大,而MVCC可以在大多数情况下代替行级锁,使用MVCC,能降低其系统开销。

MVCC是通过保存数据在某个时间点的快照来实现的。不同存储引擎的MVCC实现是不同的,下面分析一下MySQL的InnoDB的MVCC实现。

InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的,这两个列,分别保存了这个行的创建时间,一个保存的是行的删除时间。这里存储的并不是实际的时间值,而是系统版本号(可以理解为事务的ID),每开始一个新的事务,系统版本号就会自动递增,事务开始时刻的系统版本号会作为事务的ID。

例子

create table yang( 
id int primary key auto_increment, 
name varchar(20));

假设系统的版本号从1开始。

INSERT

InnoDB为新插入的每一行保存当前系统版本号作为版本号。 第一个事务ID为1;

start transaction;
insert into yang values(NULL,'yang') ;
insert into yang values(NULL,'long');
insert into yang values(NULL,'fei');
commit;

对应在数据中的表如下(后面两列是隐藏列,我们通过查询语句并看不到)

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 undefined
2 long 1 undefined
3 fel 1 undefined

SELECT

InnoDB会根据以下两个条件检查每行记录:

  • InnoDB只会查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的

  • 行的删除版本要么未定义,要么大于当前事务版本号,这可以确保事务读取到的行,在事务开始之前未被删除。

    只有a,b同时满足的记录,才能返回作为查询结果.

DELETE

InnoDB会为删除的每一行保存当前系统的版本号(事务的ID)作为删除标识。 看下面的具体例子分析: 第二个事务,ID为2:

start transaction;
select * from yang;  //(1)
select * from yang;  //(2)
commit;

假设1

假设在执行这个事务ID为2的过程中,刚执行到(1),这时,有另一个事务ID为3往这个表里插入了一条数据, 第三个事务ID为3。

start transaction;
insert into yang values(NULL,'tian');
commit;

这时表中的数据如下:

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 undefined
2 long 1 undefined
3 fel 1 undefined
4 tian 3 undefined

然后接着执行事务2中的(2),由于id=4的数据的创建时间(事务ID为3),执行当前事务的ID为2,而InnoDB只会查找事务ID小于等于当前事务ID的数据行,所以id=4的数据行并不会在执行事务2中的(2)被检索出来,在事务2中的两条select 语句检索出来的数据都只会下表:

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 undefined
2 long 1 undefined
3 fel 1 undefined

假设2

假设在执行这个事务ID为2的过程中,刚执行到(1),假设事务执行完事务3后,接着又执行了事务4, 第四个事务:

start   transaction;  
delete from yang where id=1;
commit;  

此时数据库中的表如下:

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 4
2 long 1 undefined
3 fel 1 undefined
4 tian 3 undefined

接着执行事务ID为2的事务(2),根据SELECT 检索条件可以知道,它会检索创建时间(创建事务的ID)小于当前事务ID的行和删除时间(删除事务的ID)大于当前事务的行,而id=4的行上面已经说过,而id=1的行由于删除时间(删除事务的ID)大于当前事务的ID,所以事务2的(2)select * from yang也会把id=1的数据检索出来。所以,事务2中的两条select语句检索出来的数据都如下:

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 4
2 long 1 undefined
3 fel 1 undefined

UPDATE

InnoDB执行UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间。

假设3

假设在执行完事务2的(1)后又执行,其它用户执行了事务3,4,这时,又有一个用户对这张表执行了UPDATE操作。 第5个事务:

start  transaction;
update yang set name='Long' where id=2;
commit;

根据update的更新原则:会生成新的一行,并在原来要修改的列的删除时间列上添加本事务ID,得到表如下:

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 4
2 long 1 5
3 fel 1 undefined
4 tian 3 undefined
2 Long 5 undefined

继续执行事务2的(2),根据select 语句的检索条件,得到下表:

id name 创建时间(事务ID) 删除时间(事务ID)
1 yang 1 4
2 long 1 5
3 fel 1 undefined

还是和事务2中(1)select 得到相同的结果。

上面这个加两个隐藏字段的理解方案其实并不准确,顶多算一个简化版的理解方法。事实上InnoDB的MVCC是结合使用事务版本号、表的隐藏列、undo log、read view这几个概念实现的,参见这里

有些文章里说到MVVC可以解决幻读问题,但按上述MVVC原理的分析,实际上MVVC还是无法解决幻读问题的。

参考

  1. https://zh.wikipedia.org/wiki/ACID
  2. https://blog.csdn.net/qq_25448409/article/details/78110430
  3. https://blog.csdn.net/JIESA/article/details/51317164
  4. https://blog.csdn.net/pengzonglu7292/article/details/86562799
  5. https://www.jianshu.com/p/c51ba403ce07
  6. https://zhuanlan.zhihu.com/p/52977862