背景

现有一个交易系统,每次交易都会更新余额。出账扣减余额,入账增加余额。为了保证资金安全,余额发生扣减时,需要比较现有余额与扣减金额大小,若扣减金额大于现有余额,扣减余额不足,扣减失败。

余额表(省去其他字段)结构如下:

  1. CREATE TABLE `account`
  2. (
  3. `id` bigint(20) NOT NULL,
  4. `balance` bigint(20) DEFAULT NULL,
  5. PRIMARY KEY (`id`)
  6. ) ENGINE = InnoDB
  7. DEFAULT CHARSET = utf8mb4
  8. COLLATE = utf8mb4_bin;

更新余额方法语序如下:

锁住余额,为何还会更新异常? - 图1

由于存在并发更新余额的情况,在 t3 时刻,使用写锁锁住该行记录。这样就能保证事务执行期间不会有其他事务提交变更。

现在我们假设有两个事务正在发执行该语序,执行顺序如图所示。

锁住余额,为何还会更新异常? - 图2

假设 id=1 记录 balance=1000,事务隔离等级为 RR。小伙伴们可以根据这个执行时序可以先思考下 t3,t5,t6,t7 结果。

注: 以上时序,顺序执行。但是事务 1 执行到 t3 时刻,t4 时刻,事务 2 执行时将会被阻塞,后续无法执行。 t4 时刻之后,只能先将 事务 1 语序执行,事务提交完成后,才能执行 事务 2 剩余语句。

下面放出问题的答案。

t3 (1,1000)

t5 (1,1000)

t4 (1,900)

t6 (1,1000)

有没有跟你结果的不太一样?

事务 1 查询结果基本没什么问题,事务 2 同一个事务内查询结果却不同。

现在我们先带着疑问,看完下面 MySQL 的相关原理,你就会明白一切。

相关原理

MVCC

假设在 RR 下,下图 id=1 balance=1000

锁住余额,为何还会更新异常? - 图3

上图时序顺序可以执行

事务 1 将 id=1 记录 balance 更新为 900。然后 t5 查询结果肯定还是 id=1 balance=1000,不然就读取到脏数据,不符合当前事务隔离级别。

从上面例子可以看到 id=1 的记录存在两个版本,一个为 balance=1000 ,一个为 balance=900

MySQL 使用 MVCC 实现该功能。

MVCC:Multiversion concurrency control,多版本并发控制。摘录一段淘宝数据库月报的解释:

多版本控制: 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,与Postgres在数据行上实现多版本不同,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。

可以看到 MVCC 主要用来提高并发,还可以用来读取老版本数据。下面介绍 MVCC 实现的原理。

首先我们先看下 MySQL 记录结构。

锁住余额,为何还会更新异常? - 图4

可以看到 MySQL 行记录除了真实数据以外,还会存在三个隐藏字段,用来记录额外信息。

DB_TRX_ID:事务id。
DB_ROLL_PTR: 回滚指针,指向 undolog。
ROW_ID:行 id,与此次无关。

具体行记录结构,可以参考掘金的小册『 MySQL 是怎样运行的:从根儿上理解 MySQL』,说实话小册写的真的很好,收益颇丰。哈哈。

MySQL 通过 DB_ROLL_PTR 找到 undolog,而 undolog 记录数据的变更。这样 MySQL 就能推导出变更之前记录内容。

查找过程如下:

锁住余额,为何还会更新异常? - 图5

若需要知道 V1 版本记录,首先根据当前版本 V3 的 DB_ROLL_PTR 找到 undolog,然后根据 undolog 内容,计算出上一个版本 V2。以此类推,最终找到 V1 这个版本记录。

V1,V2 并不是物理记录,没有真实存在,仅仅具有逻辑意义。

一行数据记录可能同时存在多个版本,但并不是所有记录都能对当前事务可见。不然上面 t5 就可能查询到最新的数据。所以查找数据版本时候 MySQL 必须判断数据版本是否对当前事务可见。

一致性视图

MySQL 会在事务开始后建立一个一致性视图(并不是立刻建立),在这个视图中,会保存所有活跃的事务(还未提交的事务)。

假设当前事务创建活跃事务数组为如下图。

锁住余额,为何还会更新异常? - 图6

判断记录版本对于当前事务是否可见时,基于以下规则判断:

  1. 若记录版本事务 id 小于当前活跃事务 id 数组最小值,如 id 为 40,小于 45,代表当前记录版本的事务已提交,当前记录对于当前事务可见。
  2. 若记录版本事务 id 大于当前活跃事务数组的最大值,如记录版本事务 id 为 100, 大于数组最大事务 id 90。说明了这个记录版本是当前事务创建之后生成,所以记录对于当前事务不可见。
  3. 若记录版本事务 id 是当前活跃数组事务之一,如记录数据版本事务 id 为 56。代表记录版本所属事务还未提交,所以记录对于当前事务不可见。
  4. 若记录版本事务 id 不是当前活跃数组事务之一,但是事务 id 位于数组最小值与最大值之一。如记录事务 ID 57。代表当前记录事务已提交,所以记录对于当前事务可见。
  5. 若记录版本事务 id 为当前事务 id,代表该行数据是当前事务变更的,当然得可见。

4 这个规则可能比较绕,结合上面图片比较好理解。

以上判断规则可能比较抽象,我们将其总结下面几句话。

  1. 未提交事务生成的记录版本,不可见。
  2. 视图生成前,提交事务生成记录版本可见。
  3. 视图生成后,提交事务生成记录版本不可见。
  4. 自身事务更新永远可见。

一致性视图只会在 RR 与 RC 下才会生成,对于 RR 来说,一致性视图会在第一个查询语句的时候生成。而对于 RC 来说,每个查询语句都会重新生成视图。

当前读与快照读

MySQL 使用 MVCC 机制,可以 读取之前版本数据。这些旧版本记录不会且也无法再去修改,就像快照一样。所以我们将这种查询称为快照读。

当然并不是所有查询都是快照读,select …. for update/ in share mode 这类加锁查询只会查询当前记录最新版本数据。我们将这种查询称为当前读。

问题分析

讲完原理之后,我们回过头分析一下上面查询结果的原因。

这里我们将上面答案再贴过来。

锁住余额,为何还会更新异常? - 图7

事务隔离级别为 RR,t1,t2 时刻两个事务由于查询语句,分别建立了一致性视图。

t3 时刻,由于事务 1 使用 select.. for update 为 id=1 这一行上了一把写锁,然后获取到最新结果。而 t4 时刻,由于该行已被上锁,事务 2 必须等待事务 1 释放锁才能继续。

t5 时刻根据一致性视图,不能读取到其他事务提交的版本,所以数据没变。t7 时刻余额扣减 100,t8 时刻提交事务。

此时最新版本记录为 id=1 balance=900

由于事务 1 事务提交,行锁被释放,t4 获取到写锁。由于 t4 是当前读,所以查询的结果为最新版本数据(1,900)。

重点来了。t6 查询时,id=1 这条记录最新版本数据为 (1,900)。但是最新版本事务 id,属于事务 2创建之后未提交的事务,位于活跃事务数组中。所以最新记录版本对于事务2 是不可见的。没办法只能根据 undolog 去读取上一版本记录 (1,1000) 。这个版本记录刚好对于事务 2 可见。

若当前事务隔离级别修改成 RC,那么结果就与 RR 不同。各位读者自行分析一下。

下面贴一下 RC 答案。

锁住余额,为何还会更新异常? - 图8

帮助文档

mysql mvcc
淘宝月报
innodb 相关实现
consistent-read
极客时间- MySQL 专栏—事务到底是隔离的还是不隔离的