事务的起源
了解事务之前,先来看看数据库为什么需要有事务,假设没有事务会有什么影响?假设我们有一个银行账户系统,表结构如下:
mysql> select * from account; +------+--------+---------+ | id | name | balance | +------+--------+---------+ | 1 | 张三 | 1000 | | 2 | 李四 | 5000 | +------+--------+---------+ 2 rows in set (0.00 sec)
在某个时刻,张三要向李四买件商品,因此要给李四转账 100。数据库中应该有如下两个操作,将张三的余额扣掉 100,给李四的余额增加 100。
mysql> update atm set abalance=balance-100 where aname = 张三; mysql> update atm set abalance=balance+100 where aname = 李四;
那么如果张三的余额扣款成功,但是李四的余额没有增加成功,会出现什么后果? 张三明明转了账,但是李四却没收到钱。
如果张三的余额扣款失败,但是李四的余额却增加成功呢? 张三一分钱没花,就买了东西。
所以数据库引入事务的主要目的是把数据库会从一种一致状态转换到另一种一致状态,数据库提交工作时可以确保要么所有修改都保存,要么所有修改都不保存。
事务的四大特性(ACID)
一般来说,事务满足4个条件(ACID):原子性(Atomicity,或称不可分割性)、一致性(Consistency)、隔离性(Isolation,又称独立性)、持久性(Durability)。事务的本质其实就是一系列数据库操作,只不过这些数据库操作符合 ACID 的特性而已。
- 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中如果发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性:在事务开始之前和事务结束以后,数据的总体保持一致,例如上面例子张三给李四转账前两人的余额总和是 6000,那么转账完成以后两人的余额总和还是 6000。
- 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable)。
- 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
并发事务处理带来的问题
并发事务中可能会出现以下问题:
- 更新丢失(Lost Update)或脏写:当多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题,最后的更新覆盖了由其他事务所做的更新。
- 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。一句话:事务 A 读取到了事务 B 已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果 B 事务回滚,A 读取的数据无效,不符合一致性要求。
- 不可重复读(Non-Repeatable Reads):一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。一句话:事务 A 相同的查询语句在不同时刻读出的结果不一致,不符合隔离性。
- 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。一句话:事务 A 读取到了事务 B 提交的新增数据,不符合隔离性。
事务隔离级别
脏读、不可重复读和幻读其实都是数据库一致性的问题,必须由数据库提供一定的事务隔离机制来解决。MySQL 默认的事务隔离级别是可重复读。
隔离级别 | 脏读(Dirty Read) | 不可重复读(NonRepeatable Read) | 幻读(Phantom Read) |
读未提交(Read Uncommitted) | 可能 | 可能 | 可能 |
读已提交(Read Committed) | 不可能 | 可能 | 可能 |
可重复读(Repeatable Read) | 不可能 | 不可能 | 可能 |
可串行化(Serializable) | 不可能 | 不可能 | 不可能 |
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。 同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读"和“幻读”并不敏感,可能更关心数据并发访问的能力。
事务中的语法
开启事务
可以使用下面两种语句来开启事务,开启事务后可以继续写若干条语句,这些语句都属于刚刚开启的这个事务。
# 方式一 mysql> begin; mysql> 加入事务的语句... # 方式二 mysql> start transaction; mysql> 加入事务的语句...
START TRANSACTION 语句与 BEGIN 语句有相同的功效,都标志着开启一个事务。相较于 BEGIN 语句,START TRANSACTION 语句后面可以跟随几个参数:
- READ ONLY:标识当前事务是一个只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。
- READ WRITE:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。默认是读写事务。
- WITH CONSISTENT SNAPSHOT:表示从这个语句开始,创建一个持续整个事务的一致性快照。BEGIN/START TRANSACTION 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 这个命令。该参数只对可重复读(repeatable read)级别的事务才有效,当隔离级别是其他时,会忽略 WITH CONSISTENT SNAPSHOT 的效果,等效于普通的 START TRANSACTION。
例如开启读写事务和一致性读,可以这样写:
mysql> start transaction read write, with consistent snapshot;
提交事务
当编写完事务执行的语句,就可以使用 COMMIT 命令来提交事务了。
mysql> commit;
COMMIT 语句就代表一个事务,例如前面的转账的例子可以这样写:
mysql> begin; mysql> update atm set abalance=balance-100 where aname = 张三; mysql> update atm set abalance=balance+100 where aname = 李四; mysql> commit;
回滚事务
如果我们写了几条语句之后发现前面某条语句写错了,可以使用下面这个语句将数据库恢复到执行事务之前的样子:
mysql> rollback;
保存点
- savepoint 是在数据库事务处理中实现子事务(subtransaction),也称为嵌套事务的方法。事务可以回滚到 savepoint 而不影响 savepoint 创建前的变化, 不需要放弃整个事务。一个事务中可以有多个savepoint。
- 回滚只能按顺序回滚,例如有三个保存点t_1,t_2,t_3,只能按照 t_3 > t_2 > t_1 的顺序回滚,不可以先到 t_1,再到t_3。
- 当 commit 或者 rollback 不指定 savepoint 时,所有的 savepoint 会被删除。
savepoint savepoint_name; //声明一个 savepoin rollback to savepoint_name; // 回滚到savepoint release savepoint savepoint_name; // 删除指定保留点
下面是使用保存点的例子:
mysql> select * from account; +------+--------+---------+ | id | name | balance | +------+--------+---------+ | 1 | 张三 | 1000 | | 2 | 李四 | 5000 | | 3 | 王五 | 10000 | | 4 | 马六 | 20000 | +------+--------+---------+ 4 rows in set (0.01 sec) mysql> insert into account values(5,'赵七',3000); Query OK, 1 row affected (0.01 sec) # 创建保存点 mysql> savepoint t_1; Query OK, 0 rows affected (0.05 sec) mysql> select * from account; +------+--------+---------+ | id | name | balance | +------+--------+---------+ | 1 | 张三 | 1000 | | 2 | 李四 | 5000 | | 3 | 王五 | 10000 | | 4 | 马六 | 20000 | | 5 | 赵七 | 3000 | +------+--------+---------+ 5 rows in set (0.01 sec) mysql> insert into account values(6,'王八',7000); Query OK, 1 row affected (0.01 sec) mysql> select * from account; +------+--------+---------+ | id | name | balance | +------+--------+---------+ | 1 | 张三 | 1000 | | 2 | 李四 | 5000 | | 3 | 王五 | 10000 | | 4 | 马六 | 20000 | | 5 | 赵七 | 3000 | | 6 | 王八 | 7000 | +------+--------+---------+ 6 rows in set (0.01 sec) # 回滚到保存点 mysql> rollback to t_1; Query OK, 0 rows affected (0.00 sec) mysql> select * from account; +------+--------+---------+ | id | name | balance | +------+--------+---------+ | 1 | 张三 | 1000 | | 2 | 李四 | 5000 | | 3 | 王五 | 10000 | | 4 | 马六 | 20000 | | 5 | 赵七 | 3000 | +------+--------+---------+ 5 rows in set (0.00 sec)
修改事务隔离级别
MySQL 中事务默认的隔离级别是可重复读(repeatable read),可以通过以下两种方式修改事务默认的隔离级别。
# 方式一 # level值 read uncommitted 读未提交 read committed 读已提交 repeatable read 可重复读 serializable 串行化 # 修改全局隔离级别(修改完要重新登录才生效) set global transaction isolation level <level值>; # 修改当前会话隔离级别(修改完立即生效,重新登录失效) set session transaction isolation level <level值>; # 方式二 # 隔离级别 read-uncommitted 读未提交 read-committed 读已提交 repeatable-read 可重复读 serializable 串行化 # 修改全局隔离级别(修改完要重新登录才生效) set @@global.tx_isolation ='隔离级别'; # 修改当前会话隔离级别(修改完立即生效,重新登录失效),默认的@tx_isolation是当前会话 set @@session.tx_isolation ='隔离级别';
查看事务隔离级别
# 查看当前会话和全局的隔离级别 mysql> select @@global.tx_isolation,@@session.tx_isolation; +-----------------------+------------------------+ | @@global.tx_isolation | @@session.tx_isolation | +-----------------------+------------------------+ | REPEATABLE-READ | SERIALIZABLE | +-----------------------+------------------------+ 1 row in set, 2 warnings (0.10 sec) # @tx_isolation查的是当前会话的隔离级别 mysql> select @@tx_isolation; +----------------+ | @@tx_isolation | +----------------+ | SERIALIZABLE | +----------------+ 1 row in set, 1 warning (0.03 sec)
自动提交
MySQL 中有一个系统变量 autocommit,用于自动提交事务。
mysql> show variables like 'autocommit'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | autocommit | ON | +---------------+-------+ 1 row in set (0.01 sec)
可以看到它的默认值为 ON。也就是说在默认情况下,如果不显示使用 START TRANSACTION 或者 BEGIN 语句开启一个事务,那么每一条语句都算一个独立的事务,这种特性称为事务的自动提交。
如果我们想关闭自动提交的功能,有下面两种方法:
- 1.显示地使用 TRANSACTION 或者 BEGIN 语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭自动提交的功能。
- 2.把系统变量 autocommit 设置为 OFF:
mysql> set autocommit=OFF;
隐式提交
当使用 START TRANSACTION 或者 BEGIN 语句开启了一个事务,或者把系统变量 autocommit 的值设置为 OFF 时,事务就不会进行自动提交。如果我们输入了某些语句,且这些语句会导致之前的事务悄悄地提交掉(就像输入了 COMMIT语句一样),那么这种因为某些特殊的语句而导致事务提交的情况称为隐式提交。会导致事务隐式提交的语句有下面这些:
- 定义或修改数据库对象的数据定义语言(Data Definition Language,DDL)。 所谓的数据库对象,指的就是数据库、表、视图、存储过程等。当使用 CREATE、ALTER、DROP 等语句修改这些数据库对象时,就会隐式地提交前面语句所属的事务。
- 修改 mysql 这个库中的表。 例如在使用 ALTER USER、CREATE USER、DROP USER、GRANT、RENAME USER、REVOKE、SET PASSWORD 等语句时,就会修改 mysql 这个库。
- 事务控制或关于锁的语句。
- 当我们在一个事务中还没提交或者还没回滚时就又使用 START TRANSACTION 或者 BEGIN 语句开启了另一个事务时。
- 当把系统变量 autocommit 从 OFF 改为 ON 时。
- 当使用 LOCK TABLES,UNLOCK TABLES 等关于锁定的语句时。
- 加载数据的语句。例如使用 LOAD DATA 语句向数据库中批量导入数据时。
- 关于 MySQL 复制的一些语句。 使用 START SLAVE,STOP SLAVE,RESET SLAVE,CHANGE MASTER TO 等语句时。
- 其他语句。例如 ANALYZE TABLE、CACHE INDEX、CHECK TABLE、FLUSH、LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE、RESET 等语句时。
支持事务的存储引擎
在 MySQL 中,事务支持是在引擎层实现的。MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
事务隔离级别案例分析
首先创建一张 account 表并插入一些数据。
CREATE TABLE `account` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `balance` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; INSERT INTO `test`.`account` (`name`, `balance`) VALUES (1, 'lilei', '450'); INSERT INTO `test`.`account` (`name`, `balance`) VALUES (2 ,'hanmei', '16000'); INSERT INTO `test`.`account` (`name`, `balance`) VALUES (3, 'lucy', '2400');
读未提交(read uncommitted)
设置客户端 A 和客户端 B 的事务隔离级别为 read uncommitted(读未提交),并且一起开启事务。
set tx_isolation='read-uncommitted'; begin;
(1)客户端 A 查询表 account 的初始值:以上示例的执行顺序如下表所示:
客户端A | 客户端B |
begin; | begin; |
select * from account;(lilei的balance为450) | |
update account set balance=balance-50 where id=1; | |
select * from account;(lilei的balance为400) | |
select * from account;(lilei的balance为400,出现了脏读的问题) | |
rollback; | |
update account set balance=balance-50 where id=1; | |
select * from account;(lilei的balance为400) | |
rollback |
读已提交(read committed)
设置客户端 A 和客户端 B 的事务隔离级别为 read committed(读已提交),并且一起开启事务。
set tx_isolation='read-committed'; begin;
(1)客户端 A 查询表 account 的初始值:以上示例的执行顺序如下表所示:
客户端A | 客户端B |
begin; | begin; |
select * from account;(lilei的balance为450) | |
update account set balance=balance-50 where id=1; | |
select * from account;(lilei的balance为400) | |
select * from account;(lilei的balance为450) | |
commit; | |
select * from account;(lilei的balance为400,出现了不可重复读) | |
commit; |
可重复读(repeatable read)
设置客户端 A 和客户端 B 的事务隔离级别为 repeatable read(可重复读),并且一起开启事务。
set tx_isolation='repeatable-read'; begin;
(1)客户端 A 查询表 account 的初始值:
客户端A | 客户端B |
begin; | begin; |
select * from account;(lilei的balance为400) | |
update account set balance=balance-50 where id=1; | |
select * from account;(lilei的balance为350) | |
commit; | |
select * from account;(lilei的balance为400) | |
update account set balance=balance-50 where id=1; | |
select * from account;(lilei的balance为300) | |
begin; | |
insert into account values(4,'lily',700); | |
select * from account;(能查到新增的lily这行) | |
commit; | |
select * from account;(查不到新增的lily这行) | |
update account set balance=888 where id=4; | |
select * from account;(能查到新增的lily这行,出现了幻读) | |
commit; |
串行化(serializable)
串行化这种隔离级别并发性极低,开发中很少会用到。 设置客户端 A 和客户端 B 的事务隔离级别为 serializable(串行化),并且一起开启事务。
set tx_isolation='serializable'; begin;
(1)客户端 A 查询表 account id=1 这一行的初始值:
什么是 MVCC
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC,Multiversion Concurrency Control)。MySQL 只在读已提交和可重复读隔离级别下实现了 MVCC 机制。
MVCC 概念
以下是关于 MVCC 的一些概念:
- read-view:是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
- 事务 ID:InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id(trx_id)。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
- undo 日志版本链:是指一行数据被多个事务依次修改过后,在每个事务修改完后,MysSQL 会保留修改前的数据 undo 回滚日志,并且用两个隐藏字段 trx_id 和 roll_pointer 把这些 undo 日志串联起来形成一个历史记录版本链。
Read-view 一致性视图
在可重复读隔离级别,当事务开启后,执行第一条查询 SQL 时会生成当前事务的一致性视图 read-view,该视图在事务结束之前都不会变化。
在读已提交隔离级别,当事务开启后,在每次执行查询 SQL 时都会重新生成一致性视图。
InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
数组里面事务 ID 的最小值(min_id)记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位(max_id)。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。MVC 机制的实现就是通过 read-view 机制与 undo 版本链比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。如果该版本的数据不可见,则根据版本链指针找到上一个版本的数据继续比对。
版本链比对规则:
- 1.如果 row 的 trx_id 落在绿色部分 (trx_id<min_id),表示这个版本是已提交的事务生成的,这个数据是可见的。
- 2.如果 row 的 trx_id 落在红色部分 (trx_id>max_id),表示这个版本是由将来启动的事务生成的,是不可见的。
- 3.如果 row 的 trx_id 落在黄色部分 (min_id <=trx_id<= max_id),那就包括两种情况。
- a. 若 row 的 trx_id 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的)。
- b. 若 row 的 trx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。
为什么黄色部分会包含已提交的事务呢?因为高水位的定义是事务创建时所有未提交的事务 ID 的最大值+1,但并不是小于高水位大于低水位的事务就都没有提交。
对于删除的情况可以认为是 update 的特殊情况,会将版本链上最新的数据复制一份,然后将 trx_id 修改成删除操作的 trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)标记位写上 true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果 delete_flag 标记位为 true,意味着记录已被删除,则不返回数据。
注意:BEGIN/START TRANSACTION 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句(查询或修改)时,事务才真正启动,才会向 MySQL 申请事务 ID,MySQL 内部是严格按照事务的启动顺序来分配事务 ID 的。
MVCC 例子
接下来说明的例子是在可重复读隔离级别下的。我们假设:
- 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
- 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
- 三个事务开始前,name=xiaoming1 这一行数据的 row trx_id 是 80。
这样,事务 A 的视图数组就是[99,100], 事务 B 的视图数组是[99,100,101], 事务 C 的视图数组是[99,100,101,102]。
从图中可以看到,第一个有效更新是事务 C,把 name 从 xiaoming1 改成了 xiaomign2,事务 C 已提交。这时候,这个数据的最新版本的 row trx_id 是 102,而 80 这个版本已经成为了历史版本。
第二个有效更新的是事务 B,把 name 从 xiaoming2 改成了 xiaoming3,事务 B 未提交。这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。
现在事务 A 要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起的。所以,事务 A 查询语句的读数据流程是这样的:
- 先找到 name = xiaoming3,判断出 row trx_id=101,比事务 A read-view 的高水位大(101>99),处于红色区域,不可见。
- 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大(102>99),处于红色区域,不可见。
- 再往前找,终于找到了 name=xiaoming1,它的 row trx_id=80,比低水位小,处于绿色区域,可见。
这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。
按照前面的分析方法很容易让人头晕,有一个更简单的方法来判断数据是否可见。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
- 1.版本未提交,不可见。
- 2.版本已提交,但是是在视图创建后提交的,不可见。
- 3.版本已提交,而且是在视图创建前提交的,可见。
现在,我们用这个规则来判断上面的查询结果,事务 A 的查询语句的视图数组是在事务 A 启动的时候生成的,这时候:
- name=xiaoming3 还没提交,属于情况 1,不可见。
- name=xiaoming2 虽然提交了,但是是在视图数组创建之后提交的,属于情况 2,不可见。
- name=xiaoming3 是在视图数组创建之前提交的,可见。
如果事务 A 自己更新了 name=xiaoming4,那么事务 A 去读 name 的值就应该是 xiaoming4,因为自己更新是当前读。自己改的值自己总得认吧!总结一下:
- 以一个事务启动的时刻为准,如果一个数据版本是在该事务启动之前生成的,就认。
- 如果是该事务启动以后才生成的,就不认,就必须根据记录版本链继续往上找。
- 如果是这个事务自己更新的数据,就认。
当前读和快照读
在 MySQL 读取数据时可以按照是否使用锁定读来区分当前读和快照读:
- 1.当前读:也称锁定读(locking read),通过对读取到的数据(索引记录)加锁来保证数据一致性,当前读会对所有扫描到的索引记录的行加锁。
- 2.快照读:MySQL 使用 MVCC 机制来保证被读取到数据的一致性,读取数据时不需要对数据进行加锁,且快照读不会被其他事务阻塞。
在读提交和可重复读两种事务隔离级别下,普通的 select 操作使用快照读,不会对数据加锁,也不会被事务阻塞。
除了 update 语句外,select 语句如果加锁,也是当前读。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。
mysql> select k from t where id=1 lock in share mode; mysql> select k from t where id=1 for update;
参考链接
- https://www.runoob.com/mysql/mysql-transaction.html
- https://time.geekbang.org/column/article/68963
- https://time.geekbang.org/column/article/70562、
- https://www.jianshu.com/p/46d5513f3238