InnoDB 事务隔离机制
1、事务四大特性:ACID
Atomicity 原子性:事务的操作要么一起成功,要么一起失败;
Consistency 一致性:一致性是对数据可见性的约束,一个事务多次操作数据的中间状态对其他事务不可见;
Isolation 隔离性:多个事务并发执行时,一个事务不应该受到其他事务的影响,数据库支持不同的隔离级别来满足不同场景的需求;
Durability 持久性:事务完成之后,所有的操作结果都保存到了数据库之中,不会丢失,不能回滚;
2、事务并发产生的问题
脏读:对于两个事务 T1 和T2,T1读取了已经被T2更新但还没有被提交的字段,之后若T2进行回滚,T1读取的内容就是临时且无效的;
不可重复读:对于两个事务 T1 和 T2 , T1 读取了一个字段,然后T2 更新了该字段,之后T1再次读取同一个字段,值就不同了;
幻读:对于两个事务 T1,T2,T1 从表中读取了一个字段,然后T2在该表中插入了一些新的行,之后T1再次读取同一个表,就会多出几行;
不可重复读 和 幻读 的区别:不可重复读针对的是更新和删除操作,幻读针对的是插入操作,比如:T1 正在操作一条记录,如果加锁,T2 就不能对这条记录进行更新和删除,这就避免了 不可重复读,但无法避免 T2 插入新的数据,也就是无法避免幻读,InnoDB 中通过 gap 锁来解决幻读问题,这里我们不讨论 gap 锁;
3、InnoDB 如何解决事务并发问题
InnoDB 为了解决事务并发导致的脏读、不可重复读、幻读问题,提供了事务隔离机制,共有四种隔离级别:
读未提交:一个事务还未提交,他的变更就能被其他事务看到,这个级别就是没有任何隔离;
读已提交:一个事务的变更,只有在提交后才能被其他事务看到;
可重复读:一个事务执行过程中看到的数据,总是跟这个事务启动时看到的数据一致,即使数据被其他事务更改并提交,也是不可见的;
串行化:对于同一行记录,写会加写锁,读会加读锁,当出现读写锁冲突时,后访问的事务必须等之前的事务执行完成才能继续执行;
隔离级别越高,数据一致性越能得到保障,但并发性也就越低,MySQL 默认的隔离级别是 可重复读;
针对隔离级别的相关操作:
-- 查看隔离级别
show variables like 'transaction_isolation';
-- 设置当前会话隔离级别
set session transaction isolation level read uncommitted;
set session transaction isolation level read committed;
set session transaction isolation level repeatable read;
set session transaction isolation level serializable;
-- 设置整个库的隔离级别
set global transaction isolation level read uncommitted;
set global transaction isolation level read committed;
set global transaction isolation level repeatable read;
set global transaction isolation level serializable;
4、InnoDB 隔离级别的实现
InnoDB 的四种隔离级别,读未提交不需要做任何操作,做任何操作都读当前最新的值就可以了,串行化是严格的互斥操作,通过加锁来实现,读已提交和可重复读则通过 MVCC 来实现,下面我们重点研究 MVCC 的实现原理;
4.1 两种读模式
-
快照读
读取事务快照的历史版本数据,无需加锁,正常的 select 语句就是快照读; -
当前读
读取数据库最新的数据,需要加锁才能实现,采用当前读的语句:select ... lock in share mode; select ... for update; delete update insert into replace into
看下面两个例子:
/** * 假设 num 初始值为 1,按照 代码行中的标号顺序进行执行, * session1 中 ② 和 ⑦ 查询到的 num 分别是多少? */ session1: start transaction with consistent snapshot; -- ① select * from test; -- ② select * from test; -- ⑦ commit; -- ⑧ session2: start transaction with consistent snapshot; -- ③ update test set num = 2 where id = 1; -- ④ select * from test; -- ⑤ commit; -- ⑥ /** * 假设 num 初始值为 1,按照 代码行中的标号顺序进行执行, * session1 中 ② 和 ⑧ 分别查到什么结果?⑤ 能顺利执行吗? */ session1: start transaction with consistent snapshot; -- ① select * from test; -- ② update test set num = 3 where id = 1; -- ⑤ select * from test; -- ⑧ commit; -- ⑨ session2: start transaction with consistent snapshot; -- ③ update test set num = 2 where id = 1; -- ④ select * from test; -- ⑥ commit; -- ⑦
以上两个例子:
- 由于读操作是快照读,而事务开始时已经通过 with consistent snapshot 语句生成了快照,因此两次查询结果都是 1;
- 第一个读是快照读,结果是 1,执行到更新语句时,由于 session2 先一步做了更新,而更新属于当前读,因此两个更新同时操作时会冲突,这里通过锁来解决冲突,从而可知,session1 的 ⑤ 在 session2 提交之前都处于阻塞等待状态,session 2 提交之后锁释放了,session1 才能继续执行,第二个查询得到的结果是更新之后的结果 3;
4.2 undo log
- MySQL 中对于每一条更新操作,都会记录 undo log 用于回滚和支持多版本并发控制操作(MVCC);
- undo log 记录的是逻辑日志,可以认为当delete一条记录时,undo log 中会记录一条对应的 insert 记录,反之亦然,当update 一条记录时,它记录一条对应相反的 update 记录;
- 应用到行版本控制的时候,是这样工作的:当读取的某一行被其他事务锁定时,它可以从 undo log 中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取;
这里我们不做 undo log 的详细工作原理的研究,只需要知道 MVCC 需要根据 undo log 来实现多版本数据的查找就可以了;
4.3 表的隐藏表字段
-
InnoDB 里每个事务都有一个唯一的 ID,即:transaction id,它是事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的;
-
InnoDB 通过将每条数据与操作它的 transaction id 和 undo log 做关联,来达到维护多个数据版本的目的,那么如何做关联呢?就是为表增加隐藏列,InnoDB 会默认为每张表增加三个列:
列名称 描述 DB_ROW_ID 行标识(隐藏的自增 id,如果没有明确的聚集索引,InnoDB 会自动生成一个聚集索引,这个聚集索引的值就是该 id); DB_TRX_ID 插入或更新行的最后一个事务id(删除也视为更新,但会标记为已删除); DB_ROLL_PTR 指向对应的 undo log 用于回滚到上一个版本; -
一个事务对一行进行插入或更新,会将这一行的 DB_TRX_ID 的值修改为自身的事务 id,并将 DB_ROLL_PTR 指向生成的 undo log;
4.4 一致性视图
- MySQL 中有两种视图,一种是 view,是用一个查询语句定义的虚拟表,在调用的时候调用查询语句并生成结果,另一种是 实现 MVCC 时用到的一致性视图 consistent read view,用于支持 RC(读提交)和 RR(可重复读)隔离级别的实现;
- 一个事务以生成一致性视图的时刻为准,如果一个数据版本在其之前生成,事务就认为该数据可见,如果在其之后生成事务就认为不可见,必须通过回滚的方式找到上一个版本,如果上一个版本还在一致性视图生成之后,那就继续往前回滚,直到找到比一致性视图生成时刻更早的数据版本为止,这个“回滚”操作就是通过前面表隐藏字段记录的版本信息和 undo log 来实现的;
事务生成的时刻:-- 以下事务中,第一次执行快照读操作的时候生成一致性视图 start transaction; ... commit / rollback; -- 可以通过以下语句控制事务在启动时就生成一致性视图 start transaction with consistent snapshot;
- 在实现上,InnoDB 为每个事物构建了一个数组,用来存储这个事务启动瞬间,当前所有活跃的事务 id,所谓活跃是指已经启动但未提交,数组里最小的 id 记为低水位,当前已创建的最大的事务 id+1 记为高水位,这个数组和高水位一起组成了当前事务的一致性视图;(在分配事务 id 和生成视图之间的时间段,可能产生新的事务,其 id 大于当前事务 id,若其在当前事务的一致性视图生成之前提交了,则其结果对当前事务也是可见的)
- 可以通过低水位和高水位将 MySQL 中的事务 id 分为三段:小于低水位、大于等于低水位且小于高水位、大于等于高水位,我们将三段简称为 低段、中段、高段,判断一条数据是否对当前事务可见,直接根据这条数据的 DB_TRX_ID 与当前事务的一致性视图比较即可:
- 如果数据的 DB_TRX_ID 落在了低段,即:DB_TRX_ID 小于低水位,则说明这条数据在事务生成一致性视图时已提交,可见;
- 如果数据的 DB_TRX_ID 落在了高段,即:DB_TRX_ID 大于等于高水位,则说明这条数据在事务生成一致性视图时还未提交,不可见;
- 如果数据的 DB_TRX_ID 落在了中段,即:DB_TRX_ID 大于等于低水位且小于高水位,此时,若 DB_TRX_ID 在数组中,则说明生成一致性视图时该数据还未提交,不可见,否则,这条数据可见;
以上就是一致性视图的工作原理;
RR 隔离级别是在事务执行第一条快照读语句时创建一致性视图的;
RC 隔离级别则每次执行快照读语句是都会创建最新的一致性视图;
4.5 例程
执行序号 | session1 | session2 | session3 |
---|---|---|---|
1 | start transaction; | ||
2 | start transaction; | ||
3 | insert into test(num) values(100); | ||
4 | insert into test(num) values(101); | ||
5 | select num from test; | ||
6 | insert into test(num) values(102); | ||
7 | insert into test(num) values(103); | ||
8 | insert into test(num) values(104); | ||
9 | select num from test; | ||
10 | select num from test; | ||
11 | select num from test; |
最终,三个 session 中的查询语句结果分别是多少?
- 假设 session1 中的事务 id 为 5,则 session2 中事务 id 为 6,session3 中四个事务的 id 分别为 7、8、9、10(MySQL 中不显示指定事务时,默认一条语句为一个事务);
- 根据以上假设:
- session1 中第 5 行创建一致性视图时,活跃事务队列为(5, 6),低水位为 5,高水位为 7,由于 session2 的事务 id 在队列中,因此 session1 的事务对插入的 101 不可见,因此第 5 行的查询结果为 100;
- session3 中第 9 行创建一致性视图时,活跃事务队列为(5, 6, 10),低水位为 5,高水位为 11,它不可见前面两个事务插入的数据,因此查询的结果为 102、103、104;
- session2 中第 10 行创建一致性视图时,活跃事务队列为(5, 6),低水位为 5,高水位为 11(此时全局最大的事务 id 为 10),它对 session3 的几个事务插入的数据均可见,但对 session1 插入的数据不可见,因此查询的结果为 101、102、103、104;
- session1 中第 11 行,由于之前已经创建过一致性视图了,因此这里不再创建,查询结果还是 100;
- 以上操作均在 RR 隔离级别下进行,若是 RC 隔离级别,则每次快照读都会创建最新的一致性视图,具体结果可自行推导;
5、参考资料
- 极客时间 林晓斌 老师的《MySQL 实战 45 讲》
- https://github.com/zhangyachen/zhangyachen.github.io/issues/68