(一)并发篇:详解MySQL的事务和MVCC工作机制

2023年 12月 24日 69.8k 0

详解MySQL的事务和MVCC原理

1. 什么是事务?事务带来什么问题?如何解决?

2. MVCC是什么?它的原理是什么?用它解决了什么问题?

事务是什么?

事务是我们学习MySQL时,永远绕不开的话题。我们知道,当一个系统多线程运行时,并发带来的问题永远是最主要考虑解决的。因此,而MySQL用来解决并发问题的关键词即为事务。

事务主要将围绕以下几个维度展开,如图:

image.png

什么是事务?

事务的基本概念要先了解,我个人的理解是:要么有始有终,要么其实都没有

有一场景,用户A需要向用户B转账1000块钱,我们将步骤细分为四步,如下:

  • 从数据库读取用户A的余额
  • 用户A的余额扣1000元(如果余额>1000),将扣完的余额更新到数据库
  • 从数据库读取用户B的余额
  • 将用户B的余额加1000元,将增加后的余额更新到数据库

image.png

试想?如果在第二步,用户A的余额扣完后,服务器断电了,这1000元不是直接不翼而飞了?这换做谁都无法接受吧!

因此,便有了事务这一概念。MySQL通过事务来解决这一问题。在原先转账操作的基础上,在转账前后分别开启事务和提交事务。

如果开启事务后,中途发生意外(报错了,或是服务器断电),直接将 “这件事” 退回到事务开启前的状态。如果顺利执行完,就将事务提交,完毕!

总结来说,就是在一开始做事的时候,有个标记在初始位置,如果发现情况不对,赶紧跑回去。 如果一切顺利,就不用管了。

事务四大特性总结

事务肯定是有特点的嘛,分别是原子性,持久性,隔离性,一致性。下面我们分别展开说说!

原子性

如何理解原子性? 我个人的理解是:原子是世界上最小的单位,肯定是无法再做切割的。整个事务可以看成是一个原子,无法将事务再拆分开。因此一次事务内的所有操作,肯定是要么全部成功,要么直接挫骨扬灰(全部失败)。

事务的原子性由 undo log回滚日志来保证(后续会展开说)

持久性

如何理解持久性?上文提到的转账操作,如果事务提交了,还可以修改,或者是回滚,那问题可就麻烦了。那需要事务来做什么?因此,事务的持久性代表了:提交的事务是不可被修改的状态,已经彻底保存了下来。

事务的持久性由 redo log回滚日志来保证(后续会展开说)

隔离性

如何理解隔离性?如果同时有多个事务在运行,事务之间可以互相看到对方的事务,并修改对方事务内的信息,那事务就会彻底乱套啦。因此事务的隔离性代表:未提交的事务之间相互不可见,不可修改对方事务内的信息,保证数据一致性。

事务的持久性由 MVCC机制和上锁来保证(持久性是锁篇的重点)

一致性

如何理解一致性?事务的一致性表示,事务的操作前后的数据需要保持完整性约束。(例如,上述例子提到的1000块转账钱,只是从A账户跑到了B账户,并没有凭空消失,这就是数据的完整性)由原子性+持久性+一致性共同守护。

事务的并发问题

如果整个世界只允许单线程的细胞生物存在就好了,哈哈哈!上到各门编程语言,下到操作系统,并发的问题总是会被提到,MySQL的事务也不例外。肯定是允许多事务并发的,也会带来一些并发问题,总结如下:

脏读

如何理解脏读?假设有两个正在运行的事务,事务A能读取到事务B的未提交的数据,表明发生了脏读。这个问题就很严重啦!我画了个图举例,如下:

image.png

如果发生了脏读,那就表明在t5时刻查到的余额V1为2000,如果这时候是有两个人在做交易:

  • B在t5时刻给A看完余额记录
  • 随后在t6时刻,再把事务不是提交,而是直接回滚
  • 在t7时刻,查询到的余额V2为1000。这样就涉及到诈骗了!

脏读是绝对不允许发生的!

不可重复读

如何理解不可重复读?不可重复读是在解决了脏读的基础上,存在的其它问题。具体为:一个正在活跃的事务,在不同时刻查询到的数据不一致。,还是同样的图:

image.png

如果发生了不可重复读,即:

  • 在t4时刻,事务B将余额修改为2000,没有提交
  • 在t5时刻,事务A查到的余额V1为1000,没有出现脏读现象
  • 在t6时刻,事务B把事务提交了
  • 在t7时刻,事务A再次查询余额,此时读到其他提交的事务的值,查到的余额V2为2000,出现了不可重复读问题

幻读

如何理解幻读?在处理了脏读和不可重复读后,幻读的具体为:一个正在活跃的事务,在不同时刻查询到的数据的数量不一致。幻读更多关注的是数量,多次查询某个条件的数量,出现数量不一致的情况,如图:

image.png

如果发生了幻读,即:

  • 在t2时刻,查询到符合条件的用户数量为5
  • 在t4时刻,新启动的事务B将插入一条新用户,并随后提交
  • 在t6时刻,事务A再次执行相同的sql,此时查询到符合条件的用户数量为6,和一开始的数量对不上,出现了幻读问题

总结下事务的并发问题:

  • 从严重性考虑,脏读 > 不可重复读 > 幻读
  • 从性质分类,脏读:读到其他事务未提交的数据;不可重复读:前后读取的数据不一致;幻读:前后读取的数据数量不一致
  • 事务的隔离级别

    既然让事务并发操作,会带来这么多问题,那MySQL就不可能不处理这些问题,让这些问题破坏我们的数据。针对这不同的问题,MySQL同样有多种隔离级别来应对。分别是:读未提交,读提交,重复读,串行化

    从安全性考虑

    image.png

    从会产生的问题考虑

    image.png

  • 读未提交 = 安全裸奔,什么安全性都不顾,只要速度!
  • 读提交,可以解决脏读问题,但仍然存在不可重复读和幻读。是Oracle的默认隔离级别
  • 重复读,可以解决脏读,不可重复读,绝大多数幻读(个例除外),是MySQL的默认隔离级别
  • 串行化,不会存在并发问题,直接让事务变成读写互斥操作,一劳永逸,但性能最低
  • 不同隔离级别下读到的数据

    image.png

  • 如果隔离级别为读未提交,事务可以读到未提交的事务的数据,因此V1为2000,V2为2000。
  • 如果隔离级别为读提交,可解决脏读,但会出现不可重复读问题,因此V1为1000,V2为2000。
  • 如果隔离级别为读提交,可解决绝大多数并发问题,因此V1为1000,V2也为1000。
  • 如果隔离级别为串行化,在事务A执行读操作后,没提交事务;事务B执行写操作时,会发生阻塞,直到提交。
  • 隔离级别的原理

    我们在写sql时,执行单条insert, update, delete语句时,MySQL会默认开启事务(在sql的前后默认加上begin(开启事务), commmit(提交事务))。

    而执行关键的查询select sql时,有两种写法:

    • 普通的select ... 语句(快照读),会通过MVCC的方式来解决事务的并发问题
    • select ... for update(当前读),会通过直接给数据加锁的方式来解决事务的并发问题

    而根据不同的隔离级别,MVCC和上锁的粒度也有所不同,我会在后续展开详细介绍

    MVCC是如何解决事务并发问题的?

    在上章节我们提到,普通的select语句是通过MVCC的方式来解决事务的并发问题,MVCC(多版本并发控制) 是我们这次介绍的重点,具体从以下三部分展开:

    image.png

    什么是MVCC?

    在介绍MVCC之前,我们需要了解几个基础的概念。

    一. 普通的MySQL记录,还有几个我们看不到的隐藏字段:

    • trx_id: 最近的操作这条数据的事务ID;(对一条记录做增删改时,会在这条记录上留下“我是谁”的印记,即事务ID)

    • roll_pointer: 回滚指针,指向该条记录的上一个版本:(一条数据修改后,旧记录并不是被直接抹去,而是有新纪录的指针会指向它)

    • row_id: 虚拟字段,若表中没有主键,起到代替主键的作用

    举个例子,数据的变更图如下。

    在t1时刻,由id为50的事务创建了id = 1的记录,此刻他的trx_id为50,由于是新记录,roll_pointer为NULL。

    在t2时刻,由id为51的事务修改了id = 1的记录,将age从20改成18。等于这条记录的版本被更新了。当前最新版本记录的trx_id为51,而roll_pointer则会指向当前记录的上一个版本。

    image.png

    二. Read View(视图),MVCCd的重要组成,可以理解为数据的快照,相机的快门,每生成一个Read View时(按一下相机快门),总会有一些记录(图片)被保存下来。以下是Read View的关键内容:

    • create_trx_id:创建这个read view的事务ID
    • m_ids: 创建这个read view时,当前还活跃的事务ID集合(事务是允许并发的嘛!)
    • min_trx_id: 当前还活跃的事务最小ID,即m_ids集合中的最小值
    • max_trx_id: 下一个即将创建的事务的ID。相当于是一种预分配,提前告诉这个read view会有哪个事务会来,但是现在还没来!

    image.png

    通过上述的基本概念,我们就可以系统性的学习MVCC是如何工作的,read view, rtx_id, roll_pointer...

    如何用MVCC来判定当前的数据是否可见(MVCC工作原理)?

    如何理解数据是否可见这句话? 其实就是在查询数据时,查到了本不应该让他看到的数据,才导致的并发问题。比如说:脏读:查到了其他事务还没提交的数据;不可重复读和幻读:自己的数据还没提交,查到了其他事务提交的数据

    MVCC是如何利用Read View来判断数据是否可见的?

    当前我开启了一个事务,其中有查询语句,并且手里有一个read view,判别步骤如下:

  • 获取查询到的符合筛选条件的行数据,将当前行数据的最新版本的trx_id取出来
  • 如果trx_id == create_trx_id, trx_id和read view中的create_trx_id相同,表明这条记录的当前版本是我这个事务创建的。可见,直接返回当前的记录
  • 如果trx_id < min_trx_id,trx_id小于当前活跃事务的最小id,表明这条记录的当前版本已经被提交了(事务的id是持续递增的),可见,直接返回当前的记录
  • 如果trx_id ∈ m_ids, trx_id包含在read view中的m_ids中,表明这条记录的当前版本还是活跃的,如果可见,那就直接发生脏读了,肯定不可见。因此MVCC天生就会避免脏读,继续判断
  • 如果trx_id >= max_trx_id,trx_id大于等于read view中的max_trx_id,表明这条记录的当前版本对于这个read view来说,还未创建!肯定不可见,继续判断
  • 如果没有找到可见记录,则通过隐藏列中的回滚指针,找到这条记录的上一个版本,重复步骤2-5,直到找到为止。
  • 判别流程图可以总结为如下:

    image.png

    可以看下伪代码:

    row_data = select ...;
    
    while (row_data != null){
        if (row_data.trx_id == read_view.create_trx_id) {
            break;
        }
        if (row_data.trx_id < read_view.min_trx_id) {
            break;
        }
        if (read_view.m_ids contains row_data.trx_id) {
            row_data = row_data.poll_pointer;
            continue;
        }
        if (row_data.trx_id >= read_view.max_trx_id) {
            row_data = row_data.poll_pointer;
            continue;
        }
    }
    return row_data;
    
    

    不同隔离级别下的MVCC是如何工作的?

    读未提交不谈(这玩意没有任何防御措施),重点是读提交和重复读两种隔离机制,MVCC开启Read View的时机不同,因此安全性也不同

    对于重复读隔离级别,整个事务的运行阶段,只会创建一个Read View,事务内执行的select语句共用一个read view。(从一而终)

    对于读提交隔离级别,整个事务的运行阶段,只要执行一次select语句,就会马上创建一个Read View,然后用这个新的Read View做可见性判断。(始乱终弃)

    那在重复读的隔离级别下,创建唯一的Read View的时机?

  • 如果是通过begin开启的事务,只有在第一次执行select语句的时候才会创建Read View(类似懒加载的机制)
  • 如果是通过start transiction with consistent snapshot开启的事务,在创建的时候马上就会创建Read View(类似饿汉式加载机制)
  • 读提交下的MVCC

    举个例子,下面是两个事务在不同时刻对某条相同记录执行的CRUD操作,

    image.png

    一. 在T5时刻,事务B对余额要做一次查询操作,此时会创建一个Read View,如下:

    image.png

    根据判断规则,余额的最新记录为2000,这是由id为50的记录创建的,该read_view的活跃事务id集合为[50, 51],50的事务对于当前的read_view是不可见的。因此,通过回滚指针找到上一条版本记录,即余额为1000,而余额为1000的记录则是由id < 50的事务创建的,小于min_trx_id。因此,该版本记录对于这个read_view是可见的,余额V1=1000。

    二. 在T7时刻,事务B再次对余额做一次查询操作,由于是读提交隔离级别,因此会创建新的Read View,如下:

    image.png

    根据判断规则,余额的最新记录为2000,这是由id为50的记录创建的,该read_view的最小活跃事务ID为51,小于min_trx_id。因此,该版本记录对于这个read_view是可见的,余额V2=2000。一个事务内前后时刻读取的数据不一致,出现了不可重复读的并发问题。

    重复读下的MVCC

    还是相同的例子,隔离级别升级为重复读

    image.png

    一. 在T5时刻,事务B对余额要做一次查询操作,此时会创建一个Read View,如下:

    image.png

    根据判断规则,余额的最新记录为2000,这是由id为50的记录创建的,该read_view的活跃事务id集合为[50, 51],50的事务对于当前的read_view是不可见的。因此,通过回滚指针找到上一条版本记录,即余额为1000,而余额为1000的记录则是由id < 50的事务创建的,小于min_trx_id。因此,该版本记录对于这个read_view是可见的,余额V1=1000。

    二. 在T7时刻,事务B再次对余额做一次查询操作,由于是重复读隔离级别,因此不会创建新的Read View,复用T5时刻创建的Read View。因此,时刻T7的判断和T5相同,余额V2仍然为1000。不可重复读的问题解决。从理论上来说,一个事务内,对任何查询都沿用同一个read_view,在这个期间无法看到其他事务插入的数据,也能避免幻读问题(除个例)。

    MVCC真能完全解决幻读嘛?

    毫无疑问,在重复读隔离级别下,对于普通的幻读,MVCC是可以避开的,如下图:

    image.png

    在t3时刻再次执行t2时刻的select语句,由于t3时刻使用的read view的和t1时刻的一样。因此,在t2时刻,执行的insert语句对于该read_view是不可见的, 因此select语句的结果仍然可以和t1时刻的保持一致。

    但如果是本事务去对其他事务insert的数据做修改呢?

    在讲解之前,需要科普一下快照读和当前读的概念

    • 快照读:读的是数据的版本,(读取到的不一定是最新的版本,因为有可能不可见)。普通的select ... 用的就是快照读
    • 当前读:读的是一定是数据的最新版本。执行insert, update, delete用的都是当前读(需要知道操作数据的最新值,才能操作); 还有特殊的 select ... for update语句。

    特例一:两次select间穿插update

    存在一张user表,该表里面没有数据,存在字段id, age, mal5。

    image.png

    一. 在T1时刻,A开启事务,执行select语句,由于表没有数据,因此查询结果为NULL

    二. 在T2时刻,B事务插入了一条id=10的数据,并马上提交事务

    三. 在T3时刻,事务A执行一条update语句,需要对id = 10的数据做更新,此时采用的是当前读,由于数据库有一条已经提交了的id = 10的数据,因此能读到,并修改。此时,id=10这条记录的最新版本的trx_id为事务A

    四.在T4时刻,事务A执行同样的select语句,此时对于id=10这条记录来说,最新版本的记录的trx_id为事务A,正好满足条件:read_view.create_trx_id == trx_id。结果为可见,尴尬的幻读问题出现了。

    特例二:两次select; 一次select, 一次select...for update

    还是同样的表,执行顺序变为如下:

    image.png

    一. 在T1时刻,A开启事务,执行select语句,由于表没有数据,因此查询结果为NULL

    二. 在T2时刻,B事务插入了一条id=10的数据,并马上提交事务

    三. 在T3时刻,事务A执行的是select ... for update,不同于普通的select,为当前读,一定会读取数据最新的版本,事务B提交的记录对其是可见的,尴尬的幻读问题又出现了!

    思考题,为何我的数据修改了不生效呢?

    看下图,开启事务后,执行第一次select语句的时候,我们可以看到表里的数据,所有的id值均等于age值,随后执行了一条update语句,再次执行同样的select语句,为什么数据没有改变? 什么样的场景能复现该操作?

    image.png

    其实问题的关键在于:执行了update语句,受到影响的行数居然为0。数据必然是提前被其他事务修改了。答案如下:

    image.png

    一. 在T1时刻,A开启事务,执行select语句,正常返回数据,可以看到所有的id均等于age

    二. 在T2时刻,B事务将表里的age全部+1,并马上提交事务

    三. 在T3时刻,事务A执行的是update语句,将id == age的数据中的age都修改为0。此刻会进行当前读,读取最新数据。由于最新数据的age全被+1,已经不满足id == age这个条件,因此受影响的行数为0。

    四. 在T4时刻,事务A再次执行的是普通的select语句,为快照读。这时候会从数据的最新版本开始查找,事务A此时用的read_view是在T1时刻创建的,(此时事务B操作的sql均不可见,trx_id >= read_view.max_trx_id,对于当时的事务A来说,事务B还没创建,所以肯定是读不到的)。因此会通过行的roll_pointer查找到上个版本的数据,因此查找的数据仍和T1时刻相同!!

    总结下MVCC的工作原理

    对于事务产生的脏读,不可重复读,幻读问题。对于MVCC来说,活跃的事务数据均是不可见的,因此脏读问题自然解决。

    MVCC的读提交隔离级别,生成的是语句级别的快照Read View(每次select都会创建),不可重复读,幻读问题仍然存在。

    MVCC的重复读隔离级别,生成的是事务级别的快照Read View(一个事务只会有一个),从开始到结束都会保持一致。即使有别的事务插入和修改数据,对于普通的快照读都是不可见的,因此可解决不可重复读和幻读问题

    未完待续...........还会讲MySQL的锁,上锁规则,死锁分析...重量级的还没来

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论