🌟 前言

🐶 作者简介:大家好,我是周周,目前就职于国内短视频小厂BUG攻城狮一枚。
💻个人主页:程序猿周周
📖专题系列:Java面试总结
🤺 如果文章对你有帮助,记得👍点赞👍、👀关注👀➕👌收藏👌,一键三连哦,你的支持将成为我最大的动力。

1 事务

我们都知道 MySQL 的 InnoDB 引擎是支持事务的。数据库的事务是一个不可分割的数据库操作序列,也是数据库并发控制的基本单位,其执行的结果必须使数据库从一种一致性状态变到另一种一致性状态。事务是逻辑上的一组操作,要么都执行,要么都不执行。一个典型的事务应用场景就是转账。

1.1 事务基本特性

事务有四个基本特性,分别是原子性、一致性、隔离性以及持久性,简称 ACID:

  • 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用
  • 一致性(Consistency): 事务执行前后,数据保持一致,多个事务对同一个数据读取的结果是相同的
  • 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的
  • 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

前面我们已经知道事务的原子性持久性依靠 MySQL 的几种日志实现(传送门),而一致性是原子性、持久性以及隔离性的最终体现,那么我们今天就来讲一下事务是如何实现隔离性的。

1.2 事务并发问题

相对于串行处理来说,事务的并发处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量。但并发事务处理也会带来一些问题:

  • 脏读:一个事务读取到另一个事务尚未提交的数据。 事务 A 读取事务 B 更新的数据,然后 B 回滚操作,那么 A 读取到的数据是脏数据。

  • 不可重复读:一个事务中两次读取的数据的内容不一致。 事务 A 多次读取同一数据,事务 B 在事务 A 多次读取的过程中,对数据作了更新并提交,导致事务 A 多次读取同一数据时,结果 不一致。

  • 幻读:一个事务中两次读取的数据量不一致。 系统管理员 A 将数据库中所有学生的成绩从具体分数改为 ABCDE 等级,但是系统管理员 B 就在这个时候插入了一条具体分数的记录,当系统管理员 A 改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。

不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。 解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表。

1.3 事务隔离级别

现在我们知道了并发事务带来的好处以及引发问题,SQL 标准也定义了四种隔离级别方便我们根据自身业务来抉择收益于问题间的平衡,这四种隔离级别分别是:

从上图可以看出,在标准 SQL 中只有串行化可以同时解决脏读、不可重复读以及幻读三大隔离问题。

MySQL 不仅全都支持(默认是可重复读),并且通过 MVVC 机制在高效解决读写冲突问题的同时,还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。

2 锁机制

为了解决并发事务带来的数据安全问题,MySQL 使用了锁机制。简单来说,就是数据库为了保证数据的一致性,而使各种共享资源在被并发访问时变得有序所设计的一种规则。

2.1 锁分类

MySQL 中的锁按粒度可以分为行级锁页级锁以及表级锁,其中行锁按兼容性还分为共享锁排它锁。按照加锁机制划分又可以分为乐观锁悲观锁

2.1.1 粒度划分
  • 表级锁:是 MySQL 中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分 MySQL 引擎支持。但带来最大的负面影响就是出现锁定资源争用的概率也会最高,致使并发度大打折扣。

  • 行级锁:是 MySQL 中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。又可以分为共享锁和排他锁。

  • 页级锁:是 MySQL 中锁定粒度介于行级锁和表级锁中间的一种锁。

MySQL 常用存储引擎的锁机制:

1)MyISAM 和 MEMORY 采用表级锁(table-level locking);

2)BDB采用页级锁(page-level locking)或表级锁,默认为页面锁;

3)InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。

2.1.2 用法划分

InnoDB 实现了标准的行级锁,包括两种:共享锁(S 锁)、排它锁(X 锁):

  • 共享锁:对当前行加共享锁,不会阻塞其他事务对同一行的读请求,但会阻塞对同一行的写请求。

  • 排它锁:会阻塞其他事务对同一行的读和写操作,只有当写锁释放后,才会执行其它事务的读写操作。

在 RR 隔离模式下,InnoDB 会对 update、delete 和 insert 语句涉及数据集加排它锁。如果想在 select 操作的时候加上 S 锁 或者 X 锁,就需要手动加锁:

-- 加共享锁(S)
select * from table_name where ... lock in share mode

-- 加排它锁(X)
select * from table_name where ... for update

2.2 锁实现

2.2.1 记录锁

记录锁(Record Locks) 其实就是对表中的记录加锁,叫做记录锁,简称行锁。

SELECT * FROM table_name WHERE `id` = xxx FOR UPDATE;

但需要注意的是:MySQL 与 Oracle 通过在数据块中对相应数据行加锁来实现行锁不同的是,InnoDB 行锁是通过给索引上的索引项加锁来实现的。这就意味着只有通过索引条件检索数据时 InnoDB 才使用行级锁,否则会升级使用表锁

2.2.2 间隙锁

间隙锁(Gap Locks) 是 InnoDB 在 RR 隔离级别下为了解决幻读问题时引入的锁机制。间隙锁也是 InnoDB 行锁中的一种。

SELECT * FROM table_name WHERE `id` > 100 FOR UPDATE;

ref 间隙锁范围

2.2.3 临键锁

临键锁(Next-Key Locks) 是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。但临键锁只与非唯一索引列有关,在 唯一索引列(包括主键)上不存在临键锁。

总结一下:

1)行锁是基于索引的,一旦操作没有走上索引,那么该锁就会退化到表锁。

2)记录锁存在于包括主键索引在内的唯一索引中,锁定单条索引记录。

3)间隙锁临键锁存在于非唯一索引中。

2.3 意向锁

意向锁又分为意向共享锁(IS)和 意向排他锁(IX):

  • 意向共享锁(IS):事务有意向对表中的某些行加共享锁(S)。

  • 意向排它锁(IX):事务有意向对表中的某些行加排它锁(X)。

我们需要注意的是意向共享锁(IS)和 意向排他锁(IX)都是表锁,且是一种不与行级锁冲突的表级锁,通过 InnoDB 自动加锁,无法人工干预。

2.3.1 意向锁作用

意向锁的作用就是为了让 InnoDB 中的行锁和表锁能更高效的共存

因为共享锁与排它锁互斥,所以一个事务想对一张表加共享锁时,需要满足一个条件,即当前没有其它事务持有表中任意一行记录的排他锁。

为了去检测表中的每一行是否存在排它锁的代价很明显非常高,这是一个效率很差的做法。因此引入意向锁的概念,只要看表上有没有意向共享锁(IS),有则说明表中有些行被共享行锁锁住了,因此无法获得排它锁。

2.3.2 意向锁兼容性

意向锁之间是互相兼容的,因为当不论加行级的 X 锁或 S 锁,都会自动获取表级的 IX 锁或者 IS 锁。

2.3.3 插入意向锁

插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁

该锁用以表示插入意向,当多个事务在同一区间(gap) 不同位置插入多条数据时,事务之间不会产生冲突,也不需要互相等待。

需要强调的是,虽然插入意向锁中含有意向锁三个字,但它并不属于意向锁而属于间隙锁,因为意向锁是表锁而插入意向锁是行锁。

3 MVVC

MVCC 全称是 Multiversion Concurrency Control ,即多版本并发控制技术。可以做到读写互相不阻塞,主要用于解决不可重复读和幻读问题时提高并发效率。

原理是通过数据行的多个版本管理来实现数据库的并发控制,简单来说就是保存数据的历史版本。可以通过比较版本号决定数据是否显示出来。读取数据的时候不需要加锁可以保证事务的隔离效果。

3.1 MVVC 的作用

1)提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读;

2)采用乐观锁的方式降低死锁概率;

3)同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题(需要加锁)。

3.2 当前读和快照读

在学习 MVVC 实现之前,我们必须先了解一下什么是 InnoDB 引擎下的当前读和快照读:

  • 快照读就是读取的是快照数据,不加锁的简单 Select 都属于快照读。
SELECT * FROM user WHERE ...
  • 当前读就是读的是最新数据,而不是历史的数据。加锁的 SELECT,或者对数据进行增删改都会进行当前读。
SELECT * FROM user LOCK IN SHARE MODE;
SELECT * FROM user FOR UPDATE;
INSERT INTO user values ...;
DELETE FROM user WHERE ...;
UPDATE user SET ...;

从上我们可以看出 MVCC 就是为了实现读写冲突不加锁的机制,而这里的读就是指的快照读。而当前读实际上是一种加锁的操作,是悲观锁的实现。

3.3 MVVC 实现原理

MVVC 实现原理主要是依赖数据记录中的 3 个隐式字段、undo 日志以及 Read View 来实现的。

3.3.1 隐式字段

MySQL 在每行记录中除了存储了我们定义的业务字段外,还隐式定义了 DB_TRX_IDDB_ROLL_PTRDB_ROW_ID 等字段:

  • DB_TRX_ID(最近修改事务ID) 6byte,记录创建这条记录/最后一次修改该记录的事务ID。

  • DB_ROLL_PTR(回滚指针) 7byte,指向这条记录的上一个版本(存储于rollback segment里)。

  • DB_ROW_ID(隐含的自增ID、隐式主键) 6byte,如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。

DB_TRX_ID 是每开启一个日志,都会从数据库中获得一个事务ID(也称为事务版本号),这个事务 ID 是自增的,通过 ID 大小,可以判断事务的时间顺序。

实际还有一个 deleted_bit 的删除标志隐藏字段,记录被更新或删除并不代表真的删除,而是该标志变了。

3.3.2 undo日志

我们知道,undo 日志主要分为两种:

  • insert undo log 事务在 insert 操作时产生的 undolog,只在事务回滚时需要,并且在事务提交后可以被立即丢弃。

  • update undo log 事务在 update 或 delete 操作时产生的 undolog。

显然对 MVCC 有实际作用的是 update undo log,undolog 实际就是存在rollback segment 中旧记录链,它的执行流程如下:

从上面我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的 undo log 成为一条记录版本线性链表,undolog 的链首就是最新的旧记录,链尾就是最早的旧记录。

从前面的分析可以看出,为了实现 MVCC 机制,更新或者删除操作都只是设置一下老记录的 deleted_bit,并真正删除记录。而为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。

为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个 Read View(这个 Read View 相当于系统中最老活跃事务的 Read View),如果某个记录的 deleted_bit 为 true,并且 DB_TRX_ID 相对于 purge 线程的 Read View 可见,那么这条记录一定是可以被安全清除的。

3.3.4 Read View

Read View 就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID。即 Read View 保存了当前事务开启时所有活跃的事务列表

  • Read View 的作用

所以 Read View 主要是用来做可见性判断的,即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undolog 里面的某个版本的数据。

  • Read View 的内容

我们可以把 Read View 简单的理解成有几个全局属性:

trx_ids 系统当前正在活跃的事务ID集合。
low_limit_id 活跃事务的最大的事务 ID。
up_limit_id 活跃的事务中最小的事务 ID。
creator_trx_id 创建这个 ReadView 的事务 ID。
  • Read View 可见性算法

Read View 遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(当前事务 ID)与系统当前其它活跃事务 ID 去对比(由 Read View 维护),如果 DB_TRX_ID 跟 Read View 的属性做了某些比较不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 UndoLog 中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID,直到找到满足特定条件的 DB_TRX_ID, 那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本。

3.4 整体流程

1)首先比较 DB_TRX_ID < up_limit_id,如果小于则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断;

2)接着判断 DB_TRX_ID >= low_limit_id,如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断;

3)判断 DB_TRX_ID 是否在活跃事务(trx_ids)之中,如果在则代表 Read View 生成时刻这个事务还在活跃,没有 Commit,修改的数据,当前事务是不见的;如果不在则说明这个事务在 Read View 生成之前就已经 Commit,修改的结果对当前事务可见。

3.5 常见问题

  • MVVC 在 RC 和 RR 下的区别

二者主要区别在于 Read View 生成时机的不同,从而造成 RC、RR 级别下快照读的结果的不同:

1)在 RC 级别下的,事务中每次快照读都会新生成一个快照和 Read View, 这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。

2)而在 RR 级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View,即快照读生成 Read View 时,Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。

总之在 RC 隔离级别下,是每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,则是同一个事务中的第一个快照读才会创建 Read View,并且之后的快照读获取的都是同一个 Read View。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐