详解 MySQL 事务
事务定义
事务(Transaction) 是一个最小的、不可再分的工作单元,通常对应着一个完整的业务过程(需要使用多条 SQL 语句共同完成),以保证成批的 SQL 语句要么全部执行,要么全部不执行(即将多个语句视为一个语句)。简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。
在 MySQL 中,事务支持是在 引擎层 实现的,只有使用了 InnoDB 引擎的数据库或表才支持事务。
事务四大属性
一般来说,事务必须满足四个条件,也就是事务的 ACID 属性:
- 原子性(Atomicity):事务是一个最小单元,不可再分,一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。
- 一致性(Consistency):在事务执行前和执行后,数据库的状态应保持一致。比如,A 向 B 转账,不可能 A 扣了钱,B 却没有收到。
- 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致的数据不一致。
- 持久性(Durability):事务执行结束并提交后,对数据的修改就是永久的,即使系统故障数据也不会丢失。
事务分类
显式事务
具有明显的开启和结束事务的标志:
- 使用
begin
或start transaction
启动事务,使用commit
或rollback
结束事务; - 执行
set autocommit=0
命令,关闭线程的自动提交,然后执行的 SQL 语句都将添加到一个事务中,并且不会自动提交。这个事务会持续存在直到执行commit
或rollback
语句,或者断开数据库连接。
需要注意的是 begin/start transaction
命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句时,事务才会真正启动。如果你想要马上启动一个事务,可以使用 starttransaction with consistent snapshot
这个命令。
建议在 set autocommit 为 1 的情况下,使用第一种方式显示开启事务。避免使用
set autocommit=0
命令,因为这可能会导致意外的长事务。
隐式事务
没有明显的开启和结束事务的标志:
- 在默认情况下,由于 MySQL 默认开启了事务自动提交(
autocommit=1
),所以每个 DML 语句实际上会隐式开始一个新事务,如insert
、update
、delete
语句本身就是一个事务。 - 几乎所有 非 DQL、DML 语句 在执行时都会导致 隐式提交,会结束当前进行中的事务,并且无视 autocommit 状态。
事务控制语言
TCL(Transaction Control Language,事务控制语言):
begin
或start transaction
:显式开始一个事务commit
:结束并提交事务,永久记录当前事务所做的更改rollback
:结束并回滚事务,撤销当前事务所做的更改savepoint n
:在事务中创建一个标记点(一个事务可以创建多个标记点)rollback to n
:事务回滚到标记点release savepoint n
:删除一个标记点
事务并发问题
当数据库上有多个事务同时执行时,如果没有采取必要的隔离机制,就可能出现各种并发问题,常见的有:脏读、幻读、不可重复读和两类丢失更新。
脏读(Dirty Read,读取未提交数据)
脏读是指在一个事务在执行过程中读取了另一个事务未提交的数据。例如:A 向 B 转账 100 元(事务 A 未提交),B 查看账户发现钱已到账(事务 B 读取),事务 A 发生异常回滚,B 之后查看账户发现钱其实并没有转(事务 B 进行了一次脏读)。
不可重复读(Non-Repeatable Read,前后多次读取,数据内容不一致)
不可重复读是指一个事务在执行过程中,有另一个事务对数据进行了 修改,导致第一个事务两次读取到的数据不一致。例如:事务 A 读取某一数据,同时事务 B 也修改了这个数据并向数据库提交,事务 A 再次读取该数据就得到了不同的结果,发生了不可重复读。
幻读(Phantom Read,前后多次读取,数据总量不一致)
幻读是指一个事务在执行过程中,有另一个事务 插入 了某行数据,导致第一个事务两次读取结果不同。例如:事务 A 按某一条件读表中数据,同时事务 B 插入了满足查询条件的新数据,事务 A 再按照相同的查询条件检索,就会发现两次获得的数据不一样。
第一类丢失更新(Lost Update,回滚丢失)
事务 A 回滚时,把已经提交的事务 B 的更新数据覆盖了。
时间 | 取款事务A | 转账事务B |
---|---|---|
T1 | 开始事务 | |
T2 | 开始事务 | |
T3 | 查询账户余额为1000元 | |
T4 | 查询账户余额为1000元 | |
T5 | 汇入100元把余额改为1100元 | |
T6 | 提交事务 | |
T7 | 取出100元把余额改为900元 | |
T8 | 回滚事务 | |
T9 | 余额恢复为1000元(丢失更新) |
注:ANSI/ISO SQL92 标准没有定义这种现象,标准定义的所有隔离级别都不允许第一类丢失更新发生。
第二类丢失更新(Second Lost Update,覆盖丢失/两次更新问题)
事务 A 覆盖事务 B 已经提交的数据,造成事务 B 所做的操作丢失。
时间 | 取款事务A | 转账事务B |
---|---|---|
T1 | 开始事务 | |
T2 | 开始事务 | |
T3 | 查询账户余额为1000元 | |
T4 | 查询账户余额为1000元 | |
T5 | 取出100元把余额改为900元 | |
T6 | 提交事务 | |
T7 | 汇入100元 | |
T8 | 提交事务 | |
T9 | 把余额改为1100元(丢失更新) |
解决方案:乐观锁、悲观锁。
小结
- 脏读是当前事务读取了另一个 未提交 的事务中的数据,幻读和不可重复读是当前事务读取了另一个 已经提交 的事务中的数据;
- 不可重复读的重点是修改:同样的条件,第 1 次和第 2 次读出来的 值 不一样;
- 幻读的重点在于新增或者删除:同样的条件,第 1 次和第 2 次读出来的 记录数 不一样;
- 脏读、不可重复读、幻读三个问题都是一个事务对数据修改,另一个事务总是在执行读操作(读 – 写),而丢失更新出现的场景是两事务都在对数据进行修改(写 – 写)。
事务隔离级别
为了解决 读写冲突 的问题,在数据库中实现了不同的 事务隔离级别,只要设置了会话的事务隔离级别,数据库在分析事务中的 SQL 语句时就会自动选择适当的操作。
SQL92 标准定义了 4 个等级的事务隔离级别:
- 读未提交(Read Uncommitted,RU):一个事务还没提交时,它做的变更就能被别的事务看到。
- 读已提交(Read Committed,RC):一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读(Repeatable Read,RR):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。
- 串行化(Serializable):对于同一行记录,“写” 会加 “写锁”,“读” 会加 “读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
下面来看一个例子,假设数据表 T 中只有一列,其中第一行的值为 1,按照时间顺序执行 A、B 两个事务。
1 |
|
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | |
T2 | 开始事务 | |
T3 | 查询得到值1 | |
T4 | 查询得到值1 | |
T5 | 将1改成2 | |
T6 | 查询得到值V1 | |
T7 | 提交事务 | |
T8 | 查询得到值V2 | |
T9 | 提交事务 | |
T10 | 查询得到值V3 |
在不同的隔离级别下,事务 A 的返回结果,也就是图中 V1、V2、V3 的返回值如下:
隔离级别 | V1 | V2 | V3 | 说明 |
---|---|---|---|---|
Read Uncommitted | 2 | 2 | 2 | 事务 B 虽然还没有提交,但是结果已经被 A 看到了。 |
Read Committed | 1 | 2 | 2 | 事务 B 的更新在提交后才能被 A 看到。 |
Repeatable Read | 1 | 1 | 2 | 事务在执行期间看到的数据前后必须是一致的。 |
Serializable | 1 | 1 | 2 | 事务 B 执行修改操作时,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。 |
在实现上,其实数据库内部会创建一个 读视图(Read View),访问的时候以视图的逻辑结果为准。在 “可重复读” 隔离级别下,这个视图是在 每个事务启动时 创建的,整个事务存在期间都用这个视图。在 “读提交” 隔离级别下,每个语句开始执行时 都会重新获取并生成新的读视图。这里需要注意的是,在 “读未提交” 隔离级别下会直接返回记录上的最新值,没有视图概念;而 “串行化” 隔离级别下直接用 加锁 的方式来避免并行访问,但执行效率低下,一般不使用。
不同的隔离级别对并发问题的解决情况如下:
隔离级别 | 脏读 | 不可重复读 | 幻读 | 第一类丢失更新 | 第二类丢失更新 |
---|---|---|---|---|---|
Read Uncommitted | √ | √ | √ | × | √ |
Read Committed | × | √ | √ | × | √ |
Repeatable Read | × | × | √ | × | √ |
Serializable | × | × | × | × | × |
在 MySQL 中默认的隔离级别为 Repeatable Read (可重复读)。
注意:事务的隔离级别和数据库并发性是成反比的,隔离级别越高,并发性越低,执行效率越低。
多版本并发控制
数据库实现事务隔离的方式,基本可以分为以下两种。一种是在操作数据前,对其加锁,阻止其他事务对数据进行修改;另一种是不加任何锁,通过一定机制生成一个数据请求时间点的一致性数据快照,并用这个快照来提供一定级别(语句级或事务级)的 一致性读取。后面这种技术叫做 **MVCC(Multi-Version Concurrency Control,多版本并发控制)**,也经常被称为多版本数据库。
注:MVCC 只在 Read Commited 和 Repeatable Read 两种隔离级别下工作。
在 MVCC 并发控制中,读操作可以分成两类:
- 快照读(Snapshot Read):简单的 select 操作,不需要加锁,读取的是记录的可见版本(有可能是历史版本);
- 当前读(Current Read):特殊的读操作,读取的是记录提交完成的最新版本,并且会对返回的记录加锁,比如
insert/update/delete
、select ...for update
、select ... lock in share mode
都属于当前读。
上一节提到的 读视图(Read View)就是事务进行 快照读 时产生的当前数据库的一个快照,主要用来做可见性判断,即用它来判断当前事务能够看到哪个版本的数据。这既可能是当前最新的数据,也有可能是通过 undo log 计算得到的某个版本的数据。
在具体实现上,InnoDB 的每个事务都有一个严格递增且唯一的事务 ID,记作 trx_id。数据表中的每行记录有多个数据版本,每个版本有自己的 row trx_id。每次更新数据的时候,都会生成一个新的数据版本,并且把当前 trx_id 赋值给 row trx_id。同时,将每一次改变的 row trx_id 保留下来,这样在后面的数据版本中,能够有足够的信息来构建之前的版本记录。
下面举一个例子说明,事务隔离级别为 RR,初始建表语句和事务执行顺序如下:
1 |
|
时间 | 事务A | 事务B | 事务C |
---|---|---|---|
T1 | start transaction with consistent snapshot; | ||
T2 | start transaction with consistent snapshot; | ||
T3 | update t set k=k+1 where id=1; | ||
T4 | update t set k=k+1 where id=1; | ||
T5 | select k as k1 from t where id=1; | ||
T6 | select k as k2 from t where id=1; | ||
T7 | commit; | ||
T8 | select k as k3 from t where id=1; | ||
T9 | commit; |
最后的执行结果为:k1 => 3、k2 => 1、k3 => 1。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!