【MySQL系统架构设计】
【MySQL索引设计与选择】
【MySQL事务底层原理】
在了解 MySQL 事务时,首先我们需要明确一个概念 MVCC 机制。MVCC(Multi-Version Concurrency Control)是一种数据库管理系统中常见的并发控制机制,用于管理多个事务同时访问数据库时的数据一致性和隔离性。MVCC的核心思想是为每个事务创建一个可见的、独立的数据版本,而不是锁定整个表或数据行,从而允许多个事务并发执行而不相互干扰。
MySQL 的高速读写能力,离不开对 MVCC 的具体实现,引入 MVCC 后,数据库操作只有写写之间是相互阻塞,读写、写读、读读都是可以并行的。
在了解MVCC多版本并发控制之前,我们必须先了解一下,什么是 MySQL InnoDB 下的 当前读 和 快照读。
指读取的记录是最新版本,读取时还需保证其他并发事务不能修改当前记录,需要对读取记录进行加锁。比如 select ... lock in share mode、select … for update、update、insert、delete 这些操作都是一种当前读。
指不对select操作进行加锁的读取。
快照读的出现,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,它在很多情况下,避免了加锁操作,降低了开销。在并发事务下,快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
在 InnoDB 中,快照读的前提是事务隔离级别不能为 串行化,在串行化级别下的快照读会退化为当前读;
MVCC 主要目的是为了解决 读写冲突,在 MySQL InnoDB 中,主要通过行记录中3个隐式字段,undo log,read view来实现,下面我们详细分析一下这三个具体作用。
在 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回滚段里),通过当前指针才能查找之前版本的数据。MySQL 在进行数据操作是,需要将记录先读取到 Buffer Pool 中,然后进行修改,最后再进行刷盘,为了保证数据的持久性,在进行修改后,需写入一个 Undo Log 日志,记录修改后的数据,以便后续崩溃恢复。
Undo Log 日志主要分为两种:
INSERT 时产生的 Undo Log。只需要在事务回滚时需要,事务提交后可立即删除。UPDATE、DELETE 时产生的 Undo Log。在事务回滚时需要,在快照读时也需要;不能立即删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清理。不同事务或者相同事务的对同一记录的修改,会导致该记录的 Undo Log 成为一条记录版本线性表,既链表,Undo Log 的链首就是最新的旧记录,链尾就是最早的旧记录。
为了实现 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 可见,那么这条记录一定是可以被安全清除的。
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 列表中。
结论:
TRX_ID),并创建一个 Read View。TRX_ID 与新版本相关联(Redo Log 版本链)。原始数据行将保持不变。事务具有以下四个特性:
MySQL InnoDB 是通过那些机制保证了事务的几个特性?
MySQL 事务的原子性通过 Undo Log 日志保证的,Undo Log 主要分为两种:
INSERT 时产生的 Undo Log。只需要在事务回滚时需要,事务提交后可立即删除。UPDATE、DELETE 时产生的 Undo Log。在事务回滚时需要,在快照读时也需要;不能立即删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清理。之所以要分开,本质上是因为 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,如下:

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类型还细分为三种类型,如下:
undo_type=TRX_UNDO_DEL_MARK_RECundo_type=TRX_UNDO_DEL_MARK_RECundo_type=TRX_UNDO_DEL_MARK_REC当进行 delete 操作是,由于 MVCC 需要保留 Record 的多个历史版本,如果 Record 历史版本还在被使用时,那么这个 Record 不能真正被删除,所以在需要做删除操作时,只需要修改对应 Record 的 delete_mask 标记,并记录一条类型为 TRX_UNDO_DEL_MARK_REC 类型的 Undo 日志,实现原理如下。


如果涉及到主键的更新,在 Update 语句所在的事务提交之前,不会直接把旧的记录删除,因为有可能其他的事务要访问到这个记录(MVCC相关内容),所以需对旧记录的 delete_mask 更新为 1,然后根据更新后的各项值,创建一条新记录重新定位并插入到聚集索引中。
所以如果涉及主键更新,会生成两个 Undo Log 日志记录,如下:

不更新主键的情况下,又可以分为更新的列占用的存储空间是否发生变化两种情况。
注意,这里的删除,就不是像前面那样直接修改delete_mask, 而是由用户线程同步执行真正的删除,因为这里有锁的保护,并 不存在并发问题。
MySQL InnoDB 通过 Redo Log 和 Doublewrite Buffer Files 来保证数据的持久性。

事务执行前后,数据库的状态必须保持一致。
事务并发操作,要解决的数据一致性问题:
MySQL事务隔离级别
select(select ... in share mode / select ... for update)以及更新操作 update, delete等语句使用当前读(current read),底层使用记录锁、或者 间隙锁、临键锁。select ... lock in share mode, 会和update、delete互斥。隔离级别从小到大为:
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]
需要注意的是,较高级别的隔离通常会导致更多的锁竞争和性能下降。因此,在选择隔离级别时,需要根据应用程序的需求和性能要求进行权衡。
每次查询前都会生成一个独立的 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。流程如下:

判断过程如下:
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 列表中。
除了MVCC,MySQL还提供了LBCC(Lock-Based Concurrency Control,基于锁的并发控制)的机制来实现事务的隔离特性。
基于锁的方式起始比较简单,就是一个事务在进行数据查询时,不允许其他事务修改,当前,这种方案在一定程度上影响了操作数据的率,所以对于怎么加锁、锁的范围和粒度 MySQL 进行了细的划分。
在 MySQL 中,MyISAM 只支持表锁,InnoDB 支持行锁和表锁。
在 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,正好在锁定的区间。
为什么要锁住下一个左开右闭的区间?就是为了解决幻读的问题。
在上面分析汇总,间隙锁和临键锁差不多,那什么情况下加临键锁,什么情况下加间隙锁呢?
当我们使用了范围查询,不仅仅命中了 Record 记录,还包含了 Gap间隙,在这种情况下我们使用的就是临键锁,它是MySQL里面默认的行锁算法,相当于记录锁加上间隙锁。