sheng的学习笔记-mysql框架原理

基础架构

基础架构示意图

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

sheng的学习笔记-mysql框架原理_第1张图片

连接器

第一步,你会先连接到这个数据库上,这时候接待你的就是连接器。连接器负责跟客户端建立连接、获取权限、维持和管理连接。连接命令一般是这么写的:

mysql -h$ip -P$port -u$user -p

连接命令中的 mysql 是客户端工具,用来跟服务端建立连接。在完成经典的 TCP 握手后,连接器就要开始认证你的身份,这个时候用的就是你输入的用户名和密码。

  • 如果用户名或密码不对,你就会收到一个"Access denied for user"的错误,然后客户端程序结束执行。
  • 如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。

这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它。文本中这个图是 show processlist 的结果,其中的 Command 列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接。 

sheng的学习笔记-mysql框架原理_第2张图片

如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。

数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。

建立连接的过程通常是比较复杂的,所以我建议你在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。

但是全部使用长连接后,你可能会发现,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。

怎么解决这个问题呢?你可以考虑以下两种方案。

  • 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  • 如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。 

查询缓存

MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。

如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。

但是大多数情况下建议不要使用查询缓存,因为查询缓存往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。适用于更新很少的配置表,另外在8.0版本以后就没这个功能

分析器

如果没有命中查询缓存,就要开始真正执行语句了。

首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。

  1. 分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。MySQL 从你输入的"select"这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。
  2. 做完了这些识别以后,就要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒

优化器

经过了分析器,MySQL 就知道你要做什么了。

在开始执行之前,还要先经过优化器的处理。优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。

比如你执行下面这样的语句,这个语句是执行两个表的 join:

mysql> select * from t1 join t2 using(ID) where t1.c=10 and t2.d=20;

  • 既可以先从表 t1 里面取出 c=10 的记录的 ID 值,再根据 ID 值关联到表 t2,再判断 t2 里面 d 的值是否等于 20。
  • 也可以先从表 t2 里面取出 d=20 的记录的 ID 值,再根据 ID 值关联到 t1,再判断 t1 里面 c 的值是否等于 10。

这两种执行方法的逻辑结果是一样的,但是执行的效率会有不同,而优化器的作用就是决定选择使用哪一个方案。

执行器

开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返回没有权限的错误

如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。

mysql> select * from T where ID=10;

比如我们这个例子中的表 T 中,ID 字段没有索引,那么执行器的执行流程是这样的:

  1. 调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;
  2. 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
  3. 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。

至此,这个语句就执行完成了。对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。

持久化机制

日志系统-redo log

什么是redo log

redo log叫做重做日志,是保证事务持久性的重要机制。当mysql服务器意外崩溃或者宕机后,保证已经提交的事务,确定持久化到磁盘中的一种措施。

sheng的学习笔记-mysql框架原理_第3张图片

为什么有redo log

innodb是以页为单位来管理存储空间的,任何的增删改差操作最终都会操作完整的一个页,会将整个页加载到buffer pool中,然后对需要修改的记录进行修改,修改完毕不会立即刷新到磁盘,因为此时的刷新是一个随机io,而且仅仅修改了一条记录,刷新一个完整的数据页的话过于浪费了。但是如果不立即刷新的话,数据此时还在内存中,如果此时发生系统崩溃最终数据会丢失的,因此权衡利弊,引入了redo log,也就是说,修改完后,不立即刷新,而是记录一条日志,日志内容就是记录哪个页面,多少偏移量,什么数据发生了什么变更。这样即使系统崩溃,再恢复后,也可以根据redo日志进行数据恢复。另外,redo log是循环写入固定的文件,是顺序写入磁盘的。

  1. RedoLog和UndoLog属于InnoDb引擎的事务日志(就是和事务有关系),redolog用来恢复丢失数据(恢复到最后一次提交位置)也称之为前滚操作,undolog是用来回滚到之前的版本,称之为回滚操作。
  2. RedoLog记录的是事务操作的变化,数据修改的值,不管事务是否提交都会记录。
  3. RedoLog属于物理日志,它记录的是数据文件的物理变更,在什么位置,数据做了如何变更。
  4. RedoLog使用WAL(日志优先落盘)来保证事务,也是使用这个技术来保证事务的4大特性的持久性。
  5. 要的应用场景是进行MySql的Crash Recovery(崩盘恢复)
     

redo log的流程简介

  • RedoLog的记录方式如图,它是循环写的。write pos可以看作是头,CheckPoint可以看作是尾。w->c是空闲的区域,c-w是已经写满的区域。写入时挪动w,落盘时挪动c,当w=c的位置时需要等待去落盘。
  • checkpoint就是负责落盘的,将数据存储到磁盘上并擦掉,由一个异步线程来执行的。
  • RedoLog的操作日志有两种状态,未提交时prepare状态,提交之后时commit状态。
  • RedoLog包含两部分,一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件( redo logfile)。缓冲主要是解决速度不一致引入的。另外缓冲和日志文件的交互需要经过os buffer(用户空间[user space]下的缓冲区数据一般情况下是无法直接写入磁盘的,中间必须经过操作系统内核空间[kernel space]缓冲区[OS Buffer])

sheng的学习笔记-mysql框架原理_第4张图片

redo-log以组的方式写入

在一个事物中,可能会发生多次的数据修改,对应的就是多个数据页多个偏移量位置的字段变更,也就是说会产生多条redo log,而且因为在同一个事物中,这些redo log,也是不可再分的,也就是说,一个组的redo log在持久化的时候,不能部分成功,部分失败,否则的话,就会破坏事务的原子性。另外为了提升性能redo log是按照块组织在一起,然后写入到磁盘中的,类似于数据的页,而且引入了redo log buffer,默认的大小为16MB。buffer中分了很多的block,每个block的大小为512kb,每一个事务产生的所有redo log称为一个group。

 记录redo log的时机

在完成数据的修改之后,脏页刷入磁盘之前写入重做日志缓冲区。即先修改,再写入。

脏页:内存中与磁盘上不一致的数据(并不是坏的!)

在以下情况下,redo log由重做日志缓冲区写入磁盘上的重做日志文件。

  • redo log buffer的日志占据redo log buffer总容量的一半时,将redo log写入磁盘。
  • 一个事务提交时,他的redo log都刷入磁盘,这样可以保证数据绝不丢失(最常见的情况)。注意这时内存中的脏页可能尚未全部写入磁盘。
  • 后台线程定时刷新,有一个后台线程每过一秒就将redo log写入磁盘。
  • MySQL关闭时,redo log都被写入磁盘。

写redo log的过程

sheng的学习笔记-mysql框架原理_第5张图片

如上图,展示了redo log是如何被写入log buffer的。每个mini-trasaction对应于每个DML操作,例如更新语句等。

  • 每个数据修改后被写入迷你事务私有缓冲区。
  • 当更新语句完成,redo log从迷你事务私有缓冲区被写入内存中的公共日志缓冲区。
  • 提交外部事务时,会将重做日志缓冲区刷入重做日志文件。

日志块 log block buffer内存

redo log以块为单位进行存储,每个块大小为512字节。无论是在内存重做日志缓冲区、操作系统缓冲区还是重做日志文件中,都是以这样的512字节大小的块进行存储的。

sheng的学习笔记-mysql框架原理_第6张图片

每个日志块头由以下四个部分组成

  • log_block_hdr_no:(4字节)该日志块在redo log buffer中的位置ID。
  • log_block_hdr_data_len:(2字节)该log block中已记录的log大小。写满该log block时为0x200,表示512字节。
  • log_block_first_rec_group:(2字节)该log block中第一个log的开始偏移位置。
  • lock_block_checkpoint_no:(4字节)写入检查点信息的位置。

说到底redo日志也是写到磁盘中的,也是写文件,速度不会有问题吗?所以同样的,来个缓存。与 buffer pool的作用类似,redo log也有一个缓冲内存,叫做log buffer。一个mtr对应的日志组先加入到log buffer中,然后刷新到磁盘。 顺序写入redo log buffer中,写完一个写下一个

sheng的学习笔记-mysql框架原理_第7张图片

数据文件

只要写入磁盘的数据,都会从redo log中抹除,数据库重启后,直接将redo log的数据恢复到内存。

InnoDB执行update更新操作是采用的“先写日志,在写磁盘”(WAL)的策略。更新后的行数据本身先缓存在内存中,直将缩略的关键信息写入到redo log磁盘。但缓存在内存中的数据最终总是要写入到磁盘,这个操作叫做flush

当内存数据页和磁盘数据页不一致的时候,称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。flush操作也就是“刷脏页”。

触发数据库执行flush操作的四种情况:

  • 当InnoDB的redo log写满时,此时系统会停止所有的更新操作,将环形的redo log中的“读指针”向前推,对应的所有脏页此时都会flush到磁盘上。
  • 当系统的内存不足时,当需要新的内存页,但是内存不够用时,就需要淘汰一些内存页(一般是空出最长时间没有被访问的内存页),此时如果淘汰的是脏页,就需要先将脏页写到磁盘。
  • 当mysql认为系统“空闲”时,mysql会在运行期间“见缝插针”的找机会刷一点脏页,以避免当读写业务繁忙时过快的占满系统内存或redo log空间。
  • 当mysql正常关闭时,此时mysql会把内存中所有脏页都flush到磁盘上,这样mysql下次启动的时候就直接从磁盘上读取数据,启动速度更快(相比即从磁盘读取数据,又从磁盘读取redo log日志)。

更新数据文件的是buffer pool而不是redolog

实际上,redo log 并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由 redo log 更新过去”的情况。

  • 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与 redo log 毫无关系。
  • 在崩溃恢复场景中,InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。

最小的事务MTR

全称是Mini-Transaction,顾名思义,可以理解为"最小的事务",MySQL中把对底层页面的一次原子访问的过程称之为一个Mini-Transaction,这里的原子操作,指的是要么全部成功,要么全部失败,不存在中间状态。

    MTR主要是被用在写undo log和redo log的场景下的。例如,我们要向一个B+树索引中插入一条记录,此时要么插入成功,要么插入失败,这个过程就可以称为一个MTR过程,这个过程中会产生一组redo log日志,这组日志在做MySQL的崩溃恢复的时候,是一个不可分割的整体。

    假如我们有一个事务,事务中包含3条语句,那么MTR的概念图如下:

sheng的学习笔记-mysql框架原理_第8张图片

Mini-Transaction一般遵循三条原则:

  • 1、the fix rules
  • 2、WAL
  • 3、force-log-at-commit

这里我们解释下这三条原则:

1、the fix rules

解释第一条规则之前,我们有必要了解下MySQL中的latch的概念,在MySQL中,latch是一种轻量级的锁,与lock不同,它锁定的时间特别短,在innodb中,latch又可以分为mutex(互斥量)和rwlock(读写锁)2种,它的目的在于保证并发线程操作临界资源的正确性。

理解了latch的概念,我们看看the fix rule规则:

修改一个数据页,需要获得这个数据页的x-latch;

访问一个页是需要获得s-latch或者x-latch;

持有该页的latch直到修改或者访问该页的操作完成才释放

2、WAL

WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘。WAL技术想必大家比较熟悉,它是Innodb存储引擎之所以支持崩溃恢复的根本,也就是持久化一个数据页之前,需要将内存中响应的日志页先持久化。

3、force-log-at-commit

这条原则比较重要,它是指在事务提交的时候,其产生的所有MTR日志都要刷到持久化设备中,从而保证崩溃恢复的逻辑。

Force Log at Commit机制实现了事务的持久性。在内存中操作时,日志被写入重做日志缓冲区。但在事务提交之前,必须首先将所有日志写入磁盘上的重做日志文件。

为了确保每个日志都写入重做日志文件,必须使用一个fsync系统调用,确保OS buffer中的日志被完整地写入磁盘上的log file。

fsync系统调用:需要你在入参的位置上传递给他一个fd,然后系统调用就会对这个fd指向的文件起作用。fsync会确保一直到写磁盘操作结束才会返回,所以当你的程序使用这个函数并且它成功返回时,就说明数据肯定已经安全的落盘了。所以fsync适合数据库这种程序。

sheng的学习笔记-mysql框架原理_第9张图片

日志文件-undo log

概念:

undo log是innodb引擎的一种日志,在事务的修改记录之前,会把该记录的原值(before image)先保存起来(undo log)再做修改,以便修改过程中出错能够恢复原值或者其他的事务读取

作用

从概念的定义不难看出undo log的两个作用:

  1. 事务回滚 - 原子性: undo log是为了实现事务的原子性而出现的产物,事务处理的过程中,如果出现了错误或者用户执行ROLLBACK语句,MySQL可以利用undo log中的备份将数据恢复到事务开始之前的状态。
  2. 多个行版本控制(MVCC)- 隔离性: undo log在MySQL InnoDB储存引擎中用来实现多版本并发控制,事务未提交之前,当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

什么时候会生成undo log

在事务中,进行以下四种操作,都会创建undo log

  1. insert用户定义的表
  2. update或者delete用户定义的表
  3. insert用户定义的临时表
  4. update或者delete用户定义的临时表

回滚中使用undo的原理

sheng的学习笔记-mysql框架原理_第10张图片

日志文件-bin log

binlog是Mysql sever层维护的一种二进制日志,与innodb引擎中的redo/undo log是完全不同的日志;其主要是用来记录对mysql数据更新或潜在发生更新的SQL语句,并以"事务"的形式保存在磁盘中;

作用主要有:

  • 复制:MySQL Replication在Master端开启binlog,Master把它的二进制日志传递给slaves并回放来达到master-slave数据一致的目的
  • 数据恢复:通过mysqlbinlog工具恢复数据
  • 增量备份

binlog 的写入机制

事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。

一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache

sheng的学习笔记-mysql框架原理_第11张图片

可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件。

  • 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
  • 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。

write 和 fsync 的时机,是由参数 sync_binlog 控制的:

  • sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
  • sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
  • sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。

但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。

binlog内容

binlog中是二进制内容,需要通过mysql工具查看具体内容,其中记载着逻辑内容,有3中格式:

  • statement
  • row
  • mixed

为了描述三种格式的区别,我先来创建一张表,并且初始化5条数据

create table t(
    id int(11) NOT NULL,
    a int(11) DEFAULT NULL,
    u_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY(id),
    KEY a(a),
    KEY u_time(u_time)
)ENGINE=InnoDB;
 
insert into t values(1,1,'2021-11-18');
insert into t values(2,2,'2021-11-17');
insert into t values(3,3,'2021-11-16');
insert into t values(4,4,'2021-11-15');
insert into t values(5,5,'2021-11-14');
 

如果在这张表中删除一行记录的话,我们来看看binlog是怎么记录的

delete from t where a>=4 and u_time<='2021-11-15' limit 1

1、binlog=statement格式

当binlog=statement时,binlog记录的是SQL本身的语句

ues `test`;delete from t where a>=4 and u_time<='2021-11-15' limit 1

binlog设置为statement格式的时候,因为记录的是sql语句本身,并且语句带limit 1,这个命令可能是unsafe的。这里我来说明一下为啥:

如果delete使用的是索引a,根据索引a找到第一条数据删除,也就是删除a=4这一行;
如果使用索引u_time,那么找到就是u_time='2021-11-14’这一条,也就是a=5这一行
由于statement格式下,binlog记录的是sql原文,可能导致在主库执行的时候使用的是索引a,而在备库执行的时候用了索引u_time,因此,会出现主备不一致的情况

2、binlog=row格式

如果我们把binlog设置为row格式的时候,binlog记录的不是sql原语句,而是替换成了两个event:Table_map和Delete_rows。

Table_map    |   table_id: 226(test.t)
Delete_rows  |   table_id: 226 flags: STMT_END_F

Table_map event,用于说明接下来要操作的表是test库的t表
Delete_rows event,用于定义删除的行为
通过对row格式的binlog看不出详细信息,需要进一步借助mysqlbinlog工具,用’mysqlbinlog -w data/master.0.000001 -start -position=8900’解析和查看binlog的内容,binlog可以看到这条语句是从8900事物开始,所以可以用start-position指定从哪个位置开始解析

最后可以查看到binlog使用row格式,binlog里面记录了真实删除记录的主键id,这样备库同步的时候一定会删除id=4的行,不会有主备同步不一致的问题

3、binlog=mixed格式

上面已经有了row格式,已经可以解决主备不一致的问题,为啥还会有mixed格式呢

statement格式记录sql原句,可能会导致主备不一致,所以出现了row格式
但是row格式也有一个缺点,就是很占空间,比如你delete语句删除1万行记录,statement格式会记录一个sql删除1万行就没了;但是使用row格式会把这1万要删除的记录都写到binlog中,这样会导致binlog占用了大量空间,同时写binlog也要耗费大量IO,影响mysql的整体速度
所以MySQL出了个mixed格式,它是前面两种格式的混合。意思是MySQL自己会判断这条SQL语句是否会引起主备不一致,是的话就会使用row,否则就用statement格式
也就是说上面delete语句加上了limit 1,MySQL认为会引起主备不一致,它就会使用row格式记录到binlog;如果delete 1万行记录,MySQL认为不会引起主备不一致,它就会使用statement格式记录到binlog。

对同步一致性要求高的,不能承受丢数据的,还是要用row

binlog与redolog的区别

以下面的语句为例

update T set c=c+1 where ID=2;

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

一个update语句的示意图

sheng的学习笔记-mysql框架原理_第12张图片

两段式提交

对于Mysql Innodb存储引擎而言,每次修改后,不仅需要记录Redo log还需要记录Binlog,而且这两个操作必须保证同时成功或者同时失败,否则就会造成数据不一致。为此Mysql引入两阶段提交。

1、如果先写redolog再写binlog

sheng的学习笔记-mysql框架原理_第13张图片

2、先写binlog再写redolog

sheng的学习笔记-mysql框架原理_第14张图片
3、两阶段提交

 sheng的学习笔记-mysql框架原理_第15张图片

redo log和bin log一起存在的原因

不能用单个(redolog/binlog)日志的原因:

  •  InnoDB 并不是 MySQL 的原生存储引擎。MySQL 的原生引擎是 MyISAM,设计之初就有没有支持崩溃恢复
  • 只用binlog,不行,binlog 没有能力恢复“数据页”,bin log中记录的是逻辑内容,而不是数据页的细节。数据页的实现是存储引擎的细节,对于MYSQL来说存储引擎是可以替换的插件,高层不能依赖底层实现,所以server层无法依赖存储引擎,否则无法做到插件替换
  • 只用redolog,不行,因为存储引擎只处理单节点的数据,MYSQL要做到主备数据同步,就必须做到服务层的数据同步(即server层的实现),而不能使用存储引擎层面的数据同步,否则就必须要求所有的存储引擎组件都要支持数据同步,将存储引擎和主备同步强行耦合

事务

隔离性与隔离级别

 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)

隔离性:当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。

在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。

SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )

  • 读未提交是指:一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

其中“读提交”和“可重复读”比较难理解,所以我用一个例子说明这几种隔离级别。假设数据表 T 中只有一列,其中一行的值为 1,下面是按照时间顺序执行两个事务的行为。

mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);

sheng的学习笔记-mysql框架原理_第16张图片

我们来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2、V3 的返回值分别是什么。

  • 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
  • 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
  • 若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
  • 若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

事务隔离的实现MVCC

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

sheng的学习笔记-mysql框架原理_第17张图片

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC Multi-Version Concurrency Control 多版本并发控制。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。

即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

“快照”在 MVCC 里是工作原理

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。

sheng的学习笔记-mysql框架原理_第18张图片

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

图 2 中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

索引

InnoDB 的索引模型

InnoDB 使用了 B+ 树索引模型,假设,我们有一个主键列为 ID 的表,表中有字段 k,并且在 k 上有索引,这个表的建表语句是:

mysql> create table T(
id int primary key, 
k int not null, 
name varchar(16),
index (k))engine=InnoDB;

表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下。

sheng的学习笔记-mysql框架原理_第19张图片

从图中不难看出,根据叶子节点的内容,索引类型分为主键索引非主键索引。主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。

  • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
  • 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。

回表

在下面这个表 T 中,如果我执行 select * from T where k between 3 and 5

mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0, 
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

sheng的学习笔记-mysql框架原理_第20张图片

过程如下:

  1. 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
  2. 再到 ID 索引树查到 ID=300 对应的 R3;
  3. 在 k 索引树取下一个值 k=5,取得 ID=500;
  4. 再回到 ID 索引树查到 ID=500 对应的 R4;
  5. 在 k 索引树取下一个值 k=6,不满足条件,循环结束。

在这个过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。

覆盖索引

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

最左前缀原则

复合索引使用时遵循最左前缀原则,最左前缀顾名思义,就是最左优先,即查询中使用到最左边的列,那么查询就会使用到索引,如果从索引的第二列开始查找,索引将失效。

sheng的学习笔记-mysql框架原理_第21张图片

我们用(name,age)这个联合索引来分析

sheng的学习笔记-mysql框架原理_第22张图片

可以看到,索引项是按照索引定义里面出现的字段顺序排序的。

当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到 ID4,然后向后遍历得到所有需要的结果。

如果你要查的是所有名字第一个字是“张”的人,你的 SQL 语句的条件是"where name like ‘张 %’"。这时,你也能够用上这个索引,查找到第一个符合条件的记录是 ID3,然后向后遍历,直到不满足条件为止。

可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。

索引下推

我们还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是 10 岁的所有男孩”。那么,SQL 语句是这么写的:

mysql> select * from tuser where name like '张%' and age=10 and ismale=1;

这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录 ID3

  •  MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。
  • MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数

sheng的学习笔记-mysql框架原理_第23张图片

sheng的学习笔记-mysql框架原理_第24张图片

在图 3 和 4 这两个图里面,每一个虚线箭头表示回表一次。

图 3 中,在 (name,age) 索引里面我特意去掉了 age 的值,这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。因此,需要回表 4 次。

图 4 跟图 3 的区别是,InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在我们的这个例子中,只需要对 ID4、ID5 这两条记录回表取数据判断,就只需要回表 2 次。

索引的选择

我们先建一个简单的表,表里有 a、b 两个字段,并分别建上索引,然后,我们往表 t 中插入 10 万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到 (100000,100000,100000)。

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `b` (`b`)
) ENGINE=InnoDB;


delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=100000)do
    insert into t values(i, i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

分析一条 SQL 语句:

mysql> select * from t where a between 10000 and 20000;

这会走索引a

做如下操作:

sheng的学习笔记-mysql框架原理_第25张图片

这里,session A 的操作你已经很熟悉了,它就是开启了一个事务。随后,session B 把数据都删除后,又调用了 idata 这个存储过程,插入了 10 万行数据。这时候,session B 的查询语句 select * from t where a between 10000 and 20000 就不会再选择索引 a 了。我们可以通过慢查询日志(slow log)来查看一下具体的执行情况。

set long_query_time=0;
select * from t where a between 10000 and 20000; /*Q1*/
select * from t force index(a) where a between 10000 and 20000;/*Q2*/

sheng的学习笔记-mysql框架原理_第26张图片

可以看到,Q1 扫描了 10 万行,显然是走了全表扫描,执行时间是 40 毫秒。Q2 扫描了 10001 行,执行了 21 毫秒。也就是说,我们在没有使用 force index 的时候,MySQL 用错了索引,导致了更长的执行时间

优化器选择索引的逻辑

MySQL 在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

MySQL 是得到索引的基数: 采样统计的方法。为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。采样统计的时候,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过 1/M 的时候,会自动触发重新做一次索引统计

 “order by”排序方式

你要查询城市是“杭州”的所有人名字,并且按照姓名排序返回前 1000 个人的姓名、年龄,并设定表定义和查询语句如下,解析一下执行流程

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `city` varchar(16) NOT NULL,
  `name` varchar(16) NOT NULL,
  `age` int(11) NOT NULL,
  `addr` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `city` (`city`)
) ENGINE=InnoDB;


select city,name,age from t where city='杭州' order by name limit 1000  ;

全字段排序

对上述explain如下

Extra 这个字段中的“Using filesort”表示的就是需要排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。为了说明这个 SQL 查询语句的执行过程,我们先来看一下 city 这个索引的示意图

sheng的学习笔记-mysql框架原理_第27张图片

从图中可以看到,满足 city='杭州’条件的行,是从 ID_X 到 ID_(X+N) 的这些记录。通常情况下,这个语句执行流程如下所示 :

  1. 初始化 sort_buffer,确定放入 name、city、age 这三个字段;
  2. 从索引 city 找到第一个满足 city='杭州’条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;
  7. 按照排序结果取前 1000 行返回给客户端。

我们暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还会用到这个排序。

sheng的学习笔记-mysql框架原理_第28张图片

图中“按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。

sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,MySQL 将需要排序的数据分成 12 份,每一份单独排序后存在这些临时文件中。然后把这 12 个有序文件再合并成一个有序的大文件

rowid 排序

在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在 sort_buffer 和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么 sort_buffer 里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。所以如果单行很大,这个方法效率不够好

如果 MySQL 认为排序的单行长度太大,会使用另外一种算法(如果单行超过max_length_for_sort_data配置)

新的算法放入 sort_buffer 的字段,只有要排序的列(即 name 字段)和主键 id。但这时,排序的结果就因为少了 city 和 age 字段的值,不能直接返回了,整个执行流程就变成如下所示的样子:

  1. 初始化 sort_buffer,确定放入两个字段,即 name 和 id;
  2. 从索引 city 找到第一个满足 city='杭州’条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、id 这两个字段,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到不满足 city='杭州’条件为止,也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 进行排序;
  7. 遍历排序结果,取前 1000 行,并按照 id 的值回到原表中取出 city、name 和 age 三个字段返回给客户端。

这个执行流程的示意图如

sheng的学习笔记-mysql框架原理_第29张图片

对比全字段排序,会发现这种算法多了步骤7的回表

全字段排序 VS rowid 排序

如果 MySQL 实在是担心排序内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。

如果 MySQL 认为内存足够大,会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。

对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。

因为数据是无序的,所以需要在内存或者临时文件辅助,但如果数据是有序的,就不需要扫描全表并在内存排序,所以如果建立city,name的联合索引,保证city索引读出来的数据天然按照name排序,会加快搜索速度。查询和建立索引如下

alter table t add index city_user(city, name);

select city,name,age from t where city='杭州' order by name limit 1000  ;

sheng的学习笔记-mysql框架原理_第30张图片

在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足 city='杭州’的记录,并且额外确保了,接下来按顺序取“下一条记录”的遍历过程中,只要 city 的值是杭州,name 的值就一定是有序的。这样整个查询过程的流程就变成了:

sheng的学习笔记-mysql框架原理_第31张图片

explain 的结果来印证一下

从图中可以看到,Extra 字段中没有 Using filesort 了,也就是不需要排序了。而且由于 (city,name) 这个联合索引本身有序,所以这个查询也不用把 4000 行全都读一遍,只要找到满足条件的前 1000 条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描 1000 次。

再进一步优化

针对这个查询,我们可以创建一个 city、name 和 age 的联合索引,对应的 SQL 语句如下。根据覆盖索引(覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据)

alter table t add index city_user_age(city, name, age);

对于 city 字段的值相同的行来说,还是按照 name 字段的值递增排序的,此时的查询语句也就不再需要排序了。这样整个查询语句的执行流程就变成了:

  1. 从索引 (city,name,age) 找到第一个满足 city='杭州’条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
  2. 从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
  3. 重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city='杭州’条件时循环结束。

sheng的学习笔记-mysql框架原理_第32张图片

可以看到,Extra 字段里面多了“Using index”,表示的就是使用了覆盖索引,性能上会快很多

order by rand()--优先队列排序算法

order by rand()场景

英语学习 App 首页有一个随机显示单词的功能,也就是根据每个用户

的级别有一个单词表,然后这个用户每次访问首页的时候,都会随机滚动显示三个单词。他们发现随着单词表变大,选单词这个逻辑变得越来越慢,甚至影响到了首页的打开速度。

设计的想法:去掉每个级别的用户都有一个对应的单词表这个逻辑,直接就是从一个单词表中随机选出三个单词。这个表的建表语句和初始数据的命令如下:

mysql> CREATE TABLE `words` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `word` varchar(64) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=0;
  while i<10000 do
    insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
    set i=i+1;
  end while;
end;;
delimiter ;

call idata();

order by rand()实现逻辑:

内存临时表

这个语句的意思很直白,随机排序取前 3 个

mysql> select word from words order by rand() limit 3;

对于内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,根本不会导致多访问磁盘。优化器没有了这一层顾虑,那么它会优先考虑的,就是用于排序的行越小越好了,所以,MySQL 这时就会选择 rowid 排序(不会选择全字段排序)。

这条语句的执行流程是这样的:

  1. 创建一个临时表。这个临时表使用的是 memory 引擎,表里有两个字段,第一个字段是 double 类型,为了后面描述方便,记为字段 R,第二个字段是 varchar(64) 类型,记为字段 W。并且,这个表没有建索引。
  2. 从 words 表中,按主键顺序取出所有的 word 值。对于每一个 word 值,调用 rand() 函数生成一个大于 0 小于 1 的随机小数,并把这个随机小数和 word 分别存入临时表的 R 和 W 字段中,到此,扫描行数是 10000。
  3. 现在临时表有 10000 行数据了,接下来你要在这个没有索引的内存临时表上,按照字段 R 排序。
  4. 初始化 sort_buffer。sort_buffer 中有两个字段,一个是 double 类型,另一个是整型。
  5. 从内存临时表中一行一行地取出 R 值和位置信息,分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000。
  6. 在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。
  7. 排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出 word 值,返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了 20003。

sheng的学习笔记-mysql框架原理_第33张图片

order by rand() 使用了内存临时表,内存临时表排序的时候使用了 rowid 排序方法

磁盘临时表

如果临时表大小超过了 tmp_table_size,那么内存临时表就会转成磁盘临时表。当使用磁盘临时表的时候,对应的就是一个没有显式索引的 InnoDB 表的排序过程。

这个 SQL 语句的排序没有用到临时文件,采用是 MySQL 5.6 版本引入的一个新的排序算法,即:优先队列排序算法。

我们现在的 SQL 语句,只需要取 R 值最小的 3 个 rowid。但是,如果使用归并排序算法的话,虽然最终也能得到前 3 个值,但是这个算法结束后,已经将 10000 行数据都排好序了。会浪费很多计算量

优先队列算法,流程如下:

  1. 对于这 10000 个准备排序的 (R,rowid),先取前三行,构造成一个堆;(对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组)
  2. 取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);
  3. 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。

sheng的学习笔记-mysql框架原理_第34张图片

模拟 6 个 (R,rowid) 行,通过优先队列排序找到最小的三个 R 值的行的过程。整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类

全局锁

全局锁

全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁使用场景

全局锁的典型使用场景是,做全库逻辑备份(mysqldump)。重新做主从时候。也就是把整库每个表都 select 出来存成文本。以前有一种做法,是通过 FTWRL 确保不会有其他线程对数据库做更新,然后对整个库做备份。注意,在备份过程中整个库完全处于只读状态。

数据库只读状态的危险性:

  • 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就能停止。
  • 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。

注:上面逻辑备份,是不加 –single-transaction 参数

看来加全局锁不太好。但是细想一下,备份为什么要加锁呢?来看一下不加锁会有什么问题?

为什么需要全局读锁(FTWRL)

可能有的人在疑惑,官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性快照视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。

为什么还需要 FTWRL 呢?
一致性读是好,但前提是引擎要支持这个隔离级别。 比如,对于 MyISAM 这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL 命令了。

所以,single-transaction 方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过 FTWRL 方法。这往往是 DBA 要求业务开发人员使用 InnoDB 替代 MyISAM 的原因之一。

全局锁两种方法

FLUSH TABLES WRITE READ LOCK
set global readonly=true
既然要全库只读,为什么不使用 set global readonly=true 的方式呢?确实 readonly 方式也可以让全库进入只读状态,但我还是会建议你用 FTWRL 方式,主要有几个原因:

  • 在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,我不建议你使用。
  • 在异常处理机制上有差异。如果执行FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
  • readonly 对super用户权限无效
     

表级锁

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。

与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

举个例子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。

另一类表级的锁是 MDL(metadata lock)

MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

行锁

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放

死锁和死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。

sheng的学习笔记-mysql框架原理_第35张图片

这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:

  1. 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
  2. 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。

但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。

所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且 innodb_deadlock_detect 的默认值本身就是 on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。

一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的。

另一个思路是控制并发度。根据上面的分析,你会发现如果并发能够控制住,比如同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端做并发控制。但是,你会很快发现这个方法不太可行,因为客户端很多。我见过一个应用,有 600 个客户端,这样即使每个客户端控制到只有 5 个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到 3000。

InnoDB引擎存储结构

InnoDB总体结构

首先我们来看官网的一张图(图片来源于MySQL官网):

sheng的学习笔记-mysql框架原理_第36张图片

从上图中可以看出其主要分为两部分结构,一部分为内存中的结构(上图左边),一部分为磁盘中的结构(上图右边)

内存结构

InnoDB内存中的结构主要分为:Buffer Pool,Change Buffer和Log Buffer三部分。

Buffer Pool

Buffer Pool是InnoDB缓存表和索引的一块主内存区域,Buffer Pool允许直接从内存中处理经常使用的数据,从而加快处理速度,带来一定的性能提升。 但是缓存总有放满的时候,当缓存满了新来的数据怎么处理呢?Bufer Pool中采用的是LRU(least recently used,最近最少使用)算法,LRU列表中最前面存的是高频使用页,尾部放的是最少使用的页。当有新数据过来而缓存满了就会覆盖尾部数据。

假如我们有一条查询语句非常大,返回的结果集直接就超过了Buffer Pool的大小,而这种语句使用场景又是极少的,可能查询这一次之后很久不会查询,而这一次就将缓存占满了,将一些热点数据全部覆盖了。为了避免这种情况发生,InnoDB对传统的LRU算法又做了改进,将LRU列表分拆分为2个,如下图(图片来源于MySQL官网):

sheng的学习笔记-mysql框架原理_第37张图片

该算法在new子列表中保留大量页面(5/8),old子列表包含较少使用的页面(3/8);old子列表中数据可能会被覆盖,该算法具体操作如下:

  • 3/8的Buffer Pool空间用于old子列表
  • 列表的中点是new子列表的尾部与old子列表的头部之间的边界
  • 当InnoDB将一个页面读入缓冲池时,它首先将它插入到中间点(old子列表的头)。读取的页面是由用户发起的操作(比如SQL查询)或InnoDB自动执行的预读操作
  • 访问old子列表中的页面使其“young”,并将其移动到new子列表的头部。如果读取的页是由用户发起的操作,那么就会立即进行第一次访问,并使页面处于young状态;如果读取的页是由预读发起的操作,那么第一次访问不会立即发生,而且可能直到覆盖都不会发生。
  • 操作数据时,Buffer Pool中未被访问的页会逐渐移到尾部,最终会被覆盖。

默认情况下,查询读取的页面会立即移动到新的子列表中,这意味着它们在缓冲池中停留的时间更长。

Change Buffer

Change Buffer是一种特殊的缓存结构,用来缓存不在Buffer Pool中的辅助索引页, 支持insert, update,delete(DML)操作的缓存(注意,这个在MySQL5.5之前叫Insert Buffer,仅支持insert操作的缓存)。当这些数据页被其他查询加载到Buffer Pool后,则会将数据进行merge到索引数据叶中。

sheng的学习笔记-mysql框架原理_第38张图片

InnoDB在进行DML操作非聚集非唯一索引时,会先判断要操作的数据页是不是在Buffer Pool中,如果不在就会先放到Change Buffer进行操作,然后再以一定的频率将数据和辅助索引数据页进行merge。这时候通常都能将多个操作合并到一次操作,减少了IO操作,尤其是辅助索引的操作大部分都是IO操作,可以大大提高DML性能。

如果Change Buffer中存储了大量的数据,那么可能merge操作会需要消耗大量时间。

需要说明的是,虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。

change buffer 用的是 buffer pool 里的内存,因此不能无限增大。change buffer 的大小,可以通过参数 innodb_change_buffer_max_size 来动态设置。这个参数设置为 50 的时候,表示 change buffer 的大小最多只能占用 buffer pool 的 50%。

为什么Change Buffer只能针对非聚集非唯一索引

因为如果是主键索引或者唯一索引,需要判断数据是否唯一,这时候就需要去索引页中加载数据判断而不能仅仅只操作缓存。

Change Buffer什么时候会merge

总体来说,Change Buffer的merge操作发生在以下三种情况:

  • 辅助索引页被读取到Buffer Pool时。 当执行一条select语句时,会去检查当前数据页是否在Change Buffer中,如果在,就会把数据merge到索引页
  • 该辅助索引页没有可用空间时。 InnoDB内部会检测辅助索引页是否还有可用空间(至少有1/32页),如果检测到当前操作之后,当前索引页剩余空间不足1/32时,会进行一次强制merge操作
  • 后台线程Master Thread定时merge。 Master Thread是一个非常核心的后台线程,主要负责将缓冲池中的数据异步刷新到磁盘,保证数据的一致性。

Change Buffer在更新流程的使用

如果要在这张表中插入一个新记录 (4,400) 的话,InnoDB 的处理流程是怎样的。

第一种情况是这个记录要更新的目标页在内存中。这时,InnoDB 的处理流程如下:

  1. 对于唯一索引来说,找到 3 和 5 之间的位置,判断到没有冲突,插入这个值,语句执行结束;
  2. 对于普通索引来说,找到 3 和 5 之间的位置,插入这个值,语句执行结束。

这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的 CPU 时间。

第二种情况是这个记录要更新的目标页不在内存中。这时,InnoDB 的处理流程如下:

  1. 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;
  2. 对于普通索引来说,则是将更新记录在 change buffer,语句执行就结束了。

将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。有个案例:一个DBA负责的某个业务的库内存命中率突然从 99% 降低到了 75%,整个系统处于阻塞状态,更新语句全部堵住。而探究其原因后,发现这个业务有大量插入数据的操作,而他在前一天把其中的某个普通索引改成了唯一索引。

Change Buffer使用场景

普通索引的所有场景,因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这种业务模式来说,change buffer 反而起到了副作用。

Adaptive Hash Index(自适应哈希索引)

Adaptive Hash Index,自适应哈希索引。InnoDB引擎会监控对索引页的查询,如果发现建立哈希索引可以带来性能上的提升,就会建立哈希索引,这种称之为自适应哈希索引,InnoDB引擎不支持手动创建哈希索引。

Log Buffer

日志缓冲区是存储要写入磁盘日志文件的一块数据内存区域,大小由变量innodb_log_buffer_size 控制,默认大小为16MB(5.6版本是8MB):

SHOW VARIABLES LIKE 'innodb_log_buffer_size';-- global级别,无session级别

上文讲述update语句更新流程一文中,我们只提到了Buffer Pool用来代替缓存区,通过本文对内存结构的分析,实际上Buffer Pool中严格来说还有Change Buffer,Log Buffer和Adaptive Hash Index三个部分,DML操作会缓存在Change Buffer区域,而写redo log之前会先写入Log Buffer,所以Log Buffer又可以称之为redo Log Buffer。

具体的可以看上文  持久化机制=》日志系统-redo log

redo log与change buffer区别

我们在表上执行这个插入语句:

mysql> insert into t(id,k) values(id1,k1),(id2,k2);

这里,我们假设当前 k 索引树的状态,查找到位置后,k1 所在的数据页在内存 (InnoDB buffer pool) 中,k2 所在的数据页不在内存中。如图 2 所示是带 change buffer 的更新状态图。

sheng的学习笔记-mysql框架原理_第39张图片

                      图 2 带 change buffer 的更新过程

分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1 在内存中,直接更新内存;
  2. Page 2 没有在内存中,就在内存的 change buffer 区域,记录下“我要往 Page 2 插入一行”这个信息
  3. 将上述两个动作记入 redo log 中(图中 3 和 4)。

做完上面这些,事务就可以完成了。

所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。

然后做读请求,现在要执行 select * from t where k in (k1, k2)。这里,我画了这两个读请求的流程图。

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,我在图中就没画出这两部分。

sheng的学习笔记-mysql框架原理_第40张图片

图 3 带 change buffer 的读过程

从图中可以看到:

  1. 读 Page 1 的时候,直接从内存返回。有个问题是:WAL(Write-Ahead Logging) 之后如果读数据,是不是一定要读盘,是不是一定要从 redo log 里面把数据更新以后才可以返回?其实是不用的。你可以看一下图 3 的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
  2. 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。可以看到,直到需要读 Page 2 的时候,这个数据页才会被读入内存。

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗

磁盘结构

InnoDB引擎的磁盘结构,从大的方面来说可以分为Tablespace和redo log两部分

Tablespace

Tablespace可以分为4大类,分别是:System Tablespace,File-Per-Table Tablespaces,General Tablespaces,Undo Tablespaces

System Tablespace

系统表空间中包括了 InnoDB data dictionary,doublewrite buffer, change buffer, undo logs 4个部分,默认情况下InnoDB存储引擎有一个共享表空间ibdata1,如果我们创建表没有指定表空间,则表和索引数据也会存储在这个文件当中,可以通过一个变量控制(后面会介绍)。

ibdata1文件默认大小为12MB,可以通过变量innodb_data_file_path来控制,改变其大小的最好方式就是设置为自动扩展。

innodb_data_file_path=ibdata1:12M:autoextend

上面表示默认表空间ibdata1大小为12MB,支持自动扩展大小。

当们的文件达到一定的大小之后,比如达到了998MB,我们就可以另外开启一个表空间文件:

innodb_data_home_dir=
innodb_data_file_path=/ibdata/ibdata1:988M;/disk2/ibdata2:50M:autoextend

关于上面的设置有3点需要注意:

  • innodb_data_home_dir如果不设置的话,那么就默认所有的表空间文件都在datadir目录下,而我们上面指定了2个不同路径,所以需要把innodb_data_home_dir设为空
  • autoextend这个属性,只能放在最后一个文件
  • 指定新的表空间文件名的时候,不能和现有表空间文件名一致,否则启动MySQL时会报错

当然,表空间可以增大,自然也可以减少,但是一般我们都不会去设置减少,而且减少表空间也相对麻烦,在这里就不展开叙述了。

InnoDB Data Dictionary

InnoDB数据字典由内部系统表组成,其中包含用于跟踪对象(如表、索引和表列)的元数据。元数据在物理上位于InnoDB系统表空间中。由于历史原因,数据字典元数据在某种程度上与存储在InnoDB表元数据文件(.frm文件)中的信息重叠。

Doublewrite Buffer

Doublewrite Buffer,双写缓冲区,这个是InnoDB为了实现double write而设置的一块缓冲区,double write和上面的change buffer一个确保了可靠性,一个确保了性能的提升,是InnoDB中非常重要的两大特性。

我们先来看下面一张图:

sheng的学习笔记-mysql框架原理_第41张图片

InnoDB默认页的大小是16KB,而操作系统是4KB,如果存储引擎正在写入页的数据到磁盘时发生了宕机,可能出现页只写了一部分的情况,比如只写了 4K,这种情况叫作部分写失效(partial page write),可能会导致数据丢失。

可能有人会说,可以通过redo log来恢复,但是注意,redo log恢复数据有一个前提,那就是页没有损坏,如果页本身已经被损坏了,那么是没办法恢复的,所以为了确保万无一失,我们需要先保存一个页的副本,如果出现了上面的极端情况,可以用页的副本结合redo log来恢复数据,这就是double write技术。

double write也是由两部分组成,一部分是内存中的double write buffer,大小为2MB,另一部分是物理磁盘上的共享表空间中的连续128个页,大小也是2MB,写入流程如下图(图片来源于《MySQL技术内幕 InnoDB存储引擎》):

sheng的学习笔记-mysql框架原理_第42张图片

double write机制会使得数据写入两次磁盘,但是其并不需要两倍的I/O开销或两倍的I/O操作。通过对操作系统的单个fsync()调用,数据以一个大的顺序块的形式写入到双写入缓冲区。

在大多数情况下默认启用了doublewrite缓冲区。要禁用doublewrite缓冲区,可通过将变量innodb_doublewrite设置为0即可。

Undo Logs

undo log记录了单个事务对聚集索引数据记录的最近一次修改信息,用来保证在必要时实现回滚,如果另一个事务需要在一致性读操作中查看原始数据,则从undo日志记录中检索未修改的数据,也就是说MVCC机制也依赖于undo log来实现。

与redo log不同的是,undo log存储的是逻辑日志,undo log分为两种类型:

  • insert undo log 由insert操作产生,由于插入数据操作只对当前事务可见,所以事务提交之后可以直接删除
  • update undo log 由update和delete操作产生,由于要实现MVCC多版本并发控制,故而update undo log在事务提交之后不能直接删除,而是最后由后台线程(Purge Thread或者Master Thread)来最终判断是否可以删除

undo log具体的可以看上文  持久化机制=》日志系统-undo log

File-Per-Table Tablespaces

独占表空间,通过变量innodb_file_per_table控制,在MySQL5.6开始,默认是开启的

innodb_file_per_table=ON

开启后,则每张表会开辟一个表空间,这个文件就是数据目录下的 ibd 文件,不同引擎生成的文件不一样。独占表空间存放表的索引和数据,其他数据如回滚(undo)信息,插入缓冲索引页、系统事务信息,二次写缓冲(Double write buffer)等还是存放在原来的共享表空间内。

General Tablespaces

General Tablespaces,通用表空间,和系统表空间idata1类似,一般指的是我们自己使用CREATE tablespace语法创建的共享InnoDB表空间。 创建语法为:

CREATE TABLESPACE tablespace_name
ADD DATAFILE 'file_name'
[FILE_BLOCK_SIZE = value]
[ENGINE [=] engine_name]

在数据目录中创建一个通用表空间:

CREATE TABLESPACE `ts1` ADD DATAFILE 'ts1.ibd' Engine=InnoDB;

在数据目录之外创建一个通用表空间:

CREATE TABLESPACE `ts1` ADD DATAFILE '/my/tablespace/directory/ts1.ibd' Engine=InnoDB;

然后我们再创建或者修改表的时候可以指定为创建的通用表空间:

CREATE TABLE t1 (c1 INT PRIMARY KEY) TABLESPACE ts1;
ALTER TABLE t2 TABLESPACE ts1;

Undo Tablespaces

Undo表空间包含Undo日志。Undo日志可以存储在一个或多个Undo表空间中,而不是系统表空间中。这种布局不同于默认配置,在默认配置中,undo log保存在系统表空间中。

Undo表空间的数量由innodb_undo_tablespaces变量定义。默认值是0:

SELECT @@innodb_undo_tablespaces;

Temporary Tablespace

在共享临时表空间中创建非压缩的或者用户创建的临时表和磁盘上的内部临时表会存储在临时表空间。innodb_temp_data_file_path配置选项定义临时表空间数据文件的相对路径、名称、大小和属性。如果没有为innodb_temp_data_file_path指定值,默认行为是在innodb_data_home_dir目录中创建一个名为ibtmp1的自动扩展数据文件,该文件略大于12MB。

临时表空间在正常关闭或初始化失败时被删除,并在每次服务器启动时重新创建。临时表空间在创建时会动态生成一个空间ID。如果无法创建临时表空间,MySQL会拒绝启动。如果服务器意外停止,临时表空间不会被删除。在这种情况下,我们可以进行手动删除临时表空间,或者重新启动服务器,从而自动删除和重新创建临时表空间。

可以通过如下语句查询临时表空间信息:

SELECT * FROM INFORMATION_SCHEMA.FILES WHERE TABLESPACE_NAME='innodb_temporary';

默认情况下,临时表空间数据文件会自动扩展和增加大小,以适应磁盘上的临时表,临时表的大小和ibdata1文件一样可以通过变量修改。

SHOW VARIABLES LIKE 'innodb_temp_data_file_path';-- 默认12MB大,可扩展
innodb_temp_data_file_path=ibtmp1:12M:autoextend

为了防止临时数据文件变得太大,可以配置innodb_temp_data_file_path选项来指定最大文件大小。例如:

innodb_temp_data_file_path=ibtmp1:12M:autoextend:max:500M

当数据文件达到最大时,查询失败,并显示临时表已满的错误。

Redo Log

默认情况下,InnoDB存储引擎至少有一个重做日志文件组,每个组下面至少有两个文件,如默认的ib_logfile0和ib_logfile1。MySQL以循环方式写入重做日志文件。也就是说redo log文件的个数和大小是固定的,并不会增大。

具体的可以看上文  持久化机制=》日志系统-redo log

主备同步

日常工作中,MySQL数据库是必不可少的存储,其中读写分离基本是标配,而这背后需要MySQL开启主从同步,形成一主一从、或一主多从的架构

备库一般都设置为readonly状态,可以防止误操作及切换过程中出现双写造成主备不一致。还可以通过readonly状态来判断节点的角色。readonly不会影响主备同步,因为readonly对超级用户(root权限)是无效的,用于同步更新的线程就属于root权限
 

常见的两种主备切换方式

M-S结构

M-S结构,两个节点,一个当主库、一个当备库,不允许两个节点互换角色

sheng的学习笔记-mysql框架原理_第43张图片

在状态1中,客户端的读写都直接访问节点A,而节点B是A的备库,只是将A的更新都同步过来,到本地执行。这样可以保持节点B和A的数据是相同的。

当需要切换的时候,就切成状态2。这时候客户端读写访问的都是节点B,而节点A是B的备库。

双M结构

双M结构,两个节点,一个当主库,一个当备库,允许两个节点互换角色

sheng的学习笔记-mysql框架原理_第44张图片

        对比前面的M-S结构图,可以发现,双M结构和M-S结构,其实区别只是多了一条线,即节点A和B之间总是互为主备关系。这样在切换的时候就不用再修改主备关系。

双M主备结构下的循环复制问题:

在实际生产使用中,多数情况是使用双M结构的。但是,双M结构还有一个问题需要解决。

业务逻辑在节点A执行更新,会生成binlog并同步到节点B。节点B同步完成后,也会生成binlog。(log_slave_updates设置为on,表示备库也会生成binlog)。

当节点A同时也是节点B的备库时,节点B的binlog也会发送给节点A,造成循环复制。

解决办法:

  1. 设置节点的server-id,必须不同,不然不允许设置为主备结构
  2. 备库在接到binlog后重放时,会记录原记录相同的server-id,即谁产生即为谁的。
  3. 每个节点在接受binlog时,会判断server-id,如果是自己的就丢掉。

解决后的流程:

  1. 业务逻辑在节点A执行更新,会生成带有节点A的server-id的binlog。
  2. 节点B接受到节点A发过来的binlog,并执行完成后,会生成带有节点A的server-id的binlog。
  3. 节点A接受到binlog后,发现是自己的,就丢掉。死循环就在这里断掉了。

MySQL主从复制原理:

sheng的学习笔记-mysql框架原理_第45张图片

 从图中可见,主库A上有三个线程:处理客户端读写请求的线程、bg_thread后台线程用于进行持久化、dump_thread用于传递binlog给备库;
备库B上有两个线程:io_thread用于处理与主库之间的网络连接和IO操作、sql_thread用于读取中转日志,解析出日志中的命令并执行。

一个事务日志同步的完整过程是这样的:

  1. 在备库B上通过 change master 命令,设置主库A的IP、端口号、用户名、密码,以及要从哪个位置开始请求binlog,这个位置包含的文件名和日志偏移量offset;(此时只是设立建立连接需要的信息,包括IP、port等,还未真正发起连接,连接的建立需要后面的io_thread线程来处理)
  2. 在备库B上执行 start slave 命令,此时备库B会启动两个线程,即:io_thread 和 sql_thread。 其中 io_thread 负责与主库建立网络连接;
  3. 主库A校验用户名、密码后,开始按照备库B传过来的位置,从本地读取binlog,并将其发送给备库B;
  4. 备库B接收到binlog后,将其写到本地文件,称为中转日志 relay log;
  5. 备库B上的 sql_thread 线程读取中转日志 relay_log,解析出日志中的命令,并执行。

注:在后续的MySQL版本中,sql_thread 演化为多线程,但处理流程是不变的。
 

同步机制

同步分三种同步机制:

  • 异步复制(默认
  • 半同步复制
  • 全同步复制

异步复制

MySQL默认的复制就是异步复制,主库在执行完客户端提交的事务后会立即将结果返回给客户端,并不关心从库是否已经接收并处理。主库将事务 Binlog 事件写入到 Binlog 文件中,此时主库只是通知 Dump 线程发送这些新的 Binlog,然后主库就会继续处理提交操作,并不保证这些 Binlog 传到任何一个从库节点上。这样就会存在一个问题,如果主库出现故障,此时主库已经提交的事务可能并没有传到从库上,可能导致数据丢失。

全同步复制

当主库提交事务之后,所有的从库节点必须收到、APPLY并且提交这些事务,然后主库线程才能继续做后续操作。因为需要等待所有从库执行完该事务才能返回,所以全同步复制的性能必然会收到严重的影响。        

半同步复制(推荐用这个)

半同步复制是从Mysql5.5版本开始,以插件的形式支持的,默认情况下是关闭的,使用时需在配置中打开。

Mysql默认的复制是异步的,主库在将事件写入binlog后即成功返回客户端,但并不知道从库是否以及何时会获取和处理日志。而至于半同步复制,主库在提交后执行事务提交的线程将一直等待,直到至少有一个半同步从库确认已接收到所有事件,从库仅在将事件写入其中继日志(relay log)并刷新到磁盘后,才对接收到事务的事件进行确认。此时,主库收到确认后才会对客户端进行响应。半同步复制保证了事务成功提交后,至少有两份日志记录,一份在主库的binlog上,另一份在至少一个从库的中继日志relay log上,这样就进一步保证了数据的完整性。

 

半同步复制的特点:

(1)从库会在连接到主库时告诉主库,它是不是配置了半同步。
(2)如果半同步复制在主库端开启,并且至少有一个半同步复制的从库节点,那么此时主库的事务线程在提交时会被阻塞并等待,结果有两种可能:(a)至少一个从库节点通知它已经收到了所有这个事务的Binlog事件;(b)一直等待直到超过配置的某一个时间点为止,此时,半同步复制将自动关闭,转换为异步复制。
(3)从库节点只有在接收到某一个事务的所有 Binlog,将其写入到 Relay Log 文件之后,才会通知对应主库上面的等待线程。
(4)如果在等待过程中,等待时间已经超过了配置的超时时间,没有任何一个从节点通知当前事务,那么此时主库会自动转换为异步复制,当至少一个半同步从节点赶上来时,主库便会自动转换为半同步方式的复制。
(5)半同步复制必须是在主库和从库两端都开启时才行,如果在主库上没打开,或者在主库上开启了而在从库上没有开启,主库都会使用异步方式复制。
 

备库并行复制能力

日志在备库上的执行,就是备库上 sql_thread 更新数据 (DATA) 的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。

sql_thread在备库并行中拆成了多个线程,不处理逻辑,仅仅用于转发。coordinator 就是原来的 sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了 worker 线程

coordinator 在分发的时候,需要满足以下这两个基本要求:

  • 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中。
  • 同一个事务不能被拆开,必须放到同一个 worker 中。

sheng的学习笔记-mysql框架原理_第46张图片

Mysql5.6中的并行复制

MySQL 5.5版本不支持并行复制。MySQL 5.6版本开始支持并行复制,但是其并行只是基于schema的,也就是基于库的。当有多个库时多个库可以并行进行复制,而库与库之间互不干扰。但多数情况下,可能只有单schema,即只有单个库,那基于schema的复制就没什么用了。

其核心思想是:不同schema下的表并发提交时的数据不会相互影响,即slave节点可以用对relay log中不同的schema各分配一个类似SQL功能的线程,来重放relay log中主库已经提交的事务,保持数据与主库一致。

Mysql5.7中的并行复制

MySQL 5.7是通过对事务进行分组,当事务提交时,它们将在单个操作中写入到二进制日志中。如果多个事务能同时提交成功,那么它们意味着没有冲突,因此可以在Slave上并行执行,所以通过在主库上的二进制日志中添加组提交信息。
MySQL 5.7的并行复制基于一个前提,即所有已经处于prepare阶段的事务,都是可以并行提交的。这些当然也可以在从库中并行提交,因为处理这个阶段的事务都是没有冲突的。在一个组里提交的事务,一定不会修改同一行。这是一种新的并行复制思路,完全摆脱了原来一直致力于为了防止冲突而做的分发算法,等待策略等复杂的而又效率底下的工作

MySQL8.0 并行复制

MySQL8.0 是基于write-set的并行复制。MySQL会有一个集合变量来存储事务修改的记录信息(主键哈希值),所有已经提交的事务所修改的主键值经过hash后都会与那个变量的集合进行对比,来判断改行是否与其冲突,并以此来确定依赖关系,没有冲突即可并行。这样的粒度,就到了 row级别了,此时并行的粒度更加精细,并行的速度会更快。

主备延迟

同步延迟

与数据同步有关的时间点主要包括以下三个:

主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;

之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;

备库 B 执行完成这个事务,我们把这个时刻记为 T3。

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。你可以在备库上执行 show slave status 命令,它的返回结果里面会显示seconds_behind_master,用于表示当前备库延迟了多少秒。

主备库机器的系统时间设置不一致,不会导致主备延迟的值不准。因为,备库连接到主库的时候,会通过执行 SELECT UNIX_TIMESTAMP() 函数来获得当前主库的系统时间。如果这时候发现主库的系统时间与自己不一致,备库在执行 seconds_behind_master 计算的时候会自动扣掉这个差值。

seconds_behind_master,用于表示当前备库延迟了多少秒

主备延迟的来源

1)有些部署条件下,备库所在机器的性能要比主库所在的机器性能差。这种部署现在比较少了。因为主备可能发生切换,备库随时可能变成主库,所以主备库选用相同规格的机器,并且做对称部署,是现在比较常见的情况。

2)备库的压力大。一般的想法是,主库既然提供了写能力,那么备库可以提供一些读能力。或者一些运营后台需要的分析语句,不能影响正常业务,所以只能在备库上跑。可以参考这些方案:

  • 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
  • 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。

3)大事务。主库上必须等事务执行完成才会写入 binlog,再传给备库。所以,如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10 分钟。

切换优先策略

可靠性优先策略(一般用这个)

在图 1 的双 M 结构下,从状态 1 到状态 2 切换的详细过程是这样的:

  1. 判断备库 B 现在的 seconds_behind_master(用于表示当前备库延迟了多少秒),如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
  2. 把主库 A 改成只读状态,即把 readonly 设置为 true;
  3. 判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止;
  4. 把备库 B 改成可读写状态,也就是把 readonly 设置为 false;
  5. 把业务请求切到备库 B。

这个切换流程,一般是由专门的 HA 系统来完成的,我们暂时称之为可靠性优先流程。

sheng的学习笔记-mysql框架原理_第47张图片

图中的 SBM,是 seconds_behind_master 参数的简写        

可用性优先策略

如果我强行把步骤 4、5 调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库 B,并且让备库 B 可以读写,那么系统几乎就没有不可用时间了。我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。

下图是可用性优先策略,且 binlog_format=mixed 时的切换流程和数据结果

过程如下:

  1. 步骤 2 中,主库 A 执行完 insert 语句,插入了一行数据(4,4),之后开始进行主备切换。
  2. 步骤 3 中,由于主备之间有 5 秒的延迟,所以备库 B 还没来得及应用“插入 c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。
  3. 步骤 4 中,备库 B 插入了一行数据(4,5),并且把这个 binlog 发给主库 A。
  4. 步骤 5 中,备库 B 执行“插入 c=4”这个中转日志,插入了一行数据(5,4)。而直接在备库 B 执行的“插入 c=5”这个语句,传到主库 A,就插入了一行新数据(5,5)。

最后的结果就是,主库 A 和备库 B 上出现了两行不一致的数据。可以看到,这个数据不一致,是由可用性优先流程导致的。

sheng的学习笔记-mysql框架原理_第48张图片

如果使用可用性优先策略,但设置 binlog_format=row,因为 row 格式在记录 binlog 的时候,会记录新插入的行的所有字段值,所以最后只会有一行不一致。而且,两边的主备同步的应用线程会报错 duplicate key error 并停止。也就是说,这种情况下,备库 B 的 (5,4) 和主库 A 的 (5,5) 这两行数据,都不会被对方执行。

sheng的学习笔记-mysql框架原理_第49张图片

其他

为什么表数据删掉一半,表文件大小不变

如果我的数据库占用空间太大,我把一个最大的表删掉了一半的数据,怎么表文件的大小还是没变?

参数 innodb_file_per_table

表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:

  • 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
  • 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。

从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。我建议你不论使用 MySQL 的哪个版本,都将这个值设置为 ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

数据删除流程

我们先再来看一下 InnoDB 中一个索引的示意图。

sheng的学习笔记-mysql框架原理_第50张图片

假设,我们要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。现在,你已经知道了 InnoDB 的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记录,会怎么样?答案是,整个数据页就可以被复用了。但是,数据页的复用跟记录的复用是不同的

记录的复用,只限于符合范围条件的数据。比如上面的这个例子,R4 这条记录被删除后,如果插入一个 ID 是 400 的行,可以直接复用这个空间。但如果插入的是一个 ID 是 800 的行,就不能复用这个位置了

如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。

进一步地,如果我们用 delete 命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。

你现在知道了,delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

实际上,不止是删除数据会造成空洞,插入数据也会。如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。假设图 1 中 page A 已经满了,这时我要再插入一行数据,会怎样呢?

sheng的学习笔记-mysql框架原理_第51张图片

可以看到,由于 page A 满了,再插入一个 ID 是 550 的数据时,就不得不再申请一个新的页面 page B 来保存数据了。页分裂完成后,page A 的末尾就留下了空洞(注意:实际上,可能不止 1 个记录的位置是空洞)。

另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。

也就是说,经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。而重建表,就可以达到这样的目的。

MYSQL内使用的算法

快速排序

使用场景

在order by的场景下,如果使用内存进行排序(详细参考上面  “order by”排序方式 =>全字段排序 内容),在内存中对所有数据按照指定字段进行排序,采用快速排序

快速排序概念

快速排序(Quick Sort)是从冒泡排序算法演变而来的,实际上是在冒泡排序基础上的递归分治法。快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分

大致步骤如下:

  1. 首先设置一个分界值也就是基准值又是也称为监视哨,通过该分界值将数据分割成两部分。
  2. 将大于或等于分界值的数据集中到右边,小于分界值的数据集中到左边。一趟排序过后,左边部分中各个数据元素都小于分界值,而右边部分中各数据元素都大于或等于分界值,且右边部分个数据元素皆大于左边所有数据元素。
  3. 然后,左边和右边的数据可以看成两组不同的部分,重复上述1和2步骤,当左右两部分都有序时,整个数据就完成了排序。

算法具体见:https://blog.csdn.net/coldstarry/article/details/125438331

归并排序

使用场景

在order by的场景下,如果使用了外部文件进行排序(详细参考上面  “order by”排序方式 => rowid 排序 内容),分成多个文件,每个文件都是排序好的,在对多个文件进行merge时,采用归并排序

归并排序概念:

归并是这样一种概念,它针对两个或者多个有序的数组,是合并这多个有序数组并进行排序的一种手段,它的主要处理方法是每次都找出比较各个数组的首个元素(假设从左边开始排序而且是升序的方式),找出他们之间的最小值,将其拷贝到一个新的数组上,依次类推直到所有元素处理完,看说明图:

sheng的学习笔记-mysql框架原理_第52张图片

 算法具体见:https://blog.csdn.net/coldstarry/article/details/125438342

优先队列排序算法

使用场景:

在随机排序select word from words order by rand() limit 3 这种,如果数据大于设置,MYSQL用的是优先队列排序算法(而不是使用多个文件的归并排序)(详细参考上面  order by rand()--优先队列排序算法 的章节部分)

优先队列排序算法概念:

优先队列是利用堆来实现的
堆可以看做的一颗完全二叉树的顺序存储结构,(大顶堆:每个结点的值都大于等于左右孩子的值)

优先队列的两个基本操作:(都在维护堆序性
出队:堆顶出队,最后一个记录,代替堆顶的位置,重新调整为堆
入队:将新元素放入树中的末尾,再调整为堆

sheng的学习笔记-mysql框架原理_第53张图片

算法具体见:https://blog.csdn.net/coldstarry/article/details/125474745

参考文章

MySql RedoLog_格里斯的博客-CSDN博客_mysql redolog

https://www.jb51.net/article/218148.htm

MySQL之redo log - 知乎

https://www.jb51.net/article/199344.htm

Mysql中的redo log_你的酒窝里有酒的博客-CSDN博客_mysql redolog

百度安全验证

mysql 的 归并排序_归并与归并排序_桃子酒不加冰的博客-CSDN博客

十大经典排序算法-快速排序算法详解_小小学编程的博客-CSDN博客_快速排序算法

MySQL是如何实现主备同步_Mysql_数据库 - 编程客栈

快速排序(Quick sort)_〖雪月清〗的博客-CSDN博客_快速排序

MySQL主从复制(7)——MySQL的三种复制方式:异步复制、半同步复制、全同步复制_睿思达DBA_WGX的博客-CSDN博客

MySQL的并行复制 - 简书

你可能感兴趣的:(框架分析,mysql,数据库)