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元。
- 事务执行中 :
- 从A账户扣除50元,A账户余额变为50元。
- 给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 transaction
或 begin
此时通过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
修改,但是无法屏蔽insert
、delete
这样的插入删除语句,这叫做幻读
。
比如说第一次查询有5
条数据,此时某个其它事务删掉了一条数据,第二次查询就只剩下4
条数据了。有一条数据消失了,就像一个幻影一样突然出现,所以叫做幻读。
注意一下不可重复读与幻读的区别,不可重复读是指读取同一行数据,前后内容不一样,这是因为其它事务进行了update
更新。而幻读是指前后读取出来的数据数目不同,也就是有其他事务进行了insert
、delete
。
那么为什么会出现幻读呢?数据库实现可重复读的本质,是对一行数据进行加锁,但是如果一条数据原本就不存在,如何对其加锁?所以数据库无法屏蔽新插入的数据。不过MySQL解决了这个问题,在MySQL的可重复读级别下,幻读已经被解决了。
串行化
串行化是最高的隔离级别,会对事务进行排序,同时只允许一个事务执行
在串行化下,事物之间不会并发,所以也就没有读取的问题了。但是由于只能同时执行一个事务,所以效率很低,不会使用串行化。
总结:
读未提交
:所有事务都可以看到其他事务未提交的执行结果,也就是相当于没有任何隔离性读提交
:大多数数据库的默认隔离级别,一个事务只能看到其它事务已经提交的数据可重复读
:MySQL的默认隔离级别,它确保一个事务中,多次读取时不会出现不一样的数据串行化
:对事务进行排序,同时只允许一个事务执行
读取问题:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | √ | √ | √ |
读提交 | × | √ | √ |
可重复读 | × | × | √ |
串行化 | × | × | × |
可以发现,这些隔离性大多数都和读相关,在数据库中并发分为:读-读
,读-写
,写-写
。两个事务同时读,是不会互相影响的,而两个事务同时写极少发生,大部分时间产生问题的是读写并发,因此隔离性主要解决的是读写并发问题。
如果要解决两个事务同时写产生的问题,一半会使用串行化或者加锁来解决,而不是简单依靠隔离性。
MVCC
MySQL是如何实现这几种隔离性的呢?不同数据库有不同的处理策略,postgreSQL采用的是 MVCC 实现四种隔离性,MySQL则是采用 MVCC + 锁 的方式来实现四种隔离性。
什么是MVCC?MVCC是用于解决读-写
冲突的无锁并发控制,其可以解决以下问题:
- 实现读写并发,数据库可以同时进行读写操作,而互不影响
- 可以解决脏读,不可重复读,幻读的问题
讲解MVCC
是如何实现之前,我们要简单了解数据库中的一些前提知识。
事务ID:
事务ID(Transaction ID,简称TID)是数据库系统为每个事务分配的唯一标识符。它通常是一个递增的整数,由数据库系统自动生成和管理。
每当一个新的事务开始时,数据库系统就会为其分配一个全局唯一的事务ID。这个ID在事务的整个生命周期中保持不变,直到事务结束。
隐藏列:
在MySQL中,创建表的时候,并不是指定了哪些列,就存在哪些列,而是有几个所有表都存在的隐藏列,用于管理数据。
DB_TRX_ID
:记录最近修改(insert
、delete
、update
)该行的事务IDDB_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
,由于没有历史版本,回滚指针 = NULL
,DB_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
:一个数组,存储当前所有活跃的事务IDm_creator_trx_id
:记录当前事务的事务ID,即哪一个事务创建了该读视图m_low_limit_id
:高水位,存储m_ids
的最小值,低于该值的事务都已经提交m_up_limit_id
:低水位,存储m_ids
的最大值 + 1,大于等于该值的事务都是快照后产生的事务
当事务创建完读视图后,读取数据时依照这个读视图判断读取哪一个版本。
- 已提交事务:
查询数据时,首先看DB_TRX_ID
列,看上一次是哪一个事务对其进行了修改:
该数据的DB_TRX_ID = 92
,小于m_low_limit_id = 98
,说明是已经提交的事务,那么该数据是可见的,最后就可以读取到李四
这条数据。
- 活跃事务:
该数据的DB_TRX_ID = 100
,大于m_low_limit_id = 98
,小于m_up_limit_id = 106
,在m_ids
数组中,说明是活跃的事务,那么该数据不可见。随后通过回滚指针找到上一个版本,发现上一个版本李四
是已经提交的事务,所以最后看到李四
这条数据。
- 后产生的事务:
该数据的DB_TRX_ID = 110
,大于m_up_limit_id = 106
,说明是后产生的事务,那么该数据不可见。随后通过回滚指针找到上一个版本,发现上一个版本李四
是已经提交的事务,所以最后看到李四
这条数据。
- 当前事务
该数据DB_TRX_ID = 99
,m_creator_trx_id = 99
,说明就是事务自己上一次修改的数据。可见,读取到孙七
。
每个事务创建快照的时机不同,那么看到的版本也就不同,如果在当前事务之后,有其他事务修改了数据,那么一定是活跃事务或者是后来的事务,此时当前事务会屏蔽掉这些内容,通过回滚指针找到自己可见的版本!
那么这个机制是如何划分出四个不同的隔离级别的呢?
-
读未提交
:事务可以读取到其他未提交事务的修改。这个级别不使用MVCC,因此没有快照读,事务总是读取到最新的数据。 -
读提交
:事务只能读取到其他事务已经提交的修改。在读已提交级别下,每次读取操作都会创建一个新的快照,确保读取到的数据是其他事务提交后的数据。 -
可重复读
:整个事务只在第一次读取的时候创建一个快照,后续整个事务都使用同一张快照,保证前后读取到的内容一致,可重复读。 -
串行化
:事务通过锁定来避免并发问题,MVCC的作用非常有限,因为数据访问通常是被锁定的,直到事务完成。
MVCC机制在可重复读和读已提交隔离级别中起作用,但在读未提交
和串行化
隔离级别中,其作用有限或不适用。