2025年1月18日 22:30 周六第26章 写作本书时用到的一些重要的参考资料
感谢
#
我不生产知识,只是知识的搬运工。写作本小册的时间主要用在了两个方面:
搞清楚事情的本质是什么。
这个过程就是研究源码、书籍和资料。
如何把我已经知道的知识表达出来。
这个过程就是我不停的在地上走过来走过去,梳理知识结构,斟酌用词用句,不停的将已经写好的文章推倒重来,只是想给大家一个不错的用户体验。
这两个方面用的时间基本上是一半一半吧,在搞清楚事情的本质是什么阶段,除了直接阅读MySQL
的源码之外,查看参考资料也是一种比较偷懒的学习方式。本书只是MySQL
进阶的一个入门,想了解更多关于MySQL
的知识,大家可以从下面这些资料里找点灵感。
一些链接
#
MySQL官方文档:
https://dev.mysql.com/doc/refman/5.7/en/
MySQL
官方文档是写作本书时参考最多的一个资料。说实话,文档写的非常通俗易懂,唯一的缺点就是太长了,导致大家看的时候无从下手。
MySQL Internals Manual:
https://dev.mysql.com/doc/internals/en/
介绍MySQL如何实现各种功能的文档,写的比较好,但是太少了,有很多章节直接跳过了。
何登成的github:
https://github.com/hedengcheng/tech
登博的博客非常好,对事务、优化这讨论的细节也非常多,不过由于大多是PPT结构,字太少,对上下文不清楚的同学可能会一脸懵逼。
orczhou的博客:
http://www.orczhou.com/
Jeremy Cole的博客:
https://blog.jcole.us/innodb/
Jeremy Cole大神不仅写作了innodb_ruby
这个非常棒的解析InnoDB
存储结构的工具,还对这些存储结构写了一系列的博客,在我几乎要放弃深入研究表空间结构的时候,是他老人家的博客把我又从深渊里拉了回来。
那海蓝蓝(李海翔)的博客:
https://blog.csdn.net/fly2nn
taobao月报:
http://mysql.taobao.org/monthly/
因为MySQL的源码非常多,经常让大家无从下手,而taobao月报就是一个非常好的源码阅读指南。
吐槽一下,这个taobao月报也只能当作源码阅读指南看,如果真的不看源码光看月报,那只能当作天书看,十有八九被绕进去出不来了。
MySQL Server Blog:
http://mysqlserverteam.com/
MySQL team的博客,一手资料,在我不知道看什么的时候给了很多启示。
mysql_lover的博客:
https://blog.csdn.net/mysql_lover/
mariadb的关于查询优化的文档:
https://mariadb.com/kb/en/library/query-optimizations/
不得不说mariadb的文档相比MySQL的来说就非常有艺术性了(里边儿有很多漂亮的插图),我很怀疑MySQL文档是程序员直接写的,mariadb的文档是产品经理写的。当我们想研究某个功能的原理,在MySQL文档干巴巴的说明中找不到头脑时,可以参考一下mariadb娓娓道来的风格。
Reconstructing Data Manipulation Queries from Redo Logs:
https://www.sba-research.org/wp-content/uploads/publications/WSDF2012_InnoDB.pdf
非官方优化文档:
http://www.unofficialmysqlguide.com/optimizer-trace.html
这个文档非常好,非常非常好~
MySQL8.0的源码文档:
https://dev.mysql.com/doc/dev/mysql-server
一些书籍
#
《数据库查询优化器的艺术》李海翔著
...
2025年1月18日 22:30 周六第25章 工作面试老大难-锁
解决并发事务带来问题的两种基本方式
#
上一章介绍了事务并发执行时可能带来的各种问题,并发事务访问相同记录的情况大致可以划分为3种:
读-读
情况:即并发事务相继读取相同的记录。
读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。
写-写
情况:即并发事务相继对相同的记录做出改动。
我们前面说过,在这种情况下会发生脏写
的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁
来实现的。这个所谓的锁
其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构
和记录进行关联的,如图所示:
![](img/25-01.png)
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构
,当没有的时候就会在内存中生成一个锁结构
与之关联。比方说事务T1
要对这条记录做改动,就需要生成一个锁结构
与之关联:
![](img/25-02.png)
其实在锁结构
里有很多信息,不过为了简化理解,我们现在只把两个比较重要的属性拿了出来: - trx信息
:代表这个锁结构是哪个事务生成的。 - is_waiting
:代表当前事务是否在等待。
如图所示,当事务T1
改动了这条记录后,就生成了一个锁结构
与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting
属性就是false
,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务T1
提交之前,另一个事务T2
也想对该记录做改动,那么先去看看有没有锁结构
与这条记录关联,发现有一个锁结构
与之关联后,然后也生成了一个锁结构
与这条记录关联,不过锁结构
的is_waiting
属性值为true
,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁,画个图表示就是这样:
![](img/25-03.png)
在事务T1
提交之后,就会把该事务生成的锁结构
释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2
还在等待获取锁,所以把事务T2
对应的锁结构的is_waiting
属性设置为false
,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2
就算获取到锁了。效果图就是这样:
![](img/25-04.png)
我们总结一下后续内容中可能用到的几种说法,以免大家混淆:
+
不加锁
意思就是不需要在内存中生成对应的锁结构
,可以直接执行操作。
+
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的锁结构
,而且锁结构的is_waiting
属性为false
,也就是事务可以继续执行操作。
+
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的锁结构
,不过锁结构的is_waiting
属性为true
,也就是事务需要等待,不可以继续执行操作。
小贴士:这里只是对锁结构做了一个非常简单的描述,我们后边会详细介绍介绍锁结构的,稍安勿躁。
读-写
或写-读
情况:也就是一个事务进行读取操作,另一个进行改动操作。
我们前面说过,这种情况下可能发生脏读
、不可重复读
、幻读
的问题。
小贴士:幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的,这一点要注意一下。
SQL标准
规定不同隔离级别下可能发生的问题不一样: - 在READ UNCOMMITTED
隔离级别下,脏读
、不可重复读
、幻读
都可能发生。 - 在READ COMMITTED
隔离级别下,不可重复读
、幻读
可能发生,脏读
不可以发生。 - 在REPEATABLE READ
隔离级别下,幻读
可能发生,脏读
和不可重复读
不可以发生。 - 在SERIALIZABLE
隔离级别下,上述问题都不可以发生。
不过各个数据库厂商对SQL标准
的支持都可能不一样,与SQL标准
不同的一点就是,MySQL
在REPEATABLE READ
隔离级别实际上就已经解决了幻读
问题。
怎么解决脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案:
+
方案一:读操作利用多版本并发控制(MVCC
),写操作进行加锁
。
所谓的MVCC
我们在前一章有过详细的描述,就是通过生成一个ReadView
,然后通过ReadView
找到符合条件的记录版本(历史版本是由undo日志
构建的),其实就像是在生成ReadView
的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView
之前已提交事务所做的更改,在生成ReadView
之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC
时,读-写
操作并不冲突。
小贴士:我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。
...
2025年1月18日 22:30 周六第24章 一条记录的多幅面孔-事务的隔离级别与MVCC
事前准备
#
为了故事的顺利发展,我们需要创建一个表: CREATE TABLE hero ( number INT, name VARCHAR(100), country varchar(100), PRIMARY KEY (number) ) Engine=InnoDB CHARSET=utf8;
小贴士:注意我们把这个hero表的主键命名为number,而不是id,主要是想和后边要用到的事务id做区别,大家不用大惊小怪~
然后向这个表里插入一条数据: INSERT INTO hero VALUES(1, '刘备', '蜀');
现在表里的数据就是这样的: mysql> SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.00 sec)
事务隔离级别
#
我们知道MySQL
是一个客户端/服务器
架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session
)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。在事务简介的章节中我们说过事务有一个称之为隔离性
的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,我们既想保持事务的隔离性
,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,鱼和熊掌不可得兼,舍一部分隔离性
而取性能者也。
事务并发执行遇到的问题
#
怎么个舍弃法呢?我们先得看一下访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:
脏写(Dirty Write
)
...
2025年1月18日 22:30 周六第23章 后悔了怎么办-undo日志(下)
上一章我们主要介绍了为什么需要undo日志
,以及INSERT
、DELETE
、UPDATE
这些会对数据做改动的语句都会产生什么类型的undo日志
,还有不同类型的undo日志
的具体格式是什么。本章会继续介绍这些undo日志
会被具体写到什么地方,以及在写入过程中需要注意的一些问题。
通用链表结构
#
在写入undo日志
的过程中会使用到多个链表,很多链表都有同样的节点结构,如图所示:
![](img/23-01.png)
在某个表空间内,我们可以通过一个页的页号和在页内的偏移量来唯一定位一个节点的位置,这两个信息也就相当于指向这个节点的一个指针。所以: - Pre Node Page Number
和Pre Node Offset
的组合就是指向前一个节点的指针 - Next Node Page Number
和Next Node Offset
的组合就是指向后一个节点的指针。
整个List Node
占用12
个字节的存储空间。
为了更好的管理链表,设计InnoDB
的大佬还提出了一个基节点的结构,里边存储了这个链表的头节点
、尾节点
以及链表长度信息,基节点的结构示意图如下:
![](img/23-02.png)
其中: - List Length
表明该链表一共有多少节点。 - First Node Page Number
和First Node Offset
的组合就是指向链表头节点的指针。 - Last Node Page Number
和Last Node Offset
的组合就是指向链表尾节点的指针。
整个List Base Node
占用16
个字节的存储空间。
所以使用List Base Node
和List Node
这两个结构组成的链表的示意图就是这样:
![](img/23-03.png)
小贴士:上述链表结构我们在前面的文章中频频提到,尤其是在表空间那一章重点描述过,不过我不敢奢求大家都记住了,所以在这里又强调一遍,希望大家不要嫌我烦,我只是怕大家忘了学习后续内容吃力而已~
FIL_PAGE_UNDO_LOG页面
#
我们前面介绍表空间的时候说过,表空间其实是由许许多多的页面构成的,页面默认大小为16KB
。这些页面有不同的类型,比如类型为FIL_PAGE_INDEX
的页面用于存储聚簇索引以及二级索引,类型为FIL_PAGE_TYPE_FSP_HDR
的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为FIL_PAGE_UNDO_LOG
类型的页面是专门用来存储undo日志
的,这种类型的页面的通用结构如下图所示(以默认的16KB
大小为例):
![](img/23-04.png)
“类型为FIL_PAGE_UNDO_LOG
的页”这种说法太绕口,以后我们就简称为Undo页面
了。上图中的File Header
和File Trailer
是各种页面都有的通用结构,我们前面介绍过很多遍了,这里就不赘述了(忘记了的可以到讲述数据页结构或者表空间的章节中查看)。Undo Page Header
是Undo页面
所特有的,我们来看一下它的结构:
![](img/23-05.png)
...
2025年1月18日 22:30 周六第22章 后悔了怎么办-undo日志(上)
事务回滚的需求
#
我们说过事务
需要保证原子性
,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如: - 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。 - 情况二:程序员可以在事务执行过程中手动输入ROLLBACK
语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚
(英文名:rollback
),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性
要求。
小时候我非常痴迷于象棋,总是想找厉害的大人下棋,赢棋是不可能赢棋的,这辈子都不可能赢棋的,又不想认输,只能偷偷的悔棋才能勉强玩的下去。悔棋
就是一种非常典型的回滚
操作,比如棋子往前走两步,悔棋
对应的操作就是向后走两步;比如棋子往左走一步,悔棋
对应的操作就是向右走一步。数据库中的回滚跟悔棋
差不多,你插入了一条记录,回滚
操作对应的就是把这条记录删除掉;你更新了一条记录,回滚
操作对应的就是把该记录更新为旧值;你删除了一条记录,回滚
操作对应的自然就是把该记录再插进去。说的貌似很简单的样子[手动偷笑😏]。
从上面的描述中我们已经能隐约感觉到,每当我们要对一条记录做改动时(这里的改动
可以指INSERT
、DELETE
、UPDATE
),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说: - 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。 - 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。 - 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
设计数据库的大佬把这些为了回滚而记录的这些东东称之为撤销日志,英文名为undo log
,我们也可以土洋结合,称之为undo日志
。这里需要注意的一点是,由于查询操作(SELECT
)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志
。在真实的InnoDB
中,undo日志
其实并不像我们上面所说的那么简单,不同类型的操作产生的undo日志
的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,我们先回过头来看看事务id
是个神马玩意儿。
事务id
#
给事务分配id的时机
#
我们前面在介绍事务简介
时说过,一个事务可以是一个只读事务,或者是一个读写事务:
我们可以通过START TRANSACTION READ ONLY
语句开启一个只读事务。
在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。
我们可以通过START TRANSACTION READ WRITE
语句开启一个读写事务,或者使用BEGIN
、START TRANSACTION
语句开启的事务默认也算是读写事务。
在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB
存储引擎就会给它分配一个独一无二的事务id
,分配方式如下:
对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id
,否则的话是不分配事务id
的。
小贴士:我们前面说过对某个查询语句执行EXPLAIN分析它的查询计划时,有时候在Extra列会看到Using temporary的提示,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用CREATE TEMPORARY TABLE创建的用户临时表并不一样,在事务回滚时并不需要把执行SELECT语句过程中用到的内部临时表也回滚,在执行SELECT语句用到内部临时表时并不会为它分配事务id。
- 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id
,否则的话也是不分配事务id
的。
有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id
。
说了半天,事务id
有什么子用?这个先保密,后边会一步步的详细介绍。现在只要知道只有在事务对表中的记录做改动时才会为这个事务分配一个唯一的事务id
。 小贴士:上面描述的事务id分配策略是针对MySQL 5.7来说的,前面的版本的分配方式可能不同~
事务id是怎么生成的
#
这个事务id
本质上就是一个数字,它的分配策略和我们前面提到的对隐藏列row_id
(当用户没有为表创建主键和UNIQUE
键时InnoDB
自动创建的列)的分配策略大抵相同,具体策略如下: - 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务id
时,就会把该变量的值当作事务id
分配给该事务,并且把该变量自增1。 - 每当这个变量的值为256
的倍数时,就会将该变量的值刷新到系统表空间的页号为5
的页面中一个称之为Max Trx ID
的属性处,这个属性占用8
个字节的存储空间。 - 当系统下一次重新启动时,会将上面提到的Max Trx ID
属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID
属性值)。
...
2025年1月18日 22:30 周六第21章 说过的话就一定要办到-redo日志(下)
redo日志文件
#
redo日志刷盘时机
#
我们前面说mtr
运行过程中产生的一组redo
日志在mtr
结束时会被复制到log buffer
中,可是这些日志总在内存里呆着也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:
log buffer
空间不足时
log buffer
的大小是有限的(通过系统变量innodb_log_buffer_size
指定),如果不停的往这个有限大小的log buffer
里塞入日志,很快它就会被填满。设计InnoDB
的大佬认为如果当前写入log buffer
的redo
日志量已经占满了log buffer
总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
事务提交时
我们前面说过之所以使用redo
日志主要是因为它占用的空间少,还是顺序写,在事务提交时可以不把修改过的Buffer Pool
页面刷新到磁盘,但是为了保证持久性,必须要把修改这些页面对应的redo
日志刷新到磁盘。
后台线程不停的刷刷刷
后台有一个线程,大约每秒都会刷新一次log buffer
中的redo
日志到磁盘。
正常关闭服务器时
- 做所谓的
checkpoint
时(我们现在没介绍过checkpoint
的概念,稍后会仔细介绍,稍安勿躁) - 其他的一些情况…
redo日志文件组
#
MySQL
的数据目录(使用SHOW VARIABLES LIKE 'datadir'
查看)下默认有两个名为ib_logfile0
和ib_logfile1
的文件,log buffer
中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的redo
日志文件不满意,可以通过下面几个启动参数来调节:
innodb_log_group_home_dir
该参数指定了redo
日志文件所在的目录,默认值就是当前的数据目录。
innodb_log_file_size
该参数指定了每个redo
日志文件的大小,在MySQL 5.7.21
这个版本中的默认值为48MB
,
innodb_log_files_in_group
该参数指定redo
日志文件的个数,默认值为2,最大值为100。
从上面的描述中可以看到,磁盘上的redo
日志文件不只一个,而是以一个日志文件组
的形式出现的。这些文件以ib_logfile[数字]
(数字
可以是0
、1
、2
…)的形式进行命名。在将redo
日志写入日志文件组
时,是从ib_logfile0
开始写,如果ib_logfile0
写满了,就接着ib_logfile1
写,同理,ib_logfile1
写满了就去写ib_logfile2
,依此类推。如果写到最后一个文件该咋办?那就重新转到ib_logfile0
继续写,所以整个过程如下图所示:
![](img/21-01.png)
总共的redo
日志文件大小其实就是:innodb_log_file_size × innodb_log_files_in_group
。 小贴士:如果采用循环使用的方式向redo日志文件组里写数据的话,那岂不是要追尾,也就是后写入的redo日志覆盖掉前面写的redo日志?当然可能了!所以设计InnoDB的大佬提出了checkpoint的概念,稍后我们重点介绍~
redo日志文件格式
#
我们前面说过log buffer
本质上是一片连续的内存空间,被划分成了若干个512
字节大小的block
。将log buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo
日志文件其实也是由若干个512
字节大小的block组成。
redo
日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:
- 前2048个字节,也就是前4个block是用来存储一些管理信息的。
- 从第2048字节往后是用来存储
log buffer
中的block镜像的。
所以我们前面所说的循环
使用redo日志文件,其实是从每个日志文件的第2048个字节开始算,画个示意图就是这样:
![](img/21-02.png)
普通block的格式我们在介绍log buffer
的时候都说过了,就是log block header
、log block body
、log block trialer
这三个部分,就不重复介绍了。这里需要介绍一下每个redo
日志文件前2048个字节,也就是前4个特殊block的格式都是干嘛的,废话少说,先看图:
...
2025年1月18日 22:30 周六第20章 说过的话就一定要办到-redo日志(上)
事先说明
#
本文以及接下来的几篇文章将会频繁的使用到我们前面介绍的InnoDB
记录行格式、页面格式、索引原理、表空间的组成等各种基础知识,如果大家对这些东西理解的不透彻,那么阅读下面的文字可能会有些吃力,为保证您的阅读体验,请确保自己已经掌握了我前面介绍的这些知识。
redo日志是什么
#
我们知道InnoDB
存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。我们前面介绍Buffer Pool
的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool
之后才可以访问。但是在介绍事务的时候又强调过一个称之为持久性
的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。但是如果我们只在内存的Buffer Pool
中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的(想想ATM机已经提示狗哥转账成功,但之后由于服务器出现故障,重启之后猫爷发现自己没收到钱,猫爷就被砍死了)。那么如何保证这个持久性
呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:
刷新一个完整的数据页太浪费了
有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB
中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太浪费了。
随机IO刷起来比较慢
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,倒霉催的是该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool
中的页面刷新到磁盘时,需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。
咋办呢?再次回到我们的初心:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1
改成2
我们只需要记录一下:
将第0号表空间的100号页面的偏移量为1000处的值更新为2
。
这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性
的要求。因为在系统奔溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志
,英文名为redo log
,我们也可以土洋结合,称之为redo日志
。与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的redo
日志刷新到磁盘的好处如下:
redo
日志占用的空间非常小
存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的,关于redo
日志的格式我们稍后会详细介绍,现在只要知道一条redo
日志占用的空间不是很大就好了。
redo
日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条redo
日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。
redo日志格式
#
通过上面的内容我们知道,redo
日志本质上只是记录了一下事务对数据库做了哪些修改。 设计InnoDB
的大佬们针对事务对数据库的不同修改场景定义了多种类型的redo
日志,但是绝大部分类型的redo
日志都有下面这种通用的结构:
![](img/20-01.png)
各个部分的详细释义如下:
type
:该条redo
日志的类型。
在MySQL 5.7.21
这个版本中,设计InnoDB
的大佬一共为redo
日志设计了53种不同的类型,稍后会详细介绍不同类型的redo
日志。
space ID
:表空间ID。
page number
:页号。data
:该条redo
日志的具体内容。
简单的redo日志类型
#
我们前面介绍InnoDB
的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并且表中也没有定义Unique
键,那么InnoDB
会自动的为表添加一个称之为row_id
的隐藏列作为主键。为这个row_id
隐藏列赋值的方式如下: - 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id
列的表中插入一条记录时,就会把该变量的值当作新记录的row_id
列的值,并且把该变量自增1。 - 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7
的页面中一个称之为Max Row ID
的属性处(我们前面介绍表空间结构时详细说过)。 - 当系统启动时,会将上面提到的Max Row ID
属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Row ID
属性值)。
这个Max Row ID
属性占用的存储空间是8个字节,当某个事务向某个包含row_id
隐藏列的表插入一条记录,并且为该记录分配的row_id
值为256的倍数时,就会向系统表空间页号为7的页面的相应偏移量处写入8个字节的值。但是我们要知道,这个写入实际上是在Buffer Pool
中完成的,我们需要为这个页面的修改记录一条redo
日志,以便在系统奔溃后能将已经提交的该事务对该页面所做的修改恢复出来。这种情况下对页面的修改是极其简单的,redo
日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是什么就好了,设计InnoDB
的大佬把这种极其简单的redo
日志称之为物理日志
,并且根据在页面中写入数据的多少划分了几种不同的redo
日志类型: - MLOG_1BYTE
(type
字段对应的十进制数字为1
):表示在页面的某个偏移量处写入1个字节的redo
日志类型。 - MLOG_2BYTE
(type
字段对应的十进制数字为2
):表示在页面的某个偏移量处写入2个字节的redo
日志类型。 - MLOG_4BYTE
(type
字段对应的十进制数字为4
):表示在页面的某个偏移量处写入4个字节的redo
日志类型。 - MLOG_8BYTE
(type
字段对应的十进制数字为8
):表示在页面的某个偏移量处写入8个字节的redo
日志类型。 - MLOG_WRITE_STRING
(type
字段对应的十进制数字为30
):表示在页面的某个偏移量处写入一串数据。
...
2025年1月18日 22:30 周六第19章 从猫爷被杀说起-事务简介
事务的起源
#
对于大部分程序员来说,他们的任务就是把现实世界的业务场景映射到数据库世界。比如银行为了存储人们的账户信息会建立一个account
表: CREATE TABLE account ( id INT NOT NULL AUTO_INCREMENT COMMENT '自增id', name VARCHAR(100) COMMENT '客户名称', balance INT COMMENT '余额', PRIMARY KEY (id) ) Engine=InnoDB CHARSET=utf8;
狗哥和猫爷是一对好基友,他们都到银行开一个账户,他们在现实世界中拥有的资产就会体现在数据库世界的account
表中。比如现在狗哥有11
元,猫爷只有2
元,那么现实中的这个情况映射到数据库的account
表就是这样: +----+--------+---------+ | id | name | balance | +----+--------+---------+ | 1 | 狗哥 | 11 | | 2 | 猫爷 | 2 | +----+--------+---------+
在某个特定的时刻,狗哥猫爷这些家伙在银行所拥有的资产是一个特定的值,这些特定的值也可以被描述为账户在这个特定的时刻现实世界的一个状态。随着时间的流逝,狗哥和猫爷可能陆续进行向账户中存钱、取钱或者向别人转账等操作,这样他们账户中的余额就可能发生变动,每一个操作都相当于现实世界中账户的一次状态转换。数据库世界作为现实世界的一个映射,自然也要进行相应的变动。不变不知道,一变吓一跳,现实世界中一些看似很简单的状态转换,映射到数据库世界却不是那么容易的。比方说有一次猫爷在赌场赌博输了钱,急忙打电话给狗哥要借10块钱,不然那些看场子的就会把自己剁了。现实世界中的狗哥走向了ATM机,输入了猫爷的账号以及10元的转账金额,然后按下确认,狗哥就拔卡走人了。对于数据库世界来说,相当于执行了下面这两条语句:
UPDATE account SET balance = balance - 10 WHERE id = 1; UPDATE account SET balance = balance + 10 WHERE id = 2;
...
2025年1月18日 22:30 周六第18章 调节磁盘和CPU的矛盾-InnoDB的Buffer Pool
缓存的重要性
#
通过前面的介绍我们知道,对于使用InnoDB
作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页
的形式存放在表空间
中的,而所谓的表空间
只不过是InnoDB
对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。但是各位也都知道,磁盘的速度慢的跟乌龟一样,怎么能配得上“快如风,疾如电”的CPU
呢?所以InnoDB
存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存
起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO
的开销了。
InnoDB的Buffer Pool
#
什么是个Buffer Pool
#
设计InnoDB
的大佬为了缓存磁盘中的页,在MySQL
服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool
(中文名是缓冲池
)。那它有多大呢?这个其实看我们机器的配置,如果你是土豪,你有512G
内存,你分配个几百G作为Buffer Pool
也可以啊,当然你要是没那么有钱,设置小点也行呀~ 默认情况下Buffer Pool
只有128M
大小。当然如果你嫌弃这个128M
太大或者太小,可以在启动服务器的时候配置innodb_buffer_pool_size
参数的值,它表示Buffer Pool
的大小,就像这样: [server] innodb_buffer_pool_size = 268435456
其中,268435456
的单位是字节,也就是我指定Buffer Pool
的大小为256M
。需要注意的是,Buffer Pool
也不能太小,最小值为5M
(当小于该值时会自动设置成5M
)。
Buffer Pool内部组成
#
Buffer Pool
中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB
。为了更好的管理这些在Buffer Pool
中的缓存页,设计InnoDB
的大佬为每一个缓存页都创建了一些所谓的控制信息
,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool
中的地址、链表节点信息、一些锁信息以及LSN
信息(锁和LSN
我们之后会具体介绍,现在可以先忽略),当然还有一些别的控制信息,我们这就不全介绍一遍了,挑重要的说嘛~
每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个控制块
吧,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前面,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool
对应的内存空间看起来就是这样的:
![](img/18-01.png)
咦?控制块和缓存页之间的那个碎片
是个什么玩意儿?你想想啊,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的那点儿内存空间就被称为碎片
了。当然,如果你把Buffer Pool
的大小设置的刚刚好的话,也可能不会产生碎片
~ 小贴士:每个控制块大约占用缓存页大小的5%,在MySQL5.7.21这个版本中,每个控制块占用的大小是808字节。而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。
free链表的管理
#
当我们最初启动MySQL
服务器的时候,需要完成对Buffer Pool
的初始化过程,就是先向操作系统申请Buffer Pool
的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到Buffer Pool
中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到Buffer Pool
中。那么问题来了,从磁盘上读取一个页到Buffer Pool
中的时候该放到哪个缓存页的位置呢?或者说怎么区分Buffer Pool
中哪些缓存页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录一下Buffer Pool中哪些缓存页是可用的,这个时候缓存页对应的控制块
就派上大用场了,我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表
(或者说空闲链表)。刚刚完成初始化的Buffer Pool
中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表
中,假设该Buffer Pool
中可容纳的缓存页数量为n
,那增加了free链表
的效果图就是这样的:
![](img/18-02.png)
从图中可以看出,我们为了管理好这个free链表
,特意为这个链表定义了一个基节点
,里边儿包含着链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool
申请的一大片连续内存空间之内,而是单独申请的一块内存空间。 小贴士:链表基节点占用的内存空间并不大,在MySQL5.7.21这个版本里,每个基节点只占用40字节大小。后边我们即将介绍许多不同的链表,它们的基节点和free链表的基节点的内存分配方式是一样一样的,都是单独申请的一块40字节大小的内存空间,并不包含在为Buffer Pool申请的一大片连续内存空间之内。
...
2025年1月18日 22:30 周六第17章 神兵利器-optimizer trace表的神器功效
对于MySQL 5.6
以及之前的版本来说,查询优化器就像是一个黑盒子一样,你只能通过EXPLAIN
语句查看到最后优化器决定使用的执行计划,却无法知道它为什么做这个决策。这对于一部分喜欢刨根问底的小伙伴来说简直是灾难:“我就觉得使用其他的执行方案比EXPLAIN
输出的这种方案强,凭什么优化器做的决定和我想的不一样呢?”
在MySQL 5.6
以及之后的版本中,设计MySQL
的大佬贴心的为这部分小伙伴提出了一个optimizer trace
的功能,这个功能可以让我们方便的查看优化器生成执行计划的整个过程,这个功能的开启与关闭由系统变量optimizer_trace
决定,我们看一下: mysql> SHOW VARIABLES LIKE 'optimizer_trace'; +-----------------+--------------------------+ | Variable_name | Value | +-----------------+--------------------------+ | optimizer_trace | enabled=off,one_line=off | +-----------------+--------------------------+ 1 row in set (0.02 sec)
可以看到enabled
值为off
,表明这个功能默认是关闭的。 小贴士:one_line的值是控制输出格式的,如果为on那么所有输出都将在一行中展示,不适合人阅读,所以我们就保持其默认值为off吧。
如果想打开这个功能,必须首先把enabled
的值改为on
,就像这样: mysql> SET optimizer_trace="enabled=on"; Query OK, 0 rows affected (0.00 sec)
然后我们就可以输入我们想要查看优化过程的查询语句,当该查询语句执行完成后,就可以到information_schema
数据库下的OPTIMIZER_TRACE
表中查看完整的优化过程。这个OPTIMIZER_TRACE
表有4个列,分别是: - QUERY
:表示我们的查询语句。 - TRACE
:表示优化过程的JSON格式文本。 - MISSING_BYTES_BEYOND_MAX_MEM_SIZE
:由于优化过程可能会输出很多,如果超过某个限制时,多余的文本将不会被显示,这个字段展示了被忽略的文本字节数。 - INSUFFICIENT_PRIVILEGES
:表示是否没有权限查看优化过程,默认值是0,只有某些特殊情况下才会是1
,我们暂时不关心这个字段的值。
完整的使用optimizer trace
功能的步骤总结如下: ```
- 打开optimizer trace功能 (默认情况下它是关闭的):
SET optimizer_trace=“enabled=on”;
- 这里输入你自己的查询语句
SELECT …;
- 从OPTIMIZER_TRACE表中查看上一个查询的优化过程
SELECT * FROM information_schema.OPTIMIZER_TRACE;
...