事务:一个或一组 SQL 语句组成的一个执行单元称为事务,单元中的每条 SQL 语句互相依赖。事务中的 SQL 语句要么全部执行 ( COMMIT ),要么全部不执行 ( ROLLBACK )

在 MySQL 中,根据存储引擎的不同,会决定是否支持事务

存储引擎:在 MySQL 中的数据通过使用各种不同的技术存储在文件或内存中,也就出现了不同的存储引擎

可以通过命令查看 MySQL 支持的存储引擎

  1. show engines;

MySQL 事务 - 图1

MySQL 常用的存储引擎有 innodb,myisam,memory 等。innodb 支持事务,而 myisam,memeory 不支持事务

因此,如果 MySQL 默认的存储引擎不是 InnoDB 的话,在对一个表使用事务之前,需要先把表的存储引擎修改为 Innodb

alter table 表名 engine=InnoDB;

或者,修改 MySQL 的配置文件中的

MySQL 事务 - 图2

为 InnoDB

事务的 ACID 属性

事务需要具备以下属性

  • 原子性 Atomicity:事务是一个不可分割的工作单位,事务中的操作要么发生,要么都不发生
  • 一致性 Consistency:事务完成后,会使数据从一个一致性状态切换到另一个一致性状态。A 账户只要转给了 B 账户 100,那么 B 账户必定加上了 100
  • 隔离性 Isolation:一个事务的执行不能被其它事务干扰 ( 视隔离级别而定 ),即一个事务内部的操作及使用的数据对并发执行的其它事务是不可见的,并发执行的各个事务之间不能互相干扰
  • 持久性 Durability:一个事务一旦被提交,它对数据库中数据的改变就是永久性存储的

注意,原子性并不等同于原子操作,这里的原子性值的指示一组操作的发生情况具有原子性,但是其中的语句并不是原子操作,因此就有了事务间的并发问题

原子性:即一个操作或者多个操作 要么全部执行 并且执行的过程不会被任何因素打断,要么就都不执行

原子操作:是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换

事务的创建

事务可以分为两类

  • 隐式事务:没有明显的开启和结束的标志的事务。如 insert,update,delete 等,都是隐式事务
  • 显式事务:事务具有明显的开启和结束的标记。但是由于自动提交 ( autocommit ) 功能默认是开启的,为了不让在事务过程中有的语句自动提交,因此需要先关闭自动提交功能

查看自动提交是否开启

show variables like "autocommit";

MySQL 事务 - 图3

设置自动提交为关闭

set autocommit = false;

因此,在一次 MySQL 的会话中,开启事务的步骤如下

  1. set autocommit = false;
    
  2. start transaction; #可选
    
  3. 编写事务中的 SQL 语句 ( select、insert、update、delete 等,DDL 没有事务这一说法 )语句1; 语句2; ...

  4. 结束事务,分为提交和回滚两种方式mysql commit; [rollback;]

因此一次事务的执行语法如下

set autocommit = false;
start transaction; #可选
语句1;
语句2;
...
commit;
[rollback;]

其实的话,可以不将自动提交关闭,但这样需要注意的是此时必须有显式的开始和结尾,且必须整段执行,不然事务中的语句就会自动提交了

#使用自动提交
delete from major;
rollback;

执行后 major 表清空,回滚无效,因为直接提交了

#关闭自动提交
delete from major;
rollback;

先执行删除,然后查看表,发现数据没了,再回滚,回滚成功

因此,在手动提交中,所有语句的回滚与提交都交给了程序猿来控制;而在自动提交中,不是一段完整事务中得以语句则会自动提交。因此在自动提交中想要使用事务,则需要注意事务的显式声明

实践

drop table if exists account;
create table account(
    id int primary key auto_increment,
    username varchar(20),
    balance double
)

insert into account(username, balance)
values("zzz", 1000),("yyy", 1500);

#开启事务
set autocommic = false;
start transaction;
#一组事务语句
update account set balance = 500 where username = "zzz";
update account set balance = 1200 where username = "yyy";
#提交事务
commit;

事务的并发问题和隔离级别

事务保证了一组 SQL 指令的原子性,但是事务并没有锁机制,因此多个事务之间同样存在并发问题,因此引入了隔离级别 ( 针对事务之间 )

对于同时运行的多个事务,当这些事务访问数据库中的相同数据时,如果没有采取必要的隔离机制,就会导致各种并发问题

这些并发问题包括脏读,不可重复读,幻读等

  • 脏读:对于两个事务 T1,T2。如果 T1 读取了已经被 T2 更新但还没提交的字段后,T2 回滚了,那么 T1 读取的内容就是临时且无效的
  • 不可重复读:对于两个事务 T1,T2。如果 T1 读取了一个字段,然后 T2 更新并提交了该字段,之后 T1 再次读取同一个字段,值就不同了 ( 即一个事物中两次读取的数据可能不一致 )
  • 幻读:对于两个事务 T1,T2。T1 从一个表中读取了一个字段,然后 T2 在该表中插入了一些新的行,如果 T1 再次读取同一个表,就会多出几行

脏读一般针对的是更新,而幻读一般针对的是插入和删除

为了避免这些并发问题,数据库系统提供了不同的隔离级别来让程序猿有针对性的选择事务的隔离级别,避免并发问题

MySQL 支持以下隔离级别

MySQL 事务 - 图4

MySQL 默认的隔离级别为 Repeatable Read ( Innodb 引擎下 )

事务的隔离级别越高,数据的一致性就越高,但是并发性越弱

查看数据库当前的隔离级别

select @@tx_isolation;

设置当前 MySQL 会话连接的隔离级别

set session transaction isolation level 隔离级别;

设置数据库系统的全局隔离级别

set global transaction isolation level 隔离级别;

关于并发问题的测试可以看这个网站

https://www.liaoxuefeng.com/wiki/1177760294764384/1179611198786848

MySQL 事务 - 图5

Serializable 是最高的隔离级别,这种级别下会将并发执行的事务串行化,强制顺序执行,因此不会出现并发问题 (直接串行了,还并个锤子)

delete 和 truncate 在事务中的区别

演示 delete

SET autocommit = FALSE;
START TRANSACTION;
DELETE FROM account;
ROLLBACK;

再次查询,可以发现回滚成功,是有结果的

演示 truncate

set autocommit = false;
start transaction;
truncate account;
rollback;

再次查询,没有报错,但是没有结果了

因此 truncate 不支持回滚

还原点 savepoint

还原点只能搭配 rollback 使用,可以回滚到指定的还原点

现有一表,数据如下

MySQL 事务 - 图6

set autocommit = false;
start transaction;
delete from account where id = 1;
savepoint a; #设置还原点,命名为 a
delete from account where id = 2;
rollback to a; #回滚到还原点 a,还原点之前的操作不会被回滚

再次查询如下

MySQL 事务 - 图7

事务的并发问题补充

Redo/Undo 机制

Redo/Undo 机制将所有对数据的更新操作都写到日志中,是保证事务原子性的基石
Redo log:记录数据被更新后的值,可以用来恢复未写入数据文件,但是已完成事务提交的数据 ( 原理是当事务提交后所有的修改信息都会存入 redo log 中,然后持久化到磁盘上 )
Undo log:记录数据更新前的值,用来保证数据更新失败时的回滚

事务的隔离级别

MySQL 事务有四大隔离级别,读未提交、读已提交、可重复读和串行化
读未提交下会读到另一个事务的未提交的数据,产生脏读问题,读已提交则可以解决脏读问题,但是会出现不可重复读问题。不可重复读指的是一个事务在任意时刻对同一数据的读取结果可能不同,这是受到了其它事务对数据修改后的影响,一般是因为 update 操作
可重复读解决了脏读和不可重复读问题,但是也带来了幻读问题,幻读问题一般是因为读取了其它事务插入但未提交的数据而产生的
串行化将事务的执行过程变为顺序执行,因此解决了脏读、不可重复读和幻读等问题
这四大隔离级别的隔离效果是逐渐增强的,但是性能是逐渐变差的

为什么隔离级别越高性能越差?

因为四个隔离级别是通过加锁解决的并发问题,MySQL 中的锁可以分为共享锁、排它锁、间隙锁、行锁和表锁
首先读未提交没有加锁,因此性能最好,但是也不能解决任何并发问题
而串行化则在读的时候加的是共享锁,写的时候加排它锁阻塞其它事务的写入和读取,因此性能最差,但是可以解决所有的并发问题
而读已提交和可重复读则是在解决一定并发问题时也能具有并发能力,所以使用的锁机制要优化很多,他们的底层使用的是 MVCC 进行实现的

MySQL 的锁有哪些?

排它锁:即排他写锁,可以进行读取和更新,但是加锁期间其它事务不能再次加锁

表锁和行锁是从锁的细粒度上划分的,行锁锁定的是记录上的索引,如果没有索引则在主键上创建隐藏的聚簇索引,加锁慢,但是锁冲突概率低,并发度高;行锁则加锁快,但是锁冲突的概率大,并发度低

间隙锁分为两种,分别是 Gap Lock 和 Next-key Lock,Gap Lock 锁住两个索引之间的间隙,锁住间隙的意思是两个索引间不能插入和删除数据 ( 不包括两个索引 );而 Next-key Lock 则是锁住包含两个索引在内的闭区间,相当于行锁+间隙锁,间隙锁根据 where 条件后的字段上的索引进行范围锁,如果字段上没有索引则会锁住全表
InnoDB 通过 MVCC + Next-key Lock 在可重复读级别下解决了幻读问题

什么时候会加锁?

在增删改查中,只有增删改会加排它锁,查询则不会加锁,但是可以通过在 select 语句后显式添加 lock in share mode 或者 for update 来加共享锁或者排它锁

MVCC 机制

MVCC 即多版本并发控制,是一种并发控制的方法,用来实现对数据的并发访问。MVCC 让每个读操作看到的其实是数据库的一个快照,其它事务的写操作造成的变化在当前事务完成之前对当前事务的读操作来说是不可见的,这种机制用于解决脏读和不可重复读
多版本的意思是:当事务更新一条数据时,不会直接用新数据覆盖旧数据,而是将旧数据标记为过时,然后在别处增加新数据,这样就存储了一个数据的多个版本
MVCC 下的事务一般使用时间戳或者事务 ID 去标记当前读的数据行的版本,并获取这个版本的数据。读写共存时,写操作会对需要更新的数据行进行加锁,然后去创建一个新版本,读则依然访问旧版本的数据

MVCC 原理

MVCC 使用了一致性视图也就是快照机制,用于支持可重复读和读已提交。以默认的可重复读级别来说,数据库只需要在事务开始前创建一份快照,之后的查询中都共用这个快照,从而保证后续的事务对数据的更改对当前事务是不可见的,这样就解决了不可重复读

MVCC 实现原理

InnoDB 的 MVCC 是通过在每行记录后面保存两个隐藏的列来实现。这两个列一个保存了行的创建时间,一个保存行的删除时间。当然存储的并不是真实的时间,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动新增。事务开始时刻的系统版本号会作为事务的版本号,用来查询到每行记录的版本号进行比较
当进行查询操作时,有两个规则

  • 查找的数据行的版本号必须 <= 当前事务版本号
  • 查找的数据行的版本号要么删除版本号为 null,要么大于当前事务版本号

多事务的并发写是如何实现的?

假设有事务 A 和事务 B

  • 在有索引的情况下,当事务 A 要更新数据时,则会去获取行锁锁定数据,然后事务 B 想要更新这条数据时就只能进行等待了,如果事务 A 长时间不释放行锁,则事务 B 会出现超时异常
  • 如果没有索引,则会先给所有行都加上行锁,然后进行筛选后释放不符合条件的行的行锁