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

背景

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

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


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 mvcc
淘宝月报
innodb 相关实现
consistent-read
极客时间- MySQL 专栏--事务到底是隔离的还是不隔离的

原文地址:https://www.cnblogs.com/goodAndyxublog/p/10660434.html

时间: 2024-11-05 22:07:12

锁住余额,为何还会更新异常?的相关文章

对LockWindowUpdate与GetDCEx的理解(以前不知道还可以锁住刷新)

MSDN如是说:The LockWindowUpdate function disables or enables drawing in the specified window. Only one window can be locked at a time. BOOL LockWindowUpdate(  HWND hWndLock   // handle to window); ParametershWndLock [in] Specifies the window in which dr

使用Python快速实现显示器关闭和锁住桌面

现在很多公司都需要考虑节能,特别对电方面的节能.因此很多公司都针对电脑进行管理,比如当你离开电脑时,需要把显示器处于关闭状态,同时让电脑处于锁定状态,这样有利于节能,同时也能延长电脑显示器的寿命,同时也可以让你的内容保密,不让无关人员看到相关的信息,以及可能误操作你的宝贵资料. 在没有使用这个功能程序之前,都是使用显示器的物理按钮来关闭显示,这样按的次数多了,就会导致按钮失灵,加速显示器的损坏.另外,还需要按快捷键对桌面锁住,这样也多了一点操作.使用用下面的脚本程序,就可以点击一下,即可以这两个

SharePoint Workflow Trouble Shooting(一)Task被锁住或者卡住

[译者按]Andy Li这篇文章,是我看过的最好的,最透彻的关于SharePoint Workflow架构的文章.通过阅读他的文章,我才清楚的了解了SharePoint Workflow的运作机制,并且在遇到问题的时候,知道如何下手查找问题.因此翻译过来,希望对Workflow的开发人员有帮助.本部分主要讲trouble shooting. 这篇博客是由Andy Li贡献的,他是SharePoint开发人员支持组的处理疑难问题的工程师.原文地址.这个关于Workflow的系列,是他贡献给社区的,

【java并发】(2) Java线程同步:synchronized锁住的是代码还是对象

在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行.synchronized既可以加在一段代码上,也可以加在方法上. 关键是,不要认为给方法或者代码段加上synchronized就万事大吉,看下面一段代码: class Sync { public synchronized void test() { System.out.println("test开始.."); try { Thread.sle

Oracle数据查看被锁住的用户

//lock_date是被锁住时间,如果为空证明这个用户没有被锁住 select username,lock_date from dba_users where username='GFMIS';  //GFMIS是数据库的用户 //给用户解锁 ALTER USER gfmis ACCOUNT UNLOCK;

Oracle 数据操作提示“记录被另一个用户锁住”

oracle数据中删除数据时提示“记录被另一个用户锁住” 解决方法: 1.查看数据库锁,诊断锁的来源及类型: select object_id,session_id,locked_mode from v$locked_object; 或者用以下命令: select b.owner,b.object_name,l.session_id,l.locked_mode from v$locked_object l, dba_objects b where b.object_id=l.object_id

java synchronized究竟锁住的是什么

刚学java的时候,仅仅知道synchronized一个线程锁.能够锁住代码,可是它真的能像我想的那样,能够锁住代码吗? 在讨论之前先看一下项目中常见关于synchronized的使用方法: public synchronized void syncCurrentObject() { System.out.println(Thread.currentThread().getName()+"..start.."+"-----"+System.currentTimeMi

加锁的位置 (eq:map<key,map<>> 双集合 怎么 只加锁 在用到的对象位置,而不是把整个集合锁住)

比如上边的map里套map 定义变量为data,例如组队副本 npc 为1 下有众多房间 即Map<1,<roomId,room>> ,处于多线程下,一个线程在 npc为1的下边建一个房间,房间id为1即Map<1,<1,room>> ,这时候另外一个线程同时也要在 npc为1下建一个房间id为2的房间 即 Map<1,<2,room>> ,那我代码里肯定是 先 <roomId,room> rooms  =  data.g

QReadWriteLock读写锁的一点测试(它是逻辑锁,并没有与实物相联系),只有锁住了读,才允许再次读,否则一概不允许

QReadWriteLock m_lock; void MyWidget::Button1(){ m_lock.lockForRead(); ShowMessage(tr("111")); m_lock.lockForRead(); ShowMessage(tr("222"));}此时,111和222都可以顺利执行(第一个是锁住了进行读,第二个还是读数据,所以放行) void MyWidget::Button1(){ m_lock.lockForRead(); Sh