MySQL:事务

MySQL:事务


事务概念

在实际生活中,修改一个人的信息往往包含多个步骤,比如说银行转账,要先扣除转账者的钱,再增加收账者的钱。在MySQL中,这就至少对应两条SQL语句,我们把共同完成一个功能而组成的多条SQL语句,称为一个事务。

一个事务最重要的问题就是:不能被打断,即原子性,一个事务要么还没开始,要么已经完成。就比如说银行转账时,先用第一条SQL将转账方的钱减少,第二条SQL将收账方的钱增加。结果第一条SQL执行完毕后,服务器崩溃了,最后钱就不翼而飞了。

那么这两步操作,共同构成一个事务,要么这两步都操作完了,要么还没有开始操作。那么MySQL是如何实现的呢?这里用了一个回滚机制,如果一个事务在操作过程中被打断,那么会这个事务中的所有操作还原,这个过程叫做回滚。回滚相关内容会在博客后文分析。

另外的,一个数据库中,一般不止一个用户在操作,如果一个用户正在修改数据,另一个用户在读取数据,也会导致错误。

比如之前银行转账案例,当执行完第一条SQL减少了转账者的钱。此时用户刚好来查账,执行另一个事务(SQL语句),一查发现自己的钱少了,而对方没有收到钱。此时就需要对多个事务进行隔离,隔离也会在后文进行讲解。

一个完整的事务,包含以下四个特性:

  • 原子性 Atomicity:一个事务中的所有操作,要么全部完成,要么还没开始,不允许在中间环节结束。
  • 隔离性 Isolation:数据库允许多个并发事务同时对其数据进行增删查改,而不出现错误。
  • 持久性 Durability :事务结束后,对数据进行持久化保存,即存储在硬盘中。
  • 一致性 Consistency:事务前后,数据库的完整性没有被破坏,即写入/查询的数据,必须符合业务要求。

以上四个特性简称为ACID

一致性确保数据库在事务执行前后都处于一个合法的状态。还是用银行转账的例子来说明。假设A账户有100元,B账户有200元,现在A要给B转账50元。

  • 事务开始前:A账户有100元,B账户有200元,总金额是300元。
  • 事务执行中
    1. 从A账户扣除50元,A账户余额变为50元。
    2. 给B账户增加50元,B账户余额变为250元。
  • 事务结束后:A账户有50元,B账户有250元,总金额仍然是300元。

在整个过程中,A和B账户的总金额保持不变,这就是一致性。无论事务成功还是失败,数据库的状态都必须符合预期的规则和约束。

一致性确保了数据库在任何时候都不会出现违反规则的状态。例如,在转账过程中,不能出现A账户的钱已经扣了,但B账户的钱还没增加的情况,这样会导致总金额不一致,破坏了数据库的完整性。


事务操作

通过show engines指令,查看各个引擎的信息:

其中Transactions表示事务,主流引擎中InnoDB支持事务,而MyISAM不支持事务。

  • 查看自动提交:
sql 复制代码
show variables like 'autocommit';

ON表示打开自动提交,自动提交情况下每一条语句都会被视为一个事务,为了让我们的事务正常执行起来,此时要关闭自动提交。

sql 复制代码
set autocommit = 0;

如果你已经确定自己表使用的数据库是InnoDB,那么就可以开始操作事务了!

此处使用的示例表结构如下:

sql 复制代码
create table testTransaction(
id int primary key,
name varchar(20),
tel varchar(11)
) engine = 'InnoDB';

事务提交

  • 开始事务

开始事务的方法有两种:start transactionbegin

此时通过begin开始了一个事务,随后插入了两条数据,最后直接退出MySQL。只要事务没有提交,那么就不算一个完整的事务,按照之前对事务的理解,这两条数据应该没有被插入。

重新进入数据库后,发现刚刚的两条数据没有被插入,也就是回到了事务开始前。

  • 提交事务

如果一个事务已经执行完所有语句,那么最后需要使用commit来提交事务。


事务回滚

  • 设置保存点

在事务执行过程中,可以设置保存点,保存点用于回滚。

sql 复制代码
savepoint 保存点名;

开启事务后,插入了三条数据,并在第一条数据后面设置保存点s1,第二条数据后面设置保存点s2。那么这些保存点有啥用呢?这就要和回滚结合操作了。

  • 回滚

回滚用于在一个事务内部进行撤销操作,即取消之前的某些操作。

sql 复制代码
rollback to 保存点;

回滚到s2后,可以发现少了一条数据,这就是因为最后一次操作被回滚覆盖了,也就是被撤销了。

也可以直接使用rollback,直接回滚到事务的最开始:

要注意的是,回滚只能在一个事物内部使用,一旦事务提交,就无法回滚了。


事务隔离

数据库需要面对并发场景,很可能同时有多个事务在操作数据库,事务是由多条SQL语句构成的,那么多个事务就有可能互相访问到不完整的数据。因此数据库需要事务,让每个事务都具有原子性,而原子性的实现,就是基于隔离。

隔离级别

事务隔离分为四个级别:

  • 读未提交 Read Uncommitted

  • 读提交 Read Commited

  • 可重复读 Repeatable Read

  • 串行化 Serializable

  • 查看隔离级别

可以通过指令select @@session.tx_isolation查看当前对话的隔离级别,通过select @@global.tx_isolation查看全局默认隔离级别。

在MySQL中,事务的默认隔离级别是可重复读。

  • 设置隔离级别
sql 复制代码
set 范围 transaction isolation level 隔离级别;

范围:

  • session:当前对话
  • global:全局

隔离级别:

  • read uncommitted:读未提交
  • read committed:读提交
  • repeatable read:可重复读
  • serializable:串行化

读未提交

在读未提交隔离级别下,所有事务都可以看到其他事务未提交的执行结果,也就是相当于没有任何隔离性

如图,左右是两个终端,用于模拟两个操作,左侧开启事务后,插入了两条数据,右侧则将隔离级别设置为了读未提交。可以发现左侧每插入一个数据,右侧都可以直接看到。

一个事务读取到别的事务未提交的数据,称为脏读。在实际生产中不会使用这个隔离级别,因为这个级别相当于没有隔离。


读提交

读提交是大多数数据库的默认隔离级别,一个事务只能看到其它事务已经提交的数据

如图,左侧对话开启了一个事务,右侧对话设置隔离级别为读提交,并开启了一个事务。在左侧更新数据后,右侧事务查询了一次,但是查询不到更新结果,因为左侧事务没有提交。

当左侧事务提交后,右侧事务再查询,就可以看到更新结果了。

但是这会造成不可重复读问题:在一个事务内部,前一秒和后一秒读取相同内容,结果不一样,称为不可重复读

比如上图中,右侧对话进行了两次查询,两次查询到小龙的电话号码不同。假设要从数据库中查询电话号码,发短信提醒用户交水费,第一次查询后給电话号444444发送短信,第二次查询后发现电话号变成了123456,那么还要再给123456发送一次短信吗?假设两个电话号都是小龙的,那么发送两次短信就打扰到人家了,如果第一次的444444是废弃号码,那么要是不发送短信小龙就收不到消息了!

为了解决这个问题,还有下一级隔离级别,可重复读。


可重复读

可重复读是MySQL的默认隔离级别,它确保一个事务中,多次读取时不会出现不一样的数据

右侧对话先将隔离级别设置为可重复读,随后两边同时开始事务。右侧对话首先进行一次查询。

随后左侧对话更新数据后,插入数据:

左侧完成整个事务并提交,右侧再查询,发现数据不变。也就是说在可重复读的情况下,当前事务会屏蔽其他事务提交的修改,从而保证前后查询的一致。

部分数据库在可重复读情况下,会有幻读问题:虽然可重复读可以屏蔽其它事务的update修改,但是无法屏蔽insertdelete这样的插入删除语句,这叫做幻读

比如说第一次查询有5条数据,此时某个其它事务删掉了一条数据,第二次查询就只剩下4条数据了。有一条数据消失了,就像一个幻影一样突然出现,所以叫做幻读。

注意一下不可重复读与幻读的区别,不可重复读是指读取同一行数据,前后内容不一样,这是因为其它事务进行了update更新。而幻读是指前后读取出来的数据数目不同,也就是有其他事务进行了insertdelete

那么为什么会出现幻读呢?数据库实现可重复读的本质,是对一行数据进行加锁,但是如果一条数据原本就不存在,如何对其加锁?所以数据库无法屏蔽新插入的数据。不过MySQL解决了这个问题,在MySQL的可重复读级别下,幻读已经被解决了。


串行化

串行化是最高的隔离级别,会对事务进行排序,同时只允许一个事务执行

在串行化下,事物之间不会并发,所以也就没有读取的问题了。但是由于只能同时执行一个事务,所以效率很低,不会使用串行化。

总结

  • 读未提交:所有事务都可以看到其他事务未提交的执行结果,也就是相当于没有任何隔离性
  • 读提交:大多数数据库的默认隔离级别,一个事务只能看到其它事务已经提交的数据
  • 可重复读:MySQL的默认隔离级别,它确保一个事务中,多次读取时不会出现不一样的数据
  • 串行化:对事务进行排序,同时只允许一个事务执行

读取问题:

隔离级别 脏读 不可重复读 幻读
读未提交
读提交 ×
可重复读 × ×
串行化 × × ×

可以发现,这些隔离性大多数都和读相关,在数据库中并发分为:读-读读-写写-写。两个事务同时读,是不会互相影响的,而两个事务同时写极少发生,大部分时间产生问题的是读写并发,因此隔离性主要解决的是读写并发问题。

如果要解决两个事务同时写产生的问题,一半会使用串行化或者加锁来解决,而不是简单依靠隔离性。


MVCC

MySQL是如何实现这几种隔离性的呢?不同数据库有不同的处理策略,postgreSQL采用的是 MVCC 实现四种隔离性,MySQL则是采用 MVCC + 锁 的方式来实现四种隔离性。

什么是MVCC?MVCC是用于解决读-写冲突的无锁并发控制,其可以解决以下问题:

  1. 实现读写并发,数据库可以同时进行读写操作,而互不影响
  2. 可以解决脏读,不可重复读,幻读的问题

讲解MVCC是如何实现之前,我们要简单了解数据库中的一些前提知识。


事务ID

事务ID(Transaction ID,简称TID)是数据库系统为每个事务分配的唯一标识符。它通常是一个递增的整数,由数据库系统自动生成和管理。

每当一个新的事务开始时,数据库系统就会为其分配一个全局唯一的事务ID。这个ID在事务的整个生命周期中保持不变,直到事务结束。


隐藏列

在MySQL中,创建表的时候,并不是指定了哪些列,就存在哪些列,而是有几个所有表都存在的隐藏列,用于管理数据。

  • DB_TRX_ID:记录最近修改(insertdeleteupdate)该行的事务ID
  • DB_ROLL_PTR:回滚指针,指向历史版本undo log
  • DB_ROW_ID:隐藏主键,如果表中没有主键,则会有一个隐藏主键
  • ROW_DELETE:标识一个行有没有被删除,如果为0表示该行已经被删除,如果为1表示没有被删除

以以下表为例:

sql 复制代码
create table Stu(
name varchar(20),
age int
);

插入('张三', 18),假设该事务的事务ID为123,至少会包含以下列:

对于这条新数据,是通过事务123完成的插入,DB_TRX_ID = 123,由于没有历史版本,回滚指针 = NULLDB_ROW_ID则是隐藏主键,自行完成自增。ROW_DELETE = 0表示这一行是存在的。


undo log

MySQL作为一款数据库,自然是要把数据进行持久化保存,但是插入,删除,查询这样的操作,是要把数据加载到内存中计算的。对于一个事务,只有整个事务结束,才会把数据写入硬盘保存,在写入硬盘之前,会在内存中开辟一段缓冲区,用于保存历史版本以便回滚,这个缓冲区就叫做undo log

假设我们在一个事务中,将张三的名字改为李四,事务ID为199

此时旧的数据会进入undo log保存起来,随后将name改为14,这是用户表面上看到的操作。实际上还会更新事务ID为199,表示这条数据最后一次是事务199操作的,并且将回滚指针指向在undo log中上一个版本的数据。

接着同一个事务199又把这一行删掉:

在用户看来,这一行数据已经被删掉了,实际上这行数据还在,只是ROW_DELETE = 1,而原先ROW_DELETE = 0的版本,被保存在了undo log中。

目前事务199还没有commit,此时还可以进行操作,比如说回滚!

如果想要回滚到删除数据前,由于undo log中有ROW_DELETE = 0的版本,此时可以直接通过回滚指针找到上一个版本,然后复原!

如果想要回滚到最开始,那么就从回滚指针一直往回找,直到找到NULL空指针为止!

只要事务199不提交,那么这个undo log就会一直在内存中,方便进行版本控制。当事务提交后,这个事务对应的undo log就会被MySQL自动释放,这也就是为什么事务结束后不能再回滚。

现在相信你已经了解,回滚是如何实现的了。

那么了解这些以后,我们就可以讲一讲MVCC是如何实现的了。


read view

回到这张图,可以发现一个事务在操作数据时,会生成多个版本。而MySQL中事务往往是并发的,其它事务来读取数据的时候,会读取到哪一个版本的数据呢?

mvcc通过快照读机制,读取某个固定的版本。

快照读基于读视图read view实现,在MySQL源码中,readview包含以下内容:

cpp 复制代码
class ReadView {
 private:
  trx_id_t m_low_limit_id;   // 高水位
  trx_id_t m_up_limit_id;    // 低水位
  trx_id_t m_creator_trx_id; // 当前事务ID
  ids_t m_ids;               // 活跃事务ID
};

这里只是部分ReadView 的成员。

当一个事务99进行读取时,会对当前整个MySQL生成一个读视图,读视图分为三部分:

当一个事务进行读取时,可能有很多正在活跃的事务,此时整个数据库中就有如上三种事务。这三种事务通过ReadView记录:

  • m_ids:一个数组,存储当前所有活跃的事务ID
  • m_creator_trx_id:记录当前事务的事务ID,即哪一个事务创建了该读视图
  • m_low_limit_id:高水位,存储m_ids的最小值,低于该值的事务都已经提交
  • m_up_limit_id:低水位,存储m_ids的最大值 + 1,大于等于该值的事务都是快照后产生的事务

当事务创建完读视图后,读取数据时依照这个读视图判断读取哪一个版本。

  1. 已提交事务:

查询数据时,首先看DB_TRX_ID列,看上一次是哪一个事务对其进行了修改:

该数据的DB_TRX_ID = 92,小于m_low_limit_id = 98,说明是已经提交的事务,那么该数据是可见的,最后就可以读取到李四这条数据。

  1. 活跃事务:

该数据的DB_TRX_ID = 100,大于m_low_limit_id = 98,小于m_up_limit_id = 106,在m_ids数组中,说明是活跃的事务,那么该数据不可见。随后通过回滚指针找到上一个版本,发现上一个版本李四是已经提交的事务,所以最后看到李四这条数据。

  1. 后产生的事务:

该数据的DB_TRX_ID = 110,大于m_up_limit_id = 106,说明是后产生的事务,那么该数据不可见。随后通过回滚指针找到上一个版本,发现上一个版本李四是已经提交的事务,所以最后看到李四这条数据。

  1. 当前事务

该数据DB_TRX_ID = 99m_creator_trx_id = 99,说明就是事务自己上一次修改的数据。可见,读取到孙七

每个事务创建快照的时机不同,那么看到的版本也就不同,如果在当前事务之后,有其他事务修改了数据,那么一定是活跃事务或者是后来的事务,此时当前事务会屏蔽掉这些内容,通过回滚指针找到自己可见的版本!

那么这个机制是如何划分出四个不同的隔离级别的呢?

  1. 读未提交:事务可以读取到其他未提交事务的修改。这个级别不使用MVCC,因此没有快照读,事务总是读取到最新的数据。

  2. 读提交:事务只能读取到其他事务已经提交的修改。在读已提交级别下,每次读取操作都会创建一个新的快照,确保读取到的数据是其他事务提交后的数据。

  3. 可重复读:整个事务只在第一次读取的时候创建一个快照,后续整个事务都使用同一张快照,保证前后读取到的内容一致,可重复读。

  4. 串行化:事务通过锁定来避免并发问题,MVCC的作用非常有限,因为数据访问通常是被锁定的,直到事务完成。

MVCC机制在可重复读和读已提交隔离级别中起作用,但在读未提交串行化隔离级别中,其作用有限或不适用。


相关推荐
FIN技术铺2 小时前
Redis集群模式之Redis Sentinel vs. Redis Cluster
数据库·redis·sentinel
内核程序员kevin3 小时前
在Linux环境下使用Docker打包和发布.NET程序并配合MySQL部署
linux·mysql·docker·.net
CodingBrother4 小时前
MySQL 中的 `IN`、`EXISTS` 区别与性能分析
数据库·mysql
kayotin4 小时前
Wordpress博客配置2024
linux·mysql·docker
代码小鑫4 小时前
A027-基于Spring Boot的农事管理系统
java·开发语言·数据库·spring boot·后端·毕业设计
小小不董5 小时前
Oracle OCP认证考试考点详解082系列16
linux·运维·服务器·数据库·oracle·dba
甄臻9245 小时前
Windows下mysql数据库备份策略
数据库·mysql
内蒙深海大鲨鱼5 小时前
qt之ui开发
数据库·qt·ui
杀神lwz5 小时前
Java 正则表达式
java·mysql·正则表达式
不爱学习的YY酱5 小时前
【计网不挂科】计算机网络第一章< 概述 >习题库(含答案)
java·数据库·计算机网络