数据库事务特性

直接摆出 ACID 来没有任何价值。

数据库事务的一致性:数据库在一个事务被完成后,将从一个正确的状态,转到另一个正确的状态。这是事务的根本目标。而其他三个特性是为了实现这个目标,是对事务进行了约束。

  • 持久性:提交的事务对数据库产生的影响是永久的,不存在被“撤销”(在提交前可以回滚)的情况。
  • 原子性:一个事务中可能包含有多个操作,这些操作要么一起完成,要么都被撤销,事务的提交必须是一组完整的操作。
  • 隔离性:在一个事务提交前,不会影响其他的事务读取的内容。

持久性,和原子性即使在并发的情况下也能严格的满足,但是隔离性只会在事务串行执行时能得到严格的满足。

并发的引起的问题绝大部分都时未能满足隔离性。

并发一致性问题

  • 丢失修改:两个事务修改了同一个数据,后修改的事务覆盖了前面事务的修改。
  • 读脏数据:在一个事务在事务中途对一个数据进行修改后(这个数据未达到最终状态,可能是由于事务计算的中途变量,或是事务被撤销等),另一个事务读取了这个临时状态的数据。
  • 不可重复度:在一个事务中,多次读取一个数据获取到不同的值,这个值变化的原因是由于其他未提交的事务的临时修改。
  • 幻影读:在一个事务中,相关的数据记录被其他未提交的事务删除,添加,或修改(属于一种不可重复读)。

注意一下脏读,与不可重复读和幻读之间的区别:脏读是指事务读取了其他事务未提交的临时修改,而不可重复读和幻读是读取了其他事务已经提交的数据。

隔离级别

未提交读

  • 事务中的修改对其他事务可见,即使事务未提交。(基本是没有事务隔离)
  • 使用了一级封锁协议,存在预防丢失修改的写锁。

提交读

  • 事务中的修改对其他事务再提交前不可见。
  • 使用 MVCC (在每个语句前创建 ReadView)

可重复读

  • 同一个事务多次读取同一个记录不变。
  • 使用 MVCC(在每个事务开始时创建 ReadView)

可串行化

  • 事务串行执行。
  • 使用严格的两端封锁协议。

实现事务之前的隔离(或者说解决数据库事务间的并发一致性问题,二者是等价的)可以使用封锁协议(三级封锁协议或者两段封锁协议)或者 MVCC 快照机制。

可查看:数据库锁和索引概览 (kicey.site)

封锁协议

三级封锁协议

(依据锁获取和释放的时机分级)

  • 一级封锁协议:事务修改数据需要获取行写锁,解决丢失修改
  • 二级封锁协议:事务在读取数据前需要获取行读锁,读取之后释放(事务此时未结束),解决读脏数据
  • 三级封锁协议:在二级协议的基础上,读锁的释放推迟到事务结束,解决不可重复读

两段封锁协议

事务必须分为两个阶段对数据加锁和解锁,并且在释放一个锁之后,事务不能再获取(包括其他的)任何锁。

两段封锁协议是事务串行化的充分不必要条件,另外,它可能导致死锁。

MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。

InnoDB 也可以使用特定的语句进行显示锁定:

SELECT ... LOCK In SHARE MODE;
SELECT ... FOR UPDATE;

两段封锁协议的多个等级

  • 2PL(2 Phase Locking):锁分两阶段,一阶段申请,一阶段释放
  • S2PL(Strict 2PL):在2PL的基础上,写锁保持到事务结束
  • SS2PL( Strong 2PL):在2PL的基础上,读写锁都保持到事务结束

多版本并发控制(MVVC)

多版本并发控制是 InnoDB 引擎实现提交读和可重复读的机制。

对于读提交可重复读隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,读提交隔离级别是在每个语句执行前都会重新生成一个 Read View而可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。

基本思路是为数据创建多个版本,在事务中读取相应的版本,而非加锁。在事务的修改操作(cud)会为记录创建一个新的版本快照。在 MVVC 中除了事务自身造成的修改操作,事务只能读取其他已经提交的事务的快照。

MVCC 需要利用 Undo 日志,每个日志元素通过 ROLL_PTR 把一个数据记录相关的所有快照连接,指向前一个事务,除此之外还有 TRX_ID 和 DEL 表示事务 id 和是否删除的标志(delete 操作会将这个标志置为真)。

MVVC 维护一个 ReadView 结构,主要包含当前为提交事务列表(通过事务 id TRX_ID),以及最小,最大事务。具体来讲主要有以下 4 个字段:

  • m_ids,未提交事务的列表
  • min_trx_id,未提交事务中的最小事务
  • max_trx_id,创建事务时的未提交的最大事务
  • creator_trx_id,创建当前 ReadView 的事务

另外,需要知道每个记录有两个隐藏的字段(保存在聚簇索引记录中)(trx_id,和 roll_pointer),分别是最后一个改动此记录的事务和上一个改动此记录的事务。

当一个事务去访问其他记录(不是由当前事务修改的)时,如果记录的 trx_id 值大于等于 ReadView 的 max_trx_id 值,说明这个记录是在当前事务开始之后修改的,记录的当前值对此事务不可见。

如果 trx_id 值小于 ReadView 的 min_trx_id 值,说明这个记录是在当前事务开始之前修改的,那么对当前事务可见。

如果在 min_trx_id 和 max_trx_id 之间,那么如果在 m_ids 中,说明这两个事务是并发的,互相不可见,不能使用记录的当前值。

  • 如果记录的 trx_id m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见
  • 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见
    (这里值得注意的是 min_trx_id 之后的事务也可能已经被提交,因为事务执行的速度不同)

不可见的记录值,将沿着 Undo 日志读取之前的值。

提交读和和可重复读都是使用 MVVC 实现的,区别在于提交读在每次读取数据时都会创建新的 ReadView,而可重复度在事务开始时(未执行任何语句)生成 ReadView 在,事务中统一这一个 ReadView。