MySQL 可重复读,差点就让我背上了一个 P0 事故!

小黑黑的碎碎念

哎,最近有点忙,备考复习不利,明天还要搬家,好难啊!!html

本想着这周鸽了,可是想一想仍是不行,爬起来,更新一下,周更可不能断。偷懒一下,修改一下以前的一篇历史文章,从新发布一下。mysql

先赞后看,微信搜索「程序通事」,关注就完事了sql

P0 事故:余额多扣!

这是一个真实的生产事件,事件原由以下:数据库

现有一个交易系统,每次产生交易都会更新相应帐户的余额,出帐扣减余额,入帐增长余额。数组

为了保证资金安全,余额发生扣减时,须要比较现有余额与扣减金额大小,若扣减金额大于现有余额,扣减余额不足,扣减失败。安全

帐户表(省去其余字段)结构以下:微信

CREATE TABLE `account`
(
    `id`      bigint(20) NOT NULL,
    `balance` bigint(20) DEFAULT NULL,
    PRIMARY KEY (`id`)

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_bin;
复制代码

扣减余额时,sql 语序以下所示:并发

更新余额 sql 语序

ps:看到上面的语序,有没有个小问号?为何相同查询了这么屡次?ide

其实这些 SQL 语序并不在同个方法内,而且有些方法被抽出复用,因此致使一些相同查询结果没办法往下传递,因此只得再次从数据库中查询。高并发

为了防止并发更新余额,在 t3 时刻,使用写锁锁住该行记录。若加锁成功,其余线程的若也执行到 t3,将会被阻塞,直到前一个线程事务提交。

t5 时刻,进入到下一个方法,再次获取帐户余额,而后在 Java 方法内比较余额与扣减金额,若余额充足,在 t7 时刻执行更新操做。

上面的 SQL 语序看起来没有什么问题吧,实际也是这样的,帐户系统已经在生产运行好久,没出现什么问题。可是这里须要说一个前提,系统数据库是 Oracle

可是从上面表结构,能够得知这次数据库被切换成 MySQL,系统其余任何代码以及配置都不修改(sql 存在小改动)。

就是这种状况下,并发执行发生余额多扣,即实际余额明明小于扣减金额,可是却作了余额更新操做,最后致使余额变成了负数。

下面咱们来重现并发这种状况,假设有两个事务正在发执行该语序,执行顺序如图所示。

注意点:数据库使用的是 MySQL,默认事务隔离等级,即 RR。数据库记录为 id=1 balance=1000,假设只有当时只有这两个事务在执行。

各位读者能够先思考一下,t2,t3,t4,t5,t6,t11 时刻余额多少。


下面贴一下事务隔离等级RR 下的答案。

事务1 的查询结果为:

  • t2 (1,1000)
  • t4 (1,1000)
  • t6 (1,1000)

事务 2 的查询结果为:

  • t3 (1,1000)
  • t5 (1,900)
  • t11 (1,1000)

有没有跟你想的结果的同样?

接着将事务隔离等级修改为 RC,一样再来思考一下 t2,t3,t4,t5,t6,t11 时刻余额。


再次贴下事务隔离等级RC 下的答案。

事务1 的查询结果为:

  • t2 (1,1000)
  • t4 (1,1000)
  • t6 (1,1000)

事务 2 的查询结果为:

  • t3 (1,1000)
  • t5 (1,900)
  • t11 (1,900)

事务 1 的查询结果,你们应该会没有什么问题,主要疑问点应该在于事务 2,为何换了事务隔离等级结果却不太同样?

下面咱们先带着疑问,了解一下 MySQL 的相关原理 ,看完你就会明白这一切。

  • MVCC
  • 一致性视图
  • 快照读与当前读

MVCC

咱们先来看下一个简单的例子,

事务隔离等级为 RR , id=1 balance=1000

更新时序

事务 1 将 id=1 记录 balance 更新为 900,接着事务 2 在 t5 时刻查询该行记录结果,很显然该行记录应该为 id=1 balance=1000

若是 t5 查询最新结果 id=1 balance=900,这就读取到事务 1 未提交的数据,显然不符合当前事务隔离级别

从上面例子能够看到 id=1 的记录存在两个版本,事务 1 版本记录为 balance=1000 ,事务 2 版本记录为 balance=900

上述功能,MySQL 使用 MVCC 机制实现功能。

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

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

能够看到 MVCC 主要用来提升并发,还能够用来读取老版本数据。

在学习 MVCC 原理以前,首先咱们须要了解 MySQL 记录结构。

行记录

如上图所示,account 表一行记录,除了真实数据以外,还会存在三个隐藏字段,用来记录额外信息。

  • DB_TRX_ID:事务id。

  • DB_ROLL_PTR: 回滚指针,指向 undolog。

  • ROW_ID:行 id,与这次无关。

MySQL InnoDB 里面每一个事务都会有一个惟一事务 ID,它在事务开始的时候会跟 InnoDB 的事务系统申请的,而且严格按照顺序递增的。

每次事务更新数据时,将会生成一个新的数据版本,而后会把当前的事务 id 赋值给当前记录的 DB_TRX_ID。而且数据更新记录(1,1000---->1,900)将会记录在 undo log(回滚日志)中,而后使用当前记录的 DB_ROLL_PTR 指向 und olog。

这样 MySQL 就能够经过 DB_ROLL_PTR 找到 undolog 推导出以前版本记录内容。

查找过程以下:

查找过程

若须要知道 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,t2,t3 时刻两个事务因为查询语句,分别创建了一致性视图。

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

t6 时刻根据一致性视图,不能读取到其余事务提交的版本,因此数据没变。t8 时刻余额扣减 100,t9 时刻提交事务。

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

因为事务 1 事务已提交,行锁被释放,t5 成功获取到锁。因为 t5 是当前读,因此查询的结果为最新版本数据(1,900)。

重点来了,当前这条记录的最新版本数据为 (1,900),可是最新版本事务 id,倒是事务 2 建立以后未提交的事务,位于活跃事务数组中。因此最新记录版本对于事务 2 是不可见的。

没办法只能根据 undolog 去读取上一版本记录 (1,1000) ,这个版本记录恰好对于事务 2 可见,因此 t11 的记录为 (1,1000)

而当咱们将事务隔离等级修改为 RC,每次都会从新生成一致性视图。因此 t11 时刻从新生成了一致性视图,这时候事务 1 已提交,当前最新版本的记录对于事务 2 可见,因此 t11 的结果将会变为 (1,900)

总结

MySQL 默认事务隔离等级为 RR,每一行数据(InnoDB)的均可以有多个版本,而每一个版本都有独一的事务 id。

MySQL 经过一致性视图确保数据版本的可见性,相关规则总结以下:

  • 对于 RR 事务隔离等级,普通查询仅能查到事务启动前就已经提交完成的版本数据。
  • 对于 RC 事务隔离等级,普通查询能够查到查询语句启动前就已经提交完成的版本数据。
  • 当前读老是读取最新版本的数据。

帮助文档

1: dev.mysql.com/doc/refman/…
2 mysql.taobao.org/monthly/201…
3 mysql.taobao.org/monthly/201…
4 dev.mysql.com/doc/refman/…
5 极客时间- MySQL 专栏--事务究竟是隔离的仍是不隔离的

欢迎关注个人公众号:程序通事,得到平常干货推送。若是您对个人专题内容感兴趣,也能够关注个人博客:studyidea.cn

公号底图
相关文章
相关标签/搜索