【MySQL】事务管理

发布于:2024-09-18 ⋅ 阅读:(10) ⋅ 点赞:(0)

一、引入事务

       在我们日常生活中,MySQL使用频率是非常广泛的。在买票系统中,如果我们在查看一张票剩余的票数为1张时,此时刚好有两个人去买这张票,如果出现以下场景则会出错,因为他没有遵守一人一票的规则:

       该场景就是当火车票售票系统中,西安到兰州的车票只有一张的时候,此时客户端A进行买票,因为此时车票只有一张,那么if条件成立,客户端A进行买票,当客户端A在买票的途中,刚好进程轮转,此时客户端A还没有完成买票,车票还是只有一张,客户端B的if条件成立,客户端B进行买票,此时客户端A和客户端B都完成了买票事件,这个就造成了客户端A和客户端B都买到了票,但是这是不合理的。

       上面的操作在学习中,有一个锁的概念,我们在对共享资源进行获取时,需要进行加锁,MySQL中的事务也是如此,事务的操作是原子性的。

CRUD满足什么条件呢??

  • 买票的过程要是原子的(原子性)
  • 买票应该不能相互影响(隔离性)
  • 买完票应该要永久有效(持久性)
  • 买前和买后都要是确定的状态 (一致性)

二、什么是事务

       事务就是一组DML语句组成的,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体(原子性)。MySQL提供了一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。

       事务就是要做的或者所做的事情,主要用于处理操作量大,复杂度高的数据。假设一种场景:我毕业了,学校的教务系统后台MySQL中,不在需要你的数据了,要删除你的所有信息(一般不会),要么要删除你的基本信息(姓名,电话,籍贯等)的同时,也删除和你有关的其他信息,比如,你的各科成绩,你在学校的表现,甚至你在论坛中发过的文章等。这样,就需要多条MySQL语句构成,那么所有操作结合起来,就构成了一个事务。

       正如同,我们上面所说,一个MySQL数据库,可能不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成为事务,在向MySQL服务器中发起事务处理请求。但是每一条事务至少一条SQL语句,最多有很多条SQL语句,这样如果大家都访问同样的表数据,在不加保护的情况下,就绝对会出现问题,甚至,因为事务由多条SQL构成,那么也会存在执行到一半出错或者不想在执行的情况,那么已经执行的怎么办??

所有完整的事务绝对不是简单的sql集合,还需要满足如下四个属性:

  • 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某一个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度,串联性以及后续数据库可以自发性地完成预定的工作。
  • 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交,读提交,可重复读,串行化。
  • 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

三、为什么要有事务

       事务被MySQL编写者设计出来,本质上是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题,可以想一下,当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常,服务器宕机,因此事务的本质是为了应用层服务的。

       事务的版本支持,在MySQL中只有使用Innodb数据库引擎的数据库或表才支持事务。

四、事务常见的操作方式

       在进行事务操作前,我们需要先将事务隔离级别设置为读不提交。在MySQL8的版本中的设置命令如下:

SHOW VARIABLES LIKE 'transaction_isolation';

4.1 利用一个表来验证一些观点

我们先来创建一个表,我们可以使用该表来进行实现一些观点:

create table if not exists account(
    id int primary key,
    name varchar(50) not null default '',
    blance decimal(10,2) not null default 0.0
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;

非正常演示1——证明没有commit,在客户端崩溃后,MySQL自动会进行回滚(隔离级别设置为读未提交)

非正常演示2——证明在commit之后,客户端崩溃,MySQL数据不会在受到影响,已经持久化了

非正常演示3——进行对比实验,证明begin操作会自动更改提交方式,不会受MySQL是否自动提交影响

非正常演示4——证明单条SQL与事务的关系

4.2 在进行完上述的实验后,我们会得出一些结论:

  1. 如果没有设置保存点,也可以回滚,只能回滚到事务的开始,直接使用rollback命令(前提是事务还没有提交,如果事务已经提交的话,MySQL已经进行了持久化)
  2. 事务可以手动回滚,同时,当操作异常的时候,MySQL会自动回滚
  3. 对于InnoDB每一条SQL语句都被默认封装成为事务,会自动进行提交。(select 有特殊情况,因为MySQL有MVCC)
  4. 在上面的实验中,我们可以看到事务本身的原子性(回滚),持久性。

4.3 事务操作的注意事项:

  • 如果没有设置保存点,也可以进行回滚,只能回滚到事务的开始。直接使用rollback(前提是事务还没有提交)
  • 如果一个事务被提交了(commit),则不可以进行回退(rollback)
  • 可以选择回退到呢一个保存点
  • InnoDB支持事务,MyISAM不支持事务
  • 开始事务可以使用start transaction命令或者是begin命令

五、事务的隔离级别

5.1 如何理解隔离性

  1. MySQL服务可能会被多个客户端进程(线程)访问,访问的方式一事务方式进行
  2. 一个事务可能由多条SQL构成,也就意味着,任何一个事务,都有执行前,执行中,执行后的阶段,而所谓的原子性,其实就是让用户层,要么看到执行前,要么看到执行后,执行中出现问题可以随时进行回滚。所以单个事务对用户表现出来的特性就是原子性的。
  3. 但是,毕竟所有事务都要有一个执行过程,那么在多个事务各自执行多个SQL语句的时候,就还有可能会出现相互影响的情况。比如:多个事务同时访问一张表,甚至同一行数据。
  4. 数据库中,为了保证事务执行的过程中尽量不受干扰,就有了一个重要的特征:隔离性。
  5. 数据库中,允许事务受到不同程序的干扰,就有了一种重要的特征:隔离级别。

       在事务场景中,隔离往往是必要的,因为读读是不需要进行加锁的,写写是必须要进行串行操作的,读写并发是经常使用的,所以数据库需要做好这一方面的工作,数据库中的隔离性和隔离级别就是有关该操作的。

5.2 隔离级别

5.2.1 读未提交

       在该隔离级别中,所有的事务都可以看到其他事务没有提交的执行结果。(实际生产中不可能使用这种隔离级别的),这种隔离级别没有任何隔离性,也会有很多的并发问题,比如脏读,幻读,不可重复读等,我们上面为了做实验方便,用的就是这个隔离性。

5.2.2 读提交

       该隔离级别是大多数数据库的默认的隔离级别(不是MySQL默认的)。他满足了隔离的简单定义:一个事务只能看到其他的已经提交的事务所做的改变。这种隔离级别会引起不可重复读,即一个事务执行时,如果多次select,可能会得到不同的结果。

5.2.3 可重复读

       这是MySQL默认的隔离级别,他确保同一个事务在执行中,多次读取操作数据时,会看到同样的数据行,但是会有幻读问题。

5.2.4 串行化

       这是事务的最高隔离级别,他通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题,他在每一个读的数据行中加入了共享锁,但是可能会导致超时和锁竞争(这种隔离级别太极端了,实际生产中基本不使用)。

       隔离级别如何实现:隔离,基本都是通过锁来实现的,不同的隔离级别,锁的使用是不同的。常见的有:表锁,行锁,写锁,间隙锁,Nect-Key锁(GAP + 行锁)等。不过,我们目前认识即可,先关注上层使用。

5.3 查看与设置隔离性

5.3.1 查看隔离级别的语句

// 进行查看操作,MySQL在不同的版本中的语句也有所不同

// 查看全局事务的隔离级别
SELECT @@global.transaction_isolation;   

// 查看当前会话的事务隔离级别
SELECT @@session.transaction_isolation;

// 结果同上
SELECT @@transaction_isolation;

5.3.2 设置隔离级别的语句

// 设置当前会话 or 全局隔离级别的语句
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | 
READ COMMITTED | REPEATABLE READ | SERIALIZABLE}

// 举个例子
set session transaction isolation level read committed;
set global transaction isolation level committed;
set global transaction isolation level REPEATABLE READ;

       在MySQL中,如果我们的三个隔离级别不同,采用就近原则,隔离级别和局部变量相同。当我们设置了全局隔离级别,不会影响我们之前设置的局部隔离级别,只有当我们重新启动数据库后,我们会发现局部隔离级别和全局隔离级别的隔离级别是一样的。

5.4 一致性

       我们在一致性之前就学习了原子性,隔离性,持久性,这三种性质学习完毕之后,就自然而然的发现一致性也是可以保证的。

  • 事务执行的结果,必须是数据库从一个一致性状态,变到另一个一致性状态。当数据库值包含事务成功提交的结果时,数据库必须保持一致性状态。如果系统运行发生中断,某一个事务尚未完成而被迫中断,而该未完成的事务对数据库所做的修改已经被写入数据库,此时数据库就处于一种不正确(不一致)的状态,因此一致性是通过原子性来保证的。
  • 其实一致性和用户的业务逻辑强相关,一般MySQL提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是一致性是由用户决定的。
  • 而在技术上,通过原子性来保持一致性的。

六、进一步理解隔离性?

6.1 数据库并发的场景

  • 读 - 读:不存在任何问题,也不需要并发控制
  • 读 - 写:有线程安全的问题,可能会造成事务隔离性问题,可能会遇到脏读,幻读,不可重复读
  • 写 - 写:有线程安全的问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失

6.2 读 - 写(并发情况最多)(MVCC面试题)

       多版本并发控制(MVCC)是一种用来解决读 - 写冲突的无锁并发控制,为事务分配单向增长的职务ID,为每一个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照,所以MVCC可以为数据解决以下问题:

  • 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提供了数据库并发读写的性能
  • 同时还可以解决脏读,幻读,不可重复读等事务隔离问题但是不能解决更新丢失问题

       为了理解MVCC,我们还需要知道三个前置知识:3个记录隐藏字段,undo日志,read view。

6.2.1 3个记录隐藏字段(其实是4个)

  • DB_TRX_ID:6比特,最近修改(修改/插入)事务ID,记录创建这条记录的最后一次修改改记录的事务ID。
  • DB_ROLL_PTR:7比特,回滚指针是指向这条记录的上一个版本(简单理解为指向历史版本就行了,这些数据一般都在undo log中)。
  • DB_ROW_ID:6比特,隐含的自增ID(隐藏主键),如果数据表中没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。

       但是我们在索引那一篇博客中,我们会发现,当我们没有设置主键的时候进行查询是很浪费时间的,但是这里却说,如果没有主键,就会创建一个主键建立索引,因此搜索速度要快一点才对啊。为什么不快呢??因为这个主键是隐藏的,我们是看不见,是不能使用的。

  • 补充:其实实际上还有一个删除flag隐藏字段,既记录被更新或删除,并不代表真的删除,而是删除flag变了,因此数据还在存储中。

举个例子:假设测试表的结构如下:

// 创建表的结构
create table if not exists student(
    name varchar(11) not null,
    age int not null
);

// 插入一条数据
insert into student(name, age) values('张三', 30);

// 查看表的结构
select * from student;

根据3个记录隐藏对象,我们可以得出表的结构如下:

       我们目前并不知道创建该记录的事务ID,隐式主键,我们就默认设置为null和1,第一条记录也没有其他版本,所以设置回滚指针为null。

       如何理解这个回滚指针呢??我们会使用MySQL进行增删查改的操作,在这些操作中,可能会对数据进行更改,在进行更改后,我们需要将版本保留下来,为了方便找到该版本的上一个版本,我们可以使用指针来指向我们的上一个版本,进而形成了一个版本链表。

6.2.2 undo日志

       在MySQL中会存在很多的日志,MySQL想来都是以服务进程的方式在内存中运行的。我们之前所讲的所有机制:索引,事务,隔离性,日志等,都是在内存中完成的,即在MySQL内部的相关缓冲区中保存相关数据,完成各种判断操作,最后在合适的时候,将相关操作刷新到磁盘当中的。所以,我们这里理解的undo log,简单理解成为就是MySQL中的一段内存缓冲区,用来保存日志数据。

       但是,如果从头到尾所有的日志信息都存储到缓冲区中,不是会将缓冲区中的容量填满,在MySQL中,MySQLD会将这个缓冲区进行管理,会定时刷新缓冲区中的内容。

6.2.3 模拟MVCC

在了解完上面的内容后,我们需要进行举一个例子来巩固一下知识点:

现在有一个事务10(仅仅为了好区分),对student表中记录进行修改(update):将name(张三)改成name(里斯)。

事务10,因为要修改,所以要先将该记录加锁

在修改前,先将该行记录拷贝到undo log中,所以undo log中就有了一份副本数据(原理就是写时拷贝)

所以现在MySQL中有两行同样的记录,现在要修改原始记录中的name,改成‘里斯’,并且修改原始记录的隐藏字段DB_TRX_ID为当前事务10的ID,我们默认从10开始,之后一次递增,而原始记录的回滚指针DB_ROLL_PTR列,里面写入undo log中副本数据的地址,从而指向副本记录,表示我的上一个版本就是他

事务10提交,释放锁

现在又有一个事务11,对student表中记录进行修改(update):将age(24)改为age(18)。

事务11,因为也要进行修改,所以要先对该记录加上锁

修改前,先将该行记录拷贝到undo log中,所以undo log中就又有了一行副本数据。此时,新的副本,我们采用头插方式插入undo log

现在我们修改原始记录中的age,改成18,并且修改原始记录的隐藏字段DB_TRX_ID为当前事务11的ID。而原始记录的回滚指针DB_ROLL_PTR列,里面写入undo log中副本数据的地址,从而指向副本记录,表示我的上一个版本就是他

事务11提交,释放锁

这样,我们就有了以恶搞基于链表记录的历史版本链,所谓的回滚,就是使用历史数据覆盖当前数据。上面的一个个版本,我们可以称之为一个个快照。

       我们需要进行一些思考:因为在数据库中不可能只有改操作,还要有增删查操作!如果是删除操作呢?由于删除flag隐藏字段的存在,删除数据并不是清空数据,而是设置flag为删除即可,也可以形成版本。

       如果是插入操作呢?插入操作中插入,就是之前是没有数据的,那么插入操作也就没有历史版本,但是一般为了回滚操作,插入的数据也是要被放入undo log中的,如果当前事务commit了,那么这个undo log的历史插入记录就可以被清空了。

总结一下,我们可以理解在修改和删除操作中可以形成版本链,插入操作暂时不考虑。

如果是读取操作呢?

       首先,读取操作是不会对数做任何修改的,所以,对于读取操作维护多版本是没有任何意义的。不过有一个问题就是:读取操作是读取最新的版本,还是读取历史版本呢?

当前读:读取最新的记录就是当前读,增删改,都叫做当前读,读取操作也有可能是当前读;

快照读:读取历史版本(一般而言)就叫做快照读。

       我们可以看到在多个事务同时删改查的时候都是当前读,是要加锁的,那么如果同时有读取操作过来的话,如果也要读取最新版本(当前读)那么也就需要加锁,这就是串行化。

但是如果是快照读,读取历史版本的话,是不受加锁限制的,也就是可以并行执行,换言之,就是提高了效率,这个就是MVCC的意义所在。

那是什么决定了读取操作是当前读还是快照读??因为隔离级别。

那么为什么要有隔离级别呢??

       因为事务都是原子的,所以,无论如何,事务总有先有后。但是经过上面的操作,我们发现:事务从bagin -- CURD -- commit,是有一个阶段的,也就是事务有执行前,执行中,执行后的阶段,但是,不管怎么启动多个事务,总是有先有后的。那么多个事务在执行中,CURD操作是会交织在一起的,那么,为了保证事务的有先有后,是不是应该让不同的事务看到他该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。

6.2.4 Read View

       Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每一个事务开启时,都会被分配一个ID,这个ID是递增的,所以最新的事务的ID越大)。

       Read View在MySQL的源码中,就是一个类,本质是用来进行可见性的判断。当我们某一个事务执行快照读的时候,对该记录创建一个Read View读试图,把他比作条件用来判断当前事务能偶看到哪一个版本的数据,即可能是当前最新的数据,也可能是该行记录的undo log里面的某一个版本的数据。

下面我们来看一看Read View的结构:

class Reaview
{
private:
    // 高水位,大于等于这个ID的事务均不可见
    trx_id_t m_low_limit_id;

    // 低水位,小于这个ID的事务均可见
    trx_id_t m_up_limit_id;
    
    // 创建该 Read View 的事务ID
    trx_id_t m_creator_trx_id;

    // 创建视图时的活跃是事务ID列表
    ids_t m_ids;

    // 配合purge,标识该视图不需要小于m_low_limit_no的undo log,
    // 如果其他视图也不需要,则可以删除小于m_low_limit_no的undo log
    trx_id_tm_low_limit_no;
    
    // 标记视图是否被关闭
    bool m_closed; 
}
m_ids;  // 一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; // 记录m_ids列表中事务ID最小的ID(没有写错)
low_limit_id; // Readview生成时刻系统尚未分配的下一个事务ID,也就是
                // 目前出现过的事务ID的最大值 + 1(没有写错)
creator_trx_id; // 创建该ReadView的事务ID

       我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID,即:当前记录的DB_TRX_ID。那么现在我们手中的东西就有:当前快照读的Readview和版本链中的某一个记录的DB_TRX_ID。


网站公告

今日签到

点亮在社区的每一天
去签到