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机制在可重复读和读已提交隔离级别中起作用,但在读未提交串行化隔离级别中,其作用有限或不适用。


相关推荐
难以触及的高度23 分钟前
mysql中between and怎么用
数据库·mysql
Jacky(易小天)37 分钟前
MongoDB比较查询操作符中英对照表及实例详解
数据库·mongodb·typescript·比较操作符
Karoku0661 小时前
【企业级分布式系统】ELK优化
运维·服务器·数据库·elk·elasticsearch
小技与小术2 小时前
数据库表设计范式
数据库·mysql
安迁岚3 小时前
【SQL Server】华中农业大学空间数据库实验报告 实验三 数据操作
运维·服务器·数据库·sql·mysql
安迁岚3 小时前
【SQL Server】华中农业大学空间数据库实验报告 实验九 触发器
数据库·sql·mysql·oracle·实验报告
Loganer3 小时前
MongoDB分片集群搭建
数据库·mongodb
LKID体3 小时前
Python操作neo4j库py2neo使用之创建和查询(二)
数据库·python·neo4j
刘大浪3 小时前
后端数据增删改查基于Springboot+mybatis mysql 时间根据当时时间自动填充,数据库连接查询不一致,mysql数据库连接不好用
数据库·spring boot·mybatis
一只爱撸猫的程序猿3 小时前
简单实现一个系统升级过程中的数据平滑迁移的场景实例
数据库·spring boot·程序员