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
继续写,所以整个过程如下图所示:

总共的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个字节开始算,画个示意图就是这样:

普通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
日志都有下面这种通用的结构:

各个部分的详细释义如下:
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
对应的内存空间看起来就是这样的:

咦?控制块和缓存页之间的那个碎片
是个什么玩意儿?你想想啊,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的那点儿内存空间就被称为碎片
了。当然,如果你把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链表
的效果图就是这样的:

从图中可以看出,我们为了管理好这个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;
...
2025年1月18日 22:29 周六第16章 查询优化的百科全书-Explain详解(下)
执行计划输出中各列详解
#
本章紧接着上一节的内容,继续介绍EXPLAIN
语句输出的各个列的意思。
顾名思义,Extra
列是用来说明一些额外信息的,我们可以通过这些额外信息来更准确的理解MySQL
到底将如何执行给定的查询语句。MySQL
提供的额外信息有好几十个,我们就不一个一个介绍了(都介绍了感觉我们的文章就跟文档差不多了~),所以我们只挑一些平时常见的或者比较重要的额外信息介绍给大家。
No tables used
当查询语句的没有FROM
子句时将会提示该额外信息,比如: mysql> EXPLAIN SELECT 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.00 sec)
...
2025年1月18日 22:29 周六第15章 查询优化的百科全书-Explain详解(上)
一条查询语句在经过MySQL
查询优化器的各种基于成本和规则的优化会后生成一个所谓的执行计划
,这个执行计划展示了接下来具体执行查询的方式,比如多表连接的顺序是什么,对于每个表采用什么访问方法来具体执行查询等等。设计MySQL
的大佬贴心的为我们提供了EXPLAIN
语句来帮助我们查看某个查询语句的具体执行计划,本章的内容就是为了帮助大家看懂EXPLAIN
语句的各个输出项都是干嘛使的,从而可以有针对性的提升我们查询语句的性能。
如果我们想看看某个查询的执行计划的话,可以在具体的查询语句前面加一个EXPLAIN
,就像这样:
mysql> EXPLAIN SELECT 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.01 sec)
然后这输出的一大坨东西就是所谓的执行计划
,我的任务就是带领大家看懂这一大坨东西里边的每个列都是干什么用的,以及在这个执行计划
的辅助下,我们应该怎样改进自己的查询语句以使查询执行起来更高效。其实除了以SELECT
开头的查询语句,其余的DELETE
、INSERT
、REPLACE
以及UPDATE
语句前面都可以加上EXPLAIN
这个词儿,用来查看这些语句的执行计划,不过我们这里对SELECT
语句更感兴趣,所以后边只会以SELECT
语句为例来描述EXPLAIN
语句的用法。为了让大家先有一个感性的认识,我们把EXPLAIN
语句输出的各个列的作用先大致罗列一下:
列名 描述 id
在一个大的查询语句中每个SELECT
关键字都对应一个唯一的id
select_type
SELECT
关键字对应的那个查询的类型 table
表名 partitions
匹配的分区信息 type
针对单表的访问方法 possible_keys
可能用到的索引 key
实际上使用的索引 key_len
实际使用到的索引长度 ref
当使用索引列等值查询时,与索引列进行等值匹配的对象信息 rows
预估的需要读取的记录条数 filtered
某个表经过搜索条件过滤后剩余记录条数的百分比 Extra
一些额外的信息
需要注意的是,大家如果看不懂上面输出列含义,那是正常的,千万不要纠结~。我在这里把它们都列出来只是为了描述一个轮廓,让大家有一个大致的印象,下面会细细道来,等会儿说完了不信你不会~ 为了故事的顺利发展,我们还是要请出我们前面已经用了n遍的single_table
表,为了防止大家忘了,再把它的结构描述一遍: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8;
我们仍然假设有两个和single_table
表构造一模一样的s1
、s2
表,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。为了让大家有比较好的阅读体验,我们下面并不准备严格按照EXPLAIN
输出列的顺序来介绍这些列分别是干嘛的,大家注意一下就好了。
...
2025年1月18日 22:29 周六第14章 不好看就要多整容-MySQL基于规则的优化(内含关于子查询优化二三事儿)
大家别忘了MySQL
本质上是一个软件,设计MySQL
的大佬并不能要求使用这个软件的人个个都是数据库高高手,就像我写这本书的时候并不能要求各位在学之前就会了里边儿的知识。 吐槽一下:都会了的人谁还看呢,难道是为了精神上受感化?
也就是说我们无法避免某些同学写一些执行起来十分耗费性能的语句。即使是这样,设计MySQL
的大佬还是依据一些规则,竭尽全力的把这个很糟糕的语句转换成某种可以比较高效执行的形式,这个过程也可以被称作查询重写
(就是人家觉得你写的语句不好,自己再重写一遍)。本章详细介绍一下一些比较重要的重写规则。
条件化简
#
我们编写的查询语句的搜索条件本质上是一个表达式,这些表达式可能比较繁杂,或者不能高效的执行,MySQL
的查询优化器会为我们简化这些表达式。为了方便大家理解,我们后边举例子的时候都使用诸如a
、b
、c
之类的简单字母代表某个表的列名。
移除不必要的括号
#
有时候表达式里有许多无用的括号,比如这样: ((a = 5 AND b = c) OR ((a > c) AND (c < 5)))
看着就很烦,优化器会把那些用不到的括号给干掉,就是这样: (a = 5 and b = c) OR (a > c AND c < 5)
常量传递(constant_propagation)
#
有时候某个表达式是某个列和某个常量做等值匹配,比如这样: a = 5
当这个表达式和其他涉及列a
的表达式使用AND
连接起来时,可以将其他表达式中的a
的值替换为5
,比如这样: a = 5 AND b > a
就可以被转换为: a = 5 AND b > 5
小贴士:为什么用OR连接起来的表达式就不能进行常量传递呢?自己想想~
等值传递(equality_propagation)
#
有时候多个列之间存在等值匹配的关系,比如这样: a = b and b = c and c = 5
这个表达式可以被简化为: a = 5 and b = 5 and c = 5
...
2025年1月18日 22:29 周六第13章 兵马未动,粮草先行-InnoDB统计数据是如何收集的
我们前面介绍查询成本的时候经常用到一些统计数据,比如通过SHOW TABLE STATUS
可以看到关于表的统计数据,通过SHOW INDEX
可以看到关于索引的统计数据,那么这些统计数据是怎么来的呢?它们是以什么方式收集的呢?本章将聚焦于InnoDB
存储引擎的统计数据收集策略,看完本章大家就会明白为什么前面老说InnoDB
的统计信息是不精确的估计值了(言下之意就是我们不打算介绍MyISAM
存储引擎统计数据的收集和存储方式,有想了解的同学自己个儿看看文档)。
两种不同的统计数据存储方式
#
InnoDB
提供了两种存储统计数据的方式:
永久性的统计数据
这种统计数据存储在磁盘上,也就是服务器重启之后这些统计数据还在。
非永久性的统计数据
这种统计数据存储在内存中,当服务器关闭时这些这些统计数据就都被清除掉了,等到服务器重启之后,在某些适当的场景下才会重新收集这些统计数据。
设计MySQL
的大佬们给我们提供了系统变量innodb_stats_persistent
来控制到底采用哪种方式去存储统计数据。在MySQL 5.6.6
之前,innodb_stats_persistent
的值默认是OFF
,也就是说InnoDB
的统计数据默认是存储到内存的,之后的版本中innodb_stats_persistent
的值默认是ON
,也就是统计数据默认被存储到磁盘中。
不过InnoDB
默认是以表为单位来收集和存储统计数据的,也就是说我们可以把某些表的统计数据(以及该表的索引统计数据)存储在磁盘上,把另一些表的统计数据存储在内存中。怎么做到的呢?我们可以在创建和修改表的时候通过指定STATS_PERSISTENT
属性来指明该表的统计数据存储方式: CREATE TABLE 表名 (...) Engine=InnoDB, STATS_PERSISTENT = (1|0); ALTER TABLE 表名 Engine=InnoDB, STATS_PERSISTENT = (1|0);
当STATS_PERSISTENT=1
时,表明我们想把该表的统计数据永久的存储到磁盘上,当STATS_PERSISTENT=0
时,表明我们想把该表的统计数据临时的存储到内存中。如果我们在创建表时未指定STATS_PERSISTENT
属性,那默认采用系统变量innodb_stats_persistent
的值作为该属性的值。
基于磁盘的永久性统计数据
#
当我们选择把某个表以及该表索引的统计数据存放到磁盘上时,实际上是把这些统计数据存储到了两个表里:
mysql> SHOW TABLES FROM mysql LIKE 'innodb%'; +---------------------------+ | Tables_in_mysql (innodb%) | +---------------------------+ | innodb_index_stats | | innodb_table_stats | +---------------------------+ 2 rows in set (0.01 sec)
可以看到,这两个表都位于mysql
系统数据库下面,其中: - innodb_table_stats
存储了关于表的统计数据,每一条记录对应着一个表的统计数据。 - innodb_index_stats
存储了关于索引的统计数据,每一条记录对应着一个索引的一个统计项的统计数据。
...