事务特性
- 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样;
- 一致性(Consistency):数据库的完整性不会因为事务的执行而受到破坏,比如表中有一个字段为姓名,它有唯一约束,也就是表中姓名不能重复,如果一个事务对姓名字段进行了修改,但是在事务提交后,表中的姓名变得非唯一性了,这就破坏了事务的一致性要求,这时数据库就要撤销该事务,返回初始化的状态。
- 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
- 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
并发执行的事务
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。
脏读: 如果一个事务读到了另一个未提交事务修改过的数据,就意味着发生了脏读现象。
不可重复读: 在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了不可重复读现象。
幻读: 在一个事务内多次查询某个符合查询条件的记录数量,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了幻读现象。
注意: 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读只有在当前读才会出现。
当前读是指能读到所有已经提交的记录的最新值,例如:select * from t where id = N for update
事务的隔离级别
当多个事务并发执行时可能会遇到脏读、不可重复读、幻读的现象。
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:
- 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
针对不同的隔离级别,并发事务时可能出现的问题也是不一样的。
脏读 | |||
不可重复读 | 不可重复读 | ||
幻读 | 幻读 | 幻读(通过间隙锁和行锁解决了这个问题) | |
读未提交 | 读提交 | 可重复读 | 串行化 |
InnoDB 引擎的默认隔离级别是可重复读,但是它通过next-key lock 锁(行锁和间隙锁的组合)来锁住记录之间的“间隙”和记录本身,防止其他事务在这个记录之间插入新的记录,这样就避免了幻读现象。
下面通过一个例子来说明不同的隔离级别。假设有一张用户表(user),里面有两个字段,分别是主键id和余额balance
id(主键) | balance(余额) |
6666 | 100 |
有A和B两个事务做如下操作
事务A | 事务B |
begin; select balance from user where id = 6666; 查询得到balance=100 | begin; |
update user set balance = 200 where id = 6666; | |
select balance from user where id = 6666; 查询得到balance=value1 | |
commit; | |
select balance from user where id = 6666; 查询得到balance=value2 | |
commit; | |
select balance from user where id = 6666; 查询得到balance=value3 |
不同隔离级别下,value1、value2、value2的值如下所示
value1 | value2 | value3 | |
读未提交 | 200 | 200 | 200 |
读提交 | 100 此时事务B还未提交,所以只能读到100 | 200 此时事务B已经提交 | 200 |
可重复读 | 100 | 100 | 200 |
串行化 | 100 | 100 | 100 |
隔离级别是“串行化”时,事务 B 执行update user set balance = 200 where id = 6666
的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。
隔离级别如何实现
【读未提交】 因为可以读取到未提交事务的数据,所以直接读取最新的数据就可以了
【串行化】 通过加读锁和写锁的方式避免事务并行访问
【读提交、可重复读】 通过视图(Read View)来实现
在可重复读隔离级别下,这个视图是在事务启动时创建的,整个事务期间都用这个视图。
在读提交隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。
【注意】 在 MySQL 有两种开启事务的命令,分别是:
- 第一种:
begin/start transaction
命令 - 第二种:
start transaction with consistent snapshot
命令
begin/start transaction
命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。
如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot
这个命令。
实现原理
在MySQL中,每条记录在更新的时候都会记录一条回滚日志。记录上的最新值,通过回滚操作,可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
Read-View A | Read-View B | Read-View C | Read-View D |
将2改成1 | 将3改成2 | 将4改成3 | 4 |
回滚段 | 当前值 |
在视图 A、B、C 、D里面,这个记录的值分别是 1、2、3、4,同一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制(MVCC)。对于 Read-View A
,要想得到 1,就得从当前值依次执行上面的回滚日志。
回滚日志何时删除?
在不需要的时候才删除。系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。
长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
多版本并发控制-MVCC
隐藏列
对于 InnoDB ,每行记录除了我们创建的字段外,其实还包含 3 个隐藏的列:
- row_id:隐藏的自增 id。如果表没有主键,InnoDB 会自动生成一个row_id,类似于主键。
- trx_id(事务id):记录最后一次修改该记录的事务 id
InnoDB 里面每个事务都有一个唯一的事务 id,它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
每次事务更新数据的时候,都会生成一个新的数据版本,并且把当前事务的事务ID记录在trx_id隐藏列中。
同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的trx_id。而这些旧的数据就是保存在undo log中。 - roll_pointer(回滚指针):指向这条记录的上一个版本。对数据记录进行改动时,会把旧版本的记录写入undo log中,这个回滚指针就是指向每一个旧版本记录,于是就可以通过它找到修改前的记录
Read View
Read View 有四个重要的字段:
- m_ids :创建
Read View
时,InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 id。“活跃”指的就是,启动了但还没提交。 - min_trx_id :创建
Read View
时,活跃事务中的最小事务id,也就是m_ids
的最小值。 - max_trx_id :当前系统里面已经创建过的事务 id 的最大值加 1 。
- creator_trx_id :指的是当前事务的事务id。
m_ids
活跃数组和max_trx_id
就组成了当前事务的一致性视图(Read View)
而数据版本的可见性规则,就是基于数据记录的 trx_id
和这个一致性视图的对比结果得到的。
这个视图数组把所有的 trx_id
分成了几种不同的情况。
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:
- 如果记录的
trx_id
值小于Read View
中的min_trx_id
值,表示这个版本的记录是在创建当前Read View
前已经提交的事务生成的,所以该版本的记录对当前事务可见。 - 如果记录的
trx_id
值大于等于Read View
中的max_trx_id
值,表示这个版本的记录是在创建当前Read View
后才启动的事务生成的,所以该版本的记录对当前事务不可见。 - 如果记录的
trx_id
值在Read View
的min_trx_id
和max_trx_id
之间,需要判断trx_id
是否在m_ids
列表中:
- 如果记录的
trx_id
在m_ids
列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。 - 如果记录的
trx_id
不在m_ids
列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见,这是针对读提交。
工作原理
以隔离级别为可重复读为例,假设user表里面有一条记录,这条记录的trx_id=66。如下所示
id | balance | row_id | trx_id | roll_pointer |
1 | 10 | 1 | 66 | Null |
现在有A、B、C三个事务依次启动,假设A的事务id=100,B的事务id=101,C的事务id=102,如下所示
这样,事务 A 的视图数组m_ids= [100],事务 B 的视图数组m_ids=[100,101],事务 C 的视图数组m_ids=[100,101,102]。
从图中可以看到
T1时刻,第一个有效更新是事务 C,把数据balance从10改成了11。这时候,这条记录的最新版本的trx_id
是102,而trx_id
=60这个版本已经成为了历史版本。
T2时刻,第二个有效更新是事务 B,把数据balance从11 改成了12。这时候,这条记录的最新版本的trx_id
是101,而 trx_id
=102 又成为了历史版本。
T3时刻,事务A开始读取数据,事务A的视图数据m_ids= [100],min_trx_id=100,max_trx_id=101
- 找到了id=1这条记录,此时balance=12,
trx_id
=101,此时trx_id >= max_trx_id
,说明这个版本的记录是在创建当前Read View
后才启动的事务生成的,所以该版本的记录对当前事务不可见。 - 接着,找到上一个历史版本,一看
trx_id
=102,此时trx_id >= max_trx_id
,还是不可见。 - 再往前找,此时找到了balance=10,
trx_id
=66,此时trx_id < min_trx_id
,可见。
上图中的T2时刻,事务B为何可以将balance设置为12,根据可重复读原理,在更新之前,事务B读取到的balance应该为10,为什么加1之后变成了12。这是因为,当事务B要去更新数据的时候,不能在历史版本上更新,否则事务 C 的更新就丢失了。因此,事务 B 此时的set balance=balance+1
是在事务C提交的基础上进行的操作。因此,就有这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。