【MySQL系统架构设计】
【MySQL索引设计与选择】
【MySQL事务底层原理】
一、MVCC机制
在了解 MySQL 事务时,首先我们需要明确一个概念 MVCC 机制。MVCC(Multi-Version Concurrency Control)是一种数据库管理系统中常见的并发控制机制,用于管理多个事务同时访问数据库时的数据一致性和隔离性。MVCC的核心思想是为每个事务创建一个可见的、独立的数据版本,而不是锁定整个表或数据行,从而允许多个事务并发执行而不相互干扰。
MySQL 的高速读写能力,离不开对 MVCC 的具体实现,引入 MVCC 后,数据库操作只有写写之间是相互阻塞,读写、写读、读读都是可以并行的。
在了解MVCC多版本并发控制之前,我们必须先了解一下,什么是 MySQL InnoDB 下的 当前读 和 快照读。
1.1 当前读
指读取的记录是最新版本,读取时还需保证其他并发事务不能修改当前记录,需要对读取记录进行加锁。比如 select ... lock in share mode
、select … for update、update、insert、delete 这些操作都是一种当前读。
1.2 快照读
指不对select操作进行加锁的读取。
快照读的出现,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,它在很多情况下,避免了加锁操作,降低了开销。在并发事务下,快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
在 InnoDB 中,快照读的前提是事务隔离级别不能为 串行化,在串行化级别下的快照读会退化为当前读;
二、MySQL MVCC实现原理
MVCC 主要目的是为了解决 读写冲突,在 MySQL InnoDB 中,主要通过行记录中3个隐式字段,undo log,read view来实现,下面我们详细分析一下这三个具体作用。
2.1 行记录3个隐式字段
在 MySQL InnoDB 数据行中,除了记录我们自定义的字段外,数据库还隐式定义了 DB_TRX_ID
、DB_ROLL_PTR
和 DB_ROW_ID
等隐式字段。
DB_ROW_ID
:隐含的自增ID(隐藏主键),占6byte。如果数据表没有主键,InnoDB会自动以DB_ROW_ID
生成一个聚簇索引。DB_TRX_ID
:最近修改(修改/插入)事务ID,占6byte。用于记录当前记录最后一次修改的事务ID,每次事务修改自动+1。DB_ROLL_PTR
:回滚指针,占7byte。指向这条记录的上一个版本 (存储于rollback segment回滚段里),通过当前指针才能查找之前版本的数据。
2.2 Undo Log
MySQL 在进行数据操作是,需要将记录先读取到 Buffer Pool 中,然后进行修改,最后再进行刷盘,为了保证数据的持久性,在进行修改后,需写入一个 Undo Log 日志,记录修改后的数据,以便后续崩溃恢复。
Undo Log 日志主要分为两种:
- Insert Undo Log:记录 MySQL 进行
INSERT
时产生的 Undo Log。只需要在事务回滚时需要,事务提交后可立即删除。 - Update Undo Log:记录 MySQL 进行
UPDATE
、DELETE
时产生的 Undo Log。在事务回滚时需要,在快照读时也需要;不能立即删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清理。
不同事务或者相同事务的对同一记录的修改,会导致该记录的 Undo Log 成为一条记录版本线性表,既链表,Undo Log 的链首就是最新的旧记录,链尾就是最早的旧记录。
2.2.1 purge线程
为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置一下旧记录的 delete_mask
,并不真正将旧记录删除。并且为了节省磁盘空间,InnoDB有专门的 purge 线程来清理 delete_mask
为 true
的记录。为了不影响MVCC的正常工作,purge 线程自己也维护了一个 read view(这个 read view 相当于系统中最老活跃事务的read view),如果某个记录的 delete_mask
为 1,并且 DB_TRX_ID
相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的。
2.3 Read View
Read View 是用于记录每个事务开始时间的数据结构,以便 MySQL InnoDB 在查询时能够根据事务启动时间戳和各个数据行对应的版本链信息来确定该事务能够访问到哪些数据。
当一个事务开始执行时,MySQL InnoDB 会记录下该事务开始的时间戳。之后,在这个事务执行的过程中,如果需要读取某个数据行,MySQL引擎会根据这个时间戳和该数据行对应的版本链信息(Undo Log 链),找到最近的、在该事务启动时间之前已经提交的数据版本。这样,就可以保证在该事务的执行过程中,读取的数据是和该事务启动时一致的。
每次开启一个事务时,都会创建当前事务对应的 Read View。因此,不同的事务对同一行数据的读取结果可能是不同的,这取决于它们启动的时间。
Read View 包含多个字段,如下:
struct read_view_t{
ulint type;
undo_no_t undo_no;
trx_id_t low_limit_no;
trx_id_t low_limit_id;
trx_id_t up_limit_id;
ulint n_trx_ids;
trx_id_t* trx_ids;
trx_id_t creator_trx_id;
UT_LIST_NODE_T(read_view_t) view_list;
}
其中比如重要的字段如下:
TRX_IDS
:InnoDB 为每个事务构造了一个数组trx_ids
,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。(“活跃”指的就是,启动了但还没提交。)LOW_LIMIT_ID
:TRX_IDS
数组中的最小事务ID。UP_LIMIT_ID
:Read View 生成时刻系统尚未分配的下一个事务ID的值,也就是目前已出现过的事务ID的最大值+1。CREATOR_TRX_ID
:创建当前 Read View 的事务ID。
数组里面事务 ID 的最小值记为低水位(LOW_LIMIT_ID
),当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位(UP_LIMIT_ID
)。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
低水位和高水位将事务分成了三段:
Read View 通过判断这四个字段的方式来确定哪些数据对于当前事务来说是可见的,哪些是不可见的。判断过程如下:
- 如果
TRX_ID
(当前事务id) 等于CREATOR_TRX_ID
,则说明当前事务在访问它自己修改过的记录,所以这个版本可以被当前事务访问。 - 如果
TRX_ID
小于LOW_LIMIT_ID
,则说明在 Undo Log 版本链中这个事务在当前事务生产 Read View 前已经提交,所以这个版本可以被当前事务访问。 - 如果
TRX_ID
大于或等于UP_LIMIT_ID
,说明在 Undo 版本链中的这个事务在当前事务生成 ReadView 后才开启,所以这个版本不可以被当前事务访问。 - 如果
TRX_ID
在LOW_LIMIT_ID
和UP_LIMIT_ID
之间,此时再判断一下TRX_ID
是不是在TRX_IDS
列表中。- 如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
结论:
- 修改当前行的事务提交了,数据才能被其他事务查看。
- 每个事务只能看见其他事务已经提交了的数据修改。
2.4 MySQL MVCC
- 当一个事务开始时,它会获取一个唯一的事务ID(
TRX_ID
),并创建一个 Read View。 - 当事务执行插入、更新或删除操作时,数据库不会直接修改原始数据。相反,它会为每个修改操作创建一个新的数据版本,并将事务
TRX_ID
与新版本相关联(Redo Log 版本链)。原始数据行将保持不变。 - 对于读取操作,事务将会看到在其启动时间点之前已经提交的数据版本。这意味着事务不会看到其他事务尚未提交的数据更改。
- 当事务提交时,它的修改将被标记为已提交,并且其他事务将能够看到这些已提交的修改。
- 如果多个事务同时访问相同的数据行,每个事务都会看到不同的数据版本(通过 Read View 进行查看),这使得它们可以并发地读取和修改数据而不会相互干扰。
- 数据库通过 purge 线程定期清理已提交的旧数据版本,以避免存储空间被无用的数据占用。
三、事务ACID特性
事务具有以下四个特性:
- Atomicity(原子性):事务是一个不可分割的整体,要么全部执行,要么全部不执行。
- Consistency(一致性):一致性,值事务前后数据的完整性必须保持一致。
- Isolation(隔离性):事务之间是相互隔离。
- Durability(持久性):一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。
MySQL InnoDB 是通过那些机制保证了事务的几个特性?
3.1 Atomicity
MySQL 事务的原子性通过 Undo Log 日志保证的,Undo Log 主要分为两种:
- Insert Undo Log:记录 MySQL 进行
INSERT
时产生的 Undo Log。只需要在事务回滚时需要,事务提交后可立即删除。 - Update Undo Log:记录 MySQL 进行
UPDATE
、DELETE
时产生的 Undo Log。在事务回滚时需要,在快照读时也需要;不能立即删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清理。
3.1.1 Insert Undo Log
之所以要分开,本质上是因为 Insert 操作只是为事务回滚做准备,不需要在 MVCC 中承担任何作用,因此只需要记录对应记录的 key 即可,结构如下:
Next Record Offset
: 记录下一个 Undo Log 日志地址;undo_type
:日志类型,undo_type=TRX_UNDO_INSERT_REC
,表示 insert 操作的 undo 日志类型;undo_no
:为 Undo Log 中递增编号,在一个事务汇总,从0开始递增;table_id
:记录当前操作表;Key Fields
:记录当前表的主键,用于在回滚时定位数据,多个的原因是可能存在联合主键,其中 Length 表示当前主键列占用的存储空间大小,Content 表示主键列的值;prev record offset
:记录上一个 Undo Log 日志地址;
数据表:
CREATE TABLE `user` (
`id` BIGINT(20) NOT NULL,
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
PRIMARY KEY (`id`) USING BTREE
)
;
对表 user
进行数据插入时,会向 Undo Log 中插入一条 TRX_UNDO_INSERT_REC
类型的日志,同时插入记录中的隐式字段 roll_pointer
会指向当前 Undo Log,如下:
3.1.2 Update Undo Log
Update Undo Log 中记录 delete 和 update 操作产生的 Undo Log。
Update Undo Log 涉及到 MVCC 的功能,所以 Update 操作需要保留 Record 的多个历史版本,当某个 Record 的历史版本还在被使用的时候,这个 Record 不能从 Undo Log 中真正删除,因此需要删除的时候,只是修改对应 Record 的 delete_mask
值。如果当前 Record 又被重新插入到 Undo Log 中,那么这个时候只需要修改 delete_mask
即可。
Update Undo Log 日志的清理会有专门的 purge 线程来清理 delete_mask
为 1
的记录,为了不影响MVCC的正常工作,purge 线程自己也维护了一个 read view(这个 read view 相当于系统中最老活跃事务的read view),purge线程清理条件如下:
- 记录的
delete_mask
为 1; - 并且
DB_TRX_ID
相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的;
Update Undo Log 结构如下:
与 Insert Undo Log 存在相同字段信息,除此之外,Update Undo Record增加了:
transaction_id
:记录了产生这个历史版本事务Id,用作后续 MVCC 中的版本可见性判断;roll_pointer
:指向的是该记录的上一个版本的位置,沿着roll_pointer
可以找到一个 Record 的所有历史版本;Update Fields
:记录当前这个 Record 版本修改的字段信息,包括所有被修改字段的编号,长度和历史值。
当一个事务对记录进行更新时,会把当前记录当做历史快照保存下来,形成一个历史版本链,当需要事务回滚时,可以依赖这个历史版本链将记录回滚到事务开始之前的状态,从而保证了事务的原子性。
在 Update Undo Log 中,Undo Log类型还细分为三种类型,如下:
- Delete Undo Log:
undo_type=TRX_UNDO_DEL_MARK_REC
- Update Undo Log(更新主键):
undo_type=TRX_UNDO_DEL_MARK_REC
- Update Undo Log(不更新主键):
undo_type=TRX_UNDO_DEL_MARK_REC
1. Delete Undo Log
当进行 delete 操作是,由于 MVCC 需要保留 Record 的多个历史版本,如果 Record 历史版本还在被使用时,那么这个 Record 不能真正被删除,所以在需要做删除操作时,只需要修改对应 Record 的 delete_mask
标记,并记录一条类型为 TRX_UNDO_DEL_MARK_REC
类型的 Undo 日志,实现原理如下。
2. Update Undo Log(更新主键)
如果涉及到主键的更新,在 Update 语句所在的事务提交之前,不会直接把旧的记录删除,因为有可能其他的事务要访问到这个记录(MVCC相关内容),所以需对旧记录的 delete_mask
更新为 1,然后根据更新后的各项值,创建一条新记录重新定位并插入到聚集索引中。
所以如果涉及主键更新,会生成两个 Undo Log 日志记录,如下:
3. Update Undo Log(不更新主键)
不更新主键的情况下,又可以分为更新的列占用的存储空间是否发生变化两种情况。
- 不发生变化, 表示更新的每个列,更新前后占用的存储空间不变, 那么直接可以在原记录进行修改。在不更新主键的这种情况且更新后的字段占用空间不变的情况下,向 Undo Log中添加的日志类型是TRX_UNDO_UPD_EXIST_REC,相比于其他类型,它会多出一些属性。
- n_updated, 有多少个列被更新
- 被更新的列更新前的信息
- 如果更新的列包含索引列,则需要添加索引列的信息
- 发生变化, 如果被更新的字段,任意一列更新前后占用的存储空间 发生了变化,那么就不能直接在原来的记录上直接修改,而是需要先在聚集索引中删除这条记录,然后再根据更新后的值创建一条新记录。
注意,这里的删除,就不是像前面那样直接修改delete_mask, 而是由用户线程同步执行真正的删除,因为这里有锁的保护,并 不存在并发问题。
3.2 Durability
MySQL InnoDB 通过 Redo Log 和 Doublewrite Buffer Files 来保证数据的持久性。
- Redo Log:Redo Log记录了每个事务所做的更改,包括 INSERT、UPDATE 和 DELETE 操作。Redo Log 的作用是在数据库崩溃或断电等情况下,能够通过重新执行重做日志中的操作来还原数据。这确保了已提交的事务在崩溃后不会丢失,并且数据保持一致。
- Doublewrite Buffer Files:Doublewrite Buffer 是 InnoDB 存储引擎的一个缓冲区,用于写入数据页的备份副本。在将数据写入磁盘数据文件之前,InnoDB 首先将数据写入 Doublewrite Buffer,然后再写入磁盘数据文件。这样做的好处是,如果在写入磁盘数据文件时发生崩溃或错误,不会损坏数据页,因为有 Doublewrite Buffer 中的备份。这提供了额外的数据保护和一致性。
3.3 Consistency
事务执行前后,数据库的状态必须保持一致。
3.4 Isolation
3.4.1 MySQL事务隔离级别
事务并发操作,要解决的数据一致性问题:
- 脏读(Dirty Read):指一个事务在读取了另一个事务未提交的数据。
- 不可重复读(Non-repeatable Read):指同一个事务内,同一数据行的多次读取返回了不同的结果。
- 幻读(Phantom Read):指在一个事务内,多次查询返回了不同数量的数据行(在事务执行期间,其他事务插入或删除了满足查询条件的数据行)。
MySQL事务隔离级别
- Read UnCommitted(RU,读未提交):事务可以读取其他事务未提交的数据修改。这是最低级别的隔离,它允许脏读,不可重复读和幻读。这个级别的性能最高,但数据一致性最低。
- 所有的操作都不会加锁。
- Read Committed(RC,读已提交):事务只能读取其他事务已提交的数据修改。这可以避免脏读,但仍然允许不可重复读和幻影读。
- 普通的 select 都采用快照读,使用 MVCC 实现。
- 除了两种特殊情况——外键约束检查(foreign-key constraint checking)以及重复键检查(duplicatekey checking)时会使用Gap Locks-间隙锁(后面会详细分析)封锁区间,其他加锁的 select 都使用记录锁。
- Repeatable Read(RR,可重复读):事务在开始时创建与之对应的一致性的快照(Read View),之后的查询都将基于 Read View 进行,因此不会出现不可重复读。但仍然允许幻影读。这是MySQL默认的事务隔离级别。
- 普通的 select 都采用快照读,使用 MVCC 实现。
- 加锁的
select(select ... in share mode / select ... for update)
以及更新操作update
,delete
等语句使用当前读(current read),底层使用记录锁、或者 间隙锁、临键锁。
- Serializable(串行化):要求事务完全串行执行,不允许并发操作。这可以避免脏读,不可重复读和幻读,但性能最低。
- 所有的 select 语句都会被隐式的转化为
select ... lock in share mode
, 会和update
、delete
互斥。
- 所有的 select 语句都会被隐式的转化为
隔离级别从小到大为:
Read UnCommitted -> Read Committed -> Repeatable Read -> Serializable
性能与安全性之相反。
你可以使用以下 命令来查看和设置MySQL事务的隔离级别:
# 查看默认的全局隔离级别和当前 session 的隔离级别
# 查看 8.0前
SELECT @@GLOBAL.tx_isolation, @@tx_isolation;
# 查看 8.0后
SELECT @@GLOBAL.transaction_isolation, @@transaction_isolation;
# 设置
SET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SERIALIZABLE]
需要注意的是,较高级别的隔离通常会导致更多的锁竞争和性能下降。因此,在选择隔离级别时,需要根据应用程序的需求和性能要求进行权衡。
3.4.2 MVCC如何解决RC
每次查询前都会生成一个独立的 ReadView。流程如下:
判断过程如下:
- 如果
TRX_ID
(当前事务id) 等于CREATOR_TRX_ID
,则说明当前事务在访问它自己修改过的记录,所以这个版本可以被当前事务访问。 - 如果
TRX_ID
小于LOW_LIMIT_ID
,则说明在 Undo Log 版本链中这个事务在当前事务生产 Read View 前已经提交,所以这个版本可以被当前事务访问。 - 如果
TRX_ID
大于或等于UP_LIMIT_ID
,说明在 Undo 版本链中的这个事务在当前事务生成 ReadView 后才开启,所以这个版本不可以被当前事务访问。 - 如果
TRX_ID
在LOW_LIMIT_ID
和UP_LIMIT_ID
之间,此时再判断一下TRX_ID
是不是在TRX_IDS
列表中。- 如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
3.4.3 MVCC如何解决RR
只是在第一次查询前生成一个 ReadView,之后的查询都重复使用这个 ReadView。流程如下:
判断过程如下:
- 如果
TRX_ID
(当前事务id) 等于CREATOR_TRX_ID
,则说明当前事务在访问它自己修改过的记录,所以这个版本可以被当前事务访问。 - 如果
TRX_ID
小于LOW_LIMIT_ID
,则说明在 Undo Log 版本链中这个事务在当前事务生产 Read View 前已经提交,所以这个版本可以被当前事务访问。 - 如果
TRX_ID
大于或等于UP_LIMIT_ID
,说明在 Undo 版本链中的这个事务在当前事务生成 ReadView 后才开启,所以这个版本不可以被当前事务访问。 - 如果
TRX_ID
在LOW_LIMIT_ID
和UP_LIMIT_ID
之间,此时再判断一下TRX_ID
是不是在TRX_IDS
列表中。- 如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
3.4.4 LBCC
除了MVCC,MySQL还提供了LBCC(Lock-Based Concurrency Control,基于锁的并发控制)的机制来实现事务的隔离特性。
基于锁的方式起始比较简单,就是一个事务在进行数据查询时,不允许其他事务修改,当前,这种方案在一定程度上影响了操作数据的率,所以对于怎么加锁、锁的范围和粒度 MySQL 进行了细的划分。
1. 锁的粒度
- 行锁:只锁定当前操作数据行。
- 表锁:锁定当前操作整个数据表。
在 MySQL 中,MyISAM 只支持表锁,InnoDB 支持行锁和表锁。
2. 锁的类型
在 MySQL 官网文档【InnoDB Locking】中有详细的说明,将锁分为 八类,如下:
-
Shared and Exclusive Locks(共享锁 / 排它锁):行级锁
-
Intention Locks(意向锁):表锁
-
Record Locks(记录锁):记录锁是索引记录上的锁,它封锁该行的索引记录,并不是真正的数据记录。
如下:select * from user where id = 1 FOR UPDATE;
,只会锁定 id=1 的记录。
-
Gap Locks(间隙锁):顾名思义锁间隙,它锁定一段范围内的索引记录。 比如下面这个查询
select * from user where id between 1 and 4 FOR UPDATE
,所有在 (1,4) 区间内的记录行都会被锁住,它是一个左右开区间的范围,只会锁住 id 为2,3的索引,但是1、4不会被锁定,如下图所示:
-
Next-Key Locks(临键锁):Next-Key 可以理解为一种特殊的间隙锁,每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。
假设存在如下一个数据表:
id(主键) | age(普通索引) | name |
---|---|---|
1 | 18 | 张三 |
3 | 21 | 李四 |
5 | 22 | 王二 |
7 | 35 | 小一 |
那么这个表中 age 列的潜在临键锁有(唯一索引列,包括主键列,上不存在临键锁):(-∞,18] 、 (18,21]、(21,22]、(22,35]、(35,+∞]。假设事务执行下面命令:
UPDATE user SET name = '王五' WHERE age > 18;
SELECT * FROM user WHERE age > 18 FOR UPDATE;
不管执行上面的哪个SQL,当前事务都会锁定(18,21]这个区间。之后如果有另外一个事务来执行insert into user values(10, 19, '小三');
命令会被阻塞,因为插入的数据 age=19,正好在锁定的区间。
为什么要锁住下一个左开右闭的区间?就是为了解决幻读的问题。
- Insert Intention Locks(插入意向锁):一种特殊的间隙锁,用于协调多个事务同时在同一表上进行插入操作,避免在相同的范围内插入数据,从而减少了插入冲突的可能性。和间隙锁的关系:“Insert Intention Locks” 是间隙锁(Gap Locks)的一种形式。间隙锁用于锁定索引范围之间的间隙,以防止其他事务在这些间隙内插入新数据。 “Insert Intention Locks” 表明事务打算在特定的间隙内执行插入操作,因此它们与间隙锁有关。
- AUTO-INC Locks(自增锁):是一种特殊的表锁,用来防止自增字段重复,数据插入以后就会释放,不需要等到事务提交才释放。
- Predicate Locks for Spatial Indexes(空间索引谓词锁)
在上面分析汇总,间隙锁和临键锁差不多,那什么情况下加临键锁,什么情况下加间隙锁呢?
当我们使用了范围查询,不仅仅命中了 Record 记录,还包含了 Gap间隙,在这种情况下我们使用的就是临键锁,它是MySQL里面默认的行锁算法,相当于记录锁加上间隙锁。