简介

当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。

名称 解释
脏读(dirty read) 一个事务读取到了其他事务未提交的数据
不可重复读(non-repeatable read) 一个事务前后多次读取,读取到的数据内容不一致
幻读(phantom read) 一个事务前后多次读取,读取到的数据总量不一致

隔离性是为了保证事务不受彼此之间干扰的一种属性,但是有得就有舍,你隔离的越严实,效率就会越低。因此很多时候,我们都要在两者之间做一个平衡点。

隔离级别

简介

SQL 标准的事务隔离级别包括:

名称 解释 可解决问题
读未提交(read uncommitted) 一个事务还没有提交,它做的更改就能被其他事务看到 /
读已提交(read committed) 一个事务提交之后,它说做的更改才能被其他事务看到 脏读
可重复读(repeatable read) 一个事务在执行过程中看到的事务总是和这个事务启动时看到的数据一致 不可重复读
串行化(serializable) 对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行 幻读

这四种隔离级别,并行能力依次降低,安全性依次提高。
数据库的隔离级别可以通过 transaction_isolation 这个值查看
image.png

隔离级别的实现

在实现上,数据库里面会创建一个一致性读视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

名称 隔离级别的实现方式
读未提交(read uncommitted) 直接读取记录的最新值
读已提交(read committed) 在每条SQL语句执行之前创建一致性读视图,数据以这个视图内的逻辑为主
可重复读(repeatable read) 在事务启动时创建一致性读视图,数据以这个视图内的逻辑为主
串行化(serializable) 采用加锁的方式防止事务之间的并行访问

可重复读的实现

InnoDB 的可重复读由 MVCC 和 行锁 实现

MVCC

MVCC(Multiversion Concurrency Control)中文名为多版本并发控制,通过保存数据的多个版本快照来实现并发控制,作用于已提交读和可重复读这两个隔离级别。
在上文中,我们提到了可重复读会在事务开启后创建一个一致性视图,在MySQL 中,有两个视图的概念,一个是 view,另一个则是我们要讲到的一致性读视图:

  • view 是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
  • 一致性读视图是 InnoDB 实现 MVCC 使用的技术,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
    数据版本可见性
    一致性读视图并不是一个物理的概念,经常有人说他是MySQL 在事务开启时创建的一个快照,然而这是不现实的(想想一个100G 的数据库…),下面我们来看看这个“快照” 是怎么实现的。
    首先,我们来看看这个“快照”是怎么被创建出来的,MySQL 在事务开启后,会为每个事务分配一个严格递增的事务ID (transaction id),InnoDB 表的每一行数据在被事务修改时,会为每一个事务添加一个数据版本,数据版本并不会在物理上存储这个版本的实际数据,它的主要构成为行事务ID(row trx_id)和 undo log,每个版本的实际数据由上一个版本的数据和 undo log 回滚得到,可以把多版本数据看成一条链表。
    事务启动,获取事务ID 之后,MySQL会为每个事务维护一个活跃事务数组(活跃事务即当前已经开启且未终止(回滚、提交)的事务),数组里面的事务ID 最小值为低水位,当前系统已创建的事务ID 的最大值 + 1为高水位。这个数组和高水位,就组成了当前事务的一致性视图(read-view),而数据版本对于当前事务可不可见,就是基于数据的 row trx_id 和这个一致性视图比对得到的。
    image.png
    这样,对于事务启动的瞬间来说,一个数据版本的 row trx_id 有这么几种可能性:
  1. 如果落在绿色部分,表示这个版本是已经提交的事务或者是当前事务自己生成的,这个数据是可见的
    1. 如果事务创建时,当前数据库只有自己这个事务,那么当前事务的事务ID 就是低水位
  2. 如果落在红色区域,表示创建这个数据版本的事务晚于当前事务,这个数据不可见
  3. 如果落在黄色区域,那么就包含两种情况:
    1. 若row trx_id 在活跃事务数组中,表示这个row trx_id 标识的事务在当前事务创建时未提交,这个数据不可见
    2. 若 row trx_id 不在活跃事务数组中,表示这个 row trx_id 标识的事务早于当前事务创建,且当前事务创建时已经被提交。

InnoDB 利用了“所有数据都有多个版本”这个特性,实现了“秒级创建快照”的特性。

更新逻辑

说完了查询的逻辑,我们再看看更新。这里我们举一个例子:假设有两个事务A、B分别对表T中ID 为 2 的某一行的字段 K 进行自增操作,K 的初始值为 1。

事务A 事务B
start transaction with consistent snapshot;
update t set k = k + 1 where id = 2;
update t set k = k + 1 where id = 2;
select k from t where id = 2;

按照上述表格中的时序执行事务A、B ,最终事务A 查询出来的结果为 3,这个结果和我们上述的数据版本可见性理论不符,但是它又是符合逻辑的,因为如果事务A update 之后的结果是 2 ,那么就相当于事务B 的更新操作被折叠了,这明显不是我们想要的结果。
实际上,这里用到了MySQL的一个规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。因此在这里,事务A 在更新之前获取到了当前 k 的值为 2,再执行自增操作,k 就变成 3 了。实际上,不止是更新语句,如果查询语句上面加了 for update 或者lock in share mode,那么这一条查询语句,也是当前读。

  1. # 加了读锁(S 锁,共享锁)
  2. mysql> select k from t where id=1 lock in share mode;
  3. # 写锁(X 锁,排他锁)
  4. mysql> select k from t where id=1 for update;

再往前一步,如果事务B 不是马上提交的,而是编程了下面这个失序,会发生什么呢?

事务A 事务B
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 2;
update t set k = k + 1 where id = 2;
select k from t where id = 2;
commit;
commit;
  1. 这里就要两阶段协议锁(锁资源在需要时获取,在事务终止时释放)发挥作用了,事务B在更新之后会获取id =2 这一行的锁,而事务A 在更新时的当前读也需要获取锁,因此事务A 会阻塞直到事务 B 提交。