第二部分 MySQL高级特性篇

第01章 Linux下MySQL的安装与使用

1. 安装前说明

1.1 查看是否安装过MySQL

  • 如果你是用rpm安装, 检查一下RPM PACKAGE:
rpm -qa | grep -i mysql # -i 忽略大小写
  • 检查mysql service:
systemctl status mysqld.service

1.2 MySQL的卸载

1. 关闭 mysql 服务

systemctl stop mysqld.service

2. 查看当前 mysql 安装状况

rpm -qa | grep -i mysql
# 或
yum list installed | grep mysql

3. 卸载上述命令查询出的已安装程序

yum remove mysql-xxx mysql-xxx mysql-xxx mysqk-xxxx

务必卸载干净,反复执行rpm -qa | grep -i mysql确认是否有卸载残留

4. 删除 mysql 相关文件

  • 查找相关文件
find / -name mysql
  • 删除上述命令查找出的相关文件
rm -rf xxx

5.删除 my.cnf

rm -rf /etc/my.cnf

2. MySQL的Linux版安装

2.1 CentOS7下检查MySQL依赖

1. 检查/tmp临时目录权限(必不可少)

由于mysql安装过程中,会通过mysql用户在/tmp目录下新建tmp_db文件,所以请给/tmp较大的权限。执行 :

chmod -R 777 /tmp

2. 安装前,检查依赖

rpm -qa|grep libaio
rpm -qa|grep net-tools

2.2 CentOS7下MySQL安装过程

1. 将安装程序拷贝到/opt目录下

在mysql的安装文件目录下执行:(必须按照顺序执行)

rpm -ivh mysql-community-common-8.0.25-1.el7.x86_64.rpm 
rpm -ivh mysql-community-client-plugins-8.0.25-1.el7.x86_64.rpm 
rpm -ivh mysql-community-libs-8.0.25-1.el7.x86_64.rpm 
rpm -ivh mysql-community-client-8.0.25-1.el7.x86_64.rpm 
rpm -ivh mysql-community-server-8.0.25-1.el7.x86_64.rpm
  • rpm是Redhat Package Manage缩写,通过RPM的管理,用户可以把源代码包装成以rpm为扩展名的文件形式,易于安装。
  • -i, --install 安装软件包
  • -v, --verbose 提供更多的详细信息输出
  • -h, --hash 软件包安装的时候列出哈希标记 (和 -v 一起使用效果更好),展示进度条

若存在mariadb-libs问题,则执行yum remove mysql-libs即可

2.3 查看MySQL版本

mysql --version 
#或
mysqladmin --version

2.4 服务的初始化

为了保证数据库目录与文件的所有者为 mysql 登录用户,如果你是以 root 身份运行 mysql 服务,需要执行下面的命令初始化:

mysqld --initialize --user=mysql

说明: --initialize 选项默认以“安全”模式来初始化,则会为 root 用户生成一个密码并将该密码标记为过期,登录后你需要设置一个新的密码。生成的临时密码会往日志中记录一份。

查看密码:

cat /var/log/mysqld.log

root@localhost: 后面就是初始化的密码

2.5 启动MySQL,查看状态

#加不加.service后缀都可以 
启动:systemctl start mysqld.service 
关闭:systemctl stop mysqld.service 
重启:systemctl restart mysqld.service 
查看状态:systemctl status mysqld.service

2.6 查看MySQL服务是否自启动

systemctl list-unit-files|grep mysqld.service
  • 如不是enabled可以运行如下命令设置自启动
systemctl enable mysqld.service
  • 如果希望不进行自启动,运行如下命令设置
systemctl disable mysqld.service

3. MySQL登录

3.1 首次登录

通过mysql -hlocalhost -P3306 -uroot -p进行登录,在Enter password:录入初始化密码

3.2 修改密码

ALTER USER 'root'@'localhost' IDENTIFIED BY 'new_password';

3.3 设置远程登录

1. 确认网络

1.在远程机器上使用ping ip地址保证网络畅通

2.在远程机器上使用telnet命令保证端口号开放访问

2. 关闭防火墙或开放端口

方式一:关闭防火墙

  • CentOS6 :
service iptables stop
  • CentOS7:
#开启防火墙
systemctl start firewalld.service
#查看防火墙状态
systemctl status firewalld.service
#关闭防火墙
systemctl stop firewalld.service
#设置开机启用防火墙 
systemctl enable firewalld.service 
#设置开机禁用防火墙 
systemctl disable firewalld.service

方式二:开放端口

  • 查看开放的端口号
firewall-cmd --list-all
  • 设置开放的端口号
firewall-cmd --add-service=http --permanent
firewall-cmd --add-port=3306/tcp --permanent
  • 重启防火墙
firewall-cmd --reload

4. Linux下修改配置

  • 修改允许远程登陆
use mysql;
select Host,User from user;
update user set host = '%' where user ='root';
flush privileges;

%是个 通配符 ,如果Host=192.168.1.%,那么就表示只要是IP地址前缀为“192.168.1.”的客户端都可以连接。如果Host=%,表示所有IP都有连接权限。

注意:在生产环境下不能为了省事将host设置为%,这样做会存在安全问题,具体的设置可以根据生产环境的IP进行设置。

配置新连接报错:错误号码 2058,分析是 mysql 密码加密方法变了。

**解决方法一:**升级远程连接工具版本

解决方法二:

ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'abc123';

5. 字符集的相关操作

5.1 各级别的字符集

show variables like 'character%';
  • character_set_server:服务器级别的字符集
  • character_set_database:当前数据库的字符集
  • character_set_client:服务器解码请求时使用的字符集
  • character_set_connection:服务器处理请求时会把请求字符串从character_set_client转为character_set_connection
  • character_set_results:服务器向客户端返回数据时使用的字符集

小结

  • 如果创建或修改列时没有显式的指定字符集和比较规则,则该列默认用表的字符集和比较规则
  • 如果创建表时没有显式的指定字符集和比较规则,则该表默认用数据库的字符集和比较规则
  • 如果创建数据库时没有显式的指定字符集和比较规则,则该数据库默认用服务器的字符集和比较规则

5.2 请求到响应过程中字符集的变化

graph TB
A(客户端) --> |"使用操作系统的字符集编码请求字符串"| B(从character_set_client转换为character_set_connection)
B --> C(从character_set_connection转换为具体的列使用的字符集)
C --> D(将查询结果从具体的列上使用的字符集转换为character_set_results)
D --> |"使用操作系统的字符集解码响应的字符串"| A

第02章 MySQL的数据目录

1. MySQL8的主要目录结构

find / -name mysql

1.1 数据库文件的存放路径

show variables like 'datadir'; # /var/lib/mysql/

这个数据库文件的存放路径,就相当于在windows中c盘 program data目录下的,存放数据库里的数据的路径

里面有我们创建的表以及表中的数据,这是mysql的数据路径。

在my.cnf文件中还能配置mysql的baseDir,这是mysql的安装路径。

1.2 相关命令目录

相关命令目录:/usr/bin 和/usr/sbin。

1.3 配置文件目录

配置文件目录:/usr/share/mysql-8.0(命令及配置文件),/etc/mysql(如my.cnf)

2. 数据库和文件系统的关系

像InnoDB、MyISAM这样的存储引擎都是把表存储在磁盘上的,操作系统用来管理磁盘的结构被称为文件系统,所以用专业一点的话来表述就是:像InnoDB、MyISAM这样的存储引擎都是把表存储在文件系统上的。当我们想读取数据的时候,这些存储引擎会从文件系统中把数据读出来返回给我们,当我们想写入数据的时候,这些存储引擎会把这些数据又写回文件系统。本章学习一下InnoDB和MyISAM这两个存储引擎如何在文件系统中进行存储。

2.1 查看默认数据库

show databases;

可以看到有4个数据库是属于MySQL自带的系统数据库。

  • mysql

    MySQL系统自带的核心数据库,它存储了MySQL的用户账户和权限信息,一些存储过程、事件的定义信息,一些运行过程中产生的日志信息,一些帮助信息及时区信息等。

  • information_schema

    MySQL系统自带的数据库,这个数据库保存着MySQL服务器维护的所有其他数据库的信息,比如有哪些表、哪些视图、哪些触发器、哪些列、哪些索引。这些信息并不是真实的用户数据,而是一些描述性信息,有时候也称为元数据(描述数据的数据)。在系统数据库information_schema中提供了一些以innodb_sys开头的表用于表示内部系统表。

  • performance_schema

    MySQL系统自带的数据库,这个数据库里主要保存MySQL服务器运行过程中的一些状态信息,可以用来监控MySQL服务的各项性能指标,包括统计最近执行了哪些语句,在执行过程中的每个阶段都花费了多长时间,内存的使用情况等信息。

  • sys

    MySQL系统自带的数据库,这个数据库主要通过视图的形式把information_schema和performance_schema结合起来,帮助系统管理员和开发人员监控MySQL的技术性能。

2.2 数据库在文件系统中的表示

使用create database 数据库名语句创建一个数据库的时候,在文件系统上实际发生了什么呢?其实很简单,每个数据库都对应数据目录下的一个子目录,或者说对应一个文件夹,每当新建一个数据库时,MySQL会帮我们做以下两件事:

  • 在数据目录dataDir下创建一个和数据库同名的子目录。
  • 在与该数据库同名的子目录下创建一个名为db.opt的文件(仅限MySQL5.7及之前版本),这个文件中包含了该数据库的各种属性,比如该数据库的字符集和比较规则。

2.3 表在文件系统中的表示

2.3.1 InnoDB存储引擎模式

1. 表结构

为了保存表结构,InnoDB数据目录(dataDir)下对应的数据库子目录下创建了一个专门用于描述表结构的文件

表名.frm

这个文件里的数据是描述表结构的数据,可以理解为元数据。

2. 表中数据和索引

① 系统表空间(system tablespace)

默认情况下,InnoDB会在数据目录下创建一个名为ibdata1、大小为12M自拓展文件,这个文件就是对应的系统表空间在文件系统上的表示。

② 独立表空间(file-per-table tablespace)

在MySQL5.6.6以及之后的版本中,InnoDB并不会默认的把各个表的数据存储到系统表空间中,而是为每一个表建立一个独立表空间,也就是说我们创建了多少个表,就有多少个独立表空间。使用独立表空间来存储表数据(对于InnoDB存储引擎来说,数据和索引是存储在一起的,是聚簇索引)的话,会在该表所属数据库对应的子目录下创建一个表示该独立表空间的文件,文件名和表名相同。

表名.ibd

MySQL8.0中不再单独提供表结构文件表名.frm,而是合并在表名.ibd文件中。

相当于表结构和表数据合一了。

③ 系统表空间与独立表空间的设置

我们可以自己指定使用系统表空间还是独立表空间来存储数据,这个功能由启动参数innodb_file_per_table控制

[server] 
innodb_file_per_table=0 # 0:代表使用系统表空间; 1:代表使用独立表空间

④ 其他类型的表空间

随着MySQL的发展,除了上述两种老牌表空间之外,现在还新提出了一些不同类型的表空间,比如通用表空间(general tablespace)、临时表空间(temporary tablespace)等。

2.3.2 MyISAM存储引擎模式

1. 表结构

在存储表结构方面, MyISAM 和 InnoDB 一样,也是在数据目录下对应的数据库子目录下创建了一个专门用于描述表结构的文件

表名.frm

2. 表中数据和索引

在MyISAM中的索引全部都是二级索引,该存储引擎的数据和索引是分开存放的。所以在文件系统中也是使用不同的文件来存储数据文件和索引文件,同时表数据都存放在对应的数据库子目录下。

test.frm 存储表结构 #MySQL8.0 改为了 b.xxx.sdi
test.MYD 存储数据 (MYData) 
test.MYI 存储索引 (MYIndex

在InnoDB中,数据即索引、索引即数据,叫做聚集索引,数据和索引是在一起的,数据和索引是存储在一起的,他们作为表数据,会存储在独立表空间中,也就是表名.ibd文件中。

而在MyISAM存储引擎中,表数据和索引是分开存储的,也就是表数据、索引、表的结构是三个文件。

InnoDB和MyISAM两个存储引擎他们都有表名.frm这个表示表结构的文件。(5.7版本)

2.4 小结

image-20220504000722409

第03章 用户与权限管理

1. 用户管理

1.1 登录MySQL服务器

启动MySQL服务后,可以通过mysql命令来登录MySQL服务器,命令如下:

mysql –h hostname|hostIP –P port –u username –p DatabaseName –e "SQL语句"
  • -h参数后面接主机名或者主机IP,hostname为主机,hostIP为主机IP。
  • -P参数后面接MySQL服务的端口,通过该参数连接到指定的端口。MySQL服务的默认端口是3306,不使用该参数时自动连接到3306端口,port为连接的端口号。
  • -u参数后面接用户名,username为用户名。
  • -p参数会提示输入密码。
  • DatabaseName参数指明登录到哪一个数据库中。如果没有该参数,就会直接登录到MySQL数据库中,然后可以使用USE命令来选择数据库。
  • -e参数后面可以直接加SQL语句。登录MySQL服务器以后即可执行这个SQL语句,然后退出MySQL服务器。
mysql -uroot -p -hlocalhost -P3306 mysql -e "select host,user from user"

1.2 创建用户

CREATE USER 用户名 [IDENTIFIED BY '密码'][,用户名 [IDENTIFIED BY '密码']];

举例:

CREATE USER zhang3 IDENTIFIED BY '123123'; # 默认host是 %
CREATE USER 'kangshifu'@'localhost' IDENTIFIED BY '123456';

1.3 修改用户

UPDATE mysql.user SET USER='li4' WHERE USER='wang5'; 
FLUSH PRIVILEGES;

1.4 删除用户

方式1:使用DROP方式删除(推荐)

DROP USER user[,user]…;

举例:

DROP USER li4 ; # 默认删除host为%的用户
DROP USER 'kangshifu'@'localhost';

方式2:使用DELETE方式删除(不推荐,有残留信息)

DELETE FROM mysql.user WHERE Host=’hostname’ AND User=’username’;
FLUSH PRIVILEGES;

1.5 设置当前用户密码

1. 使用ALTER USER命令来修改当前用户密码

ALTER USER USER() IDENTIFIED BY 'new_password';

2. 使用SET语句来修改当前用户密码

SET PASSWORD='new_password';

1.6 修改其它用户密码

1. 使用ALTER语句来修改普通用户的密码

ALTER USER user [IDENTIFIED BY '新密码'] 
[,user[IDENTIFIED BY '新密码']]…;

2. 使用SET命令来修改普通用户的密码

SET PASSWORD FOR 'username'@'hostname'='new_password';

2. 权限管理

2.1 权限列表

show privileges;
  • CREATE和DROP权限,可以创建新的数据库和表,或删除(移掉)已有的数据库和表。如果将MySQL数据库中的DROP权限授予某用户,用户就可以删除MySQL访问权限保存的数据库。
  • SELECT、INSERT、UPDATE和DELETE权限允许在一个数据库现有的表上实施操作。
  • SELECT权限只有在它们真正从一个表中检索行时才被用到。
  • INDEX权限允许创建或删除索引,INDEX适用于已有的表。如果具有某个表的CREATE权限,就可以在CREATE TABLE语句中包括索引定义。
  • ALTER权限可以使用ALTER TABLE来更改表的结构和重新命名表。
  • CREATE ROUTINE权限用来创建保存的程序(函数和程序),ALTER ROUTINE权限用来更改和删除保存的程序,EXECUTE权限用来执行保存的程序。
  • GRANT权限允许授权给其他用户,可用于数据库、表和保存的程序。
  • FILE权限使用户可以使用LOAD DATA INFILE和SELECT ... INTO OUTFILE语句读或写服务器上的文件,任何被授予FILE权限的用户都能读或写MySQL服务器上的任何文件(说明用户可以读任何数据库目录下的文件,因为服务器可以访问这些文件)。

2.2 授予权限的原则

权限控制主要是出于安全因素,因此需要遵循以下几个经验原则

1、只授予能满足需要的最小权限,防止用户干坏事。比如用户只是需要查询,那就只给select权限就可以了,不要给用户赋予update、insert或者delete权限。

2、创建用户的时候限制用户的登录主机,一般是限制成指定IP或者内网IP段。

3、为每个用户设置满足密码复杂度的密码

4、定期清理不需要的用户,回收权限或者删除用户。

2.3 授予权限

GRANT 权限1,权限2,…权限n ON 数据库名称.表名称 TO 用户名@用户地址 [IDENTIFIED BY ‘密码口令’];
  • 该权限如果发现没有该用户,则会直接新建一个用户。
  • 给li4用户用本地命令行方式,授予atguigudb这个库下的所有表的插删改查的权限。
GRANT SELECT,INSERT,DELETE,UPDATE ON atguigudb.* TO li4@localhost;
  • 授予通过网络方式登录的joe用户 ,对所有库所有表的全部权限,密码设为123。注意这里唯独不包括grant的权限
GRANT ALL PRIVILEGES ON *.* TO joe@'%' IDENTIFIED BY '123';

2.4 查看权限

  • 查看当前用户权限
SHOW GRANTS; 
# 或 
SHOW GRANTS FOR CURRENT_USER; 
# 或 
SHOW GRANTS FOR CURRENT_USER();
  • 查看某用户的全局权限
SHOW GRANTS FOR 'user'@'主机地址';

2.5 收回权限

注意:在将用户账户从user表删除之前,应该收回相应用户的所有权限。

  • 收回权限命令
REVOKE 权限1,权限2,…权限n ON 数据库名称.表名称 FROM 用户名@用户地址;
  • 举例
#收回全库全表的所有权限 
REVOKE ALL PRIVILEGES ON *.* FROM joe@'%'; 
#收回mysql库下的所有表的插删改查权限 
REVOKE SELECT,INSERT,UPDATE,DELETE ON mysql.* FROM joe@localhost;
  • 注意:须用户重新登录后才能生效

3. 角色管理

3.1 创建角色

CREATE ROLE 'role_name'[@'host_name'] [,'role_name'[@'host_name']]...

角色名称的命名规则和用户名类似。如果host_name省略,默认为%role_name不可省略,不可为空。

3.2 给角色赋予权限

GRANT privileges ON table_name TO 'role_name'[@'host_name'];

上述语句中privileges代表权限的名称,多个权限以逗号隔开。可使用SHOW语句查询权限名称

SHOW PRIVILEGES\G

3.3 查看角色的权限

SHOW GRANTS FOR 'role_name';

只要你创建了一个角色,系统就会自动给你一个“USAGE”权限,意思是连接登录数据库的权限

3.4 回收角色的权限

REVOKE privileges ON tablename FROM 'rolename';

3.5 删除角色

DROP ROLE role [,role2]...

注意,如果你删除了角色,那么用户也就失去了通过这个角色所获得的所有权限

3.6 给用户赋予角色

角色创建并授权后,要赋给用户并处于激活状态才能发挥作用。

GRANT role [,role2,...] TO user [,user2,...];

查询当前已激活的角色

SELECT CURRENT_ROLE();

3.7 激活角色

方式1:使用set default role 命令激活角色

SET DEFAULT ROLE ALL TO 'kangshifu'@'localhost';

方式2:将activate_all_roles_on_login设置为ON

SET GLOBAL activate_all_roles_on_login=ON;

这条 SQL 语句的意思是,对所有角色永久激活

3.8 撤销用户的角色

REVOKE role FROM user;

3.9 设置强制角色(mandatory role)

方式1:服务启动前设置

[mysqld] 
mandatory_roles='role1,role2@localhost,r3@%.atguigu.com'

方式2:运行时设置

SET PERSIST mandatory_roles = 'role1,role2@localhost,r3@%.example.com'; #系统重启后仍然有效
SET GLOBAL mandatory_roles = 'role1,role2@localhost,r3@%.example.com'; #系统重启后失效

第04章 逻辑架构

1. 逻辑架构剖析

1.1 服务器处理客户端请求

首先MySQL是典型的C/S架构,即client/server架构,服务端程序使用的是mysqld

不论客户端进程和服务器进程采用哪种方式进行通信,最后实现的效果都是:客户端进程向服务器进程发送一段文本(SQL语句),服务器进程处理后再向客户端进程发送一段文本(处理结果)。

那么服务器进程对客户端进程发送的请求做了什么处理,才能产生最后的处理结果呢?这里以查询请求为例展示:

image-20220504001341100

像InnoDB、MyISAM这样的存储引擎都是把表存储在文件系统上的。

这里的表指的是表的结构(xxx.frm)和表的数据(xxx.ibd),表的数据又指索引和数据。

InnoDB的表的数据和索引就是存储在一起的,是一级索引,都存储在表名.ibd中,也叫聚集索引。

MyISAM是二级索引,数据的索引和数据是分开存储的。

他们都有xxx.frm这个表结构文件。但是对于InnoDB来说,在MySQL8.0版本,这个表结构文件没有了,而是合并在表名.ibd文件中。

所以上图的文件系统就是这个意思。

MySQL服务器端的逻辑架构说明

解析器:如果SQL语句写得有问题的话,比如语法不对,那么就会在解析器这一步出错。

优化器:会对SQL进行逻辑上的优化和物理上的优化,物理上的优化就是指索引。后面会详细介绍。

上图中的数字是SQL语句通过连接,在MySQL服务中的执行顺序,注意9的意思是,存储引擎和文件系统交互,拿到数据时候,会将SQL语句和查询结果进行缓存,所以SQL语句在MySQL服务层首先是查询缓存中的数据。

要注意MySQL8.0版本就没有查询缓存了。

1.2 Connectors

Connectors,指的是不同语言中与SQL的交互,首先要建立连接。MySQL首先是一个网络程序,在TCP之上定义了自己的应用层协议,所以要用MySQL,我们可以编写代码,跟MySQL Server建立TCP连接,之后按照其定义好的协议进行交互。

在TCP连接上,通过MySQL协议跟MySQL进行交互。

1.3 第1层:连接层

系统(客户端)访问MySQL服务器前,做的第一件事就是建立TCP连接。

经过三次握手建立连接成功后,MySQL服务器对TCP传输过来的账号密码做身份认证、权限获取。

  • 用户名或密码不对,会收到一个Access denied for user错误,客户端程序结束执行
  • 用户名密码认证通过,会从权限表查出账号拥有的权限与连接关联,之后的权限判断逻辑,都将依赖于此时读到的权限

一个系统只会和MySQL服务器建立一个连接吗?只能有一个系统和MySQL服务器建立建立连接吗?

当然不是,多个系统都可以和MySQL服务器建立连接,每个系统建立的连接不止一个,所以为了解决TCP无限创建与TCP频繁创建销毁带来的资源耗尽、性能下降等问题,MySQL服务器里有专门的TCP连接池限制连接数,采用长连接模式复用TCP连接,来解决上述问题。

TCP连接收到请求后,必须要分配给一个线程专门与这个客户端的交互。**所以还会有个线程池,去走后面的流程。**每一个连接从线程池中获取线程,省去了创建和销毁线程的开销。

image-20220504004152165

1.4 第2层:服务层

  • 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缓存,权限缓存等 。
    • 这个查询缓存可以在不同客户端之间共享
    • 从MySQL 5.7.20开始,不推荐使用查询缓存,并在MySQL 8.0中删除

1.5 第3层:引擎层

插件式存储引擎层( Storage Engines),真正的负责了MySQL中数据的存储和提取,对物理服务器级别维护的底层数据执行操作,服务层通过API与存储引擎进行通信。

存储引擎和物理磁盘做交互,这个交互也不是指存储引擎直接从物理磁盘中取数据,再返回给客户端,要把数据加载到内存中,因为cpu直接和磁盘交互的话,存在执行速度和效率上的差异,会降低CPU的执行效率。CPU通常是和内存做交互。

1.6 小结

image-20220329113126234

简化为三层结构:

  1. 连接层:客户端和服务器端建立TCP连接,建立TCP连接后,客户端发送 SQL 至服务器端,

    关键点:MySQL维护一个TCP连接池管理连接,并且都是长连接模式,建立起TCP连接的请求会分配给一个线程,专门用于和客户端之间的交互,后面还有线程池去走后面的流程;

  2. SQL 层(服务层):对 SQL 语句进行查询处理;与数据库文件的存储方式无关;

  3. 存储引擎层:与数据库文件(文件系统)打交道,负责数据的存储和读取。

    文件系统:

    image-20220504005350560

2. SQL执行流程

2.1 MySQL 中的 SQL执行流程

image-20220329113337008

MySQL的查询流程:

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

image-20220504225324294

MySQL拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以key-value对的形式,被直接缓存在内存中。key是查询的语句,value是查询的结果。如果你的查询能够直接在这个缓存中找到key,那么这个value就会被直接返回给客户端,不需要做语法解析、查询优化等后面的步骤,如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。所以,如果查询命中缓存,MySQL不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。

**大多数情况下,查询缓存是个鸡肋,这是为什么?**以下三个原因:

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

  • **同时,如果查询请求中包含某些系统函数、用户自定义变量和函数、一些系统表,如 mysql、 information_schema、 performance_schema 数据库中的表,那这个请求就不会被缓存。**以某些系统函数举例,可能同样的函数的两次调用会产生不一样的结果,比如函数now(),每次调用都会产生最新的当地时间,如果在一个查询请求中调用了这个函数,那即使查询请求的文本信息都一样,那不同时间的两次查询也应该得到不同的结果,所以缓存这个查询语句是没有意义的!

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

总之,因为查询缓存弊大于利,查询缓存的失效非常频繁。

一般建议大家在静态表里使用查询缓存,什么叫静态表?就是一般我们极少更新的表,比如,一个系统配置表、字典表,这张表的查询才适合使用查询缓存。好在MySQL也提供了这种按需使用查询缓存的方式,可以将my.cnf参数query_cache_type设置成DEMAND,代表当SQL语句中有SQL_CACHE关键词时才缓存。

#query_cache_type有三个值,0代表关闭查询缓存OFF,1代表开启ON,2代表使用DEMAND
query_cache_type=2

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

image-20220504232259339

分析器先做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。 MySQL 从你输入的"select"这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。

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

image-20220504232445093

下面是SQL解析器的执行过程

image-20220504232621372

3. 优化器:在优化器中会确定 SQL 语句的执行路径,比如是根据全表检索,还是根据索引检索等。

image-20220504233032833

经过了解析器,MySQL就知道了我们要做什么了。在开始执行之前,还要先经过优化器的处理。

一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。

比如,优化器是在表里面有多个索引的时候,决定使用哪个索引(物理查询优化),或者在一个sql语句有多表关联查询的时候,决定各个表的连接顺序(物理查询优化),还有表达式简化(逻辑查询优化)、子查询转为连接、外连接转为内连接等。

image-20220504233335715

在查询优化器中,可以分为逻辑查询优化阶段和物理查询优化阶段。

逻辑查询优化就是通过改变sql语句的内容来使得sql查询更高效,同时为物理查询优化提供更多的候选执行计划。通常采用的方式是对SQL语句进行等价变换,对查询进行重写,而查询重写的数据基础就是关系代数。对条件表达式进行等价谓词重写、条件简化,对视图进行重写,对子查询进行优化,对连接语义进行了外连接消除、嵌套连接消除等。

物理查询优化是基于关系代数进行的查询重写,而关系代数的每一步都对应着物理计算,这些物理计算往往存在多种算法,因此需要计算各种物理路径的代价,从种选择代价最少的作为执行计划。在这个阶段里,对于单表和多表连接的操作,需要高效地使用索引,提升查询效率。

  • 物理查询优化是通过索引和表连接方式等技术来进行优化,这里重点掌握索引的使用。
  • 逻辑查询优化就是通过SQL等价变换提升查询效率,直白一点就是说,换一种查询写法执行效率可能更高。

4. 执行器

截止到现在,还没有真正地去读写真实的表,仅仅只是产出了一个执行计划。于是就进入了执行器阶段。

image-20220504234127640

在执行之前需要判断该用户是否具备权限。如果没有,就会返回权限错误。

如果具备权限,就打开表继续执行,打开表的时候,执行器就会根据表的引擎定义,调用存储引擎API对表进行读写。

存储引擎API只是抽象的接口,下面还有存储引擎层,具体实现还是要看表选择的存储引擎。

就执行 SQL查询并返回结果。在 MySQL8.0 以下的版本,如果设置了查询缓存,这时会将查询结果进行缓存。

image-20220504234316804

image-20220329114541991

SQL 语句在 MySQL 中的流程是: SQL语句→查询缓存→解析器→优化器→执行器 。

3. 数据库缓冲池(buffer pool)

InnoDB存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。而磁盘 I/O 需要消耗的时间很多,而在内存中进行操作,效率则会高很多,为了能让数据表或者索引中的数据随时被我们所用,DBMS 会申请占用内存来作为数据缓冲池在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。

这样做的好处是可以让磁盘活动最小化,从而减少与磁盘直接进行 I/O 的时间。要知道,这种策略对提升 SQL 语句的查询性能来说至关重要。如果索引的数据在缓冲池里,那么访问的成本就会降低很多。

3.1 缓冲池 vs 查询缓存

缓冲池和查询缓存不是一个东西。

1. 缓冲池(Buffer Pool)

image-20220329115517901

从图中,你能看到 InnoDB 缓冲池包括了数据页、索引页、插入缓冲、锁信息、自适应 Hash 和数据字典信息等。

缓存原则:

位置 * 频次”这个原则,可以帮我们对 I/O 访问效率进行优化。

首先,位置决定效率,提供缓冲池就是为了在内存中可以直接访问数据。

不是说缓冲池就是内存,缓冲池是DBMS来占用一部分内存来作为缓冲池。

其次,频次决定优先级顺序。因为缓冲池的大小是有限的,比如磁盘有 200G,但是内存只有 16G,缓冲池大小只有 1G,就无法将所有数据都加载到缓冲池里,这时就涉及到优先级顺序,会优先对使用频次高的热数据进行加载

缓存池的重要性

  • 对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引即一级索引,还有二级索引),还是各种系统数据,都是以页的形式存放在表空间的,而所谓的表空间只不过是InnoDB对文件系统上一个或几个实际文件的抽象,也就是说我们的数据到底还是还是存储在磁盘上的。但是磁盘的速度很慢,缓冲池可以帮助我们消除cpu和磁盘之间的鸿沟。所以InnoDB存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,**也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据都加载到内存中。**将整个页的数据加载到内存中之后,就可以进行读写访问了,在进行完读写访问操作之后,并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来再有请求再次访问该页面时,就可以省去磁盘IO的开销了。

    注意:页在操作系统和进程的角度来讲,页是一个进程的某一部分,操作系统在运行一个进程时,也不会把进程所有数据都调入内存执行,而是每次都会按照一定的规则,比如请求分页存储管理方式,由操作系统负责将所需信息(进程的页面)从外存调入内存。操作系统以页框为单位,为各个进程分配内存空间。这里的页就是指的磁盘空间上,文件系统的表空间文件中的一部分数据,指的表空间的某个页面。

缓冲池的预读特性

  • 缓冲池的作用就是提升IO效率,而我们进行读取数据的时候存在一个局部性原理,也就是说我们使用了一些数据(优先会把热数据,就是使用频次高的数据加载到内存),大概率还会使用它周围的一些数据,因此采用“预读”的机制进行提前加载,可以减少未来可能的磁盘IO操作。

2. 查询缓存

查询缓存是提前把sql语句和查询结果缓存起来,是key-value结构,这样下次不需要执行就可以直接拿到结果。而缓冲池是缓存的是表中的大量的数据,这是两回事。需要说明的是,在MySQL 中的查询缓存,不是缓存查询计划,而是查询对应的结果。因为命中条件苛刻,而且只要数据表发生变化,查询缓存就会失效,因此命中率低。

3.2 缓冲池如何读取数据

缓冲池管理器会尽量将经常使用的数据保存起来,在数据库进行页面读操作的时候,首先会判断该页面(数据)是否在缓冲池中,如果存在就直接读取,如果不存在,就会通过内存或磁盘将页面存放到缓冲池中再进行读取。

image-20220329115958009

如果我们执行SQL语句的时候,更新了缓冲池中的数据,那么这些数据会马上同步到磁盘上吗?

实际上,当我们对数据库中的记录进行修改的时候,首先会修改缓冲池中页里面的记录信息,然后数据库会以一定的频率刷新到磁盘上,注意不是每次发生更新操作,都会立刻进行磁盘回写,缓冲池会采用一种叫做checkpoint的机制将数据回写到磁盘上,这样做的好处就是提升了数据库的整体性能。

比如当缓冲池不够用时,需要释放掉一些不常用的页,此时就可以强行采用checkpoint的方式,将不常用的脏页回写到磁盘上,然后再从缓冲池中将这些不常用的页释放掉。这里的脏页指的是缓冲池中被修改过的页,与磁盘上(文件系统)数据不一致的页。

3.3 查看/设置缓冲池的大小

如果使用的是MySQL MyISAM存储引擎,它只缓存索引,不缓存数据,对应的键缓存参数为key_buffer_size

如果使用的是InnoDB存储引擎,那么是索引和数据都要缓存,可以通过查看innodb_buffer_pool_size变量来查看缓冲池的大小。

InnoDB存储引擎是数据和索引存储在一起的,而MyISAM存储引擎的索引和数据是分开存储的,也就是说索引都是二级索引。

查看缓冲池的大小

show variables like 'innodb_buffer_pool_size';

设置缓冲池的大小

set global innodb_buffer_pool_size = 268435456;

或者

[server] 
innodb_buffer_pool_size = 268435456

3.4 多个Buffer Pool实例

Buffer Pool本质是InnoDB向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的数据都需要加锁处理。在Buffer Pool特别大而且多线程访问,并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,我们可以把他们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例,他们都是独立地去申请内存空间,独立地管理各种链表。所以在多线程并发访问时,并不会相互影响,从而提供并发处理能力。

我们可以在服务器启动的时候,通过设置以下参数去设置Buffer Pool实例的个数

[server] 
innodb_buffer_pool_instances = 2

如何查看缓冲池的个数

show variables like 'innodb_buffer_pool_instances';

每个Buffer Pool实例实际占内存空间

innodb_buffer_pool_size/innodb_buffer_pool_instances

image-20220505162215734

第05章 存储引擎

为了管理方便,人们把连接管理、查询缓存、语法解析、查询优化这些并不涉及真实数据存储的部分划分为MySQL server功能,把真实存取数据的功能划分为存储引擎的功能。所以在MySQL server完成了查询优化后,只需按照生成的执行计划调用底层存储引擎提供的API,获取到数据后返回给客户端就好了。

简而言之,存储引擎就是指表的类型,其实存储引擎以前叫做表处理器,它的功能就是接收上层传下来的指令,然后对表中的数据进行读写操作。

1. 查看存储引擎

show engines;
# 或
show engines\G

2. 设置系统默认的存储引擎

  • 查看默认的存储引擎:
show variables like '%storage_engine%'; 
#或
SELECT @@default_storage_engine;
  • 修改默认的存储引擎

如果在创建表的语句中没有显式指定表的存储引擎的话,那就会默认使用InnoDB作为表的存储引擎。

SET DEFAULT_STORAGE_ENGINE=MyISAM;

或者修改my.cnf文件:

default-storage-engine=MyISAM 
# 重启服务 
systemctl restart mysqld.service

3. 设置表的存储引擎

存储引擎是负责对表中的数据进行提取和写入工作的,我们可以为不同的表设置不同的存储引擎,也就是说不同的表可以有不同的物理存储结构,不同的提取和写入方式。

3.1 创建表时指定存储引擎

CREATE TABLE 表名(
    建表语句; 
) ENGINE = 存储引擎名称;

3.2 修改表的存储引擎

ALTER TABLE 表名 ENGINE = 存储引擎名称;

4. 引擎介绍

4.1 InnoDB 引擎:具备外键支持功能的事务存储引擎

  • MySQL从3.23.34a开始就包含InnoDB存储引擎。大于等于5.5之后,默认采用InnoDB引擎
  • InnoDB是MySQL的默认事务型引擎,它被设计用来处理大量的短期(short-lived)事务。可以确保事务的完整提交(Commit)和回滚(Rollback)。
  • 除了增加和查询外,还需要更新、删除操作,那么,应优先选择InnoDB存储引擎。
  • 除非有非常特别的原因需要使用其他的存储引擎,否则应该优先考虑InnoDB引擎。
  • 数据文件结构:
    • 表名.frm 存储表结构(MySQL8.0时,合并在表名.ibd中)
    • 表名.ibd 存储数据和索引
  • InnoDB是为处理巨大数据量的最大性能设计
    • 在以前的版本中,字典数据以元数据文件、非事务表等来存储。现在这些元数据文件被删除了。比如:.frm.par.trn.isl.db.opt等都在MySQL8.0中不存在了。
  • 对比MyISAM的存储引擎,InnoDB写的处理效率差一些,并且会占用更多的磁盘空间以保存数据和索引。
  • MyISAM只缓存索引,不缓存真实数据;InnoDB不仅缓存索引还要缓存真实数据,对内存要求较高,而且内存大小对性能有决定性的影响。

4.2 MyISAM 引擎:主要的非事务处理存储引擎

  • MyISAM提供了大量的特性,包括全文索引、压缩、空间函数(GIS)等,但MyISAM不支持事务、行级锁、外键,有一个毫无疑问的缺陷就是崩溃后无法安全恢复(这个缺陷就是由于MyISAM不支持事务造成的)。
  • 5.5之前默认的存储引擎
  • 优势是访问的速度快对事务完整性没有要求或者以SELECT、INSERT为主的应用
  • 针对数据统计有额外的常数存储。故而 count(*) 的查询效率很高
  • 数据文件结构:
    • 表名.frm 存储表结构
    • 表名.MYD 存储数据 (MYData)
    • 表名.MYI 存储索引 (MYIndex)
  • 应用场景:只读应用或者以读为主的业务

4.3 Archive 引擎:用于数据存档

4.4 Blackhole 引擎:丢弃写操作,读操作会返回空内容

4.5 CSV 引擎:存储数据时,以逗号分隔各个数据项

4.6 Memory 引擎:置于内存的表

4.7 Federated 引擎:访问远程表

4.8 Merge引擎:管理多个MyISAM表构成的表集合

4.9 NDB引擎:MySQL集群专用存储引擎

5. MyISAM和InnoDB

image-20220505171914132

对比项MyISAMInnoDB
外键不支持支持
事务不支持支持
行表锁表锁,即使操作一条记录也会锁住整个表,不适合高并发的操作行锁,操作时只锁某一行,不对其它行有影响,适合高并发的操作
缓存只缓存索引,不缓存真实数据不仅缓存索引还要缓存真实数据,对内存要求较高,而且内存大小对性能有决定性的影响
自带系统表使用YN
关注点性能:节省资源、消耗少、简单业务事务:并发写、事务、更大资源
默认安装YY
默认使用NY

第06章 索引的数据结构

1.为什么使用索引?

索引是存储引擎用于快速找到数据记录的一种数据结构,就好比一本教科书的目录部分,通过目录找到对应文章的页码,便可快速定位到需要的文章。MySQL也是一样的道理,进行数据查找时,首先查看查询条件是否命中某条索引,符合则通过索引查找相关数据,如果不符合则需要进行全表扫描,即需要一条一条记录查找,直到找到与条件符合的记录。

索引是数据结构。

image-20220505173332961

image-20220505173455290

image-20220505173521250

image-20220505193509658

2. 索引及其优缺点

2.1 索引概述

MySQL官方对索引的定义为:索引(Index)是帮助MySQL高效获取数据的数据结构

**索引的本质:**索引是数据结构。你可以简单理解为“排好序的快速查找数据结构”,满足特定查找算法。这些数据结构以某种方式指向数据, 这样就可以在这些数据结构的基础上实现高级查找算法

索引是在存储引擎中实现的,因此每种存储引擎的索引不一定完全相同,并且每种存储引擎不一定支持所有索引类型。同时,存储引擎可以定义每个表的最大索引数和最大索引长度,所有存储引擎支持每个表至少16个索引,总索引长度至少为256字节。有些存储引擎支持更多的索引数和更大的索引长度。

2.2 优点

(1)类似大学图书馆建书目索引,提高数据检索的效率,降低数据库的IO成本(减少磁盘IO的次数),这也是创建索引最主要的原因。

(2)通过创建唯一索引,可以保证数据库表中每一行数据的唯一性。(唯一约束的字段会自动添加唯一索引)

(3)在实现数据的参考完整性方面,可以加速表和表之间的连接。换句话说,对于有依赖关系的子表和父表联合查询时,可以提高查询速度。

(4)在使用分组和排序子句进行数据查询时,可以显著减少查询中分组和排序的时间,降低了CPU的消耗。

2.3 缺点

(1)创建索引和维护索引要耗费时间,并且随着数据量的增加,所耗费的时间也会增加。

(2)索引需要占磁盘空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间存储在磁盘上,如果有大量的索引,索引文件就可能比数据文件更快达到最大文件尺寸。

(3)虽然索引大大提高了查询速度,同时却会降低更新表的速度当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。

提示:(一个小的技巧)

索引可以提高查询的速度,但是会影响插入数据的速度,这种情况下,最好的办法是先删除表中的索引,然后插入数据,插入完成后再创建索引。

3. InnoDB中索引的推演

3.1 索引之前的查找

先来看一个精确匹配的例子:

SELECT [列名列表] FROM 表名 WHERE 列名 = xxx;

1.在一个页中的查找

一个数据页默认大小是16kb,一个数据页中存储数据是有限的,从磁盘也就是文件系统被操作系统调入内存,调入缓冲池,是以页为单位,某个整页的数据都会被调入缓冲池。

表中的多条记录之间,在物理磁盘上并不是紧密排列的,数据量是非常大的,我们只需要保证表中的记录之间是逻辑上连续的,就是单链表。每一条记录之间是用单链表连接的。

假设目前表中的记录比较少,所有的记录都可以被存放在一个页中,在查找记录的时候可以根据搜索条件的不同分为两种情况:

  • 以主键为搜索条件

    • 主键一般是自增长的,所以主键可以看作是递增有序的。

    • 可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。

  • 以其他列作为搜索条件

    • 因为在数据页中并没有对非主键建立所谓的页目录,所以我们无法通过二分法快速定位相应的槽。这种情况下只能从最小记录开始依次遍历单链表中的每条记录,然后对比每条记录是不是符合搜索条件。很显然,这种查找的效率是非常低的。

2.在很多页中查找

大部分情况下我们表中存放的记录都是非常多的,需要好多的数据页来存储这些记录。在很多页中查找记录的话可以分为两个步骤:

  1. 定位到记录所在的页。
  2. 从所在的页内查找相应的记录。

在没有索引的情况下,不论是根据主键列或者其他列的值进行查找,由于我们并不能快速的定位到记录所在的页,所以只能从第一个页沿着双向链表一直往下找,在每一个页中根据我们上面的查找方式去查找指定的记录。因为要遍历所有的数据页,所以这种方式显然是超级耗时的。如果一个表有一亿条记录呢?索引应运而生。

3.2 设计索引

mysql> CREATE TABLE index_demo(
    -> c1 INT,
    -> c2 INT,
    -> c3 CHAR(1),
    -> PRIMARY KEY(c1)
    -> ) ROW_FORMAT = Compact;

这个新建的index_demo表中有2个INT类型的列,1个CHAR(1)类型的列,而且我们规定了c1列为主键,这个表使用Compact行格式来实际存储记录的。这里我们简化了index_demo表的行格式示意图:

image-20220330095642965

  • record_type:记录头信息的一项属性,表示记录的类型,0表示普通记录、1表示目录项记录、2表示最小记录、3表示最大记录。

  • next_record:记录头信息的一项属性,表示下一条地址相对于本条记录的地址偏移量,我们用箭头来表明下一条记录是谁。

    因为记录与记录之间在物理磁盘上并不是连续的,在逻辑上保证连续,通过链表。所以要记录某一条记录的下一条记录,就是next_record

  • 各个列的值:这里只记录在index_demo表中的三个列,分别是c1c2c3

  • 其他信息:除了上述3种信息以外的所有信息,包括其他隐藏列的值以及记录的额外信息。

将记录格式示意图的其他信息项暂时去掉并把它竖起来的效果就是这样:

image-20220330100053271

把一些记录放到页里的示意图就是:

image-20220330100150297

1. 一个简单的索引设计方案

我们在根据某个搜索条件查找一些记录时为什么要遍历所有的数据页呢?因为各个页中的记录并没有规律,我们并不知道我们的搜索条件匹配哪些页中的记录,所以不得不依次遍历所有的数据页。所以如果我们想快速的定位到需要查找的记录在哪些数据页中该咋办?我们可以为快速定位记录所在的数据页而建立一个目录,建这个目录必须完成下边这些事:

  • 下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。

    image-20220505200229398

    从图中可以看出来,index_demo表中的三条记录,都被插入到了编号为10的数据页中,此时我们再插入一条记录:

    insert into index_demo values(4, 4, 'a');
    

    因为页10最多只能存放3条记录(假设),所以我们不得不再分配一个数据页来存储新插入的记录。

    image-20220505200633583

    注意:

    • 数据页跟数据页之间也是逻辑上连续的,在物理磁盘上并不是物理连续的,也是通过链表达到逻辑上连续。

    • 新分配的数据页编号可能并不是连续的,他们只是通过维护着上一个页和下一个页的编号而建立了链表关系,另外,页10中用户记录最大的主键值是5,而页28中有一条记录的主键值是4,因为5 > 4,所以这就不符合下一个数据页中的用户记录的主键值必须大于上一个页中用户记录的主键值的要求,所以在插入主键值为4的记录的时候需要伴随着一次记录移动,也就是把主键值为5的记录移动到页28中,然后再把主键值为4的记录移动到页10中。这个过程的示意图如下:

      image-20220505201109329

      这个过程表明了在对页中的记录进行增删改查的过程中,我们必须通过一些诸如记录移动的操作来始终保持这个状态一直成立:下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。这个过程我们称为页分裂

  • 给所有的页建立一个目录项。

    由于数据页的编号可能是不连续的,所以在向index_demo表中插入多条记录之后,可能是这样的效果:

    image-20220505201556827

    因为这些16kb的页在物理存储上是不连续的,所以如果想从这么多页中根据主键值快速定位某些记录所在的页,我们需要给这些页做个目录,每个页对应一个目录项,每个目录项包括下面两个部分:

    • 页的用户记录的最小的主键值,我们用key来表示,或者说索引列的最小值(默认索引列是主键!)
    • 页号,用page_no表示。

    我们为上面几个页做好的目录像这样:

    image-20220330100409306

页28为例,它对应目录项2,这个目录项中包含着该页的页号28以及该页中用户记录的最小主键值5。我们只需要把几个目录项在物理存储器上连续存储(比如:数组),就可以实现根据主键值快速查找某条记录的功能了。比如:查找主键值为20的记录,具体查找过程分两步:

  1. 先从目录项中根据二分法快速确定出主键值为20的记录在目录项3中(因为 12 < 20 < 209 ),它对应的页是页9

  2. 再根据前边说的在页中查找记录的方式去页9中定位具体的记录。

至此,针对数据页做的简易目录就搞定了。这个目录有一个别名,称为索引

2. InnoDB中的索引方案

① 迭代1次:目录项纪录的页

上边称为一个简易的索引方案,是因为我们为了在根据主键值进行查找时使用二分法快速定位到具体的目录项而假设所有的目录项都可以在物理存储器上连续存储,但是这样做有以下几个问题:

  • InnoDB是使用页来作为管理存储空间的基本单位,每一页最多能保证16kb的连续存储空间,而随着表中记录的增多,需要非常大的连续存储空间才能把所有目录项都存下,这对记录数量非常多的表来说是不现实的。
  • 我们时常会对记录进行增删,假设我们把页28中的所有记录都删了,那意味着目录项2也就没有存在的必要了,这就需要把目录项2后的目录项都向前移动,因为我们目前假设目录项是采用连续的物理存储空间,这样牵一发而动全身的操作效率很差。

所以我们需要一种可以灵活管理所有目录项的方式,目录项长得和用户记录差不多,只不过目录项中的两个列是主键和页号而已,为了和用户记录做一下区分,我们把这些用来表示目录项的记录称为目录项记录。那InnoDB怎么区分一条记录是普通用户记录还是目录项记录呢?使用记录头信息里的record_type属性,它的各个取值代表的意思如下:

  • 0:表示普通用户记录
  • 1:目录项记录
  • 2:最小记录
  • 3:最大记录

我们把前边使用到的目录项放到数据页中的样子就是这样:

image-20220330102834658

从图中可以看出来,我们新分配了一个编号为30的页来专门存储目录项记录。这里再次强调目录项记录和普通的用户记录不同点

  • 目录项记录record_type值是1,而普通用户记录record_type值是0。
  • 目录项记录只有主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列,另外还有InnoDB自己添加的隐藏列。
  • 了解:记录头信息里还有一个叫min_rec_mask的属性,只有在存储目录项记录的页中的主键值最小的目录项记录min_rec_mask值为1,其他别的记录的min_rec_mask值都是0

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

现在以查找主键为20的记录为例,根据某个主键值去查找记录的步骤就可以大致拆分成下边两步:

  1. 先到存储目录项记录的页,也就是页30中通过二分法快速定位到对应目录项,因为 12 < 20 < 209 ,所以定位到对应的记录所在的页就是页9。

  2. 再到存储用户记录的页9中根据二分法快速定位到主键值为20的用户记录。

② 迭代2次:多个目录项纪录的页

虽然说目录项记录中只存储主键值和对应的页号,比用户记录需要的存储空间小多了,但是不论怎么说一个页只有16kb大小,能存放的目录项记录也是有限的,那如果表中的数据太多,以至于一个数据页不足以存放所有的目录项记录,如何处理呢?

这里我们假设一个存储目录项记录的页是最多只能存放4条目录项记录,所以如果此时我们再插入一条主键值为320的用户记录的话,那就需要新分配一页来存放新的目录项记录。

image-20220330103442914

从图中可以看出,我们插入了一条主键值为320的用户记录之后需要两个新的数据页:

  • 为存储该用户记录而新生成了页31
  • 因为原先存储目录项记录的页30的容量已满(我们前边假设只能存储4条目录项记录),所以不得不需要一个新的页32来存放页31对应的目录项。

现在因为存储目录项记录的页不止一个,所以如果我们想根据主键值查找一条用户记录大致需要3个步骤,以查找主键值为20的记录为例:

  1. 确定目录项记录页我们现在的存储目录项记录的页有两个,即页30页32,又因为页30表示的目录项的主键值的范围是 [1, 320) ,页32表示的目录项的主键值不小于 320 ,所以主键值为20的记录对应的目录项记录在页30中。

  2. 通过目录项记录页确定用户记录真实所在的页(二分法)。在一个存储目录项记录的页中通过主键值定位一条目录项记录的方式说过了。

  3. 在真实存储用户记录的页中定位到具体的记录(二分法)。

③ 迭代3次:目录项记录页的目录页

image-20220505210019906

image-20220330103847284

如图,我们生成了一个存储更高级目录项的页33,这个页中的两条记录分别代表页30和页32,如果用户记录的主键值在[1, 320)之间,则到页30中查找更详细的目录项记录,如果主键值不小于320的话,就到页32中查找更详细的目录项记录。

我们可以用下边这个图来描述它:

image-20220330104012108

这个数据结构,它的名称是B+树

每一个节点,都是数据页。

叶子节点里面都是我们真实的用户数据。

叶子节点里一条一条的数据之间呢,是单向链表的方式去链接的。

每一个叶子节点之间,即叶子节点层(第0层),数据页之间是双向链表的方式链接的。

非叶子节点层的页都是多个目录项记录构成的页,叫做目录页,实际也是数据页,大小也是16kb,只不过我们叫做目录页。每一个目录页里面存放的是多条目录项记录,第0层每一个节点,即每一个数据页里面存放的是多条用户数据记录。

B+Tree

一个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条记录。相当多的记录!!!

你的表里能存放100000000000条记录吗?所以一般情况下,我们用到的B+树都不会超过4层,那我们通过主键值去查找某条记录最多只需要做4个页面内的查找(查找3个目录项页和一个用户记录页),又因为在每个页面内有所谓的Page Directory(页目录),所以在页面内也可以通过二分法实现快速定位记录。

树的层次越低,IO的次数就越少!

3.3 常见索引概念

1. 聚簇索引

针对主键构建的索引

聚簇索引并不是一种单独的索引类型,而是一种数据存储方式(所有的用户记录都存储在了叶子节点),也就是所谓的索引即数据、数据即索引。

image-20220505211735337

这里看到的数据,其实就是底层数据存储的方式,或者说数据结构,就是b+树,就是物理磁盘中,数据文件中的数据的存储方式。

InnoDB存储引擎,底层存储数据的目录中,有xxx.ibd文件,这个文件中存放的就是数据和索引,数据和索引是存储在一起的,你中有我、我中有你。

聚簇的意思就是表中的数据和索引结构是一个整体。

**非聚簇索引是不存储表中的一条条记录的!!!!**索引和数据是分开存储的。

聚簇索引的叶子节点存储的都是实打实的一条条用户记录。

特点:

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

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

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

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

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

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

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

优点:

  • 数据访问更快因为聚簇索引将索引和数据保存在同一个B+树中(数据存放在叶子节点,索引结构是非叶子节点,可以这样理解,非叶子节点都是存储目录项记录的页,也就是索引,叶子节点是用户记录,非叶子节点是索引,他们作为一个整体,是b+树的结构,是聚簇索引),因此从聚簇索引中获取数据比非聚簇索引更快
  • 聚簇索引对于主键的排序查找范围查找速度非常快,因为页内的记录是按照主键大小进行排序的!下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。
  • 按照聚簇索引排列顺序,查询显示一定范围数据的时候,由于数据都是紧密相连,数据库不用从多个数据块中提取数据,所以节省了大量的io操作

缺点:

  • 插入速度严重依赖于插入顺序,按照主键的顺序插入是最快的方式,否则将会出现页分裂,严重影响性能。因此,对于InnoDB表,我们一般都会定义一个自增ID列为主键
  • 更新主键的代价很高,因为将会导致被更新的行移动(因为我们要保证下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值,所以如果更新主键值,会存在数据移动)。因此,对于InnoDB表,我们一般定义主键为不可更新
  • 二级索引访问需要两次索引查找,第一次找到主键值,第二次根据主键值找到行数据

限制:

  • 对于MySQL数据库,目前只有InnoDB存储引擎支持聚簇索引,而MyISAM并不支持聚簇索引。
  • 由于数据物理存储排序方式只能有一种,所以每个MySQL的表只能有一个聚簇索引,一般情况下就是该表的主键。
  • 如果没有定义主键,InnoDB会选择非空的唯一索引代替,如果没有这样的索引,InnoDB会隐式地定义一个主键来作为聚簇索引。
  • 为了充分利用聚簇索引的聚簇的特性,为了保证数据的顺序增长,所以InnoDB表的主键列尽量选用有序的顺序id,而不建议用无序的id,比如UUID、MD5、HASH、字符串列作为主键无法保证数据的顺序增长。
2. 二级索引(辅助索引、非聚簇索引)

上面介绍的聚簇索引只能在搜索条件是主键值时才发挥作用,因为B+树中的数据都是按照主键进行排序的(不论是第0层的用户数据,也就是数据页,还是其他层的索引结构,也就是非叶子节点的目录页,都是按照主键值进行排序的)。那如果我们想以别的列作为搜索条件应该怎么办呢?

答案:我们可以多建几棵B+树,不同的B+树中采用不同的排序规则,比方说我们用c2列的大小作为数据页,页中记录的排序规则,再建一棵B+树,效果如下图所示:

image-20220330105018965

这个B+树的叶子节点存储的并不是完整的用户数据记录,而是C2列+主键这两个列的值。

目录项记录中不再是主键+页号的搭配,而是c2列+页号的搭配。

一个表有一个聚簇索引,可以有多个二级索引。

概念:回表:我们根据这个以c2列大小排序的B+树只能确定我们要查找记录的主键值,所以如果我们想根据c2列的值查找到完整的用户记录的话,仍然需要到聚簇索引中再查一遍,这个过程称为回表。也就是根据c2列的值查询一条完整的用户记录需要使用到2棵B+树!

  • 先根据二级索引找到主键
  • 再根据主键值到聚簇索引中进行查找。

所以使用到2棵b+树。

如果把完整的用户记录放到叶子节点,那么就可以不用回表,但是太占地方了,相当于每建立一棵b+树都需要把所有的用户记录都拷贝一遍,这就太浪费存储空间了。

因为这种按照非主键列建立的b+树,需要一次回表操作才可以定位到完整的用户记录,所以这种b+树也叫做二级索引,或者辅助索引,或者非聚簇索引。

非聚簇索引的存在不影响数据在聚簇索引中的组织,所以一张表可以有多个非聚簇索引。

image-20220505222843206

image-20220505222949641

3. 联合索引

属于非聚簇索引。

我们也可以同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,比方说我们想让B+树按照c2和c3列的大小进行排序,这个包含两层含义:

  • 先把各个记录和页按照c2列进行排序,如果记录的c2列相同,则按照c3列的值进行排序。

  • b+树的叶子节点处的用户记录由c2、c3和主键列组成。

注意一点,以c2和c3列的大小为排序规则建立的B+树称为联合索引,本质上也是一个二级索引。它的意思与分别为c2和c3列分别建立索引的表述是不同的,不同点如下:

  • 建立联合索引只会建立如上图一样的1棵B+树。

  • 为c2和c3列分别建立索引会分别以c2和c3列的大小为排序规则建立2棵B+树。

3.4 InnoDB的B+树索引的注意事项

1. 根页面位置万年不动

我们前边介绍B+索引的时候,为了大家理解上的方便,先把存储用户记录的叶子节点都画出来,然后接着画存储目录项记录的内节点,实际上B+树的形成过程是这样的:

  • 每当为某个表创建一个B+树索引(聚簇索引不是人为创建的,默认就有)的时候,都会为这个索引创建一个根节点页面。最开始表中没有数据的时候,每个B+树索引对应的根节点中既没有用户记录,也没有目录项记录。
  • 随后向表中插入用户记录时,先把用户记录存储到这个根节点中。
  • 当根节点中的可用空间用完时继续插入记录,此时会将根节点中的所有记录复制到一个新分配的页,比如页a中,然后对这个新页进行页分裂的操作,得到另一个新页,比如页b。这时新插入的记录根据键值(也就是聚簇索引中的主键值,二级索引中对应的索引列的值)的大小就会被分配到页a或者页b中,而根节点便升级为存储目录项记录的页。

这个过程特别注意的是:一个B+树索引的根节点自诞生之日起,便不会再移动。这样只要我们对某个表建立一个索引,那么它的根节点的页号便会被记录到某个地方,然后凡是InnoDB存储引擎需要用到这个索引的时候,都会从那个固定的地方取出根节点的页号,从而来访问这个索引。

2. 内节点中目录项记录的唯一性

我们知道B+树索引的内节点中目录项记录的内容是索引列+页号的搭配,但是这个搭配对于二级索引来说有点不严谨。还拿index_demo表为例,假设这个表中的数据是这样的:

c1c2c3
11'u'
31'd'
51'y'
71'a'

如果二级索引中目录项的内容只是索引号+页号的搭配的话,那么为c2列建立索引后的B+树应该长这样:

image-20220330111029120

如果我们想要新插入一行记录,其中c1c2c3的值分别是:91c,那么在修改这个为c2列建立的二级索引对应的B+树时便碰到了个大问题:由于页3中存储的目录项记录是由c2列+页号的值构成的,页3中的两条目录项记录对应的c2列的值都是1,那么我们这条新插入的记录到底应该放在页4中,还是应该放在页5中啊?答案是:对不起,懵了。

为了让新插入记录能找到自己在哪个页里,我们需要**保证在B+树的同一层内节点的目录项记录除页号这个字段以外是唯一的。**所以对于二级索引的内节点的目录项记录的内容实际上是由三个部分构成的:

  • 索引列的值
  • 主键值
  • 页号

也就是我们把主键值也添加到二级索引内节点中的目录项记录了,这样就能保证B+树每一层节点中各条目录项记录除页号这个字段外是唯一的,所以我们为c2列建立二级索引后的示意图实际上应该是这样子的:

image-20220330112018199

这样我们再插入记录(9, 1, 'c')时,由于页3中存储的目录项记录是由c2列+主键+页号的值构成的,可以先把新记录的c2列的值和页3中各目录项记录的c2列的值作比较,如果c2列的值相同的话,可以接着比较主键值,因为B+树同一层中不同目录项记录的c2列+主键的值肯定是不一样的,所以最后肯定能定位唯一的一条目录项记录,在本例中最后确定新记录应该被插入到页5中。

3. 一个页面最少可以存储2条记录

一个B+树只需要很少的层级就可以轻松存储数亿条记录,查询速度相当不错!这是因为B+树本质上就是一个大的多层级目录,每经过一个目录时都会过滤掉许多无效的子目录,直到最后访问的存储真实数据的目录。那如果一个大的目录中只存放一个子目录是个啥效果呢?那就是目录层级非常非常非常多,而且最后的那个存放真实数据的目录中存放一条记录。费了半天劲只能存放一条真实的用户记录?所以InnoDB的一个数据页至少可以存放两条记录

4. MyISAM中的索引方案

B树索引适用存储引擎如表所示:

索引/存储引擎MyISAMInnoDBMemory
B-Tree索引支持支持支持

即使多个存储引擎支持同一种类型的索引,但是他们的实现原理也是不同的。Innodb和MyISAM默认的索引是Btree索引;而Memory默认的索引是Hash索引。

MyISAM引擎使用B+Tree作为索引结构,叶子节点的data域存放的是数据记录的地址。也就是二级索引,MyISAM不是聚簇索引。

4.1 MyISAM索引的原理

下图是MyISAM索引的原理图。

我们知道InnoDB中索引即数据,也就是聚簇索引的那棵B+树的叶子节点中已经把所有完整的用户记录都包含了,而MyISAM的索引方案虽然也使用的树形结构,但是却将数据和索引分开存储,也就是MyISAM的索引是二级索引。

  • 将表中的记录按照记录的插入顺序,单独存储在一个文件中,称之为数据文件,且这个文件不分页,这个文件并不划分为若干个数据页,有多少记录就往这个文件中塞多少条记录就成了,由于在插入数据的时候,并没有可以按照主键大小进行顺序排序(InnoDB引擎的聚簇索引,不论是目录页中的目录项记录还是叶子节点中的用户数据记录都会按照主键大小进行排序,根据插入的主键大小,还涉及到数据的移动,在不同页面中的移动,保证按主键排序,保证下一个页面的数据的主键大于上一个页面),所以我们并不能在这些数据上使用二分法进行查找。

  • 使用MyISAM存储引擎的表会把索引信息另外存储到一个称为索引文件的文件中,MyISAM会单独为表的主键创建一个索引,是非聚簇索引,也就是二级索引,不包含用户数据,在索引的叶子节点中存储的不是完整的用户数据记录,而是主键值+数据记录位置(记录地址)。

    这和前面提到的二级索引又有一点不同,比如说InnoDB中的二级索引,叶子节点中存储的数据是非主键列,也就是索引列+ 主键值(数据记录位置)

    总结:

    InnoDB的二级索引的叶子节点:非主键列(索引列) + 主键

    MyISAM的二级索引的叶子节点:主键值+数据记录位置

    总之就是二级索引的叶子节点不包含用户完整数据记录,而是数据记录位置

image-20220330113307813

所有MyISAM的索引都是这样的结构,没有聚簇索引和非聚簇索引之说,我们可以将这种索引理解为非聚簇索引。因为数据和索引是分离的。MyISAM的索引文件仅仅保存数据记录的地址。

4.2 MyISAM InnoDB对比

MyISAM的索引方式都是“非聚簇”的,与InnoDB包含1个聚簇索引是不同的。

小结两种引擎中索引的区别:

① 在InnoDB存储引擎中,我们只需要根据主键值对聚簇索引进行一次查找就能找到对应的记录,而在MyISAM中却需要进行一次回表操作(第一次是根据非聚簇索引找到数据地址,第二次是根据数据地址找到数据,而InnoDB的回表,第一次是根据非聚簇索引找到主键,第二次是根据主键去聚簇索引中进行查找),意味着MyISAM中建立的索引相当于全部都是二级索引

② InnoDB的数据文件本身就是索引文件,而MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址

③ InnoDB的非聚簇索引data域存储相应记录主键的值,而MyISAM索引记录的是地址。换句话说,InnoDB的所有非聚簇索引都引用主键作为data域。

④ MyISAM的回表操作是十分快速的,因为是拿着地址偏移量直接到文件中取数据的,反观InnoDB是通过获取主键之后再去聚簇索引里找记录,虽然说也不慢,但还是比不上直接用地址去访问。

⑤ InnoDB要求表必须有主键MyISAM可以没有)。如果没有显式指定,则MySQL系统会自动选择一个可以非空且唯一标识数据记录的列作为主键。如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整型。

image-20220506000814916

5. 索引的代价

索引是个好东西,可不能乱建,它在空间和时间上都会有消耗:

  • 空间上的代价

每建立一个索引都要为它建立一棵B+树,每一棵B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B+树由许多数据页组成,那就是很大的一片存储空间。

  • 时间上的代价

每次对表中的数据进行增、删、改操作时,都需要去修改(维护)各个B+树索引。而且我们讲过,B+树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列(主键或索引列)的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位页面分裂页面回收等操作来维护好节点和记录的排序。如果我们建了许多索引,每个索引对应的B+树都要进行相关的维护操作,会给性能拖后腿。

6. MySQL数据结构选择的合理性

从MySQL的角度讲,不得不考虑一个现实的问题就是磁盘IO,如果我们能让索引的数据结构尽量减少磁盘的IO操作,所消耗的时间也就越少。

查找都是索引操作,一般来说索引非常大,尤其是关系型数据库,索引可能达到几个G,甚至更多。为了减少索引在内存中的占用,数据库索引都是存储在磁盘上的,如之前所看到的数据和索引都存储在xxx.ibd文件中,这是InnoDB,因为数据和索引存储在一起,而MyISAM的索引也是单独的文件存储,存储在磁盘上的,当我们利用索引进行查询的时候,不可能一次性把所有的索引都加载到内存,只能逐一加载,那么MySQL衡量查询效率的标准就是磁盘IO次数。

6.1 Hash结构

image-20220506002356745

6.2 二叉搜索树

如果我们利用二叉树作为索引结构,那么磁盘的IO次数和索引树的高度是相关的。

image-20220330163602743

为了提高查询效率,就需要减少磁盘IO数。为了减少磁盘IO的次数,就需要尽量降低树的高度,需要把原来“瘦高”的树结构变的“矮胖”,树的每层的分叉越多越好。

6.3 AVL树

如果我们利用二叉树作为索引结构,那么磁盘的IO次数和索引树的高度是相关的。

为了解决上面二叉查找树退化成链表的问题,人们提出了平衡二叉搜索树(Balanced Binary Tree),又称为AVL树,它在二叉搜索树上增加了基础,具有以下性质:

它是一棵空树,或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

这里说一下,常见的平衡二叉树有很多种,包括了平衡二叉搜索树、红黑树、数堆、伸展树。平衡二叉搜索树是最早提出的自平衡二叉搜索树,当我们提到平衡二叉树时一般指的就是平衡二叉搜索树。事实上,第一棵树就属于平衡二叉搜索树,搜索时间复杂度是O(logn)

数据查询的时间也主要依赖于磁盘IO的次数,如果我们采用二叉树的形式,即使通过平衡二叉搜索树进行了改进,树的深度也是O(logn),当n比较大时,树的深度也是比较高的

每访问一次节点,就需要进行一次磁盘IO操作。对于以下这棵树来说,我们需要进行5次IO操作。

image-20220330163506536

针对同样的数据,如果我们把二叉树改成M 叉树(M>2)呢?当 M=3 时,同样的 31 个节点可以由下面的三叉树来进行存储:

image-20220330163709022

可以看到此时树的高度降低了,当数据量N大的时候,以及树的分叉数M大的时候,M叉树的高度会远小于二叉树的高度(M > 2),所以我们需要把树从瘦高编程矮胖。树的高度是多少,那么我们就需要进行多少次磁盘IO来找到数据。

6.4 B-Tree

B树的英文是Balance Tree,是多路平衡查找树。它的高度远小于平衡二叉树的高度。

B 树的结构如下图所示:

image-20220330163803905

B树作为多路平衡查找树,它的每一个节点最多可以包括M个子节点,M称为B树的阶。

一个 M 阶的 B 树(M>2)有以下的特性:

  1. 根节点的儿子数的范围是 [2,M]。

  2. 每个中间节点包含 k-1 个关键字和 k 个孩子,孩子的数量 = 关键字的数量 +1,k 的取值范围为[ceil(M/2), M]。

  3. 叶子节点包括 k-1 个关键字(叶子节点没有孩子),k 的取值范围为 [ceil(M/2), M]。

  4. 假设中间节点节点的关键字为:Key[1], Key[2], …, Key[k-1],且关键字按照升序排序,即 Key[i] <Key[i+1]。此时 k-1 个关键字相当于划分了 k 个范围,也就是对应着 k 个指针,即为:P[1], P[2], …,P[k],其中 P[1] 指向关键字小于 Key[1] 的子树,P[i] 指向关键字属于 (Key[i-1], Key[i]) 的子树,P[k]指向关键字大于 Key[k-1] 的子树。

  5. 所有叶子节点位于同一层。

上面那张图所表示的 B 树就是一棵 3 阶的 B 树。我们可以看下磁盘块 2,里面的关键字为(8,12),它有 3 个孩子 (3,5),(9,10) 和 (13,15),你能看到 (3,5) 小于 8,(9,10) 在 8 和 12 之间,而 (13,15)大于 12,刚好符合刚才我们给出的特征。

然后我们来看下如何用 B 树进行查找。假设我们想要查找的关键字是 9,那么步骤可以分为以下几步:

  1. 我们与根节点的关键字 (17,35)进行比较,9 小于 17 那么得到指针 P1;

  2. 按照指针 P1 找到磁盘块 2,关键字为(8,12),因为 9 在 8 和 12 之间,所以我们得到指针 P2;

  3. 按照指针 P2 找到磁盘块 6,关键字为(9,10),然后我们找到了关键字 9。

你能看出来在 B 树的搜索过程中,我们比较的次数并不少,但如果把数据读取出来然后在内存中进行比较,这个时间就是可以忽略不计的。而读取磁盘块本身需要进行 I/O 操作,消耗的时间比在内存中进行比较所需要的时间要多,是数据查找用时的重要因素。

B 树相比于平衡二叉树来说磁盘 I/O 操作要少,在数据查询中比平衡二叉树效率要高。所以只要树的高度足够低,IO次数足够少,就可以提高查询性能

小结:

  • B树在插入和删除节点的时候如果导致树不平衡,就通过自动调整节点的位置来保持树的自平衡。因为B树是多路平衡查找树。

  • 关键字集合分布在整棵树中,即叶子节点和非叶子节点都存放数据,搜索有可能在非叶子节点结束。一定要注意这点,这是和B+树很大的差别。

    即B树的内节点中也要存储数据,并不是说用户记录即数据都是存储在叶子节点上的。

  • 其搜索性能等价于在关键字全集内做一次二分查找。

再举例1:

image-20220506185426045

6.5 B+Tree

B+ 树和 B 树的差异:

  1. 有 k 个孩子的节点就有 k 个关键字。也就是孩子数量 = 关键字数,而 B 树中,孩子数量 = 关键字数+1。

  2. 非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。

  3. **非叶子节点仅用于索引,不保存数据记录,跟记录有关的信息都放在叶子节点中。**而 B 树中, 非叶子节点既保存索引,也保存数据记录

  4. 所有关键字都在叶子节点出现,叶子节点构成一个有序链表(节点之间是双向链表,节点内部的数据是单向链表),而且叶子节点本身按照关键字的大小从小到大顺序链接。

    B树的话,不是所有关键字都在叶子节点出现,因为有一些关键字是在非叶子节点中,非叶子节点也要保存数据。也就是B树的内节点既要存索引,也要存数据。

首先,B+树查询效率更稳定,因为B+树每次只有访问到叶子节点才能找到对应的数据,而在B树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况,有时候访问到非叶子节点就可以找到关键字,而有时需要访问到叶子节点,才能找到关键字。

其次,B+树的查询效率更高,这是因为通常B+树比B树更矮胖,查询所需要的磁盘IO也会更少,同样的磁盘页大小,B+树可以存储更多的节点关键字。

不仅是对单个关键字的查询上,在查询范围上,B+树的效率也比B树高。这是因为B+树中,所有关键字都出现在B+树的叶子节点中,叶子节点之间会有指针,因为数据是逻辑上排序的,是链表的形式,数据又是递增的,这使得我们范围查找可以通过指针连接查找。而在B树中,则需要通过中序遍历才能完成查询范围的查找,效率要低很多,仍是因为B树并不是所有的数据都存储在叶子节点,而是非叶子节点也要存储数据!!!

B 树和 B+ 树都可以作为索引的数据结构,在 MySQL 中采用的是 B+ 树。

但B树和B+树各有自己的应用场景,不能说B+树完全比B树好,反之亦然。

思考题:为了减少IO,索引树会一次性加载吗?

1、数据库索引是存储在磁盘上的,如果数据量很大,必然导致索引的大小也会很大,超过几个G。

2、当我们利用索引查询时候,是不可能将全部几个G的索引都加载进内存的,我们能做的只能是:逐一加载每一个磁盘页,因为磁盘页对应着索引树的节点。

思考题:B+树的存储能力如何?为何说一般查找行记录,最多只需1~3次磁盘IO

InnoDB存储引擎中页的大小为16KB,一般表的主键类型为INT(占用4个字节)或BIGINT(占用8个字节),指针类型也一般为4或8个字节,也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K个键值,因为是估算,为了方便计算,这里的K取值为10^3。也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿条记录。(这里假定一个数据页也存储10^3条行记录数据了)

实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree的高度一般都在2~4层。MySQL的InnoDB存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要1~3次磁盘I/O操作

思考题:为什么说B+树比B-树更适合实际应用中操作系统的文件索引和数据库索引?

1.B+树的磁盘读写代价更低

B+树的内部结点并没有指向关键字具体信息的指针(也就是内部节点不放数据,就能放更多索引,更多指针,而一个节点就是一个磁盘页或者说数据页,那么加载一个数据页,能读取到的索引就更多,就更容易找到需要的数据,效率就更高。)。因此其内部结点相对于B树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。

2、B+树的查询效率更加稳定

由于非终结点并不是最终指向文件内容的节点,而只是叶子结点中关键字的索引。所有任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

思考题:Hash索引与B+树索引的区别

1、Hash索引不能进行范围查询,而B+树可以。这是因为Hash索引指向的数据是无序的,而B+树的叶子节点是个有序的链表。

2、Hash索引不支持联合索引的最左侧原则(即联合索引的部分索引无法使用),而B+树可以。对于联合索引来说,Hash索引在计算Hash值的时候是将索引键合并后再一起计算Hash值,所以不会针对每个索引单独计算Hash值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。

3、Hash索引不支持 ORDER BY 排序,因为Hash索引指向的数据是无序的,因此无法起到排序优化的作用,而B+树索引数据是有序的,可以起到对该字段ORDER BY 排序优化的作用。同理,我们也无法用Hash索引进行模糊查询,而B+树使用LIKE进行模糊查询的时候,LIKE后面后模糊查询(比如%结尾)的话就可以起到优化作用。

4、InnoDB不支持哈希索引

第07章 InnoDB数据存储结构

1. 数据库的存储结构:页

索引结构给我们提供了高效的索引方式,不过索引信息以及数据记录都保存在文件上的,确切说是存储在页结构中。另一方面,索引是在存储引擎中实现的,MySQL服务器上的存储引擎负责对表中数据的读取和写入工作。不同存储引擎中存放的格式一般不同的,甚至有的存储引擎比如Memory都不用磁盘来存储数据。

由于InnoDB是MySQL的默认存储引擎,所以本章剖析InooDB存储引擎的数据存储结构。

1.1 磁盘与内存交互基本单位:页

InnoDB将数据划分为若干个页,InnoDB中页的大小默认为16KB

作为磁盘和内存之间交互的基本单位,也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中(回写操作也是以页为单位)。也就是说,**在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page),数据库I/O操作的最小单位是页。**一个页中可以存储多个行记录。

之前的B+树聚簇索引结构,为什么说一个访问了几个节点,就需要进行几次和磁盘的IO操作呢?就是因为一个节点就是一个数据页或者说磁盘页。

记录是按照行来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次I/O操作)只能处理一行数据,效率会非常低。

1.2 页结构概述

image-20220506192846780

页a、页b、页c...页n这些页可以不在物理结构上相连,只要通过双向链表相关联即可。每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储在它里边的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应的分组中的记录即可快速找到指定的记录。

1.3 页的上层结构

image-20220330183814954

区(Extent)是比页大一级的存储结构,在InnoDB存储引擎中,一个区会分配64个连续的页。因为InnoDB中的页大小默认是16KB,所以一个区的大小是64*16KB=1MB

段(Segment)由一个或多个区组成,区在文件系统是一个连续分配的空间(在InnoDB中是连续的64个页),不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会创建一个索引段。

表空间(Tablespace)是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间用户表空间撤销表空间临时表空间等。

2. 页的内部结构

页如果按照类型划分的话,常见的有数据页(保存B+树节点)、系统页、UNDO页和事务数据页等。数据页是我们最常用的页。

2.1 第1部分:文件头部和文件尾部

2.1.1 File Header(文件头部)(38字节)

作用: 描述各种页的通用信息。(比如页的编号、其上一页、下一页是谁等)

大小:38字节

名称占用空间大小描述
FIL_PAGE_SPACE_OR_CHKSUM4字节页的校验和(checksum值)
FIL_PAGE_OFFSET4字节页号
FIL_PAGE_PREV4字节上一个页的页号
FIL_PAGE_NEXT4字节下一个页的页号
FIL_PAGE_LSN8字节页面被最后修改时对应的日志序列位置
FIL_PAGE_TYPE2字节该页的类型
FIL_PAGE_FILE_FLUSH_LSN8字节仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID4字节页属于哪个表空间
  • FIL_PAGE_OFFSET(4字节):每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号可以唯一定位一个页。
  • FIL_PAGE_TYPE(2字节):这个代表当前页的类型。
类型名称十六进制描述
FIL_PAGE_TYPE_ALLOCATED0x0000最新分配,还没有使用
FIL_PAGE_UNDO_LOG0x0002Undo日志页
FIL_PAGE_INODE0x0003段信息节点
FIL_PAGE_IBUF_FREE_LIST0x0004Insert Buffer空闲列表
FIL_PAGE_IBUF_BITMAP0x0005Insert Buffer位图
FIL_PAGE_TYPE_SYS0x0006系统页
FIL_PAGE_TYPE_TRX_SYS0x0007事务系统数据
FIL_PAGE_TYPE_FSP_HDR0x0008表空间头部信息
FIL_PAGE_TYPE_XDES0x0009扩展描述页
FIL_PAGE_TYPE_BLOB0x000A溢出页
FIL_PAGE_INDEX0x45BF索引页,也就是我们所说的数据页
  • FIL_PAGE_PREV(4字节)和FIL_PAGE_NEXT(4字节):InnoDB都是以页为单位存放数据的,如果数据分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,保证这些页之间不需要是物理上的连续,而是逻辑上的连续。

  • FIL_PAGE_SPACE_OR_CHKSUM(4字节)

    就是对于一个很长的字符串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字符串,这个比较短的值就称为校验和。

    在比较两个很长的字符串之前,先比较这两个长字符串的校验和,如果校验和都不一样,则两个长字符串肯定是不同的,所以省去了直接比较两个比较长的字符串的时间损耗。

    代表当前页面的校验和(checksum)。文件头部和文件尾部都有属性:FIL_PAGE_SPACE_OR_CHKSUM

    作用:

    InnoDB存储引擎以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候断电了,造成了该页传输的不完整。

    为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),这时可以通过文件尾的校验和(checksum 值)与文件头的校验和做比对,如果两个值不相等则证明页的传输有问题,需要重新进行传输,否则认为页的传输已经完成。

    具体的:

    每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前边,所以校验和会首先被同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trailer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。这里,校验方式就是采用hash算法进行校验。

  • FIL_PAGE_LSN(8字节):页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number)

2.1.2 File Trailer(文件尾部)(8字节)
  • 前4个字节代表页的校验和:这个部分是和File Header中的校验和相对应的。
  • 后4个字节代表页面被最后修改时对应的日志序列位置(LSN):这个部分也是为了校验页的完整性的,如果首部和尾部的LSN值校验不成功的话,就说明同步过程出现了问题。

2.2 第2部分:空闲空间、用户记录和最小最大记录

image-20220506201446915

2.2.1 Free Space (空闲空间)

我们自己存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。

2.2.2 User Records (用户记录)

User Records中的这些记录按照指定的行格式一条一条摆在User Records部分,相互之间形成单链表

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

记录可以比较大小吗? 是的,记录可以比大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录是从小到大依次递增。

InnoDB规定的最小记录与最大记录这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的。

image-20220330191335574

这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分

image-20220330191446070

2.3 第3部分:页目录和页面头部

2.3.1 Page Directory(页目录)

为什么需要页目录? 在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索,提升效率。

页目录,二分法查找

  1. 将所有的记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录。

  2. 第 1 组,也就是最小记录所在的分组只有 1 个记录; 最后一组,就是最大记录所在的分组,会有 1-8 条记录; 其余的组记录数量在 4-8 条之间。 这样做的好处是,除了第 1 组(最小记录所在组)以外,其余组的记录数会尽量平分

  3. 在每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段。

  4. 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。

    image-20220506222057864

举例:

现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录。如下图:

image-20220330192130497

从这个图中我们需要注意这么几点:

  • 现在页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);槽0中的值是99,代表最小记录的地址偏移量。
  • 注意最小和最大记录的头信息中的n_owned属性
    • 最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。
    • 最大记录的n_owned值为5,这就代表着以最大记录结尾的这个分组中只有5条记录,包括最大记录本身还有我们自己插入的4条记录。

用箭头指向的方式替代数字,这样更易于我们理解,修改后如下

image-20220330192413776

为什么最小记录的n_owned值为1,而最大记录的n_owned值为5呢?

InnoDB规定:对于最小记录所在的分组只能有1条记录,最大记录所在的分组拥有的记录条数只能在1~8条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。

分组是按照下边的步骤进行的:

  • 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
  • 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
  • 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
2.3.2 Page Header(页面头部)

为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,这个部分占用固定的56个字节,专门存储各种状态信息。

名称占用空间大小描述
PAGE_N_DIR_SLOTS2字节在页目录中的槽数量
PAGE_HEAP_TOP2字节还未使用的空间最小地址,也就是说从该地址之后就是Free Space
PAGE_N_HEAP2字节本页中的记录的数量(包括最小和最大记录以及标记为删除的记录)
PAGE_FREE2字节第一个已经标记为删除的记录的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用)
PAGE_GARBAGE2字节已删除记录占用的字节数
PAGE_LAST_INSERT2字节最后插入记录的位置
PAGE_DIRECTION2字节记录插入的方向
PAGE_N_DIRECTION2字节一个方向连续插入的记录数量
PAGE_N_RECS2字节该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录)
PAGE_MAX_TRX_ID8字节修改当前页的最大事务ID,该值仅在二级索引中定义
PAGE_LEVEL2字节当前页在B+树中所处的层级
PAGE_INDEX_ID8字节索引ID,表示当前页属于哪个索引
PAGE_BTR_SEG_LEAF10字节B+树叶子段的头部信息,仅在B+树的Root页定义
PAGE_BTR_SEG_TOP10字节B+树非叶子段的头部信息,仅在B+树的Root页定义

3. InnoDB行格式(或记录格式)

3.1 指定行格式的语法

CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
ALTER TABLE 表名 ROW_FORMAT=行格式名称

3.2 COMPACT行格式

在MySQL 5.1版本中,默认设置为Compact行格式。一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分。

image-20220330193949517

3.2.1 变长字段长度列表

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

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

3.2.2 NULL值列表

Compact行格式会把可以为NULL的列统一管理起来,存在一个标记为NULL值列表中。如果表中没有允许存储 NULL 的列,则 NULL值列表也不存在了。 为什么定义NULL值列表? 之所以要存储NULL是因为数据都是需要对齐的,如果没有标注出来NULL值的位置,就有可能在查询数据的时候出现混乱。如果使用一个特定的符号放到相应的数据位表示空置的话,虽然能达到效果,但是这样很浪费空间,所以直接就在行数据得头部开辟出一块空间专门用来记录该行数据哪些是非空数据,哪些是空数据,格式如下:

  1. 二进制位的值为1时,代表该列的值为NULL。
  2. 二进制位的值为0时,代表该列的值不为NULL。

注意:同样顺序也是反过来存放的

3.2.3 记录头信息(5字节)

image-20220330194534127

名称大小(单位:bit)描述
预留位11没有使用
预留位21没有使用
delete_mask1标记该记录是否被删除
mini_rec_mask1B+树的每层非叶子节点中的最小记录都会添加该标记
n_owned4表示当前记录拥有的记录数
heap_no13表示当前记录在记录堆的位置信息
record_type3表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录
next_record16表示下一条记录的相对位置
  • delete_mask:这个属性标记着当前记录是否被删除,占用1个二进制位。

    • 值为0:代表记录并没有被删除
    • 值为1:代表记录被删除掉了

    被删除的记录为什么还在页中存储呢? 你以为它删除了,可它还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后其他的记录在磁盘上需要重新排列,导致性能消耗。所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。

  • min_rec_mask:B+树的每层非叶子节点中的最小记录都会添加该标记,min_rec_mask值为1。我们自己插入的四条记录(用户记录)的min_rec_mask值都是0,意味着它们都不是B+树的非叶子节点中的最小记录。

  • record_type:这个属性表示当前记录的类型,一共有4种类型的记录:

    • 0:表示普通记录
    • 1:表示B+树非叶节点记录
    • 2:表示最小记录
    • 3:表示最大记录
  • heap_no:这个属性表示当前记录在本页中的位置。

    怎么不见heap_no值为0和1的记录呢? MySQL会自动给每个页里加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录。最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前

    image-20220506203302015

  • n_owned:页目录中每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段

  • next_record:记录头信息里该属性非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量

    image-20220506203503802

    image-20220506203537904

    image-20220506203841921

    image-20220506203906362

    image-20220506204023122

    注意:新插入的记录复用了被删除记录的存储空间。因为被删除记录的空间标记为可重用空间,InnoDB会维护一条所谓的垃圾链表,这些都是被标记为已删除的记录,这些记录占用的空间都是可重用空间。

3.2.4 记录的真实数据
列名是否必须占用空间描述
row_id6字节行ID,唯一标识一条记录
transaction_id6字节事务ID
roll_pointer7字节回滚指针

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

3.3 Dynamic和Compressed行格式

我们可以知道一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65533个字节,这样就可能出现一个页存放不了一条记录,这种现象称为行溢出

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

在MySQL 8.0中,默认行格式就是Dynamic,Dynamic、Compressed行格式和Compact行格式挺像,只不过在处理行溢出数据时有分歧

  • Compressed和Dynamic两种记录格式对于存放在BLOB中的数据采用了完全的行溢出的方式。如图,在数据页中只存放20个字节的指针(溢出页的地址),实际的数据都存放在Off Page(溢出页)中。
  • Compact和Redundant两种格式会在记录的真实数据处存储一部分数据(存放768个前缀字节)。

4. 区、段和碎片区

4.1 为什么要有区?

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

引入的概念,一个区就是物理位置上连续的64个页(让页在物理磁盘上是连续的,那么就能进行顺序IO)。因为InnoDB中的页的大小默认是16KB,所以一个区的大小是64*16KB=1MB。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页的单位分配了,而是按照区为单位分配,甚至在表中的数据特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费(数据不足以填充满整个区),但是从性能角度看,可以消除很多的随机I/O,功大于过

顺序读取:

顺序读取其实是一种批量读取的方式,因为我们请求的数据在磁盘上往往都是相邻存储的,顺序读取可以帮我们批量读取页面,这样的话,一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘IO操作了。内存与磁盘的交互是以页为单位的。如果一个磁盘的吞吐量是40MB/s,那么对于一个16kb大小的页来说,一次可以顺序读取2560个页,相当于一个页的读取时间是0.4ms。采用批量读取的方式,即使是从磁盘上读取,效率也比从内存中只单独读取一个页的效率要高。所以我们要尽可能使用顺序IO。顺序读取就是为了批量读取。

我们不能保证所有的数据页都是连续的,于是引入区的概念。

4.2 为什么要有段?

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

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

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

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

一个区中的页是连续的,但是段中的区不是连续的。

4.3 为什么要有碎片区?

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

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

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

  • 在刚开始向表中插入数据的时候,段是从某个碎片区以单个页面为单位来分配存储空间的。
  • 当某个段已经占用了32个碎片区页面之后,就会申请以完整的区为单位来分配存储空间。

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

4.4 区的分类

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

  • 空闲的区(FREE):现在还没有用到这个区中的任何页面。
  • 有剩余空间的碎片区(FREE_FRAG):表示碎片区中还有可用的页面。
  • 没有剩余空间的碎片区(FULL_FRAG):表示碎片区中的所有页面都被使用,没有空闲页面。
  • 附属于某个段的区(FSEG):每一索引都可以分为叶子节点段和非叶子节点段

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

第08章 索引的创建与设计原则

1. 索引的声明与使用

1.1 索引的分类

  • 功能逻辑上说,索引主要有 4 种,分别是普通索引、唯一索引、主键索引、全文索引。
  • 按照物理实现方式,索引可以分为 2 种:聚簇索引和非聚簇索引。
  • 按照作用字段个数进行划分,分成单列索引和联合索引。
  1. 普通索引

    在创建普通索引时,不附加任何限制条件,只是用于提高查询效率。这类索引可以创建在任何数据类型中,其值是否唯一和非空,要由字段本身的完整性约束条件决定。建立索引以后,可以通过索引进行查询。例如,在表student的字段name上建立一个普通索引,查询记录时就可以根据该索引进行查询。

  2. 唯一性索引

    声明了唯一性的字段,自动地会添加唯一性索引。

    使用UNIQUE参数可以设置索引为唯一性索引,在创建唯一性索引时,限制该索引的值必须是唯一的,但允许有空值。在一张数据表里可以有多个唯一性索引。

    例如,在表student的字段email中创建唯一性索引,那么字段email的值就必须是唯一的,通过唯一性索引,可以更快地确定某条记录。

  3. 主键索引

    **主键约束的字段就自动会有主键索引。**主键索引就是聚簇索引。

    主键索引就是一种特殊的唯一性索引,在唯一索引的基础上加了非空的约束,也就是NOT NULL + UNIQUE,一张表里最多只能有一个主键索引,也就是只能有一个键被设置为主键。

  4. 单列索引

    在表中的单个字段上创建索引,单列索引只根据该字段进行索引,单列索引可以是普通索引,也可以是唯一性索引,还可以是全文索引。只要保证该索引只对应一个字段即可。一个表可以有多个单列索引。

  5. 多列索引(或联合索引)

    多列索引是在表的多个字段组合上创建一个索引。该索引指向创建时的多个字段,可以通过这几个字段进行查询,但是只有查询条件中使用了这些字段中的第一个字段时才会被使用。例如,在表中的字段id、name和gender上建立一个多列索引,只有在查询条件中使用了字段id时,该索引才会被使用,使用组合索引时遵循最左前缀原则

1.2 创建索引

MySQL支持多种方式在单个或多个列上创建索引:在创建表的定义语句CREATE TABLE中指定索引列,使用ALTER TABLE语句在存在的表上创建索引,或者使用CREATE INDEX语句在已存在的表上添加索引。

CREATE TABLE table_name [col_name data_type] 
[UNIQUE | FULLTEXT | SPATIAL] [INDEX | KEY] [index_name] (col_name [length]) [ASC | DESC]
  • UNIQUEFULLTEXTSPATIAL为可选参数,分别表示唯一索引、全文索引和空间索引;
  • INDEXKEY为同义词,两者的作用相同,用来指定创建索引;
  • index_name指定索引的名称,为可选参数,如果不指定,那么MySQL默认col_name为索引名;
  • col_name为需要创建索引的字段列,该列必须从数据表中定义的多个列中选择;
  • length为可选参数,表示索引的长度,只有字符串类型的字段才能指定索引长度;
  • ASCDESC指定升序或者降序的索引值存储。
1.创建表的时候创建索引

使用CREATE TABLE创建表时,除了可以定义列(字段)的数据类型外,还可以定义主键约束、唯一性约束或者外键约束,但是不论定义以上哪种约束,在定义约束的同时相当于在指定列上创建了一个索引。在是隐式的方式创建索引。

显示的方式创建:

  • 1. 创建普通索引
CREATE TABLE book( 
    book_id INT , 
    book_name VARCHAR(100), 
    authors VARCHAR(100), 
    info VARCHAR(100) , 
    comment VARCHAR(100), 
    year_publication YEAR, 
    INDEX idx_bname(book_name)
);#idx_bname是索引的名字,括号里的是索引作用的字段,为哪个字段、哪个列添加的索引。
#为这个字段加上索引之后,通过这个字段去检索数据会更有效率。
#通过命令查看索引
#1.
show create table book;
#2.
show index from book;

image-20220506234506092

通过上图,发现就用到了索引。

  • 2. 创建唯一索引

    创建唯一性约束,就会自动为这个字段创建唯一性索引

    创建唯一索引,就会自动为这个字段创建唯一性约束。

    声明唯一索引的字段,即这个字段的值只能是唯一的,但是允许空值null,而主键是不允许空值的。

CREATE TABLE test1( 
    id INT NOT NULL, 
    name varchar(30) NOT NULL, 
    UNIQUE INDEX uk_idx_id(id) 
);
  • 3. 主键索引

    通过定义主键约束的方式去定义主键索引。就是隐式的方式。

    create table book1(
        book_id INT primary key auto_increament,
        book_name varchar(100),
        authors VARCHAR(100), 
        info VARCHAR(100) , 
        comment VARCHAR(100), 
        year_publication YEAR
    );
    
CREATE TABLE student ( 
    id INT(10) UNSIGNED AUTO_INCREMENT, 
    student_no VARCHAR(200),
    student_name VARCHAR(200), 
    PRIMARY KEY(id) 
);
# 删除主键索引就是通过删除主键约束的方式。
ALTER TABLE student drop PRIMARY KEY ;
  • 4. 创建单列索引
CREATE TABLE test2( 
    id INT NOT NULL, 
    name CHAR(50) NULL, 
    INDEX single_idx_name(name(20)) 
);
  • 5. 创建组合索引
CREATE TABLE test3( 
    id INT(11) NOT NULL, 
    name CHAR(30) NOT NULL, 
    age INT(11) NOT NULL, 
    info VARCHAR(255), 
    INDEX multi_idx(id,name,age)  #这个字段的索引是有严格顺序的。因为b+树索引,会涉及到按字段的值进行排序,一定是先排id,再排name、再排age。
);

对于联合索引,使用索引的时候要遵循最左前缀原则。

最左前缀原则,通过b+树索引结构来理解,为什么在查询的时候,where子句的第一个条件没有判断联合索引的第一个字段,索引就失效,根据b+树就很好理解,因为创建索引的时候,就是有顺序的,字段相同的,再按第二个字段排,依次类推。所以一定要遵循最左前缀原则,创建联合索引时,写在第一位的字段,一定要是最常用的字段,最可能用在过滤条件中的字段。

  • 6. 创建全文索引
CREATE TABLE `papers` ( 
    id` int(10) unsigned NOT NULL AUTO_INCREMENT, 
    `title` varchar(200) DEFAULT NULL, 
    `content` text, PRIMARY KEY (`id`), 
    FULLTEXT KEY `title` (`title`,`content`) 
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
SELECT * FROM papers WHERE MATCH(title,content) AGAINST (‘查询字符串’);
  • 7. 创建空间索引
CREATE TABLE test5( 
    geo GEOMETRY NOT NULL, 
    SPATIAL INDEX spa_idx_geo(geo) 
) ENGINE=MyISAM;
2. 在已经存在的表上创建索引
  • 1. 使用ALTER TABLE语句创建索引
ALTER TABLE table_name 
ADD [UNIQUE | FULLTEXT | SPATIAL] [INDEX | KEY] [index_name] (col_name[length],...) [ASC | DESC]
  • 2. 使用CREATE INDEX创建索引
CREATE [UNIQUE | FULLTEXT | SPATIAL] INDEX index_name 
ON table_name (col_name[length],...) [ASC | DESC]

1.3 删除索引

1. 使用ALTER TABLE删除索引

ALTER TABLE table_name DROP INDEX index_name;

2. 使用DROP INDEX语句删除索引

DROP INDEX index_name ON table_name;

2. MySQL8.0索引新特性

2.1 支持降序索引

CREATE TABLE ts1(a int,b int,index idx_a_b(a,b desc));

2.2 隐藏索引

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

1. 创建表时直接创建

CREATE TABLE tablename( 
    propname1 type1[CONSTRAINT1], 
    propname2 type2[CONSTRAINT2], 
    ……
    propnamen typen, 
    INDEX [indexname](propname1 [(length)]) INVISIBLE 
);

2. 在已经存在的表上创建

CREATE INDEX indexname 
ON tablename(propname[(length)]) INVISIBLE;

3. 通过ALTER TABLE语句创建

ALTER TABLE tablename 
ADD INDEX indexname (propname [(length)]) INVISIBLE;

4. 切换索引可见状态

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

3. 索引的设计原则

为了使索引的使用效率更高,在创建索引时,必须考虑在哪些字段上创建索引和创建什么类型的索引。索引设计不合理或者缺少索引都会对数据库和应用程序的性能造成障碍。高效的索引对于良好的性能非常重要。

3.1 哪些情况适合创建索引

1. 字段的数值有唯一性的限制,那么推荐添加唯一性的索引。

如果这个字段已经有了唯一性约束了,那就不需要添加唯一性索引了,因为已经有了,已经自动生成唯一性索引了。

同理,为某个字段添加唯一性索引,那么也会自动添加唯一性约束。

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

例如,学生表中学号是具有唯一性的字段,为该字段建立唯一性索引可以很快地确定某个学生的信息,如果使用姓名的话,可能存在同名现象,从而降低查询速度。

业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。(来源:Alibaba)

说明:不要以为唯一索引影响了insert速度(因为在增删改的时候,也是需要对索引进行维护的),这个速度损耗可以忽略,但提高查找速度是明显的。

2. 频繁作为 WHERE 查询条件的字段

某个字段在SELECT语句的 WHERE 条件中经常被使用到,那么就需要给这个字段创建索引了。尤其是在数据量大的情况下,创建普通索引就可以大幅提升数据查询的效率。

3. 经常 GROUP BY ORDER BY 的列

索引就是让数据按照某种顺序进行存储或检索,因此当我们使用 GROUP BY 对数据进行分组查询,或者使用 ORDER BY 对数据进行排序的时候,就需要对分组或者排序的字段进行索引。如果待排序的列有多个,那么可以在这些列上建立组合索引

比如说group by根据一个字段进行分组,order by对另一个字段进行排序,那么分别对这两个字段建立单列索引,最后发现只有group by的字段建立的索引生效了,这是因为sql的执行顺序,先where筛选出来之后,再分组group by,再having,再order by,于是只有group by的列的索引生效了。

现在为这两个字段添加联合索引,发现查询的时候就大大减少了时间。建立联合索引一定要把group by的字段写在前,order by的字段写在后。

4. UPDATE、DELETE WHERE 条件列

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

5.DISTINCT 字段需要创建索引

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

6. 多表 JOIN 连接操作时,创建索引注意事项

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

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

最后,对用于连接的字段创建索引,并且该字段在多张表中的类型必须一致。比如course_id在student_info表和course表中都为int(11)类型,而不能一个为int,另一个为varchar类型。

如果一个为int类型,另一个为varchar类型,那么在join on的时候,MySQL会存在隐式的转换,一旦发生了隐式的转换,那么就是用了函数,用了函数,这个索引就会失效,查询效率就会降低。

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

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

我们在定义表结构的时候要显示地指定列的类型,以整数类型为例,有TINYINT,MEDIUMINT,INT,BIGINT等,他们占用的存储空间依次递增,对应的列的索引占的存储空间也是依次递增,能表示的整数范围当然也是依次递增。如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的数据类型,就是占的存储空间范围小,从而索引占的空间小,一个数据页能存下更多的索引,即一个节点能放下更多的索引,节点就少,节点是数据页,是内存与磁盘IO交互的基本单位,那么与磁盘IO的次数越少,效率越高。

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

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

8. 使用字符串前缀创建索引

假设我们的字符串很长,那存储一个字符串就需要占用很大的存储空间,在我们需要为这个字符串建立索引的时候,那就意味着在对应的B+树中有以下两个问题:

  • B+树索引的记录需要把该列的完整字符串存储起来,更费时。叶子节点是完整的用户记录,非叶子节点是目录项记录,是索引列+页号的形式。索引列是字符串的,那么需要占用很大存储空间,一个数据页(目录页)即一个节点存储的索引就更少,所需要的节点就更多,增加了与磁盘的IO,可以这么理解。
  • 如果B+树索引中索引列存储的字符串很长,那在做字符串比较的时候,会占用更多时间。

我们可以通过截取字段的前面一部分内容建立索引,这个叫做前缀索引。这样在查找记录时虽然不能精确地定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同的记录的主键值进行回表查询。

区分度计算公式:

count(distinct left(列名, 索引长度))/count(*)

拓展:Alibaba《Java开发手册》

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

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

9. 区分度高(散列性高)的列适合作为索引

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

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

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

10. 使用最频繁的列放到联合索引的左侧

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

3.2 限制索引的数目

在实际工作中,我们也需要注意平衡,索引的数目不是越多越好。我们需要限制每张表上的索引数量,建议单张表索引数量不超过6个。原因:

  • 每个索引都需要占用磁盘空间,索引越多,需要的磁盘空间就越大。
  • 索引会影响INSERT、DELETE、UPDATE等语句的性能,因为表中的数据更改的同时,索引也会进行调整和更新,会造成负担。
  • 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,会增加MySQL优化器生成执行计划时间,降低查询性能。

3.3 哪些情况不适合创建索引

1. 在where中使用不到的字段,不要设置索引

2. 数据量小的表最好不要使用索引

3. 有大量重复数据的列上不要建立索引

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

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

例如身份证、UUID(在索引比较时需要转为ASCII,并且插入时可能造成页分裂)、MD5、HASH、无序长字符串等。

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

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

image-20220507160540797

第09章 性能分析工具的使用

在数据库调优中,我们的目标就是响应时间更快,吞吐量更大。利用宏观的监控工具和微观的日志分析可以帮我们快速找到调优的思路和方式。

1. 统计SQL的查询成本:last_query_cost

一条SQL查询语句在执行前需要确定查询执行计划(优化器),如果存在多种执行计划的话,MySQL会计算每个执行计划所需要的成本(优化器、查询优化阶段),从中选择成本最小的一个作为最终执行的执行计划。

SQL语句:查询缓存-解析器-优化器-执行器

如果我们想要查看某条SQL语句的查询成本,可以在执行完这条SQL语句之后,通过查看当前会话中的last_query_cost变量值来得到当前查询的查询成本。它通常也是我们评价一个sql查询的执行效率的一个常用指标。这个查询成本对应的是SQL语句所需要读取的页的数量。数据页在B+树索引中就是一个节点!!

SHOW STATUS LIKE 'last_query_cost';

使用场景:它对于比较开销是非常有用的,特别是我们有好几种查询方式可选的时候。

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

  1. 位置决定效率。如果页就在数据库缓冲池中,那么效率是最高的,否则还需要从内存或者磁盘中进行读取,当然针对单个页的读取来说,如果页存在于内存中,会比在磁盘中读取效率高很多。
  2. 批量决定效率。如果我们从磁盘中对单一页进行随机读,那么效率是很低的(差不多10ms),而采用顺序读取的方式,批量对页进行读取,平均一页的读取效率就会提升很多,甚至要快于单个页面在内存中的随机读取。

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

2. 定位执行慢的SQL:慢查询日志

MySQL的慢查询日志,用来记录在MySQL中响应时间超过阈值的语句,具体指运行时间超过long_query_time的值的SQL,则会被记录到慢查询日志中。long_query_time的默认值为10,意思是运行10秒以上(不含10秒)的语句,认为是超出了我们的最大忍耐时间值。

它的主要作用是,帮助我们发现那些执行时间特别长的SQL查询,并且有针对性地进行优化,从而提高系统的整体效率。当我们的数据库服务器发生阻塞、运行变慢的时候,检查一下慢查询日志,找到那些慢查询,对解决问题很有帮助。比如一条sql语句执行超过5秒钟,我们就算慢SQL,希望能收集到超过5秒的sql,结合explain进行全面分析。

默认情况下,MySQL数据库没有开启慢查询日志,需要我们手动来设置这个参数。如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。

慢查询日志支持将日志记录写入文件。

2.1 开启慢查询日志参数

1. 开启slow_query_log

set global slow_query_log='ON';

查看下慢查询日志是否开启,以及慢查询日志文件的位置:

show variables like '%slow_query_log%;

2. 修改long_query_time阈值

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%'; 

#对当前session的long_query_time有效
mysql> set long_query_time=1; 
mysql> show variables like '%long_query_time%';

这样的方式修改配置,都是临时的方式,一旦服务重启,配置就失效了,如果想永久修改配置,那么就去配置文件中进行修改。

image-20220507163818901

image-20220507164734700

2.2 查看慢查询数目

SHOW GLOBAL STATUS LIKE '%Slow_queries%';

2.3 慢查询日志分析工具:mysqldumpslow

#得到返回记录集最多的10个SQL 
mysqldumpslow -s r -t 10 /var/lib/mysql/atguigu-slow.log 
#得到访问次数最多的10个SQL 
mysqldumpslow -s c -t 10 /var/lib/mysql/atguigu-slow.log
#得到按照时间排序的前10条里面含有左连接的查询语句 
mysqldumpslow -s t -t 10 -g "left join" /var/lib/mysql/atguigu-slow.log 
#另外建议在使用这些命令时结合 | 和more 使用 ,否则有可能出现爆屏情况 
mysqldumpslow -s r -t 10 /var/lib/mysql/atguigu-slow.log | more

2.4 关闭慢查询日志

在开发中,不是调优的时候,就关闭慢查询日志,多少会影响SQL的性能。

方式1:永久性方式

[mysqld] 
slow_query_log=OFF
#或
[mysqld] 
#slow_query_log =OFF

方式2:临时性方式

SET GLOBAL slow_query_log=off;

3. 查看 SQL 执行成本:SHOW PROFILE

show variables like 'profiling';
#开启
set profiling = 'ON';
#查看,能看到当前会话一共执行了几个查询
show profiles;

image-20220507165929442

#如果我们想查看最近一次查询的开销,可以使用:
show profile;
#我们也可以查看指定Query ID的查询语句的开销
show profile cpu,block io for query 2;

image-20220507170030634

image-20220507170059960

4. 分析查询语句:EXPLAIN

定位了查询慢的SQL之后,我们就可以使用EXPLAIN或DESCRIBE工具对查询语句做针对性的分析。DESCRIBE语句的使用方法与EXPLAIN语句是一样的,并且分析结果也是一样的。

MySQL中有专门负责优化select语句的优化器模块,主要功能:通过计算分析系统中收集到的统计信息,为客户端的请求Query提供它认为最优的执行计划。

这个执行计划展示了接下来具体执行查询的方式,比如多表连接的顺序是什么(物理查询优化),采用哪些索引(物理查询优化),对于每个表采用什么访问方法来具体执行查询等。MySQL为我们提供了EXPLAIN语句来帮助我们查看某个查询语句的具体执行计划,看懂EXPLAIN语句的各个输出项,可以针对性地提升我们的查询语句的性能。

能做什么?

  • 表的读取顺序
  • 数据读取操作的操作类型
  • 哪些索引可以使用
  • 哪些索引被实际使用
  • 表之间的引用
  • 每张表有多少行被优化器查询

image-20220507171258096

4.1 基本语法

EXPLAIN SELECT select_options 
#或者
DESCRIBE SELECT select_options

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

列名描述
id在一个大的查询语句中每个SELECT关键字都对应一个唯一的id
select_typeSELECT关键字对应的那个查询的类型
table表名
partitions匹配的分区信息
type针对单表的访问方法
possible_keys可能用到的索引
key实际上使用的索引
key_len实际使用到的索引长度
ref当使用索引列等值查询时,与索引列进行等值匹配的对象信息
rows预估的需要读取的记录条数
filtered某个表经过搜索条件过滤后剩余记录条数的百分比
Extra一些额外的信息

4.2 EXPLAIN各列作用

1. table

EXPLAIN语句执行后可能会出现多条记录,一条记录对应着一个表!

不论我们的查询语句有多复杂,包含了多少个表 ,到最后也是需要对每个表进行单表访问的,所以MySQL规定EXPLAIN语句输出的每条记录都对应着某个单表的访问方法,该条记录的table列代表着该表的表名(有时不是真实的表名字,可能是简称)。

2. id

  • id如果相同,可以认为是一组,从上往下顺序执行
  • 在所有组中,id值越大,优先级越高,越先执行
  • 关注点:id号每个号码,表示一趟独立的查询,一个sql的查询趟数越少越好

出现了几个select关键字,id值就会有几个。但是要注意:就算sql语句出现了两个select关键字,比如子查询,那么explain的时候也不见得有2个id值,这是因为查询优化器可能对涉及到子查询的语句进行重写,转变为多表查询,多表查询,那么select关键字就只有1个了。

比如说子查询,那么一个sql语句中可能有两个select关键字,就有两个id。(前提是没有被查询优化器转换成多表查询)

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

3. select_type

select关键字对应的那个查询的类型,确定小查询在整个大查询中扮演了一个什么角色。

一条大的查询语句里边可以包含若干个select关键字,每个select关键字代表着一个小的查询语句,而每个select关键字的from子句中都可以包含若干张表(这些表用来做连接查询),每一张表都对应着执行计划输出中的一条记录,对于在同一个select关键字中的表来说,他们的id值是相同的。

MySQL为每一个select关键字代表的小查询都定义了一个称之为select_type的属性,意思是我们只要知道了某个小查询的select_type属性,就知道了这个小查询在整个大查询中扮演了一个什么角色。下面是select_type可能的值。

image-20220507200231991

对于包含子查询的大查询来说,它是由几个小查询组成的,其中最左边的查询的select_type的值就是primary

对于包含UNION或者UNION ALL的查询来说,最左边的查询的select_type的值是primary,其余查询的select_type是union

image-20220507213023844

image-20220507213340990

4. partitions

5. type(重点)

image-20220507213742681

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

SQL性能优化的目标:至少要达到 range级别,要求是ref级别,最好是consts级别。(阿里巴巴开发手册要求)

  • System

    当表中只有一条记录,并且该表使用的存储引擎的统计数据是精确的,比如MyISAM、Memory,那么对该表的访问方法是system

  • const

    当我们根据主键或者是唯一的二级索引列与常数进行等值匹配时,对单表的访问方法就是const

  • eq_ref

    image-20220507214421081

    image-20220507214433475

  • ref

    当通过普通的二级索引列与常数进行等值匹配时来查询某个表,那么对该表的访问方法就可能是ref

  • range

    image-20220507215125805

  • index

    当我们可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是index,比如说我们建立了联合索引给以下2个字段a b

    当where子句中只出现了b,而select子句中只有a,这时根据where子句,本来是用不上联合索引,因为没有遵循最左前缀原则,但是MySQL优化器给查询语句进行了优化,用上这个联合索引,但是需要根据这整个联合索引,扫描全部的索引记录,这时访问方法就是index。

6. possible_keys和key

在EXPLAIN语句的输出执行计划中,possible_keys列表示在某个查询语句中,对某个表执行单表查询时可能用到的索引有哪些。一般查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用。key列表示实际用到的索引有哪些,如果为NULL,则没有使用索引。

7. key_len(重点)

实际用到的索引长度(即:字节数)

帮我们检查是否充分利用上了索引,值越大越好,主要针对联合索引有参考意义。

key_len的长度计算公式:

varchar(10)变长字段且允许NULL = 10 * ( character set: utf8=3,gbk=2,latin1=1)+1(NULL)+2(变长字段) 

varchar(10)变长字段且不允许NULL = 10 * ( character set:utf8=3,gbk=2,latin1=1)+2(变长字段)

char(10)固定字段且允许NULL = 10 * ( character set:utf8=3,gbk=2,latin1=1)+1(NULL) 

char(10)固定字段且不允许NULL = 10 * ( character set:utf8=3,gbk=2,latin1=1)

8. ref

9. rows(重点)

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

10. filtered

11. Extra

5. EXPLAIN的进一步使用

5.1 EXPLAIN四种输出格式

这里谈谈EXPLAIN的输出格式。EXPLAIN可以输出四种格式:传统格式JSON格式TREE格式以及可视化输出。用户可以根据需要选择适用于自己的格式。

1. 传统格式

2. JSON格式

JSON格式:在EXPLAIN单词和真正的查询语句中间加上FORMAT=JSON。用于查看执行成本cost_info

3. TREE格式

TREE格式是8.0.16版本之后引入的新格式,主要根据查询的各个部分之间的关系各部分的执行顺序来描述如何查询。

4. 可视化输出

可视化输出,可以通过MySQL Workbench可视化查看MySQL的执行计划。

5.2 SHOW WARNINGS的使用

mysql> EXPLAIN SELECT s1.key1, s2.key1 FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.common_field IS NOT NULL;
# 查看优化后的执行语句
mysql> SHOW WARNINGS\G

6. 分析优化器执行计划: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

7. MySQL监控分析视图-sys schema

7.1 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;

第10章 索引优化与查询优化

都有哪些维度可以进行数据库调优?

  • 索引失效、没有充分利用到索引---索引优化
  • 关联查询太多JOIN---SQL优化
  • 服务器调优及各个参数设置(缓冲、线程数等)--调整my.cnf
  • 数据过多--分库分表

虽然SQL查询优化的技术有很多,但是大方向上完全可以分为物理查询优化逻辑查询优化两大块

  • 物理查询优化是指通过索引和表连接方式等技术来进行优化,这里重点掌握索引的使用。
  • 逻辑查询优化就是通过SQL等价变换提升查询效率,直白一点就是换一种查询写法执行效率可能更高。

1. 索引失效案例

MySQL中提高性能的一个最有效的方式是对数据表设计合理的索引。索引提供了访问高效数据的方法,并且加快查询的速度,因此索引对查询的速度有着至关重要的影响。

  • 使用索引可以快速地定位表中的某条记录,从而提高数据库查询的速度,提高数据库的性能。
  • 如果查询时没有使用索引,查询语句就会扫描表中的所有记录。在数据量大的情况下,这样查询的速度会很慢。

大多数情况下都(默认)采用B+树来构建索引。只是空间列类型的索引使用R-树,并且MEMORY表还支持hash索引

其实,用不用索引,最终都是优化器说了算。优化器是基于什么的优化器?基于cost开销(CostBaseOptimizer),它不是基于规则(Rule-BasedOptimizer),也不是基于语义。怎么样开销小就怎么来。另外,SQL语句是否使用索引,跟数据库版本、数据量、数据选择度都有关系。

1.1 全值匹配我最爱

1.2 最佳左前缀法则

在MySQL建立联合索引时会遵守最佳左前缀匹配原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。

结论:MySQL可以为多个字段创建索引,一个索引可以包括16个字段。对于多列索引,过滤条件要使用索引必须按照索引建立时的顺序,依次满足,一旦跳过某个字段,索引后面的字段都无法被使用。如果查询条件中没有使用这些字段中第1个字段时,多列(或联合)索引不会被使用。

比如现在有索引:

image-20220508230451144

查询语句如下:

image-20220508230501235

根据最左前缀原则,查询条件中有联合索引的第一个字段age,那么这个联合索引肯定能被用上,但是不是用了这个联合索引的全部,是用了联合索引的第一个字段,联合索引第一个字段之后的索引都失效了,因为查询条件中age之后不是第二个字段,这个要根据B+树来思考,B+树联合索引,首先会根据第一个字段排序,然后第一个字段相同的话会根据第二个字段排序,依次类推。查询条件中被有写第二个字段,那么之后的索引当然会失效,因为在索引树中若不先找第二个字段,就无法找第三个字段。

所以查询条件中一定要写联合索引的第一个字段,索引才生效。

上图这种情况也只有联合索引的第一个字段生效。

image-20220508230751274

1.3 主键插入顺序

对于一个使用InnoDB存储引擎的表来说,在我们没有显示的创建索引时,表中的数据实际上都是存储在聚簇索引的叶子节点的(聚簇索引会被InnoDB隐式创建)。而记录又存储在数据页中的,数据页和记录又是按照记录主键值从小到大的顺序进行排序,所以如果我们插入的记录的主键值是依次增大的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的主键值忽小忽大的话,则可能会造成页面分裂记录移位

1.4 计算、函数、类型转换(自动或手动)导致索引失效

image-20220508233703417

下面这个语句可以使用上索引

要是SQL语句用上了计算、函数,那么就要考虑换个写法。

1.5 类型转换导致索引失效

image-20220508234123285

下面的语句可以使用上索引,因为上面的语句会做类型转换。

1.6 范围条件右边的列索引失效

应用开发中范围查询,例如:金额查询,日期查询往往都是范围查询。应将查询条件放置where语句最后。(创建的联合索引中,务必把范围涉及到的字段写在最后)

为什么会失效,就是因为范围查询,找不到具体的值,是一个范围,所以后面的列会失效,这要从b+树的角度来理解,找到了一个数据页中的一条数据之后,不管是目录项还是用户数据项,接着才能往下找,因为联合索引就是按照字段的值来进行排序的,如果是范围的话,就没有办法往下找了。

image-20220508234708282

这条sql语句,优化器会自动将我们的where条件中的顺序做转换。

在联合索引中,将会使用到范围的列写在最右。相当于给我们暗示,最后去考虑范围索引。

1.7 不等于(!= 或者<>)索引失效

1.8 is null可以使用索引,is not null无法使用索引

结论:最好在设计数据表的时候就将字段设置为 NOT NULL 约束,比如你可以将INT类型的字段,默认值设置为0。将字符类型的默认值设置为空字符串('')

拓展:同理,在查询中使用not like也无法使用索引,导致全表扫描

1.9 like以通配符%开头索引失效

在使用LIKE关键字进行查询的语句中,如果匹配字符串的第一个字符为“%”,索引就不会起作用,只有“%”不在第一个位置,索引才会起作用。

拓展:Alibaba《Java开发手册》

【强制】页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决。

1.10 OR 前后存在非索引的列,索引失效

在WHERE子句中,如果在OR前的条件列进行了索引,而在OR后的条件列没有进行索引,那么索引会失效。也就是说,OR前后的两个条件中的列都是索引时,查询中才使用索引。

1.11 数据库和表的字符集统一使用utf8mb4

统一使用utf8mb4( 5.5.3版本以上支持)兼容性更好,统一字符集可以避免由于字符集转换产生的乱码。不同的字符集进行比较前需要进行转换会造成索引失效。

一般性建议

  • 对于单列索引:

    尽量选择针对当前query过滤性更好的字段

  • 对于联合索引:

    • 当前query中,过滤性最好的字段,也就是散列值更小的字段,过滤效果好的字段,应该尽量放在联合索引的前面
    • 尽量选择能够包含当前query中where子句中的字段作为联合索引
    • 若有范围查询,尽量将范围查询的字段写在联合索引的最后面

2. 关联查询优化

2.1 内连接

结论1:对于内连接来说,查询优化器可以决定谁来作为驱动表,谁作为被驱动表出现

结论2:对于内连接来讲,如果表的连接条件中只能有一个字段有索引,则有索引的字段所在的表会被作为被驱动表

结论3:对于内连接来说,两个表的层级是一样的,不存在主表和从表,那么由优化器来决定哪个表是驱动表,哪个表是被驱动表,在两个表的连接条件都存在索引或者都不存在索引的情况下,会选择小表作为驱动表。小表驱动大表

因为被驱动表是被比较次数更多的,所以更需要对被驱动表进行优化,可以这么理解。被驱动表有索引,成本更低!!

2.2 外连接

  • 以左外连接为例,左边的表即主表是驱动表,右边的表即从表,是被驱动表。

    被驱动表上建立索引,那么效率会更好,是因为join的底层执行方式,驱动表中拿一条数据,每拿一条都要和被驱动表中的所有数据进行比较,所以被驱动表的比较字段上需要建立索引

    image-20220509001427900

    为什么不是给左表中的这个字段建立索引效果更好呢?是因为这是左外链接,本来左表中的数据就是全要的,左外连接的意思就是说结果集中除了满足条件的数据之外,主表中的其他数据即不满足on条件中的数据,也要返回。

    给主表和从表中的这个用于比较的字段都添加上索引效果更好。

    JOIN底层会把被驱动表的数据加载到缓存中,因为每拿一次主表的数据,都要和从表的所有数据进行比较,所以把从表的所有数据都加载到缓存中。

2.3 JOIN语句原理

2.3.1 外连接和内连接

join方式连接多个表,本质上是各个表之间数据的循环匹配。

驱动表和被驱动表:

  • 驱动表就是主表
  • 被驱动表就是从表、非驱动表

对于内连接来说

select * from a join b on ...

a 一定是驱动表吗,b一定是被驱动表吗。

不一定,优化器会根据查询语句做优化,决定先查哪张表,先查询的那张表就是驱动表,反之就是被驱动表。

可以理解为优化器要对被驱动表做优化,如果字段有索引,那么有索引的字段所在的那个表是被驱动表。如果on条件里面,字段都没有索引,或者都有索引,那么是小表驱动大表。

对于外连接来说

select * from a left join b on ...

也不一定a就是驱动表,b就是被驱动表,因为查询优化器可能会给这个sql语句做优化,优化之后可能会变成内连接,那么谁是驱动表谁是被驱动表,就由优化器决定了。

2.3.2 Simple Nested-Loop Join(简单嵌套循环索引)

算法相当简单,从表A中取出一条数据,遍历表B,将匹配到的数据放到result,以此类推,驱动表A中的每一条记录与被驱动表B的记录进行判断:

image-20220509181504432

当然MySQL肯定不会进行这么粗暴的表连接,所以就出现了后面的两种对Nested-Loop Join优化算法。

2.3.3 Index Nested-Loop Join(索引嵌套循环连接)

Index Nested-Loop Join其优化的思路主要是为了减少内层表数据的匹配次数,所以要求被驱动表上必须有索引才行。

通过外层表匹配条件直接与内层表索引进行匹配,避免和内层表的每条记录去进行比较,这样极大地减少了对内层表的匹配次数。

image-20220401182649509

驱动表中的每条记录通过被驱动表的索引进行访问,因为索引查询的成本是比较固定的,故MySQL优化器都倾向于使用记录数少的表作为驱动表(外表)。

2.3.4 Block Nested-Loop Join(块嵌套循环连接)

如果存在索引,那么会使用index的方式进行join,如果join的列没有索引,被驱动表要扫描的次数太多了。每次访问被驱动表,其表中的记录都会被加载到内存中,然后再从驱动表中取一条与其匹配,匹配结束后清除内存,然后再从驱动表中加载一条记录,然后把被驱动表的记录再加载到内存匹配,这样周而复始,大大增加了IO的次数。为了减少被驱动表的IO次数,就出现了Block Nested-Loop Join的方式。

不再是逐条获取驱动表的数据,而是一块一块的获取,引入了join buffer缓冲区,将驱动表join相关的部分数据列(大小受join buffer的限制)缓存到join buffer中,然后全表扫描被驱动表,被驱动表的每一条记录一次性和join buffer中的所有驱动表记录进行匹配(内存中操作),将简单嵌套循环中的多次比较合并成一次,降低了被驱动表的访问频率。

image-20220401183344880

join buffer缓存的不只是关联表的列,select后面的列也会被缓存起来。

image-20220509183558237

小结:

  • 整体效率比较:INLJ > BNLJ > SNLJ
  • 永远用小表驱动大表(其本质是减少外层循环的数据数量)
  • 为被驱动表匹配的条件增加索引(减少内层表的循环匹配次数,增加索引之后,外层表的一条数据就不需要和内层表全表数据进行匹配,而是根据索引匹配)
  • 增大join buffer size的大小(一次缓存驱动表的数据越多,那么被驱动表的扫描次数就越少)
  • 减少驱动表不必要的字段查询(字段越少,join buffer所缓存的数据就越多)
2.3.5 Hash Join

从MySQL的8.0.20版本开始将废弃BNLJ,因为从MySQL8.0.18版本开始就加入了hash join默认都会使用hash join

  • Nested Loop:对于被连接的数据子集较小的情况下,Nested Loop是个较好的选择。
  • Hash Join是做大数据集连接时的常用方式,优化器使用两个表中较小(相对较小)的表利用Join Key在内存中建立散列值,然后扫描较大的表并探测散列值,找出与Hash表匹配的行。
    • 这种方式适用于较小的表完全可以放入内存中的情况,这样总成本就是访问两个表的成本之和。
    • 在表很大的情况下并不能完全放入内存,这时优化器会将它分割成若干不同的分区,不能放入内存的部分就把该分区写入磁盘的临时段,此时要求有较大的临时段从而尽量提高I/O的性能。
    • 它能够很好的工作于没有索引的大表和并行查询的环境中,并提供最好的性能。Hash Join只能应用于等值连接,这是由Hash的特点决定的。

3. 子查询优化

子查询是 MySQL 的一项重要的功能,可以帮助我们通过一个 SQL **语句实现比较复杂的查询。但是,子查询的执行效率不高。**原因:

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

② 子查询的结果集存储的临时表,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。

③ 对于返回结果集比较大的子查询,其对查询性能的影响也就越大。

**在MySQL中,可以使用连接(JOIN)查询来替代子查询。**连接查询不需要建立临时表,其速度比子查询要快,如果查询中使用索引的话,性能就会更好。

结论:尽量不要使用NOT IN 或者 NOT EXISTS,用LEFT JOIN xxx ON xx WHERE xx IS NULL替代

4. 排序优化

在MySQL中,支持两种排序方式,分别是FileSort排序和Index排序:

  • Index排序中,索引可以保证数据的有序性,不需要再进行排序,效率更高
  • Filesort排序则一般在内存中进行排序,占用CPU较多。如果待排结果较大,会产生临时文件IO到磁盘进行排序的情况,效率较低。
  1. SQL 中,可以在 WHERE 子句和 ORDER BY 子句中使用索引,目的是在 WHERE 子句中 避免全表扫描,在 ORDER BY 子句避免使用 FileSort 排序。当然,某些情况下全表扫描,或者 FileSort 排序不一定比索引慢。但总的来说,我们还是要避免,以提高查询效率。

  2. 尽量使用 Index 完成 ORDER BY 排序。如果 WHERE 和 ORDER BY 后面是相同的列就使用单索引列;如果不同就使用联合索引。

  3. 无法使用 Index 时,需要对 FileSort 方式进行调优。

5. GROUP BY优化

  • 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会很慢。

6. 优化分页查询

优化思路一

在索引上完成排序分页操作,最后根据主键关联回原表查询所需要的其他列内容。

EXPLAIN SELECT * FROM student t,(SELECT id FROM student ORDER BY id LIMIT 2000000,10) a
WHERE t.id = a.id;

优化思路二

该方案适用于主键自增的表,可以把Limit 查询转换成某个位置的查询。

EXPLAIN SELECT * FROM student WHERE id > 2000000 LIMIT 10;

7. 优先考虑覆盖索引

7.1 什么是覆盖索引?

理解方式一:索引是高效找到行的一个方法,但是一般数据库也能使用索引找到一个列的数据,因此它不必读取整个行。毕竟索引叶子节点存储了它们索引的数据;当能通过读取索引就可以得到想要的数据,那就不需要读取行了。一个索引包含了满足查询结果的数据就叫做覆盖索引。

理解方式二:非聚簇复合索引的一种形式,它包括在查询里的SELECT、JOIN和WHERE子句用到的所有列(即建索引的字段正好是覆盖查询条件中所涉及的字段)。

简单说就是,索引列+主键包含SELECT 到 FROM之间查询的列

7.2 覆盖索引的利弊

好处:

1. 避免Innodb表进行索引的二次查询(回表)

2. 可以把随机IO变成顺序IO加快查询效率

弊端:

索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。这是业务DBA,或者称为业务数据架构师的工作。

8. 索引条件下推

8.1 使用前后的扫描过程

在不使用ICP索引扫描的过程:

storage层:只将满足index key条件的索引记录对应的整行记录取出,返回给server层

server 层:对返回的数据,使用后面的where条件过滤,直至返回最后一行。

使用ICP扫描的过程:

storage层:首先将index key条件满足的索引记录区间确定,然后在索引上使用index filter进行过滤。将满足的index filter条件的索引记录才去回表取出整行记录返回server层。不满足index filter条件的索引记录丢弃,不回表、也不会返回server层。

server 层:对返回的数据,使用table filter条件做最后的过滤。

9. 其它查询优化策略

9.1 EXISTS IN 的区分

索引是个前提,其实选择与否还会要看表的大小。你可以将选择的标准理解为小表驱动大表

9.2 COUNT(*)与COUNT(具体字段)效率

环节1:COUNT(*)COUNT(1)都是对所有结果进行COUNTCOUNT(*)COUNT(1)本质上并没有区别(二者执行时间可能略有差别,不过你还是可以把它俩的执行效率看成是相等的)。如果有WHERE子句,则是对所有符合筛选条件的数据行进行统计;如果没有WHERE子句,则是对数据表的数据行数进行统计。

**环节2:**如果是MyISAM存储引擎,统计数据表的行数只需要O(1)的复杂度,这是因为每张MyISAM的数据表都有一个meta信息存储了row_count值,而一致性则是由表级锁来保证的。

如果是InnoDB存储引擎,因为InnoDB支持事务,采用行级锁和MVCC机制,所以无法像MyISAM一样,维护一个row_count变量,因此需要采用扫描全表,是O(n)的复杂度,进行循环+计数的方式来完成统计。

**环节3:**在InnoDB引擎中,如果采用COUNT(具体字段)来统计数据行数,要尽量采用二级索引。因为主键采用的索引是聚簇索引,聚簇索引包含的信息多,明显会大于二级索引(非聚簇索引)。对于COUNT(*)COUNT(1)来说,它们不需要查找具体的行,只是统计行数,系统会自动采用占用空间更小的二级索引来进行统计。

如果有多个二级索引,会使用key_len小的二级索引进行扫描。当没有二级索引的时候,才会采用主键索引来进行统计。

9.3 关于SELECT(*)

在表查询中,建议明确字段,不要使用 * 作为查询的字段列表,推荐使用SELECT <字段列表> 查询。原因:

① MySQL 在解析的过程中,会通过查询数据字典将**"*"按序转换成所有列名**,这会大大的耗费资源和时间。

② 无法使用覆盖索引

9.4 LIMIT 1 对优化的影响

针对的是会扫描全表的 SQL 语句,如果你可以确定结果集只有一条,那么加上LIMIT 1的时候,当找到一条结果的时候就不会继续扫描了,这样会加快查询速度。

如果数据表已经对字段建立了唯一索引,那么可以通过索引进行查询,不会全表扫描的话,就不需要加上LIMIT 1了。

9.5 多使用COMMIT

只要有可能,在程序中尽量多使用 COMMIT,这样程序的性能得到提高,需求也会因为 COMMIT 所释放的资源而减少。

COMMIT 所释放的资源:

  • 回滚段上用于恢复数据的信息

  • 被程序语句获得的锁

  • redo / undo log buffer 中的空间

  • 管理上述 3 种资源中的内部花费

第11章 数据库的设计规范

1. 范 式

1.1 范式简介

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

1.2 范式都包括哪些

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

高阶的范式一定符合低阶范式的要求,满足最低要求的范式是第一范式,在第一范式的基础上进一步满足更多规范要求的称为第二范式,其余范式依次类推。

image-20220403092826169

1.3 键和相关属性的概念

范式的定义会用到主键和候选键,数据库中的键由一个或者多个属性组成。数据表中常用的几种键和属性的定义:

  • 超键:能唯一标识元组(表中的一条条数据)的属性集叫做超键(属性集里可能有多余的属性,比如一个能唯一标识记录的属性加上多余的属性,这个属性集也能够唯一标识数据,所以这个属性集是超键)
  • 候选键:如果超键不包含多余的属性(多余的属性是指不能唯一标识数据的属性),那么这个超键就是候选键(从超键中去除掉多余的属性,就是候选键)
  • 主键:用户可以从候选键中选择一个作为主键(主键在一个表中只能有一个,但是候选键可以有多个)
  • 外键:如果数据表R1中的某属性集不是R1的主键,而是另一个数据表R2的主键,那么这个属性集就是数据表R1的外键
  • 主属性:包含在任一候选键中的属性称为主属性
  • 非主属性:与主属性相对,指的是不包含在任何一个候选键中的属性。

这里有两个表:

球员表(player):球员编号 | 姓名 | 身份证号 | 年龄 | 球队编号

球队表(team):球队编号 | 主教练 | 球队所在地

  • 超键:对于球员表来说,超键就是包括球员编号或者身份证号的任意组合,比如(球员编号)(球员编号,姓名)(身份证号,年龄)等。
  • 候选键:就是最小的超键,对于球员表来说,候选键就是(球员编号)或者(身份证号)。
  • 主键:我们自己选定,也就是从候选键中选择一个,比如(球员编号)。
  • 外键:球员表中的球队编号。
  • 主属性非主属性:在球员表中,主属性是(球员编号)(身份证号),其他的属性(姓名)(年龄)(球队编号)都是非主属性。

1.4 第一范式(1st NF)

第一范式主要是确保数据表中每个字段的值必须具有原子性,也就是说数据表中每个字段的值为不可再次拆分的最小数据单位。

image-20220509220055881

1.5 第二范式(2nd NF)

第二范式要求,在满足第一范式的基础上,还要**满足数据表里的每一条数据记录,都是可唯一标识的。而且所有非主键字段,都必须完全依赖主键,不能只依赖主键的一部分。**如果知道主键的所有属性的值,就可以检索到任何元组(行)的任何属性的任何值。

要求中的主键,其实可以拓展替换为候选键。

image-20220509220637171

第二范式中的主键可以说成候选键,主键里可以有多个属性。

上图中,姓名和年龄没有完全依赖于主属性,只依赖于主属性的一部分---球员编号

比赛时间、比赛场地没有完全依赖于主属性,只依赖于一部分---比赛编号

得分 就是完全依赖于球员编号和比赛编号,那么就是完全依赖。

对于非主属性来说,如果不完全依赖于主属性,会产生什么问题呢?

  • 数据冗余:如果一个球员可以参加m场比赛,那么球员的姓名和年龄就重复了m-1次,一个比赛也可以有多个球员参加,那么比赛的时间和场地就会重复多次。
  • 插入异常:如果我们想要添加一场新的比赛,但是这时候还没有确定参加的球员都有谁,那么就没法插入。
  • 删除异常:如果要删除某个球员编号,如果没有单独保存比赛表的话,就会同时把比赛信息也删除掉。
  • 更新异常:如果我们调整了某个比赛的信息,那么数据表中所有这个比赛信息都要进行更新。

为了避免出现上述情况,我们可以把球员比赛表设计为下面的三张表:

  • 球员表----属性:球员编号、姓名、年龄等
  • 比赛表----属性:比赛编号、比赛时间、比赛场地等
  • 球员比赛关系表----属性:球员编号、比赛编号、得分等

这样的话,每张表都符合第二范式

第一范式告诉我们每一个属性是原子性的不可以再分,第二范式告诉我们每一张表就是一个独立的对象,一张表只表达一个意思。

第二范式要求实体的属性完全依赖于主关键字。如果存在不完全依赖,那么这个属性和主关键字的这一部分应该抽取出来形成一个新的实体。

1.6 第三范式(3rd NF)

第三范式是在第二范式的基础上,确保数据表中的每一个非主键字段都和主键字段直接相关,也就是说,要求数据表中的所有非主键字段不能依赖于其他非主键字段。(即,不能存在非主属性A依赖于非主属性B,非主属性B依赖于主键C的情况,即存在"A-->B-->C"的决定关系)通俗地讲,该规则的意思是所有非主键属性之间不能有依赖关系,必须相互独立。也就是说不能出现非主属性的传递依赖.

这里的主键可以理解为一个属性集,可以拓展为候选键。

非主属性就是非候选键中的属性。

image-20220509222742980

1.7 小结

关于数据表的设计,有三个范式要遵循。

(1)第一范式(1NF),确保每列保持原子性

数据库的每一列都是不可分割的原子数据项,不可再分的最小数据单元,而不能是集合、数组、记录等非原子数据项。

(2)第二范式(2NF),确保每列都和主键完全依赖

尤其在复合主键的情况下,非主键部分不应该依赖于部分主键。

(3)第三范式(3NF),确保每列都和主键直接相关,而不是间接相关

**范式的优点:**数据的标准化有助于消除数据库中的数据冗余,第三范式(3NF)通常被认为在性能、拓展性和数据完整性方面达到了最好的平衡。

**范式的缺点:**范式的使用,可能降低查询的效率因为范式等级越高,设计出来的数据表就越多、越精细,数据的冗余度就越低,进行数据查询的时候就可能需要关联多张表,这不但代价昂贵,也可能使一些索引策略无效

范式只是提出了设计的标准,实际上设计数据表时,未必一定要符合这些标准。开发中,我们会出现为了性能和读取效率违反范式化的原则,通过增加少量的冗余或重复的数据来提高数据库的读性能,减少关联查询,join表的次数,实现空间换取时间的目的。因此在实际的设计过程中要理论结合实际,灵活运用。

2. 反范式化

2.1 概述

规范化 vs 性能

  1. 为满足某种商业目标 , 数据库性能比规范化数据库更重要

  2. 在数据规范化的同时 , 要综合考虑数据库的性能

  3. 通过在给定的表中添加额外的字段,以大量减少需要从中搜索信息所需的时间

  4. 通过在给定的表中插入计算列,以方便查询

image-20220509223609674

2.2 反范式的新问题

  • 存储空间变大
  • 一个表中字段做了修改,另一个表中冗余的字段也需要做同步修改,否则数据不一致
  • 若采用存储过程来支持数据的更新、删除等额外操作,如果更新频繁,会非常消耗系统资源
  • 数据量小的情况下,反范式不能体现性能的优势,可能还会让数据库的设计更加复杂

2.3 反范式的适用场景

当冗余信息有价值或者能大幅度提高查询效率的时候,我们才会采取反范式的优化。

1. 增加冗余字段的建议

1)这个冗余字段不需要经常进行修改

2)这个冗余字段查询的时候不可或缺

2. 历史快照、历史数据的需要

在现实生活中,我们经常需要一些冗余信息,比如订单中的收货人信息,包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照,需要进行保存,但用户可以随时修改自己的信息,这时保存这些冗余信息是非常有必要的。

反范式优化也常用在数据仓库的设计中,因为数据仓库通常存储历史数据,对增删改的实时性要求不强,对历史数据的分析需求强。这时适当允许数据的冗余度,更方便进行数据分析。

3. BCNF(巴斯范式)

主属性(仓库名)对于候选键(管理员,物品名)是部分依赖的关系,这样就有可能导致异常情况。因此引入BCNF,它在 3NF 的基础上消除了主属性对候选键的部分依赖或者传递依赖关系

如果在关系R中,U为主键,A属性是主键的一个属性,若存在A->Y,Y为主属性,则该关系不属于BCNF。

4. ER模型

ER模型也叫做实体关系模型,是用来描述现实生活中客观存在的事物、事物的属性,以及事物之间关系的一种数据模型。在开发基于数据库的信息系统的设计阶段,通常使用ER模型来描述信息需要和信息特性,帮助我们理清业务逻辑,从而设计出优秀的数据库。

我们不是为了创建ER模型而创建ER模型的

ER模型是为我们设计并创建一个优秀数据库服务的。

4.1 ER 模型包括那些要素?

ER 模型中有三个要素,分别是实体、属性和关系

实体,可以看做是数据对象,往往对应于现实生活中的真实存在的个体。在 ER 模型中,用矩形来表示。实体分为两类,分别是强实体弱实体。强实体是指不依赖于其他实体的实体;弱实体是指对另一个实体有很强的依赖关系的实体。可以理解为实体就是一个表。

属性,则是指实体的特性。比如超市的地址、联系电话、员工数等。在 ER 模型中用椭圆形来表示。属性就是一个表中的属性、列、字段。

关系,则是指实体之间的联系。比如超市把商品卖给顾客,就是一种超市与顾客之间的联系。在 ER 模型中用菱形来表示。

注意:实体和属性不容易区分。这里提供一个原则:我们要从系统整体的角度出发去看,可以独立存在的是实体,不可再分的是属性。也就是说,属性不能包含其他属性。

4.2 关系的类型

在 ER 模型的 3 个要素中,关系又可以分为 3 种类型,分别是 一对一、一对多、多对多。

一对一:指实体之间的关系是一一对应的

一对多:指一边的实体通过关系,可以对应多个另外一边的实体。相反,另外一边的实体通过这个关系,则只能对应唯一的一边的实体

多对多:指关系两边的实体都可以通过关系对应多个对方的实体

5. 数据表的设计原则

数据表设计的一般原则:"三少一多"

1. 数据表的个数越少越好

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

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

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

注意:这个原则并不是绝对的,有时候我们需要牺牲数据的冗余度来换取数据处理的效率。

6. 数据库对象编写建议

6.1 关于库

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

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

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

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

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

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

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

6.2 关于表、列

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

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

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

  4. 【强制】创建表时必须显式指定字符集为utf8或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 语言,方便数据库和数据表结构的导出和导入。

6.3 关于索引

  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)为冗余索引,需要删除。

6.4 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. 【建议】线上环境,多表 JOIN 不要超过5个表。

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

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

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

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

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

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

第12章 数据库其它调优策略

1. 数据库调优的措施

1.1 调优的目标

  • 尽可能节省系统资源,以便系统可以提供更大负荷的服务。(吞吐量更大)
  • 合理的结构设计和参数调整,以提高用户操 响应的速度。(响应速度更快)
  • 减少系统的瓶颈,提高MySQL数据库整体的性能。

1.2 如何定位调优问题

  • 用户的反馈(主要)
  • 日志分析(主要)
  • 服务器资源使用监控
  • 数据库内部状况监控
  • 其它

1.3 调优的维度和步骤

第1步:选择适合的 DBMS

第2步:优化表设计

第3步:优化逻辑查询

第4步:优化物理查询

物理查询优化是在确定了逻辑查询优化之后,采用物理优化技术(比如索引和表连接等),通过计算代价模型对各种可能的访问路径进行估算,从而找到执行方式中代价最小的作为执行计划。

在这个部分中,我们需要掌握的重点是对索引的创建和使用。

第5步:使用 Redis Memcached 作为缓存

第6步:库级优化

1、读写分离

image-20220403102536170

2、数据分片

image-20220403102618627

2. 优化MySQL服务器

2.1 优化服务器硬件

**服务器的硬件性能直接决定着MySQL数据库的性能。**硬件的性能瓶颈直接决定MySQL数据库的运行速度和效率。针对性能瓶颈提高硬件配置,可以提高MySQL数据库查询、更新的速度。

(1)配置较大的内存

(2)配置高速磁盘系统

(3)合理分布磁盘I/O

(4)配置多处理器

2.2 优化MySQL的参数

  • innodb_buffer_pool_size:这个参数是Mysql数据库最重要的参数之一,表示InnoDB类型的表和索引的最大缓存。它不仅仅缓存索引数据,还会缓存表的数据。这个值越大,查询的速度就会越快。但是这个值太大会影响操作系统的性能。
  • key_buffer_size:表示索引缓冲区的大小。索引缓冲区是所有的线程共享。增加索引缓冲区可以得到更好处理的索引(对所有读和多重写)。当然,这个值不是越大越好,它的大小取决于内存的大小。如果这个值太大,就会导致操作系统频繁换页,也会降低系统性能。对于内存在4GB左右的服务器该参数可设置为256M384M
  • table_cache:表示同时打开的表的个数。这个值越大,能够同时打开的表的个数越多。物理内存越大,设置就越大。默认为2402,调到512-1024最佳。这个值不是越大越好,因为同时打开的表太多会影响操作系统的性能。
  • query_cache_size:表示查询缓冲区的大小。可以通过在MySQL控制台观察,如果Qcache_lowmem_prunes的值非常大,则表明经常出现缓冲不够的情况,就要增加Query_cache_size的值;如果Qcache_hits的值非常大,则表明查询缓冲使用非常频繁,如果该值较小反而会影响效率,那么可以考虑不用查询缓存;Qcache_free_blocks,如果该值非常大,则表明缓冲区中碎片很多。MySQL8.0之后失效。该参数需要和query_cache_type配合使用。
  • query_cache_type的值是0时,所有的查询都不使用查询缓存区。但是query_cache_type=0并不会导致MySQL释放query_cache_size所配置的缓存区内存。
    • 当query_cache_type=1时,所有的查询都将使用查询缓存区,除非在查询语句中指定SQL_NO_CACHE,如SELECT SQL_NO_CACHE * FROM tbl_name。
    • 当query_cache_type=2时,只有在查询语句中使用SQL_CACHE关键字,查询才会使用查询缓存区。使用查询缓存区可以提高查询的速度,这种方式只适用于修改操作少且经常执行相同的查询操作的情况。
  • sort_buffer_size:表示每个需要进行排序的线程分配的缓冲区的大小。增加这个参数的值可以提高ORDER BYGROUP BY操作的速度。默认数值是2 097 144字节(约2MB)。对于内存在4GB左右的服务器推荐设置为6-8M,如果有100个连接,那么实际分配的总共排序缓冲区大小为100 × 6 = 600MB。
  • join_buffer_size = 8M:表示联合查询操作所能使用的缓冲区大小,和sort_buffer_size一样,该参数对应的分配内存也是每个连接独享。
  • read_buffer_size:表示每个线程连续扫描时为扫描的每个表分配的缓冲区的大小(字节)。当线程从表中连续读取记录时需要用到这个缓冲区。SET SESSION read_buffer_size=n可以临时设置该参数的值。默认为64K,可以设置为4M。
  • innodb_flush_log_at_trx_commit:表示何时将缓冲区的数据写入日志文件,并且将日志文件写入磁盘中。该参数对于innoDB引擎非常重要。该参数有3个值,分别为0、1和2。该参数的默认值为1。
    • 值为0时,表示每秒1次的频率将数据写入日志文件并将日志文件写入磁盘。每个事务的commit并不会触发前面的任何操作。该模式速度最快,但不太安全,mysqld进程的崩溃会导致上一秒钟所有事务数据的丢失。
    • 值为1时,表示每次提交事务时将数据写入日志文件并将日志文件写入磁盘进行同步。该模式是最安全的,但也是最慢的一种方式。因为每次事务提交或事务外的指令都需要把日志写入(flush)硬盘。
    • 值为2时,表示每次提交事务时将数据写入日志文件,每隔1秒将日志文件写入磁盘。该模式速度较快,也比0安全,只有在操作系统崩溃或者系统断电的情况下,上一秒钟所有事务数据才可能丢失。
  • innodb_log_buffer_size:这是 InnoDB 存储引擎的事务日志所使用的缓冲区。为了提高性能,也是先将信息写入 Innodb Log Buffer 中,当满足 innodb_flush_log_trx_commit 参数所设置的相应条件(或者日志缓冲区写满)之后,才会将日志写到文件(或者同步到磁盘)中。
  • max_connections:表示 允许连接到MySQL数据库的最大数量 ,默认值是 151 。如果状态变量connection_errors_max_connections 不为零,并且一直增长,则说明不断有连接请求因数据库连接数已达到允许最大值而失败,这是可以考虑增大max_connections 的值。在Linux 平台下,性能好的服务器,支持 500-1000 个连接不是难事,需要根据服务器性能进行评估设定。这个连接数 不是越大 越好 ,因为这些连接会浪费内存的资源。过多的连接可能会导致MySQL服务器僵死。
  • back_log:用于控制MySQL监听TCP端口时设置的积压请求栈大小。如果MySql的连接数达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源,将会报错。5.6.6 版本之前默认值为 50 , 之后的版本默认为 50 + (max_connections / 5), 对于Linux系统推荐设置为小于512的整数,但最大不超过900。如果需要数据库在较短的时间内处理大量连接请求, 可以考虑适当增大back_log 的值。
  • thread_cache_size线程池缓存线程数量的大小,当客户端断开连接后将当前线程缓存起来,当在接到新的连接请求时快速响应无需创建新的线程 。这尤其对那些使用短连接的应用程序来说可以极大的提高创建连接的效率。那么为了提高性能可以增大该参数的值。默认为60,可以设置为120。
  • wait_timeout:指定一个请求的最大连接时间,对于4GB左右内存的服务器可以设置为5-10。
  • interactive_timeout:表示服务器在关闭连接前等待行动的秒数。

3. 优化数据库结构

3.1 拆分表:冷热数据分离

拆分表的思路是,把一个包含很多字段的表拆分成2个或者多个相对较小的表,这样做的原因是,这些表中某些字段的操作频率很高(热数据),经常要进行查询或者更新操作,而另外一些字段的使用频率很低(冷数据),冷热数据分离,可以减小表的宽度。如果放在一个表里面,又根据聚簇索引即主键索引,叶子节点会存放用户的完整数据记录,那么每次查询都要读取大记录,会消耗较多的资源。

MySQL限制每个表最多存储4096列,并且每一行数据的大小不能超过65535字节。表越宽,把表装载进内存缓冲池时所占用的内存越大,也会消耗更多的IO。

冷热数据分离的目的是:

  • 减少磁盘IO,保证热数据的内存缓存命中率
  • 更有效地利用缓存,避免读入无用的冷数据。

image-20220510011844211

3.2 增加中间表

对于需要经常进行联合查询的表,可以建立中间表以提高查询效率,通过建立中间表,把需要经常联合查询的数据插入到中间表中,然后将原来的联合查询改为对中间表的查询,以此来提高查询效率。

3.3 增加冗余字段

反范式化的设计。

设计数据库表时应尽量遵循范式理论的规约,尽可能减少冗余字段,让数据库设计看起来精致、优雅。但是,合理地加入冗余字段可以提高查询速度。

表的规范化程度越高,表与表之间的关系也越多,需要连接查询的情况就越多,这是由于每个表都表示一个意思,没有冗余字段,在设计上看起来更合理,在有些查询上,就需要用到多个表连接查询。为了提升效率,我们可以考虑增加冗余字段来减少连接。

3.4 优化数据类型

改进表的设计时,可以考虑字段的数据类型,这个问题在大家刚从事开发时基本不算问题,但是,随着参与的项目越来越大,数据量也越来越多的时候,就不能只从系统稳定性的角度来思考问题了,还要考虑到系统整体的稳定性和效率,此时,优先选择符合存储需要的最小的数据类型。

这个最小指的是存储数据占用的空间最小,范围最小,但是要满足业务需求。

列的字段越大,建立索引时所需要的空间也就越大(聚簇索引会默认创建,数据和索引是一起的),这样一个数据页中所能存储的索引节点的数量就越少,在遍历时,所需要的IO次数就越多,索引的性能就越差。

情况1:对整数类型数据进行优化。

遇到整数类型的字段可以用INT 型。这样做的理由是,**INT 型数据有足够大的取值范围,不用担心数据超出取值范围的问题。**刚开始做项目的时候,首先要保证系统的稳定性,这样设计字段类型是可以的。但在数据量很大的时候,数据类型的定义,在很大程度上会影响到系统整体的执行效率。

对于非负型的数据(如自增ID、整型IP)来说,要优先使用无符号整型UNSIGNED来存储。因为无符号相对于有符号,同样的字节数,存储的数值范围更大。如tinyint有符号为-128-127,无符号为0-255,多出一倍的存储空间。

情况2:既可以使用文本类型也可以使用整数类型的字段,要选择使用整数类型

跟文本类型数据相比,大整数(BIGINT)往往占用更少的存储空间,因此,在存取和比对的时候,可以占用更少的内存空间。所以,在二者皆可用的情况下,尽量使用整数类型,这样可以提高查询的效率。如:将IP地址转换成整型数据。

情况3:避免使用TEXT、BLOB数据类型

MySQL内存临时表不支持TEXT、BLOB这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。

如果一定要使用这类型数据,建议把BLOB或是TEXT列分离到单独的扩展表中。

情况4:避免使用ENUM类型

情况5:使用TIMESTAMP存储时间

情况6:用DECIMAL代替FLOAT和DOUBLE存储精确浮点数

总之,遇到数据量大的项目时,一定要在充分了解业务需求的前提下,合理优化数据类型,这样才能充分发挥资源的效率,使系统达到最优

3.5 优化插入记录的速度

1. MyISAM引擎的表:

① 禁用索引

② 禁用唯一性检查

③ 使用批量插入

④ 使用LOAD DATA INFILE 批量导入

2. InnoDB引擎的表:

① 禁用唯一性检查

② 禁用外键检查

③ 禁止自动提交

3.6 使用非空约束

在设计字段的时候,如果业务允许,建议尽量使用非空约束

这样做的好处是:

  • 进行比较和计算时,省去对NULL值的判断是否为空的开销,提高存储效率。
  • 非空字段也容易创建索引,因为索引NULL列需要额外的空间来保存,所以要占用更多的空间。使用非空约束,就可以节省存储空间。

3.7 分析表、检查表与优化表

1. 分析表

ANALYZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name[,tbl_name]…

默认的,MySQL服务会将 ANALYZE TABLE语句写到binlog中,**以便在主从架构中,从服务能够同步数据。**可以添加参数LOCAL 或者 NO_WRITE_TO_BINLOG取消将语句写到binlog中。

使用ANALYZE TABLE分析表的过程中,数据库系统会自动对表加一个只读锁。在分析期间,只能读取表中的记录,不能更新和插入记录。ANALYZE TABLE语句能够分析InnoDB和MyISAM类型的表,但是不能作用于视图。

ANALYZE TABLE分析后的统计结果会反应到cardinality的值,该值统计了表中某一键所在的列不重复的值的个数。该值越接近表中的总行数,则在表连接查询或者索引查询时,就越优先被优化器选择使用。

2. 检查表

CHECK TABLE tbl_name [, tbl_name] ... [option] ... option = {QUICK | FAST | MEDIUM | EXTENDED | CHANGED}

MySQL中可以使用CHECK TABLE语句来检查表。CHECK TABLE语句能够检查InnoDB和MyISAM类型的表是否存在错误。CHECK TABLE语句在执行过程中也会给表加上只读锁

3. 优化表

OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ...

MySQL中使用OPTIMIZE TABLE语句来优化表。但是,OPTILMIZE TABLE语句只能优化表中的VARCHARBLOBTEXT类型的字段。一个表使用了这些字段的数据类型,若已经删除了表的一大部分数据,或者已经对含有可变长度行的表(含有VARCHAR、BLOB或TEXT列的表)进行了很多更新,则应使用OPTIMIZE TABLE来重新利用未使用的空间,并整理数据文件的碎片

OPTIMIZE TABLE 语句对InnoDB和MyISAM类型的表都有效。该语句在执行过程中也会给表加上只读锁

4.大表优化

4.1 限定查询的范围

禁止不带任何限制数据范围条件的查询语句。

4.2 读写分离

经典的数据库拆分方案,主库负责写,从库负责读。

  • 一主一从模式

    image-20220510184237766

  • 双主双从模式

    image-20220510184313614

4.3 垂直拆分

当数据量达到千万级以上的时候,有时候我们需要把**一个数据库切成多份(一个数据库里就是多张数据表,相当于把多张数据表进行分组,放到不同的数据库服务器上),放到不同的数据库服务器上。**减少对单一服务器的访问压力。

image-20220510184704055

  • 如果数据库中的数据表过多,可以采用垂直分库的方式,将关联的数据表部署在同一个数据库服务器上。

  • 如果数据表中的过多,可以采用垂直分表的方式,将一张数据表拆分成多张数据表,把经常一起使用的列放到同一张表里。

    image-20220510184941761

垂直拆分的优点:

可以使得列数据变小,在查询时减少读取的Block数,减少IO次数,此外,垂直拆分可以简化表的结构,易于维护。

缺点:

主键会出现冗余,因为分表操作。需要管理冗余列,并会引起join操作。此外,垂直拆分会使得事务更加复杂。

4.4 水平拆分

image-20220510185252533

水平拆分和垂直拆分的不同是:水平拆分的每张小表都保持相同的表结构,而垂直拆分并不是将拆分的表保持相同的表结构。

第13章 事务基础知识

1. 数据库事务概述

事务是数据库区别于文件系统的重要特性之一,当我们有了事务就会让数据库始终保持一致性,同时我们还能通过事务的机制恢复到某个时间点,这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。

MySQL存储引擎中,只有InnoDB是支持事务的。

1.1 基本概念

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

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

1.2 事务的ACID特性

  • 原子性(atomicity):

原子性是指事务是一个不可分割的工作单位,**要么全部提交,要么全部失败回滚。**事务一定是作为一个整体出现。

  • 一致性(consistency):

一致性是指事务执行前后,数据从一个合法性状态变换到另外一个合法性状态。这种状态是语义上的而不是语法上的,跟具体的业务有关。

那什么是合法的数据状态呢?满足预定的约束的状态就叫做合法的状态。通俗一点,这状态是由我们自己定义的(比如满足现实世界中的约束),满足这个状态,数据就是一致的,不满足这个状态,数据就是不一致的。如果事务中的某个操作失败了,系统就会自动撤销当前正在执行的事务,返回到事务操作之前的状态。

image-20220510190929111

  • 隔离型(isolation):

事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

image-20220510191837001

这个例子就是没有遵守事务的隔离性。

  • 持久性(durability):

持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响。**持久性是指数据会回写到磁盘中,永久保存下来。**上面图片中,隔离性的例子,就是因为B的值没有回写到磁盘,就有另一个事务也来操作这个数据,就导致结果不对,实际上应该一个事务要对其他并发访问这个数据表的事务隔离,所以当前事务应该是到中止状态或者说提交状态,即数据已经持久化之后,另外的事务才能来操作这个表。

持久性是通过事务日志来保证的。日志包括了重做日志(REDO)回滚日志(UNDO)。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库重启后也能找到没有更新到数据库系统中的重做日志,重新执行,从而使事务具有持久性。

总结:

ACID是事务的四大特性,原子性是基础,隔离性是手段,一致性是约束,持久性是目的。

1.3 事务的状态

  • 活动的(active)

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

  • 部分提交的(partially committed)

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

  • 失败的(failed)

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

  • 中止的(aborted)

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

  • 提交的(committed)

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

image-20220403110448951

图中可见,只有当事务处于提交的或者中止的状态时,一个事务的生命周期才算是结束了。对于已经提交的事务来说,该事务对数据库所做的修改将永久生效。对于处于中止状态的事务,该事务对数据库所做的所有修改都会被回滚到没执行该事务之前的状态。

2. 如何使用事务

使用事务有两种方式

  • 显示事务
  • 隐式事务

事务的完成过程

  • 开启事务
  • 一系列DML操作
  • ...
  • 事务结束状态:提交状态(COMMIT)、中止状态(ROLLBACK)

2.1 显式事务

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

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

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

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

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

WITH CONSISTENT SNAPSHOT:启动一致性读。

image-20220510193537606

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

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

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

# 将事务回滚到某个保存点。 
mysql> ROLLBACK TO [SAVEPOINT]
#要注意将事务回滚到保存点,不是事务最终的状态,事务也不是中止状态,回滚到保存点之后,还要考虑之后的操作,并且最后还要ROLLBACK,还是COMMIT。

其中关于SAVEPOINT相关操作有:

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

2.2 隐式事务

  • 显式地使用START TRANSACTION或者BEGIN语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。

  • 把系统变量autocommit的值设置为OFF

    在autocommit的值是ON的情况下,**每一个DML操作都是一个独立的事务。**这就是隐式事务

2.3 隐式提交数据的情况

  • DDL操作会自动提交数据,不受autocommit影响

    默认情况下DML操作会自动提交数据,默认情况就是autocommit为true

  • 数据定义语言(Data definition language,缩写为:DDL)

    当我们使用create、drop、alter等语句去修改数据库对象时,就会隐式地提交前边语句所属的事务。

  • 隐式使用或修改mysql数据库中的表

    image-20220510194504708

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

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

      image-20220510194539697

    • 当前的autocommit系统变量的值为OFF,我们手动把它调为ON时,也会隐式的提交前边语句所属的事务。

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

2.4 小结

  • 当我们设置autocommit=0时,不论是否采用start transaction或者begin的方式开启事务,都需要用commit进行提交,让事务生效,使用rollback对事务进行回滚。

  • 当我们设置autocommit=1时,每条SQL语句都会自动进行提交。

    不过这时,如果采用start transaction或者begin的方式开启事务,那么这个事务只有在commit的时候才会生效,在rollback时才会回滚。

3. 事务隔离级别

MySQL是一个客户端/服务端架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称为一个会话(session),每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说,可能同时处理多个事务。

事务有隔离性,理论上在某个事务对某个数据进行访问的时候,其他事务应该进行排队。当该事务提交之后,其他事务才可以继续访问这个数据。但是这样对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,那就看二者如何权衡取舍了。

3.1 数据并发问题

可能会出现的问题

1. 脏写(Dirty Write

对于两个事务 Session A、Session B,如果事务Session A修改了另一个未提交事务Session B修改过的数据,那就意味着发生了脏写

未提交事务Session B修改数据,但是未提交,这个时候我们select是能看到数据的,因为这个数据才内存中,我们做了修改,但是没提交,意味着事务没有进入生命周期的最终状态,即中止状态或提交状态,这里就是没有进入提交状态,也就是B修改的数据没有回写到磁盘中,那么这个时候事务A来修改这个数据的话,仍然是能够修改的。这就是脏写。

所以要明确,一个事务对数据做了修改,但是没有提交,不意味着我们select看不到这个数据!这个数据是存在的,只不过没有回写到磁盘,没有持久化,持久化才是目的!!那么没有持久化,而数据存在于内存中时,其他事务来操作,那么就出现了问题。

image-20220403112416944

Session A和Session B各开启了一个事务,事务B先将studentno列为1的记录的name列更新为'李四',然后事务A接着又把这条数据的name列修改为‘张三’,如果之后session B中的事务进行了回滚(那么说明事务进行了生命周期的最终状态---中止状态,刷盘了,即数据从内存回写到磁盘了,持久化了),那么session A中的更新也就不复存在,这种现象就是脏写。

这时session A中的事务就没有效果了,明明把数据更新了,也提交了,但是看不到效果。这里大家对事务的隔离级别比较了解的话,会发现默认隔离级别下,上面session A中的事务对数据的更新语句会处于等待状态。

2. 脏读(Dirty Read

对于两个事务 Session A、Session B,Session A读取了已经被 Session B更新但还没有被提交的字段(这里再次强调,是能读到的,只不过session B没有进行提交,数据没有进行持久化,没有回写到磁盘)。之后若 Session B回滚(那么事务进入到中止状态),Session A读取的内容就是临时且无效的。

事务的提交状态和中止状态才是事务生命周期的最终状态。

image-20220403112435995

image-20220510223605049

事务A读到了这个表的临时数据!!这就是脏读,因为这是临时数据,是脏数据!

脏读是事务A读取到其他事务没有提交的临时数据。

不可重复读,是事务A正常读取数据,只不过这个数据被别的事务修改了,再次读就不同了,所以是不可重复读。

3. 不可重复读(Non-Repeatable Read

对于两个事务Session A、Session B,Session A读取了一个字段,然后 Session B更新了该字段。 之后Session A再次读取同一个字段,值就不同了。那就意味着发生了不可重复读。

image-20220403112458183

4. 幻读(Phantom

对于两个事务Session A、Session B, Session A 从一个表中读取了一个字段, 然后 Session B 在该表中插入了一些新的行。 之后, 如果 Session A再次读取同一个表, 就会多出几行。那就意味着发生了幻读。

image-20220403112514712

注意1:

有的同学会有疑问,那如果Session B中剔除了一些符合studentno > 0的记录而不是插入新记录,那么Session A之后再根据studentno > 0的条件读取的记录变少了,这种现象算不算幻读呢?这种现象不属于幻读幻读强调的是一个事物按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录

注意2:

那对于先前已经读到的记录,之后又读取不到这种情况,算啥呢?这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到之前读取没有获取到的记录。

3.2 SQL中的四种隔离级别

上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题有轻重缓急之分,我们没有必要解决全部的问题,有一些问题是可以接受的,比如不可重复读和幻读,因为事务A读了一些数据,事务B增加了一些数据,或者修改了事务A之前查询的数据,事务A中的操作语句再次查询时,发现数据不一样了,这个在某些场景下会被认为是正常的情况,但是幻读和不可重复读仍然是并发事务的问题!!

不可能解决所有并发问题,如果全部解决,那么隔离级别会被设置得很高,这会导致数据库性能很差!!一般不会这样做。

问题严重性排序

脏写 > 脏读 > 不可重复读 > 幻读 

我们愿意舍弃一部分隔离性来换取一部分性能,这里就体现在设立一些隔离级别,隔离级别越低,那么并发问题越多。

要在并发问题和数据库性能上达到一个平衡。

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

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

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

    不可重复读的意思是说只要事务B修改数据并提交之后,那么这个数据不是脏数据,能被事务A读到。

    脏读是指事务A读到了事务B没有提交的临时数据或者说脏数据。

  • REPEATABLE READ:可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容(如果是读已提交隔离级别,那么事务B对数据进行了修改,然后提交,说明数据不是临时数据,那么就会被事务A读到,那么事务A在当前事务中就读到了不同的数据,这是不可重复读的问题,不是脏读,因为读到的数据是已提交的数据,是持久化的数据,所以不是脏读,但是两次读到的数据仍然不一样,是因为没有解决不可重复读的问题)。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别

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

可重复读这个隔离级别也是可以解决幻读的,但是需要我们手动对select操作加行锁也就是独占锁----这也正是可串行化隔离级别MySQL会为我们隐式做的事情。在可重复读这个隔离级别,我们手动做,手动添加行锁(独占锁)来解决幻读。

image-20220403112740425

脏写这个问题太严重了,不论哪种隔离级别,都解决了脏写。

image-20220510225155107

3.3 如何设置事务的隔离级别

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

或者:

SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '隔离级别' 
#其中,隔离级别格式: 
> READ-UNCOMMITTED 
> READ-COMMITTED 
> REPEATABLE-READ 
> SERIALIZABLE

第14章 MySQL事务日志

事务有4种特性:原子性、一致性、隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢?

  • 事务的隔离性由锁机制实现。
  • 而事务的原子性、一致性和持久性由事务的 redo 日志和undo 日志来保证。
    • REDO LOG 称为重做日志提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性。
    • UNDO LOG 称为回滚日志,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。

REDO和UNDO都可以视为是一种恢复操作,但是:

  • REDO: 是存储引擎层(InnoDB)生成的日志,记录的是“物理级别”上的页修改操作,比如页号xxx,偏移量yyy写入了zzz数据,主要是为了保证数据的可靠性。

  • UNDO:是存储引擎层(InnoDB)生成的日志,记录的是逻辑操作日志,比如对某一行数据进行了INSERT操作,那么undo log就记录一条与之相反的delete操作,主要用于事务的回滚和一致性非锁定读。

    undo log记录的是每个修改操作的逆操作。

1. redo日志

InnoDB存储引擎是以页为单位来管理存储空间的,在真正访问页面之间,需要把磁盘上的页缓存到内存中的缓冲区之后才可以访问。

所有的变更都必须先更新到缓冲池中的数据,然后缓冲池中的脏页(也就是没有回写到磁盘中的数据页)会以一定的频率被刷入磁盘(checkPoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样可以保证整体的性能。

1.1 为什么需要REDO日志

一方面,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint并不是每次变更的时候就触发的,而是master线程隔一段时间去处理的。所以最坏的情况就是事务提交后,刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。

另一方面,事务包含持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。

那么如何保证这个持久性呢?一个简单的做法:在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题

另一个解决的思路:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来(那么就需要记录)。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。比如,某个事务将系统表空间中第10号页面中偏移量为100处的那个字节的值1改成2。我们只需要记录一下:将第0号表空间的10号页面的偏移量为100处的值更新为 2 。这就是REDO日志。

InnoDB引擎的事务采用了WAL(Write-Ahead Logging),这种技术的思想就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log。当发生宕机且数据未刷新到磁盘时,数据库服务重启后可以通过redo log来恢复,保证了ACID中的D,这就是redo log的作用。

1.2 REDO日志的好处、特点

1. 好处

  • redo日志降低了刷盘频率
  • redo日志占用的空间非常小

2. 特点

  • redo日志是顺序写入磁盘的
  • 事务执行过程中,redo log不断记录

1.3 redo的组成

Redo log可以简单分为以下两个部分:

  • 重做日志的缓冲 (redo log buffer) ,保存在内存中,是易失的。

参数设置:innodb_log_buffer_size:

redo log buffer 大小,默认16M,最大值是4096M,最小值为1M。

  • 重做日志文件 (redo log file),保存在硬盘中,是持久的。

1.4 redo的整体流程

image-20220403114709581

第1步:先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝

第2步:生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值

第3步:当事务commit时,将redo log buffer中的内容刷新到 redo log file,对 redo log file采用追加写的方式

第4步:定期将内存中修改的数据刷新到磁盘中

Write-Ahead Log(预先日志持久化):在持久化一个数据页之前,先将内存中相应的日志页持久化。

1.5 redo log的刷盘策略

redo log的写入并不是直接写入磁盘的,InnoDB引擎会在写redo log的时候先写redo log buffer,之后以一定的频率刷入到真正的redo log file中。这里的一定的频率怎么看待呢?就是所说的刷盘策略。

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自己决定什么时候同步到磁盘文件。

1.6 不同刷盘策略演示

1. 流程图

image-20220403115232833

小结:Innodb_flush_log_at_trx_commit=1

只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。

image-20220403115249492

image-20220511172652113

image-20220403115300809

虽然用户可以通过设置Innodb_flush_log_at_trx_commit的值为0或2,来提高事务提交的性能,但需清楚,这种设置方法丧失了事务的ACID特性。

1.7 写入redo log buffer过程

1. 补充概念:Mini-Transaction

MySQL把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction

一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条(一组)redo日志

在进行崩溃恢复的时候,这一组redo日志作为一个不可分割的整体。

image-20220404091224993

2. redo 日志写入log buffer

向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个问题就是应该写在哪个block的哪个偏移量处(一个block的大小是512个字节),所以InnoDB的设计者特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写到log buffer中的哪个位置。

不同的事务可能是并发执行的,所以事务T1事务T2之间的mtr可能是交替执行的。每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到redo log buffer中,也就是说不同事务的mtr的redo log可能是交替写入log buffer的。

一个mtr内部的redo log那一定是挨着的

image-20220404091511602

一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就写入log buffer,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer中。

1.8 redo log file

1. 相关参数设置

  • 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_flush_log_at_trx_commit:控制 redo log 刷新到磁盘的策略,默认为1

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

2. 日志文件组

image-20220404092038421

3. checkpoint

image-20220404092106617

如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。

2. Undo日志

redo log是事务持久性的保证,undo log是事务原子性的保证。在事务中更新数据前置操作其实是要先写入一个 undo log 。

2.1 如何理解Undo日志

事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但有时候事务执行到一半会出现一些情况,比如:

  • 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误操作系统错误,甚至是突然断电导致的错误。
  • 情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前事务的执行。

以上情况出现,我们需要把数据改回原先的样子,这个过程称之为回滚,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。

整个事务的数据要作为一个整体!!!

image-20220511184826408

此外,undo log会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护。

2.2 Undo日志的作用

  • 作用1:回滚数据

    用户对undo日志可能有误解:undo用于将数据库物理地恢复到执行语句或事务之前的样子。但事实并非如此,undo是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子,所有修改逻辑都被取消掉了,但是数据结构和页本身在回滚之后可能大不相同。

    这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务,数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中的某几条记录,同时还有一个别的事务在对同一个页中的另几条记录做修改。因此,不能将一个页回滚到事务开始之前的样子,因为这样会影响其他事务正在进行的工作。所以undo日志是逻辑日志,会将增删改这三种修改操作逆向地执行回去,不会影响其他事务对本数据库的修改,只是当前事务的所有操作回滚回去。

  • 作用2:MVCC(详情看第16章)

    undo的另一个作用是mvcc,即在InnoDB存储引擎中MVCC的实现是通过undo来完成,当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。

2.3 undo的存储结构

1. 回滚段与undo页

InnoDB对undo log的管理采用段的方式,也就是回滚段(rollback segment)。每个回滚段记录了1024undo log segment,而在每个undo log segment段中进行undo页的申请。

2. 回滚段与事务

  1. 每个事务只会使用一个回滚段,一个回滚段在同一时刻可能会服务于多个事务。

  2. 当一个事务开始的时候,会制定一个回滚段,在事务进行的过程中,当数据被修改时,原始的数据会被复制到回滚段。

  3. 在回滚段中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前的盘区不够用,事务会在段中请求扩展下一个盘区,如果所有已分配的盘区都被用完,事务会覆盖最初的盘区或者在回滚段允许的情况下扩展新的盘区来使用。

  4. 回滚段存在于undo表空间中,在数据库中可以存在多个undo表空间,但同一时刻只能使用一个undo表空间。

  5. 当事务提交时,InnoDB存储引擎会做以下两件事情:

    • 将undo log放入列表中,以供之后的purge操作
    • 判断undo log所在的页是否可以重用,若可以分配给下个事务使用

3. 回滚段中的数据分类

  1. 未提交的回滚数据(uncommitted undo information)

    该数据所关联的事务并未提交,用于实现读一致性,所以该数据不能被其他事务的数据覆盖。

  2. 已经提交但未过期的回滚数据(committed undo information)

    该数据关联的事务已经提交,但仍受到undo retention参数的保持时间的影响。

  3. 事务已经提交并过期的数据(expired undo information)

    事务已经提交,而且数据保存时间已经超过undo retention参数指定的时间,属于已经过期的数据。当回滚段满了之后,会优先覆盖“事务已经提交并过期的数据”。

事务提交后并不能马上删除undo log及undo log所在的页,这是因为可能还有其他事务需要通过undo log来得到行记录之前的版本,故事务提交时将undo log放入一个链表中,是否可以最终删除undo log及undo log所在的页由purge线程来决定。

2.4 undo的类型

在InnoDB存储引擎中,undo log分为:

  • insert undo log

    insert undo log是指在insert操作中产生的undo log,因为insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log可以在事务提交后直接删除,不需要进行purge操作。

  • update undo log

    update undo log记录的是对delete 和 update操作产生的逻辑上的undo log,该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除,提交时放入undo log链表,等待purge线程(清除线程)进行最后的删除。

2.5 undo log的生命周期

image-20220512155401343

1. 简要生成过程

只有Buffer Pool的流程:

image-20220404093706650

有了Redo Log和Undo Log之后:

image-20220404093832512

在更新Buffer Pool中的数据之前,我们需要先将该数据事务之前的状态写入undo log中,假设更新到一半出错了,我们就可以通过Undo Log回滚到事务之前。

并不是说事务提交之后,回滚段中的undo log segment中的undo log 及undo log数据页就会删除,要放入链表中,最终等待purge线程来删除。

insert的undo log在事务提交之后可以立即删除。

5到6步之间,有一个page cache,这是现代操作系统改善效率提供的机制,redo log buffer刷盘到redo log持久化文件的这个过程会有一个策略。

Binlog是实现主从复制的,实现主机和从机之间的数据同步。

2. 详细生成过程

image-20220404093950136

当我们执行INSERT时:

begin; 
INSERT INTO user (name) VALUES ("tom");

image-20220404094033441

当我们执行UPDATE时:

image-20220404094105525

UPDATE user SET id=2 WHERE id=1;

image-20220404094142337

3. undo log是如何回滚的

以上面的例子来说,假设执行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的数据删除

4. undo log的删除

  • 针对于insert undo log

因为insert操作的记录,只对事务本身可见,对其他事务不可见。故该undo log可以在事务提交后直接删除,不需要进行purge操作。

  • 针对于update undo log

该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。

2.6 小结

image-20220404094436830

undo log是逻辑日志,对事务回滚时,只是将数据库逻辑地恢复到原来的样子。

redo log是物理日志,记录的是数据页的物理变化,undo log不是redo log的逆过程。

第15章 锁

1. 概述

事务的隔离性由锁来实现!

锁是计算机协调多个进程或线程并发访问某一资源的机制,在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程访问,这就是保证线程安全性,保证数据的一致性(一致性同样可以理解为是满足约束条件状态的数据)和完整性。这个思想在数据库领域中同样重要。

在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制,因此产生了同时锁机制也为实现MySQL的各个隔离级别提供了保证。 锁冲突 也是影响数据库并发访问性能的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。

2. MySQL并发事务访问相同记录

2.1 读-读情况

读-读情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。

2.2 写-写情况

写-写情况,即并发事务相继对相同的记录做出改动。

在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过来实现的。

这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始没有锁结构和记录进行关联。如图。

image-20220512162022056

当一个事务想对这条记录做改动时,首先会看看内存中有没有锁结构和这条记录相关联,当没有的时候就会在内存中生成一个锁结构与之关联。比如,事务T1要对这条记录做改动,就需要生成一个锁结构与之关联。

image-20220512162208201

在锁结构里有很多信息,为了简化理解,只把比较重要的两个属性拿了出来:

  • trx信息:代表这个锁结构是哪个事务生成
  • is_waiting:代表当前事务是否在等待。

当事务T1要改动这条记录,生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。

**在事务T1提交之前,**另一个事务T2也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景称为获取锁失败或者说加锁失败。图示:

image-20220512164808936

锁结构是与记录关联,锁结构里记录了事务信息和is_waiting。

在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程(MySQL是有连接池、有线程池来执行SQL语句的)唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图:

image-20220512165212730

2.3 读-写或写-读情况

读-写写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读不可重复读幻读的问题。

各个数据库厂商对SQL标准的支持可能不一样,比如MySQL在可重复读隔离级别上就已经解决了幻读问题,这是通过MVCC,后续会讲解。

2.4 并发问题的解决方案

怎么解决脏读不可重复读幻读这些问题呢?其实有两种可选的解决方案:

  • 方案一:读操作利用多版本并发控制(MVCC,下章讲解),写操作进行加锁

所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本**(历史版本由undo日志构建)**。查询语句只能到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。

正式因为能够读取数据的历史记录版本,所以才解决了不可重复读和幻读的问题,因为不可重复读和幻读问题相当于都是读到了数据表中的被其他事务更新了之后的数据!!!

ReadView就相当于是屏障,当前事务只能够读取这道屏障之前的已提交事务所作的修改

普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。

  • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,**ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,**也就是避免了脏读现象,但是没有避免不可重复读和幻读,因为每次select操作都会生成一个ReadView,在ReadView之前的其他事务所做的更改能被看到,所以如果有其他事务修改数据,那么能被看到,导致不可重复读问题。
  • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作 才会生成一个ReadView,之后的SELECT操作都复用这ReadView,这样也就避免了不可重复读和幻读的问题。所以后面的select操作读到的数据都是ReadView之前的事务修改提交的数据,也就是第一次select之后,若后面有其他事务对数据修改或做了添加,那么其实是读不到的,所以解决了不可重复读和幻读的问题!
  • 方案二:读、写操作都采用加锁的方式。

    如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。

    比如,在银行存款业务中,需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写入到数据库中,在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其它事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写一样需要排队执行。

    image-20220512171649266

  • 小结对比发现:

    • 采用MVCC方式的话,读-写操作彼此并不冲突(读用MVCC,写加锁),性能更高
    • 采用加锁方式的话,读-写操作彼此需要排队执行(读写都加锁),影响性能。

一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。

3. 锁的不同角度分类

image-20220405093322184

3.1 从数据操作的类型划分:读锁、写锁

对于数据库中并发事务的读-读情况并不会引起什么问题,对于写-写读-写写-读这些情况可能会引起一些并发问,需要使用MVCC来读,对写加锁,或者对读写都加锁的方式来解决并发问题。在使用对读写都加锁的方式来解决并发问题这种情况,由于既要允许读-读情况不受影响,又要使写-写、读-写、写-读情况中的操作相互阻塞,所以MySQL实现一个由两种类型的锁组成的锁系统来解决,这两种类型的锁通常被称为共享锁(Shared Lock, S Lock)和排他锁(Exclusive Lock, X Lock),也叫读锁(readLock)和写锁(write lock)。共享锁就是读锁,排他锁就是写锁。

  • 读锁:也称为共享锁、英文用S表示。针对同一份数据,**多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。**但是对其他事务的写操作是阻塞的!
  • 写锁:也称为排他锁、英文用X表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。

举例(行级读写锁):

如果一个事务T1已经获得了某个行r的读锁,那么此时另外一个事务T2是可以获得这个行r的读锁的,获取锁就是锁结构和这个行记录进行关联!!因为读取操作并没有改变行r的数据,但是如果某个事务T3,想获得行r的写锁(排他锁),则它必须等待T1、T2释放掉行r上的读锁才行。

X锁S锁
X锁不兼容不兼容
S锁不兼容兼容

1. 锁定读

说读锁就是S锁即共享锁,其实不太严谨,其实读锁也可以加上排他的行为!!并不一定就是读锁就是共享锁。

在采用加锁方式解决脏读不可重复读幻读这些问题时,读取一条记录时需要获取该记录的S锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:

  • 对读取的记录加S锁
SELECT ... LOCK IN SHARE MODE;
# 或
SELECT ... FOR SHARE; #(8.0新增语法)

在普通的select语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么他会为读取到的记录(读取到的记录不止一行)加共享锁,**这样允许别的事务继续获取这些记录的共享锁(比方说别的事务也是用了select ... lock in share mode语句来读取这些记录),但是不能获取这些记录的排他锁(比如说别的事务使用select ... for update或者直接修改记录,也就是执行写操作),**如果别的事务想要获取这些记录的排他锁,那么他们会阻塞直到当前事务提交之后将这些记录上的共享锁释放掉。

  • 对读取的记录加X锁(读锁加上排他的行为)
SELECT ... FOR UPDATE;

在普通的select语句后边加上for update,如果当前事务执行了该语句,那么他会为读取到的记录(读取到的记录不止一行)加上排他锁,这样既不允许别的事务获取这些记录的共享锁(比方说别的事务也是用了select ... lock in share mode语句来读取这些记录),也不允许别的事务获取这些记录的排他锁(比如说别的事务使用select ... for update或者直接修改记录,也就是执行写操作)。如果别的事务想要获取这些记录的共享锁排他锁,那么他们会阻塞直到当前事务提交之后将这些记录上的排他锁释放掉。

MySQL8.0新特性:

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

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

2. 写操作(自动加排他锁!)

读操作也就是select,即可以为读到的数据加上共享锁,也可以为它们加上排他锁。

写操作也就是以下三种操作一定是为操作的数据加上排他锁。

也就是写操作会自动加排他锁!

比如说别的事务使用select ... for update或者直接修改记录,也就是执行写操作,会加排他锁。

select ... for update是读操作加排他锁,也就是读锁加上排他的特性,修改记录是写操作,会自动加排他锁!!!

  • DELETE

    对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。

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

    • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。

      则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原纪录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。

    • 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。

      则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。

    • 情况3:修改该记录的键值。

      则相当于在原纪录上做DELECT操作之后再来一次INSERT操作。

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

总结:

  • DELETE和UPDATE操作先加上排他锁或者说先获取到要操作的记录的排他锁,再执行操作。
  • INSERT操作不用显示加上排他锁,而是由MySQL提供隐式锁。

3.2 从数据操作的粒度划分:表级锁、页级锁、行锁

为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情,锁的粒度越小,意味着需要管理的锁资源越多,耗费的资源越多。因此数据库系统需要在高并发响应和系统性能两方面进行平衡,这样就产生了锁粒度的概念。

对一条记录加锁或者说获得一条记录的锁,影响的只是这条记录而已,我们就说这个锁的粒度比较细,其实一个事务也可以在表级别进行加锁,自然就称为表锁或者表级锁。对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。按锁的粒度划分主要分为表锁、行锁和页锁。

1. 表锁(Table Lock)

该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎,并且表锁是开销最少的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁的问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣

① 表级别的S锁、X锁(InnoDB不会添加,除非用户手动获取锁)

在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。在对某个表执行一些诸如ALTER TABLEDROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞。同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)结构来实现的。

一般情况下,不会使用InnoDB存储引擎提供的表级别的S锁X锁。只会在一些特殊情况下,比方说崩溃恢复过程中用到。比如,在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t 的S锁或者X锁可以这么写:

  • LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁

  • LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁

总结:MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁或者写锁的。

MyISAM引擎加表级锁:

image-20220512202803992

② 意向锁 (intention lock)

  1. InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁表级锁共存,而意向锁就是其中的一种表锁

    1、意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。

    2、意向锁是一种不与行级锁冲突的表级锁,这一点非常重要。

    3、表明“某个事务正在某些行持有了锁或该事务准备去持有锁”

  2. 意向锁分为两种:

    • 意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)
    -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 
    SELECT column FROM table ... LOCK IN SHARE MODE;
    
    • 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)
    -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。 
    SELECT column FROM table ... FOR UPDATE;
    

    即:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在数据表的对应意向锁。

  3. 意向锁要解决的问题

    现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级级别的空间示意里面是否已经上过锁。

    在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排它锁即可。

    • 如果事务想要获取数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁(InnoDB会为我们自动添加)
    • 如果事务想要获取数据表中某些记录的排它锁,就需要在数据表上添加意向排他锁(InnoDB会为我们自动添加)

    这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。

  4. 意向锁会与普通的排他/共享锁互斥

    image-20220512214929906

    注意这里的排他/共享锁都是表级锁,意向锁不会与行级的共享/排他锁互斥。

  5. 意向锁和意向锁之间是共享的

    image-20220512215337713

    这是因为这是意向锁!反映的实际上是这个表里有某些行上了锁,比如select操作一条数据的时候,获取了这条数据的排他锁,或者说给这条数据上了排他锁,那么这个表也自动地被InnoDB上了意向排他锁,那么此时另一个事务给这个表里的另一条数据上排他锁的时候,当然是可以的,那么此时这个表被另一个事务也自动地上了意向排他锁,所以意向排他锁之间是兼容的。另外三种关系也是一样的。

    根本原因就是意向锁是反映的这个表里的某些数据上了锁,比如事务A给a、b、c三条数据上了锁,事务B给e、d、f三条数据上了锁,那么这个表相当于由于两个事务操作,由InnoDB上了两个意向锁,这里肯定是兼容的,因为行锁之间并没有冲突,所以反映行锁的意向锁之间当然不会冲突!

  6. 结论

    • InnoDB支持多粒度锁,特定场景下,意向锁可以和表级锁共存。
    • 意向锁之间不排斥,原因在上面解释了,但除了IS与S外,意向锁会与表级共享锁和表级排他锁互斥。
    • IX、IS是表级锁,不会和行级的S、X发生冲突,只会和表级的S、X锁发生冲突。IS和S除外,因为都是共享锁。
    • 意向锁在保证了行级锁,并发性的前提下,实现了行锁和表锁共存,并且满足事务隔离性的需求。

    事务的隔离性是通过锁来实现的!!

③ 自增锁(AUTO-INC锁)

1. “Simple inserts” (简单插入)

可以预先确定要插入的行数(当语句被初始处理时)的语句。包括没有嵌套子查询的单行和多行INSERT...VALUES()REPLACE语句。

2. “Bulk inserts” (批量插入)

事先不知道要插入的行数(和所需自动递增值的数量)的语句。比如INSERT ... SELECTREPLACE ... SELECTLOAD DATA语句,但不包括纯INSERT。 InnoDB在每处理一行,为AUTO_INCREMENT列分配一个新值。

3. “Mixed-mode inserts” (混合模式插入)

这些是“Simple inserts”语句但是指定部分新行的自动递增值。例如INSERT INTO teacher (id,name) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');只是指定了部分id的值。另一种类型的“混合模式插入”是INSERT ... ON DUPLICATE KEY UPDATE

对于上面数据插入的案例,MySQL采用了自增锁的方式来实现,AUTO-INT锁是当向使用含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个AUTO-INT锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INT锁释放掉。一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。也正因为此,其并发性显然并不高,当我们向一个有AUTO_INCREMENT关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的,所以innodb通过innodb_autoinc_lock_mode的不同取值来提供不同的锁定机制,来显著提高SQL语句的可伸缩性和性能。

innodb_autoinc_lock_mode有三种取值,分别对应与不同锁定模式:

(1)innodb_autoinc_lock_mode = 0(“传统”锁定模式)

在此锁定模式下,所有类型的insert语句都会获得一个特殊的表级AUTO-INC锁,用于插入具有AUTO_INCREMENT列的表。这种模式其实就如我们上面的例子,即每当执行insert的时候,都会得到一个表级锁(AUTO-INC锁),使得语句中生成的auto_increment为顺序,且在binlog中重放的时候,可以保证master与slave中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务中执行insert的时候,对于AUTO-INC锁的争夺会限制并发能力。

(2)innodb_autoinc_lock_mode = 1(“连续”锁定模式)

在 MySQL 8.0 之前,连续锁定模式是默认的。

在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束。这适用于所有INSERT ... SELECT,REPLACE ... SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁。

对于“Simple inserts”(要插入的行数事先已知),则通过在mutex(轻量锁)的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。如果另一个事务保持AUTO-INC锁,则“Simple inserts”等待AUTO-INC锁,如同它是一个“bulk inserts”。

(3)innodb_autoinc_lock_mode = 2(“交错”锁定模式)

从 MySQL 8.0 开始,交错锁模式是默认设置。

在这种锁定模式下,所有类INSERT语句都不会使用表级AUTO-INC锁,并且可以同时执行多个语句。这是最快和最可拓展的锁定模式,但是当使用基于语句的复制或恢复方案时,从二进制日志重播SQL语句时,这是不安全的。

在此锁定模式下,自动递增值保证在所有并发执行的所有类型的insert语句中是唯一单调递增的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号),为任何给定语句插入的行生成的值可能不是连续的。

④ 元数据锁(MDL锁)

MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。

读锁之间不互斥,因此可以有多个线程同时对一张表增删改查,读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性!!解决了DML操作和DDL操作之间的一致性问题。

不需要显示使用,在访问一个表时会被自动加上。

2. InnoDB中的行锁

行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。

**优点:**锁定力度小,发生锁冲突概率低,可以实现的并发度高

**缺点:**对于锁的开销比较大,加锁会比较慢,容易出现死锁情况

InnoDB与MyISAM的最大不同有两点:一是支持事务;二是采用了行级锁。

① 记录锁(Record Locks)

记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP

记录锁是有S锁和X锁之分的,称之为S型记录锁X型记录锁

  • 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
  • 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。

② 间隙锁(Gap Locks)

  1. MySQLREPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁

    image-20220512223646812

    gap锁的提出仅仅是为了防止插入幻影记录而提出的。虽然有共享gap锁独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。(这会导致死锁)

  2. 间隙锁的创建

    在两条记录范围之间,去做select操作,做select操作的同时,添加行级的共享锁或排他锁,那么就会添加上共享gap锁或间隙gap锁,这个锁不回限制其他事务对这条记录继续添加记录锁或gap锁。

    比如在3-8之间的任意一条数据,做select操作,添加行级记录锁,此时相当于给3-8开区间的所有数据都添加上了锁,这就是间隙锁,防止数据插入。

    如果在主键值最大的后面添加间隙锁,比如给id为25的数据添加间隙锁,25这个数据当然是不存在的,等价于给id > 20的任意记录添加间隙锁,效果是对id > 20的范围添加了间隙锁,就不能插入id比20大的数据了。

  3. 间隙锁导致死锁的问题

    image-20220512225928434

③ 临键锁(Next-Key Locks)

有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。

begin; 
select * from student where id <=8 and id > 3 for update;

④ 插入意向锁(Insert Intention Locks)

我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁next-key锁也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁。插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。

插入意向锁是在插入一条记录行前,由INSERT 操作产生的一种间隙锁

事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

image-20220512232420415

从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间并不会阻塞,它们可以同时获取到id值为8的插入意向锁,然后执行插入操作。

3. 页锁

页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

3.3 从对待锁的态度划分:乐观锁、悲观锁

从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想

1. 悲观锁(Pessimistic Locking)

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

select ... for update 是MySQL中悲观锁。

注意:select ... for update 语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。

2. 乐观锁(Optimistic Locking)

乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在Java中java.util.concurrent.atomic包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。

1. 乐观锁的版本号机制

在表中设计一个版本字段 version,第一次读的时候,**会获取 version 字段的取值。**然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

2. 乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则说明这个数据被其他事务已经进行了更改,当前事务对数据的修改就不会成功,就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。

3. 两种锁的适用场景

从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:

  1. 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

  2. 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写写 - 写的冲突。

image-20220513000732732

3.4 按加锁的方式划分:显式锁、隐式锁

1. 隐式锁

  • **情景一:**对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。

  • **情景二:**对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。

即:一个事务对新插入的记录可以不显示的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁是一种延迟加锁的机制,从而来减少加锁的数量。

2. 显式锁

通过特定的语句进行加锁,我们一般称之为显示加锁。

3.5 其它锁之:全局锁

全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份

全局锁的命令:

Flush tables with read lock

3.6 其它锁之:死锁

  1. 死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。

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

    image-20220513171605954

  2. 产生死锁的必要条件

    • 两个或者两个以上的事务
    • 每个事务都已经持有锁并且申请新的锁
    • 锁资源同时只能被同一个事务持有或者不兼容
    • 事务之间因为持有锁和申请锁导致彼此循环等待
  3. 如何处理死锁

    **方式1:**等待,直到超时(innodb_lock_wait_timeout=50s)

    即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。

    **方式2:**使用死锁检测进行死锁处理

    发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(将持有最少行级排他锁的事务进行回滚),让其他事务得以继续执行。

    有死锁,InnoDB会选择回滚undo量最小的事务。

  4. 如何避免死锁

    • 合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争。
    • 调整业务逻辑SQL执行顺序,避免update/delete这种长时间持有锁的SQL在事务前面
    • 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短资源锁定的时间,发生锁冲突的几率也就更小
    • 在并发比较高的系统中,不要显示加锁,特别是在事务里显示加锁。
    • 降低隔离级别。

4. 锁的内存结构

我们前边说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比如:

select * from user lock in share mode;

理论上创建多个锁结构没问题,但是如果一个事务要获取10000条记录的锁,生成10000个锁结构也太崩溃了,所以决定在对不同记录加锁时,如果符合下边这些条件的记录会放到一个锁结构中:

  • 在同一个事务中对数据上锁
  • 上锁的数据在同一个页面中
  • 等待状态一样
  • 加锁的类型是一样的

InnoDB的锁结构如下:

image-20220405151409557

结构解析:

1. 锁所在的事务信息

不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个 锁结构 ,这里就记录这个事务的信息。

锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。

2. 索引信息

对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。

3. 表锁/行锁信息

表锁结构行锁结构在这个位置的内容是不同的:

  • 表锁:记载着是对哪个表加的锁,还有其他的一些信息。
  • 行锁:记载了三个重要的信息:
    • Space ID :记录所在表空间。
    • Page Number :记录所在页号。
    • n_bits :对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits 属性代表使用了多少比特位。

n_bits的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后也不至于重新分配锁结构

4. type_mode

这是一个32位的数,被分成了lock_modelock_typerec_lock_type三个部分,如图所示:

image-20220405151829547

  • 锁的模式(lock_mode),占用低4位,可选的值如下:
    • LOCK_IS(十进制的0):表示共享意向锁,也就是IS锁
    • LOCK_IX(十进制的1):表示独占意向锁,也就是IX锁
    • LOCK_S(十进制的2):表示共享锁,也就是S锁
    • LOCK_X(十进制的3):表示独占锁,也就是X锁
    • LOCK_AUTO_INC(十进制的4):表示AUTO-INC锁

在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和 LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。

  • 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:
    • LOCK_TABLE(十进制的16),也就是当第5个比特位置为1时,表示表级锁。
    • LOCK_REC(十进制的32),也就是当第6个比特位置为1时,表示行级锁。
  • 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在 lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
    • LOCK_ORDINARY(十进制的0):表示next-key锁
    • LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁
    • LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示正经记录锁
    • LOCK_INSERT_INTENTION(十进制的2048):也就是当第12个比特位置为1时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。
  • is_waiting属性呢?基于内存空间的节省,所以把 is_waiting 属性放到了 type_mode 这个32位的数字中:
    • LOCK_WAIT(十进制的256) :当第9个比特位置为1时,表示is_waitingtrue,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waitingfalse,也就是当前事务获取锁成功。

5. 其他信息

为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

6. 一堆比特位

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

5. 锁监控

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所代替。

第16章 多版本并发控制

1. 什么是MVCC

MVCC (Multiversion Concurrency Control),多版本并发控制。顾名思义,MVCC 是通过数据行的多个版本管理来实现数据库的并发控制。这项技术使得在InnoDB的事务隔离级别下执行一致性读操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁。

2. 快照读与当前读

MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁非阻塞并发读,而这个读指的就是快照读, 而非当前读当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC读的是快照,本质是采用乐观锁思想的一种方式。

2.1 快照读

快照读又叫一致性读,读取的是快照数据。不加锁的简单的 SELECT 都属于快照读,即不加锁的非阻塞读。

之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于MVCC,它在很多情况下,避免了加锁操作,降低了开销。

既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

2.2 当前读

当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。

当前读就是读取最新数据,为了做之后的修改操作,当然不能读取到历史数据。

快照读只是为了读数据,而不是为了之后的修改,所以不一定要读取到数据的最新值,也不会引起并发问题,增加了并发性,但是又并没有上锁,所以是乐观锁思想。

3. 复习

3.1 再谈隔离级别

我们知道事务有 4 个隔离级别,可能存在三种并发问题:

image-20220405153617536

另图:

image-20220405153632021

在MySQL中,默认的隔离级别是可重复读,可以解决脏读和不可重复读的问题,如果仅从定义的角度来看,它并不能解决幻读问。如果我们想要解决幻读问,就需要采用串行化的方式,也就是将隔离级别提升到最高,但这样一来就会大幅降低数据库的事务并发能力。

MVCC可以不采用锁机制,而是通过乐观锁的方式来解决不可重复读和幻读问题!它可以在大多数情况下替代行级锁,降低系统的开销。

3.2 隐藏字段、Undo Log版本链

回顾一下undo日志的版本链,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列。隐藏列一共有三个:隐式生成聚簇索引的主键列、事务ID列、回滚指针列。

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

  • roll_pointer(回滚指针):每次对某条聚簇索引记录进行改动时,都会先把旧的版本写入到 undo日志中,然后才进行修改,然后将修改的SQL语句写入redo log buffer,然后将redo log buffer按某种策略刷盘到磁盘中,也就是写入到redo log file中,这其中涉及到page cache。然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

    回滚指针是和undo log链打交道的。

    insert undo只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的undo log segment也会被系统回收。(也就是该undo日志占用的undo 页面链表要么被重用,要么被释放。)

    每次对记录进行改动,都会记录一条undo日志,每条undo日志都由记录行格式的roll_pointer(回滚指针)指向(INSERT操作对应的undo log没有该属性,因为该记录在insert之前没有更早的版本)。可以将这些undo 日志都连起来,串成一个链表:

    image-20220513184324327

    对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的数据旧版本都会被roll_pointer属性连接成一个链表,我们把这个链称为版本链,版本链的头节点就是当前记录的最新值。

    每个版本中还包含生成该版本时对应的事务id。

4. MVCC实现原理之ReadView

MVCC 的实现依赖于:隐藏字段、Undo Log版本链、Read View

4.1 什么是ReadView

在MVCC机制中,多个事务对同一个行记录进行更新时会产生多个历史快照,这些历史快照保存在undo log里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到ReadView了,它帮我们解决了行的可见性问题。

ReadView就是事务在使用MVCC机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃”指的就是,启动了但还没提交)。

ReadView和事务是一对一的。

4.2 设计思路

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

使用SERIALIZABLE隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。

MVCC是针对READ COMMITTEDREPEATABLE READ这两个隔离级别的。

使用READ COMMITTEDREPEATABLE READ隔离级别的事务,都必须保证读到已经提交了的事务修改过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView要解决的主要问题。

这个ReadView中主要包含4个比较重要的内容,分别如下:

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

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

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

  2. up_limit_id,活跃的事务中最小的事务 ID。

  3. low_limit_id,表示生成ReadView时系统中应该分配给下一个事务的id值。low_limit_id 是系统最大的事务id值,这里要注意是系统中的事务id,需要区别于正在活跃的事务ID。

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

4.3 ReadView的规则

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

  • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值小于ReadView中的up_limit_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于或等于ReadView中的low_limit_id值,表明生成该版本数据的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的up_limit_idlow_limit_id之间,那就需要判断一下trx_id属性值是不是在 trx_ids 列表中。
    • 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
    • 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

4.4 MVCC整体操作流程

了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过MVCC找到它:

  1. 首先获取事务自己的版本号,也就是事务 ID;

  2. 获取 ReadView;

  3. 查询得到的数据,根据得到的数据的事务id,然后与 ReadView 中的事务版本号进行比较;

  4. 如果不符合 ReadView 规则,就需要从 Undo Log(版本链) 中获取历史快照;

  5. 最后返回符合规则的数据。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性(判断可见性是判断历史版本数据的事务id和创建该readView的事务id比较,比较活跃事务id,活跃事务id中的最小事务id,以及low_limit_id),依次类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

InnoDB中,MVCC是通过undo log + read view进行数据读取,undo log保存了历史快照,而read view规则帮我们判断当前版本的数据是否可见。

在隔离级别为读已提交(Read Committed)时,一个事务中的每一次 SELECT 查询都会重新获取一次Read View。

如表所示:

image-20220405154948505

注意,此时同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。

当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:

image-20220405155041964

有一种屏障的意思,来保证历史版本数据的可见性。

5. 举例说明

5.1 READ COMMITTED隔离级别下

READ COMMITTED :每次读取数据前都生成一个ReadView

5.2 REPEATABLE READ隔离级别下

使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView ,之后的查询就不会重复生成了。

5.3 如何解决幻读

假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图所示。

image-20220405155640520

假设现在有事务 A 和事务 B 并发执行,事务 A的事务 id 为20事务 B的事务 id 为30

步骤1:事务 A 开始第一次查询数据,查询的 SQL 语句如下。

select * from student where id >= 1;

在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 ReadView 的内容如下:trx_ids= [20,30]up_limit_id=20low_limit_id=31creator_trx_id=20

由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 ReadView机制,发现该行数据的trx_id=10,小于事务 A 的 ReadView 里 up_limit_id,这表示这条数据是事务 A 开启之前,其他事务就已经提交了的数据,因此事务 A 可以读取到。

结论:事务 A 的第一次查询,能读取到一条数据,id=1。

步骤2:接着事务 B(trx_id=30),往表 student 中新插入两条数据,提交事务。

insert into student(id,name) values(2,'李四'); 
insert into student(id,name) values(3,'王五');

此时表student 中就有三条数据了,对应的 undo 如下图所示:

image-20220405155909223

步骤3:接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成ReadView。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据ReadView 机制,判断每条数据是不是都可以被事务 A 看到。

1)首先 id=1 的这条数据,前面已经说过了,可以被事务 A 看到。

2)然后是 id=2 的数据,它的 trx_id=30,此时事务 A 发现,这个值处于 up_limit_id 和 low_limit_id 之间,因此还需要再判断 30 是否处于 trx_ids 数组内。由于事务 A 的 trx_ids=[20,30],因此在数组内,这表示 id=2 的这条数据是与事务 A 在同一时刻启动的其他事务提交的,所以这条数据不能让事务 A 看到。

3)同理,id=3 的这条数据,trx_id 也为 30,因此也不能被事务 A 看见。

image-20220405155941753

结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。

6. 总结

这里介绍了MVCCREAD COMMITTDREPEATABLE READ这两种隔离级别的事务在执行快照读操作时访问记录的版本链的过程。这样使不同事务的读-写写-读操作并发执行,从而提升系统性能。

核心点在于 ReadView 的原理,READ COMMITTDREPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同:

  • READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView
  • REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。

说明:我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的,数据还得回滚的,不能真正物理地删除。

第17章 其它数据库日志

1. MySQL支持的日志

1.1 日志类型

MySQL有不同类型的日志文件,用来存储不同类型的日志,分为二进制日志错误日志通用查询日志慢查询日志,这也是常用的4种。MySQL 8又新增两种支持的日志:中继日志数据定义语句日志。使用这些日志文件,可以查看MySQL内部发生的事情。

  • **慢查询日志:**记录所有执行时间超过long_query_time的所有查询,方便我们对查询进行优化。

  • **通用查询日志:**记录所有连接的起始时间和终止时间,以及连接发送给数据库服务器的所有指令,对我们复原操作的实际场景、发现问题,甚至是对数据库操作的审计都有很大的帮助。

  • **错误日志:**记录MySQL服务的启动、运行或停止MySQL服务时出现的问题,方便我们了解服务器的状态,从而对服务器进行维护。

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

  • **中继日志:**用于主从服务器架构中,从服务器用来存放主服务器二进制日志内容的一个中间文件。从服务器通过读取中继日志的内容,来同步主服务器上的操作。

  • **数据定义语句日志:**记录数据定义语句执行的元数据操作。

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

1.2 日志的弊端

  • 日志功能会降低MySQL数据库的性能

  • 日志会占用大量的磁盘空间

2. 通用查询日志(general query log)

通用查询日志用来记录用户的所有操作,包括启动和关闭MySQL服务、所有用户的连接开始时间和截止时间、发给 MySQL 数据库服务器的所有 SQL 指令等。当我们的数据发生异常时,查看通用查询日志,还原操作时的具体场景,可以帮助我们准确定位问题。

2.1 查看当前状态

mysql> SHOW VARIABLES LIKE '%general%';

2.2 启动日志

方式1:永久性方式

[mysqld] 
general_log=ON 
general_log_file=[path[filename]] #日志文件所在目录路径,filename为日志文件名

方式2:临时性方式

SET GLOBAL general_log=on; # 开启通用查询日志
SET GLOBAL general_log_file=’path/filename’; # 设置日志文件保存位置
SET GLOBAL general_log=off; # 关闭通用查询日志
SHOW VARIABLES LIKE 'general_log%'; # 查看设置后情况

2.3 停止日志

方式1:永久性方式

[mysqld] 
general_log=OFF

方式2:临时性方式

SET GLOBAL general_log=off;
SHOW VARIABLES LIKE 'general_log%';

3.错误日志(error log)

3.1 启动日志

在MySQL数据库中,错误日志功能是默认开启的。而且,错误日志无法被禁止

[mysqld] 
log-error=[path/[filename]] #path为日志文件所在的目录路径,filename为日志文件名

3.2 查看日志

mysql> SHOW VARIABLES LIKE 'log_err%';

3.3 删除\刷新日志

install -omysql -gmysql -m0644 /dev/null /var/log/mysqld.log
mysqladmin -uroot -p flush-logs

4. 二进制日志(bin log)

4.1 查看默认情况

mysql> show variables like '%log_bin%';

4.2 日志参数设置

方式1:永久性方式

[mysqld] 
#启用二进制日志 
log-bin=atguigu-bin 
binlog_expire_logs_seconds=600 max_binlog_size=100M

设置带文件夹的bin-log日志存放目录

[mysqld] 
log-bin="/var/lib/mysql/binlog/atguigu-bin"

注意:新建的文件夹需要使用mysql用户,使用下面的命令即可。

chown -R -v mysql:mysql binlog

方式2:临时性方式

# global 级别 
mysql> set global sql_log_bin=0; 
ERROR 1228 (HY000): Variable 'sql_log_bin' is a SESSION variable and can`t be used with SET GLOBAL 

# session级别 
mysql> SET sql_log_bin=0; 
Query OK, 0 rows affected (0.01 秒)

4.3 查看日志

mysqlbinlog -v "/var/lib/mysql/binlog/atguigu-bin.000002"
# 不显示binlog格式的语句
mysqlbinlog -v --base64-output=DECODE-ROWS "/var/lib/mysql/binlog/atguigu-bin.000002"
# 可查看参数帮助 
mysqlbinlog --no-defaults --help 

# 查看最后100行 
mysqlbinlog --no-defaults --base64-output=decode-rows -vv atguigu-bin.000002 |tail -100 

# 根据position查找 
mysqlbinlog --no-defaults --base64-output=decode-rows -vv atguigu-bin.000002 |grep -A20 '4939002'

上面这种办法读取出binlog日志的全文内容比较多,不容易分辨查看到pos点信息,下面介绍一种更为方便的查询命令:

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:查询总条数(不指定就是所有行)
mysql> show binlog events in 'atguigu-bin.000002';

4.4 使用日志恢复数据

mysqlbinlog恢复数据的语法如下:

mysqlbinlog [option] filename|mysql –uuser -ppass;
  • filename:是日志文件名。
  • option:可选项,比较重要的两对option参数是--start-date、--stop-date 和 --start-position、-- stop-position。
    • --start-date 和 --stop-date:可以指定恢复数据库的起始时间点和结束时间点。
    • --start-position和--stop-position:可以指定恢复数据的开始位置和结束位置。

注意:使用mysqlbinlog命令进行恢复操作时,必须是编号小的先恢复,例如atguigu-bin.000001必须在atguigu-bin.000002之前恢复。

4.5 删除二进制日志

1. PURGE MASTER LOGS:删除指定日志文件

PURGE {MASTER | BINARY} LOGS TO ‘指定日志文件名’ 
PURGE {MASTER | BINARY} LOGS BEFORE ‘指定日期’

5. 再谈二进制日志(binlog)

5.1 写入机制

binlog的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache。

image-20220405163025361

write和fsync的时机,可以由参数sync_binlog控制,默认是 0。为0的时候,表示每次提交事务都只write,由系统自行判断什么时候执行fsync。虽然性能得到提升,但是机器宕机,page cache里面的binglog 会丢失。如下图:

image-20220405163125180

为了安全起见,可以设置为1,表示每次提交事务都会执行fsync,就如同redo log 刷盘流程一样。最后还有一种折中方式,可以设置为N(N>1),表示每次提交事务都write,但累积N个事务后才fsync。

image-20220405163205364

在出现IO瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。同样的,如果机器宕机,会丢失最近N个事务的binlog日志。

5.2 binlog与redolog对比

  • redo log 它是物理日志,记录内容是“在某个数据页上做了什么修改”,属于 InnoDB 存储引擎层产生的。
  • 而 binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server 层。
  • 虽然它们都属于持久化的保证,但是侧重点不同。
    • redo log 让InnoDB存储引擎拥有了崩溃恢复能力。
    • binlog保证了MySQL集群架构的数据一致性

5.3 两阶段提交

在执行更新语句过程,会记录redo log与binlog两块日志,以基本的事务为单位,redo log在事务执行过程中可以不断写入,而binlog只有在提交事务时才写入,所以redo log与binlog的写入时机不一样。

为了解决两份日志之间的逻辑一致问题,InnoDB存储引擎使用两阶段提交方案。

image-20220405163716222

使用两阶段提交后,写入binlog时发生异常也不会有影响

image-20220405163902977

另一个场景,redo log设置commit阶段发生异常,那会不会回滚事务呢?

image-20220405163927129

并不会回滚事务,它会执行上图框住的逻辑,虽然redo log是处于prepare阶段,但是能通过事务id找到对应的binlog日志,所以MySQL认为是完整的,就会提交事务恢复数据。

6. 中继日志(relay log)

6.1 介绍

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

6.2 恢复的典型错误

如果从服务器宕机,有的时候为了系统恢复,要重装操作系统,这样就可能会导致你的服务器名称与之前不同。而中继日志里是包含从服务器名的。在这种情况下,就可能导致你恢复从服务器的时候,无法从宕机前的中继日志里读取数据,以为是日志文件损坏了,其实是名称不对了。

解决的方法也很简单,把从服务器的名称改回之前的名称。

第18章 主从复制

1. 主从复制概述

1.1 如何提升数据库并发能力

一般应用对数据库而言都是“读多写少”,也就说对数据库读取数据的压力比较大,有一个思路就是采用数据库集群的方案,做主从架构、进行读写分离,这样同样可以提升数据库的并发处理能力。但并不是所有的应用都需要对数据库进行主从架构的设置,毕竟设置架构本身是有成本的。

如果我们的目的在于提升数据库高并发访问的效率,那么首先考虑的是如何优化SQL和索引,这种方式简单有效;其次才是采用缓存的策略,比如使用 Redis将热点数据保存在内存数据库中,提升读取的效率;最后才是对数据库采用主从架构,进行读写分离。

1.2 主从复制的作用

第1个作用:读写分离。

第2个作用就是数据备份。

第3个作用是具有高可用性。

2. 主从复制的原理

2.1 原理剖析

三个线程

实际上主从同步的原理就是基于 binlog 进行数据同步的。在主从复制过程中,会基于3 个线程来操作,一个主库线程,两个从库线程。

image-20220405164559961

二进制日志转储线程(Binlog dump thread)是一个主库线程。当从库线程连接的时候, 主库可以将二进制日志发送给从库,当主库读取事件(Event)的时候,会在 Binlog 上加锁,读取完成之后,再将锁释放掉。

从库 I/O 线程会连接到主库,向主库发送请求更新 Binlog。这时从库的 I/O 线程就可以读取到主库的二进制日志转储线程发送的 Binlog 更新部分,并且拷贝到本地的中继日志 (Relay log)。

从库 SQL 线程会读取从库中的中继日志,并且执行日志中的事件,将从库中的数据与主库保持同步。

image-20220405164718627

复制三步骤

步骤1:Master将写操作记录到二进制日志(binlog)。

步骤2:SlaveMaster的binary log events拷贝到它的中继日志(relay log);

步骤3:Slave重做中继日志中的事件,将改变应用到自己的数据库中。 MySQL复制是异步的且串行化的,而且重启后从接入点开始复制。

复制的问题

复制的最大问题:延时

2.2 复制的基本原则

  • 每个Slave只有一个Master

  • 每个Slave只能有一个唯一的服务器ID

  • 每个Master可以有多个Slave

3. 同步数据一致性问题

主从同步的要求:

  • 读库和写库的数据一致(最终一致);

  • 写数据必须写到写库;

  • 读数据必须到读库(不一定);

3.1 理解主从延迟问题

进行主从同步的内容是二进制日志,它是一个文件,在进行网络传输的过程中就一定会存在主从延迟(比如 500ms),这样就可能造成用户在从库上读取的数据不是最新的数据,也就是主从同步中的数据不一致性问题。

3.2 主从延迟问题原因

在网络正常的时候,日志从主库传给从库所需的时间是很短的,即T2-T1的值是非常小的。即,网络正常情况下,主备延迟的主要来源是备库接收完binlog和执行完这个事务之间的时间差。

**主备延迟最直接的表现是,从库消费中继日志(relay log)的速度,比主库生产binlog的速度要慢。**造成原因:

1、从库的机器性能比主库要差

2、从库的压力大

3、大事务的执行

3.3 如何减少主从延迟

若想要减少主从延迟的时间,可以采取下面的办法:

  1. 降低多线程大事务并发的概率,优化业务逻辑

  2. 优化SQL,避免慢SQL,减少批量操作,建议写脚本以update-sleep这样的形式完成。

  3. 提高从库机器的配置,减少主库写binlog和从库读binlog的效率差。

  4. 尽量采用短的链路,也就是主库和从库服务器的距离尽量要短,提升端口带宽,减少binlog传输的网络延时。

  5. 实时性要求的业务读强制走主库,从库只做灾备,备份。

3.4 如何解决一致性问题

读写分离情况下,解决主从同步中数据不一致的问题, 就是解决主从之间 数据复制方式 的问题,如果按照数据一致性 从弱到强 来进行划分,有以下 3 种复制方式。

方法 1:异步复制

image-20220405165455998

方法 2:半同步复制

image-20220405165513025

方法 3:组复制

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

image-20220405165650425

第19章 数据库备份与恢复

1. 物理备份与逻辑备份

物理备份:备份数据文件,转储数据库物理文件到某一目录。物理备份恢复速度比较快,但占用空间比较大,MySQL中可以用xtrabackup工具来进行物理备份。

逻辑备份:对数据库对象利用工具进行导出工作,汇总入备份文件内。逻辑备份恢复速度慢,但占用空间小,更灵活。MySQL 中常用的逻辑备份工具为mysqldump。逻辑备份就是备份sql语句,在恢复的时候执行备份的sql语句实现数据库数据的重现。

2. mysqldump实现逻辑备份

2.1 备份一个数据库

mysqldump –u 用户名称 –h 主机名称 –p密码 待备份的数据库名称[tbname, [tbname...]]> 备份文件名 称.sql
mysqldump -uroot -p atguigu>atguigu.sql #备份文件存储在当前目录下
mysqldump -uroot -p atguigudb1 > /var/lib/mysql/atguigu.sql

2.2 备份全部数据库

mysqldump -uroot -pxxxxxx --all-databases > all_database.sql 
mysqldump -uroot -pxxxxxx -A > all_database.sql

2.3 备份部分数据库

mysqldump –u user –h host –p --databases [数据库的名称1 [数据库的名称2...]] > 备份文件名 称.sql
mysqldump -uroot -p --databases atguigu atguigu12 >two_database.sql
mysqldump -uroot -p -B atguigu atguigu12 > two_database.sql

2.4 备份部分表

mysqldump –u user –h host –p 数据库的名称 [表名1 [表名2...]] > 备份文件名称.sql
mysqldump -uroot -p atguigu book> book.sql
#备份多张表 
mysqldump -uroot -p atguigu book account > 2_tables_bak.sql

2.5 备份单表的部分数据

mysqldump -uroot -p atguigu student --where="id < 10 " > student_part_id10_low_bak.sql

2.6 排除某些表的备份

mysqldump -uroot -p atguigu --ignore-table=atguigu.student > no_stu_bak.sql

2.7 只备份结构或只备份数据

  • 只备份结构
mysqldump -uroot -p atguigu --no-data > atguigu_no_data_bak.sql
  • 只备份数据
mysqldump -uroot -p atguigu --no-create-info > atguigu_no_create_info_bak.sql

2.8 备份中包含存储过程、函数、事件

mysqldump -uroot -p -R -E --databases atguigu > fun_atguigu_bak.sql

3. mysql命令恢复数据

mysql –u root –p [dbname] < backup.sql

3.1 单库备份中恢复单库

#备份文件中包含了创建数据库的语句
mysql -uroot -p < atguigu.sql
#备份文件中不包含了创建数据库的语句
mysql -uroot -p atguigu4< atguigu.sql

3.2 全量备份恢复

mysql –u root –p < all.sql

3.3 从全量备份中恢复单库

sed -n '/^-- Current Database: `atguigu`/,/^-- Current Database: `/p' all_database.sql > atguigu.sql 
#分离完成后我们再导入atguigu.sql即可恢复单个库

3.4 从单库备份中恢复单表

cat atguigu.sql | sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `class`/!d;q' > class_structure.sql 
cat atguigu.sql | grep --ignore-case 'insert into `class`' > class_data.sql 
#用shell语法分离出创建表的语句及插入数据的语句后 再依次导出即可完成恢复 

use atguigu; 
mysql> source class_structure.sql; 
Query OK, 0 rows affected, 1 warning (0.00 sec) 

mysql> source class_data.sql; 
Query OK, 1 row affected (0.01 sec)

4. 表的导出与导入

4.1 表的导出

1. 使用SELECT…INTO OUTFILE导出文本文件

SHOW GLOBAL VARIABLES LIKE '%secure%';
SELECT * FROM account INTO OUTFILE "/var/lib/mysql-files/account.txt";

2. 使用mysqldump命令导出文本文件

mysqldump -uroot -p -T "/var/lib/mysql-files/" atguigu account
# 或
mysqldump -uroot -p -T "/var/lib/mysql-files/" atguigu account --fields-terminated- by=',' --fields-optionally-enclosed-by='\"'

3. 使用mysql命令导出文本文件

mysql -uroot -p --execute="SELECT * FROM account;" atguigu> "/var/lib/mysql-files/account.txt"

4.2 表的导入

1. 使用LOAD DATA INFILE方式导入文本文件

LOAD DATA INFILE '/var/lib/mysql-files/account_0.txt' INTO TABLE atguigu.account;
# 或
LOAD DATA INFILE '/var/lib/mysql-files/account_1.txt' INTO TABLE atguigu.account FIELDS TERMINATED BY ',' ENCLOSED BY '\"';

2. 使用mysqlimport方式导入文本文件

mysqlimport -uroot -p atguigu '/var/lib/mysql-files/account.txt' --fields-terminated- by=',' --fields-optionally-enclosed-by='\"'
Last Updated:
Contributors: 陈杨