PPXu

MySQL-Transaction-MVCC

2019-05-07

事务ACID

事务的四大特性,Atomicity(原子性), Consistency(一致性), Isolation(隔离性), Durability(持久性)。

A(原子性)

一个事务的操作,要么全部执行,要么全部不执行。

C(一致性)

事务总是从一个一致的状态转换到另一个一致的状态。在事务开始之前和结束之后,数据库的完整性约束没有被破坏。

I(隔离性)

隔离性决定了一个事务的修改结果在什么时候能够被其他事务看到。隔离性的概念,离不开并发控制(concurrency control)、可串行化(serializability)、锁(lock)等概念。

D(持久性)

事务一旦提交,所有的变化都是永久的,即使发生宕机等故障,数据库也能将数据恢复。持久性保证的是事务系统的高可靠性,而非高可用。事务本身并不保证高可用性,需要一些系统共同配合来完成。

事务特性的实现

MySQL事务的原子性和持久性,靠redo log实现,redo log称为重做日志,通常是物理日志,记录的是页的物理修改操作。
一致性则用undo log来保证,undo log是回滚日志,是逻辑日志,根据每行纪录进行记录。
两种日志的作用都可以视为一种恢复操作,redo恢复的是提交事务修改的页操作,而undo回滚行记录到某个特定的版本。

隔离级别

SQL标准定义的四个隔离级别:

  • Read Uncommitted(读未提交,RU)
    可以读到其它未提交事务导致的数据变更。最低的隔离级别,存在脏读的问题。

  • Read Committed(读已提交,RC)
    可以读到已提交的事务导致的数据变更,解决了RU的脏读问题,但存在不可重复读的问题。

  • Repeatable Read(可重复读,RR)
    InnoDB存储引擎默认支持的隔离级别便是Repeatable Read。可重复读,解决了RC的不可重复读问题,但也可能存在幻读的问题。InnoDB在RR级别下,使用Next-Key Lock算法,可以避免幻读的产生,所以说,在RR级别下已经能完全保证事务的最高隔离性要求,也就是以下的Serializable级别。

  • Serializable(串行化)
    最高的隔离级别,事务间串行化操作,可以理解为事务间无并发。

隔离级别由上至下依次为越来越高,而隔离级别越低则事务请求的锁就越少,或者锁保持的时间就越短。这就是为何大多数数据库系统默认的事务隔离级别是Read Committed(读已提交)。

并发控制

并发的任务对同一个临界资源进行操作,如果不采取措施,可能导致不一致,故必须进行并发控制(Concurrency Control)。

如Java中的锁一样,利用普通锁的互斥性是保证一致性的常用手段,本质上一种串行化的执行过程。
普通的锁,体现的是独占性:

  • 操作数据前,锁住,实施互斥,不允许其他的并发任务操作;
  • 操作完成后,释放锁,让其他任务执行;

这种方式简单粗暴,但性能上可以有优化的空间,于是,出现新的锁种类:

  • 共享锁(Share Locks,记为S锁),读取数据时加S锁
  • 排他锁(eXclusive Locks,记为X锁),修改数据时加X锁

共享锁之间不互斥,也就是:读读可并行
排他锁与任何锁都互斥,也就是:写读、写写都不可并行

排他锁的劣在于,一旦有写,对读也存在串行化的影响,没法提高并发。而事实上可以优化,读可以不受写阻塞。数据多版本就是为了提高并发的一种优化手段。

数据多版本

核心原理在于:读与写操作的数据,副本隔离不一致

  • 写任务发生时,将数据克隆一份,以版本号区分;
  • 写任务操作新克隆的数据,直至提交;
  • 并发读任务可以继续读取旧版本的数据,不至于阻塞;

基于同一个初始版本v0,写任务将克隆一份数据副本,进行修改,修改后的数据视为新版本v1,但写任务未提交前,所有读任务都读取初始版本v0,不会受写任务阻塞。

提高并发的演进思路,就在于此:

普通锁,本质是串行执行
读写锁,可以实现读读并发
数据多版本,可以实现读写并发

Redo log的必要性

Redo log用于实现事务的持久性(ACID里的D)。数据库事务提交后,必须将更新后的数据刷到磁盘上,以保证ACID特性。但这种数据落盘的方式,是随机写,性能较低,如果每次数据落盘都立马操作这种随机写,便会影响吞吐量。于是,默认通过“提交时强制写日志”(Force Log at Commit)的机制进行优化(当然,也可以设置为不强制写日志),也就是说,必须在将事务的数据变更行为记入redo log并进行日志持久化后,再等事务的commit操作完成后,这才算事务完成。

Redo log记录的两个阶段分别是:

  1. 先写入文件缓存
  2. 后必须进行一次fsync操作(确保持久化)

日志的写入采取文件末尾追加的方式,也就是顺序写,而后再定期(较短的时间间隔)fsync落盘,顺序写比之随机写,性能得到大大的提升

假如某一时刻,数据库崩溃,还没来得及刷盘的数据,在数据库重启后,会重做redo日志文件里的内容,以保证已提交事务对数据产生的影响都刷到磁盘上。

Undo log的必要性

Undo log用以保证原子性(A)与一致性(C),以及配合实现innoDB的MVCC机制。

数据库事务未提交时,会将事务修改数据的镜像(即修改前的旧版本)存放到undo日志里,当事务回滚时,或者数据库奔溃时,可以利用undo日志,即旧版本数据,撤销未提交事务对数据库产生的影响。

对于insert操作,undo日志记录新数据的PK(ROW_ID),回滚时直接删除;
对于delete/update操作,undo日志记录旧数据row,回滚时直接恢复

而两类操作存放数据的buffer是不同的。除了回滚操作,undo的另一作用便是为MVCC机制提供数据的历史版本回溯。

PS:为什么说undo log是逻辑日志?
Undo log的回滚机制,只是保证所有数据变更行为被逻辑上取消了,也就是根据undo log找到数据变更行为,把对应的逆反行为执行上,即成功回滚。对于每个delete行为,则执行insert作回滚;每个update,则执行相反的update把数据恢复回去;而每个insert,则执行delete,但是这样并不会把物理存储结构的变化给变更回去,譬如,用户执行了insert 10W条记录的一个事务,会导致表空间增大,但在rollback完成后,仅仅是表数据记录回滚,表空间的大小并不会因此而收缩。而为了保证undo log的持久性,undo log的写入操作也会引发redo log的产生。

多版本并发控制(MVCC)

回溯到数据历史版本

首先InnoDB每一行数据还有一个DB_ROLL_PT的回滚指针,用于指向该行修改前的上一个历史版本。
事实上,MySQL给每行数据添加了三个隐藏字段,分别是该行的隐式行ID(DB_ROW_ID),事务号(DB_TRX_ID)和上述的回滚指针。

当插入一条新数据时,记录上对应的回滚指针为NULL,表明没有历史版本。


更新记录时,原记录将被放入到undo表空间中,并通过DB_ROLL_PT指向该记录。MySQL就是根据记录上的回滚段指针及事务ID判断记录是否可见,如果不可见则按照DB_ROLL_PT继续回溯查找。

Read View判断行记录是否可见

Read View用来判断是否为当前执行事务所见,从而达到对事务之间的数据可见性控制。
当开始一个事务或者每个查询语句执行时,把当前系统中活动的事务的ID都拷贝到一个列表,这个列表中最早的事务ID是tmin,最晚的事务ID为tmax,这个列表为Read View。当读到一行数据时,该行上面当前事务ID(DB_TRX_ID)记为tid0(也就是最后一次对数据进行修改的事务的ID),当前行数据是否可见的判断逻辑如图:

READ COMMITTED
事务内的每个查询语句执行时都会重新创建Read View,这样就会产生不可重复的现象。

REPEATABLE READ
事务开始时创建Read View,在事务结束这段时间内,每一次查询都不会重新创建Read View,从而实现了可重复读。

InnoDB为何能够支持高并发

回滚段里的数据,其实是历史数据的快照(snapshot),这些数据是不会被修改,select可以肆无忌惮的并发读取他们。

快照读(Snapshot Read),这种一致性不加锁的读(Consistent Nonlocking Read),就是InnoDB并发如此之高的核心原因之一。

这里的一致性是指,事务读取到的数据,要么是事务开始前就已经存在的数据(当然,是其他已提交事务产生的),要么是事务自身插入或者修改的数据。

普通的select语句都是快照读,显式加锁(select for update/select lock in share mode)的select语句不属于快照读。

扫描二维码,分享此文章