InnoDB的事务和崩溃恢复

Posted by KANG's BLOG on Thursday, March 24, 2022

1 事务提供的安全保证

1.1 ACID特性

为保证事务(transaction)是正确可靠的,数据库引擎必须具备的四个特性:

原子性(Atomicity)

事务内的一系列操作应该是否一个整体单元,不存在中间态,或者说中间态对外不可见。

一致性(Consistency)

在ACID理论中,一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。但是,一致性并不是数据库自身的特性,而是通过满足原子性和隔离性而达到的效果。

隔离性(Isolation)

指代并发事务相互执行相互隔离,互不影响。

持久性(Durability)

数据库中,事务提交成功后,即使发生任何崩溃,也不能影响事务中的写入变动。

1.2 InnoDB如何保证ACID

InnoDB分别使用如下方式来保证了这四个特性:

  • redo log重做日志用来保证事务的持久性
  • undo log回滚日志保证事务的原子性
  • undo log + redo log保证事务的一致性
  • 锁(共享、排他)用来保证事务的隔离性

2 事务隔离级别

2.1 Read uncommitted

读未提交,顾名思义,就是一个事务可以读取另一个未提交事务的数据。

这就可能发生脏读的问题,即A事务中读取到了B事务中未提交的数据,但是B事务进行了回滚,A事务中这时候就持有了并不存在的脏数据。

2.2 Read committed

读提交,或者叫读已提交,就是一个事务要等另一个事务提交后才能读取或者写入数据。

Mysql通过行级锁来防止脏写的发生(同时只能有一个写),用多版本控制来防止脏读(事务更新使用新值,其他事务读取扔读旧值)。

  1. A事务中读取当前钱包余额为1块钱,并把它增加到2块钱,当事务并未提交
  2. 这时B事务开始执行,将余额从1块钱修改为3块钱,并成功提交事务,当前数据为3块钱
  3. A事务中再次读取,发现余额从1块钱变为3块钱

这就是“不可重复读”问题。也就是说,一次事务中两次读取相同的数据却发现数据不一致了。

2.3 Repeatable read(Mysql默认)

可重复读,就是在开始读取数据(事务开启)时,不再允许修改操作,但允许写操作。

但是仍旧无法避免幻读问题。

什么是幻读?

一个事务里,多次查询的结果集个数不一致,称为幻读。

举个例子,假设数据库中订单表数据如下:

id 订单号 金额(元)
3 NO101 10.0
5 NO102 20.0
6 NO103 17.0

现在开启事务A,把id大于等于3且小于等于5的订单数据的支付金额进行求和,即30元。

但事务A尚未提交时,事务B在数据库中插入了一条id为4的数据:

id 订单号 金额(元)
4 NO104 5.0

那么当事务A再次查询发现此时订单表数据id在[3, 5]区间内的总额为35元。

Mysql彻底解决幻读了吗?

答案是没有!

InnoDB通过MVCC和Next-key Lock解决了部分幻读问题。

之所以说是“部分”,是因为GAP锁的作用前提是locking reads,快照读是不会触发GAP锁的,而每次当前读都会生成一个新的“读视图”,那么一个事务中,如果先进行快照读,再进行当前读,那么是会发生幻读的。

假设目前表中数据如下:

id Name Age
1 张三 5
2 李四 6
3 王五 7
事务1 事务2
begin
select * from students where age > 6
(查询结果只有王五)
begin
insert into students (age, name) values(8, ‘赵六’)
commit
select * from students where age > 6 for update
(查询结果有王五和赵六两条数据,发生了幻读)
commit

结果就是,当前读由最新的数据创建了新的读视图,所以和原快照读的结果不一致。

2.4 Serializable 序列化

Serializable也被称为串行化,是最高的事务隔离级别,在该级别下,事务串行化顺序执行,可以避免脏读、不可重复读与幻读。但是这种事务隔离级别效率低下,比较耗数据库性能,一般不使用。

序列化的问题

序列化最大问题就是性能较差。

数据库只有一种被广泛使用的串行化算法,那就是两阶段(2PL)加锁:事务执行前要获取锁,执行结束后要释放锁

在2PL下,读取操作必须先获取读锁(共享锁),更新操作必须获取写锁(独占锁)。一个对象被独占锁占用时,其他事务必须等待。

这样的规则下,不仅加解锁消耗非常大,还降低了事务的并发性。

过多的使用锁,增加了死锁的概率,数据库检测到死锁会破坏死锁条件,即中止其中一个事务,那么则需要应用层对这个事务进行重试。

3 binlog、redo log和undo log的具体作用

3.1 binlog

binlog是Mysql Server层级的逻辑变更日志,它记录了数据库的所有变更,以二进制存储在磁盘上。

主要作用在两个方面:

  • 增量备份

  • 主从复制

3.2 redo log(持久性保证)

redo log是InnoDB存储引擎层的物理日志,记录的是数据具体做了什么修改,或者新数据的备份。

在事务提交前将redo log持久化。具体顺序如下:

  1. 写入redo log,暂不提交
  2. 写入binlog
  3. 提交redo log

数据写入时,实际上是写入了内存中的Buffer Pool,Buffer Pool中的数据根据策略定期写入磁盘。那么就带来一个问题,如果在写入磁盘前系统崩溃了,数据一致性难以保证,所以在事务完成前会写入redo log,系统崩溃重启后,通过redo log来恢复未持久化到页中的数据。

这样还能使写入变为一个异步操作,那么在写入磁盘前,数据库引擎还能对多次同一个键的修改进行合并,降低磁盘写入内容和次数。

3.3 undo log(一致性保证)

事务中的每一次修改,innodb 都会先记录对应的 undo log 记录。与 redo log 用于数据的灾后重新提交不同,undo log 主要用于数据修改的回滚。

与 redo log 记录的是物理页的修改不同,undo log 记录是数据修改前的快照。

在事务中每次更新记录后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被数据的roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。

当事务回滚时,通过undo log版本链的顺序对数据反向更新。这条版本链是MVCC机制的基础。

4 InnoDB保证事务隔离性的方式 - MVCC(多版本并发控制)

多版本的意思就是一条数据在数据库中同时存在多个版本,在某个事务对其进行操作的时候,需要查看这一条记录的隐藏列事务版本id,比对事务id并根据事务隔离级别去判断读取哪个版本的数据。

隐藏属性 是否必须 描述
row_id 行ID,唯一标识一条记录(如果定义主键,则没有)
transaction_id 事务ID
roll_pointer DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本,形成版本链

MVCC机制下,每个事务都会生成自己的ReadView(后面简称RV),RV包含四个属性:

  • M_ids:创建该Readview时,活跃的事务id集合。
  • Min_trx_id:创建该readview时,最小的事务id,也即M_ids中的最小值。
  • Max_trx_id:要分配给下一个事务的id。不是当前的最大事务ID,应该是当前的最大事务(不一定活跃,可能已经结束)id+1
  • Creator_trx_id:创建该readview的事务id,也即该readview的拥有者。

查询数据时,会比较数据的版本号(trx_id)和该事务自身RV中的版本号:

  • 如果 trx_id == Creator_trx_id,说明数据是自己修改的,那么可以读取。
  • 如果 trx_id < min_trx_id,说明说明该记录已经在生成readview之前被提交了,可以读取。
  • 如果min_trx_id < trx_id < max_trx_id,这时需要在m_ids中查找,如果trx_id在m_ids中,如果不活跃了表示事务已提交可访问,如果还在表示事务还在活跃尚未提交不可访问。这个过程就是遍历版本链,一个一个找,看哪个能访问,如果一个都找不到,那么查询结果里这条记录就不可见。
  • 如果 trx_id >= max_trx_id,说明数据版本号高于当前事务,对当前事务来说属于“未来的数据”,所以不能读取。

5 事务持久化策略

5.1 steal / no-steal

是否允许一个uncommitted的事务将修改更新到磁盘

  • steal策略

    那么此时磁盘上就可能包含uncommitted的数据,因此系统需要记录undo log,以防事务abort时进行回滚(roll-back)

  • no steal策略

    就表示磁盘上不会存在uncommitted数据,因此无需回滚操作,也就无需记录undo log

5.2 force / no-force

事务在committed之后是否将所有更新立刻持久化到磁盘

  • force策略

    在committed之后必须将所有更新立刻持久化到磁盘

  • no-force策略

    在committed之后可以不立即持久化到磁盘,而是缓存更新批量持久化到磁盘

no-force的优点是提升顺序写,缺点是有可能在crash后导致事务数据丢失,所以需要记录redo log,系统重启后进行前滚(roll-forward)操作

现在DBMS常用的是steal/no-force策略,即无论事务提交与否,都不强制将数据写入磁盘。因此一般都需要记录redo log和undo log。这样可以获得较快的运行时性能,代价就是在数据库恢复(recovery)的时候需要做很多的事情,增大了系统重启的时间。