事务
我们平时在开发中是离不开 MySQL 的事务的,那什么是事务?事务就是逻辑上的一组操作,要么一荣俱荣,要么一损俱损,谁都别活。 事务的目的就是一个,保证数据的一致性。
事务我们常说有四个特性,ACID,那这些的含义是什么?
- A(Atomicity) 原子性: 事务是最小的执行单位,不允许分割,要么全部完成,要么完全失去作用。
- C(Consistency)一致性: 执行事务前后,数据保持一致,我给你 100 ,那么我就减少 100 ,你增加 100,咱俩的钱的总量是不变的。
- I(Isolation)隔离性: 每个事务之间的隔离的,互不影响。
- D(Durability)持久性: 一个事务提交之后,对数据库的改变是持久的。
而原子性,隔离性,持久性都是手段,究极目的都是为了一致性。
因为存在并发的事务,提升了性能,也会出现一些问题。而这些问题也是通过很多手段进行解决,我们细细道来~
脏度
一个事务读取到了另一个事务还没有提交的数据,这就是脏读。举个栗子,比如,A 事务和B 事务同时进行,A 事务将数据 a 从10改成了 20,B 事务读取了 a 的值是 20,当 A 回滚了的话,B 事务还是用 20 进行的后续处理,这就是脏读,也是比较严重的问题。
丢失修改
还是举个栗子,AB 两个事务同时进行,A 事务读取 a 的值为 20,然后进行-1 得到 19,B 事务读取 a 的值也是 20 然后进行-1 也是 19,两个存储库之后发现结果为 19, 丢失了率先提交的事务的数据。
不可重复读
当一个事务对一个数据进行多次查询的时候,因为别的事务进行了修改并提交,那么可能会导致,这个事务中多次读取到的数据是不一致的,这就叫不可重复读。
幻行读
和不可重复读的场景类似,区别就是,读取几行数据的时候,比如范围查询,age<50 ,读到了 5 行数据,然后其他事务也插入了 age 小于 50 的数据,那么这个事务再次执行这个查询的时候会发现数据多了,这就是幻行读。
为了解决这些问题,MySQL 也弄出了很多的方案,首当其冲的就是隔离级别
SQL 的隔离级别
- 读未提交: 最低的隔离级别,可以读取未提交的数据,脏读,不可重复读,幻行读都可能会发生。
- 读已提交: 允许读取已经提交的数据,避免了脏度的出现,但是不可重复读和幻行读仍然可能出现。
- 可重复度: 对同一个字段的多次的读取结果是一致的,除非自己修改的,避免了脏读和不可重复读,但是仍然存在幻行读的情况。
- 可串行化: 最高的隔离级别,都特么别玩了,事务给我一个一个走,三种问题都避免了,效率直接拉低到谷底。
InnoDB 存储引擎默认的隔离级别是可重复度,也就是 RR ,为什么呢?这就和 MySQL 的机制有关系了,MySQL 我们很常见的特性就是集群部署,读写分离,主从复制,而主从复制是依赖于 binlog 的,我在之前的文章讲解 binlog 的时候说过,binlog 记录数据是是以逻辑SQL 的形式的,有三种模式,分别是 statement ,row ,和mixed ,具体的大家翻我前面的文章,statement 记录的是 SQL 的原文。好了举个栗子。在 RC 的隔离级别下
这样的执行过程之后因为事务 ** ** 是先提交的,那么 binlog 里面就先记录了 insert ,然后再记录 delete ,那么同步从库的时候就全给删了,这属于幻行读的一部分,所以 MySQL 默认隔离级别是 RR , 那么总不能允许幻行读的存在吧,所以就依赖两种机制来避免,就是锁+MVCC 机制。我们先说锁
锁
MySQL 同时支持表级锁和行级锁,表级锁就是锁整张表,加锁快,不会出现死锁,但是并发的场景下效率是非常低的。
共享锁和排他锁
落实到数据库层面都是加的这两种锁,其实也就是读锁和写锁。而且都是在 SQL 上有所体现的。
共享锁可以通过 select 。。。。 lock in share mode(Mysql5.7 或 8)/select 。。。for share 来显示指定。
排他锁可以通过select 。。。。for update 来显示指定。
意向锁
意向锁其实就是一种标志,我们上面说的锁都是对于表内数据的锁,而数据是有很多行了,如何判定表中的数据有没有锁呢?我们总不能遍历所有的数据然后找吧,所以就增加了意向锁,分为意向共享锁(IS),和意向独占锁(IX),也就是当我们想对数据增加锁的时候都会对表增加意向意向锁。
行级锁
行级锁不是单纯的锁一行,而是一个概念,是以行为单位的锁,在 MySQL 中,数据是以索引树的形式组织的,锁的相关都和索引相关,关于索引的问题,我会单独抽出一篇文章进行讲解。当delete 或 update 的时候的 where 条件没有命中索引或者索引不生效的情况下,会加表锁。
行级锁主要就是三种,记录锁(record lock ),间隙锁(gap lock ),和临键锁(next-key lock ),临键锁就是记录锁和间隙锁的组合,在 RR 的隔离级别下,默认的行级锁都是临键锁,但是啊,如果你操作的索引是唯一索引或者是主键,那么加的就是记录锁了。所以关键点还是锁一下这个临键锁,我会非常详细的推演整个锁的过程,看看是怎么敲定锁的范围的。
MySQL版本,8.0.34 ,表数据示例
1、主键等值查询,并且数据存在的情况下
sql
begin;
select * from test where aid = 5 for update;
select * from performance_schema.data_locks 查看锁的状态
此时第一行加的是表锁,加的一个IX,就是意向独占锁,第二行是加的行级锁,类型是独占锁,锁的数据是主键为5的数据。
同理使用for share 就是加的IS 和S。X,REC_NOT_GAP: 行独占锁,锁定1行
所以当查询使用主键,并且数据存在的情况下,使用的是记录锁。
2、主键等值查询,但是数据不存在的情况
sql
begin;
select * from test where aid = 7 for update;
select * from performance_schema.data_locks 查看锁的状态
此时第一行加的是表锁,加的一个IX ,就是意向独占锁,第二行是加的行级锁,类型是独占锁+间隙锁,数据是10,因为10是不等于查询的7 的
所以是开区间,然后得到的区间就是 (5,10) ,5没有显示是因为5是第一条数据了,其实也就是起点;
开启另一个事务执行:
insert into test values(4,4,4,4);
成功!
insert into test values(8,8,8,8);
失败
X,GAP: 独占锁,开区间
所以当查询使用主键,但是数据不存在的情况下,使用间隙锁,锁定范围就是小于查询数据最近和大于查询数据最近的范围,左开右开区间。
3、使用主键范围查询两个值都能查到的情况
sql
begin;
select * from test where aid >=5 and aid <=10 for update;
commit;
第一行5的数据是行独占锁,所以就是左边5 闭合区间;
第二行10 的数据是X,所以就是Next-key lock 闭区间,最后得到的结果就是 [5,10]
开启新事务
执行insert into test values(8,8,8,8);
执行失败;
执行insert into test values(4,4,4,4);
执行成功!
所以使用主键范围查询两个值都能查到的情况,左闭右闭。下面就不演示都能查到的情况了。
4、使用主键范围,左边能查到,右边查不到的情况
sql
begin;
select * from test where aid >5 and aid <7 for update;
commit;
没有5的行独占,所以区间是**(5,10)**
开启新事务
执行insert into test values(4,4,4,4);
成功!
执行insert into test values(8,8,8,8);
失败!
执行update test set a = 11 where aid =10;
成功!
所以使用主键范围左边能查到右边查不到的情况,会往下找到最近的数据,然后开区间。
5、使用主键范围,左右都不能查到
sql
begin;
select * from test where aid>6 and aid<13 for update;
commit;
区间就是 (5,15)
开启新事务:
insert into test values(4,4,4,4);
成功!
insert into test values(8,8,8,8);
失败!
update test set a = 11 where aid =15;
成功!
update test set a = 11 where aid =10;
失败
所以使用主键范围,左右都查不到的情况下,那么就左边最近的数据作为左开区间,右边最近的数据作为右开区间。
主键和其他索引生效的效果差不多,差的就是锁的数据量的问题,毕竟索引是按照排序规则构建的,所以范围就是有序的一种查找。从上面我们就知道右边当右边的值能查到的时候。那个值就是边界,这个其实是修复过的bug ,之所以称之为Next-key lock 就是因为要锁住下一个key,早以前的一些版本中,它会从这个值之后往下找第一个不等于它的作为边界,还是看这个例子
sql
begin;
select * from test where aid >=5 and aid <=10 for update;
commit;
所以上面的结果的区间就变成**[5,15]** ,把20给锁了,这个是我们不希望看到了。同时对于加锁规则,比如
sql
begin;
select * from test where aid>6 and aid<13 for update;
commit;
这个例子在8.0.17 版本下会锁住右边15的数据,还是闭合区间,在8.0.18版本修复了。
我用的是8.0.34 ,这些问题都没有出现,应该是在8.0.25版本之后就没有这两种问题了,现在所看到的结果更像是我们期待的结果。
同理如果左边不能查到,右边能查到,那么就左边最近的数据作为左开区间,右边数据就是右边界,开闭取决于等于不等于。
小总结: 所以加锁能一定程度上解决读数据的一些问题,但是加锁就代表着有额外的开销,那么就会影响性能,在 RC 的隔离级别下,是不会使用间隙锁和临键锁的,所以就会产生不可重复读和幻行读的情况,但是我们很多业务情况下都是可以不去考虑这些问题的,比如你要更新年龄小于 15 的数据,那么即便是幻读了,但是也是年龄小于 15 的数据,对结果也是没有影响的,相反的情况下,如果额外增加锁去控制,反倒会增加开销,甚至是增加死锁的概率。所以很多大厂在即便是默认隔离级别是 RR 的情况下,也会选择 RC 的原因,遇到这种极端情况,可以用其他的手段去解决。关于死锁的问题,放到讲解索引的地方说。
当前读与快照读
快照读就是读取快照生成的那一刻的数据,比如我们不加锁的select 语句都是快照读,当前读就是加锁的 select,或者增删改的语句。其实快照读是很关键的,在 RC 的级别下,每次读取都会生成一个快照,总是读取行的最新版本,这也就是不可重复读的产生原因。而在RR 的级别下,快照在事务中第一次 select 的时候生成,然后只有本事务对数据进行更改才会更新快照。 这两个很重要,先记住。而如果在 RR 下使用了当前读,那么就要增加next-key lock来控制幻读的产生。
MVCC详解
当我们理清了锁的概念以及隔离级别之后,我们就可以进入 MVCC 了,MVCC 也就是多版本并发控制,这是一种对于并发数据一致性控制的一个手段,并不是某些引擎独有的,只是实现细节不同,下面我将会对 Innodb 引擎在 RC 和 RR 隔离级别的场景下,来讲一下它的实现过程。
我上面说了,读取和写入都是依赖于数据快照的,不可能直接修改数据,都是修改完然后再放过去。而快照基于版本,多次更新就是多个版本,对于读操作,那么就是我要读取到不晚于我这个事务开始时间的最新版本。
对于写操作也是类似,我要写数据,那么我一定对我获得的这个数据快照升一个版本,然后依赖于这个新版本对并发修改进行控制。很有CAS 那个味道。
一个事务成功提交,那么它提交的这个数据版本,就是数据库最新的版本,对其他事务可见。
一个事务回滚,那么所做的修改全部撤销,版本失效,对其他事务不可见。
所以数据库事务的版本将会频繁更新,所以 MVCC 也会定期更新,删除不再需要的旧版本数据,释放空间。
MVCC 的基础概念和核心属性
MVCC 的实现依赖于下面这三个:
-
隐藏字段: InnoDB 为每行记录增加了三个隐藏字段
DB_TRX_ID: 代表着最后一次插入或者更新该行的事务 id ,对于 delete 操作,在内部视为更新操作,会在记录头的 delete_flag 设置为已删除。
DB_ROLL_PTR: 回滚指针,指向该行的 redolog,如果没被更新过,那么就是空。
DB_ROW_ID: 如果没有设置主键且没有唯一索引的情况下,会使用这个id 来生成聚簇索引。
-
ReadView: 这个也是算法的核心,依赖于这里面的数据进行判定数据是否可见,结构如下
java
class ReadView {
/* ... */
private:
// 下一个将被分配的事务 id,所以大于等于这个 id 的事务的版本是不可见的
trx_id_t m_low_limit_id;
// 活跃事务列表,也就是事务开启的时候,还没有提交事务的 id 列表,不包括当前事务和已经提交的事务
ids_t m_ids;
// 活跃事务列表中最小的事务id,如果活跃事务为空,那么这个值就等于上面那个即将被分配事务的 id
//小于这个id的事务都是可见的
trx_id_t m_up_limit_id;
//创建该 Read View 的事务ID
trx_id_t m_creator_trx_id;
//事务 Number, 小于该 Number 的 Undo Logs 均可以被清除
trx_id_t m_low_limit_no;
//标记 Read View 是否 close
m_closed;
}
- undo log : 这个之前将日志相关的知识点的时候说过了,主要就是用它进行回滚,它有个很关键的点就是它一旦事务提交就会将 log 标记成可回收状态,然后使用清理线程清理,它分为两类,一种是
insert undo log
,一种是update undo log
,对于insert
来说新增进入的数据只能在当前事务中可见,回滚那么就删除就行了,对于update
来说,可能是需要更新多次的,所以就需要形成一个链表,链表的尾部就是读取到的最原始数据,链表的头部就是更新的最新的数据(理解成头插法)。undolog
不是事务私有的,所有的事务都会在这里记录的,所以undolog
也记录了事务的 id 和指向上一次修改的 undolog 指针,从而形成链表,这个很关键。
数据可见性算法
上面说的都是这个算法所需要依赖的数据,在 Innodb 存储引擎中,在开启一个事务之后,在执行 select
之前都会创建一个 Read View
,也就相当于打了一个快照,然后比较快照数据来决定数据是否可见。过程如下:
- 如果读取记录的
DB_TRX_ID
事务id 小于m_up_limit_id
活跃事务的最小 id,那么表明记录已经提交了,是可见的。 - 如果读取记录的
DB_TRX_ID
事务id 大于等于m_up_limit_id
活跃事务的最小id ,那么跳到步骤 5。 - 因为
m_up_limit_id
可能等于当前的事务 id ,所以再判断一下活跃事务列表是否为 null,如果为空说明修改该行的事务已经提交了,那么就可见 - 如果读取记录的
DB_TRX_ID
事务id 大于等于m_up_limit_id
活跃事务的最小id 且小于m_low_limit_id
即将分配的事务 id ,那么就说明记录的DB_TRX_ID
事务id 可能在活跃事务列表中,那么就需要查找一下,如果找到了,说明我创建ReadView
之前这个记录已经被记录的DB_TRX_ID
事务id 但是没有提交,或者创建ReadView
之后这个记录被记录的DB_TRX_ID
事务id修改了,这些都不可见,继续步骤5 往上找。 如果没找到,说明已经提交了,就可见。 - 根据记录行上面记录的 undolog 的指针取出快照记录,然后继续步骤 1 用快照记录的 DB_TRX_ID,直到找到满足的快照版本或者返回空。
看个流程图更清晰一些
所以这个 ReadView
很关键,在 RC 下每次都会生成一个新的 ReadView
,所以就会造成不可重复读,前后看见的数据可能不一致,但是在 RR 的隔离级别下,只会用第一次生成的 ReadView
,实现了可重复读也防止了快照下的幻读。