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

Updated on with 0 views and 0 comments

背景

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

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


CREATE TABLE `account`
(
  `id`      bigint(20) NOT NULL,
  `balance` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_bin;

更新余额方法语序如下:

155352192415531cf0b6d7bc943bb92d7bae39ed062e9.png

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

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

155360388396258e72fd5e6594eb98813bfcd3d669383.png

假设 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

1554298213401c149ef8205b742499ab48009be7ee56b.png

上图时序顺序可以执行

事务 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 记录结构。

1553605546873d59eb92539f54c8dbea2472a7d7084a4.png

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

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

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

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

查找过程如下:

1553605512647dd50a0ecd8e24d7181fb55c26dda64ed.png

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

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

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

一致性视图

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

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

155369351894096ad3291e93249cb9619c04144e21ebd.png

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

  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 这类加锁查询只会查询当前记录最新版本数据。我们将这种查询称为当前读。

问题分析

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

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

155369504013300e840bd99ce4f468c7ab717b4cc5bab.png

事务隔离级别为 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 答案。

1553695417855dde81b636f584b3ba979b4007d1ca2c0.png

帮助文档

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


本文首发于: https://studyidea.cn/articles/2019/08/26/1566790545895.html

欢迎关注我的公众号:程序通事,获得日常干货推送。如果您对我的专题内容感兴趣,也可以关注我的博客:studyidea.cn