欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

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

程序员文章站 2023-11-19 12:22:10
背景 现有一个交易系统,每次交易都会更新余额。出账扣减余额,入账增加余额。为了保证资金安全,余额发生扣减时,需要比较现有余额与扣减金额大小,若扣减金额大于现有余额,扣减余额不足,扣减失败。 余额表( 省去其他字段 )结构如下: sql CREATE TABLE ( bigint(20) NOT NU ......

背景

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

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

create table `account`
(
  `id`      bigint(20) not null,
  `balance` bigint(20) default null,
  primary key (`id`)
) engine = innodb
  default charset = utf8mb4
  collate = utf8mb4_bin;

更新余额方法语序如下:

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

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

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

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

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

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

上图时序顺序可以执行

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

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

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

db_trx_id:事务id。
db_roll_ptr: 回滚指针,指向 undolog。
row_id:行 id,与此次无关。

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

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

查找过程如下:

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

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

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

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

一致性视图

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

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

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

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

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

问题分析

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

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

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

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

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

帮助文档





极客时间- mysql 专栏--事务到底是隔离的还是不隔离的