MySQL事务实现原理
概览
本文主要记录 MySQL 中 ACID 特性的具体实现原理
ACID 4 个特性中:
- 一致性(consistency)是目的;
- 原子性(atomicity)、隔离性(isolation)、持久性(durability)是手段。
简单来说,ACID的实现原理如下:
- 原子性:通过
undo log来实现事务中断情况下的回滚,从而实现原子性 - 持久性:通过
redo log这种预写式日志(Write-Ahead-Log)来保证持久性 - 隔离性:通过 锁机制(表锁,意向锁,行锁)+MVCC (隐藏列,undo log版本链,ReadView)机制实现4种事务隔离级别
- 一致性:数据库层面通过AID和一些约束来保障一致性,但这并不够,还需要应用层的保证
原子性(Atomicity)
事务通常由多个语句组成,原子性保证每个事务都被视为一个单独的单元,要么完全成功,要么完全失败。即一个事务(transaction)中的所有操作,要么全部执行成功,要么全部不执行。
简单来说:原子性的结果就是没有中间状态,如果有中间状态则一致性就不会得到满足。
有两种情况会导致出现中间态:
- 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
- 情况二:程序员可以在事务执行过程中手动输入
ROLLBACK语句结束当前的事务的执行。
但没有中间态是不太可能的,因此MySQL 通过 undo log 这种方式,保证了执行失败或者主动要求下可以进行回滚,同时通过 隔离性(Isolation) 保证了回滚前的中间状态对其他事务不可见。 这样就可以造成一个假象,即这个事务,看起来什么都没做,符合原子性的要求。
除非是
read uncommited,那么无法隔离
综合来讲,MySQL原子性的实现原理:
- 通过
undolog在失败时回滚保证在结果上是原子性的, 即没有中间状态。 - 通过
隔离性保证了在其他并发事务看来是原子性的,即中间状态对外不可见。
undo log
undo log 它记录了事务中所有逻辑执行语句对应的反操作。当事务发生回滚的时候,InnoDB 会根据 undo log 的内容做相反的工作。
undo log 分为两类:
Insert undo log:当发生插入的时候,会生成这样的一个undo log,记录了插入的主键ID,在回滚的时候,只需要根据主键ID删除即可Update undo log:当发生update等修改操作时,会将被修改的字段长度,字段原来的值进行记录,在回滚的时候即可将对应的值重新设置回旧值。当发生delete操作时,将被删除行进行记录,回滚时重新写入即可。(所以不会立马真的删除整个行,而是先标记,然后保证没有事务可以访问后再真正删除)
[!note]
为什么分两种 undo log?
Insert undo log是针对插入数据生成的,插入数据仅对当前事务可见,因此只用于当前事务回滚,无需参与 MVCC 机制,因此在事务提交后就可以删除,Update undo log是对数据修改产生的,需要留下来形成版本链,提供历史版本数据,支持 MVCC 的快照读功能。有了undo log才知道之前版本的数据是怎么样的。
隔离性(Isolation)
隔离性,指一个事务内部的操作及使用的数据对正在进行的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。 正是它保证了原子操作的过程中,中间状态对其它事务不可见。
MySQL主要是通过结合 锁+MVCC机制 来实现的隔离性,这里先大概总结一下
在 MySQL 中实现了SQL标准中定义的四个隔离级别
| 隔离级别 | 可能出现的问题 | MySQL的实现方式 |
|---|---|---|
| 读未提交 | 脏读、不可重复读、幻读 | 读写数据不加任何限制,直接读取最新的真实数据 |
| 读已提交 | 不可重复读、幻读 | 基于MVCC实现,每次读取都生成一个快照(ReadView),然后读快照内容 |
| 可重复读 | 幻读 | 基于MVCC和next-key锁实现,仅在第一次读取生成快照,后面都读取同一快照数据,可以杜绝大部分幻读 |
| 串行化 | 无 | 基于读写锁实现 |
并发执行的问题
这里我们可以先探讨以下在并发情况下可能发生哪些冲突:
- 读-读操作:无冲突
- 读-写操作:有冲突
- 写-写操作:有冲突
因此,隔离性主要是为了解决 读写冲突、写写冲突。
如果只采用锁机制来实现隔离级别
那么毫无疑问,读写操作和写写操作都必须上锁来保证隔离性,读取的时候上读锁,写的时候上写锁。如果采用 MVCC 机制和锁机制联合
那么读写操作无需上锁,只有写写操作上锁,因为 MVCC 机制保证了读取的是数据的快照,而写数据是写最新的数据,所以没有冲突,提高了并发的效率。这也是为什么要使用 MVCC,因为可以提高并发效率,解决来读写冲突需要上锁的问题
MVCC原理
这里的 MVCC 原理主要是 InnoDB 中实现 MVCC 的原理。
版本链
对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
每次对记录进行更新,都会将旧值放入一个新的 undo log 中,这就算是该记录的一个旧版本,undo log 中也有一个 roll_pointer,用来指向更早的 undo log,所以可以形成一个版本链,版本链的头节点就是当前记录最新值:
ReadView
ReadView 意思就是一个读视图,可以理解成一个快照。其作用是告诉事务:版本链中哪些版本是当前事务可见的,从而通过这个快照来实现隔离
ReadView 中包含以下4个内容:
m_ids:系统中当前活跃的事务 id 列表min_trx_id:在生成ReadView时,当前系统活跃事务的最小 idmax_trx_id:在生成ReadView时,当前系统活跃事务的最大 id+1creator_trx_id:表示生成当前ReadView的事务的id
那么如何根据 ReadView 中的这些内容判断一个记录的版本能不能被当前事务读取呢?前面版本链中提到每一条记录中都有两个隐藏列,其中一个是 trx_id,也就是最新修改当前记录的事务id,我们是根据这个事务id结合ReadView来判断的:
- 如果当前记录的
trx_id=creator_id,那么代表当前记录是当前事务修改或者创建的,可以读取 - 如果当前记录的
trx_id<min_trx_id,那么代表该记录是过去就已经提交了的事务修改或创建的,可以读取 - 如果当前记录的
min_trx_id<trx_id<max_trx_id,那么需要分类讨论- 如果
trx_id在m_ids中,说明修改或者创建这条记录的事务还没有提交,不能读取 - 如果
trx_id不在m_ids中,说明修改或者创建这条记录的事务已经提交来,可以读取
- 如果
- 如果当前记录的
trx_id>max_trx_id,说明修改或者创建这条记录的事务来自未来,不可读取
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上面的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
接下来说说 ReadView 是如何用来实现隔离级别的,ReadView 主要参与 读已提交和可重复读 两个隔离级别的实现
读已提交
对于读已提交,每次读取都需要读取最新的提交数据,因此其实现方式是,==每次读取,都生成一个ReadView==,然后读取内容,这样读写之间也是不需要加锁的,所以可能产生不可重复读的问题。(即读到数据被删除或者修改)可重复读
对于可重复读,要求每次读到的数据都是一样的(重复的),因此其实现方式是,==第一次读取的时候生成一个ReadView,之后的每次读取都使用第一次生成的ReadView==,这样就可以实现可重复读了,并且这种方式还一定程度避免了幻读。(因为读的是快照,未来插入的数据是读不到的)
锁机制
共享锁和独占锁
前面我们提到,MySQL 基于锁和MVCC来实现不同的隔离级别,在加锁的时候,要让读-读操作不冲突,使读-写,写-写操作阻塞,因此引入了共享锁(读锁)和独占锁(写锁)。
共享锁,英文名:Shared Locks,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。独占锁,也常称排他锁,英文名:Exclusive Locks,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。
多粒度锁
在MySQL中的锁有如下类别:
| 锁类型 | 具体锁名 |
|---|---|
| 表锁 | 共享表锁 S |
| 独占表锁 X | |
| 意向读锁 IS | |
| 意向写锁 IX | |
| 自增锁 AUTO-INC Lock | |
| 元数据锁 Metadata Lock | |
| 行锁 | 记录锁 Record Lock |
| 间隙锁 Gap Lock | |
| 临键锁 Next-key Lock | |
| 隐式锁 | |
| 插入意向锁 Insert Intention Lock |
表锁
表锁顾名思义就是锁住整个表,表锁也分为独占锁和共享锁。以下情况下MySQL会上表锁:
- 在不支持表锁的存储引擎,比如 MyISAM 中,所有操作都是表锁
- 在 InnoDB 中
- 显式使用
Lock Table语句 - 全表扫描
- 外键约束检查
- 显式使用
意向锁
意向锁存在的意义是为能否上表锁进行一个快速判断。当要给一个表上读锁的时候,先要判断每一个记录有没有上写锁,如果去遍历太耗费时间,因此,假设要对一个记录行上读锁,会先给表上读意向锁(IS),如果要对记录行上写锁,先给表上(IX),这样便于后面上表锁判断。
| 兼容性 | X |
IX |
S |
IS |
|---|---|---|---|---|
X |
不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX |
不兼容 | 兼容 | 不兼容 | 兼容 |
S |
不兼容 | 不兼容 | 兼容 | 兼容 |
IS |
不兼容 | 兼容 | 兼容 | 兼容 |
自增锁
在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值。
这种自增的原理是内存中为每个表维护了一个全局变量,因此当发生并发插入的时候,需要上锁。就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
元数据锁
元数据锁是一种特殊的表锁,用于保护表的元数据(如表结构)在并发操作下的完整性。当执行涉及表结构变更的语句(如ALTER TABLE、CREATE INDEX、DROP TABLE等)或查询表的元数据信息时,MySQL会自动对表加元数据锁。
记录锁
从这里开始就是行锁,行锁是最重要的锁。这里要强调的是,记录锁是针对索引上的锁,因为你要上锁肯定要找到这个记录再上锁吧,怎么找?靠索引,如果没有索引怎么办?那只能全表扫了,那也就升级成表锁了。
Record Lock 时最正儿八经的行锁,功能就是锁住对应的行。记录锁也是分为读锁和写锁的,上写锁的时候其余事务都无法获取任何锁来做操作,上读锁的时候其他事务也可以上读锁。
以下情况会上记录锁:
- 在执行
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE或DELETE等操作时自动设置,用于保护特定行的并发访问(即执行 锁定读(当前读) 操作时)
间隙锁
Gap Lock 是为了解决幻读出现的锁,目的是锁住记录之间的间隙,不包括记录本身,也就是一个开区间。
[!danger] 为什么要间隙锁?
当我们采用范围扫描来查询数据的时候,如果采用行锁,那么确实可以锁住记录不让其他事务修改,但是防不住其他事务在我们的记录间隙中进行插入,比如我们查询where id between 3 and 8,记录锁只能锁 3 和 8,但是如果其他事务插入一个 5,那么我们还是可以读到。(可能有人觉得 MVCC 不是快照读,只要我们刚开始的时候没有其他事务插入不就读不到了吗?没错,如果是 一致性读(快照读) 确实不用担心这个问题,但是如果我们是 锁定读(当前读) ,即通过加锁的方式,读取最新内容,普通的记录锁就无法保证不产生幻读,即有新的记录插入了)MySQL 的设计者们因此想出了间隙锁,把(3,8)这个区间锁住,想插入的时候就会被这个锁拦住。在MySQL的可重复读(Repeatable Read)隔离级别下,InnoDB默认启用间隙锁。
例如下图:给值为8的记录上锁的时候,会将 (3,8)这个间隙上锁,即给前面的间隙上锁。
还有一点要注意的是,间隙锁是不互斥的,因此两个事务都上间隙锁,然后插入,可能导致死锁
临键锁
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录,所以设就有了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁,本质是 记录锁 和 间隙锁 的结合,上锁的区间是 前开后闭
临键锁实际上是 InnoDB 在 RR 隔离级别下默认使用的行锁类型,用来完全避免幻读。当对一个索引范围进行查询或更新时,InnoDB不仅锁定查询条件直接命中的记录,还会锁定查询条件两侧的间隙,确保在事务执行期间,其他事务不能在这个范围内插入新的记录。
下图,是给 8 这个行上记录锁,给前面的间隙上间隙锁
什么时候上间隙锁?
- 默认行为(RR隔离级别):
- 在可重复读(RR)隔离级别下,InnoDB对索引记录的锁定是默认采用临键锁。这意味着,除了锁定特定的索引记录外,还会自动锁定该记录前面的间隙。
- 示例:
SELECT * FROM table WHERE id = 10 FOR UPDATE;不仅会对id为10的记录加记录锁,还会对该记录之前(小于10)的间隙加锁。
- 范围查询:
- 对于涉及范围的查询(如使用
BETWEEN、>,<,>=,<=等操作符),InnoDB不仅会对查询条件命中的每个索引记录加记录锁,还会对这些记录两侧的间隙分别加间隙锁。因此,范围查询实际上会施加一系列临键锁。 - 示例:
SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE;会对id为10到20之间的每个记录以及这些记录之间的间隙都加临键锁。
- 对于涉及范围的查询(如使用
插入意向锁
一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的 间隙锁,如果有的话,插入操作需要等待,直到拥有间隙锁的那个事务提交。但是设计InnoDB的大佬规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。设计InnoDB的大佬就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁。但事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。
隐式锁
当事务进行 Insert 插入记录后,如果其他事务执行了 锁定读(当前读) ,可能会读到当前插入的数据,这是不被允许的,因为会发生脏读(读到未提交的数据),所以在 Insert 插入记录时,会给 Insert 的记录上一个隐式锁,防止其他事务读取,因此其他读取的事务会阻塞住
一致性读(快照读)
所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读,比方说:
1 | |
一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。(所以不涉及任何上锁)
锁定读(当前读)
那么假设我们有一些场景,比如读取用户的余额,这种强一致性场景下必须要求读到最新的数据,怎么办?这时候 一致性读(快照读) 无法满足需要,因此就有了当前读,即通过上锁读取当前最新的数据
给记录上读锁的当前读
1
SELECT ... LOCK IN SHARE MODE;给记录上写锁的当前读
1
SELECT ... FOR UPDATE;
其余的,只要是涉及对数据进行修改的操作也均是 当前读
- Update
- Delete
持久性实现原理
持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
InnoDB 通过 redo log 重做日志保证了事务的持久性。
redo log 本质是一种 WAL (Write-Ahead-Log) 预写式日志,也即在修改实际数据前,先去写 redo log,写入成功后才去修改数据,因此只要 redo log 保存下来了,就算数据库崩溃,也能根据 redo log 恢复。
并且还需要明白的是 redo log 不同于 undo log ,redo log 是一种物理日志,记录的是 某一页,某一个偏移地址,修改成了什么,是存储引擎提供的特性。MyISAM 就不具备这种用于崩溃恢复的特性。
redo log 具体原理:
- 当数据修改时,会先在 redo log 记录这次操作,然后修改 Buffer Pool 中的数据,
- 当事务提交时,会调用 fsync 接口对 redo log 进行刷盘。
- redo log 采用的是 WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到 Buffer Pool 。
- 如果 MySQL 宕机,重启时可以读取 redo log 中的数据,对数据库进行恢复,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求。
Buffer Pool 中的脏页不会马上刷盘,而是有一些策略来刷盘,主要也是为了提高写入效率。
这里其实也涉及到一个点,就是为什么不每修改一个页就直接落盘?这不也能保证持久化吗?
- 首先,MySQL 中页大小是 16KB ,但是每次我们修改数据可能就几B,并且可能是改了很多不同的页,这样写入的效率很低,并且还是随机写几乎。但是如果我们存
redo log,redo log是纯追加式日志,写入是顺序写,效率提高了很多,而且redo log写入后不担心持久化问题,脏页由 Buffer Pool 策略控制写入即可。
QA
使用 临键锁 的目的是什么?
MySQL 怎么防止幻读?
Binlog 和 Redolog的区别?