并发事务正确性的准则 可串行化_【浅析】数据库中事务、事务隔离级别、MVCC和锁之间的关系...

一、为什么存在事务

学生时代,学习事务,大都是从一个银行转账的例子开始,让我们回溯一下:

假设有如下一张use_balance表,表中有三个字段username、balance和bankcard:

5f7ae44dfec9d05abe7a17e753bda3aa.png
user_balance

场景:A用户要给B用户转100块钱

假设我们在给A账户减掉100之后,服务突然崩溃了,在没有任何保护措施的情况下,此时user_balance将会变成如下这样:

f17d99b36ef7096b0b2a92629b9e35d8.png
user_balance

可以看到A平白无故少了100块钱,这显然不是我们想要的,我们期望看到的是:无论什么情况下,A和B账户的总和都是1200。

这个时候就需要用到事务了。事务的出现就是为了保证数据在变更过程中的一致性。

二、事务

事务最直观的特性就是要么完全执行,要么都不执行。主要包含以下四个特性(简称ACID):

  1. A,原子性(Atomicity)。原子的概念就是不可分割,你可以把它理解为组成物质的基本单位,也是我们进行数据处理操作的基本单位。
  2. C,就是一致性(Consistency)。一致性指的就是数据库在进行事务操作后,会由原来的一致状态,变成另一种一致的状态。也就是说当事务提交后,或者当事务发生回滚后,数据库的完整性约束不能被破坏。
  3. I,就是隔离性(Isolation)。它指的是每个事务都是彼此独立的,不会受到其他事务的执行影响。
  4. D,指的是持久性(Durability)。事务提交之后对数据的修改是持久性的,即使在系统出故障的情况下,数据的修改依然是有效的。

在这四个特性中,原子性是基础,隔离性是手段,一致性是约束条件,而持久性是我们的目的

三、事务隔离级别

首先需要明确一个概念,事务是为了保证数据在变更过程中的一致性,关键词是变更。在理解了事务的基础上,我们看一下事务隔离级别。

事务ACID特性中,隔离性是为了防止数据库在并发处理时出现数据不一致的情况。具体来讲就是一个事务在执行的过程中,要避免其他事务的影响

事务之间完全隔离只存在于串行化模式下。在并发情景下,事务之间无法做到完全隔离,因此会出现不同程度的数据不一致问题,这些问题我们统称为异常,SQL-92 标准定义了如下三种异常:

  1. 脏读(Dirty Read):读到了其他事务还没有提交的数据
  2. 不可重复读(Nnrepeatable Read):对某数据进行读取,发现两次读取的结果不同,也就是说没有读到相同的内容
  3. 幻读(Phantom Read):事务 A 根据条件查询得到了 N 条数据,但此时事务 B 更改或者增加了 M 条符合事务 A 查询条件的数据,这样当事务 A 再次进行查询的时候发现会有 N+M 条数据,产生了幻读

这里说一下不可重复读和幻读的区别:

  1. 不可重复读是同一条记录的内容被修改了,重点在于UPDATE
  2. 幻读是查询某一个范围的数据行变多了或者少了,重点在于INSERT和DELETE

为了解决上述数据不一致问题,我们提出了数据隔离级别。SQL-92 标准定义了 4 种隔离级别来解决这些异常情况。解决异常数量从少到多的顺序(比如读未提交可能存在 3 种异常,可串行化则不会存在这些异常)决定了隔离级别的高低,

这四种隔离级别从低到高分别是:读未提交(READ UNCOMMITTED )、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)和可串行化(SERIALIZABLE)。

  • 读未提交,也就是允许读到未提交的数据,这种情况下会产生脏读、不可重复读、幻读等情况
  • 读已提交就是只能读到已经提交的内容,可以避免脏读的产生,属于 RDBMS 中常见的默认隔离级别(比如说 Oracle 和 SQL Server)
  • 可重复读,保证一个事务在相同查询条件下两次查询得到的数据结果是一致的,可以避免不可重复读和脏读,但无法避免幻读。MySQL 默认的隔离级别就是可重复读
  • 可串行化,将事务进行串行化,也就是在一个队列中按照顺序执行,可串行化是最高级别的隔离等级,可以解决事务读取中所有可能出现的异常情况,但是它牺牲了系统的并发性

四.MVCC

我们都知道,同一时间如果有多个线程并发访问一个资源的时候,就会引起竞争,在没有任何保护措施的情况下,多个线程并发操作资源,就会引起资源的不一致。而为了解决这种不一致,通常我们会对多线程进行串行化处理,而加锁就是其中一种最常用的方法。加锁保证了数据在任何时刻最多只有一个线程在进行访问,保证了数据的完整性和一致性。

锁有多种划分维度,以程序员的角度来讲,锁可以分为乐观锁和悲观锁。

  • 乐观锁(Optimistic Locking)认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现
  • 悲观锁(Pessimistic Locking)对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

而我们今天所说的MVCC,就是乐观锁的一种实现方式,MVCC 的英文全称是 Multiversion Concurrency Control,中文翻译过来就是多版本并发控制。

首先我们看一下为什么需要MVCC。

以开头那张表为例,假设现在A跟B之间有很多行:

3d5bb4c7c79742c3689cd0cf3ec3f6bf.png
user_balance

场景依然是A与B之间的转账,但同时管理员要进行如下操作,统计表中金额总数:

SELECT SUM(balance) FROM user_amount

情景一 在不用MVCC,使用自身锁机制的情况下,A给B转账可能要等很久:

ac604d53508fb77d59f842bc9386ecf7.png
A给B转账

可以看到为了保证数据的一致性,我们需要给统计到的数据行都加上行锁。这时如果 A 所在的数据行加上了行锁,就不能给 B 转账了,只能等到所有操作完成之后,释放行锁再继续进行转账,这样就会造成用户事务处理的等待时间过长。

情景二 可能会出现死锁

比如管理员读到 A 有 1000 元的时候,此时 B 开始执行给 A 转账:

start transaction;
UPDATE user_balance SET balance=balance-100 WHERE username ='B';
UPDATE user_balance SET balance=balance+100 WHERE username ='A';
commit

此时会发现此时 A 被锁住了,而管理员事务还需要对 B 进行访问,但 B 被用户事务锁住了,此时就发生了死锁。

c19d5357fa0d92e8179e515bc625cccd.png
B给A转账

结论:通过上述两个情景我们会发现在没有其他协助机制的场景下,在并发事务下,我们很容易遇到读写互相阻塞和死锁的问题,而MVCC因采用了乐观锁的机制,所以可以很好的解决读写互相阻塞的问题,降低死锁的概率。而且因为MVCC通过数据行的多个版本管理来实现数据库的并发控制,这样我们就可以通过比较版本号决定数据是否显示出来达到一致性读的效果,从而解决数据一致性问题。

总结:通过使用MVCC我们可以解决如下几个问题:

  1. 读写之间阻塞的问题,通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事务并发处理能力。
  2. 降低了死锁的概率。这是因为 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。
  3. 解决一致性读的问题。一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。

下边说一下什么是快照读和当前读

所谓快照读就是读取的是快照数据,不是实时的数据,不加锁的简单的select都属于快照读:

SELECT * FROM player WHERE ...

当前读就是读取实时最新的数据,比如以下操作:

SELECT * FROM player LOCK IN SHARE MODE;
SELECT * FROM player FOR UPDATE;
INSERT INTO player values ...
INSERT INTO player values ...
INSERT INTO player values ...

五. innoDB中MVCC的实现

MVCC只是一种乐观锁的实现,没有正式的标准,所以不同的DBMS中MVCC的实现方式也不尽相同,以下讲解innoDB中MVCC的实现。

首先了解几个概念:

事务版本号

每开启一个事务,我们都会从数据库中获得一个事务 ID(也就是事务版本号),这个事务 ID 是自增长的,通过 ID 大小,我们就可以判断事务的时间顺序。

行记录的隐藏列

InnoDB 的叶子段存储了数据页,数据页中保存了行记录,而在行记录中有一些重要的隐藏字段,如下图所示:

93ca9876e48caacb29fed3847de5fe89.png
数据行信息
  1. db_row_id:隐藏的行 ID,用来生成默认聚集索引。如果我们创建数据表的时候没有指定聚集索引,这时 InnoDB 就会用这个隐藏 ID 来创建聚集索引。采用聚集索引的方式可以提升数据的查找效率。
  2. db_trx_id:操作这个数据的事务 ID,也就是最后一个对该数据进行插入或更新的事务 ID。
  3. db_roll_ptr:回滚指针,也就是指向这个记录的 Undo Log 信息。

Undo Log

InnoDB 将行记录快照保存在了 Undo Log 里,我们可以在回滚段中找到它们,如下图所示:

fc8df506a51d0615b6293bcc0376894d.png
undo log

从图中你能看到回滚指针将数据行的所有快照记录都通过链表的结构串联了起来,每个快照的记录都保存了当时的 db_trx_id,也是那个时间点操作这个数据的事务 ID。这样如果我们想要找历史快照,就可以通过遍历回滚指针的方式进行查找。

Read View 是如何工作的

在 MVCC 机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在 Undo Log 里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到 Read View 了,它帮我们解决了行的可见性问题。

Read View 保存了当前事务开启时所有活跃(还没有提交)的事务列表,换个角度你可以理解为 Read View 保存了不应该让这个事务看到的其他的事务 ID 列表。

在 Read VIew 中有几个重要的属性:

  1. trx_ids,系统当前正在活跃的事务 ID 集合。
  2. low_limit_id,活跃的事务中最大的事务 ID。
  3. up_limit_id,活跃的事务中最小的事务 ID。
  4. creator_trx_id,创建这个 Read View 的事务 ID。

如图所示,trx_ids 为 trx2、trx3、trx5 和 trx8 的集合,活跃的最大事务 ID(low_limit_id)为 trx8,活跃的最小事务 ID(up_limit_id)为 trx2。

e0179cb7c9e5909a4570cfee70eead74.png
事务ID集合

假设当前有事务 creator_trx_id 想要读取某个行记录,这个行记录的事务 ID 为 trx_id,那么会出现以下几种情况。

如果 trx_id < 活跃的最小事务 ID(up_limit_id),也就是说这个行记录在这些活跃的事务创建之前就已经提交了,那么这个行记录对该事务是可见的。

如果 trx_id > 活跃的最大事务 ID(low_limit_id),这说明该行记录在这些活跃的事务创建之后才创建,那么这个行记录对当前事务不可见。

如果 up_limit_id < trx_id < low_limit_id,说明该行记录所在的事务 trx_id 在目前creator_trx_id 这个事务创建的时候,可能还处于活跃的状态,因此我们需要在 trx_ids 集合中进行遍历,如果 trx_id 存在于 trx_ids 集合中,证明这个事务 trx_id 还处于活跃状态,不可见。否则,如果 trx_id 不存在于 trx_ids 集合中,证明事务 trx_id 已经提交了,该行记录可见。

了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过多版本并发控制技术找到它:

  1. 首先获取事务自己的版本号,也就是事务 ID;
  2. 获取 Read View;
  3. 查询得到的数据,然后与 Read View 中的事务版本号进行比较;
  4. 如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;
  5. 最后返回符合规则的数据。

你能看到 InnoDB 中,MVCC 是通过 Undo Log + Read View 进行数据读取,Undo Log 保存了历史快照,而 Read View 规则帮我们判断当前版本的数据是否可见。

需要说明的是,在隔离级别为读已提交时,一个事务中的每一次 SELECT 查询都会获取一次 Read View。而在隔离级别为可重复读时,Read View只会在事务开始的时候创建一次。

注意:在读已提交的隔离级别下,同样的查询语句每次都会重新获取Read View且这种隔离级别下锁粒度为记录锁,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。当隔离级别为可重复读的时候,因为Read View只会在事务开始的时候创建一次且这种隔离级别下锁粒度为Next-key,因此不会产生不可重复复和幻读。

下边简单说一下innoDB三种行锁:

  1. 记录锁:针对单个行记录添加锁。
  2. 间隙锁(Gap Locking):可以帮我们锁住一个范围(索引之间的空隙),但不包括记录本身。采用间隙锁的方式可以防止幻读情况的产生。
  3. Next-Key 锁:帮我们锁住一个范围,同时锁定记录本身,相当于间隙锁 + 记录锁,可以解决幻读的问题。

下边简单说一下读已提交隔离级别下幻读是怎么产生的,以及可重复读情况是幻读是怎么解决的:

首先明确几个概念:

  1. 行锁是需要的时候才会加上的,也就是说扫描到的行才会加锁,没有扫描到的行不需要加锁
  2. 行锁并不是不需要了就立刻释放,而是要等到事务结束时才释放
  3. 在可重复读隔离级别下,幻读针对的是当前读。因为这种隔离级别下read-view是在事务开始时创建的且后续一直使用,所以如果事务期间一直是快照读的话是不会产生幻读的

然后我们假设把数据表看成一个列表,每行数据看成列表中的一个元素,假设现在有1、3、5、7这样一个列表:

在读已提交的情况:假设我们使用索引找到了1和3,这时候1和3加上了排它锁,我们无法更改1和3,但我们可以在1之前,1-3之间以及3之后插入任意的数据,这个时候当你再一次读取的数据时候,就会发现这三个区间多了很多数据,这就产生了幻读

在可重复度的情况:假设我们使用索引找到了1和3,这个时候因为可重复隔离界别下,锁的粒度是Next-Key,因此这个时候1和3、1之前的区间、1-3之间的区间以及3之后的区间都会被锁住,因此我们没办法操作1和3这两个数据以及这两个数据产生的3个区间,这样下次读取的时候就不会产生多出或者减少的情况,解决了幻读

六.总结

事务:是为了解决并发情景下,数据变更过程的一致性

事务隔离级别:并发模式下不存在事务之间完全的隔离,这也就造成了在事务执行过程中会产生不同程度的异常,而隔离级别的提出,就是为了解决不同程度的数据读取异常

MVCC:MVCC的提出是为了解决读写之间互相阻塞、死锁问题以及一致性读的问题,其核心是 Undo Log+ Read View。

注意:MVCC只是乐观锁的一种实现方式,单纯的MVCC是没有办法解决可重复读隔离级别下幻读的问题,必须结合其他的机制。比如innoDB就是采用MVCC+Next-key组合的方式来解决可重复度隔离级别下幻读问题的。

本文是《SQL必知必会》学习后的笔记,内容多来自此专栏

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值