总集

逻辑架构

参考:MySQL逻辑架构 - 知乎 (zhihu.com)

连接层

将MySQL服务层通过线程池和TCP连接池,与多个客户端连接。主要工作是:连接处理授权认证安全防护

服务层

服务层用于处理核心服务,如标准的SQL接口、查询解析、SQL优化和统计、全局的和引擎依赖的缓存与缓冲器等等。所有的与存储引擎无关的工作,如过程、函数等,都会在这一层来处理。在该层上,服务器会解析查询并创建相应的内部解析树,并对其完成优化,如确定查询表的顺序,是否利用索引等,最后生成相关的执行操作。

服务层可以细分为几个部分:

  • SQL Interface: SQL接口

    • 接收用户的SQL命令,并且返回用户需要查询的结果。比如SELECT ... FROM就是调用SQL Interface

    • MySQL支持DML(数据操作语言)、DDL(数据定义语言)、存储过程、视图、触发器、自定义函数等多种SQL语言接口

  • Parser: 解析器

    • 在解析器中对 SQL 语句进行语法分析、语义分析。将SQL语句分解成数据结构,并将这个结构传递到后续步骤,以后SQL语句的传递和处理就是基于这个结构的。如果在分解构成中遇到错误,那么就说明这个SQL语句是不合理的。

    • 在SQL命令传递到解析器的时候会被解析器验证和解析,并为其创建语法树,并根据数据字典丰富查询语法树,会验证该客户端是否具有执行该查询的权限。创建好语法树后,MySQL还会对SQL查询进行语法上的优化,进行查询重写。

  • Optimizer: 查询优化器

    • SQL语句在语法解析之后、查询之前会使用查询优化器确定 SQL 语句的执行路径,生成一个执行计划。

    • 这个执行计划表明应该使用哪些索引进行查询(全表检索还是使用索引检索),表之间的连接顺序如何,最后会按照执行计划中的步骤调用存储引擎提供的方法来真正的执行查询,并将查询结果返回给用户。

    • 它使用“选取-投影-连接”策略进行查询。例如:

    SELECT id,name FROM student WHERE gender = '女';

    这个SELECT查询先根据WHERE语句进行选取,而不是将表全部查询出来以后再进行gender过滤。 这个SELECT查询先根据id和name进行属性投影,而不是将属性全部取出以后再进行过滤,将这两个查询条件连接起来生成最终查询结果。

  • Caches & Buffers: 查询缓存组件

    • MySQL内部维持着一些Cache和Buffer,比如Query Cache用来缓存一条SELECT语句的执行结果,如果能够在其中找到对应的查询结果,那么就不必再进行查询解析、优化和执行的整个过程了,直接将结果反馈给客户端。

    • 这个缓存机制是由一系列小缓存组成的。比如表缓存,记录缓存,key缓存,权限缓存等 。

    • 这个查询缓存可以在不同客户端之间共享。

    • 从MySQL5.7.20开始,不推荐使用查询缓存,并在MySQL 8.0中删除

引擎层

存储引擎层,存储引擎负责实际的MySQL数据的存储与提取,服务器通过API 与 存储引擎进行通信。不同的存储引擎功能和特性有所不同,这样可以根据实际需要有针对性的使用不同的存储引擎。

SQL执行流程

MySQL的查询流程:

1. 查询缓存:Server如果在查询缓存中发现了这条SQL语句,就会直接将结果返回给客户端;如果没有,就进入到解析器阶段。需要说明的是,因为查询缓存往往效率不高,所以在MySQL8.0之后就抛弃了这个功能。

查询缓存是提前把查询结果缓存起来,这样下次不需要执行就可以直接拿到结果。需要说明的是,在MySQL中的查询缓存,不是缓存查询计划,而是查询对应的结果。只有相同的查询操作才会命中查询缓存两个查询请求在任何字符上的不同(例如:空格、注释、大小写),都会导致缓存不会命中

同时,如果查询请求中包含某些系统函数、用户自定义变量和函数、一些系统表,如 mysql、 information_schema、 performance_schema 数据库中的表,那这个请求就不会被缓存。

此外,既然是缓存,那就有它缓存失效的时候。MySQL的缓存系统会监测涉及到的每张表,只要该表的结构或者数据被修改,如对该表使用了INSERTUPDATEDELETETRUNCATE TABLEALTER TABLEDROP TABLEDROP DATABASE语句,那使用该表的所有缓存都将变为无效并被删除。对于更新压力大的数据库来说,查询缓存的命中率会非常低。

2. 解析器:在解析器中对SQL语句进行语法分析、语义分析。

分析器先做词法分析。用户输入的是由多个字符串和空格组成的一条SQL语句,MySQL需要识别出里面的字符串分别是什么,代表什么。

接着做语法分析。根据词法分析的结果,语法分析器(比如:Bison)会根据语法规则,判断用户输入的这个SQL语句是否满足MySQL语法。如果SQL语句正确,则会生成一个语法树,否则,报语法错误的信息

3. 优化器:在优化器中会确定SQL语句的执行路径,比如是根据全表检索,还是根据索引检索等。在查询优化器中,可以分为逻辑查询优化阶段和物理查询优化阶段。

物理优化:通过索引、表连接方式等技术进行优化;逻辑优化:通过SQL等价变换提升查询效率。

4. 执行器:在执行之前需要判断该用户是否具备权限。如果没有,就会返回权限错误。如果具备权限,就执行 SQL查询并返回结果。在 MySQL8.0 以下的版本,如果设置了查询缓存,这时会将查询结果进行缓存。

存储引擎

MySQL中的数据用各种不同的技术存储在文件(或者内存)中。这些技术中的每一种技术都使用不同的存储机制、索引技巧、锁定水平并且最终提供广泛的不同的功能和能力。通过选择不同的技术,你能够获得额外的速度或者功能,从而改善你的应用的整体功能。

而存储引擎说白了就是如何存储数据如何为存储的数据建立索引如何更新/查询数据等技术的实现方法。

MySQL 提供了多个存储引擎,在 MySQL 中,不需要在整个服务器中使用同一种存储引擎,针对具体的要求,可以对每一个表使用不同的存储引擎。

常见存储引擎

  • InnoDB:主要的事务存储引擎

    • InnoDB,支持事务、行级锁、自增主键、外键、自动灾难恢复。

    • MySQL5.5之后,成为默认存储引擎。

    • 除非有非常特别的原因需要使用其他的存储引擎,否则应该优先考虑InnoDB引擎。

    • 数据文件结构:

      • 表名.frm 存储表结构(MySQL8.0时,合并在表名.ibd中)

      • 表名.ibd 存储数据和索引

    • 对比MyISAM的存储引擎,InnoDB写的处理效率差一些,并且会占用更多的磁盘空间以保存数据和索引。

  • MyISAM:主要的非事务存储引擎

    • MyISAM提供了大量的特性,包括全文索引、压缩、空间函数(GIS)等,但MyISAM不支持事务、行级锁、外键。

    • 5.5之前默认的存储引擎

    • 优势是访问的速度快,适合对事务完整性没有要求或者以SELECT、INSERT为主的应用

    • 针对数据统计有额外的常数存储。故而 count(*) 的查询效率很高

    • 数据文件结构:

      • 表名.frm 存储表结构

      • 表名.MYD 存储数据 (MYData)

      • 表名.MYI 存储索引 (MYIndex)

    • 应用场景:只读应用或者以读为主的业务

InnoDB和MyISAM对比

对比项

MyISAM

InnoDB

外键

不支持

支持

事务

不支持

支持

行表锁

表锁,即使操作一条记录也会锁住整个表,不适合高并发的操作

行锁,操作时只锁某一行,不对其它行有影响,适合高并发的操作

缓存

只缓存索引,不缓存真实数据

不仅缓存索引还要缓存真实数据,对内存要求较高,而且内存大小对性能有决定性的影响

自带系统表使用

关注点

性能:节省资源、消耗少、简单业务

事务:并发写、事务、更大资源

默认安装

默认使用

InnoDB详解

参考:mysql存储引擎InnoDB详解,从底层看清InnoDB数据结构 - 知乎 (zhihu.com)

数据库存储结构

数据是存放在磁盘上的,会先读取到内存中再由CPU进行操作。由于磁盘读取速度很慢,如果每次操作都只读取一行数据的话,磁盘IO量会非常高,导致性能很差。因此InnoDB将磁盘中的所有数据划分为若干个页(page),以页作为磁盘与内存交互的基本单位,一般页的大小为16KB页与页之间通过双向链表进行连接

这主要是基于两种维度进行考量:

  1. 时间维度:如果一条数据正在在被使用,那么在接下来一段时间内大概率还会再被使用。可以认为热点数据缓存都属于这种思路的实现。

  2. 空间维度:如果一条数据正在在被使用,那么存储在它附近的数据大概率也会很快被使用。InnoDB的数据页和操作系统的页缓存则是这种思路的体现。

页的上层结构

除了页之外,在数据库中还存在着区(Extent)、段(Segment)和表空间(Tablespace)的概念。

  • 区(Extent):是比页大一级的存储结构,一个区有64个连续的页,所以一个区的大小为1MB

  • 段(Segment):由一个或多个区(不要求相邻)组成。段是数据库中的分配单位

  • 表空间(Tablespace):由一个或多个段组成

InnoDB 中的页并非只有一种,比如有存放 Insert Buffer 的页、存放 undo log 的页、存放数据的页等等。其中我们最关注的还是存放我们表数据的页,又称索引页,或者数据页,以下介绍的就是数据页

页的内部结构

一个页由七部分构成:

名称占用空间描述

File Header(文件头)

38字节

页的通用信息(比如页的编号、其上一页、下一页是谁等)

Page Header(页头部)

56字节

页的专有信息(比如存储了多少条数据、第一条数据的地址等)

Infimum + Supremum(最小和最大记录)

26字节

指向页中的最小记录和最大记录的指针(比较的是主键大小)

User Records(用户记录)

不确定

存储的数据(单向链表方式存储)

Free Space(空闲空间)

不确定

页中尚未使用的空间

Page Directory(页目录)

不确定

为页中存储的数据记录目录,方便通过二分法查找某条具体数据的位置

File Trailer(文件尾)

8字节

校验页的完整性

行格式

行格式就是页中真正保存的数据的格式。行格式分为四种:CompactRedundantDynamicCompressed。行格式之间都大同小异,主要介绍Compact

几种行格式的具体区别可以参考:Innodb 四种行格式对比_shuifa2008的博客-CSDN博客

变长字段长度列表

MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、TEXT类型,BLOB类型,拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据不是固定的,所以我们在存储真实数据的时候需要把这些数据实际上占用的字节数也存起来。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表。

注意:这里面存储的变长长度和字段顺序是反过来的。比如两个varchar字段在表结构的顺序是a(10),b(15)。那么在变长字段长度列表中存储的长度顺序就是15,10。

NULL值列表

Compact行格式会把可以为NULL的列存在一个NULL值列表中。如果表中没有允许存储NULL的列,则NULL值列表也不存在。

例如一个表中有四个列C1、C2、C3、C4,其中C2、C3允许为NULL,则NULL值列表中会有两个bit位来逆序表示每条C2、C3是否为NULL。

规则为:bit位的值为1时,代表该列的值为NULL;bit位的值为0时,代表该列的值不为NULL。

例如一条行记录为1,NULL,NULL,a,则它的NULL值列表就为1 1

例如一条行记录为2,b,NULL,a,则它的NULL值列表就为1 0

之所以要存储NULL值列表是因为数据都是需要对齐的,如果没有标注出来NULL值的位置,就有可能在查询数据的时候出现混乱。如果使用一个特定的符号放到相应的数据位表示空置的话,虽然能达到效果,但是这样很浪费空间,所以直接就在行数据得头部开辟出一块空间专门用来记录该行数据哪些是非空数据,哪些是空数据。

记录头信息

记录头信息分为几个部分:

名称大小(单位:bit)描述

预留位1

1

没有使用

预留位2

1

没有使用

delete_mask

1

标记该记录是否被删除(1表示被删除,0表示未删除)

mini_rec_mask

1

B+树的每层非叶子节点中的(主键)最小记录都会添加该标记(值为1)

n_owned

4

页目录中每个组中最后一条记录的头信息中会存储该组有多少条记录,就是n_owned

heap_no

13

表示当前记录在这个页的中的位置

record_type

3

表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录

next_record

16

表示下一条记录的相对位置

隐藏列

记录的真实数据中,除了自定义的列的数据以外,还有三个隐藏列:

列名是否必须占用空间描述

DB_ROW_ID(row_id)

6字节

行ID,唯一标识一条记录

(DB_TRX_ID)transaction_id

6字节

事务ID

(DB_ROLL_PTR)roll_pointer

7字节

回滚指针

一个表没有手动定义主键,则会选取一个Unique键作为主键,如果连Unique键都没有定义的话,则会为表默认添加一个名为row_id的隐藏列作为主键。所以row_id是在没有自定义主键以及Unique键的情况下才会存在的。

行溢出

一个页的大小一般为16KB,但是MySQL中有些不定长的字段可以存储的值很大,例如VARCHAR最多可以存储65533个字节,这样的话就会导致一个页面连一条记录都存不下,这种现象就是行溢出。

在Compact和Reduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中进行分页存储,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页。这称为页的扩展

在MySQL8.0中,默认行格式就是Dynamic。Dynamic、Compressed行格式和Compact行格式基本一致,只不过在处理行溢出数据时不同:

  • Compressed和Dynamic两种记录格式对于存放在BLOB中的数据采用了完全的行溢出的方式。在数据页中只存放20个字节的指针(溢出页的地址),实际的数据都存放在Off Page(溢出页)中。

  • Compact和Redundant两种格式会在记录的真实数据处存储一部分数据(存放768个前缀字节)。

区、段和碎片区(了解)

为什么要有区

B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。B+树索引的使用场景中,范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了。而如果链表中相邻的两个页物理位置离得非常远,就会发生随机I/O。随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用顺序I/O。

所以引入区的概念,一个区就是物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页的单位分配了,而是按照区为单位分配,甚至在表中的数据特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费(数据不足以填充满整个区),但是从性能角度看,可以消除很多的随机I/O。

为什么要有段

对于范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。

除了索引的叶子节点段和非叶子节点段之外,InnoDB中还有为存储一些特殊的数据而定义的段,比如回滚段。所以,常见的段有数据段、索引段、回滚段。数据段即为B+树的叶子节点,索引段即为B+树的非叶子节点。

在InnoDB存储引擎中,对段的管理都是由引擎自身所完成,DBA不能也没有必要对其进行控制。这从一定程度上简化了DBA对于段的管理。

段其实不对应表空间中的某一个连续的物理区域,而是一个逻辑上的概念,由若干个零散的页面以及一些完整的区组成。

为什么要有碎片区

默认情况下,一个使用InnoDB存储引擎的表只有一个聚簇索引,一个索引会生成2个段,而段是以区为单位申请存储空间的,一个区默认占用1M(64*16KB=1024KB)存储空间,所以默认情况下一个只存在几条记录的小表也需要2M的存储空间么?以后每次添加一个索引都要多申请2M的存储空间么?这对于存储记录比较少的表简直是天大的浪费。这个问题的症结在于到现在为止我们介绍的区都是非常纯粹的,也就是一个区被整个分配给某一个段,或者说区中的所有页面都是为了存储同一个段的数据而存在的,即使段的数据填不满区中所有的页面,那余下的页面也不能挪作他用。

为了考虑以完整的区为单位分配给某个段对于数据量较小的表太浪费存储空间的这种情况,InnoDB提出了一个碎片(fragment)区的概念。在一个碎片区中,并不是所有的页都是为了存储同一个段的数据而存在的,而是碎片区中的页可以用于不同的目的,比如有些页面用于段A,有些页面用于段B,有些页甚至哪个段都不属于。碎片区直属于表空间,并不属于任何一个段。

所以此后为某个段分配存储空间的策略是这样的:

  • 在刚开始向表中插入数据的时候,段是从某个碎片区以单个页面为单位来分配存储空间的。

  • 当某个段已经占用了32个碎片区页面之后,就会申请以完整的区为单位来分配存储空间。

所以现在段不能仅定义为是某些区的集合,更精确的应该是某些零散的页面以及一些完整的区的集合。

区的分类

区大体上可以分为4种类型:

  • 空闲的区(FREE):现在还没有用到这个区中的任何页面。

  • 有剩余空间的碎片区(FREE_FRAG):表示碎片区中还有可用的页面。

  • 没有剩余空间的碎片区(FULL_FRAG):表示碎片区中的所有页面都被使用,没有空闲页面。

  • 附属于某个段的区(FSEG):每一索引都可以分为叶子节点段和非叶子节点段

处于FREEFREE_FRAG以及FULL_FRAG这三种状态的区都是独立的,直属于表空间。而处于FSEG状态的区是附属于某个段的。

索引的数据结构

什么是索引

MySQL官方对索引的定义为:索引(Index)是帮助MySQL高效获取数据的数据结构。在InnoDB中,索引的形式为B+树。

这里所谓的高效,是指当我们从磁盘中读取数据时,应该尽可能地减少磁盘IO次数

之前我们讲过InnoDB中,磁盘与内存交互的基本单位是页。可以想象一下,如果我们需要从MySQL中查找一条数据,应该如何查找?

朴素的思想:将数据页(或者区)读取到内存,然后再在内存中通过页目录,查询所需要的数据;如果未查询到,又需要进行一次磁盘IO,读取新的数据页到内存中。试想一下,如果一个表中有百万、千万条数据,即使我们每次进行磁盘IO的时候都尽量读取很多的数据页到内存中进行查询,但是产生的磁盘IO次数也还是会非常多。

这时就需要一种数据结构快速找到数据存在的那个数据页,从而减少磁盘IO次数。这个数据结构就是B+树

如果还未了解B+树,可以参考:什么是B+树?(详细图解)_初念初恋的博客-CSDN博客_b+树

索引的设计方案

现在,假设现在一张表中有这么几条数据(为了演示方便,假设一个页中最多只有三条数据、每次磁盘IO只能读取一个页)。

此时,假设我们需要查询主键(黄色字段)=20的行数据,就会遇到刚才说的问题:需要先读取页10,再读取页28,再读取页9,最终找到数据,可以想象一下如果数据量很大,必然会产生很多次磁盘IO。

解决方案就是我们设计一个目录项:

目录项中有两个字段:

  • page_no:这个目录项所表示的数据页的编号

  • key:这个目录项所表示的数据页的中最小的数据的主键值

有了目录项,就可以先把目录项都读取到内存中,通过二分法查找,根据目录项中key的范围找到目标数据在哪个数据页中,然后直接读取那个数据页即可。

但是,随着数据量的增大,数据页会越来越多,相应的,目录项也会越来越多,直到需要多次磁盘IO才能把目录项全部读入内存中,这样又退化到我们设计目录项之前的状态了。同时,目录项还缺乏一个具体的存储结构来管理。

于是,我们可以将目录项用数据页来存储。并且,给存放目录项的数据页再设计一个数据页用来存放其目录项,简称套娃。

目录项记录 和普通的 用户记录不同点:

  • 目录项记录 的 record_type 值是1,而 普通用户记录 的 record_type 值是0,InnoDB就是通过这个来区分 用户记录目录项记录 的。

  • 目录项记录只有 主键值和页的编号 两个列,而普通的用户记录的列是用户自己定义的,可能包含 很多列 ,另外还有InnoDB自己添加的隐藏列。

  • 了解:记录头信息里还有一个叫 min_rec_mask 的属性,只有在存储 目录项记录 的页中的主键值最小的 目录项记录min_rec_mask 值为 1 ,其他别的记录的 min_rec_mask 值都是 0

相同点:两者用的是一样的数据页,都会为主键值生成 Page Directory (页目录),从而在按照主键值进行查找时可以使用 二分法 来加快查询速度。

像这样的数据结构,就是B+树:

一个B+树的节点其实可以分成好多层,规定最下边的那层,也就是存放我们用户记录的那层为第0层,之后依次往上加。之前我们做了一个非常极端的假设:存放用户记录的页最多存放3条记录,存放目录项记录的页最多存放4条记录。其实真实环境中一个页存放的记录数量是非常大的,假设所有存放用户记录 的叶子节点代表的数据页可以存放 100条用户记录 ,所有存放目录项记录的内节点代表的数据页可以存 放 1000条目录项记录,那么:

  • 如果B+树只有1层,也就是只有1个用于存放用户记录的节点,最多能存放 100 条记录。

  • 如果B+树有2层,最多能存放 1000×100=10,0000 条记录。

  • 如果B+树有3层,最多能存放 1000×1000×100=1,0000,0000 条记录。

  • 如果B+树有4层,最多能存放 1000×1000×1000×100=1000,0000,0000 条记录。

而每多一层B+树只会增加一次磁盘IO。一般来说,三层B+树索引足以存放百万级别的数据,而它的磁盘IO次数只有三次。

索引的分类

聚簇索引

上面的例子中,我们是根据数据的主键来对数据进行排序的,这种索引就称为主键索引/聚簇索引/一级索引。这种索引只能针对查询条件是主键的查询进行索引优化。

特点:

  • 使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:

    • 页内的记录是按照主键的大小顺序排成一个单向链表。

    • 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。

    • 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。

  • B+树的叶子节点存储的是完整的用户记录。

    所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。

我们把具有这两种特性的B+树称为聚簇索引,所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引并不需要我们在MySQL语句中显式的使用INDEX语句去创建,InnDB存储引擎会自动的为我们创建聚簇索引。

优点:

  • 数据访问更快,因为聚簇索引将索引和数据保存在同一个B+树中,因此从聚簇索引中获取数据比非聚簇索引更快。

  • 聚簇索引对于主键的排序查找范围查找速度非常快

  • 按照聚簇索引排列顺序,查询显示一定范围数据的时候,由于数据都是紧密相连,数据库不用从多个数据块中提取数据,所以节省了大量的io操作。

缺点:

  • 插入速度严重依赖于插入顺序,按照主键的顺序插入是最快的方式,否则将会出现页分裂,严重影响性能。因此,对于InnoDB表,我们一般都会定义一个自增的ID列为主键。

  • 更新主键的代价很高,因为将会导致被更新的行移动。因此,对于InnoDB表,我们一般定义主键为不可更新

非聚簇索引

在实际的使用场景中,很显然不可能总是用主键作为查询条件。这时就需要对不同的查询字段按需建立索引了。这种索引就称为非聚簇索引/二级索引/辅助索引

非聚簇索引和聚簇索引最大的区别就在于,它的叶子节点并不存放完整的数据,而是只存放建立索引的字段主键值。因此当我们通过非聚簇索引查找数据时,找到的其实只是主键值,此时需要拿主键值,再去聚簇索引中找到完整的数据。这就称为回表

索引的使用和设计原则

索引的声明和使用

索引分类

  • 从功能逻辑上说,索引主要有 4 种,分别是普通索引、唯一索引、主键索引、全文索引。

  • 按照物理实现方式,索引可以分为 2 种:一级索引(聚簇索引)和二级索引(非聚簇索引)。

    • 一级索引:索引和数据存储在一起,都存储在同一个B+tree中的叶子节点。一般主键索引都是一级索引。

    • 二级索引树的叶子节点存储的是主键而不是数据。也就是说,在找到索引后,得到对应的主键,再回到一级索引中找主键对应的数据记录。

  • 按照作用字段个数进行划分,分成单列索引和联合索引。

创建索引

# 建表时建立索引
CREATE TABLE table_name [col_name data_type] 
[UNIQUE | FULLTEXT | SPATIAL] [INDEX | KEY] [index_name] (col_name [length]) [ASC | DESC]

# 在已存在的表上创建索引
ALTER TABLE table_name 
ADD [UNIQUE | FULLTEXT | SPATIAL] [INDEX | KEY] [index_name] (col_name[length],...) [ASC | DESC]

CREATE [UNIQUE | FULLTEXT | SPATIAL] INDEX index_name 
ON table_name (col_name[length],...) [ASC | DESC]
  • UNIQUEFULLTEXTSPATIAL为可选参数,分别表示唯一索引、全文索引和空间索引;

  • INDEXKEY为同义词,两者的作用相同,用来指定创建索引;

  • index_name指定索引的名称,为可选参数,如果不指定,那么MySQL默认col_name为索引名;

  • col_name为需要创建索引的字段列,该列必须从数据表中定义的多个列中选择;

  • length为可选参数,表示索引的长度,只有字符串类型的字段才能指定索引长度;

  • ASCDESC指定升序或者降序的索引值存储。

查看索引

SHOW INDEX FROM table_name

删除索引

ALTER TABLE table_name DROP INDEX index_name;

DROP INDEX index_name ON table_name;

隐藏索引(8.0新特性)

从 MySQL8.x 开始支持隐藏索引(invisible indexes),只需要将待删除的索引设置为隐藏索引,使查询优化器不再使用这个索引(即使使用force index(强制使用索引),优化器也不会使用该索引),确认将索引设置为隐藏索引后系统不受任何响应,就可以彻底删除索引。这种通过先将索引设置为隐藏索引,再删除索引的方式就是软删除。

# 切换成隐藏索引 
ALTER TABLE tablename ALTER INDEX index_name INVISIBLE; 

# 切换成非隐藏索引
ALTER TABLE tablename ALTER INDEX index_name VISIBLE; 

# 也可以在创建索引时指定 INVISIBLE 
# 索引创建时默认是 VISIBLE

(当索引被隐藏时,其内容仍是实时更新的,因此不推荐长期隐藏索引)

创建索引原则(推荐创建索引的场景)

字段的数值有唯一性的限制

索引本身可以起到约束的作用,比如唯一索引、主键索引都可以起到唯一性约束的,因此在我们的数据表中,如果某个字段是唯一的,就可以直接创建唯一性索引,或者主键索引。这样可以更快速地通过该索引来确定某条记录。

参考:普通索引的性能会不会比唯一索引好? - 掘金 (juejin.cn)

Mysql - 普通索引与唯一索引之间性能差别change buffer_it_lihongmin的博客-CSDN博客

频繁作为 WHERE 查询条件且具有明显区分特性的字段

某个字段在SELECT语句的 WHERE 条件中经常被使用到并且具有明显区分特性(例如username,而不是sex这种),那么就需要给这个字段创建索引了。尤其是在数据量大的情况下,创建普通索引就可以大幅提升数据查询的效率。

经常 GROUP BY 和 ORDER BY 的列

某个字段在 SELECT 语句的 WHERE 条件中经常被使用到,那么就需要给这个字段创建索引了。尤其是在数据量大的情况下,创建普通索引就可以大幅提升数据查询的效率。索引就是让数据按照某种顺序进行存储或检索,因此当我们使用 GROUP BY 对数据进行分组查询,或者使用 ORDER BY 对数据进行排序的时候,就需要对分组或者排序的字段进行索引。如果待排序的列有多个,那么可以在这些列上建立组合索引

UPDATE、DELETE 的 WHERE 条件列

对数据按照某个条件进行查询后再进行 UPDATE 或 DELETE 的操作,如果对 WHERE 字段创建了索引,就能大幅提升效率。原理是因为我们需要先根据 WHERE 条件列检索出来这条记录,然后再对它进行更新或删除。如果进行更新的时候,更新的字段是非索引字段,提升的效率会更明显,这是因为非索引字段更新不需要对索引进行维护。

查询 DISTINCT 字段

有时候我们需要对某个字段进行去重,使用 DISTINCT,那么对这个字段创建索引,也会提升查询效率。

多表 JOIN

  • 连接表的数量尽量不要超过 3 张,因为每增加一张表就相当于增加了一次嵌套的循环,数量级增长会非常快,严重影响查询的效率。

  • 对 WHERE 条件创建索引,因为 WHERE 才是对数据条件的过滤。如果在数据量非常大的情况下,没有 WHERE 条件过滤是非常可怕的。

  • 对用于连接的字段创建索引,并且该字段在多张表中的类型必须一致

使用列的类型小的创建索引

我们这里所说的类型大小指的就是该类型表示的数据范围的大小

  • 数据类型越小,在查询时进行的比较操作越快

  • 数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘 I/O 带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。

这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键使用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/O。

使用字符串前缀创建索引

如果某个字段是 varchar 类型的(或者 text 等等),那么其中可能存储了很长的字符串,当建立索引时,可以不用整个字符串建立索引,而是指截取前面部分建立索引

拓展:Alibaba《Java开发手册》

**【强制】**在 varchar 字段上建立索引时,必须指定索引长度,没必要对全字段建立索引,根据实际文本区分度决定索引长度。

说明:索引的长度与区分度是一对矛盾体,一般对字符串类型数据,长度为 20 的索引,区分度会高达 90% 以上。

区分度计算公式:count(distinct left(列名, 索引长度))/count(*)

区分度高的列适合作为索引

列的基数指的是某一列中不重复数据的个数,比方说某个列包含值 2,5,8,2,5,8,2,5,8,虽然有 9 条记录,但该列的基数却是 3 。也就是说,**在记录行数一定的情况下,列的基数越大,该列中的值越分散;列的基数越小,该列中的值越集中。**这个列的基数指标非常重要,直接影响我们是否能有效的利用索引。最好为列的基数大的列建立索引,为基数太小的列建立索引效果可能不好。

可以使用公式 select count(distinct a)/count(*) from t1 计算区分度,越接近 1 越好,一般超过 33% 就算是比较高效的索引了。

拓展:联合索引把区分度高(散列性高)的列放在前面。

使用频率高的列放到联合索引的左侧

多个字段都要创建索引的情况下,联合索引优于单值索引

单张表的索引不超过6个

不适合创建索引的场景

  • where 中使用不到的字段不要使用索引

  • 数据量小的表不要使用索引

  • 有大量重复数据的列上不要创建索引

  • 避免对经常更新的表创建过多索引

  • 不建议用无序的值作为索引

  • 删除不再使用或者很少使用的索引

  • 不要定义冗余或重复的索引

插入 1KW 条测试数据

创建测试表t_user

CREATE TABLE `t_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c_user_id` varchar(36) NOT NULL DEFAULT '' COMMENT '用户Id',
  `c_name` varchar(22) NOT NULL DEFAULT '' COMMENT '用户名',
  `c_province_id` int(11) NOT NULL COMMENT '省份Id',
  `c_city_id` int(11) NOT NULL COMMENT '城市Id',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`c_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

创建临时表

CREATE TABLE `tmp_table` (
  `id` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

用 python 生成 1kw 个数据

[root@fengye tmp]# python -c "for i in range(1, 1+10000000): print(i)" > base.txt

在 mysql 中执行导入命令

load data infile '\home\tmp\base.txt' replace into table  tmp_table

可能出现的报错

1290 - The MySQL server is running with the --secure-file-priv option so it cannot execute this statement

原因:mysql默认没有开启 secure_file_priv

解决:在 my.ini(/etc/my.cnf) 中的 [mysqld] 节点中添加

secure_file_priv = '',然后重启

说明:

secure_file_prive = null 限制mysqld 不允许导入导出

secure_file_priv = /var/lib/mysql-files/ 限制mysqld的导入导出只能发生在/var/lib/mysql-files/目录下

secure_file_priv = '' 不对mysqld的导入导出做限制

从临时表中的数据插入到 t_user

insert into t_user
select id,uuid(),concat('userNickName',id),floor(rand()*1000),floor(rand()*100),now()
from tmp_table

打乱创建时间

UPDATE t_user SET create_time=date_add(create_time, interval FLOOR(1 + (RAND() * 7)) year);

性能分析工具

查看系统性能参数

SHOW STATUS LIKE '参数'

常用的性能参数:

Connections:连接 MySQL 服务器的次数
Uptime:MySQL服务器上线事件
Slow_queries:慢查询次数
Innodb_rows_read:已select的行数
Innodb_rows_inserted:已inserted的行数Innodb_rows_updated:已updated的行数Innodb_rows_deleted:已deleted的行数
Com_select:查询操作的次数
Com_insert:插入操作的次数
Com_update:更新操作的次数
Com_delete:删除操作的次数

查看上一条SQL语句的查询成本

SHOW STATUS LIKE 'last_query_cost' 

SQL 查询是一个动态的过程,从页加载的角度来看,我们可以得到以下两点结论:

  1. 位置决定效率。如果页就在数据库缓冲池中,那么效率是最高的,否则还需要从内存或者磁盘中进行读取,当然针对单个页的读取来说,如果页存在于内存中,会比在磁盘中读取效率高很多。

  2. 批量决定效率。如果我们从磁盘中对单一页进行随机读取,那么效率是很低的(差不多10ms),而采用顺序读取的方式,批量对页进行读取,平均一页的读取效率就会提升很多,甚至要快于单个页面在内存中的随机读取。

所以说,遇到I/O并不用担心,方法找对了,效率还是很高的。我们首先要考虑数据存放的位置,如果是经常使用的数据就要尽量放到缓冲池中,其次我们可以充分利用磁盘的吞吐能力,一次性批量读取数据,这样单个页的读取效率也就得到了提升。

慢查询日志

MySQL的慢查询日志,用来记录在MySQL中响应时间超过阈值(即 long_query_time ,默认为10s)的语句。

慢查询日志默认为关闭状态,需要手动开启,支持持久化到文件。

建议在测试环境中开启,正式环境中关闭,因为该功能会影响性能

补充说明:

除了 long_query_time 外,还有一个系统变量 min_examined_row_limit,表示查询扫描过的最小记录数。

当一条查询扫描的记录数大于 min_examined_row_limit 同时查询的执行事件超过 long_query_time ,那么这个查询就会被记录到慢查询日志中。

这个值默认是 0 ,可以在 my.ini/my.cnf 中修改,也可用 SET 命令修改。

修改慢查询配置

查看慢查询是否开启

SHOW VARIABLES LIKE 'slow_query_log';

查看慢查询日志位置

SHOW VARIABLES LIKE 'slow_query_log_file';

开启慢查询

SET GLOBAL slow_query_log='ON';

查看慢查询阈值

show variables like '%long_query_time%';

修改慢查询阈值

#测试发现:设置global的方式对当前session的long_query_time失效。对新连接的客户端有效。所以可以一并 执行下述语句 
mysql> set global long_query_time = 1; 
mysql> show global variables like '%long_query_time%'; 

mysql> set long_query_time=1; 
mysql> show variables like '%long_query_time%';

查看慢查询数量

SHOW GLOBAL STATUS LIKE '%Slow_queries%';

慢查询日志分析

#得到按照时间排序的前10条里面含有左连接的查询语句 
mysqldumpslow -s t -t 10 -g "left join" /var/lib/mysql/atguigu-slow.log 

参数:
-s 表示按照何种方式排序
    c 访问次数
    l 锁定时间
    r 返回记录
    t 查询时间
    al 平均锁定时间
    ar 平均返回记录数
    at  平均查询时间
-t 返回前面多少条数据
-g 后边搭配一个正则匹配模式,大小写不敏感

可能会出现的报错:

mysqldumpslow: command not found...

原因:系统默认去 /usr/bin 下查找命令

解决方法:找到 mysql 的安装目录下的 /bin/mysqldumpslow

执行 ln -s /www/server/mysql/bin/mysqldumpslow /usr/bin

重置慢查询日志

mysqladmin -uroot -p flush-logs slow

EXPLAIN 分析查询语句

参考:mysql explain详解 - 天下没有收费的bug - 博客园 (cnblogs.com)

语法

EXPLAIN 查询语句

EXPLAIN 语句输出的各个列的作用如下

列名描述

id

在一个大的查询语句中每个SELECT关键字都对应一个唯一的id

select_type

SELECT关键字对应的那个查询的类型

table

表名

partitions

匹配的分区信息

type

针对单表的访问方法

possible_keys

可能用到的索引

key

实际上使用的索引

key_len

实际使用到的索引长度

ref

当使用索引列等值查询时,与索引列进行等值匹配的对象信息

rows

预估的需要读取的记录条数

filtered

某个表经过搜索条件过滤后剩余记录条数的百分比

Extra

一些额外的信息

  • id

    SELECT识别符(执行顺序的标识)。

    1. id值越大优先级越高,越先被执行

    2. id相同时,执行顺序由上至下

  • select_type

    • SIMPLE(简单SELECT,不使用UNION或子查询等)

    • PRIMARY(子查询中最外层查询,查询中若包含任何复杂的子部分,最外层的select被标记为PRIMARY)

    • UNION(UNION中的第二个或后面的SELECT语句)

    • DEPENDENT UNION(UNION中的第二个或后面的SELECT语句,取决于外面的查询)

    • UNION RESULT(UNION的结果,union语句中第二个select开始后面所有select)

    • SUBQUERY(子查询中的第一个SELECT,结果不依赖于外部查询)

    • DEPENDENT SUBQUERY(子查询中的第一个SELECT,依赖于外部查询)

    • DERIVED(派生表的SELECT, FROM子句的子查询)

    • UNCACHEABLE SUBQUERY(一个子查询的结果不能被缓存,必须重新评估外链接的第一行)

  • table

    显示这一步所访问数据库中表名称(显示这一行的数据是关于哪张表的),有时不是真实的表名字,可能是简称,例如上面的e,d,也可能是第几步执行的结果的简称

  • type

    对表的访问方式,表示 mysql 在表中找到所需行的方式,又称访问类型。

    • ALL

      不用索引,遍历全表(效率最低)

    • index

      使用索引,遍历全表(效率还是很低)

      select count(*) from user;

    • range

      范围查询,用在 between/like/<= 等等

      select * from user where user_name like 'test';

    • ref

      通过普通的二级索引进行等值查询(普通指的是不唯一索引,因此查询结果可能有多条)

      select * from user where user_name = 'test';

    • ref_or_null

      ref 类似,条件中多了一个 is null 判断

      select * from user where address = 'test' or address is null;

    • eq_ref

      连表查询时,连接的条件是主键唯一二级索引,这是连表查询中连接效率最高的

      select * from user join department on department.user_id = user.id;

    • const

      根据主键唯一二级索引与常数进行等值匹配

      select * from user where id = 100;

    • system

      const的特例,当查询的表只有一条数据时,用system(innodb不支持)

    结果从好到坏:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

    SQL性能优化的目标:至少达到range级别,要求是ref级别,最好是consts级别

  • possible_keys & key

    possible_key 表示可能用到的索引

    key 表示实际用到的索引

  • key_len

    实际使用的索引的长度

  • ref

    查询条件的类型

  • rows

    预计需要读取的行数(越小越好)

  • filtered

    某个表经过搜索条件过滤后剩余记录条数的百分比

  • extra

    记录了一些额外的信息来帮助理解MySQL实际查询的过程

EXPLAIN 输出格式

EXPLAIN FORMAT=<xxx> ...

  • 传统格式

  • JSON 格式(最详细)

  • TREE 格式(8.0.16+)

  • 可视化输出(Mysql workbench)

分析优化执行计划:trace

# 开启
SET optimizer_trace="enabled=on",end_markers_in_json=on; 
# 设置大小
set optimizer_trace_max_mem_size=1000000;
# 使用
select * from student where id < 10;
select * from information_schema.optimizer_trace\G

MySQL监控分析视图 sys schema

索引情况

#1. 查询冗余索引 
select * from sys.schema_redundant_indexes; 
#2. 查询未使用过的索引 
select * from sys.schema_unused_indexes; 
#3. 查询索引的使用情况 
select index_name,rows_selected,rows_inserted,rows_updated,rows_deleted from sys.schema_index_statistics where table_schema='dbname' ;

表相关

# 1. 查询表的访问量 
select table_schema,table_name,sum(io_read_requests+io_write_requests) as io from sys.schema_table_statistics group by table_schema,table_name order by io desc; 
# 2. 查询占用bufferpool较多的表 
select object_schema,object_name,allocated,data
from sys.innodb_buffer_stats_by_table order by allocated limit 10; 
# 3. 查看表的全表扫描情况 
select * from sys.statements_with_full_table_scans where db='dbname';

语句相关

#1. 监控SQL执行的频率 
select db,exec_count,query from sys.statement_analysis order by exec_count desc; 
#2. 监控使用了排序的SQL 
select db,exec_count,first_seen,last_seen,query
from sys.statements_with_sorting limit 1; 
#3. 监控使用了临时表或者磁盘临时表的SQL 
select db,exec_count,tmp_tables,tmp_disk_tables,query
from sys.statement_analysis where tmp_tables>0 or tmp_disk_tables >0 order by (tmp_tables+tmp_disk_tables) desc;

IO相关

#1. 查看消耗磁盘IO的文件 
select file,avg_read,avg_write,avg_read+avg_write as avg_io
from sys.io_global_by_file_by_bytes order by avg_read limit 10;

Innodb 相关

#1. 行锁阻塞情况 
select * from sys.innodb_lock_waits;

索引优化和查询优化

索引失效的几种情况

参考:mysql索引失效的常见原因和如何用好索引 - 问题大白 - 博客园 (cnblogs.com)

  • 不满足最佳左前缀原则

  • where 条件后使用计算、函数、类型转换

  • 范围条件右边索引失效(指的是联合索引中的范围

  • 使用了 select * (会引起回表)

  • 字段不允许为空,则 is nullis not null 都失效;若字段允许为空,则 is nullref 类型的索引,而 is not nullrange 类型的索引。

  • like 查询左边有 %

  • OR 前后存在非索引的列

  • 使用 not in

  • 使用 !=<>

回表问题和索引覆盖

参考:避免写出致命 SQL,搞懂 MySQL 回表 - 掘金 (juejin.cn)

InnoDB 中,索引分为聚簇索引和普通索引。

  • 聚簇索引的叶子节点存储的是完整的行记录

  • 普通索引的叶子节点存储的是主键

可见,InnoDB 的聚簇索引负责存储完整的行数据,而普通索引只能帮助找到主键。因此,InnoDB 必须也只能有一个聚簇索引。

  1. 如果表定义的主键,那么主键就是聚簇索引

  2. 如果表没有定义主键,那么第一个 not nullunique 列就是聚簇索引

  3. 否则,InnoDB 会创建一个隐藏的 DB_ROW_ID 作为聚簇索引

回表

利用普通索引查询到某条数据的主键后,又返回到聚簇索引,重新定位该数据。

回表查询的性能比扫一遍索引树低。

索引覆盖

如果一个索引包含(覆盖)了所需要查询的字段的值,那么就称为索引覆盖

在 InnoDB 中,普通索引树的叶子节点存储的都是主键+索引列值。

为了避免回表,可以对需要查询的数据建立联合索引。

连接查询优化

参考:MySQL查询优化——连接以及连接原理 - 简书 (jianshu.com)

文章中有一处有误,MySQL 从 8.0.18 版本开始添加 hash join,并从 8.0.20 开始移除 BNLJ

连接查询原理

在 MySQL 中,A left join B on condition 的执行过程如下:

  1. 以 table_A 为驱动表,检索 table_B

  2. 根据 on 条件过滤 table_B 的数据,构建 table_A 结果集,并且添加外部行。

  3. 对结果集执行 where 条件过滤。如果A中有一行匹配 where 子句但是B中没有一行匹配on条件,则生成另一个B行,其中所有列设置为 NULL。

驱动表:用来最先获得数据,并以此表为依据,逐步获得其他表的数据,直至最终查询到所有符合条件的数据的第一个表。外连接:根据连接方向,主表是驱动表;内连接:小表驱动大表(where条件后的小的结果集将作为驱动表)。

在上述过程中,根据 on 条件过滤 table_B 的数据的算法,就被称为 join 算法。

  • SNLJ(Simple Nested Loop Join):

    1. 从驱动表中选取数据,遍历被驱动表,匹配到的数据放入结果集

    2. 重复选取驱动表中的其他数据,直到遍历完驱动表

    3. 匹配效率非常低,并且还伴随着大量的 IO

  • INLJ(Index Nested Loop Join)

    1. 相比于 SNLJ ,从驱动表中选取出的数据和被驱动表中的匹配时,使用索引进行遍历

    2. 因此要求 on 的匹配条件是被驱动表的索引

    3. 提高了匹配效率

  • BNLJ(Block Nested Loop Join)

    1. 相比于 SNLJ ,在遍历被驱动表时,会读取整块的数据并将其缓存在内存的 join buffer 中

    2. 是被驱动表没有索引情况下的默认算法(8.0.18以前)

    3. 减少 IO 次数

总体性能:INLJ > BNLJ > SNLJ

优化

  1. 为被驱动表的 on 匹配条件添加索引

  2. 增加 join buffer 的大小(通过增加缓存数据的大小,减少 IO 次数)

  3. 减少驱动表不必要的字段查询(字段越少,join buffer 缓存的数据就越多)

MySQL 从 8.0.18 版本开始添加 Hash Join,并从 8.0.20 开始移除 BNLJ

  • Nested Loop:对于被连接的数据子集较小的情况下,Nested Loop是个较好的选择。

  • Hash Join是做大数据集连接时的常用方式,优化器使用两个表中较小(相对较小)的表利用Join Key在内存中建立散列值,然后扫描较大的表并探测散列值,找出与Hash表匹配的行。

    • 这种方式适用于较小的表完全可以放入内存中的情况,这样总成本就是访问两个表的成本之和。

    • 在表很大的情况下并不能完全放入内存,这时优化器会将它分割成若干不同的分区,不能放入内存的部分就把该分区写入磁盘的临时段,此时要求有较大的临时段从而尽量提高 I/O 的性能。

    • 它能够很好的工作于没有索引的大表和并行查询的环境中,并提供最好的性能。Hash Join 只能应用于等值连接,这是由 Hash 的特点决定的。

子查询优化

参考:MySQL 子查询优化 - 简书 (jianshu.com)

子查询执行慢的原因:

  • 执行子查询时,MySQL 需要为内层查询语句的查询结果建立一个临时表,然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。这样会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。

  • 子查询的临时表中不会存在索引。

优化方法:用连接查询(join)替代子查询

排序优化

MySQL两种排序方法:

  • index:直接使用索引,因为索引是有序的,所以不需要额外的排序。效率高。

  • filesort:通过相应的排序算法,将取得的数据在系统变量设置的内存排序区(sort_buffer_size)中进行排序,如果内存装载不下,会将磁盘上的数据进行分块,再对各个数据块进行排序,再将各个块合并成有序的结果集。效率低。

优化排序的思路就是尽量使用 index,迫不得已的情况下也要使用优化的 filesort。

然而,并非是 只要 order by 的列上有索引,排序就一定会用 index ,需要分情况来看。

几种常见的排序情况

参考:MySQL优化篇:排序分组优化

  • order by 的列上建立了索引并且使用了 limit 关键字,则会使用索引:

    select * from student order by age limit 100;

    如果没有使用 limit ,排序就不会走索引。因为如果使用了索引, select * 会引起大量回表,执行器会判断这种行为得不偿失,就将其优化成了 filesort。

    但是如果实现了索引覆盖,那么不加 limit 关键字,也会走索引:

    select age from student order by age limit 100;
  • 排序的列不满足联合索引的最左前缀原则,不使用索引;排序的顺序和索引相反,不使用索引。

  • 没有 where 条件过滤,不使用索引

filesort 的两种排序法

  • 双路排序(慢)

    扫描两次磁盘最终得到数据。第一次扫描获取到排序列和主键,将其放入 sort_buffer 中按照 order by 排序后,再进行第二次扫描,获取到完整的行数据返回。

  • 单路排序(快)

    扫描一次最终得到数据。直接从磁盘读取查询需要的所有列,按照 order by 在 sort_buffer 中排序,然后返回。

整体来看,单路排序各方面优于双路排序,但是会占用更多的内存空间,并且需要适当提高 sort_buffer_size 。

优化思路:

  1. 提高 sort_buffer_size

  2. 提高 max_length_for_sort_data (数据总量超过这个值则会用双路,低于这个值用单路)

  3. select 尽量只选择必要的字段

分组优化

  • group by 使用索引的原则几乎跟order by一致 ,group by 即使没有过滤条件用到索引,也可以直接使用索引。

  • group by 先排序再分组,遵照索引建的最佳左前缀法则

  • 当无法使用索引列,可以增大 max_length_for_sort_datasort_buffer_size 参数的设置

  • where效率高于having,能写在where限定的条件就不要写在having中了

  • 减少使用order by,和业务沟通能不排序就不排序,或将排序放到程序端去做。Order by、group by、distinct这些语句较为耗费CPU,数据库的CPU资源是极其宝贵的。

  • 包含了order by、group by、distinct这些查询的语句,where条件过滤出来的结果集请保持在1000行以内,否则SQL会很慢。

分页优化

参考:数据量很大,分页查询很慢,怎么优化? - 简书 (jianshu.com)

分页查询例子:

select * from student limit 10000,100;
# 查询 10000-10100 条数据,默认是按照主键排序

上述例子虽然只查询出了100条数据,但实际上是先读取10100条数据,再抛弃前10000条数据。如果偏移量或者查询数量较多,那么查询的效率是很低的。

优化:

  • 先用子查询,检索出指定范围内的主键

    select * from student t,(select id from student limit 10000,100) a where t.id = a.id;

    由于子查询满足了索引覆盖,所以查询的效率很高

  • 如果主键是连续递增的,可以用 id 的范围

    select * from student where id between 10000 and 10100;

索引条件下推

参考:MySQL--索引条件下推优化 - zengkefu - 博客园 (cnblogs.com)

简称 ICP (Index Condition Pushdown)

假设有联合索引 idx(class_id,student_name,address):

select * from student
  where class_id = "1"
  and student_name like "%王%"
  and address like "%江苏%";
# like 条件不走索引
  • 不开启 ICP 执行过程:先根据 class_id 回表找到数据,然后将数据与 like 条件匹配。数据量大时有大量回表。

  • 开启 ICP 执行过程:先在索引树中判断是否符合 like 条件,再将筛选完的数据回表(由于创建的是联合索引,所以索引树的叶子结点上有完整的 class_id、student_name、address)。减少回表次数。

ICP 默认开启,也可以用 set optimizer_switch='index_condition_pushdown=off/on' 手动开启或关闭

其他优化建议

  • exist 和 in 的区别

    select * from A where A_id exists (select A_id from B)
    # 以 A 为主表
    
    select * from A where A_id in (select A_id from B)
    # 以 B 为主表

    根据小表驱动大表的原则,A表大则用exists,B表大则用in

  • 如果确定查询的结果只有一条,可以加上 limit 1 来提高效率(避免找到结果后继续扫描)

数据库设计规范

范式

理解数据库范式-通俗易懂 - Strawberry丶 - 博客园 (cnblogs.com)

**在关系型数据库中,关于数据表设计的基本原则、规则就称为范式。**可以理解为,一张数据表的设计结构需要满足的某种设计标准的级别。要想设计一个结构合理的关系型数据库,必须满足一定的范式。

目前关系型数据库有六种常见范式,按照范式级别,从低到高分别是:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、巴斯-科德范式(BCNF)、第四范式(4NF)和第五范式(5NF,又称完美范式)。高级别的范式满足低级别的范式。

范式只是设计的标准,实际上设计数据表时,不一定要完全满足这些标准。

一些概念:

属性:表中的字段

元组:表中的一行数据。

:表中可以唯一确定一个元组的某个属性(或者属性组),如果这样的码有不止一个,那么大家都叫候选码,我们从候选码中挑一个出来做老大,它就叫主码

全码:如果一个码包含了所有的属性,这个码就是全码。

主属性:一个属性只要在任何一个候选码中出现过,这个属性就是主属性。

非主属性:与上面相反,没有在任何候选码中出现过,这个属性就是非主属性。

外码:一个属性(或属性组),它不是码,但是它别的表的码,它就是外码。

第一范式

定义:确保数据表中的每个字段的值具有原子性,即不可拆分

第二范式

定义:满足第一范式,所有非主属性完全依赖码(每条数据都可以唯一标识,并且普通字段完全依赖于主键)

完全依赖:设X,Y是关系R的两个属性集合,X’是X的真子集,存在X→Y,但对每一个X’都有X’!→Y,则称Y完全函数依赖于X。

部分依赖:设X,Y是关系R的两个属性集合,存在X→Y,若X’是X的真子集,存在X’→Y,则称Y部分函数依赖于X。

例如有一张表中有key1,key2,列1,列2,列3。完全依赖就是只有知道了key1和key2的值,才能唯一确定列1,列2,列3。而如果只知道key1或key2,则无法唯一确定列1,列2,列3。

通俗来说,第二范式的就是要求一张表只做一件事。

第三范式

定义:满足第二范式,消除传递依赖

即每一个非主属性都直接依赖码,而非主属性之间不能互相依赖

巴斯范式

定义:满足第三范式,主属性之间没有互相依赖


**范式的优点:**有助于消除数据冗余。

**范式的缺点:**可能会降低查询效率,因为范式等级越高,设计的表就越多,越精细,查询时就需要关联更多的表。

在实际开发中,一般只满足到第三范式或巴斯范式;为了提高查询的效率,可能会适当增加冗余数据。

反范式化

当冗余信息有价值或者能够大幅提高查询效率,就会采取反范式化来优化。

添加冗余字段的建议:

  1. 冗余字段不需要经常修改

  2. 冗余字段查询时不可或缺

数据表的设计原则

  1. 表的个数越少越好

  2. 表中的字段个数越少越好

  3. 表中联合主键的字段个数越少越好

  4. 使用主键和外键越多越好

设计建议

库相关

  1. 【强制】库的名称必须控制在32个字符以内,只能使用英文字母、数字和下划线,建议以英文字母开头。

  2. 【强制】库名中英文一律小写,不同单词采用下划线分割。须见名知意。

  3. 【强制】库的名称格式:业务系统名称_子系统名。

  4. 【强制】库名禁止使用关键字(如type,order等)。

  5. 【强制】创建数据库时必须显式指定字符集,并且字符集只能是utf8mb4。创建数据库SQL举例:CREATE DATABASE crm_fund DEFAULT CHARACTER SET 'utf8';

  6. 【建议】对于程序连接数据库账号,遵循权限最小原则。使用数据库账号只能在一个DB下使用,不准跨库。程序使用的账号原则上不准有drop权限

  7. 【建议】临时库以tmp_为前缀,并以日期为后缀;备份库以bak_为前缀,并以日期为后缀。

表、列相关

  1. 【强制】表和列的名称必须控制在32个字符以内,表名只能使用英文字母、数字和下划线,建议以英文字母开头

  2. 【强制】 表名、列名一律小写,不同单词采用下划线分割。须见名知意。

  3. 【强制】表名要求有模块名强相关,同一模块的表名尽量使用统一前缀。比如:crm_fund_item

  4. 【强制】创建表时必须显式指定字符集为utf8mb4。

  5. 【强制】表名、列名禁止使用关键字(如type,order等)。

  6. 【强制】创建表时必须显式指定表存储引擎类型。如无特殊需求,一律为InnoDB。

  7. 【强制】建表必须有comment。

  8. 【强制】字段命名应尽可能使用表达实际含义的英文单词或缩写。如:公司 ID,不要使用 corporation_id, 而用corp_id 即可。

  9. 【强制】布尔值类型的字段命名为is_描述。如member表上表示是否为enabled的会员的字段命名为 is_enabled。

  10. 【强制】禁止在数据库中存储图片、文件等大的二进制数据。通常文件很大,短时间内造成数据量快速增长,数据库进行数据库读取时,通常会进行大量的随机IO操作,文件很大时,IO操作很耗时。通常存储于文件服务器,数据库只存储文件地址信息。

  11. 【建议】建表时关于主键:表必须有主键 (1)强制要求主键为id,类型为int或bigint,且为auto_increment 建议使用unsigned无符号型。 (2)标识表里每一行主体的字段不要设为主键,建议设为其他字段如user_id,order_id等,并建立unique key索引。因为如果设为主键且主键值为随机插入,则会导致innodb内部页分裂和大量随机I/O,性能下降。

  12. 【建议】核心表(如用户表)必须有行数据的创建时间字段(create_time)和最后更新时间字段(update_time),便于查问题。

  13. 【建议】表中所有字段尽量都是NOT NULL属性,业务可以根据需要定义DEFAULT值。 因为使用NULL值会存在每一行都会占用额外存储空间、数据迁移容易出错、聚合函数计算结果偏差等问题。

  14. 【建议】所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)。

  15. 【建议】中间表(或临时表)用于保留中间结果集,名称以tmp_开头。备份表用于备份或抓取源表快照,名称以bak_开头。中间表和备份表定期清理。

  16. 【示范】一个较为规范的建表语句:

CREATE TABLE user_info ( 
    `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键', 
    `user_id` bigint(11) NOT NULL COMMENT '用户id', 
    `username` varchar(45) NOT NULL COMMENT '真实姓名', 
    `email` varchar(30) NOT NULL COMMENT '用户邮箱', 
    `nickname` varchar(45) NOT NULL COMMENT '昵称', 
    `birthday` date NOT NULL COMMENT '生日', 
    `sex` tinyint(4) DEFAULT '0' COMMENT '性别', 
    `short_introduce` varchar(150) DEFAULT NULL COMMENT '一句话介绍自己,最多50个汉字', 
    `user_resume` varchar(300) NOT NULL COMMENT '用户提交的简历存放地址', 
    `user_register_ip` int NOT NULL COMMENT '用户注册时的源ip', 
    `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', 
    `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', 
    `user_review_status` tinyint NOT NULL COMMENT '用户资料审核状态,1为通过,2为审核中,3为未 通过,4为还未提交审核',
    PRIMARY KEY (`id`), 
    UNIQUE KEY `uniq_user_id` (`user_id`), 
    KEY `idx_username`(`username`), 
    KEY `idx_create_time_status`(`create_time`,`user_review_status`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='网站用户基本信息'
  1. 【建议】创建表时,可以使用可视化工具。这样可以确保表、字段相关的约定都能设置上。实际上,我们通常很少自己写 DDL 语句,可以使用一些可视化工具来创建和操作数据库和数据表。可视化工具除了方便,还能直接帮我们将数据库的结构定义转化成 SQL 语言,方便数据库和数据表结构的导出和导入。

索引相关

  1. 【强制】InnoDB表必须主键为id int/bigint auto_increment,且主键值禁止被更新

  2. 【强制】InnoDB和MyISAM存储引擎表,索引类型必须为BTREE

  3. 【建议】主键的名称以pk_开头,唯一键以uni_uk_开头,普通索引以idx_开头,一律使用小写格式,以字段的名称或缩写作为后缀。

  4. 【建议】多单词组成的columnname,取前几个单词首字母,加末单词组成column_name。如: sample 表 member_id 上的索引:idx_sample_mid。

  5. 【建议】单个表上的索引个数不能超过6个

  6. 【建议】在建立索引时,多考虑建立联合索引,并把区分度最高的字段放在最前面。

  7. 【建议】在多表 JOIN 的SQL里,保证被驱动表的连接列上有索引,这样JOIN 执行效率最高。

  8. 【建议】建表或加索引时,保证表里互相不存在冗余索引。 比如:如果表里已经存在key(a,b), 则key(a)为冗余索引,需要删除。

SQL编写相关

  1. 【强制】程序端SELECT语句必须指定具体字段名称,禁止写成 *。

  2. 【建议】程序端insert语句指定具体字段名称,不要写成INSERT INTO t1 VALUES(…)。

  3. 【建议】除静态表或小表(100行以内),DML语句必须有WHERE条件,且使用索引查找。

  4. 【建议】INSERT INTO…VALUES(XX),(XX),(XX).. 这里XX的值不要超过5000个。 值过多虽然上线很快,但会引起主从同步延迟。

  5. 【建议】SELECT语句不要使用UNION,推荐使用UNION ALL,并且UNION子句个数限制在5个以内。

  6. 【建议】减少使用ORDER BY,和业务沟通能不排序就不排序,或将排序放到程序端去做。ORDER BY、GROUP BY、DISTINCT 这些语句较为耗费CPU,数据库的CPU资源是极其宝贵的。

  7. 【建议】包含了ORDER BY、GROUP BY、DISTINCT 这些查询的语句,WHERE 条件过滤出来的结果集请保持在1000行以内,否则SQL会很慢。

  8. 【建议】对单表的多次alter操作必须合并为一次,且必须留下alter语句的记录到git中。对于超过100W行的大表进行alter table,必须经过DBA审核,并在业务低峰期执行,多个alter需整合在一起。因为alter table会产生表锁,期间阻塞对于该表的所有写入,对于业务可能会产生极大影响。

  9. 【建议】批量操作数据时,需要控制事务处理间隔时间,进行必要的sleep。

  10. 【建议】事务里不要包含过多慢SQL。因为过长的事务会导致锁数据较久,MySQL内部缓存、连接消耗过多等问题。

  11. 【建议】事务里更新语句尽量基于主键或UNIQUE KEY,如UPDATE… WHERE id=XX;否则会产生间隙锁,内部扩大锁定范围,导致系统性能下降,产生死锁。

事务

概述

**事务:**一组逻辑操作单元,使数据从一种状态变换到另一种状态

**事务处理的原则:**保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方式。当在一个事务中执行多个操作时,要么所有的事务都被提交(commit),那么这些修改就永久地保存下来;要么数据库管理系统将放弃所作的所有修改,整个事务回滚(rollback)到最初状态。

事务的ACID特性:

一般来说,事务都需要满足以下特性:

  • 原子性(atomicity):

    事务是不可分割的工作单元,要么全部提交,要么全部回滚

  • 一致性(consistency):

    事务执行前后,从一个合法状态变换为另一个合法状态,即执行前后都满足业务的约束(符合业务的要求)

  • 隔离性(isolation):

    多个用户并发访问数据库,执行事务时,事务之间不会互相干扰

  • 持久性(durability)

    事务一旦被提交,数据的改变就是永久性的

事务的状态:

  • 活动的(active)

    事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。

  • 部分提交的(partially committed)

    当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态。

  • 失败的(failed)

    当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。

  • 中止的(aborted)

    如果事务执行了一部分而变为失败的状态,那么就需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态。

  • 提交的(committed)

    当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。

使用事务

显式事务

步骤1: START TRANSACTION 或者 BEGIN ,作用是显式开启一个事务。

mysql> BEGIN; 
#或者 
mysql> START TRANSACTION;

START TRANSACTION 语句相较于 BEGIN 特别之处在于,后边能跟随几个修饰符:

READ ONLY:标识当前事务是一个只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。

READ WRITE:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。

WITH CONSISTENT SNAPSHOT:启动一致性读。

**步骤2:**一系列事务中的操作(主要是DML,不含DDL)

**步骤3:**提交事务 或 中止事务(即回滚事务)

# 提交事务。当提交事务后,对数据库的修改是永久性的。
mysql> COMMIT;
# 回滚事务。即撤销正在进行的所有没有提交的修改 
mysql> ROLLBACK; 

# 将事务回滚到某个保存点。 
mysql> ROLLBACK TO [SAVEPOINT]

其中关于SAVEPOINT相关操作有:

# 在事务中创建保存点,方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。
SAVEPOINT 保存点名称;
# 删除某个保存点
RELEASE SAVEPOINT 保存点名称;

隐式事务

在MySQL中,如果没有显示地用 START TRANSACTION 或者 BEGIN 开启事务,那么每一条DML语句都会被包装成一个独立的事务并自动提交。

有一个系统变量 autocommit ,默认为 true ,可以将其修改为 false 来关闭自动提交效果。这样的话写入的多条DML语句都会属于同一个事务,直到手动提交或回滚。

隐式提交数据的情况

  • 当使用DDL语句、修改MySQL的表结构时,就会隐式地提交之前未提交的事务

  • 事务控制或关于锁定的语句

    • 当我们在一个事务还没提交或者回滚时就又使用 START TRANSACTION 或者 BEGIN 语句开启了另一个事务时,会隐式的提交上一个事务。

    • autocommit 为 true

    • 使用LOCK TABLESUNLOCK TABLES等关于锁定的语句也会隐式的提交前边语句所属的事务。

事务隔离级别

数据并发问题

对于 SQL 来说,服务端可能和多个客户端建立连接,每个客户端与服务端建立的连接被称为会话Session)。每个会话都可以在自己的会话中向服务器发送请求语句,请求语句可能是事务的一部分,当多个会话同时发送请求时,就会遇到数据并发问题。

  1. 脏写(Dirty Write)

对于了两个事务 Session ASession B,如果 Session A 修改Session B 还未提交的数据,就发生了脏写。若 Session B 回滚,那么 Session A 修改的数据就不存在了。

  1. 脏读(Dirty Read)

对于两个事务 Session ASession B,如果 Session A 读取Session B 还未提交的数据,就发生了脏读。若 Session B 回滚,那么 Session A 读取的数据就是临时且失效的。

  1. 不可重复读(Non-Repeatable Read)

对于两个事务 Session ASession B,如果 Session A 读取了一个字段,然后 Session B 更新了这个字段,之后 Session A 再次读取同一个字段,值就不同了,就发生了不可重复读Session A 的两次读取是在同一个事务中,因此逻辑上来说应当相同)

  1. 幻读(Phantom)

对于两个事务 Session ASession B,如果 Session A 读取了一个字段,然后 Session B 在表中插入了一些新的数据,之后 Session A 再次读取同一个字段,就会多出一些数据,就发生了幻读(如果 Session B 删除了一些数据,导致第二次读取到的数据少了,不属于幻读。幻读强调的是读取到了之前没有的记录)

严重程度排序:脏写 > 脏读 > 不可重复读 > 幻读

SQL的四种隔离级别

SQL标准中设立了4个隔离级别:

  • READ UNCOMMITTED:读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。

  • READ COMMITTED:读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读、幻读问题仍然存在。

  • REPEATABLE READ:可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别,MySQL中这个级别可以通过临键锁、MVCC解决幻读

  • SERIALIZABLE:串行化。在事务持续期间,禁止其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。

MySQL中设置事务的隔离级别

查看隔离级别

select @@transaction_isolation

设置隔离级别

SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 隔离级别; 
#其中,隔离级别格式: 
> READ UNCOMMITTED 
> READ COMMITTED 
> REPEATABLE READ 
> SERIALIZABLE

事务日志

事务有四大特性:原子性一致性隔离性永久性隔离性锁机制实现,原子性一致性持久性都由事务的redo日志undo日志来保证。

  • redo log:重做日志,提供再写入操作,恢复提交事务修改的页操作,保证事务的持久性

  • undo log:回滚日志,回滚行记录到某个特定的版本,用来保证事务的原子性一致性

redo日志

innoDB引擎是以页为单位来管理存储空间的,在真正访问磁盘中的页时,需要先把磁盘中的页缓存到内存中的 buffer pool 。所有的增删改查都必须先更新缓冲池中的数据,然后缓冲池中的数据(脏页,指内存中改动了但还未刷新到磁盘的页)再以一定的频率刷入磁盘(checkpoint机制)。

由于checkpoint不是每次更改都会触发的,因此如果在触发前数据库宕机,那么缓冲池中的数据就丢失了。为了保证数据库的持久性,所以引入了redo日志。

如果取消缓冲池,每次数据有更新就刷入磁盘,也可以解决持久性的问题。但是同样是磁盘文件操作,为什么redo日志要优于这种方法?

  • 当我们要修改页中的一条数据时,需要先把整个页都加载到内存中进行修改,修改完成后再刷入磁盘。频繁的读取会严重浪费性能,而redo日志只是记录了物理日志,并不会读取页;

  • 当同时需要修改多个页时,磁盘的随机IO性能太差,而redo日志只需要做顺序IO。

redo日志的组成

  • 重做日志缓冲(redo log buffer):保存在内存中的临时缓冲数据

redo log buffer 默认大小16MB,最大4096MB,最小1MB

查看当前 redo log buffer 大小:show variables like '%innodb_log_buffer_size%'

  • 重做日志文件(redo log file):保存在磁盘中的持久化文件

位置:/var/lib/mysql/ib_logfile0/var/lib/mysql/ib_logfile1

redo日志的流程

一个更新事务的流程

  1. 将原始数据从磁盘中读入内存中的缓冲池,在其中中修改数据

  2. 生成一条redo日志并将日志写入redo log buffer,记录的是数据被修改后的值

  3. 当事务commit时,将redo log buffer中的内容追加写入到redo log file

  4. 定期将缓冲池中的数据刷新到磁盘中

redo log的刷盘策略

redo log buffer刷盘到redo log file的过程并不是真正的刷到磁盘中去,只是刷入到**文件系统缓存(page cache)**中去(这是现代操作系统为了提高文件写入效率做的一个优化),真正的写入会交给系统自己来决定(比如page cache足够大了)。那么对于InnoDB来说就存在一个问题,如果交给系统来同步,同样如果系统宕机,那么数据也丢失了(虽然整个系统宕机的概率还是比较小的)。

针对这种情况,InnoDB给出 innodb_flush_log_at_trx_commit 参数,该参数控制 commit提交事务时,如何将 redo log buffer 中的日志刷新到 redo log file 中。它支持三种策略:

  • 设置为0:表示每次事务提交时不进行刷盘操作。(系统默认master thread每隔1s进行一次重做日志的同步)

  • 设置为1:表示每次事务提交时都将进行同步,刷盘操作(默认值

  • 设置为2:表示每次事务提交时都只把 redo log buffer 内容写入 page cache,不进行同步。由os自己决定什么时候同步到磁盘文件。

查看刷盘策略: show variables like 'innodb_flush_log_at_trx_commit'

不同的刷盘策略对事务的执行性能有不同的影响,性能由高到低:0 > 2 > 1

redo log file相关参数设置

  • innodb_log_group_home_dir:指定 redo log 文件组所在的路径,默认值为./,表示在数据库的数据目录下。MySQL的默认数据目录(var/lib/mysql)下默认有两个名为ib_logfile0ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。此redo日志文件位置还可以修改。

  • innodb_log_files_in_group:指明redo log file的个数,命名方式如:ib_logfile0,ib_logfile1... ib_logfilen。默认2个,最大100个。

  • innodb_log_file_size:单个 redo log 文件设置大小,默认值为 48M 。最大值为512G,注意最大值指的是整个 redo log 系列文件之和,即(innodb_log_files_in_group * innodb_log_file_size)不能大于最大值512G。

日志文件组

从上面可以看出,磁盘上的redo日志文件不止一个,而是以一个日志文件组的形式出现的。这些文件以 ib_logfile[数字] 的形式命名,每个redo日志文件大小是一样的。

redo日志在写入日志文件组时,从 ib_logfile0 开始写,如果写满了,就写 ib_logfile1 ;同理,当前一个文件写满时,就往下一个文件中写;当最后一个文件写满后,就重新回到 ib_logfile0 继续写。

checkpoint

【mysql】关于checkpoint机制 - 踏雪无痕SS - 博客园 (cnblogs.com)

在整个日志文件组中还有两个重要的属性: write pos、checkpoint

  • write pos 记录当前的位置,一边写一边后移

  • checkpoint 是要擦除的位置

上图中,checkpoint左边是已经刷盘的数据,可以放心覆盖;右边是还未刷盘的数据,需要等待刷盘,checkpoint向前走之后才能覆盖

小结

undo日志

参考:MySQL回滚日志(undo log)总结_每天都要进步一点点的博客-CSDN博客_undolog

undo日志用于存储每一个事务在执行更新数据之前的原始数据,以便于在事务回滚后恢复原数据,从而保证原子性。

undo日志的作用

  • 回滚数据

undo日志并不会将数据库物理地恢复到执行事务之前的样子,undo是逻辑日志,只能将数据库逻辑地恢复到原来的样子,数据结构和页本身并不能完全恢复。

  • MVCC(多版本并发控制)

当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据版本是怎样的,从而让用户能够读取到当前事务操作之前的数据(快照读)

undo日志的存储结构

InnoDB 对 undo log 的存储采用了分段方式进行存储(回滚段 rollback segment)。

一个回滚段中有1024个undo日志段,也就是说一个回滚段支持1024个undo日志操作。在InnoDB1.1之前,只支持1个回滚段。从InnoDB1.1开始,可以支持128个回滚段。从InnoDB1.2开始,可以通过参数对回滚段进行一些设置:

  • innodb_undo_directory:设置回滚段的存放路径,默认值为InnodDB存储引擎的目录

  • innodb_undo_logs:设置回滚段的数量,默认为128

  • innodb_undo_tablespaces:设置构成回滚段的表空间文件的数量

回滚段和事务的关系

  • 每个事务使用一个回滚段,每个回滚段在同一时刻可以服务于多个事务

  • 事务产生的undo日志会不断填充回滚段中的区,当前的区不够使用时,会扩展至下一个区。如果回滚段中的所有区都被占满,事务会覆盖最初的区(在可被覆盖的情况下)。

回滚段中的数据分类

  • 未提交的回滚数据:该数据所关联的事务并未提交,不能被其他事务的数据覆盖

  • 已经提交但未过期的回滚数据:该数据所关联的事务已经提交,但是不能被其他事务数据覆盖

  • 已经提交并且已经过期的数据:该数据所关联的事务已经提交,并且已经过期,会被优先覆盖

事务提交后并不能马上删除undo日志,因为可能还有其他事务要通过undo日志来得到之前版本的数据。

undo日志的类型

  • insert undo log

事务在 insert 过程中产生的 undo log ,只对事务本身可见,因此在事务提交后可以直接删除。

  • update undo log

事务在 update 和 delete 过程中产生的 undo log ,对其他事务可见,需要提供MVCC机制,因此不能再事务提交时删除。提交时放入 undo log 链表,等待 purge 线程进行最后的删除。

undo日志的生命周期

undo日志的生成

对于InnoDB引擎来说,每个行记录除了记录本身的数据外,还有几个隐藏列:

  • DB_ROW_ID:如果没有显式地为表指定主键,并且表中也没有唯一索引,那么 InnoDB 会自动为每一行生成,作为隐藏主键

  • DB_TRX_ID:每个事务都会分配一个事务ID,当事务对某条记录执行改动操作时,就会把这个ID写入到这里

  • DB_ROLL_PTR:指向 undo log 的指针

undo日志的回滚

针对上面的例子,执行 rollback 的流程:

  1. 通过 undo no=3 的日志把 id=2 的数据删除

  2. 通过 undo no=2 的日志把 id=1 的数据的 deletemark 还原成0

  3. 通过 undo no=1 的日志把 id=1 的数据的 name 还原成 Tom

  4. 通过 undo no=0 的日志把 id=1 的数据删除

undo日志的删除

  • 对于 insert undo log

由于只对事务本身可见,因此可以在事务提交后直接删除,不需要进行 purge 操作

  • 对于 update undo log

由于对其他事务可见,可能会需要提供MVCC机制。因此事务提交时,会被放入undo日志链表,等待purge线程进行删除。

purge的作用主要是清理undo页删除普通页中被标记的数据。在InnoDB中,事务中的DELETE操作只会把数据行标记为删除,需要等待purge线程执行真正的删除操作。

参考:MySQL purge 线程 - 腾讯云开发者社区-腾讯云 (tencent.com)

小结

参考:一张图彻底搞懂 MySQL 的锁机制 | MySQL 技术论坛 (learnku.com)

锁是一种用于管理共享资源的并发访问的机制,一般都由数据库或存储引擎根据当前的事务隔离级别自动添加,我们只需要了解它的机制即可。

MySQL几种并发情况

读-读并发

不会对数据有影响,不需要处理。

写-写并发

会发生脏写问题,SQL规范的任何一个隔离级别都用锁解决了这个问题。

读-写并发

可能发生脏读不可重复读幻读的问题

读-写并发问题的解决方案

  • 读写都加锁(读写操作彼此需要排队执行,性能低,但是读出来的数据始终是最新版本)

  • 读操作采用MVCC,写操作加锁(读写操作不冲突,性能高,无法保证读出来的数据最新)

锁的分类

从数据的操作类型划分

  • 共享锁/读锁/S锁:多个事务的读操作可以同时进行,不会相互影响也不会相互阻塞。

  • 排他锁/写锁/X锁:当前的写操作没有完成前,会阻塞其他写锁和读锁,确保在一段时间内,只有一个事务能执行写入,并且防止其他事务读取正在写入的资源。同理,在当前读操作执行时,也会阻塞其他的写操作。

在InnoDB中,共享锁和排他锁既可以作用在表上,也可以作用在行上。

读操作可以加共享锁和排他锁,写操作一般只加排他锁。

共享锁排他锁

共享锁

兼容

不兼容

排他锁

不兼容

不兼容

给读操作加锁

  • 给读操作加共享锁

    SELECT ... LOCK IN SHARE MODE;
    # 或
    SELECT ... FOR SHARE; #(8.0新增语法)
    # 加的是表锁
  • 给读操作加排他锁

    SELECT ... LOCK IN UPDATE MODE;
    # 或
    SELECT ... FOR UPDATE; #(8.0新增语法)
    # 加的是表锁

MySQL8.0新特性:

在5.7及之前的版本,执行 SELECT ... FOR UPDATE ,如果获取不到锁,会一直等待,直到超时(innodb_lock_wait_timeout变量)。在8.0版本后,添加NOWAITSKIP LOCKED语法,跳过锁等待,或者跳过锁定。(SELECT ... FOR UPDATE NOWAIT

  • NOWAIT:如果查询的行已经加锁,会立即报错返回

  • SKIP LOCKED:如果查询的行已经加锁,只返回结果中不包含被锁定的行

给写操作加锁

  • 给写操作加排他锁

    DELETE/INSERT/UPDATE ... LOCK IN UPDATE MODE;
    # 或
    SELECT ... FOR UPDATE; #(8.0新增语法)
    # 加的是表锁

写操作和锁的具体执行过程

  • DELETE:对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。

  • UPDATE:在对一条记录做UPDATE操作时分为三种情况:

    • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。

    • 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。新插入的记录由INSERT操作提供的隐式锁进行保护。

    • 情况3:修改该记录的键值,则相当于在原记录上做DELECT操作之后再来一次INSERT操作。

  • INSERT:一般情况下,新插入一条记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。

从锁的粒度划分

  • 表锁:锁定整张表,是MySQL的基本锁策略,不依赖于存储引擎。锁的粒度最大,冲突概率高,并发度低,开销低,不容易死锁

  • 行锁:锁定某一行,依赖于存储引擎实现。锁的粒度最小,冲突概率低,并发度高,开销大,容易出现死锁

  • 页锁:锁定某一页,各项属性介于表锁和行锁之间

每个层级的锁的数量是有限制的。由于锁会占用内存,锁空间的大小也有限制。当某个层级的锁的数量超过这个层级的上限时,就会进行锁升级,即用大粒度的锁取代小粒度的锁,从而降低锁空间的内存占用,但是会降低并发度

不同粒度的锁之间不能共存。

表锁分类

  • 共享锁、排他锁

    InnoDB对某个表执行DML语句时(CRUD),不会自动添加表级的S锁和X锁;执行 ALTER TABLE 之类的DDL语句时,阻塞其他的DML语句;同理,执行DML语句也会阻塞DDL语句。而MyISAM在执行查询语句前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。

    手动给表加锁:lock tables t read/write (一般不用)

    查看加锁的表:show open tables where in_use > 0

    手动解锁所有表:unlock tables

  • 意向锁(intention lock)

    意向锁是一种特殊的表锁,它可以和行锁共存意向锁的作用是让粒度更高的锁知道其中是否上过粒度小的锁(因此意向锁相互之间都是兼容的)。如果没有意向锁,当一个事务想要给一张表加表锁时,需要遍历该表的所有行,查看其中是否有行锁。

    当我们给某一行数据加上行锁时,会自动给更高粒度的空间(页、表)上一个意向锁。这样当其他事务需要给这个空间上更高粒度的锁时,就不用再遍历了。

    意向锁也分为意向共享锁,意向排他锁,自动根据行锁的类型进行选择。

  • 自增锁(auto inc)

    当表中有自增字段(auto increment)时,为了确保自增字段是连续自增的,就需要自增锁来实现。当执行插入时,就会自动添加一个表级的自增锁,执行完毕后再释放。由于每条插入语句都需要参与自增锁的竞争,并发度很低,所以可以通过 innodb_autoinc_lock_mode 变量来改变锁定机制。

    MySQL的插入分成三种:简单插入、批量插入、混合插入。

    简单插入是指可以预先知道插入的行数的语句,例如没有嵌套子查询的 insert;

    批量插入是指不能预先知道插入的行数的语句,例如嵌套子查询的 insert;

    混合插入与简单插入类似,但是部分数据手动指定了自动递增字段的值。

    innodb_autoinc_lock_mode = 0

    传统的模式,每个插入语句都添加一个表级自增锁。

    innodb_autoinc_lock_mode = 1

    MySQL8.0之前的默认值。在这种情况下,批量插入仍然使用自增锁,但是简单插入则使用 mutex (轻量级锁,只在分配过程中保持)来获取所需数量的自动低增值。

    innodb_autoinc_lock_mode = 2

    MySQL8.0后的默认值。在这种情况下,所有的插入语句都不会使用自增锁,但是执行批量插入时,生成的自增字段的值可能不连续。

    更多关于自增字段的细节参考:MySQL自增主键为什么不连续 - 腾讯云开发者社区-腾讯云 (tencent.com)

  • 元数据锁(DML)

    元数据锁的作用是保证读写的正确性不被表结构影响。

    当对表做CRUD操作时,自动加元数据读锁;当对表结构做变更操作时,自动加元数据写锁。

    读锁与读锁兼容,读锁与写锁、写锁与写锁不兼容。

行锁分类

  • 记录锁(record locks)

    字面意思,给一条行记录加锁,也是最常用的锁。记录锁也分为读锁和写锁,规则与表级的相同。

  • 间隙锁(gap locks)

    间隙锁用于解决幻读问题(也可以用MVCC解决)。

    插入间隙锁后,不允许其他事务在两条记录之间插入新数据。

  • 临键锁(Next-key locks)

    相当于是记录锁和间隙锁的结合体,是InnoDB的默认锁。

从对待锁的态度划分

  • 悲观锁:总是假设最坏的情况,每次拿数据时都会加锁。例如行锁、表锁、读锁、写锁等。

  • 乐观锁:认为并发操作是小概率事件,不对操作加锁,而是在更新时判断在此期间数据有没有被改动。可以通过版本号或CAS机制实现。(JUC的atomic就是通过CAS实现的)

悲观锁和乐观锁是锁的设计思想,而不是具体的某个锁。

乐观锁版本号机制

在表中设计一个version字段,对行数据的更新操作执行都执行以下步骤:

  1. 读取行数据和version的值。

  2. 在内存中对行数据进行操作。

  3. 再次读取version的值。

  4. version在3中的值与1中的值进行比较,如果相同则将行数据更新到磁盘,并且把磁盘中的version值+1;如果不同则从1重新开始。

两种锁的适用场景

  • 乐观锁:适合读多写少。

  • 悲观锁:适合写多读少。

从加锁的方式划分

  • 显式锁:(存储引擎或数据库自动生成、手动添加)创建锁结构来起到锁的作用。

  • 隐式锁:不创建锁结构,也可以起到锁的作用。

隐式锁的主要应用 场景是插入语句。每条行记录(聚簇索引的叶子节点)中都有一个trx_id属性,表示最近对这条记录进行操作的事务的id。如果有事务2要对这条数据添加锁,会先看这条记录的trx_id表示的事务1是否处于活跃状态。如果是,则表明该条数据还在被事务1操作中,那么事务2会帮其创建一个锁,并且自身进入等待事务1的状态中。这种情况就是隐式锁转化为显式锁。

参考:MySQL InnoDB隐式锁功能解析 - 知乎 (zhihu.com)

全局锁

对整个数据库进行加锁,让整个库处于只读状态。

使用场景:全库逻辑备份。

死锁

两个事务互相持有对方需要的锁,并且等待对方释放,双方都不会释放自己的锁。

产生死锁的必要条件

  1. 两个或以上的事务

  2. 每个事务都已经持有锁并且申请新的锁

  3. 锁资源同时只能被同一个事务持有或者不兼容

  4. 事务之间因为持有锁和申请锁导致彼此循环等待

死锁的关键在于每个事务加锁的顺序不一致。如果一致,不会形成死锁。

解决死锁的方法

  1. 等待,直到超时

    两个事务相互等待时,当一个事务等待事件超过阈值时,就将其回滚,从而释放锁,让另一个事务继续执行。通过innodb_lock_wait_timeout设置等待时间,默认50s。

    缺点:等待时间不好设置,太长影响业务正常执行,太短容易误伤正常事务的的等待。

  2. 使用死锁检测进行处理

    使用wait-for graph算法检测死锁。innodb_deadlock_detect开启或关闭。

    构建出以事务为点,锁为边的有向图,如果图中存在环,则存在死锁。innobb引擎就会选择回滚undo量最小的事务,让其他事务继续执行。

    缺点:算法本身需要耗费时间,如果同时并发的事务太多,会影响性能。

    解决方法:用其他中间件对更新相同行的操作进行排队。

如何避免死锁

  • 合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争。

  • 调整业务SQL执行顺序,避免update/delete等长时间持有锁的SQL在事务前面。

  • 避免大事务,尽量拆分成多个小事务处理。

  • 降低隔离级别。

  • 在并发高的场景下不要在事务中手动加锁。

参考:彻底搞懂MySQL死锁_AlbenXie的博客-CSDN博客_mysql死锁

锁的内存结构

给一条记录加锁的本质就是在内存中创建一个与之关联的锁结构。

加锁时,并不会对每条记录都创建一个锁结构,而是为了节约空间,将满足一些条件的记录都用同一个锁结构表示:

  • 同一个事务中的加锁操作

  • 被加锁的记录在同一个页中

  • 加锁的类型一样

  • 等待状态一样

结构解析

  1. 锁所在的事务信息:记录锁的基础信息的指针。

  2. 索引信息:(行锁特有)记录加锁的记录的索引信息的指针。

  3. 表锁/行锁信息:

    • 表锁:记录当前表和一些其他信息。

    • 行锁:记录当前行所在的表空间(Space ID)、页号(Page Number)、行标记(n_bits)。

  4. type_mode:

    一个32位的数,被分为 lock_modelock_typerec_lock_type 三个部分。

    • lock_mode:表示当前锁的模式

      • LOCK_IS(十进制的0):表示共享意向锁,也就是IS锁。

      • LOCK_IX(十进制的1):表示独占意向锁,也就是IX锁。

      • LOCK_S(十进制的2):表示共享锁,也就是S锁。

      • LOCK_X(十进制的3):表示独占锁,也就是X锁。

      • LOCK_AUTO_INC(十进制的4):表示AUTO-INC锁。

    • lock_type:表示当前锁的类型

      • LOCK_TABLE(十进制的1,即第1个bit为1):表示表级锁

      • LOCK_REC(十进制的2,即第2个bit为1):表示行级锁

    • rec_lock_type:行锁的具体类型

      • LOCK_ORDINARY(十进制的0):表示next-key锁。

      • LOCK_GAP(十进制的512,即第10个bit为1):表示gap锁。

      • LOCK_REC_NOT_GAP(十进制的1024,即第11个bit为1):表示记录锁。

      • LOCK_INSERT_INTENTION(十进制的2048,即第11个bit为1):表示插入意向锁。

      • 补充:十进制的1,即第1个bit为1时,表示is_waiting=true,即当前事务处在等待状态,尚未获取到锁;为0时,表示is_waiting=false,即当前事务获取锁成功。

  5. 其他信息:为了更好的管理各种锁结构而设计的哈希表和链表。

  6. 比特位:如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits属性表示的。InnoDB数据页中的每条记录在记录头信息中都包含一个 heap_no 属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,即一个比特位映射到页内的一条记录。

锁的监控

mysql> show status like 'innodb_row_lock%';
  • Innodb_row_lock_current_waits:当前正在等待锁定的数量;

  • Innodb_row_lock_time:从系统启动到现在锁定总时间长度;(等待总时长)

  • Innodb_row_lock_time_avg:每次等待所花平均时间;(等待平均时长)

  • Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;

  • Innodb_row_lock_waits:系统启动后到现在总共等待的次数;(等待总次数)

其他监控方法:

MySQL把事务和锁的信息记录在了information_schema库中,涉及到的三张表分别是INNODB_TRXINNODB_LOCKSINNODB_LOCK_WAITS

MySQL5.7及之前,可以通过information_schema.INNODB_LOCKS查看事务的锁情况,但只能看到阻塞事务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。

MySQL8.0删除了information_schema.INNODB_LOCKS,添加了performance_schema.data_locks,可以通过performance_schema.data_locks查看事务的锁情况,和MySQL5.7及之前不同,performance_schema.data_locks不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。

同时,information_schema.INNODB_LOCK_WAITS也被performance_schema.data_lock_waits所代替。

多版本并发控制(MVCC)

参考:MySQL 如何解决幻读(MVCC 原理分析) - 掘金 (juejin.cn)

概述

MVCC(Multiversion Concurrency Control),多版本并发控制。通过数据行的多个版本管理来实现数据库的并发控制。相比于用锁来实现并发控制,MVCC的并发能力更高(只有写-写之间相互阻塞),但是无法保证读出的数据是最新版本。

快照读和当前读

  • 快照读

    又叫一致性读,读取的是快照数据而不是实时的最新数据。不会与写操作冲突。不加锁的简单的SELECT都是快照读。快照读的幻读由MVCC解决。

  • 当前读

    读取的是最新版本,与写操作冲突,要保证读取过程中其他并发事务不能修改当前记录。加锁的SELECT或增删改操作都会执行当前读。当前读的幻读由临键锁解决。

MVCC实现原理

MVCC实现依赖于:版本链(trx_id和roll_pointer)、Undo日志、ReadView

版本链

之前讲过在InnoDB行格式中,每个聚簇索引都包含三个隐藏列

列名是否必须说明

row_id

创建的表中有主键或者非 NULL的 UNIQUE 键时都不会包含 row_id 列

trx_id

事务ID,每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列

roll_pointer

回滚指针,每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后用 roll_pointer 指向这个旧的版本。同时,旧的版本也会有一个自己的 roll_pointer 指向更旧的一个版本。

每次对记录进行改动,都会生成一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,就是版本链。

Undo日志

Undo日志除了可以保证事务在rollback时的原子性和一致性,还可以用于存放MVCC的快照读的数据。

ReadView

上面说到,改动的记录都在undo日志中,那如何选择到底读取哪个版本的记录呢?

  • 对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。

  • 对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录,不存在并发问题。

  • 对于使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的

核心问题就是:READ COMMITTEDREPEATABLE READ 隔离级别在不可重复读和幻读上的区别在哪里?这两种隔离级别对应的不可重复读幻读都是指同一个事务在两次读取记录时出现不一致的情况,这两种隔离级别关键是需要判断版本链中的哪个版本是当前事务可见的

ReadView 就可以用来帮助我们解决可见性问题。事务进行快照读操作的时候就会产生 ReadView,它保存了当前事务开启时所有活跃的事务列表(活跃指的是未提交的事务)。

ReadView中主要保存了以下几个比较重要的内容:

  1. creator_trx_id,创建这个 ReadView 的事务 ID。

说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。

  1. m_ids,生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。

  2. min_trx_id,生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id 也就是 m_ids 中的最小值。

  3. max_trx_id,表示生成ReadView时系统中应该分配给下一个事务的id值。

注意:max_trx_id 并不是 m_ids 中的最大值,事务id是递增分配的。比如,现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。

在有了 ReadView 之后,在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  1. trx_id = creator_trx_id可访问

    如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

  2. trx_id < min_trx_id可访问

    如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  3. trx_id >= max_trx_id不可访问

    如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。

  4. min_trx_id <= trx_id < max_trx_id,并且存在 m_ids 列表中,不可访问

    如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

  5. 某个版本的数据对当前事务不可见

    如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同

当事务处在READ COMMITTED中,事务中的每条读语句都会重新生成一个ReadView,这意味着历史版本对于这个事务的读操作是会不断变化的,因此有可能导致连续的两次读取内容不同,也就是不可重复读。

当事务处在REPEATABLE READ中,事务中只有第一条读语句会生成一个ReadView,后面的所有读操作都会沿用第一次的ReadView,从而保证每次读取的内容都一致。这样也就一次性解决了不可重复读和幻读的问题。

需要注意的一点:因为ReadView是只对快照读生效的,所以MVCC并不能完全解决幻读问题。当前读的幻读问题需要Next-key Locks解决。

总结

MVCC在可重复读的隔离级别下解决了以下问题:

  1. 通过历史版本,让读-写操作可以并发执行,提高了并发效率。

  2. 解决了脏读、不可重复读、(快照读情况下)幻读。

其他数据库日志

参考:[玩转MySQL之八]MySQL日志分类及简介 - 知乎 (zhihu.com)

在MySQL中,除了之前提到的Redo日志和Undo日志外,还有一些其他不同功能的日志。

  • 慢查询日志:记录所有执行时间超过long_query_time的查询。

  • 通用查询日志:记录所有链接的起始时间和终止时间,以及连接发送给数据库服务器的所有指令。

  • 错误日志:记录MySQL服务的启动、运行、停止时遇到的各种错误。

  • 二进制日志:记录所有更改数据的语句,用于主从服务器之间的数据同步,以及服务器遇到故障后的恢复。

  • 中继日志:用于主从服务器架构中,从服务器用来存放主服务器二进制日志内容的一个中间文件(MySQL8.0以后新增)。

  • 数据定义语言日志:记录数据定义语句执行的元数据操作(MySQL8.0以后新增)。

除了二进制日志,其他日志都是文本文件。默认情况下,所有日志都创建在MySQL数据目录中。

慢查询日志

前面性能分析工具写过了,不再赘述。

通用查询日志

通用查询日志用来记录用户的所有操作,包括启动和关闭MySQL服务、所有用户的连接开始时间和截止时间、发给 MySQL 数据库服务器的所有 SQL 指令等。

由于通用查询日志记录的数据非常多,因此只建议在测试环境下开启。

查看当前通用查询日志的状态

mysql> show variables like '%general_log%';
+------------------+------------------------------+
| Variable_name    | Value                        |
+------------------+------------------------------+
| general_log      | OFF                          |
| general_log_file | /var/lib/mysql/DB-Server.log |
+------------------+------------------------------+
  • 参数general_log用来控制开启、关闭MySQL查询日志

  • 参数general_log_file用来控制查询日志的位置

设置通用查询日志的存储方式

可以通过log_output设置通用查询日志的存储方式:

  • FILE:表示日志存储在文件中。

  • TABLE:表示日志存储在mysql库中的general_log表中。

  • FILE, TABLE:表示将日志同时存储在文件和general_log表中,会徒增很多IO压力,一般不会这样设置。

  • NONE:表示不记录日志,即使general_log设置为ON,如果log_output设置为NONE,也不会记录查询日志。

mysql> show variables like 'log_output';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_output    | FILE  |
+---------------+-------+

注意:log_output参数不止用于设置通用查询日志的存储方式,也同样会影响慢查询日志。

开启/关闭通用查询日志

  • 方法一:修改my.cnf配置文件,需要重启MySQL服务,修改后永久生效。

    [mysqld]
    general_log = 1/0
  • 方法二:命令行设置,重启后会失效。

    set global general_log = 1/0

错误日志

错误日志默认开启,并且无法被关闭。默认情况下,错误日志存储在数据库的数据文件目录中,名称为hostname.err,其中,hostname为服务器主机名。

查看错误日志配置

mysql> show variables like 'log_err%';
+----------------------------+----------------------------------------+
| Variable_name              | Value                                  |
+----------------------------+----------------------------------------+
| log_error                  | ./fengye.err                           |
| log_error_services         | log_filter_internal; log_sink_internal |
| log_error_suppression_list |                                        |
| log_error_verbosity        | 2                                      |
+----------------------------+----------------------------------------+

删除错误日志

MySQL5.5.7之前,可以用mysqladmin –u root –pflush-logs命令,直接将旧文件重命名为filename.err._old,并创建新文件;从MySQL5.5.7开始,只能手动重命名后,开启一个新的错误日志文件:

[root@fengye data]# mv fengye404.err  fengye404.err._old
[root@fengye data]# mysqladmin flush-logs

二进制日志

参考:mysql binlog详解 - Presley - 博客园 (cnblogs.com)

MySQL的二进制日志(binary log)是一个二进制文件,主要记录所有数据库表结构变更(例如CREATE、ALTER TABLE…)以及表数据修改(INSERT、UPDATE、DELETE…)的所有操作,并且记录了语句发生时间、执行时长、操作数据等其它额外信息。

binlog的应用场景

  • 数据恢复:如果MySQL服务意外停止,可以通过binlog来恢复。

  • 数据复制:主数据库向从数据库复制数据。

binlog和redolog的区别

  1. redolog由InnoDB产生;binlog由MySQL数据库产生。

  2. redolog是物理日志,记录了”某个页上做了什么修改“;biglog是逻辑日志,存储对应的SQL语句。

  3. redolog的空间是固定的,循环写入;binlog空间没有限制,追加写入。

  4. redolog一般对于用户不可见,由存储引擎维护并保证数据库崩溃时事务的持久性;binlog用于人工恢复数据。

查看binlog配置

mysql> show variables like '%log_bin%';
+---------------------------------+----------------------------------+
| Variable_name                   | Value                            |
+---------------------------------+----------------------------------+
| log_bin                         | ON                               |
| log_bin_basename                | /www/server/data/mysql-bin       |
| log_bin_index                   | /www/server/data/mysql-bin.index |
| log_bin_trust_function_creators | OFF                              |
| log_bin_use_v1_row_events       | OFF                              |
| sql_log_bin                     | ON                               |
+---------------------------------+----------------------------------+
  • log_bin:是否开启binlog,MySQL8默认开启

  • log_bin_basename:binlog日志不止一个文件,由多个文件组成(每次MySQL重启都会创建一个新的binlog)。这个参数表示binlog的基本文件名,每个文件后面都会追加标识来表示每个文件。

  • log_bin_index:binlog文件的索引文件。由于binlog可能包含很多文件,因此需要一个索引来管理。

  • log_bin_trust_function_creators:是否可以创建存储过程。(具体参考:MySQL参数log_bin_trust_function_creators介绍 - 潇湘隐者 - 博客园 (cnblogs.com)

修改binlog配置

  • 方法一:修改my.cnf配置文件,需要重启MySQL服务,修改后永久生效。

    [mysqld]
    log-bin=fengye404-bin
    # binlog的基础文件名,当带上路径时,也会同时指定文件存放的路径
    binlog_expire_logs_seconds=3600
    # binlog文件的过期时间,单位是秒,超过时间后会删除,不设置默认30天
    max_binlog_size=100M
    # 单个binlog文件的大小,超过大小时会自动创建新的文件,不设置默认1GB
  • 方法二:命令行设置,重启后会失效(不支持global,只支持session)。

    mysql> SET sql_log_bin=0; 

查看binlog列表

mysql> show binary logs;
+------------------+-----------+-----------+
| Log_name         | File_size | Encrypted |
+------------------+-----------+-----------+
| mysql-bin.000014 |  21784592 | No        |
| mysql-bin.000015 |  11311449 | No        |
+------------------+-----------+-----------+

查看binlog内容

由于binlog是二进制文件,无法直接查看,需要借助mysqlbinlog命令工具

mysqlbinlog "/www/server/data/mysql/mysql-bin.000015"

除了使用mysqlbinlog工具,还可以使用下面这种更加方便的查询命令:

mysql> show binlog events [IN 'log_name'] [FROM pos] [LIMIT [offset,] row_count];
  • IN 'log_name':指定要查询的binlog文件名(不指定就是第一个binlog文件) 

  • FROM pos:指定从哪个pos起始点开始查起(不指定就是从整个文件首个pos点开始算)

  • LIMIT [offset]:偏移量(不指定就是0)

  • row_count:查询总条数(不指定就是所有行)

例子:show binlog events in "/www/server/data/mysql/mysql-bin.000015";

查看binlog格式

mysql> show variables like 'binlog_format';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| binlog_format | MIXED |
+---------------+-------+
  • STATEMENT:记录每一条修改数据的SQL语句。

    优点:不需要记录每一行的变化,减少了binlog的日志量,节约IO,提高性能。

  • ROW:不记录SQL语句,而是记录那些行被修改。

    优点:清楚地记录每一行数据修改地细节,不会出现某些特定情况下存储过程、函数、trigger的调用导致的无法正确复制的问题。

  • MIXED:Statement和Row的结合

使用binlog恢复数据

mysqlbinlog [option] <filename> | mysql –u [username] -p [password] -v [database];
  • filename:文件完整路径

  • option:可选参数。比较重要的两对option参数是--start-date、--stop-date 和 --start-position、-- stop-position。

    • --start-date--stop-date:可以指定恢复数据库的起始时间点和结束时间点。

    • --start-position--stop-position:可以指定恢复数据的开始位置和结束位置。

  • username、password、database:用户名、密码、指定的数据库

注意:使用binlog恢复数据的同时,也相当于对数据库中的数据进行改动。因此恢复数据的操作也会被同时写入binlog。因此在恢复之前最好使用flush logs重新开启一个新的binlog文件。

删除binlog文件

mysql>PURGE {MASTER | BINARY} LOGS TO '指定日志文件名';
# 删除指定文件之前的(不包括指定文件)文件
mysql>PURGE {MASTER | BINARY} LOGS BEFORE '指定日期';

中继日志

中继日志只在主从服务器架构的从服务器上存在。从服务器为了与主服务器保持一致,要从主服务器读取binlog的内容,并且把读取到的信息写入本地的日志文件中,这个从服务器本地的日志文件就叫中继日志。然后,从服务器读取中继日志,并根据中继日志的内容对从服务器的数据进行更新,完成主从服务器的数据同步。

文件名格式:从服务器名 -relay-bin.序号。中继日志也有一个索引文件从服务器名 -relay-bin.index

中继日志的格式与二进制日志相同,也需要用mysqlbinlog查看

主从复制

参考:小白都能懂的Mysql主从复制原理(原理+实操) - 知乎 (zhihu.com)

在实际的生产环境中,一般都是读多写少,为了提高性能,会采用主从复制的方式进行读写分离。即在主数据库(master)中写入数据,从数据库(slave)中读取数据。

原理

主从复制的过程主要由三个线程参与:

  • master(binlog dump thread):主库线程,主库数据更新时,将更新事件写入主库的binlog,并且通知从库数据有更新。

  • slave(I/O thread):从库线程,读取主库的binlog并写入从库的relay log。

  • slave(SQL thread):从库线程,读取从库的relay log并执行SQL语句,将数据更新到从库的表中。

注意:要实现主从复制,必须要求主库开启binlog

搭建主从复制

主机配置文件

my.cnf

[mysqld]
# [必选] 主服务器唯一ID
server-id=1

# [必选] 启用binlog,并指定基础文件名,当带上路径时,也会同时指定文件存放的路径
log-bin=fengye404

# [可选] 0(默认)表示读写(主机),1表示只读(从机)
read-only=0

# [可选] binlog保留时长,单位为秒,不填默认30天
binlog_expire_logs_seconds=6000

# [可选] 单个binlog文件最大大小,默认1GB
max_binlog_siez=200M

# [可选] 忽略的数据库,一般忽略mysql自带的数据库
binlog-ignore-db=mysql
binlog-ignore-db=information_schema
binlog-ignore-db=performance_schema
binlog-ignore-db=sys

# [可选] 记录binlog的数据库,默认全部
binlog-do-db=test

# [可选] 设置binlog格式
binlog_format=MIXED

从机配置文件

[mysqld]
# [必选] 从服务器唯一ID
server-id=2

# [必选] 启用relaylog,并指定基础文件名,当带上路径时,也会同时指定文件存放的路径
relay-log=fengye404

# [可选] 0(默认)表示读写(主机),1表示只读(从机)
read-only=0

由于每台服务器的情况不同,剩下的部分自己实操吧,懒得写了

主从复制的一致性问题

根据上面讲的主从复制的原理,很容易想象到,其实主库和从库的内容不是实时同步的,其中可能会由于一些网络传输问题而存在一定的延迟。这样就会造成读写分离时读库的数据不是最新数据,也就是会发生主从同步中的数据不一致问题。按照数据一致性从弱到强,有三种数据同步策略。

异步复制

主库开启事务,更新完数据后可以直接提交,不需要等从库返回任何结果。

优点是不会影响主库写的效率,缺点是数据一致性弱。

半同步复制

主库开启事务,更新完数据后可以必须等待至少一个从库接收到了binlog并写入到中继日志中后,才能提交。可以通过rpl_semi_sync_master_wait_for_slave_count参数设置需要多少个从库响应。

优点是数据一致性相比于异步复制提高了很多,缺点是主库的写入性能收到影响

组复制

半同步复制虽然一定程度上提高了数据的一致性,但是由于其需要从库响应来判断是否提交,所以无法满足对数据一致性要求很高的场景。

组复制技术,简称MGR(MySQL Group Replication),是MySQL5.7.17以后推出的新的数据复制技术,是基于Paxos协议的状态机复制。

首先我们将多个节点共同组成一个复制组,在执行读写事务的时候,需要通过一致性协议层(Consensus 层)的同意,也就是读写事务想要进行提交,必须要经过组里“大多数人”(对应 Node 节点)的同意,大多数指的是同意的节点数量需要大于 (N/2+1),这样才可以进行提交,而不是原发起方一个说了算。而针对只读事务则不需要经过组内同意,直接 COMMIT 即可。

全文大致参考:MySQL数据库教程_bilibili

Last updated