Mysql
第04章 逻辑架构
1. 逻辑架构剖析
首先MySQL是典型的C/S架构,即Clinet/Server 架构,服务器端程序使用的是 mysqld。
不论客户端进程和服务器进程是采用哪种方式进行通信,最后实现的效果是:客户端进程向服务器进程发送一段文本(SQL语句),服务器进程处理后再向客户端进程返回一段文本(处理结果)。
那服务器进程对客户端进程发送的请求做了什么处理,才能产生最后的处理结果呢?这里以查询请求为例展示:
1.1 服务器处理客户端请求

下面具体展开如下:


1.2 Connectors
Connectors, 指的是不同语言中与SQL的交互。MySQL首先是一个网络程序,在TCP之上定义了自己的应用层协议。所以要使用MySQL,我们可以编写代码,与MySQL Server 建立TCP连接,之后按照其定义好的协议进行交互。或者比较方便的方法是调用SDK,比如Native C API、JDBC、PHP等各语言MySQL Connecotr,或者通过ODBC。通过SDK来访问MySQL,本质上还是在TCP连接上通过MySQL协议跟MySQL进行交互
接下来的MySQL Server结构可以分为如下三层:
1.3 第一层:连接层
连接管理的职责是负责认证、管理连接、获取权限信息。
系统(客户端)访问MySQL服务器前,做的第一件事就是建立TCP连接。
经过三次握手建立连接成功后,MySQL服务器对TCP传输过来的账号密码做身份认证、权限获取。
- 用户名或密码不对,会收到一个
Access denied for user错误,客户端程序结束执行 - 用户名密码认证通过,会从权限表查出账号拥有的权限与连接关联,之后的权限判断逻辑,都将依赖于此时读到的权限

TCP连接池
多个系统都可以和MySQL服务器建立连接,每个系统建立的连接不止一个。所以,为了解决TCP无限创建与TCP频繁创建销毁带来的资源耗尽、性能下降问题。MySQL服务器里有专门的TCP连接池限制连接数采用长连接模式复用TCP连接,来解决上述问题。
线程池
TCP连接收到请求后,必须要分配给一个线程专门与这个客户端的交互。所以还会有个线程池,去走后面的流程。每一个连接从线程池中获取线程,省去了创建和销毁线程的开销。
这些内容我们都归纳到MySQL的连接管理组件中。
1.4 第二层:服务层
第二层架构主要完成大多数的核心服务功能,如SQL接口,并完成缓存的查询,SQL的分析和优化及部分内置函数的执行。所有跨存储引擎的功能也在这一层实现,如过程、函数等。
在该层,服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化:如确定查询表的顺序,是否利用索引等,最后生成相应的执行操作。
如果是SELECT语句,服务器还会查询内部的缓存。如果缓存空间足够大,这样在解决大量读操作的环境中能够很好的提升系统的性能。
| 组件名 | 解释 |
|---|---|
Management Services & Utilities |
系统管理和控制工具 |
SQL Interface |
SQL 接口。接受用户的 SQL 命令,并且返回用户需要查询的结果。比如 select from 就是调用 SQL Interface。 |
Parser |
SQL 解析器。SQL 命令传递到解析器的时候会被解析器验证和解析。 |
Optimizer |
SQL 查询优化器。SQL 语句在查询之前会使用查询优化器对查询进行优化,比如有 where 条件时,优化器来决定先投影还是先过滤。 |
Cache & Buffer |
SQL 查询缓存。如果查询缓存有命中的查询结果,查询语句就可以直接去查询缓存中取数据。这个缓存机制是由一系列小缓存组成的。比如表缓存,记录缓存,key 缓存,权限缓存等。 |
-
SQL Interface: SQL接口
- 接收用户的SQL命令,并且返回用户需要查询的结果。比如SELECT … FROM就是调用SQL Interface
- MySQL支持DML(数据操作语言)、DDL(数据定义语言)、存储过程、视图、触发器、自定义函数等多种SQL语言接口
-
Parser: 解析器
- 在解析器中对 SQL 语句进行语法分析、语义分析。将SQL语句分解成数据结构,并将这个结构传递到后续步骤,以后SQL语句的传递和处理就是基于这个结构的。如果在分解构成中遇到错误,那么就说明这个SQL语句是不合理的。
- 在SQL命令传递到解析器的时候会被解析器验证和解析,并为其创建
语法树,并根据数据字典丰富查询语法树,会验证该客户端是否具有执行该查询的权限。创建好语法树后,MySQL还会对SQL查询进行语法上的优化,进行查询重写。
-
Optimizer: 查询优化器
-
SQL语句在语法解析之后、查询之前会使用查询优化器确定SQL语句的执行路径,生成一个
执行计划。 -
这个执行计划表明应该
使用哪些索引进行查询(全表检索还是使用索引检索),表之间的连接顺序如何,最后会按照执行计划中的步骤调用存储引擎提供的方法来真正的执行查询,并将查询结果返回给用户。 -
它使用“
选取-投影-连接”策略进行查询。例如:1
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 第三层:引擎层
插件式存储引擎层( Storage Engines)真正的负责了MySQL中数据的存储和提取,对物理服务器级别维护的底层数据执行操作,服务层通过API与存储引擎进行通信。
插件式的存储引擎架构将查询处理和其它的系统任务以及数据的存储提取相分离。这种架构可以根据业务的需求和实际需要选择合适的存储引擎。同时开源的MySQL还允许开发人员设置自己的存储引擎。
MySQL 8.0.25 默认支持的存储引擎如下
1 | show engines; |
[
1.6 存储层
所有的数据,数据库、表的定义,表的每一行的内容,索引,都是存在文件系统上,以文件的方式存在的,并完成与存储引擎的交互。当然有些存储引擎比如InnoDB,也支持不使用文件系统直接管理裸设备,但现代文件系统的实现使得这样做没有必要了。在文件系统之下,可以使用本地磁盘,可以使用DAS、NAS、SAN等各种存储系统。
1.7 小结
MySQL架构图本节开篇所示。下面为了熟悉SQL执行流程方便,我们可以简化如下:
[
简化为三层结构:
- 连接层:客户端和服务器端建立连接,客户端发送 SQL 至服务器端;
- SQL 层(服务层):对 SQL 语句进行查询处理;与数据库文件的存储方式无关;
- 存储引擎层:与数据库文件打交道,负责数据的存储和读取。
2. SQL执行流程
2.1 MySQL中的SQL执行流程
MySQL的查询流程:
1)查询缓存
Server如果在查询缓存中发现了这条SQL语句,就会直接将结果返回给客户端;如果没有,就进入到解析器阶段。需要说明的是,因为查询缓存往往效率不高,所以在MySQL8.0之后就抛弃了这个功能。
MySQL拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以key-value对的形式,被直接缓存在内存中。key是查询的语句,value是查询的结果。如果查询能够直接在这个缓存中找到key,那么这个value就会被直接返回给客户端。如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。所以,如果查询命中缓存,MySQL不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。
1.1) 大多数情况查询缓存就是个鸡肋,为什么呢?
查询缓存是提前把查询结果缓存起来,这样下次不需要执行就可以直接拿到结果。
需要说明的是,在MySQL中的查询缓存,不是缓存查询计划,而是查询对应的结果。这就意味着查询匹配的鲁棒性大大降低,只有相同的查询操作才会命中查询缓存。两个查询请求在任何字符上的不同(例如:空格、注释、大小写),都会导致缓存不会命中。因此MySQL的查询缓存命中率不高。
鲁棒性(Robustness)是指系统、程序或算法在面临不确定性、异常情况或错误输入时,依然能够正确运行或保持一定性能的能力。
同时,如果查询请求中包含某些系统函数、用户自定义变量和函数、一些系统表,如 mysql、 information_schema、 performance_schema 数据库中的表,那这个请求就不会被缓存。以某些系统函数举例,可能同样的函数的两次调用会产生不一样的结果,比如函数NOW,每次调用都会产生最新的当前时间,如果在一个查询请求中调用了这个函数,那即使查询请求的文本信息都一样,那不同时间的两次查询也应该得到不同的结果,如果在第一次查询时就缓存了,那第二次查询的时候直接使用第一次查询的结果就是错误的!
此外,既然是缓存,那就有它缓存失效的时候。MySQL的缓存系统会监测涉及到的每张表,只要该表的结构或者数据被修改,如对该表使用了INSERT、UPDATE、DELETE、TRUNCATE TABLE、ALTER TABLE、DROP TABLE或DROP DATABASE语句,那使用该表的所有高速缓存查询都将变为无效并从高速缓存中删除!对于更新压力大的数据库来说,查询缓存的命中率会非常低。
总之,因为查询缓存往往弊大于利,查询缓存的失效非常频繁。
1.2) 查询缓存的使用建议
一般建议在静态表里使用查询缓存,什么叫静态表呢?就是一般极少更新的表。比如,一个系统配置表、字典表,这张表上的查询才适合使用查询缓存。好在MySQL也提供了这种“按需使用”的方式。你可以将my.cnf参数query_cache_type设置成DEMAND,代表当sql语句中有SQL_CACHE关键词时才缓存。比如:
1 | query_cache_type 有3个值。 0代表关闭查询缓存OFF,1代表开启ON,2代表(DEMAND) |
这样对于默认的SQL语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用SQL_CACHE显式指定,像下面这个语句一样:
1 | SELECT SQL_CACHE column1, column2, ... |
如果缓存处于开启,也可以使用SQL_NO_CACHE来显示指明不使用缓存
查看当前mysq实例是否开启缓存机制
1 | MySQL5.7中 |
监控查询缓存的命中率
1 | show status like '%Qcache%'; |
运行结果解析:
Qcache_free_blocks:表示查询缓存中还有多少剩余的blocks,如果该值显示较大,则说明查询缓存中的内存碎片过多了,可能在一定的时间进行整理。Qcache_free_memory:查询缓存的内存大小,通过这个参数可以很清晰的知道当前系统的查询内存是否够用,是多了,还是不够用,DBA可以根据实际情况做出调整。Qcache_hits:表示有多少次命中缓存。可以通过该值来验证查询缓存的效果。数字越大,缓存效果越理想。Qcache_inserts:表示多少次未命中然后插入,意思是新来的SQL请求在缓存中未找到,不得不执行查询处理,执行查询处理后把结果insert到查询缓存中。这样的情况的次数越多,表示查询缓存应用到的比较少,效果也就不理想。当然系统刚启动后,查询缓存是空的,这很正常。Qcache_lowmem_prunes:该参数记录有多少条查询因为内存不足而被移除出查询缓存。通过这个值,用户可以适
当的调整缓存大小。Qcache_not_cached:表示因为query_cache_type的设置而没有被缓存的查询数量。Qcache_queries_in_cache:当前缓存中缓存的查询数量。Qcache_total_blocks:当前缓存的block数量。
2)解析器
在解析器中对SQL语句进行语法分析、语义分析。
如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL需要知道你要做什么,因此需要对SQL语句做解析。SQL语句的分析分为词法分析与语法分析。
分析器先做词法分析。你输入的是由多个字符串和空格组成的一条SQL语句,MySQL需要识别出里面的字符串分别是什么,代表什么。
MySQL 从你输入的"select"这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。
接着,要做“语法分析”。根据词法分析的结果,语法分析器(比如:Bison)会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。
如果语句不对,就会收到“You have an error in your SQL syntax”的错误提醒。
如果SQL语句正确,则会生成一个语法树
如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL需要知道你要做什么,因此需要对SQL语句做解析。SQL语句的分析分为词法分析与语法分析。
分析器先做“ 词法分析 ”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面 的字符串分别是什么,代表什么。
MySQL 从你输入的"select"这个关键字识别出来,这是一个查询语 句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。
接着,要做“ 语法分析 ”。根据词法分析的结果,语法分析器(比如:Bison)会根据语法规则,判断你输 入的这个 SQL 语句是否 满足 MySQL 语法 。
select department_id,job_id, avg(salary) from employees group by department_id;
如果SQL语句正确,则会生成一个这样的语法树:

下图是SQL分词分析的过程步骤:

至此解析器的工作任务也基本圆满了。接下来进入到优化器。
3)优化器
优化器的作用
在优化器中会确定SQL语句的执行路径,比如是根据全表检索,还是根据索引检索等。
经过了解析器,MySQL就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。
比如:优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序,还有表达式简化、子查询转为连接、外连接转为内连接等。
举例:如下语句是执行两个表的join:
1 | select * from test1 join test2 using(ID) |
1 | 方案1:可以先从表test1里面取出name=’zhangwei’的记录的ID值,再根据ID值关联到表test2,再判断test2里面name的值是否等于’mysql高级课程’。 |
逻辑查询优化和物理查询优化
在查询优化器中,可以分为逻辑查询优化阶段和物理查询优化阶段。
逻辑查询优化就是通过改变SQL语句的内容来使得SQL查询更高效,同时为物理查询优化提供更多的候选执行计划。通常采用的方式是对SQL语句进行等价变换,对查询进行重写,而查询重写的数学基础就是关系代数。对条件表达式进行等价谓词重写、条件简化,对视图进行重写,对子查询进行优化,对连接语义进行了外连接消除、嵌套连接消除等。
物理查询优化是基于关系代数进行的查询重写,而关系代数的每一步都对应着物理计算,这些物理计算往往存在多种算法,因此需要计算各种物理路径的代价,从中选择代价最小的作为执行计划。在这个阶段里,对于单表和多表连接的操作,需要高效地使用索引,提升查询效率。
4)执行器
在执行之前需要判断该用户是否具备权限。如果没有,就会返回权限错误。如果具备权限,就执行 SQL查询并返回结果。在MySQL8.0以下的版本,如果设置了查询缓存,这时会将查询结果进行缓存。
如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,调用存储引擎API对表进行的读写。存储引擎API只是抽象接口,下面还有个存储引擎层,具体实现还是要看表选择的存储引擎。
1 | select * from test where id=1; |
比如:表 test 中,ID 字段没有索引,那么执行器的执行流程是这样的:
1 | 调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是1,如果不是则跳过,如果是则将这行存在结果集中; |
5)小结
SQL语句在MySQL中的流程是:SQL语句→查询缓存→解析器→优化器→执行器。
[
2.2 MySQL8中SQL执行原理
一条SQL语句会经历不同的模块,在不同的模块中,SQL执行所使用的资源(时间)不同。
下面通过命令行分析SQL语句的执行经过的模块和占用的时间。
1) 查看profiling 是否开启
1 | select @@profiling; |
profiling=0 代表关闭,需要把 profiling 打开,即设置为 1:
1 | set profiling=1; |
2) 多次执行相同SQL查询
1 | select * from employees; |
3) 查看profiles
1 | show profiles; # 显示最近的几次查询 |
4) 查看profile
显示执行计划,查看程序的执行步骤
1 | # 默认查询最后一次的执行过程(show profiles返回的Query_ID为7) |
checking permissions权限检查Opening tables打开表init初始化System lock锁系统optimizing优化查询preparing准备executing执行
1 | # 查询更丰富的内容 |
除了查看cpu、io阻塞等参数情况,还可以查询下列参数的利用情况
1 | Syntax: |
2.3 MySQL5.7中SQL执行分析
在MySQL5.7中测试,显式开启查询缓存模式。
1)在/etc/my.cnf配置文件中开启查询缓存
1 | query_cache_type=1 |
2)重启服务器
1 | systemctl restart mysqld |
3)开启查询执行计划
1 | set profiling=1; |
4)执行语句两次
1 | select * from employees; |
5)查看profiles
1 | mysql> show profiles; |
6)查看profile
1 | mysql> show profile for query 1; |
从结果中可以看出查询语句直接从缓存中获取数据。
3. 数据库缓冲池(buffer pool)
InnoDB存储引擎是以页为单位来管理存储空间的,增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。而磁盘I/O需要消耗的时间很多,而在内存中进行操作,效率则会高很多,为了能让数据表或者索引中的数据随时被我们所用,DBMS会申请占用内存来作为数据缓冲池,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。
这样做的好处是可以让磁盘活动最小化,从而减少与磁盘直接进行I/O的时间。这种策略对提升 SQL 语句的查询性能来说至关重要。如果索引的数据在缓冲池里,那么访问的成本就会降低很多。
3.1 缓冲池 vs 查询缓存
1)缓冲池(Buffer Pool)
在InnoDB存储引擎中有一部分数据会放到内存中,缓冲池则占了这部分内存的大部分,它用来存储各种数据的缓存
InnoDB缓冲池包括了数据页、索引页、插入缓存、锁信息、自适应Hash和数据字典信息等。
缓存池的重要性:
对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是InnoDB对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。但是各位也都知道,磁盘的速度慢的跟乌龟一样,怎么能配得上“快如风,疾如电"的CPU呢?这里,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟。所以InnoDB存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。
缓存原则:
“位置 * 频次”这个原则,对I/O访问效率进行优化。
首先,位置决定效率,提供缓冲池就是为了在内存中可以直接访问数据。
其次,频次决定优先级顺序。因为缓冲池的大小是有限的,比如磁盘有 200G,但是内存只有 16G,缓冲池大小只有 1G,就无法将所有数据都加载到缓冲池里,这时就涉及到优先级顺序,会优先对使用频次高的热数据进行加载。
缓冲池的预读特性:
缓冲池的作用就是提升 I/O 效率,而我们进行读取数据的时候存在一个“局部性原理”,也就是说我们使用了一些数据,大概率还会使用它周围的一些数据,因此采用“预读”的机制提前加载,可以减少未来可能的磁盘 I/O 操作。
2)查询缓存
查询缓存是提前把查询结果缓存起来,这样下次不需要执行就可以直接拿到结果。需要说明的是,在MySQL中的查询缓存,不是缓存查询计划,而是查询对应的结果。因为命中条件苛刻,而且只要数据表发生变化,查询缓存就会效,因此命中率低。
缓冲池服务于数据库整体的/O操作,它们的共同点都是通过缓存的机制来提升效率。
3.2 缓冲池如何读取数据
缓冲池管理器会尽量将经常使用的数据保存起来,在数据库进行页面读操作的时候,首先会判断该页面是否在缓冲池中,如果存在就直接读取,如果不存在,就会通过内存或磁盘将页面存放到缓冲池中再进行读取。
缓存在数据库中的结构和作用如下图所示:

如果我们执行SQL语句的时候更新了缓存池中的数据,那么这些数据会马上同步到磁盘上吗?
实际上,当我们对数据库中的记录进行修改的时候,首先会修改缓冲池中页里面的记录信息,然后数据库会以一定的频率刷新到磁盘中。注意并不是每次发生更新操作,都会立即进行磁盘回写。缓冲池会采用一种叫做 checkpoint 的机制 将数据回写到磁盘上,这样做的好处就是提升了数据库的整体性能。
比如,当缓冲池不够用时,需要释放掉一些不常用的页,此时就可以强行采用checkpoint的方式,将不常用的脏页回写到磁盘上,然后再从缓存池中将这些页释放掉。这里的脏页 (dirty page) 指的是缓冲池中被修改过的页,与磁盘上的数据页不一致。
3.3 查看/设置缓冲池的大小
Mysql MyISAM存储引擎只缓存索引,不缓存数据,对应的键缓存参数为key_buffer_size
InnoDB存储引擎,可以通过查看innodb_buffer_pool_size变量来查看缓冲池的大小。
查看缓冲池的大小
1 | show variables like 'innodb_buffer_pool_size'; |
此时InnoDB的缓冲池大小是134217728/1024/1024=128MB
设置缓冲池的大小
1 | set global innodb_buffer_pool_size = 268435456; |
或者
1 | [server] |
3.4 多个Buffer Pool实例
Buffer Pool本质是 InnoDB 向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的数据都需要加锁处理。在Buffer Pool特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表。所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。
可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数
1 | [server] |
表明创建2个 Buffer Pool 实例
查看缓冲池的个数
1 | show variables like 'innodb_buffer_pool_instances'; |
每个Buffer Pool实例实际占内存空间
1 | innodb_buffer_pool_size/innodb_buffer_pool_instances |
总共的大小除以实例的个数,结果就是每个Buffer Pool实例占用的大小。
不过也不是说 Buffer Pool 实例创建的越多越好,分别管理各个Buffer Pool也是需要性能开销的,InnDB规定:当innodb_buffer_pool_size的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances的值修改为1。建议在 Buffer Pool 大于等于1G的时候设置多个Buffer Pool实例。
3.5 引申问题
Buffer Pool是MySQL内存结构中十分核心的一个组成,可以先把它想象成一个黑盒子
黑盒下的更新数据流程
查询数据的时候,会先去Buffer Pool中查询。如果Buffer Pool中不存在,存储引擎会先将数据从磁盘加载到Buffer Pool中,然后将数据返回给客户端;同理,当我们更新某个数据的时候,如果这个数据不存在于 Buffer Pool,同样会先数据加载进来,然后修改内存的数据。被修改的数据会在之后统一刷入磁盘。

这个过程看似没啥问题,实则是有问题的。假设我们修改Buffer Pool中的数据成功,但是还没来得及将数据刷入磁盘MySQL就挂了怎么办?按照上图的逻辑,此时更新之后的数据只存在于Buffer Pool中,如果此时MySQL宕机了,这部分数据将会永久地丢失;
再者,更新到一半突然发生错误了,想要回滚到更新之前的版本,该怎么办?连数据持久化的保证、事务回滚都做不到还谈什么崩溃恢复?
答案:Redo Log & Undo Log
第05章 存储引擎
为了管理方便,人们把连接管理、查询缓存、语法解析、查询优化这些并不涉及真实数据存储的功能划分为MySQL Server的功能,把真实存取数据的功能划分为存储引擎的功能。所以在MySQL Server完成了查询优化后,只需按照生成的执行计划调用底层存储引擎提供的API,获取到数据后返回给客户端就好了。
MySQL中提到了存储引擎的概念。简而言之,存储引擎就是指表的类型。其实存储引擎以前叫做表处理器,后来改名为存储引擎,它的功能就是接收上层传下来的指令,然后对表中的数据进行提取或写入操作。
1. 查看存储引擎
1 | show engines; |

查询结果显示,MySQL8支持9种存储引擎,分别为MEMORY、MRG_MYISAM、CSV、FEDERATED、PERFORMANCE_SCHEMA、MyISAM、InnoDB、BLACKHOLE和ARCHIVE。
Engine参数表示存储引擎名称。Support参数表示MySQL数据库管理系统是否支持该存储引擎:YES表示支持,NO表示不支持。DEFAULT表示系统默认支持的存储引擎。Comment参数表示对存储引擎的评论。Transactions参数表示存储引擎是否支持事务:YES表示支持,NO表示不支持。XA参数表示存储引擎所支持的分布式是否符合XA规范:YES表示支持,NO表示不支持。代表着该存储引擎是否支持分布式事务。Savepoints参数表示存储引擎是否支持事务处理的保存点:YES表示支持,NO表示不支持。也就是说,该存储引擎是否支持部分事务回滚。
2. 设置系统默认的存储引擎
- 查看默认的存储引擎:
1 | show variables like '%storage_engine%'; |
- 修改默认的存储引擎
如果在创建表的语句中没有显式指定表的存储引擎的话,那就会默认使用InnoDB作为表的存储引擎。
1 | SET DEFAULT_STORAGE_ENGINE=MyISAM; |
或者修改my.cnf文件:
1 | default-storage-engine=MyISAM |
1 | 重启服务 |
3. 设置表的存储引擎
存储引擎是负责对表中的数据进行提取和写入工作的,我们可以为不同的表设置不同的存储引擎,也就是说不同的表可以有不同的物理存储结构,不同的提取和写入方式。
创建表时指定存储引擎
1 | CREATE TABLE 表名( |
修改表的存储引擎
1 | ALTER TABLE 表名 ENGINE = 存储引擎名称; |
查看表的存储引擎
1 | SHOW CREATE TABLE <table_name>; |
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
不支持事务、行级锁、外键,有一个毫无疑问的缺陷就是崩溃后无法安全恢复。 5.5之前默认的存储引擎- 优势是访问的
速度快,对事务完整性没有要求或者以SELECT、INSERT为主的应用 - 针对数据统计有额外的常数存储。故而 count(*) 的查询效率很高
- 数据文件结构:
- 表名.frm 存储表结构
- 表名.MYD 存储数据 (MYData)
- 表名.MYI 存储索引 (MYIndex)
- 应用场景:只读应用或者以读为主的业务
4.3 Archive 引擎:用于数据存档
archive是归档的意思,仅仅支持插入和查询两种功能(行被插入后不能再修改)- 在MySQL5.5以后
支持索引功能 - 拥有很好的压缩机制,使用
zlib压缩库,在记录请求的时候实时的进行压缩,经常被用来作为仓库使用 - 创建ARCHIVE表时,存储擎会创建名称以表名开头的文件。数据文件的扩展名为
.ARZ - 根据英文的测试结论来看,同样数据量下,
Archive表比MyISAM表要小大约75%,比支持事务处理的InnoDB表小大约83% - ARCHIVE存储引擎采用了
行级锁。该ARCHIVE引擎支持AUTO_INCREMENT列属性。AUTO_INCREMENT列可以具有唯一索引或非唯一索引。尝试在任何其他列上创建索引会导致错误 - Archive表
适合日志和数据采集(档案)类应用;适合存储大量的独立的作为历史记录的数据。拥有很高的插入速度,但是对查询的支持较差 - 下表展示了ARCHIVE存储引擎功能
| 特征 | 支持 |
|---|---|
| B树索引 | 不支持 |
备份/时间点恢复(在服务器中实现,而不是在存储引擎中) |
支持 |
| 集群数据库 | 不支持 |
| 聚集索引 | 不支持 |
压缩数据 |
支持 |
| 数据缓存 | 不支持 |
| 加密数据(加密功能在服务器中实现) | 支持 |
| 外键 | 不支持 |
| 全文检索索引 | 不支持 |
| 地理空间数据类型 | 支持 |
| 地理空间索引 | 不支持 |
| 哈希索引 | 不支持 |
| 索引缓存 | 不支持 |
锁粒度 |
行锁 |
| MVCC | 不支持 |
| 存储限制 | 没有任何限制 |
| 交易 | 不支持 |
更新数据字典的统计信息 |
支持 |
4.4 Blackhole 引擎:丢弃写操作,读操作会返回空内容
- Blackhole引擎没有实现任何存储机制,它会
丢弃所有插入的数据,不做任何保存。 - 但服务器会记录Blackhole表的日志,所以可以用于复制数据到备库,或者简单地记录到日志。但这种应用方式会碰到很多问题,因此并不推荐。
4.5 CSV 引擎:存储数据时,以逗号分隔各个数据项
- CSV引擎可以将
普通的CSV文件作为MySQL的表来处理,但不支持索引 - CSV引擎可以作为一种
数据交换的机制,非常有用 - CSV存储的数据直接可以在操作系统里,用文本编辑器,或者excel读取。
- 对于数据的快速导入、导出是有明显优势的。
创建CSV表时,服务器会创建一个纯文本数据文件,其名称以表名开头并带有.CSV扩展名。当你将数据存储到表中时,存储引擎将其以逗号分隔值格式保存到数据文件中。
1 | CREATE TABLE test (i INT NOT NULL, c CHAR(10) NOT NULL) ENGINE = CSV; |
创建CSV表还会创建相应的元文件,用于存储表的状态和表中存在的行数。此文件的名称与表的名称相同,后缀为CSM。
1 | test.CSM test.CSV |
创建使用CSV引擎的表,表的字段必须声明为NOT NULL
4.6 Memory 引擎:置于内存的表
概述:
Memory采用的逻辑介质是内存, 响应速度很快,但是当mysqld守护进程崩溃的时候数据会丢失。另外,要求存储的数据是数据长度不变的格式,比如,Blob和Text类型的数据不可用(长度不固定的)。
主要特征
-
Memory同时
支持哈希(HASH)索引和B+树索引。 -
- 哈希索引相等的比较快,但是对于范围的比较慢很多
默认使用哈希(HASH)索引,其速度要比使用B型树(BTREE)索引快- 如果希望使用B树索引,可以在创建索引时选择使用。
-
Memory表至少比MyISAM表要
快一个数量级。 -
MEMORY
表的大小是受到限制的。表的大小主要取决于两个参数,分别是max_rows和max_heap_table_size其中,max_rows可以在创建表时指定;max_heap_table_size的大小默认为16MB,可以按需要进行扩大。 -
数据文件与索引文件分开存储。
-
- 每个基于MEMORY存储引擎的表实际对应一个磁盘文件,该文件的文件名与表名相同,类型为
frm类型,该文件中只存储表的结构,而其数据文件都是存储在内存中的 - 这样有利于数据的快速处理,提供整个表的处理效率。
- 每个基于MEMORY存储引擎的表实际对应一个磁盘文件,该文件的文件名与表名相同,类型为
-
缺点:其数据易丢失,生命周期短。基于这个缺陷,选择MEMORY存储引擎时需要特别小心。
使用Memory存储引擎的场景
-
目标数据比较小,而且非常频繁的进行访问,在内存中存放数据,如果太大的数据会造成内存溢出。可以通过参数max_heap_table_size控制Memory表的大小,限制Memory表的最大的大小。 -
如果
数据是临时的,而且必须立即可用得到,那么就可以放在内存中。 -
存储在Memory表中的数据如果
突然间丢失的话也没有太大的关系。
4.7 Federated 引擎:访问远程表
Federated引擎是访问其他MySQL服务器的一个代理,尽管该引擎看起来提供了一种很好的跨服务器的灵活性,但也经常带来问题,因此默认是禁用的。
4.8 Merge引擎:管理多个MyISAM表构成的表集合
4.9 NDB引擎:MySQL集群专用存储引擎
也叫做NDB Cluster存储引擎,主要用于MySQL Cluster 分布式集群环境,类似于Oracle的RAC集群。
4.10 引擎对比
MySQL中同一个数据库,不同的表可以选择不同的存储引擎。如下表对常用存储引擎做出了对比。
| 特点 | MyISAM | InnoDB | MEMORY | MERGE | NDB |
|---|---|---|---|---|---|
| 存储限制 | 有 | 64TB | 有 | 没有 | 有 |
事务安全 |
支持 | ||||
锁机制 |
表锁,即使操作一条记录也会锁住整个表,不适合高并发的操作 | 行锁,操作时只锁某一行,不对其它行有影响,适合高并发的操作 | 表锁 | 表锁 | 行锁 |
| B树索引 | 支持 | 支持 | 支持 | 支持 | 支持 |
| 哈希索引 | 支持 | 支持 | |||
| 全文索引 | 支持 | ||||
| 集群索引 | 支持 | 支持 | |||
| 数据缓存 | 支持 | 支持 | 支持 | ||
索引缓存 |
只缓存索引,不缓存真实数据 | 不仅缓存索引还要缓存真实数据,对内存要求较高,而且内存大小对性能有决定性的影响 | 支持 | 支持 | 支持 |
| 数据可压缩 | 支持 | ||||
| 空间使用 | 低 | 高 | N/A | 低 | 低 |
| 内存使用 | 低 | 高 | 中等 | 低 | 高 |
| 批量插入的速度 | 高 | 低 | 高 | 高 | 高 |
支持外键 |
支持 |
5. MyISAM和InnoDB
MySQL5.5之前的默认存储引擎是MyISAM,5.5之后改为了InnoDB。
首先对于InnoDB存储引擎,提供了良好的事务管理、崩溃修复能力和并发控制。因为InnoDB存储引擎支持事务,所以对于要求事务完整性的场合需要选择InnoDB,比如数据操作除了插入和查询以外还包含有很多更新、删除操作,像财务系统等对数据准确性要求较高的系统。缺点是其读写效率稍差,占用的数据空间相对比较大。
其次对于MyISAM存储引擎,如果是小型应用,系统以读操作和插入操作为主,只有很少的更新、删除操作,并且对事务的要求没有那么高,则可以选择这个存储引擎。MyISAM存储引擎的优势在于占用空间小,处理速度快;缺点是不支持事务的完整性和并发性。
这两种引擎各有特点,当然你也可以在MySQL中,针对不同的数据表,可以选择不同的存储引擎。
| 对比项 | MyISAM | InnoDB |
|---|---|---|
| 外键 | 不支持 | 支持 |
| 事务 | 不支持 | 支持 |
| 行表锁 | 表锁,即使操作一条记录也会锁住整个表,不适合高并发的操作 | 行锁,操作时只锁某一行,不对其它行有影响,适合高并发的操作 |
| 缓存 | 只缓存索引,不缓存真实数据 | 不仅缓存索引还要缓存真实数据,对内存要求较高,而且内存大小对性能有决定性的影响 |
| 自带系统表使用 | Y | N |
| 关注点 | 性能:节省资源、消耗少、简单业务 | 事务:并发写、事务、更大资源 |
| 默认安装 | Y | Y |
| 默认使用 | N | Y |
6.知识补充
InnoDB表的优势
InnoDB存储引擎在实际应用中拥有诸多优势,比如操作便利、提高了数据库的性能、维护成本低等。如果由于硬件或软件的原因导致服务器崩溃,那么在重启服务器之后不需要进行额外的操作。InnoDB崩溃恢复功能自动将之前提交的内容定型,然后撤销没有提交的进程,重启之后继续从崩溃点开始执行。
InnoDB存储引擎在主内存中维护缓冲池,高频率使用的数据将在内存中直接被处理。这种缓存方式应用于多种信息,加速了处理进程。
在专用服务器上,物理内存中高达80%的部分被应用于缓冲池。如果需要将数据插入不同的表中,可以设置外键加强数据的完整性。更新或者删除数据,关联数据将会被自动更新或删除。如果试图将数据插入从表,但在主表中没有对应的数据,插入的数据将被自动移除。如果磁盘或内存中的数据出现崩溃,在使用脏数据之前,校验和机制会发出警告。当每个表的主键都设置合理时,与这些列有关的操作会被自动优化。插入、更新和删除操作通过做改变缓冲自动机制进行优化。InnoDB不仅支持当前读写,也会缓冲改变的数据到数据流磁盘
InnoDB的性能优势不只存在于长时运行查询的大型表。在同一列多次被查询时,自适应哈希索引会提高查询的速度。使用InnoDB可以压缩表和相关的索引,可以在不影响性能和可用性的情况下创建或删除索引。对于大型文本和BLOB数据,使用动态行形式,这种存储布局更高效。通过查询INFORMATION_SCHEMA库中的表可以监控存储引擎的内部工作。在同一个语句中,InnoDB表可以与其他存储引擎表混用。即使有些操作系统限制文件大小为2GB,InnoDB仍然可以处理。当处理大数据量时,InnoDB兼顾CPU,以达到最大性能
InnoDB和ACID模型
ACID模型是一系列数据库设计规则,这些规则着重强调可靠性,而可靠性对于商业数据和任务关键型应用非常重要。MySQL包含类似InnoDB存储引擎的组件,与ACID模型紧密相连,这样出现意外时,数据不会崩溃,结果不会失真。如果依赖ACID模型,可以不使用一致性检查和崩溃恢复机制。如果拥有额外的软件保护,极可靠的硬件或者应用可以容忍一小部分的数据丢失和不一致,可以将MySQL设置调整为只依赖部分ACID特性,以达到更高的性能。下面讲解InnoDB存储引擎与ACID模型相同作用的四个方面。
原子方面
ACID的原子方面主要涉及InnoDB事务,与MySQL相关的特性主要包括:
- 自动提交设置
- COMMIT语句
- ROLLBACK语句
- 操作INFORMATION_SCHEMA库中的表数据
一致性方面
ACID模型的一致性主要涉及保护数据不崩溃的内部InnoDB处理过程,与MySQL相关的特性主要包括:
- InnoDB双写缓存
- InnoDB崩溃恢复
隔离方面
隔离是应用于事务的级别,与MySQL相关的特性主要包括:
- 自动提交设置
- SET ISOLATION LEVEL语句
- InnoDB锁的低级别信息
耐久性方面
ACID模型的耐久性主要涉及与硬件配置相互影响的MySQL软件特性。由于硬件复杂多样化,耐久性方面没有具体的规则可循。与MySQL相关的特性有:
- InnoDB双写缓存,通过innodb_doublewrite配置项配置
- 配置项innodb_flush_log_at_trx_commit
- 配置项sync_binlog
- 配置项innodb_file_per_table
- 存储设备的写入缓存
- 存储设备的备用电池缓存
- 运行MySQL的操作系统
- 持续的电力供应
- 备份策略
- 对分布式或托管的应用,最主要的在于硬件设备的地点以及网络情况
InnoDB架构
缓冲池 缓冲池是主内存中的一部分空间,用来缓存已使用的表和索引数据。缓冲池使得经常被使用的数据能够直接在内存中获得,从而提高速度
更改缓存 更改缓存是一个特殊的数据结构,当受影响的索引页不在缓存中时,更改缓存会缓存辅助索引页的更改。索引页被其他读取操作时会加载到缓存池,缓存的更改内容就会被合并。不同于集群索引,辅助索引并非独一无二的。当系统大部分闲置时,清除操作会定期运行,将更新的索引页刷入磁盘。更新缓存合并期间,可能会大大降低查询的性能。在内存中,更新缓存占用一部分InnoDB缓冲池。在磁盘中,更新缓存是系统表空间的一部分。更新缓存的数据类型由innodb_change_buffering配置项管理
自适应哈希索引 自适应哈希索引将负载和足够的内存结合起来,使得InnoDB像内存数据库一样运行,不需要降低事务上的性能或可靠性。这个特性通过innodb_adaptive_hash_index选项配置,或者通过--skip-innodb_adaptive_hash_index命令行在服务启动时关闭
重做日志缓存 重做日志缓存存放要放入重做日志的数据。重做日志缓存大小通过innodb_log_buffer_size配置项配置。重做日志缓存会定期地将日志文件刷入磁盘。大型的重做日志缓存使得大型事务能够正常运行而不需要写入磁盘
系统表空间 系统表空间包括InnoDB数据字典、双写缓存、更新缓存和撤销日志,同时也包括表和索引数据。多表共享,系统表空间被视为共享表空间
双写缓存 双写缓存位于系统表空间中,用于写入从缓存池刷新的数据页。只有在刷新并写入双写缓存后,InnoDB才会将数据页写入合适的位置
撤销日志 撤销日志是一系列与事务相关的撤销记录的集合,包含如何撤销事务最近的更改。如果其他事务要查询原始数据,可以从撤销日志记录中追溯未更改的数据。撤销日志存在于撤销日志片段中,这些片段包含于回滚片段中
每个表一个文件的表空间 每个表一个文件的表空间是指每个单独的表空间创建在自身的数据文件中,而不是系统表空间中。这个功能通过innodb_file_per_table配置项开启。每个表空间由一个单独的.ibd数据文件代表,该文件默认被创建在数据库目录中
通用表空间 使用CREATE TABLESPACE语法创建共享的InnoDB表空间。通用表空间可以创建在MySQL数据目录之外能够管理多个表并支持所有行格式的表
撤销表空间 撤销表空间由一个或多个包含撤销日志的文件组成。撤销表空间的数量由innodb_undo_tablespaces配置项配置
临时表空间 用户创建的临时表空间和基于磁盘的内部临时表都创建于临时表空间。innodb_temp_data_file_path配置项定义了相关的路径、名称、大小和属性。如果该值为空,默认会在innodb_data_home_dir变量指定的目录下创建一个自动扩展的数据文件
重做日志 重做日志是基于磁盘的数据结构,在崩溃恢复期间使用,用来纠正数据。正常操作期间,重做日志会将请求数据进行编码,这些请求会改变InnoDB表数据。遇到意外崩溃后,未完成的更改会自动在初始化期间重新进行
7.阿里巴巴、淘宝用哪个
| 产品 | 价格 | 目标 | 主要功能 | 是否可投入生产? |
|---|---|---|---|---|
| Percona Server | 免费 | 提供XtraDB存储引擎的包装器 和其他分析工具 |
XtraDB | 是 |
| MariaDB | 免费 | 扩展MySQL以包含XtraDB和其他性能改进 | XtraDB | 是 |
| Drizzle | 免费 | 提供比 MySQL 更强大的可扩展性和性能改进 | 高可用性 | 是 |
Percona为MySQL数据库服务器进行了改进,在功能和性能上较MySQL有很显著的提升。- 该版本提升了在高负载情况下的InnoDB的性能、为DBA提供一些非常有用的性能诊断工具;另外有更多的参数和命令来控制服务器行为。
- 该公司新建了一款存储引擎叫
Xtradb完全可以替代Innodb,并且在性能和并发上做得更好 - 阿里巴巴大部分mysql数据库其实使用的
perconal的原型加以修改。
第06章 索引的数据结构
1. 索引及其优缺点
1.1 索引概述
MySQL官方对索引的定义为:索引(Index)是帮助MySQL高效获取数据的数据结构。
**索引的本质:**索引是数据结构。可以简单理解为“排好序的快速查找数据结构”,满足特定查找算法。这些数据结构以某种方式指向数据,这样就可以在这些数据结构的基础上实现高级查找算法。
索引是在存储引擎中实现的,因此每种存储引擎的索引不一定完全相同,并且每种存储引擎不一定支持所有索引类型。同时,存储引擎可以定义每个表的最大索引数和最大索引长度。所有存储引擎支持每个表至少16个索引,总索引长度至少为256字节。有些存储引擎支持更多的索引数和更大的索引长度。
1.2 优点
(1)类似大学图书馆建书目索引,提高数据检索的效率,降低数据库的IO成本,这也是创建索引最主要的原因。
(2)通过创建唯一索引,可以保证数据库表中每一行数据的唯一性。
(3)在实现数据的参考完整性方面,可以加速表和表之间的连接。换句话说,对于有依赖关系的子表和父表联合查询时,可以提高查询速度。
(4)在使用分组和排序子句进行数据查询时,可以显著减少查询中分组和排序的时间,降低了CPU的消耗。
1.3 缺点
(1)创建索引和维护索引要耗费时间,并且随着数据量的增加,所耗费的时间也会增加。
(2)索引需要占磁盘空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间存储在磁盘上,如果有大量的索引,索引文件就可能比数据文件更快达到最大文件尺寸。
(3)虽然索引大大提高了查询速度,同时却会降低更新表的速度。当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。
索引可以提高查询的速度,但是会影响插入记录的速度。这种情况下,最好的办法是先删除表中的索引,然后插入数据,插入完成后再创建索引。
2. InnoDB中索引
1 | SELECT [列名列表] FROM 表名 WHERE 列名 = xxx; |
2.1 InnoDB索引方案
1)表的行格式
行格式指的是存储在表中的每一行数据的物理存储格式
1 | CREATE TABLE index_demo( |
这个新建的index_demo表中有2个INT类型的列,1个CHAR(1)类型的列,而且我们规定了c1列为主键,这个表使用Compact行格式来实际存储记录的。这里我们简化了index_demo表的行格式示意图:

record_type:记录头信息的一项属性,表示记录的类型,0表示普通记录、1表示目录项记录、2表示最小记录、3表示最大记录。next_record:记录头信息的一项属性,表示下一条地址相对于本条记录的地址偏移量,我们用箭头来表明下一条记录是谁。各个列的值:这里只记录在index_demo表中的三个列,分别是c1、c2和c3。其他信息:除了上述3种信息以外的所有信息,包括其他隐藏列的值以及记录的额外信息。
将记录格式示意图的其他信息项暂时去掉并把它竖起来的效果就是这样:

2)数据页
数据页
多条记录根据主键值,从小到大排列存放在一个数据页中
页目录
数据页内存在以主键值生成的Page Directory(页目录),从而在按照主键值进行查找时可以使用二分法来加快查询速度。
页分裂
当向一个已满的数据页插入新记录时,数据库系统会动态地将该页分裂成两个新的数据页,以便容纳新的数据记录
两个数据页之间以双向链表的方式连接
下一个数据页中用户记录的主键值大于上一个页中用户记录的主键值。
页编号
每个页有页编号,页与页之间以双向链表连接,数据页的编号不一定连续


3)目录页
为了快速定位记录所在的数据页,所以建立一个目录
目录项
每个数据页抽出一条记录,记录主键值和数据页编号等信息,作为一条目录项
目录项记录和普通的用户记录的区别:
目录项记录的record_type值是1,而普通用户记录的record_type值是0。- 目录项记录只有
主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列,另外还有InnoDB自己添加的隐藏列。 - 了解:记录头信息里还有一个叫
min_rec_mask的属性,只有在存储目录项记录的页中的主键值最小的目录项记录的min_rec_mask值为1,其他别的记录的min_rec_mask值都是0。
目录页
多条目录项组成一个目录页
记录目录项的页中也存在页目录,按照主键值进行查找时可以使用 二分法 来加快查询速度。

多个目录项记录的页
多个目录页之间以双向链表的方式连接

4)B+Tree
表中的数据非常多则会产生很多存储目录项记录的页,为了根据主键值快速定位一个存储目录项记录的页,那就为这些存储目录项记录的页再生成一个更高级的目录,就像是一个多级目录一样,大目录里嵌套小目录
最终生成的这个数据结构,它的名称是B+树。


一个B+树的节点其实可以分成好多层,规定最下边的那层,也就是存放我们用户记录的那层为第0层,之后依次往上加。之前我们做了一个非常极端的假设:存放用户记录的页最多存放3条记录,存放目录项记录的页最多存放4条记录。其实真实环境中一个页存放的记录数量是非常大的,假设所有存放用户记录的叶子节点代表的数据页可以存放100条用户记录,所有存放目录项记录的内节点代表的数据页可以存放1000条目录项记录,那么:
- 如果B+树只有1层,也就是只有1个用于存放用户记录的节点,最多能存放
100条记录。 - 如果B+树有2层,最多能存放
1000×100=10,0000条记录。 - 如果B+树有3层,最多能存放
1000×1000×100=1,0000,0000条记录。 - 如果B+树有4层,最多能存放
1000×1000×1000×100=1000,0000,0000条记录。相当多的记录!!!
你的表里能存放100000000000条记录吗?所以一般情况下,我们用到的B+树都不会超过4层,那我们通过主键值去查找某条记录最多只需要做4个页面内的查找(查找3个目录项页和一个用户记录页),又因为在每个页面内有所谓的Page Directory(页目录),所以在页面内也可以通过二分法实现快速定位记录。
2.2 常见索引概念
索引按照物理实现方式,索引可以分为2种:聚簇(聚集)和非聚簇(非聚集)索引。我们也把非聚集索引称为二级索引或者辅助索引。
聚簇索引
聚簇索引不仅是一种索引类型,也是一种数据存储方式(所有的用户记录都存储在了叶子结点),也就是所谓的索引即数据,数据即索引。
聚簇索引中的“聚簇”指的是索引中存储数据行的方式。
索引键和实际数据行存储在同一个结构中,这种结构称为聚簇
特点:
1)使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:
-
页内的记录是按照主键的大小顺序排成一个单向链表。 -
各个存放
用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。 -
存放
目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。
2)B+树的叶子节点存储的是完整的用户记录。
所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。
我们把具有这两种特性的B+树称为聚簇索引,所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引并不需要我们在MySQL语句中显式的使用INDEX语句去创建,InnoDB存储引擎会自动的为我们创建聚簇索引。
优点:
数据访问更快,因为聚簇索引将索引和数据保存在同一个B+树中,因此从聚簇索引中获取数据比非聚簇索引更快- 聚簇索引对于主键的
排序查找和范围查找速度非常快 - 按照聚簇索引排列顺序,查询显示一定范围数据的时候,由于数据都是紧密相连,数据库不用从多个数据块中提取数据,所以
节省了大量的io操作。
缺点:
插入速度严重依赖于插入顺序,按照主键的顺序插入是最快的方式,否则将会出现页分裂,严重影响性能。因此,对于InnoDB表,我们一般都会定义一个自增ID列为主键更新主键的代价很高,因为将会导致被更新的行移动。因此,对于InnoDB表,我们一般定义主键为不可更新二级索引访问需要两次索引查找,第一次找到主键值,第二次根据主键值找到行数据
限制:
- 对于MySQL数据库,目前只有InnoDB数据引擎支持聚簇索引,而MyISAM并不支持聚簇索引。
- 由于数据物理存储排序方式只能有一种,所以每个MySQL的
表只能有一个聚簇索引。一般情况下就是该表的主键。 - 如果没有定义主键,Innodb会选择
非空的唯一索引代替。如果没有这样的索引,Innodb会隐式的定义一个主键来作为聚簇索引。 - 为了充分利用聚簇索引的聚簇的特性,所以innodb表的主键列尽量
选用有序的顺序id,而不建议用无序的id,比如UUID, MD5, HASH, 字符串列作为主键无法保证数据的顺序增长。
二级索引(辅助索引、非聚簇索引)
因为每个MySQL的表只能有一个聚簇索引,一般情况下就是该表的主键
-
以表中的非主键列或者列组合建立的索引即为
二级索引当非主键列作为查询或者连表、排序等操作的条件时,为了提高效率,就可以以非主键列建立二级索引
-
二级索引不存储完整的行数据,只包含索引列及主键信息
因为每建立一颗B+树都把所有的用户记录再拷贝一遍,太浪费存储空间了。
-
因为数据和索引分开存储,所以二级索引属于非聚簇索引
-
概念:回表 根据二级索引的B+树只能确定要查找记录的主键值,如果想查找到完整的用户记录的话,仍然需要到
聚簇索引中再查一遍,这个过程称为回表。查询一条完整的用户记录需要使用到2棵B+树! -
二级索引的目录项,包含主键值,索引列,页号,从而保证目录项的唯一性
因为二级索引列的值是可以重复的,而主键是唯一的。
联合索引
联合索引同时以多个列的大小作为排序规则,也就是同时以多个列建立索引
例如B+树按照c2和c3列的大小进行排序,这个包含两层含义:
-
先把各个记录和页按照c2列进行排序。
-
在记录的c2列相同的情况下,采用c3列进行排序
以c2和c3列的大小为排序规则建立的B+树称为联合索引
联合索引可以是聚簇索引也可以是非聚簇索引,当联合索引包含主键列时,就会建立聚簇索引
2.3 InnoDB的B+树索引的注意事项
1)根页面位置万年不动
实际上B+树的形成过程:
- 每当为某个表创建一个B+树索引(聚簇索引不是人为创建的,默认就有)的时候,都会为这个索引创建一个
根节点页面。最开始表中没有数据的时候,每个B+树索引对应的根节点中既没有用户记录,也没有目录项记录。 - 随后向表中插入用户记录时,先把用户记录存储到这个
根节点中。 - 当根节点中的可用
空间用完时继续插入记录,此时会将根节点中的所有记录复制到一个新分配的页,比如页a中,然后对这个新页进行页分裂的操作,得到另一个新页,比如页b。这时新插入的记录根据键值(也就是聚簇索引中的主键值,二级索引中对应的索引列的值)的大小就会被分配到页a或者页b中,而根节点便升级为存储目录项记录的页。
这个过程特别注意的是:一个B+树索引的根节点自诞生之日起,便不会再移动。这样只要我们对某个表建立一个索引,那么它的根节点的页号便会被记录到某个地方,然后凡是InnoDB存储引擎需要用到这个索引的时候,都会从那个固定的地方取出根节点的页号,从而来访问这个索引。
2)内节点中目录项记录的唯一性
我们知道B+树索引的内节点中目录项记录的内容是索引列+页号的搭配,但是这个搭配对于二级索引来说有点不严谨。还拿index_demo表为例,假设这个表中的数据是这样的:
| c1 | c2 | c3 |
|---|---|---|
| 1 | 1 | ‘u’ |
| 3 | 1 | ‘d’ |
| 5 | 1 | ‘y’ |
| 7 | 1 | ‘a’ |
如果二级索引中目录项的内容只是索引号+页号的搭配的话,那么为c2列建立索引后的B+树应该长这样:

如果我们想要新插入一行记录,其中c1、c2、c3的值分别是:9、1、c,那么在修改这个为c2列建立的二级索引对应的B+树时便碰到了个大问题:由于页3中存储的目录项记录是由c2列+页号的值构成的,页3中的两条目录项记录对应的c2列的值都是1,那么我们这条新插入的记录到底应该放在页4中,还是应该放在页5中啊?答案是:对不起,懵了。
为了让新插入记录能找到自己在哪个页里,我们需要**保证在B+树的同一层内节点的目录项记录除页号这个字段以外是唯一的。**所以对于二级索引的内节点的目录项记录的内容实际上是由三个部分构成的:
- 索引列的值
- 主键值
- 页号
也就是我们把主键值也添加到二级索引内节点中的目录项记录了,这样就能保证B+树每一层节点中各条目录项记录除页号这个字段外是唯一的,所以我们为c2列建立二级索引后的示意图实际上应该是这样子的:

这样我们再插入记录(9, 1, 'c')时,由于页3中存储的目录项记录是由c2列+主键+页号的值构成的,可以先把新记录的c2列的值和页3中各目录项记录的c2列的值作比较,如果c2列的值相同的话,可以接着比较主键值,因为B+树同一层中不同目录项记录的c2列+主键的值肯定是不一样的,所以最后肯定能定位唯一的一条目录项记录,在本例中最后确定新记录应该被插入到页5中。
3)一个页面最少可以存储2条记录
一个B+树只需要很少的层级就可以轻松存储数亿条记录,查询速度相当不错!这是因为B+树本质上就是一个大的多层级目录,每经过一个目录时都会过滤掉许多无效的子目录,直到最后访问的存储真实数据的目录。那如果一个大的目录中只存放一个子目录是个啥效果呢?那就是目录层非常多,而且最后的那个存放真实数据的目录中存放一条记录。费了半天劲只能存放一条真实的用户记录?所以InnoDB的一个数据页至少可以存放两条记录
3. MyISAM中的索引方案
B树索引适用存储引擎如表所示:
| 索引/存储引擎 | MyISAM | InnoDB | Memory |
|---|---|---|---|
| B-Tree索引 | 支持 | 支持 | 支持 |
即使多个存储引擎支持同一种类型的索引,但是他们的实现原理也是不同的。Innodb和MyISAM默认的索引是Btree索引;而Memory默认的索引是Hash索引。
MyISAM引擎使用B+Tree作为索引结构,叶子节点的data域存放的是数据记录的地址。
MySQL官方,凡是写B—Tree的地方,都是B+树
3.1 MyISAM索引的原理
InnoDB中索引即数据,也就是聚簇索引的那棵B+树的叶子节点包含所有完整的用户记录,而MyISAM的索引方案虽然也使用树形结构,但是却将索引和数据分开存储:
- 将表中的记录
按照记录的插入顺序单独存储在一个文件中,称之为数据文件。这个文件并不划分为若干个数据页,有多少记录就往这个文件中塞多少记录。由于在插入数据的时候并没有刻意按照主键大小排序,所以并不能在这些数据上使用二分法进行查找。 - 使用
MyISAM存储引擎的表会把索引信息另外存储到一个称为索引文件的另一个文件中。MyISAM会单独为表的主键创建一个索引,索引的叶子节点中存储的不是完整的用户记录,而是主键值 + 数据记录地址的组合。

这里表一共有三列,假设我们以Col1为主键,上图是一个MyISAM表的主索引(Primary key)示意。可以看出
MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主键索引和二级索引(Secondary key)在结构上没有任何区别,只是主键索引要求key是唯一的,而二级索引的key可以重复。如果我们在Col2上建立一个二级索引,则此索引的结构如下图所示:

3.2 MyISAM与InnoDB对比
MyISAM的索引方式都是“非聚簇”的,与InnoDB包含1个聚簇索引是不同的。
小结两种引擎中索引的区别:
① 在InnoDB存储引擎中,我们只需要根据主键值对聚簇索引进行一次查找就能找到对应的记录,而在MyISAM中却需要进行一次回表操作,意味着MyISAM中建立的索引相当于全部都是二级索引。
② InnoDB的数据文件本身就是索引文件,而MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。
③ InnoDB的非聚簇索引data域存储相应记录主键的值,而MyISAM索引记录的是地址。换句话说,InnoDB的所有非聚簇索引都引用主键作为data域。
④ MyISAM的回表操作是十分快速的,因为是拿着地址偏移量直接到文件中取数据的,反观InnoDB是通过获取主键之后再去聚簇索引里找记录,虽然说也不慢,但还是比不上直接用地址去访问。
⑤ InnoDB要求表必须有主键(MyISAM可以没有)。如果没有显式指定,则MySQL系统会自动选择一个可以非空且唯一标识数据记录的列作为主键。如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整型。
小结:
了解不同存储引擎的索引实现方式对于正确使用和优化索引都非常有帮助。比如:
举例1:知道了InnoDB的索引实现后,就很容易明白为什么不建议使用过长的字段作为主键,因为所有二级索引都引用主键索引,过长的主键索引会令二级索引变得过大。数据页大小固定,数据项越大,单页存储的记录数越少,B+数的层级越多。
举例2:用非单调的字段作为主键在InnoDB中不是个好主意,因为InnoDB数据文件本身是一棵B+Tree,非单调的主键会造成在插入新记录时,数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。

4. 索引的代价
索引是个好东西,可不能乱建,它在空间和时间上都会有消耗:
空间上的代价
每建立一个索引都要为它建立一棵B+树,每一棵B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B+树由许多数据页组成,那就是很大的一片存储空间。
时间上的代价
每次对表中的数据进行增、删、改操作时,都需要去修改各个B+树索引。而且我们讲过,B+树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位,页面分裂、页面回收等操作来维护好节点和记录的排序。如果我们建了许多索引,每个索引对应的B+树都要进行相关的维护操作,会给性能拖后腿。
一个表上索引建的越多,就会占用越多的存储空间,在增删改记录的时候性能就越差。为了能建立又好又少的索引,我们得学学这些索引在哪些条件下起作用的。
5. MySQL数据结构选择的合理性
从MySQL的角度讲,不得不考虑一个现实问题就是磁盘IO。如果我们能让索引的数据结构尽量减少硬盘的IO操作,所消耗的时间也就越小。可以说,磁盘的I/O操作次数对索引的使用效率至关重要。
查找都是索引操作,一般来说索引非常大,尤其是关系型数据库,当数据量比较大的时候,索引的大小有可能几个G甚至更多,为了减少索引在内存的占用,数据库索引是存储在外部磁盘上的。当我们利用索引查询的时候,不可能把整个索引全部加载到内存,只能逐一加载,那么MySQL衡量查询效率的标准就是磁盘IO次数。
5.1 Hash结构
Hash 本身是一个函数,又被称为散列函数,它可以帮助我们大幅提升检索数据的效率。
Hash 算法是通过某种确定性的算法(比如 MD5、SHA1、SHA2、SHA3)将输入转变为输出。相同的输入永远可以得到相同的输出,假设输入内容有微小偏差,在输出中通常会有不同的结果。
哈希结构效率很高,例如HashMap,查询/插入/修改/删除的平均时间复杂度都是 O(1); (key, value)
Hash结构效率高,那为什么索引结构要设计成树型呢?
原因1:Hash索引仅能满足(=)(< >)和IN查询。如果进行范围查询,哈希型的索引,时间复杂度会退化为 O(n);而树型的“有序”特性,依然能够保持O(log2N)的高效率。
原因2:Hash索引还有一个缺陷,数据的存储是没有顺序的,在ORDER BY的情况下,使用Hash索引还需要对数据重新排序。
原因3:对于联合索引的情况,Hash值是将联合索引键合并后一起来计算的,无法对单独的一个键或者几个索引键进行查询。
原因4:对于等值查询来说,通常Hash索引的效率更高,不过也存在一种情况,就是索引列的重复值如果很多,效率就会降低。这是因为遇到Hash冲突时,需要遍历桶中的行指针来进行比较,找到查询的关键字,非常耗时。所以,Hash索引通常不会用到重复值多的列,比如列为性别、年龄的情况等。
Hash索引适用存储引擎如表所示:
| 索引 / 存储引擎 | MyISAM | InnoDB | Memory |
|---|---|---|---|
| HASH索引 | 不支持 | 不支持 | 支持 |
Hash索引的适用性:
Hash索引存在着很多限制,相比之下在数据库中B+树索引的使用面会更广,不过也有一些场景采用Hash索引效率更高,比如在键值型(Key-Value)数据库中,Redis存储的核心就是Hash表。
MySQL中的Memory存储引擎支持Hash存储,如果我们需要用到查询的临时表时,就可以选择Memory存储引擎,把某个字段设置为Hash索引,比如字符串类型的字段,进行Hash计算之后长度可以缩短到几个字节。当字段的重复度低,而且经常需要进行等值查询的时候,采用Hash索引是个不错的选择。
另外,InnoDB 本身不支持Hash索引,但是提供自适应Hash索引(Adaptive Hash Index)。什么时候下才会使用自适应Hash索引呢?如果某个数据经常被访问,当满足一定条件的时候,就会将这个数据页的地址存放到Hash表中。这样下次查询的时候,就可以直接找到这个页面的所在位置。这样让B+树也具备了Hash索引的优点。

采用自适应Hash索引目的是方便根据SQL的查询条件加速定位到叶子节点,特别是当B+树比较深的时候,通过自适应Hash索引可以明显提高数据的检索效率。
我们可以通过innodb_adaptive_hash_index变量来查看是否开启了自适应Hash,比如:
1 | mysql> show variables like '%adaptive_hash_index'; |
5.2 二叉搜索树
如磁盘的IO次数和索引树的高度是相关的。为了提高查询效率,就需要减少磁盘IO数。为了减少磁盘IO的次数,就需要尽量降低树的高度,需要把原来“瘦高”的树结构变的“矮胖”,树的每层的分叉越多越好。
二叉搜索树无法自平衡,极端情况下会退化成链表
每个非叶子节点只能有两个子节点,树的层数太多,数太高,索引效率低。
5.3 AVL树
AVL树实现了自平衡,左右两个子树的高度差不会超过1,并且左右两个子树都是一颗二叉平衡树
数据查询的时间主要依赖于磁盘I/O的次数,如果采用二叉树的形式,即使通过平衡二叉搜索树进行了改进,树的深度也是O(log2n),当n比较大时,深度也是比较高的
针对同样的数据,如果把二叉树改成M 叉树(M>2)当数据量N大的时候,以及树的分叉树M大的时候,M叉树的高度会远小于二叉树的高度。我们需要把 树从“瘦高” 变 “矮胖”。
5.4 B-Tree
B树的英文是Balance Tree,也就是多路平衡查找树。简写为B-Tree。它的高度远小于平衡二叉树的高度。
B树的结构如下图所示:

B树作为多路平衡查找树,它的每一个节点最多可以包括M个子节点,M 称为 B 树的阶。每个磁盘块中包括了关键字和子节点的指针。如果一个磁盘块中包括了x个关键字,那么指针数就是x+1。对于一个100阶的B树来说,如果有3层最多可以存储约100万的索引数据。对于大量的索引数据来说,采用B树的结构是非常适合的,因为树的高度要远小于二叉树的高度。
一个M阶的B树(M>2)有以下的特性:
-
根节点的儿子数的范围是 [2,M]。
-
每个中间节点包含 k-1 个关键字和 k 个孩子,孩子的数量 = 关键字的数量 +1,k 的取值范围为[ceil(M/2), M]。 ceil向上取整
-
叶子节点包括 k-1 个关键字(叶子节点没有孩子),k 的取值范围为 [ceil(M/2), M]。
-
假设中间节点节点的关键字为: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] 的子树。
-
所有叶子节点位于同一层。
上面那张图所表示的 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,那么步骤可以分为以下几步:
① 我们与根节点的关键字 (17,35)进行比较,9 小于 17 那么得到指针 P1;
② 按照指针 P1 找到磁盘块 2,关键字为(8,12),因为 9 在 8 和 12 之间,所以我们得到指针 P2;
③ 按照指针 P2 找到磁盘块 6,关键字为(9,10),然后我们找到了关键字 9。
你能看出来在B树的搜索过程中,我们比较的次数并不少,但如果把数据读取出来然后在内存中进行比较,这个时间就是可以忽略不计的。而读取磁盘块本身需要进行 I/O 操作,消耗的时间比在内存中进行比较所需要的时间要多,是数据查找用时的重要因素。B 树相比于平衡二叉树来说磁盘 I/O 操作要少,在数据查询中比平衡二叉树效率要高。所以只要树的高度足够低,IO次数足够少,就可以提高查询性能。
小结
-
B树在插入和删除节点的时候如果导致树不平衡,就通过自动调整节点的位置来保持树的自平衡。
-
关键字集合分布在整棵树中,即叶子节点和非叶子节点都存放数据。搜索有可能在非叶子节点结束。
-
其搜索性能等价于在关键字全集内做一次二分查找。
再举例

5.5 B+Tree
B+树也是一种多路搜索树,基于 B 树做出了改进,主流的 DBMS 都支持 B+ 树的索引方式,比如 MySQL。相比较于 B-Tree,B+Tree 适合文件索引系统。
B+树和B树的差异:
-
有k个孩子的节点就有k个关键字。也就是孩子数量=关键字数,而B树中,孩子数量=关键字数+1。
-
非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。
-
非叶子节点仅用于索引,不保存数据记录,跟记录有关的信息都放在叶子节点中。而B树中,
非叶子节点既保存索引,也保存数据记录。 -
所有关键字都在叶子节点出现,叶子节点构成一个有序链表,而且叶子节点本身按照关键字的大小从小到大顺序链接。
B+树的优势
B+树和B树有个根本的差异在于,B+树的中间节点并不直接存储数据。这种设计的好处有哪些呢?
首先,B+树的查询效率更稳定,因为B+树每次只有访问到叶子节点才能找到对应的数据,而在B树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况。有时候访问到了非叶子节点就可以找到关键字,而有时需要访问到叶子节点才能找到关键字。
其次,B+树的查询效率更高。这是因为通常B+树比B树更矮胖(阶数更大,深度更低),查询所需要的磁盘IO也会更少。同样的磁盘页大小,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不支持哈希索引
思考题:Hash 索引与 B+ 树索引是在建索引的时候手动指定的吗?
针对InnoDB和MyISAM存储引擎,都会默认采用B+树索引,而非使用Hash索引。InnoDB提供的自适应Hash是不需要手动指定的。如果是Memory/Heap和NDB存储引擎,则可以选择使用Hash索引。
5.6 R树
R-Tree在MySQL很少使用,仅支持geometry数据类型,支持该类型的存储引擎只有myisam、bdb、innodb、ndb、archive几种。
举个R树在现实领域中能够解决的例子:查找20英里以内所有的餐厅。如果没有R树会怎么解决?一般情况下我们会把餐厅的坐标(x,y)分为两个字段存放在数据库中,一个字段记录经度,另一个字段记录纬度。这样的话我们就需要遍历所有的餐厅获取其位置信息,然后计算是否满足要求。如果一个地区有100家餐厅的话,我们就要进行100次位置计算操作了,如果应用到谷歌、百度地图这种超大数据库中,这种方法便必定不可行了。R树就很好的解决了这种高维空间搜索问题。它把B树的思想很好的扩展到了多维空间,采用了B树分割空间的思想,并在添加、删除操作时采用合并、分解结点的方法,保证树的平衡性。因此,R树就是一棵用来存储高维数据的平衡树。相对于B-Tree,R-Tree的优势在于范围查找。
| 索引 / 存储引擎 | MyISAM | InnoDB | Memory |
|---|---|---|---|
| R-Tree索引 | 支持 | 支持 | 不支持 |
第07章 InnoDB数据存储结构
【Xmind思维导图】 https://ai.xmind.cn/share/yLhtvCz7
1. 数据库的存储结构:页
索引结构给我们提供了高效的索引方式,不过索引信息以及数据记录都保存在文件上的,确切说是存储在页结构中。另一方面,索引是在存储引擎中实现的,MySQL服务器上的存储引擎负责对表中数据的读取和写入工作。不同存储引擎中存放的格式一般不同的,甚至有的存储引擎比如Memory都不用磁盘来存储数据。
由于InnoDB是MySQL的默认存储引擎,所以本章剖析InooDB存储引擎的数据存储结构。
1.1 磁盘与内存交互基本单位:页
InnoDB将数据划分为若干个页,InnoDB中页的大小默认为16KB。
以页作为磁盘和内存之间交互的基本单位,也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。也就是说,**在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page),数据库I/O操作的最小单位是页。**一个页中可以存储多个行记录。
记录是按照行来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次I/O操作)只能处理一行数据,效率会非常低。
1.2 页结构概述
页a、页b、页c…页n这些页可以不在物理结构上相连,只要通过双向链表相关联即可。每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储在它里边的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应的分组中的记录即可快速找到指定的记录。
1.3 页的大小
不同的数据库管理系统(简称DBMS)的页大小不同。比如在MySQL的InnoDB存储引擎中,默认页的大小是 16KB,可以通过下面的命令来进行查看:
1 | show variables like '%innodb_page_size%'; |
SQL Server中页的大小为8KB,而在Oracle中用术语"块"(Block)来表示"页",Oracle支持的块大小为2KB, 4KB, 8KB, 16KB, 32KB和64KB。
1.4 页的上层结构
另外在数据库中,还存在着区(Extent)、段(Segment)和表空间(Tablespace)的概念。
行、页、区、段、表空间的关系如下图所示:

区(Extent)是比页大一级的存储结构,在InnoDB存储引擎中,一个区会分配64个连续的页。因为InnoDB中的页大小默认是16KB,所以一个区的大小是64*16KB=1MB。
段(Segment)由一个或多个区组成,区在文件系统是一个连续分配的空间(在InnoDB中是连续的64个页),不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会创建一个索引段。
表空间(Tablespace)是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。
2. 页的内部结构
页如果按类型划分的话,常见的有数据页(保存B+树节点)、系统表、Undo页和事务数据页等。数据页是我们最常使用的页。
数据页的 16KB 大小的存储空间被划分为七个部分,分别是文件头(File Header)、页头(Page Header)、最大最小记录(Infimum + supremum)、用户记录(User Records)、空闲空间(Free Space)、页目录(Page Directory)和文件尾(File Tailer)。
页结构的示意图如下所示:

这7个部分作用分别如下

我们可以把这7个结构分为3个部分。
第1部分:文件头部和文件尾部
首先是文件通用部分,也就是文件头和文件尾。
File Header(文件头部)(38字节)
作用:
描述各种页的通用信息。(比如页的编号、其上一页、下一页是谁等)
大小:38字节
| 名称 | 占用空间大小 | 描述 |
|---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM |
4字节 |
页的校验和(checksum值) |
FIL_PAGE_OFFSET |
4字节 |
页号 |
FIL_PAGE_PREV |
4字节 |
上一个页的页号 |
FIL_PAGE_NEXT |
4字节 |
下一个页的页号 |
FIL_PAGE_LSN |
8字节 |
页面被最后修改时对应的日志序列位置 英文名 Log Sequence Number |
FIL_PAGE_TYPE |
2字节 |
该页的类型 |
| FIL_PAGE_FILE_FLUSH_LSN | 8字节 |
仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 |
| FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4字节 |
页属于哪个表空间 |
-
FIL_PAGE_OFFSET(4字节)每一个页都有一个单独的页号,InnoDB通过页号可以唯一定位一个页。
-
FIL_PAGE_TYPE(2字节):这个代表当前页的类型。
| 类型名称 | 十六进制 | 描述 |
|---|---|---|
| FIL_PAGE_TYPE_ALLOCATED | 0x0000 | 最新分配,还没有使用 |
FIL_PAGE_UNDO_LOG |
0x0002 | Undo日志页 |
| FIL_PAGE_INODE | 0x0003 | 段信息节点 |
| FIL_PAGE_IBUF_FREE_LIST | 0x0004 | Insert Buffer空闲列表 |
| FIL_PAGE_IBUF_BITMAP | 0x0005 | Insert Buffer位图 |
FIL_PAGE_TYPE_SYS |
0x0006 | 系统页 |
| FIL_PAGE_TYPE_TRX_SYS | 0x0007 | 事务系统数据 |
| FIL_PAGE_TYPE_FSP_HDR | 0x0008 | 表空间头部信息 |
| FIL_PAGE_TYPE_XDES | 0x0009 | 扩展描述页 |
| FIL_PAGE_TYPE_BLOB | 0x000A | 溢出页 |
FIL_PAGE_INDEX |
0x45BF | 索引页,也就是我们所说的数据页 |
-
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)
File Trailer(文件尾部)(8字节)
- 前4个字节代表页的校验和:这个部分是和File Header中的校验和相对应的。
- 后4个字节代表页被最后修改时对应的日志序列位置(LSN):这个部分也是为了校验页的完整性的,如果首部和尾部的LSN值校验不成功的话,就说明同步过程出现了问题。
第2部分:空闲空间、用户记录和最小最大记录
第二个部分是记录部分
页的主要作用是存储记录,所以“最大和最小记录”和“用户记录”部分占了页结构的主要空间。

Free Space (空闲空间)
我们自己存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
User Records (用户记录)
User Records中的这些记录按照指定的行格式一条一条摆在User Records部分,相互之间形成单链表。
Infimum + Supremum(最小最大记录)
记录可以比较大小吗?
是的,记录可以比大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录是从小到大依次递增。
InnoDB规定的最小记录与最大记录这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的。

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

第3部分:页目录和页头
Page Directory(页目录)
为什么需要页目录
在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索,提升效率。
页目录结构
① 将所有的记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录。
第1组,也就是最小记录所在的分组只有1个记录;
最后一组,就是最大记录所在的分组,会有1-8条记录;
其余的组记录数量在4-8条之间。
这样做的好处是,除了第1组(最小记录所在组)以外,其余组的记录数会尽量平分。
② 在每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned字段。
③ 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。
分组可以减少减少页目录的大小
分组可以减少页目录的变动
如果每条记录的偏移都放在页目录中,增加删除记录,就会频繁更改页目录。页目录是数组结构,增删改的时间复杂度是比较高的。
一个页为16KB,假设一条记录,占200个字节,每页也有将近80条多记录。
举例1:
现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录。如下图:

从这个图中我们需要注意这么几点:
- 现在页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量(就是从页的0字节开始数,数112个字节);槽0中的值是99,代表最小记录的地址偏移量。
- 注意最小和最大记录的头信息中的n_owned属性
- 最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。
- 最大记录的n_owned值为5,这就代表着以最大记录结尾的这个分组中只有5条记录,包括最大记录本身还有我们自己插入的4条记录。
用箭头指向的方式替代数字,这样更易于我们理解,修改后如下

举例2:添加更多记录

页目录分组的过程
InnoDB规定:对于最小记录所在的分组只能有1条记录,最大记录所在的分组拥有的记录条数只能在1~8条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。
分组是按照下边的步骤进行的:
- 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
- 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
- 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
页目录结构快速查找记录过程
在一个数据页中查找指定主键值的记录的过程分为两步:
-
通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
-
通过记录的next_record属性遍历该槽所在的组中的各个记录。
因为记录之间通过单链表连接,所以只能从左向右遍历
Page Header(页头)
为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,这个部分占用固定的56个字节,专门存储各种状态信息。
| 名称 | 占用空间大小 | 描述 |
|---|---|---|
| PAGE_N_DIR_SLOTS | 2字节 | 在页目录中的槽数量 |
| PAGE_HEAP_TOP | 2字节 | 还未使用的空间最小地址,也就是说从该地址之后就是Free Space |
| PAGE_N_HEAP | 2字节 | 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) |
| PAGE_FREE | 2字节 | 第一个已经标记为删除的记录的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) |
| PAGE_GARBAGE | 2字节 | 已删除记录占用的字节数 |
| PAGE_LAST_INSERT | 2字节 | 最后插入记录的位置 |
| PAGE_DIRECTION | 2字节 | 记录插入的方向 |
| PAGE_N_DIRECTION | 2字节 | 一个方向连续插入的记录数量 |
| PAGE_N_RECS | 2字节 | 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) |
| PAGE_MAX_TRX_ID | 8字节 | 修改当前页的最大事务ID,该值仅在二级索引中定义 |
| PAGE_LEVEL | 2字节 | 当前页在B+树中所处的层级 |
| PAGE_INDEX_ID | 8字节 | 索引ID,表示当前页属于哪个索引 |
| PAGE_BTR_SEG_LEAF | 10字节 | B+树叶子段的头部信息,仅在B+树的Root页定义 |
| PAGE_BTR_SEG_TOP | 10字节 | B+树非叶子段的头部信息,仅在B+树的Root页定义 |
PAGE_DIRECTION
假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION。
PAGE_N_DIRECTION
假设连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
3. InnoDB行格式(或记录格式)
我们平时的数据以行为单位来向表中插入数据,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。InnoDB存储引擎设计了4种不同类型的行格式,分别是Compact、Redundant、Dynamic和Compressed行格式。
查看MySQL8的默认行格式:
1 | mysql> SELECT @@innodb_default_row_format; |
也可以使用如下语法查看具体表使用的行格式:
1 | SHOW TABLE STATUS LIKE '表名'\G |
3.1 指定行格式的语法
1 | CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称 |
1 | ALTER TABLE 表名 ROW_FORMAT=行格式名称 |
3.2 COMPACT行格式
在MySQL 5.1版本中,默认设置为Compact行格式。一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分。

1)变长字段长度列表
MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、TEXT类型,BLOB类型,这些数据类型修饰列称为变长字段,变长字段中存储多少字节的数据不是固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表。
注意:这里面存储的变长长度和字段顺序是反过来的。比如两个varchar字段在表结构的顺序是a(10),b(15)。那么在变长字段长度列表中存储的长度顺序就是15,10,是反过来的。
举例
1 | CREATE TABLE record_test_table ( |
以record_test_table表中的第一条记录举例:因为record_test_table表的col1、col2、col4列都是VARCHAR(8)类型的,所以这三个列的值的长度都需要保存在记录开头处,注意record_test_table表中的各个列都使用的是ascii字符集(每个字符只需要1个字节来进行编码)。
| 列名 | 存储内容 | 内容长度(十进制表示) | 内容长度(十六进制表示) |
|---|---|---|---|
| col1 | zhangsan | 8 | 0x08 |
| col2 | lisi | 4 | 0x04 |
| col4 | songhk | 6 | 0x06 |
又因为这些长度值需要按照列的逆序存放,所以最后变长字段长度列表的字节串用十六进制表示的效果就是(各个字节之间实际上没有空格,用空格隔开只是方便理解):
06 04 08
把这个字节串组成的变长字段长度列表填入上边的示意图中的效果就是:

2)NULL值列表(NULL bitmap)
Compact行格式会把可以为NULL的列统一管理起来,存在一个标记为NULL值列表中。如果表中没有允许存储 NULL 的列,则 NULL值列表也不存在了。
为什么定义NULL值列表?
之所以要存储NULL是因为数据都是需要对齐的,如果没有标注出来NULL值的位置,就有可能在查询数据的时候出现混乱。如果使用一个特定的符号放到相应的数据位表示空置的话,虽然能达到效果,但是这样很浪费空间,所以直接就在行数据的头部开辟出一块空间专门用来记录该行数据哪些是非空数据,哪些是空数据,格式如下:
-
二进制位的值为1时,代表该列的值为NULL。
-
二进制位的值为0时,代表该列的值不为NULL。
注意:同样顺序也是反过来存放的
例如:
字段 a、b、c,其中a是主键,在某一行中存储的数依次是 a=1、b=null、c=2。那么Compact行格式中的NULL值列表中存储:01。第一个0表示c不为null,第二个1表示b是null。这里之所以没有a是因为数据库会自动跳过主键,因为主键肯定是非NULL且唯一的,在NULL值列表的数据中就会自动跳过主键。
举例:以上面record_test_table表为例
1 | INSERT INTO record_test_table(col1, col2, col3, col4) |

3)记录头信息(5字节)
1 | CREATE TABLE page_demo( |
1 | INSERT INTO page_demo |

| 名称 | 大小(单位:bit) | 描述 |
|---|---|---|
预留位1 |
1 | 没有使用 |
预留位2 |
1 | 没有使用 |
delete_mask |
1 | 标记该记录是否被删除 |
mini_rec_mask |
1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned |
4 | 表示当前记录拥有的记录数 |
heap_no |
13 | 表示当前记录在记录堆的位置信息 |
record_type |
3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 |
next_record |
16 | 表示下一条记录的相对位置 |
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,也就是说它们的位置最靠前
-
n_owned:页目录中每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned字段 -
next_record:记录头信息里该属性非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。
比如:第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。注意,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定Infimum记录(也就是最小记录)的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录)。
下图用箭头代替偏移量表示next_record。

删除操作
从表中删除掉一条记录,这个链表也是会跟着变化:
1 | DELETE FROM page_demo WHERE c1 = 2; |

从图中可以看出来,删除第2条记录前后主要发生了这些变化:
- 第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1。
- 第2条记录的next_record值变为了0,意味着该记录没有下一条记录了。
- 第1条记录的next_record指向了第3条记录。
- 最大记录的n_owned值从5变成了4。
添加操作
主键值为2的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,会发生什么事呢?
1 | INSERT INTO page_demo VALUES(2, 200, 'tong'); |

直接复用了原来被删除记录的存储空间。
说明:
当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。
4)记录的真实数据
记录的真实数据除了我们自己定义的列的数据以外,还会有三个隐藏列:
| 列名 | 是否必须 | 占用空间 | 描述 |
|---|---|---|---|
| row_id | 否 | 6字节 | 行ID,唯一标识一条记录 |
| transaction_id | 是 | 6字节 | 事务ID |
| roll_pointer | 是 | 7字节 | 回滚指针 |
实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR。
一个表没有手动定义主键,则会选取一个Unique键作为主键,如果连Unique键都没有定义的话,则会为表默认添加一个名为row_id的隐藏列作为主键。所以row_id是在没有自定义主键以及Unique键的情况下才会存在的。
5)举例:分析Compact行记录的内部结构
1 | CREATE TABLE mytest( |
在Windows操作系统下,可以选择通过程序UltraEdit打开表空间文件mytest.ibd这个二进制文件
1 | ------------------------------------------------------------------------------------------ |
第一条数据分析('a','bb','bb','ccc')
| 十六进制码 | 解释 |
|---|---|
73 75 70 72 65 6d 75 6d |
supremum最大记录结尾 |
03 02 01 |
变长字段长度列表,逆序 col4的值为ccc,长度为3,col2的值为bb,长度为2, col1的值为a,长度为1 |
00 |
NULL标志位,第一条没有NULL值 |
00 00 10 |
Record Header,固定5字节长度 预留位1和2、delete_mask、mini_rec_mask都是0,占4位 0 n_owned 占4位 00010heap_no 占十三位,由于是第2条记录,0和1是最小最大记录,所以二进制 0000 0000 0001 0 record_type 占三位,普通记录,所以二进制 000 |
00 2C |
next_record,指向下一条记录next_record字段的末尾 本条记录的next_record字段末尾距离 下一条记录的next_record末尾相差44个字节 所以本条记录的next_reocrd是44个字节,16进制2C |
00 00 00 2b 68 00 |
RowID InnoDB自动创建,6字节 |
00 00 00 00 06 05 |
TransactionID |
80 00 00 00 32 01 10 |
Roll Pointer |
61 |
列1数据’a’ |
62 62 |
列2数据’bb’ |
62 62 20 20 20 20 20 20 20 20 |
列3数据’bb’,由于类型是char(10),20代表空格,填充8个空格 |
63 63 63 |
列4数据’ccc’ |
注意1:InnoDB每行有隐藏列TransactionID和Roll Pointer。
注意2:固定长度CHAR字段在未能完全占用其长度空间时,会用0x20来进行填充。
注意3:Record Header的最后两个字节,这两个字节代表next_recorder,0x2c代表下一个记录的偏移量,当前记录的next_record字段末尾,加上偏移量0x2c个字节,就是下条记录next_record字段末尾。
分析有NULL值的第三条数据('d',NULL,NULL,'fff')
1 | --------------------------------------------------------------------- |
第三行有NULL值,因此NULL标志位不再是00而是06,转换成二进制为00000110,为1的值代表第2列和第3列的数据为NULL。在其后存储列数据的部分,用户会发现没有存储NULL列,而只存储了第1列和第4列非NULL的值。
因此这个例子很好地说明了:不管是CHAR类型还是VARCHAR类型,在compact格式下NULL值都不占用任何存储空间。
next_record
第三条记录的next_record的值是ff 98,是一个负数,负的104个字节 43+44+17=104
第三条记录是最后一条记录,最后一条记录的next_record字段指向最大记录
第二条记录next_record是43,即第二条记录的next_record字段结尾距离第三条记录的next_record字段结尾,相差43个字节
第一条记录的next_record是44
第一条记录的next_record字段结尾距离最大记录的next_record字段末尾,相差17个字节
所以第三条记录的next_record的值是负104
3.3 Dynamic和Compressed行格式
VARCHAR(M)类型,最多可以存放字节数
1 | CREATE TABLE varchar_size_demo( |
1 | ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs |
报错信息表达的意思是:MySQL对一条记录占用的最大存储空间是有限制的,除BLOB或者TEXT类型的列之外, 其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。
这个65535个字节除了列本身的数据之外,还包括一些其他的数据,以Compact行格式为例,比如说我们为了存储一个VARCHAR(M)类型的列,除了真实数据占有空间以外,还需要记录的额外信息。
如果该VARCHAR类型的列没有NOT NULL属性,那最多只能存储65532个字节的数据,因为变长字段的长度占用 2个字节,NULL值标识需要占用1个字节。
如果有not null属性,那么就不需要NULL值标识,也就可以多存储一个字节,即65533个字节
1 | CREATE TABLE varchar_size_demo( |
行溢出
我们可以知道一个页的大小一般是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个前缀字节)。
Compressed行记录格式的另一个功能就是,存储在其中的行数据会以zlib的算法进行压缩,因此对于BLOB、TEXT、VARCHAR这类大长度类型的数据能够进行非常有效的存储。
3.4 Redundant行格式
Redundant是MySQL 5.0版本之前InnoDB的行记录存储方式,MySQL 5.0支持Redundant是为了兼容之前版本的页格式。
1 | ALTER TABLE record_test_table ROW_FORMAT=Redundant; |
从上图可以看到,不同于Compact行记录格式,Redundant行格式的首部是一个字段长度偏移列表,同样是按照列的顺序逆序放置的。
字段长度偏移列表
注意Compact行格式的开头是变长字段长度列表,而Redundant行格式的开头是字段长度偏移列表,与变长字段长度列表有两处不同:
- 少了“变长”两个字:Redundant行格式会把该条记录中所有列(包括隐藏列)的长度信息都按照逆序存储到字段长度偏移列表。
- 多了“偏移”两个字:这意味着计算列值长度的方式不像Compact行格式那么直观,它是采用两个相邻数值的差值来计算各个列值的长度。
举例:比如第一条记录的字段长度偏移列表就是:
2B 25 1F 1B 13 0C 06
因为它是逆序排放的,所以按照列的顺序排列就是:
06 0C 13 17 1A 24 25
按照两个相邻数值的差值来计算各个列值的长度的意思就是:
1 | 第一列(row_id)的长度就是 0x06个字节,也就是6个字节。 |
记录头信息(record header)
不同于Compact行格式,Redundant行格式中的记录头信息固定占用6个字节(48位),每位的含义见下表。
| 名称 | 大小(bit) | 描述 |
|---|---|---|
| () | 1 | 未使用 |
| () | 1 | 未使用 |
| deleted_mask | 1 | 该行是否已被删除 |
| min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
| n_owned | 4 | 该记录拥有的记录数 |
| heap_no | 13 | 索引堆中该条记录的位置信息 |
n_fields |
10 | 记录中列的数量 |
1byte_offs_flag |
1 | 记录字段长度偏移列表中每个列对应的偏移量 使用1个字节还是2个字节表示 |
| next_record | 16 | 页中下一条记录的绝对位置 |
与Compact行格式的记录头信息对比来看,有两处不同:
- Redundant行格式多了n_field和1byte_offs_flag这两个属性。
- Redundant行格式没有record_type这个属性。
n_fields:代表一行中列的数量,占用10位,所以MySQL一个行支持最多的列为1023。1byte_offs_flags,定义了偏移列表占用1个字节还是2个字节。当它的值为1时,表明使用1个字节存储。当它的值为0时,表明使用2个字节存储。
1byte_offs_flag的值是怎么选择的
每个列对应的偏移量可以占用1个字节或者2个字节来存储,那到底什么时候用1个字节,什么时候用2个字节呢?其实是根据该条Redundant行格式记录的真实数据占用的总大小来判断的:
- 当记录的真实数据占用的字节数值不大于127(十六进制0x7F,二进制01111111)时,每个列对应的偏移量占用1个字节。
- 当记录的真实数据占用的字节数大于127,但不大于32767(十六进制0x7FFF,二进制0111111111111111)时,每个列对应的偏移量占用2个字节。
有没有记录的真实数据大于32767的情况呢?有,不过此时的记录已经存放到了溢出页中,在本页中只保留前768个字节和20个字节的溢出页面地址。因为字段长度偏移列表处只需要记录每个列在本页面中的偏移就好了,所以每个列使用2个字节来存储偏移量就够了。
大家可以看出来,Redundant行格式还是比较简单粗暴的,直接使用整个记录的真实数据长度来决定使用1个字节还是2个字节存储列对应的偏移量。只要整条记录的真实数据占用的存储空间大小大于127,即使第一个列的值占用存储空间小于127,那对不起,也需要使用2个字节来表示该列对应的偏移量。简单粗暴,就是这么简单粗暴(所以这种行格式有些过时了)。
为了在解析记录时知道每个列的偏移量是使用1个字节还是2个字节表示的,Redundant行格式特意在记录头信息里放置了一个称之为1byte_offs_flag的属性
Redundant行格式中NULL值的处理
因为Redundant行格式并没有NULL值列表,所以Redundant行格式在字段长度偏移列表中的各个列对应的偏移量处做了一些特殊处理————将列对应的偏移量值的第一个比特位作为是否为NULL的依据,该比特位也可以被称之为NULL比特位。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的NULL比特位是不是为1。如果为1,那么该列的值就是NULL,否则不是NULL。
这也就解释了上边介绍为什么只要记录的真实数据大于127(十六进制0x7F,二进制01111111)时,就采用2个字节来表示一个列对应的偏移量,主要是第一个比特位是所谓的NULL比特位,用来标记该列的值是否为NULL。
1 | CREATE TABLE record_test_table ( |
1 | INSERT INTO record_test_table(col1, col2, col3, col4) |
但是还有一点要注意,对于值为NULL的列来说,该列的类型是否为定长类型决定了NULL值的实际存储方式,我们接下来分析一下record_test_table表的第二条记录,它对应的字段长度偏移列表如下
A4 A4 1A 17 13 0C 06
按照列的顺序排放就是:
06 0C 13 17 1A A4 A4
我们分情况看一下:
-
如果存储NULL值的字段是定长类型的,比方说
CHAR(M)数据类型的,则NULL值也将占用记录的真实数据部分,并把该字段对应的数据使用0x00字节填充。第二条记录的c3列的值是NULL,而c3列的类型是
CHAR(10),占用记录的真实数据部分10字节,所以我们看到在Redundant行格式中使用0x00000000000000000000来表示NULL值。另外,c3列对应的偏移量为0xA4,它对应的二进制实际是:10100100,可以看到最高位为1,意味着该列的值是NULL。将最高位去掉后的值变成了0100100,对应的十进制值为36,而c2列对应的偏移量为0x1A,也就是十进制的26。36 - 26 = 10,也就是说最终c3列占用的存储空间为10个字节。
-
如果该存储NULL值的字段是变长数据类型的,则不在记录的真实数据处占用任何存储空间。
比如record_test_table表的c4列是VARCHAR(10)类型的,VARCHAR(10)是一个变长数据类型,c4列对应的偏移量为0xA4,与c3列对应的偏移量相同,这也就意味着它的值也为NULL,将0xA4的最高位去掉后对应的十进制值也是36,36 - 36 = 0,也就意味着c4列本身不占用任何记录的实际数据处的空间。
除了以上的几点之外,Redundant行格式和Compact行格式还是大致相同的。
4. 区、段和碎片区
4.1 为什么要有区?
B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。我们介绍B+树索引的使用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。再一次强调,磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O。
引入区的概念,一个区就是物理位置上连续的64个页。因为InnoDB中的页的大小默认是16KB,所以一个区的大小是64*16KB=1MB。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页的单位分配了,而是按照区为单位分配,甚至在表中的数据特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费(数据不足以填充满整个区),但是从性能角度看,可以消除很多的随机I/O,功大于过!
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):每一索引都可以分为叶子节点段和非叶子节点段
处于FREE、FREE_FRAG以及FULL_FRAG这三种状态的区都是独立的,直属于表空间。而处于FSEG状态的区是附属于某个段的。
5. 表空间
表空间可以看做是InnoDB存储引擎逻辑结构的最高层,所有的数据都存放在表空间中。
表空间是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。表空间数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间(System tablespace)、 独立表空间(File-per-table tablespace)、 撤销表空间(Undo Tablespace)和 临时表空间(Temporary Tablespace)等。
5.1 独立表空间
独立表空间,即每张表有一个独立的表空间,也就是数据和索引信息都会保存在自己的表空间中。独立的表空间 (即:单表) 可以在不同的数据库之间进行迁移。
空间可以回收 (DROP TABLE 操作可自动回收表空间;其他情况,表空间不能自己回收) 。如果对于统计分析或是日志表,删除大量数据后可以通过:alter table TableName engine=innodb;回收不用的空间。对于使用独立表空间的表,不管怎么删除,表空间的碎片不会太严重的影响性能,而且还有机会处理。
独立表空间结构
独立表空间由段、区、页组成。
真实表空间对应的文件大小
我们到数据目录里看,会发现一个新建的表对应的.ibd文件只占用了96K,才6个页面大小 (MySQL5.7中),这是因为一开始表空间占用的空间很小,因为表里边都没有数据。不过别忘了这些.ibd文件是自扩展的,随着表中数据的增多,表空间对应的文件也逐渐增大。
MySQL8新建表对应的.ibd文件会有7个页大小,因为表结构和表数据信息存放在一块了。MySQL5.7是分开存储的。
查看InnoDB的表空间类型
1 | show variables like 'innodb_file_per_table' |
innodb_file_per_table=ON, 这就意味着每张表都会单词保存一个.ibd文件。
5.2 系统表空间
系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,这部分是独立表空间中没有的。
InnoDB数据字典
每当我们向一个表中插入一条记录时,MySQL校验过程如下:
先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合。如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的B+树中。所以说,MySQL除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比如说:
1 | - 某个表属于哪个表空间,表里边有多少列 |
上述这些数据并不是我们使用INSERT语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得以引入的一些额外数据,这些数据页称为元数据。InnoDB存储引擎特意定义了一些列的内部系统表 (internal system table) 来记录这些元数据:
| 表名 | 描述 |
|---|---|
SYS_TABLES |
整个InnoDB存储引擎中所有表的信息 |
SYS_COLUMNS |
整个InnoDB存储引擎中所有列的信息 |
SYS_INDEXES |
整个InnoDB存储引擎中所有索引的信息 |
SYS_FIELDS |
整个InnoDB存储引擎中所有索引对应的列的信息 |
| SYS_FOREIGN | 整个InnoDB存储引擎中所有外键的信息 |
| SYS_FOREIGN_COLS | 整个InnoDB存储引擎中所有外键对应列的信息 |
| SYS_TABLESPACES | 整个InnoDB存储引擎中所有表空间的信息 |
| SYS_DATAFILES | 整个InnoDB存储引擎中所有表空间对应文件系统的文件路径信息 |
| SYS_VIRTUAL | 整个InnoDB存储引擎中所有虚拟生成列的信息 |
这些系统表也称为数据字典,它们都是以B+树的形式保存在系统表空间的某个页面中。其中SYS_TABLES、SYS_COLUMNS、SYS_INDEXES、SYS_FIELDS这四个表尤其重要,称之为基本系统表(basic system tables) ,我们先看看这4个表的结构:
SYS_TABLES表结构
| 列名 | 描述 |
|---|---|
NAME |
表的名称。主键 |
ID |
InnoDB存储引擎中每个表都有一个唯一的ID。(二级索引) |
N_COLS |
该表拥有列的个数 |
TYPE |
表的类型,记录了一些文件格式、行格式、压缩等信息 |
MIX_ID |
已过时,忽略 |
MIX_LEN |
表的一些额外的属性 |
CLUSTER_ID |
未使用,忽略 |
SPACE |
该表所属表空间的ID |
SYS_COLUMNS表结构
| 列名 | 描述 |
|---|---|
TABLE_ID |
该列所属表对应的ID。(与POS一起构成联合主键) |
POS |
该列在表中是第几列。 |
NAME |
该列的名称 |
MTYPE |
main data type主数据类型,例如INT、CHAR、VARCHAR、FLOAT、DOUBLE等。 |
PRTYPE |
precise type精确数据类型,修饰主数据类型的属性,如是否允许 NULL 值,是否允许负数 |
LEN |
该列最多占用存储空间的字节数。 |
PREC |
该列的精度,不过这列貌似都没有使用,默认值通常为0。 |
SYS_INDEXES表结构
| 列名 | 描述 |
|---|---|
TABLE_ID |
该索引所属表对应的ID。(与ID一起构成联合主键) |
ID |
InnoDB存储引擎中每个索引都有一个唯一的ID。 |
NAME |
该索引的名称。 |
TYPE |
该索引的类型,如聚簇索引、唯一索引、更改缓冲区的索引,全文索引、普通的二级索引等。 |
N_FIELDS |
该索引包含的列的个数。 |
SPACE |
该索引根页面所在的表空间ID。 |
PAGE_NO |
该索引根页面所在的页号。 |
MERGE_THRESHOLD |
如果页面中的记录被删除到某个比例,就把该页面和相邻页面合并,这个值就是这个比例 |
SYS_FIELDS表结构
| 列名 | 描述 |
|---|---|
INDEX_ID |
该索引列所属的索引的ID。(与 POS 一起构成联合主键) |
POS |
该索引列在某个索引中是第几列。 |
COL_NAME |
该索引列的名称。 |
注意:用户不能直接访问InnoDB的这些内部系统表,除非你直接去解析系统表空间对应文件系统上的文件。不过考虑到查看这些表的内容可能有助于大家分析问题,所以在系统数据库information_schema中提供了一些以 innodb_sys开头的表:
1 | USE information_schema; |
在information_scheme数据库中的这些以INNODB_SYS开头的表并不是真正的内部系统表(内部系统表就是我们上边以SYS开头的那些表),而是在存储引擎启动时读取这些以SYS开头的系统表,然后填充到这些以INNODB_SYS开头的表中。以INNODB_SYS开头的表和以SYS开头的表中的字段并不完全一样,但仅供大家参考已经足矣。
附录:数据页加载的三种方式
InnoDB从磁盘中读取数据 最小单位 是数据页。而你想得到的 id = xxx 的数据,就是这个数据页众多行中的一行。
对于MySQL存放的数据,逻辑概念上我们称之为表,在磁盘等物理层面而言是按 数据页 形式进行存放的,当其加载到 MySQL 中我们称之为 缓存页。
如果缓冲池没有该页数据,那么缓冲池有以下三种读取数据的方式,每种方式的读取速率是不同的:
1)内存读取
如果该数据存在于内存中,基本上执行时间在1ms左右,效率还是很高的。
2)随机读取
如果数据没有在内存中,就需要在磁盘上对该页进行查找,整体时间预估在10ms左右,这10ms中有6ms是磁盘的实际等待时间(包括了寻道和半圈旋转时间),有3ms是对可能发生的排队时间的估计值,另外还有1ms的传输时间,将页从磁盘服务器缓冲区传输到数据库缓冲区中。这10ms看起来很快,但实际上对于数据库来说消耗的时间已经非常长了,因为这还只是一个页的读取时间。
3)顺序读取
顺序读取其实是一种批量读取的方式,因为我们请求的数据在磁盘上往往都是相邻存储的,顺序读取可以帮助我们批量读取页面,这样的话,一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘I/O操作了。如果一个磁盘的吞吐量是40MB/S,那么对于一个16KB大小的页来说,一次可以顺序读取2560(40MB/16KB)个页,相当于一个页的读取时间为0.4ms。采用批量读取的方式,即使是从磁盘上进行读取,效率也比从内存中只单独读取一个页的效率要高。
第08章 索引的创建与设计原则
1. 索引的声明与使用
1.1 索引的分类
MySQL的索引包括普通索引、唯一性索引、全文索引、单列索引、多列索引和空间索引等。
- 从
功能逻辑上说,索引主要有4种,分别是普通索引、唯一索引、主键索引、全文索引。 - 按照
物理实现方式,索引可以分为2种:聚簇索引和非聚簇索引。 - 按照
作用字段个数进行划分,分成单列索引和联合索引。
1)普通索引
在创建普通索引时,不附加任何可限制条件,只是用于提高查询效率。这类索引可以创建在任何数据类型中,其值是否唯一和非空,要由字段本身的完整性约束条件决定。建立索引以后,可以通过索引进行查询。例如,在表student 的字段name上建立一个普通索引,查询记录时就可以根据该索引进行查询。
2)唯一性索引
使用UNIQUE参数可以设置索引为唯一性索引。在创建唯一性索引时,限制该索引的值必须是唯一的,但允许有空值。在一张数据表里可以有多个唯一索引。
例如,在表student的字段email中创建唯一性索引,那么字段email的值就必须是唯一的。通过唯一性索引,可以更快速地确定某条记录。
3)主键索引
主键索引就是一种特殊的唯一性索引,在唯一索引的基础上增加了不为空的约束,也就是NOT NULL+UNIQUE,一张表里最多只有一个主键索引。
Why?这是由主键索引的物理实现方式决定的,因为数据存储在文件中只能按照一种顺序进行存储。
4)单列索引
在表中的单个字段上创建索引。单列索引只根据该字段进行索引。单列索引可以是普通索引,也可以是唯一性索引, 还可以是全文索引。只要保证该索引只对应一个字段即可。一个表可以有多个单列索引。
5)多列(组合、联合)索引
多列索引是在表的多个字段组合上创建一个索引。该索引指向创建时对应的多个字段,可以通过这几个字段进行查询,但是只有查询条件中使用了这些字段中的第一个字段时才会被使用。
例如,在表中的字段id、name和gender上建立一个多列索引idx_id_name_gender,只有在查询条件中使用了字段id时该索引才会被使用。使用组合索引时遵循最左前缀集合。
6)全文索引
全文索引(也称全文检索)是目前搜索引擎使用的一种关键技术。它能够利用分词技术等多种算法智能分析出文本文字中关键词的频率和重要性,然后按照一定的算法规则智能地筛选出我们想要的搜索结果。全文索引非常适合大型数据集,对于小的数据集,它的用处比较小。
使用参数FULLTEXT可以设置索引为全文索引。在定义索引的列上支持值的全文查找,允许在这些索引列中插入重复值和空值。全文索引只能创建在CHAR、VARCHAR或TEXT类型及其系列类型的字段上,查询数据量较大的字符串类型的字段时,使用全文索引可以提高查询速度。
例如,表student的字段information是TEXT类型,该字段包含了很多文字信息。在字段information上建立全文索引后,可以提高查询字段information的速度
全文索引典型的有两种类型:自然语言的全文索引和布尔全文索引
自然语言搜索引擎将计算每一个文档对象和查询的相关度。这里,相关度是基于匹配的关键词的个数,以及关键词在文档中出现的次数。**在整个索引中出现次数越少的词语,匹配时的相关度就越高。**相反,非常常见的单词将不会被搜索,如果一个词语在超过50%的记录中都出现了,那么自然语言的搜索将不会搜索这类词语。
MySQL数据库从3.23.23版开始支持全文索引,但在MySQL5.6.4以前只有MyISAM支持,5.6.4版本以后InnoDB 才支持全文索引。但是官方版本不支持中文分词,需要第三方分词插件。在5.7.6版本,MySQL内置了ngram全文解析器,用来支持亚洲语种的分词。测试或使用全文索引时,要先看一下自己的MySQL版本、存储引擎和数据类型是否支持全文索引。
随着大数据时代的到来,关系型数据库在应对全文索引需求时已力不从心,逐渐被Solr、Elasticsearch等专门的搜索引擎所替代。
7)空间索引
使用参数SPATIAL可以设置索引为空间索引。空间索引只能建立在空间数据类型上,这样可以提高系统获取空间数据的效率。MySQL中的空间数据类型包括GEOMETRY、POINT、LINESTRING和POLYGON等。目前只有MyISAM存储引擎支持空间检索,而且索引的字段不能为空值。
小结:不同的存储引擎支持的索引类型也不一样:
- InnoDB:支持B-tree、Full-text等索引,不支持Hash索引。
- MyISAM:支持B-tree、Full-text等索引,不支持Hash索引。
- Memory:支持B-tree、Hash等索引,不支持Full-text索引。
- NDB:支持Hash索引,不支持B-tree、Full-text等索引。
- Archive:不支持B-tree、Hash、Full-text等索引。
1.2 查看索引
1 | SHOW CREATE TABLE table_name; |
1 | SHOW INDEX FROM table_name\G |
Non_unique: 索引是否唯一。如果值为1,表示该索引允许重复值;如果值为0,表示该索引是唯一的,不允许重复值。
Key_name: 索引的名称。在这里是 year_publication。
Seq_in_index: 列在索引中的顺序。如果索引包含多个列,这个字段表示每个列在索引中的位置
Column_name: 索引中包含的列名。在这里是 year_publication。
Collation: 索引的排序方式。A 表示升序。
Cardinality: 索引的基数,表示索引中唯一值的近似数量。值为 0 通常表示索引刚创建,还没有统计数据。
Sub_part: 如果是部分索引,表示索引的前缀长度。NULL 表示整个列都包含在索引中。
Packed: 索引键是否被压缩。NULL 表示没有压缩。
Null: 列是否允许 NULL 值。YES 表示允许 NULL 值。
Index_type: 索引的类型。这里是 BTREE,表示使用 B 树结构。
Comment: 索引的注释。
Index_comment: 关于索引的额外注释。
1.3 创建索引
MySQL :: MySQL 8.0 Reference Manual :: 15.1.15 CREATE INDEX Statement
MySQL支持多种方法在单个或多个列上创建索引:在创建表的定义语句CREATE TABLE中指定索引列,使用ALTER TABLE语句在存在的表上创建索引,或者使用CREATE INDEX语句在已存在的表上添加索引。
创建表的时候创建索引
隐式的创建索引
使用CREATE TABLE创建表时,除了可以定义列的数据类型外,还可以定义主键约束、外键约束或者唯一性约束,而不论创建哪种约束,在定义约束的同时相当于在指定列上创建了一个索引。
1 | CREATE TABLE dept( |
显式创建表时创建索引,基本语法格式如下:
1 | CREATE TABLE table_name( |
-
UNIQUE、FULLTEXT和SPATIAL为可选参数,分别表示唯一索引、全文索引和空间索引; -
INDEX与KEY为同义词,两者的作用相同,用来指定创建索引; -
index_name指定索引的名称,为可选参数,如果不指定,那么MySQL默认col_name为索引名; -
col_name为需要创建索引的字段列,该列必须从数据表中定义的多个列中选择; -
length为可选参数,表示索引的长度,只有字符串类型的字段才能指定索引长度;只有普通索引可以指定索引长度,唯一索引,主键索引,全文索引,空间索引均不能指定索引长度
-
ASC或DESC指定升序或者降序的索引值存储。
1)创建普通索引
在book表中的year_publication字段上建立普通索引,SQL语句如下:
1 | CREATE TABLE book( |
2)创建唯一索引
1 | CREATE TABLE test( |
3)主键索引
通过定义主键约束的方式定义主键索引
设定为主键后数据库会自动建立索引,innodb为聚簇索引,语法:
- 随表一起创建索引
1 | CREATE TABLE student ( |
- 删除主键索引
1 | ALTER TABLE student |
修改主键索引:必须先删除掉(drop)原索引,再新建(add)索引
4)创建单列索引
1 | CREATE TABLE test2( |
5)创建组合索引
1 | CREATE TABLE test3( |
6)创建全文索引
FULLTEXT全文索引可以用于全文检索,并且只为 CHAR 、VARCHAR 和 TEXT 列创建索引。索引总是对整个列进行,不支持局部 (前缀) 索引。
1 | CREATE TABLE test ( |
只有普通索引可以指定索引长度
1 | CREATE TABLE papers ( |
在MySQL5.7及之后版本中可以不指定最后的ENGINE了,因为在此版本中InnoDB支持全文索引。
全文索引的查询方式
不同于like方式的查询
1 | SELECT * FROM papers WHERE content LIKE '%查询字符串%'; |
全文索引用match+against方式查询
1 | SELECT * FROM papers WHERE MATCH(title,content) AGAINST('查询字符串'); |
注意
① 使用全文索引前,注意版本支持情况;
② 全文索引比 like + % 快 N 倍,但是可能存在精度问题;
③ 如果需要全文索引的是大量数据,建议先添加数据,再创建索引。
根据部分字符创建全文索引
在MySQL中,FULLTEXT索引不支持在索引中指定字段的前几个字符。FULLTEXT索引只能针对整个字段创建。
如果需要针对字段的前 50 个字符进行索引,可以考虑以下替代方案
① 使用虚拟字段
创建一个虚拟字段来存储前 50 个字符,并对该虚拟字段创建 FULLTEXT 索引
1 | CREATE TABLE test ( |
② 创建一个新字段并手动填充数据
1 | CREATE TABLE test ( |
7)创建空间索引
空间索引创建中,要求空间类型的字段必须为非空
1 | CREATE TABLE test( |
注意创建时,指定空间类型字段值的非空约束并且表的存储引擎为MyISAM
在已经存在的表上创建索引
- 使用
ALTER TABLE语句创建索引
1 | ALTER TABLE table_name |
- 使用
CREATE INDEX创建索引
1 | CREATE [UNIQUE | FULLTEXT | SPATIAL] INDEX index_name |
1.4 删除索引
- 使用
ALTER TABLE删除索引
1 | ALTER TABLE table_name DROP INDEX index_name; |
添加AUTO_INCREMENT约束字段的唯一索引不能被删除
AUTO_INCREMENT 要求字段必须是主键或者唯一键
- 使用
DROP INDEX语句删除索引
1 | DROP INDEX index_name ON table_name; |
删除表中的列时,如果要删除的列为索引的组成部分,则该列也会从索引中删除。如果组成索引的所有列都被删除,则整个索引将被删除。
2. MySQL8.0索引新特性
2.1 支持降序索引
降序索引以降序存储键值。虽然在语法上,从MySQL 4版本开始就已经支持降序索引的语法了,但实际上该DESC定义是被忽略的,直到MySQL 8.x版本才开始真正支持降序索引(仅限于InnoDB存储引擎)。
MySQL在8.0版本之前创建的仍然是升序索引,使用时进行反向扫描,这大大降低了数据库的效率。在某些场景下,降序索引意义重大。例如,如果一个查询,需要对多个列进行排序,且顺序要求不一致,那么使用降序索引将会避免数据库使用额外的文件排序操作,从而提高性能。
举例:降序索引在MySQL5.7和8.x中的区别
① 分别在MySQL 5.7版本和MySQL 8.0版本中创建数据表ts1,结果如下:
1 | CREATE TABLE ts1(a int,b int,index idx_a_b(a,b desc)); |
1 | *************************** MySQL5.7.26 *************************** |
MySQL5.7从结果可以看出,索引仍然是默认的升序。MySQL8.0中索引已经是降序了
② 分别在MySQL5.7版本和MySQL8.0版本的数据表ts1中插入800条随机数据,执行语句如下:
1 | DELIMITER // |
③ 查看数据表ts1的执行计划
在MySQL 5.7版本中查看数据表ts1的执行计划,结果如下:
从结果可以看出,执行计划中扫描数为800,而且使用了Using filesort。
1 | EXPLAIN SELECT * FROM ts1 ORDER BY a,b DESC LIMIT 5\G |
提示 Using filesort是MySQL中一种速度比较慢的外部排序,能避免是最好的。多数情况下,管理员
可以通过优化索引来尽量避免出现Using filesort,从而提高数据库执行速度。
在MySQL 8.0版本中查看数据表ts1的执行计划。从结果可以看出,执行计划中扫描数为5,而且没有使用
Using filesort。
1 | EXPLAIN SELECT * FROM ts1 ORDER BY a,b DESC LIMIT 5; |
注意 降序索引只对查询中特定的排序顺序有效,如果使用不当,反而查询效率更低。例如,上述
查询排序条件改为order by a desc, b desc,MySQL 5.7的执行计划要明显好于MySQL 8.0。
④ 将排序条件修改为order by a desc, b desc后,对比不同版本中执行计划的效果。
在MySQL5.7版本中查看数据表ts1的执行计划,在MySQL 8.0版本中查看数据表ts1的执行计划。
1 | EXPLAIN SELECT * FROM ts1 ORDER BY a DESC,b DESC LIMIT 5; |
从结果可以看出,修改后MySQL 5.7的执行计划要明显好于MySQL8.0。
2.2 隐藏索引
在MySQL5.7版本及之前,只能通过显式的方式删除索引。此时,如果发现删除索引后出现错误,又只能通过显式创建索引的方式将删除的索引创建回来。如果数据表中的数据量非常大,或者数据表本身比较大,这种操作就会消耗系统过多的资源,操作成本非常高。
从MySQL8.x开始支持隐藏索引(invisible indexes),只需要将待删除的索引设置为隐藏索引,使查询优化器不再使用这个索引(即使使用force index(强制使用索引),优化器也不会使用该索引),确认将索引设置为隐藏索引后系统不受任何响应,就可以彻底删除索引。这种通过先将索引设置为隐藏索引,再删除索引的方式就是软删除。
同时,如果你想验证某个索引删除之后的查询性能影响,就可以暂时先隐藏该索引。
注意:
主键不能被设置为隐藏索引。
当表中没有显式主键时,表中第一个唯一非空索引会成为隐式主键,也不能设置为隐藏索引。
索引默认是可见的,在使用CREATE TABLE、CREATE INDEX或者ALTER TABLE等语句时可以通过VISIBLE或者INVISIBLE关键词设置索引的可见性。
1)创建表时直接创建
在MySQL中创建隐藏索引通过SQL语句INVISIBLE来实现,其语法形式如下:
1 | CREATE TABLE tablename( |
上述语句比普通索引多了一个关键字INVISIBLE,用来标记索引为不可见索引。
2)在已经存在的表上创建
1 | CREATE INDEX indexname |
3)通过ALTER TABLE语句创建
1 | ALTER TABLE tablename |
4)切换索引可见状态
1 | ALTER TABLE tablename ALTER INDEX index_name INVISIBLE; #切换成隐藏索引 |
注意
当索引被隐藏时,它的内容仍然是和正常索引一样实时更新的。如果一个索引需要长期被隐藏,那么可以将其删除,因为索引的存在会影响插入、更新和删除的性能。
通过设置隐藏索引的可见性可以查看索引对调优的帮助。
5)使隐藏索引对查询优化器可见
在MySQL8.x版本中,为索引提供了一种新的测试方式,
可以通过查询优化器的一个开关use_invisible_indexes来打开某个设置,使隐藏索引对查询优化器可见。如果use_invisible_indexes设置为off(默认),优化器会忽略隐藏索引。如果设置为on,即使隐藏索引不可见,优化器在生成执行计划时仍会考虑使用隐藏索引。
1)在MySQL命令行执行如下命令查看查询优化器的开关设置。
1 | select @@optimizer_switch \G |
在输出的结果信息中找到如下属性配置。
1 | use_invisible_indexes=off |
此属性配置值为off,说明隐藏索引默认对查询优化器不可见。
2)使隐藏索引对查询优化器可见,需要在MySQL命令行执行如下命令:
1 | set session optimizer_switch="use_invisible_indexes=on"; # 可见 |
SQL语句执行成功,再次查看查询优化器的开关设置。
1 | select @@optimizer_switch \G |
此时,在输出结果中可以看到如下属性配置
1 | use_invisible_indexes=on |
use_invisible_indexes属性的值为on,说明此时隐藏索引对查询优化器可见
3. 索引的设计原则
3.1 数据准备
第1步:创建数据库、创建表
1 | CREATE DATABASE atguigudb1; |
第2步:创建模拟数据必需的存储函数
1 | # 函数1:创建随机产生字符串函数 |
chars_str 包含 26 个小写字母和 26 个大写字母,总共 52 个字符
RAND():生成一个 0 到 1 之间的随机浮点数。[0,1)
RAND() * 52:将随机数放大到 0 到 52 之间。[0,52)
FLOOR(1 + RAND() * 52):将浮点数截断为整数,使结果在 1 到 52 之间(包括 1 和 52)。
1 | # 函数2:创建随机数函数 |
创建函数,假如报错:
1 | This function has none of DETERMINISTIC...... |
由于开启过慢查询日志bin-log, 必须为function指定DETERMINISTIC或NOT DETERMINISTIC属性。
主从复制,主机会将写操作记录在bin-log日志中。从机读取bin-log日志,执行语句来同步数据。如果使用函数来操作数据,可能会导致从机和主机执行的结果不一致,因为这些函数可能依赖于某些条件(如当前时间、随机数等)。所以,默认情况下,mysql不开启创建函数设置。
-
查看mysql是否允许创建函数:
1
show variables like 'log_bin_trust_function_creators';
-
命令开启:允许创建函数设置:
1
set global log_bin_trust_function_creators=1; # 不加global只是当前窗口有效。
-
mysqld重启,上述参数又会消失。永久方法:
- windows下:my.ini[mysqld]加上:
1
log_bin_trust_function_creators=1
- linux下:/etc/my.cnf下my.cnf[mysqld]加上:
1
log_bin_trust_function_creators=1
第3步:创建插入模拟数据的存储过程
1 | # 存储过程1:创建插入课程表存储过程 |
1 | # 存储过程2:创建插入学生信息表存储过程 |
第4步:调用存储过程
1 | CALL insert_course(100); |
3.2 哪些情况适合创建索引
1)字段的数值有唯一性的限制
索引本身可以起到约束的作用,比如唯一索引、主键索引都可以起到唯一性约束的,因此在数据表中,如果某个字段是唯一的,就可以直接创建唯一性索引,或者主键索引。这样可以更快速地通过该索引来确定某条记录。
业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。(来源:Alibaba)
说明:不要以为唯一索引影响了insert速度,这个速度损耗可以忽略,但提高查找速度是明显的。
2)频繁作为WHERE查询条件的字段
某个字段在SELECT语句的WHERE条件中经常被使用到,那么就需要给这个字段创建索引了。尤其是在数据量大的情况下,创建普通索引就可以大幅提升数据查询的效率。
3)经常GROUP BY和ORDER BY的列
索引就是让数据按照某种顺序进行存储或检索,因此当使用 GROUP BY 对数据进行分组查询,或者使用 ORDER BY 对数据进行排序的时候,就需要对分组或者排序的字段进行索引。如果待排序的列有多个,那么可以在这些列上建立组合索引。
同时存在GROUP BY和ORDER BY时,可以建立联合索引,联合索引顺序先GROUP BY字段,再ORDER BY字段,这样效率是最高的。
说明:多个单列索引在多条件查询时 只会生效一个索引 (MySQL 会选择其中一个限制最严格的作为索引),所以在多条件联合查询的时候 最好创建联合索引。
4)UPDATE、DELETE 的 WHERE 条件列
1 | UPDATE table_name |
UPDATE和DELETE语句需要先根据 WHERE 条件列检索出对应记录,然后再对它进行更新或删除。所以对WHERE 字段创建索引,能提升效率。如果进行更新的时候,更新的字段是非索引字段,提升的效率会更明显,这是因为非索引字段更新不需要对索引进行维护。
5)DISTINCT 字段需要创建索引
使用 DISTINCT 关键字对某个字段进行去重时,对这个字段创建索引,也会提升查询效率。
索引会把数据按照某种顺序进行排序,所以去重的时候也会快很多。
6)多表 JOIN 连接操作时,创建索引注意事项
首先,连接表的数量尽量不要超过 3 张,因为每增加一张表就相当于增加了一次嵌套的循环,数量级增长会非常快,严重影响查询的效率。
其次,对 WHERE 条件创建索引,因为 WHERE 才是对数据条件的过滤。如果在数据量非常大的情况下,没有 WHERE 条件过滤是非常可怕的。
最后,对用于连接的字段创建索引,并且该字段在多张表中的类型必须一致。
7)使用列的类型小的创建索引
这里所说的类型大小指的就是该类型表示的数据范围的大小。
在定义表结构的时候要显式的指定列的类型,以整数类型为例,有TINYINT、MEDIUMINT、INT、BIGINT等,它们占用的存储空间依次递增,能表示的整数范围当然也是依次递增。如果想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如能使用INT就不要使用BIGINT,能使用MEDIUMINT就不要使用INT。这是因为:
- 数据类型越小,在查询时进行的比较操作越快
- 数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以
放下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。
这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键使用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/O。
8)使用字符串前缀创建索引
假设字符串很长,那存储一个字符串就需要占用很大的存储空间。当需要为这个字符串列建立索引时,那就意味着在对应的B+树中有这么两个问题:
- B+树索引中的记录需要把该列的完整字符串存储起来,更费时。而且字符串越长,
在索引中占用的存储空间越大。 - 如果B+树索引中索引列存储的字符串很长,那在做字符串
比较时会占用更多的时间。
我们可以通过截取字段的前面一部分内容建立索引,这个就叫前缀索引。这样在查找记录时虽然不能精确的定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同的记录的主键值回表查询完整的字符串值。既节约空间,又减少了字符串的比较时间,还大体能解决排序的问题。
例如,TEXT和BLOG类型的字段,进行全文检索会很浪费时间,如果只检索字段前面的若干字符,这样可以提高检索速度。
截取长度问题
问题是,截取多少呢?截取得多了,达不到节省索引存储空间的目的;截取得少了,重复内容太多,字段的散列度(选择性、区分度)会降低。怎么计算不同的长度的选择性呢?
区分度计算公式:越接近1是越好的
1 | # 字段在全部数据中的区分度 |
引申另一个问题:索引列前缀对排序的影响
如果使用了索引列前缀,比如说边只把address列的前12个字符放到了二级索引中,下边这个查询可能就有点儿尴尬了:
1 | SELECT * FROM shop |
因为二级索引中不包含完整的address列信息,所以无法对前12个字符相同,后边的字符不同的记录进行排序,也就是说使用索引列前缀的方式无法支持使用索引排序,只能使用文件排序。
拓展: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.3 限制索引的数目
在实际工作中,需要注意平衡,索引的数目不是越多越好。需要限制每张表上的索引数量,建议单张表索引数量不超过6个。原因:
- 每个索引都需要占用
磁盘空间,索引越多,需要的磁盘空间就越大。 - 索引会影响
INSERT、DELETE、UPDATE等语句的性能,因为表中的数据更改的同时,索引也会进行调整和更新,会造成负担。 - 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的
索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,会增加MySQL优化器生成执行计划时间,降低查询性能。
3.4 哪些情况不适合创建索引
1)在where中使用不到的字段,不要设置索引
**WHERE条件(包括GROUP BY、ORDER BY)**里用不到的字段不需要创建索引,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的。
2)数据量小的表最好不要使用索引
在数据表中的数据行数比较少的情况下,比如不到1000行,是不需要创建索引的。
这种情况下,是否使用索引,查询花费的时间相差不大,甚至使用索引查询花费的时候反而更多。
3)有大量重复数据的列上不要建立索引
字段中如果有大量重复数据,不要创建索引。
比如在学生表的性别字段上只有男与女两个不同值,因此无须建立索引。如果建立索引,不但不会提高查询效率,反而会严重降低数据更新速度。
举例:要在100万行数据中查找其中的50万行(比如性别为男的数据),一旦建立了索引,需要先访问这50万次索引,然后再访问50万次数据表,这样加起来的开销比不使用索引可能还要大。
结论:当数据重复度大,比如
高于10%的时候,不需要对这个字段使用索引
4)避免对经常更新的表创建过多的索引
第一层含义:频繁更新的字段不一定要创建索引。因为更新数据的时候,也需要更新索引,在更新索引的时候也会造成负担,从而影响效率。
第二层含义:避免对经常更新的表创建过多的索引,并且索引中的列尽可能少。索引虽然提高了查询速度,同时却会降低更新表的速度。
5)不建议用无序的值作为索引
例如身份证、UUID(在索引比较时需要转为ASCII,并且插入时可能造成页分裂)、MD5、HASH、无序长字符串等。
6)删除不再使用或者很少使用的索引
7)不要定义冗余或重复的索引
① 冗余索引
有时候有意或者无意的就对同一个列创建了多个索引
比如:index(a,b,c)相当于index(a)、index(a,b)、index(a,b,c)。
② 重复索引
比如在主键约束的列上,创建了唯一索引或者普通索引
第09章 性能分析工具的使用
数据库调优的目标 响应时间更快, 吞吐量更大 。
利用宏观的监控工具和微观的日志分析可以帮我们快速找到调优的思路和方式。
1. 数据库服务器的优化步骤
当我们遇到数据库调优问题的时候,该如何思考呢?这里把思考的流程整理成下面这张图。
整个流程划分成了 观察(Show status) 和 行动(Action) 两个部分。字母 S 的部分代表观察(会使 用相应的分析工具),字母 A 代表的部分是行动(对应分析可以采取的行动)。
通过观察了解数据库整体的运行状态,通过性能分析工具查询执行慢的SQL语句,查看具体的SQL执行计划,甚至是SQL执行中的每一步的成本代价,这样才能定位问题所在,找到了问题,再采取相应的行动。
详细解释一下这张图
首先在S1部分,我们需要观察服务器的状态是否存在周期性的波动。如果存在周期性波动,有可能是周期性节日的原因,比如双十一,促销活动等。这种情况下,我们可以通过A1这一步骤解决,也就是加缓存,或者更改缓存失效策略。
如果缓存策略没有解决,或者不是周期性波动的原因,我们就需要进一步分析查询延迟和卡顿的原因。接下来进入 S2这一步骤,开启慢查询。慢查询可以定位执行慢的SQL语句。通过设置long_query_time参数定义慢的阈值,如果SQL执行时间超过了long_query_time,则认为是慢查询。当收集到这些慢查询之后,就可以通过分析工具对慢查询日志进行分析。
在S3这一步骤中,知道了执行慢的SQL语句,可以对此针对性地用EXPLAIN查看对应SQL语句的执行计划,或者使用show profile查看SQL每一个步骤的时间成本。从而分析SQL查询慢的原因,是因为执行计划不好,还是等待的时间长。
如果是SQL等待时间长,进入A2步骤。调优服务器的参数,比如适当增加数据库缓存池等。如果是SQL执行时间长,就进入A3步骤,考虑索引设计的问题?还是查询关联的数据表过多?还是因为数据表的字段设计问题。然后在这些维度上进行对应的调整。
如果A2和A3都不能解决问题,需要考虑数据库自身的SQL查询性能是否已经达到了瓶颈,如果确认没有达到性能瓶颈,就需要重新检查,重复以上的步骤。如果已经达到了性能瓶颈,进入A4阶段,需要考虑增加服务器,采用读写分离的架构,或者考虑对数据库进行分库分表,比如垂直分库,垂直分表和水平分表等。
2. 查看系统性能参数
在MySQL中,可以使用SHOW STATUS语句查询一些MySQL数据库服务器的性能参数、执行频率。
SHOW STATUS语句语法如下:
1 | SHOW [GLOBAL|SESSION] STATUS LIKE '参数'; |
一些常用的性能参数如下:
- Connections:连接MySQL服务器的次数。
- Uptime:MySQL服务器的上线时间。
- Slow_queries:慢查询的次数。
- Innodb_rows_read:Select查询返回的行数
- Innodb_rows_inserted:执行INSERT操作插入的行数
- Innodb_rows_updated:执行UPDATE操作更新的行数
- Innodb_rows_deleted:执行DELETE操作删除的行数
- Com_select:查询操作的次数。
- Com_insert:插入操作的次数。对于批量插入的INSERT操作,只累加一次。
- Com_update:更新操作的次数。
- Com_delete:删除操作的次数。
若查询MySQL服务器的连接次数,则可以执行如下语句:
1 | SHOW STATUS LIKE 'Connections'; |
若查询服务器工作时间,则可以执行如下语句:
1 | SHOW STATUS LIKE 'Uptime'; |
若查询MySQL服务器的慢查询次数,则可以执行如下语句:
1 | SHOW STATUS LIKE 'Slow_queries'; |
慢查询次数参数可以结合慢查询日志找出慢查询语句,然后针对慢查询语句进行表结构优化或者查询语句优化。
再比如,如下的指令可以查看相关的指令情况:
1 | SHOW STATUS LIKE 'Innodb_rows_%'; |
3. 统计SQL的查询成本:last_query_cost
一条SQL查询语句在执行前需要查询执行计划,如果存在多种执行计划的话,MySQL会计算每个执行计划所需要的成本,从中选择成本最小的一个作为最终执行的执行计划。
如果想要查看某条SQL语句的查询成本,可以在执行完这条SQL语句之后,通过查看当前会话中的last_query_cost变量值来得到当前查询的成本。它通常也是我们评价一个查询的执行效率的一个常用指标。这个查询成本对应的是SQL语句所需要读取的读页的数量。
1 | SHOW STATUS LIKE 'last_query_cost'; |
以第8章的student_info表为例:
1 | CREATE TABLE `student_info` ( |
查询id=900001的记录,查看查询成本,直接在聚簇索引上进行查找:
1 | SELECT student_id, class_id, NAME, create_time FROM student_info WHERE id = 900001; |
运行结果(1条记录,运行时间为0.042s)
然后再看下查询优化器的成本,实际上我们只需要检索一个页即可:
1 | mysql> SHOW STATUS LIKE 'last_query_cost'; |
想要查询id在900001到9000100之间的学生记录
1 | SELECT student_id, class_id, NAME, create_time FROM student_info WHERE id BETWEEN 900001 AND 900100; |
运行结果(100条记录,运行时间为0.046s)
然后再看下查询优化器的成本,这时我们大概需要进行20个页的查询。
1 | mysql> SHOW STATUS LIKE 'last_query_cost'; |
页的数量是刚才的20倍,但是查询的效率并没有明显的变化,实际上这两个SQL查询的时间基本上一样,就是因为采用了顺序读取的方式将页面一次性加载到缓冲池中,然后再进行查找。虽然页数量(last_query_cost)增加了不少,但是通过缓冲池的机制,并没有增加多少查询时间。
使用场景:它对于比较开销是非常有用的,特别是我们有好几种查询方式可选的时候。
SQL查询是一个动态的过程,从页加载的角度来看,我们可以得到以下两点结论:
位置决定效率。如果页就在数据库缓冲池中,那么效率是最高的,否则还需要从内存或者磁盘中进行读取,当然针对单个页的读取来说,如果页存在于内存中,会比在磁盘中读取效率高很多。
批量决定效率。如果我们从磁盘中对单一页进行随机读,那么效率是很低的(差不多10ms),而采用顺序读取的方式,批量对页进行读取,平均一页的读取效率就会提升很多,甚至要快于单个页面在内存中的随机读取。所以说,遇到I/O并不用担心,方法找对了,效率还是很高的。我们首先要考虑数据存放的位置,如果是经常使用的数据就要尽量放到
缓冲池中,其次我们可以充分利用磁盘的吞吐能力,一次性批量读取数据,这样单个页的读取效率也就得到了提升。
4. 定位执行慢的SQL:慢查询日志
MySQL的慢查询日志,用来记录在MySQL中响应时间超过阈值的语句,具体指运行时间超过long_query_time的值的SQL,则会被记录到慢查询日志中。long_query_time的默认值为10,意思是运行10秒以上(不含10秒)的语句,认为是超出了我们的最大忍耐时间值。
它的主要作用是,帮助我们发现那些执行时间特别长的SQL查询,并且有针对性地进行优化,从而提高系统的整体效率。当我们的数据库服务器发生阻塞、运行变慢的时候,检查一下慢查询日志,找到那些慢查询,结合explain进行全面分析。
默认情况下,MySQL数据库没有开启慢查询日志,需要我们手动来设置这个参数。如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。
慢查询日志支持将日志记录写入文件。
4.1 开启慢查询日志参数
1)开启slow_query_log
1 | set global slow_query_log='ON'; |
查看慢查询日志开启状态,以及慢查询日志文件的位置:
1 | show variables like '%slow_query_log%'; |
2)修改long_query_time阈值
1 | show variables like '%long_query_time%'; # 默认值是10s |
1 | # 测试发现:设置global的方式对当前session的long_query_time失效。对新连接的客户端有效。 |
3)配置文件中持久化设置参数
修改 my.cnf 文件,[mysqld]下增加或修改参数long_query_time、slow_query_log和 slow_query_log_file后,然后重启MySQL服务器。
1 | [mysqld] |
如果不指定存储路径,慢查询日志默认存储到MySQL数据库的数据文件夹下。如果不指定文件名,默认文件名为hostname_slow.log。
4.2 查看慢查询数目
1 | SHOW GLOBAL STATUS LIKE '%Slow_queries%'; |
补充说明
除了上述变量,控制慢查询日志的还有一个系统变量:
min_examined_row_limit。这个变量的意思是,查询扫描过的最少记录数。这个变量和查询执行时间,共同组成了判别一个查询是否是慢查询的条件。如果查询扫描过的记录数大于等于这个变量的值,并且查询执行时间超过 long_query_time 的值,那么,这个查询就被记录到慢查询日志中;反之,则不被记录到慢查询日志中。
1
2
3
4
5
6 >show variables like 'min%';
>+------------------------+-------+
>| Variable_name | Value |
>+------------------------+-------+
>| min_examined_row_limit | 0 |
>+------------------------+-------+这个值默认为0。与long_query_time=10合在一起,表示只要查询的执行时间超过10秒钟,哪怕一个记录也没有扫描过,都要被记录到慢查询日志中。可以根据需要,通过修改配置文件,来修改查询时长,或者通过SET指令,用SQL语句修改“min_examined_row_limit”的值。
4.3 慢查询日志分析工具:mysqldumpslow
1 | # mysqldumpslow --help |
-a: 不将数字抽象成N,字符串抽象成S
-s: 是表示按照何种方式排序:
-
at:平均查询时间(默认方式) t: 查询时间
-
al:平均锁定时间 l: 锁定时间
-
ar:平均返回记录数 r: 返回记录
-
c: 访问次数
-g: 后边搭配一个正则匹配模式,大小写不敏感的
-r: 默认结果排序从大到小,使用该参数后,从大到小排序
-r参数在-s和-t之后影响查询结果的排序。具体来说,首先会根据
-s ORDER指定的字段进行排序,然后会根据-t NUM显示前NUM个查询结果,最后-r参数会反转这些结果的排序顺序。所以,整个处理过程是先排序,再取前
NUM个查询,然后反转排序顺序。
举例:按照查询时间排序,查看前五条SQL语句,这样写即可
1 | # mysqldumpslow -s t -t 5 /var/lib/mysql/localhost-slow.log |
工作常用参考
1 | 得到返回记录集最多的10个SQL |
4.4 关闭慢查询日志
方式1:永久性方式
1 | [mysqld] |
方式2:临时性方式
1 | SET GLOBAL slow_query_log=off; |
4.5 删除慢查询日志
1 | show variables like '%slow_query_log%'; |
慢查询日志的目录默认为MySQL的数据目录,在该目录下手动删除慢查询日志文件即可。
使用命令mysqladmin flush-logs来重新生成查询日志文件,具体命令如下,执行完成会在数据目录下重新生成慢查询日志文件。
1 | mysqladmin -uroot -p flush-logs slow |
提示
慢查询日志都是使用mysqladmin flush-logs命令来删除重建的。使用时一定要注意,一旦执行了这个命令,慢查询日志都只存在新的日志文件中,如果需要旧的查询日志,就必须事先备份
5.查看SQL执行成本:SHOW PROFILE
show profile 是 MySQL 提供的可以用来分析当前会话中 SQL 都做了什么、执行的资源消耗工具的情况,可用于 sql 调优的测量。默认情况下处于关闭状态,并保存最近15次的运行结果。
1 | show variables like 'profiling'; |
**show profile的常用查询参数: **
① ALL:显示所有的开销信息。
② BLOCK IO:显示块IO开销。
③ CONTEXT SWITCHES:上下文切换开销。
④ CPU:显示CPU开销信息。
⑤ IPC:显示发送和接收开销信息。
⑥ MEMORY:显示内存开销信息。
⑦ PAGE FAULTS:显示页面错误开销信息。
⑧ SOURCE:显示和Source_function,Source_file,Source_line相关的开销信息。
⑨ SWAPS:显示交换次数开销信息。
日常开发需注意的结论:
① converting HEAP to MyISAM: 查询结果太大,内存不够,数据往磁盘上搬了。
② Creating tmp table:创建临时表。先拷贝数据到临时表,用完后再删除临时表。
③ Copying to tmp table on disk:把内存中临时表复制到磁盘上,警惕!
④ locked。
如果在show profile诊断结果中出现了以上4条结果中的任何一条,则sql语句需要优化。
注意:
不过SHOW PROFILE命令将被启用,我们可以从information_schema中的profiling数据表进行查看。
sending data时间说明
mysql5.7文档解释
Sending data 线程正在为SELECT语句读取和处理行,并将数据发送到客户端。由于在此状态下进行的操作往往涉及大量磁盘访问(读取),因此它通常是一个查询生命周期中运行时间最长的状态。
executing The thread has begun executing a statement.
没有建立索引的查询情况下,executing很快,Sending data很慢
猜测:在mysql5.7中executing只代表开始执行,执行的时间在Sending data中
mysql8文档解释
mysql8.0中,Sending data 包含在executing中
MySQL 8.4 Reference Manual :: 10.14.3 General Thread States
经执行验证,mysql8.0中没有Sending data,执行时间在executing中。
总之,理解成执行时间一部分
6. 分析查询语句:EXPLAIN
6.1 概述
**定位了查询慢的SQL之后,就可以使用EXPLAIN或DESCRIBE工具针对性的分析查询语句。**DESCRIBE语句
的使用方法与EXPLAIN语句是一样的, 并且分析结果也是一样的。
MySQL中有专门负责优化SELECT语句的优化器模块,主要功能: 通过计算分析系统中收集到的统计信息, 为客户端请求的Query提供它认为是最优的执行计划(他认为最优的数据检索方式,但不见得是DBA认为是最优的, 这部分最耗费时间) 。
这个执行计划展示了接下来具体执行查询的方式, 比如多表连接的顺序是什么,对于每个表采用什么访问方法来
具体执行查询等等。MySQL为我们提供了EXPLAIN语句来帮我们查看某个查询语句的具体执行计划, 大家看懂
EXPLAIN语句的各个输出项, 可以有针对性的提升我们查询语句的性能。
1. 能做什么?
- 表的读取顺序
- 数据读取操作的操作类型
- 哪些索引可以使用
- 哪些索引被实际使用
- 表之间的引用
- 每张表有多少行被优化器查询
2. 官网介绍
https://dev.mysql.com/doc/refman/5.7/en/explain-output.html
https://dev.mysql.com/doc/refman/8.0/en/explain-output.html
MySQL Reference Manual/optimization/Understanding the Query Execution Plan/EXPLAIN Output Format
3. 版本情况
- MySQL5.6.3以前只能
EXPLAIN SELECT;MYSQL5.6.3以后就可以EXPLAIN SELECT,UPDATE,DELETE - 在5.7以前的版本中,想要显示
partitions需要使用explain partitions命令;想要显示filtered需要使用explain extended命令。在5.7版本后,默认explain直接显示partitions和filtered中的信息。
6.2 基本语法
EXPLAN可以查看语句的执行计划,执行EXPLAN时并没有真正的执行后面的语句。
1 | EXPLAIN SELECT select_options |
EXPLAIN语句输出的各个列的作用如下:
| 列名 | 描述 |
|---|---|
id |
在一个大的查询语句中每个SELECT关键字都对应一个唯一的id |
select_type |
SELECT关键字对应的那个查询的类型 |
table |
表名 |
partitions |
匹配的分区信息 |
type |
针对单表的访问方法 |
possible_keys |
可能用到的索引 |
key |
实际上使用的索引 |
key_len |
实际使用到的索引长度 |
ref |
当使用索引列等值查询时,与索引列进行等值匹配的对象信息 |
rows |
预估的需要读取的记录条数 |
filtered |
某个表经过搜索条件过滤后剩余记录条数的百分比 |
Extra |
一些额外的信息 |
6.3 数据准备
1)建表
建立两张相同的表s1,s2
1 | CREATE TABLE s1 ( |
2)设置参数 log_bin_trust_function_creators
创建函数,假如报错,需开启如下命令:允许创建函数设置:
1 | set global log_bin_trust_function_creators=1; # 不加global只是当前窗口有效。 |
3)创建随机字符串函数
1 | DELIMITER // |
4)创建存储过程
创建两个存储过程,分别插入数据到s1,s2
1 | DELIMITER // |
5)调用存储过程
1 | CALL insert_s1(10001,10000); |
6.4 EXPLAIN各列作用
1)table
不论查询语句有多复杂,包含了多少个表,到最后也是需要对每个表进行单表访问的,所以MySQL规定EXPLAIN语句输出的每条记录都对应着某个单表的访问方法,该条记录的table列代表着该表的表名(有时不是真实的表名字,可能是简称)。
1 | EXPLAIN SELECT * FROM s1 JOIN s2 on s1.key1=s2.key1; |
临时表也有对应记录,比如UNION语句
UNION 需要对表进行去重,所以会产生临时表
注意 UNION ALL不需要去重,所以不会有临时表
1 | EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2; |
2)id
查询语句中每出现一个SELECT关键字,MySQL就会为它分配一个唯一的id值。这个id值就是EXPLAIN语句的第一个列。
- id如果相同,可以认为是一组,从上往下顺序执行
- 在所有组中,id值越大,优先级越高,越先执行
- 关注点:id号每个号码,表示一趟独立的查询,一个sql的查询趟数越少越好
2.1)连接查询
对于连接查询来说,一个SELECT关键字后边的FROM子句中可以跟随多个表,所以在连接查询的执行计划中,每个表都会对应一条记录,但是这些记录的id值都是相同的。出现在前边的表表示驱动表,出现在后边的表表示 被驱动表
在join连接查询中,驱动表在SQL语句执行的过程中总是先被读取。而被驱动表在SQL语句执行的过程中总是后被读取。在读取驱动表数据后,放入到join_buffer后,再去读取被驱动表中的数据来和驱动表中的数 据进行匹配。如果匹配成功,就返回结果,否则该丢弃,继续匹配下一条
2.2)查询中包含子查询
包含子查询的查询语句来说,就可能涉及多个SELECT关键字,所以在包含子查询的查询语句的执行计划中,每个SELECT关键字都会对应一个唯一的id值
1 | EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3='a'; |
查询优化器可能对涉及子查询的查询语句进行重写,从而转换为连接查询
在大多数现代数据库系统中,查询优化器会尝试将子查询重写为等价的连接操作(JOIN),以提高执行效率。这种优化过程称为子查询去关联(Subquery Unnesting)或子查询扁平化(Subquery Flattening)。
1 | ######查询优化器可能对涉及子查询的查询语句进行重写###### |
1 | SELECT s1.* |
虽然查询语句是一个子查询,但是执行计划中s1和s2表对应的记录的id值全部是1,这就表明了查询优化器将子查询转换为了连接查询
2.3)联合查询
联合查询也会涉及多个SELECT关键字,每个SELECT关键字都会对应一个唯一的id值
3)select_type
SELECT关键字对应的那个查询的类型,确定小查询在整个大查询中扮演了一个什么角色
一条大的查询语句里边可以包含若干个SELECT关键字,每个SELECT关键字代表着一个小的查询语句,而每个SELECT关键字的FROM子句中都可以包含若干张表(这些表用来做连接查询),每一张表都对应着执行计划输出中的一条记录,对于在同一个SELECT关键字中的表来说,它们的id值是相同的。
MySQL为每一个SELECT关键字代表的小查询都定义了一个称之为select_type的属性,意思是我们只要知道了某个小查询的select_type属性,就知道了这个小查询在整个大查询中扮演了一个什么角色
select_type取值
| 名称 | 描述 |
|---|---|
SIMPLE |
Simple SELECT (not using UNION or subqueries) |
PRIMARY |
Outermost SELECT 最外层的SELECT 指包含子查询的SELECT语句最外层的SELECT |
UNION |
Second or later SELECT statement in a UNION |
UNION RESULT |
Result of a UNION |
SUBQUERY |
First SELECT in subquery |
DEPENDENT SUBQUERY |
First SELECT in subquery, dependent on outer query |
DEPENDENT UNION |
Second or later SELECT statement in a UNION, dependent on outer query |
DERIVED |
Derived table |
MATERIALIZED |
Materialized subquery |
UNCACHEABLE SUBQUERY |
A subquery for which the result cannot be cached and must be re-evaluated for each row of the outer query |
UNCACHEABLE UNION |
The second or later select in a UNION that belongs to an uncacheable subquery (see UNCACHEABLE SUBQUERY) |
**3.1)SIMPLE **
查询语句中不包含UNION或者子查询的查询都是SIMPLE类型。
1 | EXPLAIN SELECT * FROM s1; # 单表查询 |
3.2)PRIMARY、UNION、UNION RESULT
UNION联合查询其左边的查询是PRIMARY,右边的查询类型是UNION,去重的临时表查询类型是:UNION RESULT
- 对于包含 UNION 或者 UNION ALL 的大查询来说,它是由几个小查询组成的,其中最左边的那个查询的 select_type 值就是
PRIMARY,其余的小查询的 select_type 值就是UNION - MySQL选择使用临时表来完成 UNION 查询的去重工作,针对该临时表的查询的 select_type 就是
UNION RESULT
对于包含子查询的大查询来说,其中最左边的那个查询的 select_type 值就是PRIMARY
1 | EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2; |
3.3)SUBQUERY、DEPENDENT SUBQUERY
-
如果包含子查询的查询语句不能够转为对应的多表连接的形式(
semi-join的形式)(也就是不会被优化器进行自动的优化),并且该子查询是不相关的子查询该子查询的第一个
SELECT关键字代表的那个查询的 select_type 就是SUBQUERY。也就是外层查询是 Primary ,内层查询是 SUBQUERY
1 | EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3 ='a'; |
-
如果子查询不能被转换为多表连接的形式,并且该子查询是相关子查询。
比如下面的查询在内部子查询使用了外部的表。则该子查询的第一个 SELECT 关键字代表的那个查询的 select_type 就是
DEPENDENT SUBQUERY。外层查询是 Primary ,内层查询是 DEPENDENT SUBQUERY
1 | EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE s1.key2 = s2.key2) OR key3 = 'a'; |
select_type为
DEPENDENT SUBQUERY的查询可能会被执行多次,相关子查询依赖于外部查询中的列值。这使得子查询在每次外部查询处理行时都需要重新执行
相关子查询和不相关子查询概念回顾
相关子查询(Correlated Subquery)是指子查询中引用了外部查询中的列,子查询的执行依赖于外部查询中的每一行。每次外部查询处理一行数据,子查询都会重新执行一次
不相关子查询(Non-Correlated Subquery)是指子查询独立于外部查询,可以独立执行。子查询的结果在外部查询执行之前就已经确定。
3.4)DEPENDENT UNION
在包含 UNION 或者 UNION ALL 的大查询中,如果各个小查询都依赖于外查询的话,那么除了最左边的小查询外,其余各个小查询的 select_type 值都是DEPENDENT UNION
1 | EXPLAIN SELECT * FROM s1 WHERE key1 IN |
优化器有时会把IN优化成EXISTS语句,使得上面的语句变成相关子查询
3.5)DERIVED
对于包含派生表的查询,该派生表对应的子查询的 select_type 就是DERIVED
1 | EXPLAIN SELECT * FROM |
3.6)MATERIALIZED
当查询优化器在执行包含子查询的语句时,选择将子查询物化之后与外层查询进行连接查询时,
该子查询对应的select_type属性就是MATERIALIZED
1 | EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2);#子查询被转为了物化表 |
select_type 属性为 MATERIALIZED 代表查询类型为物化查询,这表示该查询是一个物化表的创建或刷新操作。
外层查询,访问一张临时表(物化表)和一张普通表,查询的类型是简单查询SIMPLE
UNCACHEABLE SUBQUERY UNCACHEABLE UNION 不常用,省略。
4)partitions
代表分区表中的命中情况,非分区表,该项为NULL。一般情况下查询语句的执行计划的partitions列的值都是NULL
官方文档 如果想详细了解,可以如下方式测试。创建分区表
1 | -- 创建分区表, |
查询 id 大于200(200>100,p1分区)的记录,查看执行计划,partitions 是 p1,符合我们的分区规则
1 | DESC SELECT * FROM user_partitions WHERE id>200; |
5)type(重点)
执行计划的一条记录就代表着MySQL对某个表的执行查询时的访问方法,又称"访问类型”,其中的type列就表明了这个访问方法是啥,是较为重要的一个指标。比如,看到type列的值是ref,表明MySQL即将使用ref访问方法来执行对s1表的查询
完整的访问方法如下,结果值从最好到最坏依次是:
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。比方说我们新建一个MyISAM表,并为其插入一条记录:
1 | CREATE TABLE t(i int) Engine=MyISAM; |
把表改成使用InnoDB存储引擎,则执行计划的
type列是ALL
const
当我们根据主键或者唯一二级索引列与常数进行等值匹配时,对单表的访问方法就是const, 比如:
1 | EXPLAIN SELECT * FROM s1 WHERE id = 10005; |

eq_ref
在连接查询时,如果被驱动表是通过主键或者唯一二级索引列等值匹配的方式进行访问的(如果该主键或者唯一二级索引是联合索引的话,所有的索引列都必须进行等值比较)。则对该被驱动表的访问方法就是eq_ref,比方说:
1 | EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id; |

从执行计划的结果中可以看出,MySQL打算将s2作为驱动表,s1作为被驱动表,重点关注s1的访问 方法是 eq_ref ,表明在访问s1表的时候可以 通过主键的等值匹配 来进行访问。
ref
当通过普通的二级索引列与常量进行等值匹配时来查询某个表,那么对该表的访问方法就可能是ref
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; |

要求不存在隐式转换,存在隐式转换,索引失效,type类型为ALL
-
fulltext全文索引 -
ref_or_null
当对普通二级索引进行等值匹配查询,该索引列的值也可以是NULL值时,那么对该表的访问方法就可能是ref_or_null,比如说:
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' OR key1 IS NULL; |
index_merge
一般情况下对于某个表的查询只能使用到一个索引,但单表访问方法时在某些场景下可以使用Interseation、union、Sort-Union这三种索引合并的方式来执行查询。我们看一下执行计划中是怎么体现MySQL使用索引合并的方式来对某个表执行查询的:
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' OR key3 = 'a'; |

从执行计划的type列的值是 index_merge 就可以看出,MySQL打算使用索引合并的方式来执行对s1表的查询。
unique_subquery
类似于两表连接中被驱动表的eq_ref访问方法,unique_subquery是针对在一些包含IN子查询的查询语句中,如果查询优化器决定将IN子查询转换为EXISTS子查询,而且子查询可以使用到主键进行等值匹配的话,那么该子查询执行计划的type列的值就是unique_subquery
1 | EXPLAIN |

index_subquery
index_subquery 与 unique_subquery 类似,只不过访问子查询中的表时使用的是普通的索引,比如这样:
1 | EXPLAIN |

存疑,这里的执行结果是ref
range
如果使用索引获取某些范围区间的记录,那么就可能使用到range访问方法
1 | EXPLAIN SELECT * FROM s1 WHERE key1 IN ('a', 'b', 'c'); |

index
当我们可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是index,比如这样:
1 | EXPLAIN SELECT key_part2 FROM s1 WHERE key_part3 = 'a'; |

上述查询中的所有列表中只有key_part2一个列,而且搜索条件中也只有 key_part3 一个列,这两个列又恰好包含在idx_key_part这个索引中,可是搜索条件key_part3不能直接使用该索引进行ref和range方式的访问,只能扫描整个idx_key_part索引的记录,所以查询计划的type列的值就是index。
再一次强调,对于使用InnoDB存储引擎的表来说,二级索引的记录只包含索引列和主键列的值,而聚簇索引中包含用户定义的全部列以及一些隐藏列,所以扫描二级索引的代价比直接全表扫描,也就是扫描聚簇索引的代价更低一些。
ALL
全表扫描
1 | EXPLAIN SELECT * FROM s1; |

一般来说,这些访问方法中除了 All 这个访问方法外,其余的访问方法都能用到索引,除了 index_merge 访问方法外,其余的访问方法都最多只能用到一个索引。
6)possible_keys和key
在EXPLAIN语句输出的执行计划中,possible_keys列表示在某个查询语句中,对某个列执行单表查询时可能用到的索引有哪些。一般查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用。key列表示实际用到的索引有哪些,如果为NULL,则没有使用索引。比方说下面这个查询:
1 | EXPLAIN SELECT * FROM s1 WHERE key1 > 'z' AND key3 = 'a'; |

上述执行计划的possible_keys列的值是idx_key1,idx_key3,表示该查询可能使用到idx_key1, idx_key3两个索引,然后key列的值是idx_key3,表示经过查询优化器计算使用不同索引的成本后,最后决定采用idx_key3。
注意key并不一定是possible_keys的一个子集
有时候,
key字段中显示的索引并不一定出现在possible_keys中。这通常是因为MySQL的优化器在特定情况下可能选择使用某些隐式索引或一些额外的索引优化策略,这些索引在possible_keys中未显示。
7)key_len(重点)
实际使用到的索引长度 (即:字节数)
检查是否充分的利用了索引,值越大越好(相对于自身),主要针对于联合索引,有一定的参考意义。
int类型
如果 INT 列不允许 NULL,key_len 为 4
1 | EXPLAIN SELECT * FROM s1 WHERE id = 10005; |

如果 INT 列允许 NULL,key_len 会增加 1 个字节,用于存储 NULL 标志,因此 key_len 为 5。
1 | EXPLAIN SELECT * FROM s1 WHERE key2 = 10126; |

varchar类型
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; |

key1 VARCHAR(100) 一个字符占3个字节,100*3,是否为NULL占用一个字节,varchar的长度信息占两个字节。
1 | EXPLAIN SELECT * FROM s1 WHERE key_part1 = 'a' AND key_part2 = 'b'; |

key_len的长度计算公式:
1 | varchar(10)变长字段且允许NULL = 10 * ( character set:utf8=3,gbk=2,latin1=1)+1(NULL)+2(变长字段) |
8)ref
当使用索引列等值查询时,与索引列进行等值匹配的对象信息。
当使用索引列等值匹配的条件去执行查询时,也就是在访问方法是 const 、 eq_ref 、 ref 、 ref_or_null 、 unique_subquery 、 index_subquery 其中之一时, ref 列展示的就是与索引列作等值匹配的结构是什么,比如只是一个常数或者是某个列。
const表示索引列与常量值进行匹配
可以看到ref列的值是const,表明在使用idx_key1索引执行查询时,与key1列作等值匹配的对象是一个常数
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; |

column_name:表示使用某个列的值与索引进行匹配。
通常用于多表连接的情况下,表与表之间的连接条件会用到这个列。
1 | EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id; |

func:表示索引列与一个返回值为常量的函数进行匹配。
1 | EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s2.key1 = UPPER(s1.key1); |

9)rows(重点)
预估的需要读取的记录条数,条目数越小越好。这是因为值越小,加载I/O的页数就越少
1 | EXPLAIN SELECT * FROM s1 WHERE key1 > 'z'; |

10)filtered
某个表经过搜索条件过滤后剩余记录条数的百分比
如果进行单表扫描时使用了索引,那么在计算时需要估算出除了符合索引条件外,满足其他搜索条件的记录数量。
1 | EXPLAIN SELECT * FROM s1 WHERE key1 > 'z' AND common_field = 'a'; |

对于单表查询来说,这个filtered的值没有什么意义,我们更关注在连接查询中驱动表对应的执行计划记录的filtered值,它决定了被驱动表要执行的次数 (即: rows * filtered)
1 | EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.common_field = 'a'; |

从执行计划中可以看出来,查询优化器打算把s1当作驱动表,s2当作被驱动表。我们可以看到驱动表s1表的执行计划的rows列为9895,filtered列为10.00,这意味着驱动表s1 的扇出值就是9895×10.00%=989.5,这说明还要对被驱动表执行大约989次查询。
11)Extra
Extra列是用来说明一些额外信息的,包含不适合在其他列中显示但十分重要的额外信息。通过这些额外信息可以更准确的理解MySQL到底将如何执行给定的查询语句。MySQL提供的额外信息有好几十个,仅介绍部分。
No tables used
当查询语句的没有 FROM 子句时将会提示该额外信息
1 | EXPLAIN SELECT 1; |
Impossible WHERE
查询语句的WHERE子句永远为FALSE时将会提示该额外信息。即查询条件永远不可能满足,查不到数据。
1 | EXPLAIN SELECT * FROM s1 WHERE 1 != 1; |
Using where
除了使用索引来过滤数据以外,还使用了其他过滤条件
情况一:没有使用索引,WHERE子句中存在过滤条件
当使用全表扫描来执行对某个表的查询,并且该语句的WHERE子句中有针对该表的搜索条件时,在 Extra 列中会提示上述额外信息。比如下边这个查询:
1 | EXPLAIN SELECT * FROM s1 WHERE common_field = 'a'; |

情况二:使用了索引作为过滤条件,WHERE子句中除了索引列,还存在其他过滤条件
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' AND common_field = 'a'; |
当使用索引访问来执行对某个表的查询,并且该语句的 WHERE 子句中有除了该索引包含的列之外的其他搜索条件时,在 Extra 列中也会提示上述额外信息。

如果只使用了索引作为过滤条件,则不存在额外信息
1 | EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; |

No matching min/max row
当查询列表处有MIN或者MAX聚合函数,但是并没有符合WHERE子句中的搜索条件的记录时。
1 | EXPLAIN SELECT MIN(key1) FROM s1 WHERE key1 = 'abcdefg'; |

Select tables optimized away
当查询列表处有MIN或者MAX聚合函数,存在符合WHERE子句中的搜索条件的记录时。
存疑
Using index
当查询列表以及搜索条件中只包含属于某个索引的列,即使用覆盖索引的情况下
覆盖索引,就是索引中覆盖了需要查询的所有字段,不需要再使用聚簇索引进行回表查找。
1 | EXPLAIN SELECT key1 FROM s1 WHERE key1 = 'a'; |

Using index condition
MySQL使用了Index Condition Pushdown (ICP) 技术,提前在索引层面上过滤数据行。
**只出现在使用非主键索引的情况下,目的是减少回表的成本。**即使用二级索引的时候,先筛选再回表。而不是回表后再筛选。要求过滤的列包含在二级索引中。
1 | EXPLAIN SELECT * FROM s1 WHERE key1 > 'z' ; |

如果回表后,再进行筛选,则extra会出现Using where。即过滤条件的列,不在二级索引中。
如果同时在索引层面以及回表之后都进行了数据过滤,则会extra会同时出现Using index condition和Using where。
1 | EXPLAIN SELECT * FROM s1 WHERE key1 > 'z' AND common_field LIKE '%a'; |

Index Condition Pushdown (ICP) 是MySQL的一项查询优化技术,引入于MySQL 5.6,用于提高通过索引查找数据的效率。ICP允许MySQL在索引扫描的过程中,提前应用部分查询条件,从而减少不必要的行访问(即减少从存储引擎读取的行数),提高查询性能。
ICP的工作原理:
在没有ICP的情况下,MySQL在进行索引扫描时通常会读取索引并将匹配的行提取出来,然后再检查这些行是否符合查询的其他条件。这意味着即使有些行不符合查询条件,它们仍然会被读取,这增加了I/O开销。
而在有ICP的情况下,MySQL在读取索引时可以提前应用查询条件,即在索引层面就过滤掉不符合条件的行,从而减少需要从表中读取的行数。
Using join buffer (Block Nested Loop)
在连接查询执行过程中,当被驱动表不能有效的利用索引加快访问速度,MySQL会使用缓存来提升性能。MySQL一般会为其分配一块名叫join buffer的内存块来加快查询速度,也就是我们所讲的基于块的嵌套循环算法
1 | EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.common_field = s2.common_field; |

Not exists
当我们使用左外或者右外连接时,如果 WHERE 子句中包含要求被驱动表的某个列等于NULL值的搜索条件,而且那个列又是不允许存储NULL值的,那么在该表的执行计划的Extra列就会提示Not exists额外信息
1 | EXPLAIN SELECT * FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.id IS NULL; |

Using intersect(...) 、 Using union(...) 和 Using sort_union(...)
如果执行计划的Extra列出现以上内容,表示MySQL合并多个索引来执行查询,Intersect、union、sort_union是三种不同的索引合并方式。括号中的...表示需要进行索引合并的索引名称。
-
Using intersect(...)表示MySQL在查询执行时使用了多个索引的交集来满足查询条件。这意味着MySQL从多个索引扫描结果中取出公共部分(交集)以生成最终的结果集。通常用于查询条件是AND关系的情况
-
Using union(...)表示MySQL使用了多个索引扫描结果的并集来生成查询结果。MySQL从多个索引的扫描结果中取出所有满足任一条件的记录,并将这些记录合并在一起。通常在查询条件之间是OR关系时使用
-
Using sort_union(...)表示MySQL在合并多个索引的扫描结果时,先对这些结果进行排序,然后再合并。与Using union(...)类似,它也用于处理多个索引的并集,但需要排序以确保正确的合并顺序。
1 | mysql> EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' OR key3 = 'a'\G |
1 | mysql> EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age = 10 OR name = 'Abel'\G |
Zero limit
当LIMIT子句的参数为 0 时,表示压根儿不打算从表中读出任何记录,将会提示该额外信息
1 | EXPLAIN SELECT * FROM s1 LIMIT 0; |
Using filesort
有一些情况下对结果集中的记录进行排序是可以用到索引的。
1 | EXPLAIN SELECT * FROM s1 ORDER BY key1 LIMIT 10; |
很多情况下排序操作无法使用到索引,只能在内存中(记录较少的时候)或者磁盘中(记录较多的时候)进行排序,MySQL把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort)。如果某个查询需要使用文件排序的方式执行查询,就会在执行计划的Extra列显示Using filesort提示。
1 | EXPLAIN SELECT * FROM s1 ORDER BY common_field LIMIT 10; |
Using temporary
在许多查询的执行过程中,MySQL可能会借助临时表来完成一些功能,比如去重、排序之类的,比如我们在执行许多包含DISTINCT、GROUP BY、UNION等子句的查询过程中,如果不能有效利用索引来完成查询,MySQL很有可能寻求通过建立内部的临时表来执行查询。如果查询中使用到了内部的临时表,在执行计划的Extra列将会显示Using temporary提示
1 | EXPLAIN SELECT DISTINCT common_field FROM s1; |
1 | EXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field\G |
执行计划中出现Using temporary并不是一个好的征兆,因为建立与维护临时表要付出很大的成本的,所以我们最好能使用索引来替代掉使用临时表,比方说下边这个包含GROUP BY子句的查询就不需要使用临时表:
1 | EXPLAIN SELECT key1, COUNT(*) AS amount FROM s1 GROUP BY key1; |
12)小结
- EXPLAIN不考虑各种Cache
- EXPLAIN不能显示MySQL在执行查询时所作的优化工作
- EXPLAIN不会告诉你关于触发器、存储过程的信息或用户自定义函数对查询的影响情况
- 部分统计信息是估算的,并非精确值
7. EXPLAIN的进一步使用
7.1 EXPLAIN四种输出格式
EXPLAIN可以输出四种格式:传统格式,JSON格式,TREE格式以及可视化输出。用户可以根据需要选择适用于自己的格式。
1)传统格式
传统格式简单明了,输出是一个表格形式,概要说明查询计划。
1 | EXPLAIN SELECT s1.key1, s2.key1 FROM s1 |
2)JSON格式
第1种格式中介绍的EXPLAIN语句输出中缺少了一个衡量执行好坏的重要属性 —— 成本。而JSON格式是四种格式里面输出信息最详尽的格式,里面包含了执行的成本信息。
- JSON格式:在EXPLAIN单词和真正的查询语句中间加上
FORMAT=JSON。主要关注执行成本cost_info
1 | EXPLAIN FORMAT = JSON SELECT ... |
- 传统格式与json格式的各个字段存在如下表所示的对应关系(mysql5.7官方文档)。
| Column | JSON Name | Meaning |
|---|---|---|
| id | select_id | The SELECT identifier |
| select_type | None | The SELECT type |
| table | table_name | The table for the output row |
| partitions | partitions | The matching partitions |
| type | access_type | The join type |
| possible_keys | possible_keys | The possible indexes to choose |
| key | key | The index actually chosen |
| key_len | key_length | The length of the chosen key |
| ref | ref | The columns compared to the index |
| rows | rows | Estimate of rows to be examined |
| filtered | filtered | Percentage of rows filtered by table condition |
| Extra | None | Additional information |
这样我们就可以得到一个json格式的执行计划,里面包含该计划花费的成本。比如这样:
1 | EXPLAIN FORMAT=JSON |
"cost_info" 成本计算
1 | // s1 表的 "cost_info" 部分 |
-
read_cost是由下边这两部分组成的: -
- IO 成本
- 检测
rows × (1 - filter)条记录的 CPU 成本
-
eval_cost: 检测rows × filter条记录的成本。 一般值为 0.1 × rows × filter -
prefix_cost就是单独查询 s1 表的成本,也就是:read_cost + eval_cost -
data_read_per_join表示在此次查询中需要读取的数据量。
rows 和 filter 都是我们前边介绍执行计划的输出列
在JSON格式的执行计划中,rows 相当于 rows_examined_per_scan,filtered名称不变。
1 | # s2 表的 "cost_info" 部分 |
由于s2表是被驱动表,所以可能被读取多次,这里的read_cost和eval_cost是访问多次s2表后累加起来的值,这里的prefix_cost的值代表的是整个连接查询预计的成本,也就是单次查询s1表和多次查询s2表后的成本的和,也就是:
1 | 1071.68 + 989.50 + 98.95 = 2160.13 |
3)TREE格式
TREE格式是8.0.16版本之后引入的新格式,主要根据查询的各个部分之间的关系和各部分的执行顺序来描述如何查询。
1 | EXPLAIN FORMAT=tree |
4)可视化输出
可视化输出,可以通过MySQL Workbench可视化查看MySQL的执行计划。通过点击Workbench的放大镜图标,即可生成可视化的查询计划。
上图按从左到右的连接顺序显示表。红色框表示全表扫描,而绿色框表示使用索引查找。对于每个表,显示使用的索引。还要注意的是,每个表格的框上方是每个表访问所发现的行数的估计值以及访问该表的成本。
7.2 SHOW WARNINGS的使用
使用EXPLAIN语句查看了某个查询的执行计划后,紧接着还可以使用SHOW WARNINGS语句查看与这个查询的执行计划有关的一些扩展信息,比如这样:
1 | EXPLAIN SELECT s1.key1, s2.key1 FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.common_field IS NOT NULL; |

1 | SHOW WARNINGS\G |
大家可以看到SHOW WARNINGS展示出来的信息有三个字段,分别是Level、Code、Message。我们最常见的就是Code为1003的信息,当Code值为1003时,Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句。比如我们上边的查询本来是一个左(外)连接查询,但是有一个s2.common_field IS NOT NULL的条件,这就会导致查询优化器把左(外)连接查询优化为内连接查询,从SHOW WARNINGS的Message字段也可以看出来,原本的LEFE JOIN已经变成了JOIN。
但是大家一定要注意,我们说Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句,并不是等价于,也就是说Message字段展示的信息并不是标准的查询语句,在很多情况下并不能直接拿到黑框框中运行,它只能作为帮助我们理解MySQL将如何执行查询语句的一个参考依据而已。
8. 分析优化器执行计划:trace
OPTIMIZE_TRACE是mysql5.6中引入的一个跟踪功能,它可以跟踪优化器做出的各种决策,比如访问表的方法,各种开销计算,各种转换等,结果会被记录到information_schema.optimizer_trace表中。
此功能默认关闭。开启trace,并设置格式为JSON,同时设置trace最大能够使用的内存大小,避免解析过程中因为默认内存过小而不能够完整展示。命令如下:
1 | # 开启 |
开启后,可分析如下语句:
- INSERT 、DELETE 、UPDATE、SELECT
- REPLACE
- EXPLAIN
- SET
- DECLARE
- CASE
- IF
- RETURN
- CALL
测试:执行如下SQL语句
1 | select * from student where id < 10; |
最后, 查询 information_schema.optimizer_trace 就可以知道MySQL是如何执行SQL的
1 | select * from information_schema.optimizer_trace\G |
1 | *************************** 1. row *************************** |
9. MySQL监控分析视图-sys schema
关于MySQL的性能监控和问题诊断,我们一般都从performance_schema中去获取想要的数据,在MySQL5.7.7版本中新增sys schema,它将performance_schema和information_schema中的数据以更容易理解的方式总结归纳为"视图”,其目的就是为了降低查询performance_schema的复杂度,让DBA能够快速的定位问题。下面看看这些库中都有哪些监控表和视图,掌握了这些,在我们开发和运维的过程中就起到了事半功倍的效果。
9.1 Sys schema视图摘要
主机相关:以host_summary开头,主要汇总了IO延迟的信息。
Innodb相关:以innodb开头,汇总了innodb buffer信息和事务等待innodb锁的信息。
I/O相关:以io开头,汇总了等待I/O、I/O使用量情况。
内存使用情况:以memory开头,从主机、线程、事件等角度展示内存的使用情况
连接与会话信息:processlist和session相关视图,总结了会话相关信息。
表相关:以schema_table开头的视图,展示了表的统计信息。
索引信息:统计了索引的使用情况,包含冗余索引和未使用的索引情况。
语句相关:以statement开头,包含执行全表扫描、使用临时表、排序等的语句信息。
用户相关:以user开头的视图,统计了用户使用的文件I/O、执行语句统计信息。
等待事件相关信息:以wait开头,展示等待事件的延迟情况。
9.2 Sys schema视图使用场景
索引情况
1 | #1. 查询冗余索引 |
表相关
1 | # 1. 查询表的访问量 |
语句相关
1 | #1. 监控SQL执行的频率 |
IO相关
1 | #1. 查看消耗磁盘IO的文件 |
Innodb 相关
1 | #1. 行锁阻塞情况 |
风险提示:
通过sys库去查询时,MySQL会消耗大量资源去收集相关信息,严重的可能会导致业务请求被阻塞,从而引起
故障。建议生产上不要频繁的去查询sys或者performance_schema、information_schema来完成监控、巡检等工作。
10. 小结
查询时数据库中最频繁的操作,提高查询速度可以有效地提高MySQL数据库的性能。通过对查询语句的 分析可以了解查询语句的执行情况,找出查询语句执行的瓶颈,从而优化查询语句!
第10章 索引优化与查询优化
都有哪些维度可以进行数据库调优?简言之:
- 索引失效、没有充分利用到索引——建立索引
- 关联查询太多JOIN(设计缺陷或不得已的需求)——SQL优化
- 服务器调优及各个参数设置(缓冲、线程数等)——调整my.cnf
- 数据过多——分库分表
关于数据库调优的知识非常分散。不同的DBMS,不同的公司,不同的职位,不同的项目遇到的问题都不尽相同。这里分为三个章节进行细致讲解。
虽然SQL查询优化的技术有很多,但是大方向上完全可以分成物理查询优化和逻辑查询优化两大块。
- 物理查询优化是通过
索引和表连接方式等技术来进行优化,这里重点需要掌握索引的使用。 - 逻辑查询优化就是通过SQL
等价变换提升查询效率,直白一点就是说,换一种查询写法效率可能更高。
1. 数据准备
学员表 插 50万 条,班级表 插 1万 条。
1 | CREATE DATABASE dbtest_optimization; |
步骤1:建表
1 | CREATE TABLE `class` ( |
步骤2:设置参数
命令开启:允许创建函数设置
1 | set global log_bin_trust_function_creators=1; # 不加global只是当前窗口有效。 |
步骤3:创建函数
生成随机字符串函数
1 | DELIMITER // |
生成随机数函数
1 | DELIMITER // |
步骤4:创建存储过程
创建往stu表中插入数据的存储过程
1 | DELIMITER // |
创建往class表中插入数据的存储过程
1 | DELIMITER // |
步骤5:调用存储过程
1 | #执行存储过程,往class表添加1万条数据 |
步骤6:删除指定表上的索引
创建存储过程
1 | DELIMITER // |
执行存储过程
1 | CALL proc_drop_index("dbname","tablename"); |
2. 索引失效案例
MySQL中提高性能的一个最有效的方式是对数据表设计合理的索引。索引提供了高效访问数据的方法,并且加快查询的速度,因此索引对查询的速度有着至关重要的影响。
- 使用索引可以
快速地定位表中的某条记录,从而提高数据库查询的速度,提高数据库的性能。 - 如果查询时没有使用索引,查询语句就会
扫描表中的所有记录。在数据量大的情况下,这样查询的速度会很慢。
大多数情况下都默认采用B+树来构建索引。只是空间列类型的索引使用R-树,并且MEMORY表还支持hash索引。
其实,用不用索引,最终都是优化器说了算。优化器是基于什么的优化器?基于cost开销(CostBaseOptimizer),它不是基于规则(Rule-BasedOptimizer),也不是基于语义。怎么样开销小就怎么来。另外,SQL语句是否使用索引,跟数据库版本、数据量、数据选择度都有关系。
2.1 全值匹配
全值匹配是指在MySQL中查询条件的顺序和数量与联合索引中列的顺序和数量相同。全值匹配可以充分的利用组合索引,查询效率最高。
分析查询语句中用到的索引
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age=30; |
建立索引前执行:可以看到查询效率不高,因为没有索引,当前查询是全表扫描
1 | SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4 AND name = 'abcd'; |
在不同字段以及字段组合上建立索引
1 | CREATE INDEX idx_age ON student(age); |
建立索引后执行:
1 | SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4 AND name = 'abcd'; |
分别测试后,可以发现,当符合全值匹配要求的时候,查询效率最高
2.2 最左前缀法则
在MySQL中创建联合索引时会遵守最左前缀匹配原则,即在检索数据时从联合索引的最左边开始匹配,并且不跳过索引中的列。
举例1:前面的索引顺序是age->classId->name,这里跳过中间的classId
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.age=30 AND student.name = 'abcd'; |
可以看到key_len值为5,表示只使用了age字段作为索引,说明只是用了索引的一部分。
举例2:跳过最左的age
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student |
可以看到,type值为ALL,表示没有使用索引。
结论:MySQL可以为多个字段创建索引,一个索引最多可以包括16个字段。**对于多列索引,过滤条件要使用索引,必须按照索引创建时的顺序依次满足,一旦跳过某个字段,索引后面的字段都将无法被用作索引。**如果查询条件中没有使用这些字段中的第一个字段,那么多列索引不会被使用。
举例3:索引idx_age_classid_name还能否正常使用?
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student |
2.3 主键插入顺序
对于一个使用InnoDB存储引擎的表来说,在我们没有显示的创建索引时,表中的数据实际上都是存储在聚簇索引的叶子节点的。而记录又存储在数据页中的,数据页和记录又是按照记录主键值从小到大的顺序进行排序,所以如果我们插入的记录的主键值是依次增大的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的主键值忽小忽大的话,则可能会造成页面分裂和记录移位。
为了避免这样无谓的性能损耗,最好让插入的记录的主键值依次递增
所以建议:让主键具有AUTO_INCREMENT,让存储引擎自己为表生成主键,而不是我们手动插入
2.4 计算、函数导致索引失效
1)查询条件中包含函数,导致索引失效
创建索引
1 | CREATE INDEX idx_name ON student(NAME); |
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name LIKE 'abc%'; |
第一种写法:索引生效
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name LIKE 'abc%'\G |
第二种写法:索引失效
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE LEFT(student.name,3) = 'abc'; |
2)查询条件中包含计算,导致索引失效
1 | CREATE INDEX idx_sno ON student(stuno); |
1 | EXPLAIN SELECT SQL_NO_CACHE id, stuno, NAME FROM student WHERE stuno+1 = 900001; |
1 | EXPLAIN SELECT SQL_NO_CACHE id, stuno, NAME FROM student WHERE stuno+1 = 900001\G |
1 | EXPLAIN SELECT SQL_NO_CACHE id, stuno, NAME FROM student WHERE stuno = 900000\G |
2.5 类型转换导致索引失效
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name='123';# 使用到索引 |
name字段的数据类型是VARCHAR,name=123发生数据类型转换,导致索引失效。
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name='123'\G |
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name=123\G |
结论:设计实体类属性时,一定要与数据库字段类型相对应。否则,就会出现类型转换的情况
2.6 范围条件右边的列索引失效
当查询条件中使用了范围查询(例如,使用了<、<=、>、>=、BETWEEN等运算符)时,在该范围条件右边的列索引不会被MySQL优化器使用。
一般发生在优化器使用联合索引的情况下,当联合索引的某个列,被用于范围查询时,该联合索引右侧的列,不会被使用。
1 | # 删除多余的索引 |
1 | CREATE INDEX idx_age_classid_name ON student(age,classId,name); |
使用了索引idx_age_classid_name,使用到的索引长度为10,说明只用到了age和classId
1 | CREATE INDEX idx_age_name_classid ON student(age,name,classId); |
使用了索引idx_age_name_classid,使用到了索引的全部字段
应用开发中范围查询,例如:金额查询,日期查询往往都是范围查询。应将查询条件放置where语句最后。(属于规范,一种暗示,where条件中字段的顺序,不影响联合索引的使用)
创建的联合索引时,务必把涉及到范围的字段写在最后
2.7 不等于(!= 或者<>)索引失效
- 为name字段创建索引
1 | CREATE INDEX idx_name ON student(NAME); |
- 使用不等于符号编写SQL语句
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name <> 'abc'; |
执行计划中的type值为ALL,表明SQL语句没有使用索引。在编写SQL语句中,应尽量避免使用此类查询条件。
2.8 is null可以使用索引,is not null无法使用索引
IS NULL: 可以触发索引
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age IS NULL; |
IS NOT NULL: 无法触发索引
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age IS NOT NULL; |
结论:最好在设计数据表的时候就将
字段设置为 NOT NULL 约束,比如可以将INT类型的字段,默认值设置为0。将字符类型的默认值设置为空字符串(‘’)拓展:同理,在查询中使用
not like也无法使用索引,导致全表扫描
上述结论有问题
IS NULL可以走索引的原因
非聚簇索引是通过B+树的方式进行存储的,null值作为最小数看待,全部放在树的最左边,形成链表,如果获取is null的数据,可以从最左开始直到找到记录不是null结束
IS NULL不走索引的情况
当null值占多数时,IS NULL不走索引,is not null此时可以走索引
非聚簇索引查询需要回表才能获得记录数据(覆盖索引除外),那么在这过程中优化器发现回表次数太多,执行成本已经超过全表扫描。
例如:几乎所有数据都命中,都需要回表.这个时候,优化器会放弃索引,走效率更高全表扫描
IS NOT NULL走索引的情况
当null值占大多数的时候
当使用覆盖索引的时候。无需回表,在聚簇索引上,可以获得所有非空数据的时候。
总之查询条件中包含IS NULL,IS NOT NULL,查询是否使用索引看成本,结合场景去分析
2.9 like以通配符%开头索引失效
在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为’%‘,索引就不会起作用。只有’%'不在第一个位置,索引才会起作用。
1 | CREATE INDEX idx_name ON student(name); |
- 使用到索引
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name LIKE 'ab%'; |
- 未使用到索引
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name LIKE '%ab%'; |
拓展:Alibaba《Java开发手册》
【强制】页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决。
2.10 OR前后存在非索引的列,索引失效
在WHERE子句中,如果在OR前的条件列进行了索引,而在OR后的条件列没有进行索引,那么索引会失效。也就是说,OR前后的两个条件中的列都是索引时,查询中才使用索引。
因为OR的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描,因此所以的条件列也会失效。
查询语句使用OR关键字的情况:
1 | # 未使用到索引 |
因为classId字段上没有索引,所以上述查询语句没有使用索引。
1 | #使用到索引 |
因为age字段和name字段上都有索引,所以查询中使用了索引。这里使用到了index_merge,简单来说index_merge就是对age和name分别进行了扫描,然后将这两个结果集进行了合并。这样做的好处就是避免了全表扫描。
2.11 数据库和表的字符集统一使用utf8mb4
统一使用utf8mb4( 5.5.3版本以上支持)兼容性更好,统一字符集可以避免由于字符集转换产生的乱码。不同的字符集进行比较前需要进行转换会造成索引失效。
2.12 练习及一般性建议
**练习:**假设:index(a,b,c)
| Where语句 | 索引是否被使用 |
|---|---|
| where a = 3 | Y,使用到a |
| where a = 3 and b = 5 | Y,使用到a, b |
| where a = 3 and b = 5 and c = 4 | Y,使用到a,b,c |
| where b = 3 where b = 3 and c = 4 where c = 4 |
N |
| where a = 3 and c = 5 | 使用到a, 但是c不可以,b中间断了 |
| where a = 3 and b > 4 and c = 5 | 使用到a和b,c不能用在范围之后,b断了 |
| where a is null and b is not null | is null 支持索引 但是 is not null 不支持。所以a可以使用索引,但是b不可以使用 |
| where a <> 3 | 不能使用索引 |
| where abs(a) = 3 | 不能使用索引 |
| where a = 3 and b like ‘kk%’ and c = 4 | Y,使用到a,b,c |
| where a = 3 and b like ‘%kk’ and c = 4 | Y,只用到a |
| where a = 3 and b like ‘%kk%’ and c = 4 | Y,只用到a |
| where a = 3 and b like ‘k%k%’ and c = 4 | Y,使用到a,b,c |
一般性建议
- 对于单列索引,尽量选择针对当前query过滤性更好的索引
- 在选择组合索引的时候,当前query中过滤性最好的字段在索引字段顺序中,位置越靠前越好。
- 在选择组合索引的时候,尽量选择能够覆盖当前query中where子句中更多的索引。
- 在选择组合索引的时候,如果某个字段可能出现范围查询时,尽量把这个字段放在索引次序的最后面。
总之,书写SQL语句时,尽量避免造成索引失效的情况
3. 关联查询优化
3.1 数据准备
1 | #图书 |
1 | DELIMITER $ |
3.2 采用左外连接
LFET JOIN 连表操作中,左表中的每一行都会被包含在结果集中,而右表的行只有当满足连接条件时才会出现在结果中,所以右表是关键的。在右表中对连接条件建立索引可以提高检索效率。
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM `classification` LEFT JOIN book ON classification.card = book.card\G |
type值为ALL,说明当前的SQL语句没有使用索引。查询的效率还是比较低的。
其中,表book是被驱动表,给book中的card列添加索引,就可避免全表扫描。
1 | CREATE INDEX idx_book_card ON book(card); |
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM `classification` LEFT JOIN book ON classification.card = book.card\G |
可以看到第二条记录的type变为了ref,rows的优化也比较明显。
给驱动表添加索引
再给驱动表classification添加索引,驱动表依然全表扫描。驱动表type值为index,表明使用了索引,但是依然进行了全表扫描。
1 | CREATE INDEX idx_classification_card ON classification(card); |
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM `classification` LEFT JOIN book ON classification.card = book.card\G |
3.3 采用内连接 INNER JOIN
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM `classification` INNER JOIN book ON classification.card = book.card\G |
对于内连接,查询优化器会自动选择驱动表和被驱动表。
连接字段上有索引的表,会优先成为被驱动表。驱动表,无论是否添加索引,都会进行全表扫描。
在两个表的连接条件都存在索引的情况下,会选择小的表作为驱动表。小表驱动大表
3.4 JOIN语句的原理
join方式连接多个表,本质就是各个表之间数据的循环匹配。MySQL5.5版本之前,MySQL只支持一种表间关联方式,就是嵌套循环(Nested Loop Join)。如果关联表的数据量很大,则join关联的执行时间会很长。在MySQL5.5以后的版本中,MySQL通过引入BNLJ算法来优化嵌套执行。
1)驱动表和被驱动表
驱动表就是主表,被驱动表就是从表、非驱动表。
- 对于内连接来说:
1 | SELECT * FROM A JOIN B ON ... |
优化器会根据查询语句做优化,决定先查哪张表。先查询的那张表就是驱动表,反之就是被驱动表。通过explain关键字可以查看。
- 对于外连接来说:
1 | SELECT * FROM A LEFT JOIN B ON ... |
通常,大家会认为A就是驱动表,B就是被驱动表。但也未必。测试如下:
1 | CREATE TABLE a(f1 INT, f2 INT, INDEX(f1)) ENGINE=INNODB; |
优化器有时会优化外连接为内连接,因此被驱动表和驱动表有时由优化器决定
2) Simple Nested-Loop Join (简单嵌套循环连接)
算法相当简单,从表A中取出一条数据1,遍历表B,将匹配到的数据放到result… 以此类推,驱动表A中的每一条记录与被驱动表B的记录进行判断:
可以看到这种方式效率是非常低的,以上述表A数据100条,表B数据1000条计算,则A*B=10万次。开销统计如下:
| 开销统计 | SNLJ |
|---|---|
| 外表扫描次数 | 1 |
| 内表扫描次数 | A(A表条目数) |
| 读取记录数 | A+B*A |
| JOIN比较次数 | B*A |
| 回表读取记录次数 | 0 |
当然mysql肯定不会这么粗暴的去进行表的连接,所以就出现了后面的两种对Nested-Loop Join优化算法。
3)Index Nested-Loop Join(索引嵌套循环连接)
Index Nested-Loop Join其优化的思路主要是为了减少内层表数据的匹配次数,所以要求被驱动表上必须有索引才行。通过外层表匹配条件直接与内层表索引进行匹配,避免和内存表的每条记录去进行比较,这样极大的减少了对内层表的匹配次数。
驱动表中的每条记录通过被驱动表的索引进行访问,因为索引查询的成本是比较固定的,故mysql优化器都倾向于使用记录数少的表作为驱动表(外表)。
| 开销统计 | SNLJ | INLJ |
|---|---|---|
| 外表扫描次数 | 1 | 1 |
| 内表扫描次数 | A | 0 |
| 读取记录数 | A+B*A | A+B(match) |
| JOIN比较次数 | B*A | A*Index(Height) |
| 回表读取记录次数 | 0 | B(match) (if possible) |
被驱动表加索引,效率是非常高的,如果索引不是主键索引,还需要进行一次回表查询。
4)Block Nested-Loop Join(块嵌套循环连接)
如果存在索引,那么会使用index的方式进行join,如果join的列没有索引,被驱动表要扫描的次数太多了。每次访问被驱动表,其表中的记录都会被加载到内存中,然后再从驱动表中取一条与其匹配,匹配结束后清除内存,然后再从驱动表中加载一条记录,然后把被驱动表的记录再加载到内存匹配,这样周而复始,大大增加了IO的次数。为了减少被驱动表的IO次数,就出现了Block Nested-Loop Join的方式。
不再是逐条获取驱动表的数据,而是一块一块的获取,引入了join buffer缓冲区,将驱动表join相关的部分数据列(大小受join buffer的限制)缓存到join buffer中,然后全表扫描被驱动表,被驱动表的每一条记录一次性和join buffer中的所有驱动表记录进行匹配(内存中操作),将简单嵌套循环中的多次比较合并成一次,降低了被驱动表的访问频率。
注意:
这里缓存的不只是关联表的列,select后面的列也会缓存起来。
在一个有N个join关联的sql中会分配N-1个join buffer。所以查询的时候尽量减少不必要的字段,可以让join buffer中可以存放更多的列。
| 开销统计 | SNLJ | INLJ | BNLJ |
|---|---|---|---|
| 外表扫描次数: | 1 | 1 | 1 |
| 内表扫描次数: | A | 0 | A * used_column_size/join_buffer_size+1 |
| 读取记录数: | A+B * A | A+B(match) | A+B*(A * used_column_size/join_buffer_size) |
| JOIN比较次数: | B * A | A * Index(Height) | B * A |
| 回表读取记录次数: | 0 | B(match) (if possible) | 0 |
参数设置:
- block_nested_loop
通过show variables like '%optimizer_switch% 查看 block_nested_loop 状态。默认是开启的。
- join_buffer_size
驱动表能不能一次加载完,要看join buffer能不能存储所有的数据,默认情况下join_buffer_size=256k。
1 | mysql> show variables like '%join_buffer%'; |
join_buffer_size的最大值在32位操作系统可以申请4G,而在64位操作系统下可以申请大于4G的Join Buffer空间(64位Windows除外,其大值会被截断为4GB并发出警告)。
5)Join小结
-
整体效率比较:INLJ > BNLJ > SNLJ
-
永远用小结果集驱动大结果集(其本质就是减少外层循环的数据数量)
小的度量单位指的是表行数 * 每行大小,每行大小受查询列表影响表行数受到where过滤的影响
1 | select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100; # 推荐 |
-
为被驱动表匹配的条件增加索引(减少内层表的循环匹配次数)
-
增大join buffer size的大小(一次索引的数据越多,那么内层表的扫描次数就越少)
-
减少驱动表不必要的字段查询(字段越少,join buffer所缓存的数据就越多)
6)Hash Join
从MySQL的8.0.20版本开始将废弃BNLJ,因为从MySQL8.0.18版本开始就加入了hash join默认都会使用hash join
- Nested Loop:对于被连接的数据子集较小的情况下,Nested Loop是个较好的选择。
- Hash Join是做
大数据集连接时的常用方式,优化器使用两个表中较小(相对较小)的表 作为构建表,创建哈希表;大表作为探测表,逐行查找匹配记录,在哈希表中快速定位匹配的记录- 这种方式适用于较小的表完全可以放入内存中的情况,这样总成本就是访问两个表的成本之和。
- 在表很大的情况下并不能完全放入内存,这时优化器会将它分割成
若干不同的分区,不能放入内存的部分就把该分区写入磁盘的临时段,此时要求有较大的临时段从而尽量提高I/O的性能。 - 它能够很好的工作于没有索引的大表和并行查询的环境中,并提供最好的性能。Hash Join只能应用于等值连接,这是由Hash的特点决定的。
| 类别 | Nested Loop | Hash Join |
|---|---|---|
| 使用条件 | 任何条件 | 等值连接(=) |
| 相关资源 | CPU、磁盘I/O | 内存、临时空间 |
| 特点 | 当有高选择性索引或进行限制性搜索时效率比较高,能够快速返回第一次的搜索结果。 | 当缺乏索引或者索引条件模糊时,Hash Join比Nested Loop有效。在数据仓库环境下,如果表的记录数多,效率高。 |
| 缺点 | 当索引丢失或者查询条件限制不够时,效率很低;当表的记录数多时,效率低。 | 为建立哈希表,需要大量内存。第一次的结果返回较慢。 |
4. 子查询优化
MySQL从4.1版本开始支持子查询,使用子查询可以进行SELECT语句的嵌套查询,即一个SELECT查询的结果作为另一个SELECT语句的条件。子查询可以一次性完成很多逻辑上需要多个步骤才能完成的SQL操作。
**子查询是 MySQL 的一项重要的功能,可以帮助我们通过一个 SQL 语句实现比较复杂的查询。但是,子查询的执行效率不高。**原因:
① 执行子查询时,MySQL需要为内层查询语句的查询结果建立一个临时表,然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。这样会消耗过多的CPU和IO资源,产生大量的慢查询。
② 子查询的结果集存储的临时表,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。
③ 对于返回结果集比较大的子查询,其对查询性能的影响也就越大。
**在MySQL中,可以使用连接(JOIN)查询来替代子查询。**连接查询不需要建立临时表,其速度比子查询要快,如果查询中使用索引的话,性能就会更好。
举例1:查询学生表中是班长的学生信息
- 使用子查询
1 | # 创建班级表中班长的索引 |
- 推荐使用多表查询
1 | EXPLAIN SELECT stu1.* FROM student stu1 JOIN class c |
查询1比查询2快,原因分析
查询1
物化了子查询结果,在主查询中对物化结果进行
eq_ref操作,效率较高。idx_monitor是普通索引而不是唯一索引,但在物化表中,可能优化成唯一或者主键索引。
数据库在创建物化表时,可能会应用一些优化措施,比如索引、数据压缩、数据聚合等,以加速查询。这些优化措施可以大大提高物化表的查询效率
当一组数据需要频繁查询,但数据本身的变化频率较低时,物化表能显著加快查询速度。
查询2
type的值为
ref,每一行stu1.stuno,class表中的monitor可能对应多行数据。
举例2 查询所有不为班长的同学
- 不推荐
1 | EXPLAIN SELECT SQL_NO_CACHE a.* |
- 推荐
1 | EXPLAIN SELECT SQL_NO_CACHE a.* |
该例子还是子查询快
结论:尽量不要使用NOT IN 或者 NOT EXISTS,用LEFT JOIN xxx ON xx WHERE xx IS NULL替代
5. 排序优化
5.1 排序优化概述
问题:在 WHERE 条件字段上加索引,但是为什么在 ORDER BY 字段上还要加索引呢?
在MySQL中,支持两种排序方式,分别是 FileSort 和 Index 排序。
- Index 排序中,索引可以保证数据的有序性,不需要再进行排序,
效率更高。 - FileSort 排序则一般在
内存中进行排序,占用CPU较多。如果待排结果较大,会产生临时文件 I/O 到磁盘进行排序的情况,效率较低。
优化建议
- SQL中,可以在 WHERE 子句和 ORDER BY 子句中使用索引,目的是在 WHERE 子句中
避免全表扫描,在 ORDER BY 子句避免使用 FileSort 排序。当然,某些情况下全表扫描,或者 FileSort 排序不一定比索引慢。但总的来说,我们还是要避免,以提高查询效率。 - 尽量使用 Index 完成 ORDER BY 排序。如果 WHERE 和 ORDER BY 后面是相同的列就使用单索引列;如果不同就使用联合索引。
- 无法使用 Index 时,需要对 FileSort 方式进行调优。
5.2 测试
删除student表和class表中已创建的索引。
1 | CALL proc_drop_index('dbtest_optimization','student'); |
测试一:排序列未创建索引
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student ORDER BY age, classid; |
测试二: order by 时不limit,索引失效
1 | # 创建索引 |
查询所有数据,且使用的是非聚簇索引,还需要回表,此时使用索引的查询开销大于全表扫描,所以优化器选择不使用索引。
1 | # 增加limit过滤条件,使用上索引了。 |
测试三:order by 时不满足最左前缀法则,索引失效
同最左前缀法则
1 | #创建索引 age, classid, stuno |
测试四:order by 时规则不一致,索引失效(顺序错,不索引;方向反,不索引)
ORDER BY 中的列顺序或者排序方式(升序或降序)与索引定义的顺序不一致,会导致索引失效
1 | # 正确写法,索引有效 |
索引 idx_age_classid_name 是基于 (age, classid, name) 创建的,默认情况下各列都是升序
虽然索引的默认顺序是升序(ASC),但MySQL可以通过反向扫描索引Backward index scan 来实现DESC排序
1 | EXPLAIN SELECT * FROM student ORDER BY age DESC, classid DESC LIMIT 10\G |
测试五:过滤字段和排序字段共同使用一个联合索引
1 | EXPLAIN SELECT * FROM student WHERE age=45 ORDER BY classid; # 联合索引只是用了age字段 |
只是用了索引中的age字段,过滤完成之后,字段较少没必要使用联合索引的全部
1 | EXPLAIN SELECT * FROM student WHERE age=45 ORDER BY classid\G |
当order by 和 where 中的字段满足联合索引左前缀原则,则可以使用索引,具体是否使用,看成本
1 | # 没有使用索引,因为回表成本高于全表扫描 |
小结
1 | INDEX a_b_c(a,b,c) |
5.3 案例实战
ORDER BY子句,尽量使用Index方式排序,避免使用FileSort方式排序。
执行案例前先清除student上的索引,只留主键:
1 | DROP INDEX idx_age ON student; |
场景:查询年龄为30岁的,且学生编号小于101000的学生,按用户名称排序
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age = 30 AND stuno <101000 ORDER BY NAME\G |
1 | SELECT SQL_NO_CACHE * FROM student WHERE age = 30 AND stuno <101000 ORDER BY NAME; |
结论:type 是 ALL,即最坏的情况。Extra 里还出现了 Using filesort,也是最坏的情况。优化是必须的。
方案一: 为了去掉filesort我们可以把索引建成
1 | #创建新索引 |
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age = 30 AND stuno <101000 ORDER BY NAME\G |
1 | SELECT SQL_NO_CACHE * FROM student WHERE age = 30 AND stuno <101000 ORDER BY NAME; |
方案二:尽量让where的过滤条件和排序使用上索引
建一个三个字段的组合索引:
1 | DROP INDEX idx_age_name ON student; |
1 | EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age = 30 AND stuno <101000 ORDER BY NAME; |
1 | SELECT SQL_NO_CACHE * FROM student WHERE age = 30 AND stuno <101000 ORDER BY NAME; |
发现using filesort依然存在,所以name并没有用到索引,而且type还是range。原因是,因为stuno是一个范围过滤,所以索引后面的字段不会在使用索引了 。
结果有filesort的sql运行速度,超过了已经优化掉filesort的sql,而且快了很多。
原因:
所有的排序都是在条件过滤之后才执行的。所以,如果条件过滤掉大部分数据的话,剩下几百几千条数据进行排序
其实并不是很消耗性能,即使索引优化了排序,但实际提升性能很有限。相对的 stuno<101000 这个条件,如
果没有用到索引的话,要对几万条的数据进行扫描,这是非常消耗性能的,所以索引放在这个字段上性价比最高,是最优选择。
结论:
- 两个索引同时存在,mysql自动选择最优的方案。(对于这个例子,mysql选idx_age_stuno_name)。但是,
随着数据量的变化,选择的索引也会随之变化的。- 当【范围条件】和【group by 或者 order by】的字段出现二选一时,优先观察条件字段的过滤数量,如果过滤的数据足够多,而需要排序的数据并不多时,优先把索引放在范围字段上。反之,亦然。
5.4 filesort算法:双路排序和单路排序
排序的字段若不在索引列上,则filesort会有两种算法:双路排序和单路排序
1)双路排序(慢)
MySQL 4.1之前是使用双路排序字面意思就是两次扫描磁盘,最终得到数据
- 第一次磁盘扫描
当执行ORDER BY查询时,MySQL首先会从磁盘读取所需的排序列(通常是ORDER BY子句中指定的列),并将这些列以及对应的行指针(row pointer)加载到内存中(排序缓冲区)。
-
对数据进行排序
MySQL会对内存中加载的这些列及其行指针进行排序。这一阶段只对
ORDER BY列和行指针进行排序,而不涉及表的其他列。 -
第二次磁盘扫描
一旦对排序列和行指针完成排序,MySQL会根据排序后的行指针再次从磁盘中读取完整的行数据,并输出结果。这个步骤需要对磁盘进行第二次扫描以获取完整的行数据。
由于需要对磁盘进行两次扫描(第一次扫描获取排序列,第二次扫描获取对应行的所有其他字段),这就会导致很大的IO开销,尤其是在大数据集的情况下。磁盘IO通常是数据库性能的瓶颈,所以双路排序被认为是“慢”的。
2)单路排序(快)
为了优化双路排序中的IO问题,MySQL4.1之后引入了单路排序
单路排序只需要一次磁盘扫描,它从磁盘读取所有需要的列(包括SELECT中的字段和ORDER BY列),然后将这些数据加载到内存中的sort_buffer里,在内存中进行排序。最后扫描排序后的列表进行输出,它的效率更快,避免了双路排序的两次磁盘扫描。并且把随机IO变成了顺序IO。
但是它会使用更多的空间,因为它把每一行都保存在内存中了。
3)单路排序的问题
在sort_buffer中,单路要比多路多占用很多空间,因为单路是把所有需要的字段都取出,所以有可能取出的数据的总大小超出了sort_buffer的容量,导致每次只能取sort_buffer容量大小的数据,进行排序(创建tmp文件,多路合并),排完再取sort_buffer容量大小,再排…从而多次I/O。
单路本来想省一次I/O操作,反而导致了大量的I/O操作,反而得不偿失。
4)优化策略
① sort_buffer_size
它决定了每个连接(session)中用于排序操作的内存缓冲区大小。当MySQL执行带有 ORDER BY 或 GROUP BY 的查询时,会使用这个缓冲区来存储排序的数据。如果数据量超过缓冲区大小,MySQL 会将部分数据写到磁盘上,这会影响查询的性能。
增大 sort_buffer_size 可以在一定程度上提高排序的性能,但如果设置过大,可能会导致内存使用量过高,尤其是并发连接较多时。通常需要权衡内存使用和查询性能。
不管用哪种算法,提高这个参数都会提高效率。
1 | SHOW VARIABLES LIKE '%sort_buffer_size%'; |
-
sort_buffer_size的默认大小是256KB -
innodb_sort_buffer_size这是InnoDB存储引擎在特定情况下使用的缓冲区大小,主要是在创建索引或者重建索引的过程中使用。例如,运行
ALTER TABLE创建新索引或者OPTIMIZE TABLE时,InnoDB会使用这个缓冲区来提高排序的效率。默认值是 1048576 字节,1MB。
② max_sort_length
max_sort_length用于控制单个字段排序内容的长度,最小值是4字节,默认值是1024字节。服务器将使用每个字段的前max_sort_length字节进行排序。
例如,将max_sort_length的值设置为5,表示只有字段的前5字节会参与排序。如果字段前5字节的内容相同,而后面的内容不同,则会导致MySQL认为参与排序的字段内容相同,有可能出现排序结果和预期不一致的情况。
增加max_sort_length的大小也可能需要增加sort_buffer_size的大小。
③ max_length_for_sort_data(存疑)
该参数决定MySQL在进行排序时,最大允许的排序行数据长度。如果排序行数据长度超过max_length_for_sort_data,MySQL会在排序时使用指针而不是整个字段内容进行排序,即使用双路排序方式。
但是如果设的太高,数据总容量超过sort_buffer_size的概率就增大,明显症状是高的磁盘I/O活动和低的处理器使用率。
mysql5.7默认值是1024字节,mysql8默认值是4096字节。
1 | SHOW VARIABLES LIKE '%max_length_for_sort_data%'; |
提高这个参数,会增加用改进算法的概率。
当max_length_for_sort_data的值较低时,MySQL更有可能使用双路排序,因为内存中无法容纳所有数据。
当max_length_for_sort_data的值较高时,并且需要排序的数据量小于该值时,则MySQL更倾向于使用单路排序,因为这样可以避免磁盘I/O,提高性能。
在mysql8.0.20之后,已经标记为废弃
max_length_for_sort_data(默认值为 4096)只控制一件事:如果附加字段的最大大小超过 4 KB,我们将回退到对行 ID 进行排序(这很昂贵,因为排序后我们需要对表进行大量随机访问以实际获取数据)。然而,典型的情况是 JSON blob 或几何列;虽然它们理论上可能非常大,从而产生巨大的最大长度,但实际上它们通常很小,结果导致性能较差。
MySQL :: WL#13600: Deprecate system variable max_length_for_sort_data
④ Order by 时select * 是一个大忌。最好只 Query 需要的字段。
-
当Query的字段大小总和小于
max_length_for_sort_data,而且排序字段不是 TEXT|BLOB 类型时,会用改进后的算法–单路排序,否则用老算法–多路排序。 -
两种算法的数据都有可能超过sort_buffer_size的容量,超出之后,会创建 tmp 文件进行合并排序,导致多次 I/O,但是用单路排序算法的风险会更大一些,所以要提高
sort_buffer_size。
6. GROUP BY优化
- group by 使用索引的原则几乎跟order by一致 ,group by 即使没有过滤条件用到索引,也可以直接使用索引。
- group by 先排序再分组,遵照索引建的最佳左前缀法则
- 当无法使用索引列,可以增大
max_length_for_sort_data和sort_buffer_size参数的设置 - where效率高于having,能写在where限定的条件就不要写在having中了
- 减少使用order by,和业务沟通能不排序就不排序,或将排序放到程序端去做。Order by、group by、distinct这些语句较为耗费CPU,数据库的CPU资源是极其宝贵的。
- 包含了order by、group by、distinct这些查询的语句,where条件过滤出来的结果集请保持在1000行以内,否则SQL会很慢。
7. 优化分页查询
一般分页查询时,通过创建覆盖索引能够比较好地提高性能。一个常见又头疼的问题就是limit 2000000,10,此时需要MySQL排序前2000010记录,仅仅返回2000000-2000010的记录,其他记录丢弃,查询排序的代价非
常大。
1 | EXPLAIN SELECT * FROM student LIMIT 2000000,10; |
优化思路一
在索引上完成排序分页操作,最后根据主键关联回原表查询所需要的其他列内容。
1 | EXPLAIN SELECT * FROM student t,(SELECT id FROM student ORDER BY id LIMIT 2000000,10) a |
优化思路二
该方案适用于主键自增的表,可以把Limit 查询转换成某个位置的查询。
1 | EXPLAIN SELECT * FROM student WHERE id > 2000000 LIMIT 10; |
思路二要求ID自增,且步长为1,并且起始也为1。
8. 优先考虑覆盖索引
8.1 什么是覆盖索引?
覆盖索引是非聚簇索引的一种形式,当使用非聚簇索引时,如果查询所需要的数据列从索引中就能够获取到,不必回表操作,那么这时候使用的索引就是覆盖索引。
索引的字段需要覆盖查询条件中所涉及的字段。包括在查询里的SELECT、JOIN和WHERE子句用到的所有列
简单说就是,索引列+主键包含SELECT 到 FROM之间查询的列。
不是所有类型的索引都可以成为覆盖索引,因为覆盖索引必须存储索引列的值,而哈希索引、空间索引,全文索引等,都不能存储索引列的值。索引MySQL只能使用B+树索引作为覆盖索引。
重要案例
1 | CALL proc_drop_index('dbtest_optimization','student'); |
1 | # 不等于符号导致索引失效 |
1 | # 使用覆盖索引 |
在之前在案例中,不等于符号导致索引失效,因为不等于意味着要遍历整个索引树以排除不符合条件的行,并且非聚簇索引还需要回表,不如直接全表扫描。
在现在这种情况,查询所需要的列,全在非聚簇索引中,无需回表,遍历索引树的成本小于全表扫描的成本。所以优化器会选择使用索引。
1 | # like以通配符%开头导致的索引失效情况,也可以使用覆盖索引 |
8.2 覆盖索引的利弊
好处:
1.避免Innodb表进行索引的二次查询(回表)
Innodb是以聚集索引的顺序来存储的,对于Innodb来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据,在查找到相应的键值后,还需要通过主键进行二次查询才能获取我们真实所需要的数据。
在覆盖索引中,二级索引的键值中可以获取所要的数据,避免了对主键的二次查询,减少了IO操作,提升了查询效率。
2.可以把随机IO变成顺序IO加快查询效率
非聚簇索引需要回表,回表要根据主键值,去聚簇索引中查找,主键值不是连续的,聚簇索引不同页存储在不同位置,接近随机IO。
由于覆盖索引是按键值的顺序存储的,对于IO密集型的范围查找来说,对比随机从磁盘读取每一行的数据IO要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的IO转变成索引查找的顺序IO。
由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。
弊端:
索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。这是业务DBA,或者称为业务数据架构师的工作。
9. 前缀索引
9.1 前缀索引
MySQL是支持前缀索引的。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。
1 | # index1 |
这两种不同的定义在数据结构和存储上有什么区别呢?
如果使用的是index1(即email整个字符串的索引结构),执行顺序是这样的:
① 从index1索引树找到满足索引值是’zhangssxyz@xxx.com’的这条记录,取得ID2的值;
② 到主键上查到主键值是ID2的行,判断email的值是正确的,将这行记录加入结果集;
③ 取index1索引树上刚刚查到的位置的下一条记录,发现已经不满足email='zhangssxyz@xxx.com’的 条件了,循环结束。
这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。
如果使用的是index2(即email(6)索引结构),执行顺序是这样的:
① 从index2索引树找到满足索引值是’zhangs’的记录,找到的第一个是ID1;
② 到主键上查到主键值是ID1的行,判断出email的值不是’zhangssxyz@xxx.com’,这行记录丢弃;
③ 取index2上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出ID2,再到ID索引上取整行然后判断,这次值对了,将这行记录加入结果集;
④ 重复上一步,直到在idxe2上取到的值不是’zhangs’时,循环结束。
也就是说**使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。**前面已经讲过区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。
9.2 前缀索引对覆盖索引的影响
前面我们说了使用前缀索引可能会增加扫描行数,这会影响到性能。其实,前缀索引的影响不止如此,我们再看一下另外一个场景:
如果使用index1(即email整个字符串的索引结构)的话,可以利用覆盖索引,从index1查到结果后直接就返回了,不需要回到ID索引再去查一次。而如果使用index2(即email(6)索引结构)的话,就不得不回到ID索引再去判断email字段的值。
即使你将index2的定义修改为 email(18) 的前缀索引,这时候虽然index2已经包含了所有的信息,但 InnoDB还是要回到id索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。
1 | select id,email from teacher where email='songhongkangexxx.com'; |
结论:使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是在选择使用前缀索引时需要考虑的一个因素。
9.3 拓展内容
对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。但是,遇到前缀的区分度不够好的 情况时,我们要怎么办呢?
比如,我们国家的身份证号,一共 18 位,其中前 6 位是地址码,所以同一个县的人的身份证号前6位一般会是相同的。
假设你维护的数据库是一个市的公民信息系统,这时候如果对身份证号做长度为6的前缀索引的话,这个索引的区分度就非常低了。按照我们前面说的方法,可能你需要创建长度为12以上的前缀索引,才能够满足区分度要求。
但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。 那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理方法呢?
这种方法,既可以占用更小的空间,也能达到相同的查询效率。有!
**第一种方式是使用倒序存储。**如果你存储身份证号的时候把它倒过来存,每次查询的时候:
1 | select field list from teacher |
由于身份证号的最后6位没有地址码这样的重复逻辑,所以最后这6位很可能就提供了足够的区分度。当然,实践中你还要使用count(distinct)方法去做验证。
第二种方式是使用hash字段。你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。
1 | alter table teacher add id_card_crc int unsigned |
然后每次插入新记录的时候,都同时用crc32()这个函数得到校验码填到这个新字段,由于校验码可能存在冲突,也就是说两个不同的身份证号通过crc32()函数得到的结果可能是相同的,所以你的查询语句 where 部分要判断id_card的值是否精确相同。
1 | select field list from t |
这样,索引的长度变成了4个字节,比原来小了很多。
从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些,因为 crc32 算出来的值虽然有 冲突的概率但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是 用的前缀索引的方式,也就是说还是会增加扫描行数。
10. 索引条件下推
10.1 使用前后对比
Index Condition Pushdown(ICP)是MySQL5.6中新特性,是一种在存储引擎层使用索引过滤数据的一种优化方式。ICP可以减少存储引擎访问基表的次数以及MySQL服务器访问存储引擎的次数。MySQL的逻辑架构包括服务层和存储引擎层,索引下推中的下的意思是,将部分上层(服务层)负责的事情交给下层(存储引擎层)处理。
-
在不使用ICP情况下,存储引擎会遍历索引以定位基表中的行,并将它们返回给MySQL服务层,由MySQL服务层根据WHERE后面的条件评估是否保留行。
-
在使用ICP的情况下,如果部分WHERE条件可以仅使用索引中的列进行筛选,则MySQL服务器会把这部分WHERE条件放到存储引擎筛选。然后,存储引擎通过使用索引条目来筛选数据,并且只有在满足这一条件时才从表中读取行。
-
- 好处:ICP可以减少存储引擎必须访问基表的次数和MySQL服务层必须访问存储引擎的次数。
-
- 但是,ICP的加速效果取决于在存储引擎内通过
ICP筛选掉的数据的比例
- 但是,ICP的加速效果取决于在存储引擎内通过
减少存储引擎访问基表的次数:因为存储引擎可以通过索引条件直接过滤掉不符合条件的数据,从而减少对基表的访问。
减少MySQL服务层访问存储引擎的次数:存储引擎已经在索引层面应用了部分条件过滤,因此传输到 MySQL 服务层的数据量减少了,从而减少了服务层与存储引擎之间的交互。
猜测性的理解
在没有ICP之前,WHERE子句中的条件如果无法在索引层面上减少需要扫描的索引数据的条数。那么在索引上不会用这些条件进行数据过滤。
假设存在联合索引INDEX (zipcode, lastname, firstname)
1 | SELECT * FROM people |
mysql可以通过索引上的列zipcode减少需要需要扫描的索引数据条数,因为zipcode='95054'可以在索引层面上定位数据的范围。从而减少扫描的数量。
而lastname LIKE '%etrunia%',address LIKE '%Main Street%'必须遍历索引中的每条数据,才能得到结果。没法在索引上减少扫描的行数。所以不会被用于过滤索引。
推测依据是官方文档以下内容
MySQL :: MySQL 8.4 Reference Manual :: 10.2.1.6 Index Condition Pushdown Optimization
MySQL can use the index to scan through people with
zipcode='95054'. The second part (lastname LIKE '%etrunia%') cannot be used to limit the number of rows that must be scanned, so without Index Condition Pushdown, this query must retrieve full table rows for all people who havezipcode='95054'.
1 | SELECT * FROM people WHERE zipcode='000001' AND lastname LIKE '张%'; |

前两句的差距是非常小的,说明无论是否使用ICP技术,过滤都是在存储引擎层完成的,没有到服务层。作证了猜想,即可以缩写扫描索引条数的过滤条件,无论是否使用ICP技术,这些过滤都是在存储引擎层完成的。
1 | SELECT * FROM people WHERE zipcode='000001' AND lastname LIKE '%张%'; |
后两句的差距是非常大的,后一句不使用ICP,只能根据zipcode='000001'在索引层面上进行范围定位。而前一句使用ICP,不仅根据zipcode='000001'在索引层面上进行范围定位,还通过lastname LIKE '%张%'在索引层面筛选数据。
佐证了猜测。说明在不使用ICP的情况下,存储引擎层只使用能够在索引层面上减少扫描索引条目数的where条件。
LIKE '张%' 的模式会让 MySQL 能够利用索引,因为它的匹配模式从字符串开头开始。这意味着 MySQL 可以通过索引查找以“张”开头的姓氏,找到符合条件的记录。
LIKE '%张%' 这个查询模式中,% 号出现在字符串开头,MySQL 无法使用索引来优化查询。它必须扫描整个索引中的 lastname 列进行,以查找包含“张”字的记录。
10.2 使用前后的扫描过程
在不使用ICP索引扫描的过程:
storage层:只将满足index key条件的索引记录对应的整行记录取出,返回给server层
server层:对返回的数据,使用后面的where条件过滤,直至返回最后一行。

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

10.3 ICP的开启/关闭
默认情况下启动索引条件下推。可以通过设置系统变量optimizer_switch控制:index_condition_pushdown
1 | # 打开索引下推 |
当使用索引条件下推时,EXPLAIN语句输出结果中Extra列内容显示为Using index condition。
- 通过MySQL的
optimizer_switch系统变量检查或控制 ICP 功能是否启用。
1 | SHOW VARIABLES LIKE 'optimizer_switch'\G |
- 可以通过查询
INFORMATION_SCHEMA.INNODB_METRICS表监控 ICP 使用情况
需要先启用 INNODB_METRICS 开启收集与ICP相关的指标:
1 | SET GLOBAL innodb_monitor_enable = 'all'; # 开启 |
1 | SELECT NAME, SUBSYSTEM, COUNT, MAX_COUNT, MIN_COUNT, AVG_COUNT |
icp_attempts:ICP 尝试使用的次数。
icp_match:ICP 匹配成功的次数(即 ICP 实际被使用的次数)。
10.4 ICP使用案例
1 | CREATE TABLE `people` ( |
插入数据
1 | INSERT INTO `people` VALUES |
为该表定义联合索引 zip_last_first (zipcode, lastname, firstname)。如果我们知道了一个人的邮编,但是不确定这个人的姓氏,我们可以进行如下检索:
1 | EXPLAIN SELECT * FROM people |
执行查看SQL的查询计划,Extra 中显示了Using index condition,这表示使用了索引下推。另外,Using where表示条件中包含需要过滤的非索引列的数据,即address LIKE '%北京市%'这个条件并不是索引列,需要在服务端过滤掉。
关闭ICP查看执行计划
1 | SET optimizer_switch = 'index_condition_pushdown=off'; |
查看执行计划,已经没有Using index condition,表明没有使用ICP
1 | EXPLAIN SELECT * FROM people |
10.5 开启和关闭ICP性能对比
创建存储过程,主要目的是插入很多000001的数据,这样查询的时候为了在存储引擎层做过滤,减少IO,也为
减少缓冲池(缓存数据页,没有IO)的作用。
1 | DELIMITER // |
调用存储过程
1 | CALL insert_people(1000000); |
首先打开profiling
1 | set profiling = 1; |
执行SQL语句,此时默认打开索引下推。
1 | SELECT * FROM people WHERE zipcode='000001' AND lastname LIKE '%张%' ; |
再次执行SQL语句,不使用索引下推
1 | SELECT /*+ no_icp (people) */ * FROM people WHERE zipcode='000001' AND lastname LIKE '%张%' ; |
查看当前会话所产生的所有profiles
1 | show profiles; |
多次测试效率对比来看,使用ICP优化的查询效率会好一些。这里建议多存储一些数据效果更明显。
10.6 ICP的使用条件
① 如果表的访问类型为range、ref、eq_ref 和 ref_or_null可以使用ICP。
② ICP可以使用InnDB和MyISAM表,包括分区表InnoDB和MyISAM表。ICP是功能是在 MySQL 5.6 版本中首次引入的。MySQL5.6版本的不支持分区表的ICP功能,5.7版本开始支持。
③ 对于InnoDB表,ICP仅用于二级索引。
在InnoDB中,主键索引是聚簇索引,即主键索引和数据行是存储在一起的。当MySQL使用主键索引进行查询时,它会直接访问到完整的数据行,因此不需要进行索引条件下推。
④ 当SQL使用覆盖索引时,不支持ICP优化方法。
ICP的主要目标是减少回表次数,通过在存储引擎层筛选数据来避免不必要的回表操作。对于覆盖索引来说,因为所有查询的数据都已经在索引中,根本没有回表的过程。
⑤ 相关子查询的条件不能使用ICP
相关子查询中的条件依赖于外部查询中的某些值,因此子查询在执行时需要根据外部查询的每一行去重新计算结果。例如,以下查询中,子查询依赖于外部查询中的 t1.id:
1 | SELECT * FROM t1 WHERE t1.column1 IN (SELECT t2.column2 FROM t2 WHERE t2.id = t1.id); |
这种情况下,MySQL无法将子查询中的条件在扫描索引时推送给存储引擎,从而利用索引进行条件过滤。
ICP的前提是查询条件必须是静态的,并且在索引扫描过程中可以确定,这样存储引擎才能提前过滤数据。
虽然外部的查询语句,没法使用索引下推,但是子查询的内部查询部分,如果有合适的索引,仍然可以使用索引条件下推
11. 普通索引 VS 唯一索引
在不同的业务场景下,应该选择普通索引,还是唯一索引?
假设我们在维护一个居民系统,每个人都有一个唯一的身份证号,而且业务代码已经保证了不会写入两个重复的身份证号。如果居民系统需要按照身份证号查姓名,考虑在 id_card 字段上建立索引是必要的。
1 | select name from CUser where id_card='xxxxxxxyyyyyyzzzzz'; |
由于身份证号字段比较大,不建议把身份证号当做主键。我们可以选择在 id_card 字段上创建唯一索引或普通索引。如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的。
从性能的角度考虑,你选择唯一索引还是普通索引呢?选择的依据是什么呢?
假设我们有一个主键为ID的表,表中字段k有索引且值不重复,建表语句如下:
1 | create table test( |
表中R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6)。
11.1 查询过程
执行查询的语句 select id from test where k=5时
- 对于普通索引来说,查找到满足条件的第一个记录(5,500)后,需要查找下一个记录,直到碰到第一个不满足k=5条件的记录。
- 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。
那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。
InnoDB的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16KB
11.2 更新的过程
如果要在这张表中插入一个新记录 (4,400) 的话,InnoDB的处理流程是怎样的?
第一种情况是,这个记录要更新的目标页在内存中。这时:
- 对于唯一索引来说,找到 3 和 5 之间的位置,判断为没有冲突,插入这个值,语句执行结束
- 对于普通索引来说,找到 3 和 5 之间的位置,插入这个值,语句执行结束。
这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的CPU时间。
为了说明普通索引和唯一索引对更新语句性能的影响这个问题,介绍一下 change buffer。
当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InnoDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。
将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge 。除了访问这个数据页会触发merge外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行merge操作。
如果能够将更新操作先记录在change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率。
那么,什么条件下可以使用change buffer呢?
对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入 (4.400) 这个记录,就要先判断现在表中是否已经存在 k=4 的记录,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用 change buffer 了。 因此,唯一索引的更新就不能使用 change buffer,实际上也只有普通索引可以使用。
- 对于在内存中的数据,唯一索引和普通索引的更新效率一样。
- 对于不在内存中的数据,唯一索引需要先读数据到内存中,然后更新。普通索引直接使用 change buffer,更快。
change buffer 用的是 buffer pool 里的内存,因此不能无限增大。change buffer 的大小,可以通过参数innodb_change_buffer_maxsize来动态设置。默认值是25,这个参数设置为50的时候,表示changebuffer的大小最多只能占用buffer pool的 50%。
11.3 change buffer的使用场景
change buffer只限于用在普通索引的场景下,而不适用于唯一索引。那么,现在有一个问题就是:普通索引的所有场景,使用change buffer都可以起到加速作用吗?
因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。
因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。
反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,之后由于马上要访问这个数据页,会立即触发 merge 过程,这样随机访问 I/O 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这种业务模式来说,changebuffer 反而起到了副作用。
-
普通索引和唯一索引应该怎么选择?其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,建议你
尽量选择普通索引。 -
在实际使用中会发现,
普通索引和change buffer的配合使用,对于数据量大的表的更新优化还是很明显的。 -
如果所有的更新后面,都马上
伴随着对这个记录的查询,那么你应该关闭change buffer。而在其他情况下,change buffer都能提升更新性能。 -
由于唯一索引用不上change buffer的优化机制,因此如果
业务可以接受,从性能角度出发建议优先考虑非唯一索引。但是如果"业务可能无法确保"的情况下,怎么处理呢?首先, 业务正确性优先。我们的前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。这种情况下,本节的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,给你多提供一个排查思路。
然后,在一些
归档库的场景,可以考虑使用普通索引。比如,线上数据只需要保留半年,然后历史数据保存在归档库。这时候,归档数据已经是确保没有唯一键冲突了。要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。
12. 其它查询优化策略
12.1 EXISTS和IN的区分
哪种情况下应该使用EXISTS,哪种情况应该用IN。选择的标准是看能否使用表的索引吗?
索引是个前提,其实选择与否还会要看表的大小。可以将选择的标准理解为小表驱动大表。
比如下面这样:
1 | SELECT * FROM A WHERE cc IN (SELECT cc FROM B) |
当A小于B时,用 EXISTS。因为EXISTS的实现,相当于外表循环,实现的逻辑类似于:
1 | for i in A |
当 B 小于 A 时用 IN,因为实现的逻辑类似于:
1 | for i in B |
结论:哪个表小就用哪个表来驱动,A 表小就用 EXISTS ,B 表小就用 IN
12.2 COUNT(*)与COUNT(具体字段)效率
问: 在MySQL中统计数据表的行数, 可以使用三种方式: SELECT COUNT(*)、SELECT COUNT(1) 和 SELECT COUNT(具体字段),使用这三者之间的查询效率是怎样的?
答:
前提: 如果你要统计的是某个字段的非空数据行数, 则另当别论, 毕竟比较执行效率的前提是结果一样才可以。
环节1:COUNT(*)和COUNT(1)都是对所有结果进行COUNT,COUNT(*)和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小的二级索引进行扫描。当没有二级索引的时候,才会采用主键索引来进行统计。
12.3 关于SELECT(*)
在表查询中,建议明确字段,不要使用 * 作为查询的字段列表,推荐使用SELECT <字段列表> 查询。原因:
① MySQL在解析的过程中,会通过查询数据字典将"*"按序转换成所有列名,这会大大的耗费资源和时间。
② 无法使用覆盖索引
12.4 LIMIT 1 对优化的影响
针对的是会扫描全表的SQL语句,如果你可以确定结果集只有一条,那么加上LIMIT 1的时候,当找到一条结果的时候就不会继续扫描了,这样会加快查询速度。
如果数据表已经对字段建立了唯一索引,那么可以通过索引进行查询,不会全表扫描的话,就不需要加上LIMIT 1了。
12.5 多使用COMMIT
只要有可能,在程序中尽量多使用COMMIT,这样程序的性能得到提高,需求也会因为COMMIT所释放的资源而减少。
COMMIT所释放的资源:
-
回滚段上用于恢复数据的信息
-
被程序语句获得的锁
-
redo / undo log buffer 中的空间
-
管理上述 3 种资源中的内部花费
13. 淘宝数据库,主键如何设计的?
聊一个实际问题:淘宝的数据库,主键是如何设计的?
某些错的离谱的答案还在网上年复一年的流传着,甚至还成为了所谓的MySQL军规。其中,一个最明显的错误就是关于MySQL的主键设计。
大部分人的回答如此自信:用8字节的 BIGINT 做主键,而不要用INT。 错 !
这样的回答,只站在了数据库这一层,而没有 从业务的角度 思考主键。主键就是一个自增ID吗?站在 2022年的新年档口,用自增做主键,架构设计上可能 连及格都拿不到 。
13.1 自增ID的问题
自增ID做主键,简单易懂,几乎所有数据库都支持自增类型,只是实现上各自有所不同而已。自增ID除 了简单,其他都是缺点,总体来看存在以下几方面的问题:
可靠性不高
存在自增ID回溯的问题,这个问题直到最新版本的MySQL 8.0才修复。
在 MySQL 5.x 及更早版本中,如果多个并发事务同时插入数据到一个带有
AUTO_INCREMENT自增主键的表时,MySQL 会在每个事务提交之前,为该事务分配一个自增 ID(主键值)。为了确保自增 ID 的唯一性,MySQL使用了一个自增锁(AUTO-INC lock)来控制每个事务获取自增 ID 的顺序。这个锁的存在导致了两个问题:
性能瓶颈:当多个并发插入操作发生时,所有事务都必须等待获取这个自增锁,这会导致插入性能下降。
回溯问题:如果某个事务在插入数据过程中回滚或出现失败,MySQL会回溯到之前的状态,但已分配的自增 ID 并不会被回收。这意味着有可能出现 ID 不连续的情况,尽管这不是致命问题,但在某些场景下会影响业务逻辑
**安全性不高 **
对外暴露的接口可以非常容易猜测对应的信息。比如:/User/1/这样的接口,可以非常容易猜测用户ID的 值为多少,总用户数量有多少,也可以非常容易地通过接口进行数据的爬取。
性能差
自增ID的性能较差,需要在数据库服务器端生成。
交互多
业务还需要额外执行一次类似 last_insert_id() 的函数才能知道刚才插入的自增值,这需要多一次的 网络交互。在海量并发的系统中,多1条SQL,就多一次性能上的开销。
**局部唯一性 **
最重要的一点,自增ID是局部唯一,只在当前数据库实例中唯一,而不是全局唯一,在任意服务器间都 是唯一的。对于目前分布式系统来说,这简直就是噩梦。
13.2 业务字段做主键
为了能够唯一地标识一个会员的信息,需要为会员信息表设置一个主键。那怎么为这个表设置主键,才能达到我们理想的目标呢?这里我们考虑业务字段做主键。
表数据如下
| cardno | membername | memberphone | memberpid | address | sex | birthday |
|---|---|---|---|---|---|---|
| 10000001 | 张三 | 13812345678 | 110123200001017890 | 北京 | 男 | 2000-01-01 |
| 10000002 | 李四 | 13512312312 | 123123199001012356 | 上海 | 女 | 1990-01-01 |
这个表里,哪个字段比较合适呢?
- 选择卡号
会员卡号(cardno)看起来比较合适,因为会员卡号不能为空,而且有唯一性,可以用来标识一条会员记录。
1 | mysql> CREATE TABLE demo.membermaster |
不同的会员卡号对应不同的会员,字段“cardno”唯一地标识某一个会员。如果都是这样,会员卡号与会员一一对应,系统是可以正常运行的。
但实际情况是,会员卡号可能存在重复使用的情况。比如,张三因为工作变动搬离了原来的地址,不再到商家的门店消费了(退还了会员卡),于是张三就不再是这个商家门店的会员了。但是,商家不想让 这个会员卡空着,就把卡号是“10000001”的会员卡发给了王五。
从系统设计的角度看,这个变化只是修改了会员信息表中的卡号是“10000001”这个会员信息,并不会影响到数据一致性。也就是说,修改会员卡号是“10000001”的会员信息,系统的各个模块,都会获取到修改后的会员信息,不会出现“有的模块获取到修改之前的会员信息,有的模块获取到修改后的会员信息, 而导致系统内部数据不一致”的情况。因此,从信息系统层面上看是没问题的。
但是从使用系统的业务层面来看,就有很大的问题了,会对商家造成影响。
比如,我们有一个销售流水表(trans),记录了所有的销售流水明细。2020年12月01日,张三在门店购买了一本书,消费了89元。那么,系统中就有了张三买书的流水记录,如下所示:
| transactionno (流水单号) | itemnumber (商品编号) | quantity (销售数量) | price (价格) | salesvalue (销售金额) | cardno (会员卡号) | transdate (交易时间) |
|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 89 | 89 | 10000001 | 2020-12-01 |
接着,我们查询一下2020年12月01日的会员销售记录:
1 | mysql> SELECT b.membername,c.goodsname,a.quantity,a.salesvalue,a.transdate |
如果会员卡“10000001”又发给了王五,我们会更改会员信息表。导致查询时:
1 | mysql> SELECT b.membername,c.goodsname,a.quantity,a.salesvalue,a.transdate |
这次得到的结果是:王五在2020年12月01日,买了一本书,消费89元。显然是错误的!结论:千万不能把会员卡号当做主键。
- 选择会员电话或身份证号
会员电话可以做主键吗?不行的。在实际操作中,手机号也存在被运营商收回,重新发给别人用的情况。
那身份证号行不行呢?好像可以。因为身份证决不会重复,身份证号与一个人存在一一对应的关系。可问题是,身份证号属于个人隐私,顾客不一定愿意给你。要是强制要求会员必须登记身份证号,会把很多客人赶跑的。其实,客户电话也有这个问题,这也是我们在设计会员信息表的时候,允许身份证号和电话都为空的原因
所以,建议尽量不要用跟业务有关的字段做主键。毕竟,作为项目设计的技术人员,我们谁也无法预测在项目的整个生命周期中,哪个业务字段会因为项目的业务需求而有重复,或者重用之类的情况出现。
经验: 刚开始使用MySQL时,很多人都很容易犯的错误是喜欢用业务字段做主键,想当然地认为了解业务需求,但实际情况往往出乎意料,而更改主键设置的成本非常高。
13.3 淘宝的主键设计
在淘宝的电商业务中,订单服务是一个核心业务。请问,订单表的主键淘宝是如何设计的呢?是自增ID吗?
打开淘宝,看一下订单信息:
订单号不是自增ID!我们详细看下上述4个订单号:
1 | 1550672064762308113 |
订单号是19位的长度,且订单的最后5位都是一样的,都是08113。且订单号的前面14位部分是单调递增的。
大胆猜测,淘宝的订单ID设计应该是:
1 | 订单ID = 时间 + 去重字段 + 用户ID后6位尾号 |
这样的设计能做到全局唯一,且对分布式系统查询及其友好。
13.4 推荐的主键设计
非核心业务:对应表的主键自增ID,如告警、日志、监控等信息。
核心业务 :主键设计至少应该是全局唯一且是单调递增。全局唯一保证在各系统之间都是唯一的,单调递增是希望插入时不影响数据库性能。
这里推荐最简单的一种主键设计:UUID。
UUID的特点:
全局唯一,占用36字节,数据无序,插入性能差。
认识UUID:
- 为什么UUID是全局唯一的?
- 为什么UUID占用36个字节?
- 为什么UUID是无序的?
MySQL数据库的UUID组成如下所示:
1 | UUID = 时间+UUID版本(16字节)- 时钟序列(4字节) - MAC地址(12字节) |
以UUID值e0ea12d4-6473-11eb-943c-00155dbaa39d举例:

为什么UUID是全局唯一的?
在UUID中时间部分占用60位,存储的类似TIMESTAMP的时间戳,但表示的是从1582-10-15 00:00:00.00 到现在的100ns的计数。可以看到UUID存储的时间精度比TIMESTAMPE更高,时间维度发生重复的概率降 低到1/100ns。
时钟序列是为了避免时钟被回拨导致产生时间重复的可能性。MAC地址用于全局唯一。
为什么UUID占用36个字节?
UUID根据字符串进行存储,设计时还带有无用"-"字符串,因此总共需要36个字节。
为什么UUID是随机无序的呢?
因为UUID的设计中,将时间低位放在最前面,而这部分的数据是一直在变化的,并且是无序。
改造UUID
若将时间高低位互换,则时间就是单调递增的了,也就变得单调递增了。MySQL8.0可以更换时间低位和时间高位的存储方式,这样UUID就是有序的UUID了。
MySQL8.0还解决了UUID存在的空间占用的问题,除去了UUID字符串中无意义的"-"字符串,并且将字符串用二进制类型保存,这样存储空间降低为了16字节。
可以通过MySQL8.0提供的uuid_to_bin函数实现上述功能,同样的,MySQL也提供了bin_to_uuid函数进行转化:
1 | SET @uuid = UUID(); |
通过函数uuid_to_bin(@uuid,true)将UUID转化为有序UUID了。全局唯一 + 单调递增,这不就是我们想要的主键!
有序UUID性能测试
16字节的有序UUID,相比之前8字节的自增ID,性能和存储空间对比究竟如何呢?
我们来做一个测试,插入1亿条数据,每条数据占用500字节,含有3个二级索引,最终的结果如下所示:

从上图可以看到插入1亿条数据有序UUID是最快的,而且在实际业务使用中有序UUID在业务端就可以生成。还可以进一步减少SQL的交互次数。
另外,虽然有序UUID相比自增ID多了8个字节,但实际只增大了3G的存储空间,还可以接受。
在当今的互联网环境中,非常不推荐自增ID作为主键的数据库设计。更推荐类似有序UUID的全局 唯一的实现。
另外在真实的业务系统中,主键还可以加入业务和系统属性,如用户的尾号,机房的信息等。这样 的主键设计就更为考验架构师的水平了。
如果不是MySQL8.0 怎么办?
手动赋值字段做主键!
比如,设计各个分店的会员表的主键,因为如果每台机器各自产生的数据需要合并,就可能会出现主键重复的问题。
可以在总部MySQL数据库中,有一个管理信息表,在这个表中添加一个字段,专门用来记录当前会员编号的最大值。
门店在添加会员的时候,先到总部MySQL数据库中获取这个最大值,在这个基础上加1,然后用这个值作为新会员的“id”,同时,更新总部 MySQL 数据库管理信息表中的当前会员编号的最大值。
这样一来,各个门店添加会员的时候,都对同一个总部MySQL数据库中的数据表字段进行操作,就解决了各门店添加会员时会员编号冲突的问题。
第11章 数据库的设计规范
1. 为什么需要数据库设计
我们在设计数据表的时候,要考虑很多问题。比如:
- 用户会需要什么数据?需要在数据表里保存哪些数据?
- 如何保证数据表中的数据的
正确性,当插入、删除、更新的时候该进行怎样的约束检查? - 如何降低数据表的
数据冗余度,保证数据表不会因为用户量的增长而迅速扩张? - 如何使负责维护数据库的人员
更方便地使用数据库? - 使用数据表的应用场景会有不同,可以针对不同的情况,设计出来的数据表可能
千差万别。
现状情况下,面临的场景:
当数据库运行了一段时间之后,我们会发现数据表设计的有问题。重新调整数据表的结构,就需要做数据迁移,这个可能会影响程序的业务逻辑,以及网站的正常访问。
如果是糟糕的数据库设计可能会造成以下问题:
- 数据冗余、信息丢失、存储空间浪费
- 数据更新、插入、删除的异常
- 无法正确表示信息
- 丢失有效信息
- 程序性差
良好的数据库设计则有以下优点:
- 节省数据的存储空间
- 能够保证数据的完整性
- 方便进行数据库应用系统的开发
总之,开始设置数据库表的时候,我们就需要重视数据表的设计。为了建立冗余较小、结构合理的数据库,设计数据表时必须遵循一定的规则。
2. 范 式
2.1 范式简介
**在关系型数据库中,关于数据表设计的基本原则、规则就称为范式。**可以理解为,一张数据表的设计结构需要满足的某种设计标准的级别。要想设计一个结构合理的关系型数据库,必须满足一定的范式。
范式的英文名称是Normal Form,简称NF。它是英国人E.F.Codd在上个世纪70年代提出关系数据库模型后总结出来的。范式是关系数据库理论的基础,也是我们在设计数据库结构过程中所要遵循的规则和指导方法。
2.2 范式都包括哪些
目前关系型数据库有六种常见范式,按照范式级别,从低到高分别是:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、巴斯-科德范式(BCNF)、第四范式(4NF)和第五范式(5NF,又称完美范式)。
数据库的范式设计越高阶,冗余度就越低,同时高阶的范式一定符合低阶范式的要求,满足最低要求的范式是第一范式(1NF)。在第一范式的基础上进一步满足更多规范的要求称为第二范式(2NF),其余范式以此类推。
一般来说,在关系型数据库设计中,最高也就遵循到BCNF, 普遍还是3NF。但也不绝对,有时候为了提高某些查询性能,我们还需要破坏范式规则,也就是反规范化。
2.3 键和相关属性的概念
范式的定义会使用到主键和候选键,数据库中的键(Key)由一个或者多个属性组成。数据表中常用的几种键和属性的定义:
超键:能唯一标识元组的属性集叫做超键。候选键:如果超键不包括多余的属性,那么这个超键就是候选键。主键:用户可以从候选键中选择一个作为主键。外键:如果数据表 R1 中的某属性集不是 R1 的主键,而是另一个数据表 R2 的主键,那么这个属性集就是数据表 R1 的外键。主属性:包含在任一候选键中的属性称为主属性。非主属性:与主属性相对,指的是不包含在任何一个候选键中的属性。
通常,我们也将候选键称为码,把主键也称为主码。因为键可能是由多个属性组成的,针对单个属性,我们还可以用主属性和非主属性来进行区分。
决定 通常指的是一个属性或属性集确定另一个属性或属性集的能力,例如,如果属性A的值可以唯一地确定属性B的值,那么A决定B。
举例
这里有两个表:
球员表(player):球员编号|姓名|身份证号|年龄|球队编号
球队表(team):球队编号|主教练|球队所在地
超键:对于球员表来说,超键就是包括球员编号或者身份证号的任意组合,比如(球员编号)(球员编号,姓名)(身份证号,年龄)等。候选键:就是最小的超键,对于球员表来说,候选键就是(球员编号)或者(身份证号)。主键:我们自己选定,也就是从候选键中选择一个,比如(球员编号)。外键:球员表中的球队编号。主属性、非主属性:在球员表中,主属性是(球员编号)(身份证号),其他的属性(姓名)(年龄)(球队编号)都是非主属性。
2.4 第一范式 (1st NF)
第一范式主要是确保数据表中每个字段的值必须具有原子性,也就是说数据表中每个字段的值为不可再次拆分的最小数据单位。
我们在设计某个字段的时候,对于字段X来说,不能把字段X拆分成字段X-1和字段X-2。事实上,任何的DBMS都会满足第一范式的要求,不会将字段进行拆分。
举例1:
假设一家公司要存储员工的姓名和联系方式。它创建一个如下表:

该表不符合1NF,因为规则说表的每个属性必须具有原子(单个)值,员工lisi和zhaoliu的emp_mobile值违反了该规则。为了使表符合1NF,我们应该有如下表数据:

举例2:
user表的设计不符合第一范式
| 字段名称 | 字段类型 | 是否是主键 | 说明 |
|---|---|---|---|
| id | INT | 是 | 主键id |
| username | VARCHAR(30) | 否 | 用户名 |
| password | VARCHAR(50) | 否 | 密码 |
| user_info | VARCHAR(255) | 否 | 用户信息 (包含真实姓名、电话、住址) |
其中,user_info字段为用户信息,可以进一步拆分成更小粒度的字段,不符合数据库设计对第一范式的要求。将user_info拆分后如下:
| 字段名称 | 字段类型 | 是否是主键 | 说明 |
|---|---|---|---|
| id | INT | 是 | 主键id |
| username | VARCHAR(30) | 否 | 用户名 |
| password | VARCHAR(50) | 否 | 密码 |
| real_name | VARCHAR(30) | 否 | 真实姓名 |
| phone | VARCHAR(12) | 否 | 联系电话 |
| address | VARCHAR(100) | 否 | 家庭住址 |
举例3:
属性的原子性是主观的。例如,Employees关系中雇员姓名应当使用1个(fullname)、2个(firstname和lastname)还是3个(firstname、middlename 和 lastname)属性表示呢?答案取决于应用程序。如果应用程序需要分别处理雇员的姓名部分(如:用于搜索目的),则有必要把它们分开。否则,不需要。
表1:
| 姓名 | 年龄 | 地址 |
|---|---|---|
| 张三 | 20 | 广东省广州市三元里78号 |
| 李四 | 24 | 广东省深圳市龙华新区 |
表2:
| 姓名 | 年龄 | 省 | 市 | 地址 |
|---|---|---|---|---|
| 张三 | 20 | 广东 | 广州 | 三元里78号 |
| 李四 | 24 | 广东 | 深圳 | 龙华新区 |
2.5 第二范式 (2nd NF)
第二范式要求,在满足第一范式的基础上,还要**满足数据表里的每一条数据记录,都是可唯一标识的。而且所有非主键字段,都必须完全依赖主键,不能只依赖主键的一部分。**如果知道主键的所有属性的值,就可以检索到任何元组(行)的任何属性的任何值。(要求中的主键,其实可以扩展替换为候选键)。
举例1:
成绩表(学号,课程号,成绩)关系中,(学号,课程号)可以决定成绩,但是学号不能决定成绩,课程号也不能决定成绩,所以“(学号,课程号)→成绩”就是完全依赖关系。
举例2:
比赛表 player_game ,里面包含球员编号、姓名、年龄、比赛编号、比赛时间和比赛场地等属性,这里候选键和主键都为(球员编号,比赛编号),我们可以通过候选键(或主键)来决定如下的关系:
1 | (球员编号, 比赛编号) → (姓名, 年龄, 比赛时间, 比赛场地,得分) |
但是这个数据表不满足第二范式,因为数据表中的字段之间还存在着如下的对应关系:
1 | (球员编号) → (姓名,年龄) |
对于非主属性来说,并非完全依赖候选键。这样会产生怎样的问题呢?
数据冗余 :如果一个球员可以参加 m 场比赛,那么球员的姓名和年龄就重复了 m-1 次。一个比赛也可能会有 n 个球员参加,比赛的时间和地点就重复了 n-1 次。
插入异常:如果我们想要添加一场新的比赛,但是这时还没有确定参加的球员都有谁,那么就没法插入。
删除异常:如果我要删除某个球员编号,如果没有单独保存比赛表的话,就会同时把比赛信息删除掉。
更新异常:如果我们调整了某个比赛的时间,那么数据表中所有这个比赛的时间都需要进行调整,否则就会出现一场比赛时间不同的情况。
为了避免出现上述的情况,我们可以把球员比赛表设计为下面的三张表。
| 表名 | 属性(字段) |
|---|---|
| 球员player表 | 球员编号、姓名和年龄等属性 |
| 比赛game表 | 比赛编号、比赛时间和比赛场地等属性 |
| 球员比赛关系player_game表 | 球员编号、比赛编号和得分等属性 |
这样的话,每张数据表都符合第二范式,也就避免了异常情况的发生。
1NF 告诉我们字段属性需要是原子性的,而 2NF 告诉我们一张表就是一个独立的对象,一张表只表达一个意思。
举例3:
定义了一个名为 Orders 的关系,表示订单和订单行的信息:
违反了第二范式,因为有非主键属性仅依赖于候选键(或主键)的一部分。例如,可以仅通过orderid找到订单的 orderdate,以及customerid和companyname,而没有必要再去使用productid。
修改:
Orders表和OrderDetails表如下,此时符合第二范式。
小结:第二范式(2NF)要求实体的属性完全依赖主关键字。如果存在不完全依赖,那么这个属性和主关键字的这一部分应该分离出来形成一个新的实体,新实体与元实体之间是一对多的关系。
2.6 第三范式 (3rd NF)
第三范式是在第二范式的基础上,确保数据表中的每一个非主键字段都和主键字段直接相关,也就是说,要求数据表中的所有非主键字段不能依赖于其他非主键字段。(即,不能存在非主属性A依赖于非主属性B,非主属性B依赖于主键C的情况,即存在"A–>B–>C"的决定关系)通俗地讲,该规则的意思是所有非主键属性之间不能有依赖关系,必须相互独立。
这里的主键可以扩展为候选键。
举例1:
部门信息表 :每个部门有部门编号(dept_id)、部门名称、部门简介等信息。
员工信息表:每个员工有员工编号、姓名、部门编号。列出部门编号后就不能再将部门名称、部门简介等与部门有关的信息再加入员工信息表中。
如果不存在部门信息表,则根据第三范式(3NF)也应该构建它,否则就会有大量的数据冗余。
举例2:
| 字段名称 | 字段类型 | 是否是主键 | 说明 |
|---|---|---|---|
| id | INT | 是 | 商品主键id(主键) |
| category_id | INT | 否 | 商品类别id |
| category_name | VARCHAR(30) | 否 | 商品类别名称 |
| goods_name | VARCHAR(30) | 否 | 商品名称 |
| price | DECIMAL(10,2) | 否 | 商品价格 |
商品类别名称依赖于商品类别编号,不符合第三范式。
修改:
表1:符合第三范式的商品类别表的设计
| 字段名称 | 字段类型 | 是否是主键 | 说明 |
|---|---|---|---|
| id | INT | 是 | 商品类别主键id |
| category_name | VARCHAR(30) | 否 | 商品类别名称 |
表2:符合第三范式的商品表的设计
| 字段名称 | 字段类型 | 是否是主键 | 说明 |
|---|---|---|---|
| id | INT | 是 | 商品主键id |
| category_id | VARCHAR(30) | 否 | 商品类别id |
| goods_name | VARCHAR(30) | 否 | 商品名称 |
| price | DECIMAL(10,2) | 否 | 商品价格 |
商品表goods通过商品类别id字段(category_id)与商品类别表goods_category进行关联。
举例3:
球员player表:球员编号、姓名、球队名称和球队主教练。现在,我们把属性之间的依赖关系画出来,如下图所示:
你能看到球员编号决定了球队名称,同时球队名称决定了球队主教练,非主属性球队主教练就会传递依赖于球员编号,因此不符合3NF的要求。
如果要达到3NF的要求,需要把数据表拆成下面这样:
| 表名 | 属性(字段) |
|---|---|
| 球员表 | 球员编号、姓名和球队名称 |
| 球队表 | 球队名称、球队主教练 |
举例4:
修改第二范式中的举例3。
此时的Orders关系包含orderid、orderdate、customerid和companyname属性,主键定义为orderid。customerid和companyname均依赖于主键orderid。例如,你需要通过orderid主键来查找代表订单中客户的customerid,同样,你需要通过 orderid 主键查找订单中客户的公司名称(companyname)。然而,customerid和companyname也是互相依靠的。为满足第三范式,可以改写如下:
符合3NF后的数据模型通俗地讲,2NF和3NF通常以这句话概括:“每个非键属性依赖于键,依赖于整个键,并且除了键别无他物”。(非键属性不互相依赖,非键属性只依赖于整个键)
2.7 小结
关于数据表的设计,有三个范式要遵循。
① 第一范式(1NF),确保每列保持原子性
数据库的每一列都是不可分割的原子数据项,不可再分的最小数据单元,而不能是集合、数组、记录等非原子数据项。
② 第二范式(2NF),确保每列都和主键完全依赖
尤其在复合主键的情况向下,非主键部分不应该依赖于部分主键。
③ 第三范式(3NF),确保每列都和主键直接相关,而不是间接相关
**范式的优点:**数据的标准化有助于消除数据库中的数据冗余,第三范式(3NF)通常被认为在性能、拓展性和数据完整性方面达到了最好的平衡。
**范式的缺点:**范式的使用,可能降低查询的效率。因为范式等级越高,设计出来的数据表就越多、越精细,数据的冗余度就越低,进行数据查询的时候就可能需要关联多张表,这不但代价昂贵,也可能使一些索引策略无效。
范式只是提出了设计的标准,实际上设计数据表时,未必一定要符合这些标准。开发中,我们会出现为了性能和读取效率违反范式化的原则,通过增加少量的冗余或重复的数据来提高数据库的读性能,减少关联查询,join表的次数,实现空间换取时间的目的。因此在实际的设计过程中要理论结合实际,灵活运用。
范式本身没有优劣之分,只有适用场景不同。没有完美的设计,只有合适的设计,我们在数据表设计中,还需要根据需求将范式和反范式混合使用。
3. 反范式化
3.1 概述
有的时候不能简单按照规范要求设计数据表,因为有的数据看似冗余,其实对业务来说十分重要。这个时候,我们就要遵循业务优先的原则,首先满足业务需求,再尽量减少冗余。
如果数据库中的数据量比较大,系统的独立访客量(Unique Visitor,UV)、页面访问量(Page View,PV)、访问频次比较高,则完全按照MySQL的三大范式设计数据表,读数据时会产生大量的关联查询,在一定程度上会影响数据库的读性能。如果我们想对查询效率进行优化,反范式优化也是一种优化思路。此时,可以通过在数据表中增加冗余字段来提高数据库的读性能。
规范化 vs 性能
为满足某种商业目标, 数据库性能比规范化数据库更重要
在数据规范化的同时, 要综合考虑数据库的性能
通过在给定的表中添加额外的字段,以大量减少需要从中搜索信息所需的时间
通过在给定的表中插入计算列,以方便查询
3.2 应用举例
举例1:
员工的信息存储在employees表中,部门信息存储在departments表中。通过employees表中的 department_id字段与departments表建立关联关系。如果要查询一个员工所在部门的名称:
1 | SELECT employee_id,department_name |
如果经常需要进行这个操作,连接查询就会浪费很多时间。可以在employees表中增加一个冗余字段 department_name,这样就不用每次都进行连接操作了。
举例2:
反范式化的goods商品信息表设计如下:
| 字段名称 | 字段类型 | 是否是主键 | 说明 |
|---|---|---|---|
| id | INT | 是 | 商品id(主键) |
| category_id | VARCHAR(30) | 否 | 商品类别id |
| category_name | VARCHAR(30) | 否 | 商品类别名称 |
| goods_name | VARCHAR(30) | 否 | 商品名称 |
| price | DECIMAL(10,2) | 否 | 商品价格 |
举例3:
我们有2个表,分别是商品流水表(trans)和 商品信息表(goodsinfo) 。商品流水表里有400万条流水记录,商品信息表里有2000条商品记录。
商品流水表:

商品信息表:

两个表是符合第三范式要求的。但是,在我们项目的实施过程中,对流水的查询频率很高,而且为了获取商品名称,基本都会用到与商品信息表的连接查询。
为了减少连接,我们可以直接把商品名字段加到流水表里面。这样一来,我们就可以直接从流水表中获取商品名称字段了。虽然增加了冗余字段,但是避免了关联查询,提升了查询的效率。
新的商品流水表如下所示:

举例4:
课程评论表 class_comment,对应的字段名称及含义如下:
| 字段 | comment_id | class_id | comment_text | comment_time | stu_id |
|---|---|---|---|---|---|
| 含义 | 课程评论ID | 课程ID | 评论内容 | 评论时间 | 学生ID |
学生表 student,对应的字段名称及含义如下:
| 字段 | stu_id | stu_name | create_time |
|---|---|---|---|
| 含义 | 学生ID | 学生昵称 | 注册时间 |
在实际应用中,我们在显示课程评论的时候,通常会显示这个学生的昵称,而不是学生ID,因此当我们想要查询某个课程的前1000条评论时,需要关联class_comment和student这两张表来进行查询。
实验数据:模拟两张百万量级的数据表
为了更好地进行SQL优化实验,我们需要给学生表和课程评论表随机模拟出百万量级的数据。我们可以通过存储过程来实现模拟数据。
创建数据库和表
1 | CREATE DATABASE dbtest_nf; |
创建存储过程
1 | # 创建向学生表中添加数据的存储过程 |
学生对课程的评价表,class_comment随机生成100万条评论。这里我们设置为给某一课程评论,比如class_id=10001。评论的内容为随机的20个字母。以下是创建随机的100万条课程评论的存储过程:
1 | # 创建向课程评论表中添加数据的存储过程 |
1 | CALL batch_insert_student(10000,1000000); |
1 | SELECT COUNT(*) FROM student; #1000000 |
反范式优化实验对比
如果我们想要查询课程 ID 为 10001 的第 10000-30000 条评论,需要写成下面这样:
1 | SELECT p.comment_text, p.comment_time, stu.stu_name |
运行结果(20000条数据行):运行时长为0.02秒。
如果我们想要提升查询的效率,可以允许适当的数据冗余,也就是在商品评论表中增加用户昵称字段
新建 class_comment2 数据表,在 class_comment 数据表的基础上增加 stu_name 字段。
1 | #课程评论表2 |
1 | # 创建向课程评论表中添加数据的存储过程 |
1 | CALL batch_insert_class_comments2(10000,1000000); |
这样一来,只需单表查询就可以得到数据集结果:
1 | SELECT comment_text, comment_time, stu_name |
反范式化之后,只需要扫描一次聚集索引即可,运行时间为0.002秒,查询时间是之前的1/10。可以看到,在表中数据量比较大的情况下,查询效率会得到显著的提升。
3.3 反范式的新问题
虽然反范式化可以通过空间来换时间的方式来提高查询效率,但是这也会带来一些问题。
- 存储
空间变大了,因为多了冗余字段。 - 一个表中字段做了修改,另一个表中冗余的字段也需要做同步修改,否则
数据不一致 - 若采用存储过程来支持数据的更新、删除等额外操作,如果更新频繁,会非常
消耗系统资源 - 在
数据量小的情况下,反范式不能体现性能的优势,可能还会让数据库的设计更加复杂
3.4 反范式的适用场景
当冗余信息有价值或者能大幅度提高查询效率的时候,我们才会采取反范式的设计。
① 增加冗余字段一定要符合两个条件:
一是这个冗余字段不需要经常进行修改;
二是这个冗余字段查询的时候不可或缺。
② 历史快照、历史数据的需要
在现实生活中,我们经常需要一些冗余信息,比如订单中的收货人信息,包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照,需要进行保存,但用户可以随时修改自己的信息,这时保存这些冗余信息是非常有必要的。
反范式优化也常用在数据仓库的设计中,因为数据仓库通常存储历史数据,对增删改的实时性要求不强,对历史数据的分析需求强。这时适当允许数据的冗余度,更方便进行数据分析。
数据仓库和数据库在使用上的区别:
数据库设计的目的在于
捕捉数据,而数据仓库设计的目的在于分析数据。数据库对数据的
增删改实时性要求强,需要存储在线的用户数据,而数据仓库存储的一般是历史数据。数据库设计需要
尽量避免冗余,但为了提高查询效率也允许一定的冗余度,而数据仓库在设计上更偏向采用反范式设计,
4. BCNF(巴斯范式)
人们在3NF的基础上进行了改进,提出了巴斯范式(BCNF),也叫巴斯-科德范式(Boyce-Codd Normal Form)。BCNF被认为没有新的设计规范加入,只是对第三范式中设计规范要求更强,使得数据库冗余度更小。所以,称为是修正的第三范式,或扩充的第三范式,BCNF不被称为第四范式。
若一个关系达到了第三范式,并且它只有一个候选键,或者它的每个候选键都是单属性,则该关系自然达到BC范式。
一般来说,一个数据库设符合3NF或者BCNF就可以了。
1)案例
我们分析如下表的范式情况:
| 仓库名 | 管理员 | 物品名 | 数量 |
|---|---|---|---|
| 北京仓 | 张三 | iphone XR | 10 |
| 北京仓 | 张三 | iphone 7 | 20 |
| 上海仓 | 李四 | iphone 7p | 30 |
| 上海仓 | 李四 | iphone 8 | 40 |
在这个表中,一个仓库只有一个管理员,同时一个管理员也只管理一个仓库。
我们先来梳理下这些属性之间的依赖关系。
仓库名决定了管理员,管理员也决定了仓库名,同时(仓库名,物品名)的属性集合可以决定数量这个属性。这样,我们就可以找到数据表的候选键。
候选键 :是(管理员,物品名)和(仓库名,物品名),然后我们从候选键中选择一个作为主键,比如(仓库名,物品名)。
主属性 :包含在任一候选键中的属性,也就是仓库名,管理员和物品名。
非主属性 :数量这个属性。
2)是否符合三范式
如何判断一张表的范式呢?我们需要根据范式的等级,从低到高来进行判断。
首先,数据表每个属性都是原子性的,符合 1NF 的要求;
其次,数据表中非主属性”数量“都与候选键全部依赖,(仓库名,物品名)决定数量,(管理员,物品名)决定数量。因此,数据表符合 2NF 的要求;
最后,数据表中的非主属性,不传递依赖于候选键。因此符合 3NF 的要求。
3)存在的问题
既然数据表已经符合了 3NF 的要求,是不是就不存在问题了呢?我们来看下面的情况:
-
增加一个仓库,但是还没有存放任何物品。根据数据表实体完整性的要求,主键不能有空值,因此会出现
插入异常 -
如果仓库更换了管理员,我们就可能会
修改数据表中的多条记录; -
如果仓库里的商品都卖空了,那么此时仓库名称和相应的管理员名称也会随之被删除。
你能看到,即便数据表符合 3NF 的要求,同样可能存在插入,更新和删除数据的异常情况。
4)问题解决
首先我们需要确认造成异常的原因:主属性(仓库名)对于候选键(管理员,物品名)是部分依赖的关系,这样就有可能导致异常情况。因此引入BCNF,它在3NF的基础上消除了主属性对候选键的部分依赖或者传递依赖关系。
- 如果在关系R中,U为主键,A属性是主键的一个属性,若存在A->Y,Y为主属性,则该关系不属于BCNF。
根据 BCNF 的要求,我们需要把仓库管理关系 warehouse_keeper 表拆分成下面这样:
仓库表:(仓库名,管理员)
库存表:(仓库名,物品名,数量)
这样就不存在主属性对于候选键的部分依赖或传递依赖,上面数据表的设计就符合 BCNF。
5)再举例
有一个学生导师表,其中包含字段:学生ID,专业,导师,专业GPA,这其中学生ID和专业是联合主键。
| StudentId | Major | Advisor | MajGPA |
|---|---|---|---|
| 1 | 人工智能 | Edward | 4.0 |
| 2 | 大数据 | William | 3.8 |
| 1 | 大数据 | William | 3.7 |
| 3 | 大数据 | Joseph | 4.0 |
这个表的设计满足三范式,但是这里存在另一个依赖关系,专业依赖于导师,也就是说每个导师只做一个专业方面的导师,只要知道了是哪个导师,我们自然就知道是哪个专业的了。
所以这个表的部分主键Major依赖于非主键属性Advisor,那么我们可以进行以下的调整,拆分成2个表:
学生导师表:
| StudentId | Advisor | MajGPA |
|---|---|---|
| 1 | Edward | 4.0 |
| 2 | William | 3.8 |
| 1 | William | 3.7 |
| 3 | Joseph | 4.0 |
导师表:
| Advisor | Major |
|---|---|
| Edward | 人工智能 |
| William | 大数据 |
| Joseph | 大数据 |
5. 第四范式
多值依赖的概念:
多值依赖即属性之间的一对多关系,记为K—>—>A。函数依赖事实上是单值依赖,所以不能表达属性值之间的一对多关系。平凡的多值依赖:全集U=K+A,一个K可以对应于多个A,即K—>—>A。此时整个表就是一组一对多关系。非平凡的多值依赖:全集U=K+A+B,一个K可以对应于多个A,也可以对应于多个B,A与B相互独立,即K—>—>A,K—>—>B。整个表有多组一对多关系,且有:"一"部分是相同的属性集合,“多”部分是相互独立的属性集合。
第四范式即在满足*巴斯-科德范式(BCNF)*的基础上,消除非平凡且非函数依赖的多值依赖(即把同一表的多对多关系删除)。
**举例1:**职工表(职工编号,职工孩子姓名,职工选修课程)。
在这个表中,同一个职工可能会有多个职工孩子姓名。同样,同一个职工也可能会有多个职工选修课程,即这里存在着多值事实,不符合第四范式。
如果要符合第四范式,只需要将上表分为两个表,使它们只有一个多值事实,例如: 职工表一 (职工编 号,职工孩子姓名), 职工表二(职工编号,职工选修课程),两个表都只有一个多值事实,所以符合第四范式。
举例2:
比如我们建立课程、教师、教材的模型。我们规定,每门课程有对应的一组教师,每门课程也有对应的一组教材,一门课程使用的教材和教师没有关系。我们建立的关系表如下:
课程ID,教师ID,教材ID;这三列作为联合主键。
为了表述方便,我们用Name代替ID,这样更容易看懂:
| Course | Teacher | Book |
|---|---|---|
| 英语 | Bill | 人教版英语 |
| 英语 | Bill | 美版英语 |
| 英语 | Jay | 美版英语 |
| 高数 | William | 人教版高数 |
| 高数 | Dave | 美版高数 |
这个表除了主键,就没有其他字段了,所以肯定满足BC范式,但是却存在多值依赖导致的异常。
假如我们下学期想采用一本新的英版高数教材,但是还没确定具体哪个老师来教,那么我们就无法在这个表中维护Course高数和Book英版高数教材的的关系。
解决办法是我们把这个多值依赖的表拆解成2个表,分别建立关系。这是我们拆分后的表:
| Course | Teacher |
|---|---|
| 英语 | Bill |
| 英语 | Jay |
| 高数 | William |
| 高数 | Dave |
以及
| Course | Book |
|---|---|
| 英语 | 人教版英语 |
| 英语 | 美版英语 |
| 高数 | 人教版高数 |
| 高数 | 美版高数 |
6. 第五范式、域键范式
除了第四范式外,我们还有更高级的第五范式(又称完美范式)和域键范式(DKNF)。
在满足第四范式(4NF)的基础上,消除不是由候选键所蕴含的连接依赖。如果关系模式R中的每一个连接依赖均由R的候选键所隐含,则称此关系模式符合第五范式。
函数依赖是多值依赖的一种特殊的情况,而多值依赖实际上是连接依赖的一种特殊情况。但连接依赖不像函数依赖和多值依赖可以由语义直接导出,而是在关系连接运算时才反映出来。存在连接依赖的关系模式仍可能遇到数据冗余及插入、修改、删除异常等问题。
第五范式处理的是无损连接问题,这个范式基本没有实际意义,因为无损连接很少出现,而且难以察觉。而域键范式试图定义一个终极范式,该范式考虑所有的依赖和约束类型,但是实用价值也是最小的,只存在理论研究中。
7. 实战案例
商超进货系统中的进货单表进行剖析:
进货单表:

这个表中的字段很多,表里的数据量也很惊人。大量重复导致表变得庞大,效率极低。如何改造?
在实际工作场景中,这种由于数据表结构设计不合理,而导致的数据重复的现象并不少见。往往是系统虽然能够运行,承载能力却很差,稍微有点流量,就会出现内存不足、CPU使用率飙升的情况,甚至会导致整个项目失败。
7.1 迭代1次:考虑1NF
第一范式要求:所有的字段都是基本数据类型,不可进行拆分。这里需要确认,所有的列中,每个字段只包含一种数据。
这张表里,我们把“property"这一字段,拆分成”specification (规格)" 和 “unit (单位)”,这两个字段如下:

7.2 迭代2次:考虑2NF
第二范式要求,在满足第一范式的基础上,还要满足数据表里的每一条数据记录,都是可唯一标识的。而且所有字段,都必须完全依赖主键,不能只依赖主键的一部分。
第1步,就是要确定这个表的主键。通过观察发现,字段“listnumber(单号)"+"barcode(条码)"可以唯一标识每一条记录,可以作为主键。
第2步,确定好了主键以后,判断哪些字段完全依赖主键,哪些字段只依赖于主键的一部分。把只依赖于主键一部分的字段拆出去,形成新的数据表。
首先,进货单明细表里面的"goodsname(名称)""specification(规格)""unit(单位)"这些信息是商品的属性,只依赖于"batcode(条码)",不完全依赖主键,可以拆分出去。我们把这3个字段加上它们所依赖的字段"barcode(条码)“,拆分形成新的数据表"商品信息表”。
此外,字段supplierid(供应商编号) suppliername(供应商名称) stock(仓库)只依赖于"listnumber(单号)",不完全依赖于主键,所以,我们可把这3个字段再加上它们依赖的字段"listnumber(单号)“拆出去,就形成了一个新的表"进货单头表”。
剩下的字段,会组成新的表,我们叫它"进货单明细表"。
这样一来,原来的数据表就拆分成了3个表。
进货单表头
| listnumber | supplierid | suppliername | stock |
|---|---|---|---|
| 100001 | 1 | 食品厂 | 仓库 |
| 100001 | 1 | 食品厂 | 仓库 |
| 100002 | 2 | 服装厂 | 卖场 |
| 100003 | 1 | 食品厂 | 卖场 |
进货单明细表:
| listnumber | barcode | quantity | importprice | importvalue |
|---|---|---|---|---|
| 100001 | 0001 | 200 | 25 | 5000 |
| 100001 | 0002 | 400 | 9.9 | 3960 |
| 100002 | 0003 | 5 | 2000 | 10000 |
| 100003 | 0002 | 200 | 15 | 3000 |
商品信息表:
| barcode | goodsname | specification | unit |
|---|---|---|---|
| 0001 | 方便面 | 6包 | 袋 |
| 0002 | 棒棒糖 | 10支 | 盒 |
| 0003 | 西服 | NULL | 套 |
现在,我们再来分析一下拆分后的3个表,保证这3个表都满足第二范式的要求。
第3步,在“商品信息表”中,字段“barcode"是有可能存在重复的,比如,用户门店可能有散装称重商品和自产商品,会存在条码共用的情况。所以,所有的字段都不能唯一标识表里的记录。这个时候,我们必须给这个表加上一个主键,比如说是自增字段"itemnumber"。
现在就可以把进货单明细表里面的字段"barcode"都替换成字段"itemnumber",这就得到了新的如下表。
进货单明细表:
| listnumber | itemnumber | quantity | importprice | importvalue |
|---|---|---|---|---|
| 100001 | 1 | 200 | 25 | 5000 |
| 100001 | 2 | 400 | 9.9 | 3960 |
| 100002 | 3 | 5 | 2000 | 10000 |
| 100003 | 2 | 200 | 15 | 3000 |
商品信息表:
| itemnumber | barcode | goodsname | specification | unit |
|---|---|---|---|---|
| 1 | 0001 | 方方便面 | 6包 | 袋 |
| 2 | 0002 | 棒棒糖 | 10支 | 盒 |
| 3 | 0003 | 西服 | NULL | 套 |
拆分后的3个数据表就全部满足了第二范式的要求
7.3 迭代3次:考虑3NF
我们的进货单头表,还有数据冗余的可能。因为"suppliername"依赖"supplierid",那么就可以按照第三范式的原则进行拆分了。我们就进一步拆分进货单头表,把它拆解成供货商表和进货单头表。
供货商表:
| supplierid | suppliername |
|---|---|
| 1 | 食品厂 |
| 2 | 服装厂 |
进货单头表:
| listnumber | supplierid | stock |
|---|---|---|
| 100001 | 1 | 仓库 |
| 100002 | 2 | 卖场 |
| 100003 | 1 | 卖场 |
这2个表都满足第三范式的要求了。
7.4 反范式化:业务优先的原则
在进货单明细表中, quantity * importprice = importvalue、“importprice"、 “quantity"和"importvalue可以通过任意两个计算出第三个来,这就存在冗余字段。如果严格按照第三范式的要求,应该进行进一步优化。优化的办法是删除其中一个字段,只保留另外2个,这样就没有冗余数据了。
可是,真的可以这样做吗? 要回答这个问题就要先了解下实际工作中的业务优先原则。
所谓的业务优先原则,就是指一切以业务需求为主,技术服务于业务。完全按照理论的设计不一定就是最优,还要根据实际情况来决定。这里就来分析一下不同选择的利与弊。
对于 quantity * importprice =importvalue,看起来"importvalue"似乎是冗余字段,但并不会导 致数据不一致,可是,如果把这个字段取消,是会影响业务的。
因为有的时候,供货商会经常进行一些促销活动,按金额促销,那他们拿来的进货单只有金额,没有价格。而“importprice"反而是通过“importvalue / quantity"计算出来的,经过四舍五入,会产生较大的误 差。这样日积月累,最终会导致查询结果出现较大偏差,影响系统的可靠性。
举例:进货金额(importvalue)是25.5元,数量(quantity)是 34,那么进货价格(importprice)就等于 25.5/34=0.74元,但是如果用这个计算出来的进货价格(importprice)来计算进货金额,那么,进货金额 (importvalue)就等于0.74x34=25.16元,其中相差了25.5-25.16=0.34元
所以,本着业务优先的原则,在不影响系统可靠性的前提下,可适当增加数据冗余,保留 “importvalue"importprice”和“quantity"。
因此,最后我们可以把进货单表拆分成下面的4个表:
供货商表:
| supplierid | suppliername |
|---|---|
| 1 | 食品厂 |
| 2 | 服装厂 |
进货单头表:
| listnumber | supplierid | stock |
|---|---|---|
| 100001 | 1 | 仓库 |
| 100002 | 2 | 卖场 |
| 100003 | 1 | 卖场 |
进货单明细表:
| listnumber | itemnumber | quantity | importprice | importvalue |
|---|---|---|---|---|
| 100001 | 1 | 200 | 25 | 5000 |
| 100001 | 2 | 400 | 9.9 | 3960 |
| 100002 | 3 | 5 | 2000 | 10000 |
| 100003 | 2 | 200 | 15 | 3000 |
商品信息表:
| itemnumber | barcode | goodsname | specification | unit |
|---|---|---|---|---|
| 1 | 0001 | 方方便面 | 6包 | 袋 |
| 2 | 0002 | 棒棒糖 | 10支 | 盒 |
| 3 | 0003 | 西服 | NULL | 套 |
这样一来,我们就避免了冗余,而且还能够满足业务的需求,这样的数据表设计,才是合格的设计。
8. ER模型
数据库设计是牵一发而动全身的。那有没有什么办法提前看到数据库的全貌呢?比如需要哪些数据表,数据表中 应该有哪些字段,数据表与数据表之间有什么关系,通过什么字段进行连接,等等。这样我们才能进行整体的梳理和设计。
其实,ER模型就是一个这样的工具。ER模型也叫作实体关系模型,是用来描述现实生活中客观存在的事物、事物的属性,以及事物之间关系的一种数据模型。在开发基于数据库的信息系统的设计阶段,通常使用 ER 模型来 描述信息需求和信息特性,帮助我们理清业务逻辑,从而设计出优秀的数据库。
8.1 ER模型包括那些要素?
ER 模型中有三个要素,分别是实体、属性和关系。
实体,可以看做是数据对象,往往对应于现实生活中的真实存在的个体。在ER模型中,用矩形来表示。实体分为两类,分别是强实体和弱实体。强实体是指不依赖于其他实体的实体;弱实体是指对另一个实体有很强的依赖关系的实体。
属性,则是指实体的特性。比如超市的地址、联系电话、员工数等。在ER模型中用椭圆形来表示。
关系,则是指实体之间的联系。比如超市把商品卖给顾客,就是一种超市与顾客之间的联系。在ER模型中用菱形来表示。
注意:实体和属性不容易区分。这里提供一个原则:我们要从系统整体的角度出发去看,可以独立存在的是实体,不可再分的是属性。也就是说,属性不能包含其他属性。
8.2 关系的类型
在ER模型的3个要素中,关系又可以分为3种类型,分别是 一对一、一对多、多对多。
一对一:指实体之间的关系是一一对应的
一对多:指一边的实体通过关系,可以对应多个另外一边的实体。相反,另外一边的实体通过这个关系,则只能对应唯一的一边的实体
多对多:指关系两边的实体都可以通过关系对应多个对方的实体
8.3 建模分析
ER 模型看起来比较麻烦,但是对我们把控项目整体非常重要。如果你只是开发一个小应用,或许简单设计几个表够用了,一旦要设计有一定规模的应用,在项目的初始阶段,建立完整的ER模型就非常关键 了。开发应用项目的实质,其实就是建模。
我们设计的案例是电商业务 ,由于电商业务太过庞大且复杂,所以我们做了业务简化,比如针对 SKU(StockKeepingUnit,库存量单位)和SPU(Standard Product Unit,标准化产品单元)的含义上,我 们直接使用了SKU,并没有提及SPU的概念。本次电商业务设计总共有8个实体,如下所示。
- 地址实体
- 用户实体
- 购物车实体
- 评论实体
- 商品实体
- 商品分类实体
- 订单实体
- 订单详情实体
其中,用户和商品分类是强实体,因为它们不需要依赖其他任何实体。而其他属于弱实体,因为它们虽然都可以独立存在,但是它们都依赖用户这个实体,因此都是弱实体。知道了这些要素,我们就可以给电商业务创建ER模型了,如图:
在这个图中,地址和用户之间的添加关系,是一对多的关系,而商品和商品详情示一对1的关系,商品和订单是多对多的关系。 这个ER模型,包括了8个实体之间的8种关系。
(1)用户可以在电商平台添加多个地址;
(2)用户只能拥有一个购物车;
(3)用户可以生成多个订单;
(4)用户可以发表多条评论;
(5)一件商品可以有多条评论;
(6)每一个商品分类包含多种商品;
(7)一个订单可以包含多个商品,一个商品可以在多个订单里。
(8)订单中又包含多个订单详情,因为一个订单中可能包含不同种类的商品
8.4 ER模型的细化
有了这个ER模型,我们就可以从整体上理解电商的业务了。刚刚的ER模型展示了电商业务的框架,但是只包括了订单,地址,用户,购物车,评论,商品,商品分类和订单详情这八个实体,以及它们之间的关系,还不能对应到具体的表,以及表与表之间的关联。我们需要把属性加上,用椭圆来表示, 这样我们得到的ER模型就更加完整了。
因此,我们需要进一步去设计一下这个ER模型的各个局部,也就是细化下电商的具体业务流程,然后把它们综合到一起,形成一个完整的ER模型。这样可以帮助我们理清数据库的设计思路。
接下来,我们再分析一下各个实体都有哪些属性,如下所示。
(1) 地址实体 包括用户编号、省、市、地区、收件人、联系电话、是否是默认地址。
(2) 用户实体 包括用户编号、用户名称、昵称、用户密码、手机号、邮箱、头像、用户级别。
(3) 购物车实体 包括购物车编号、用户编号、商品编号、商品数量、图片文件url。
(4) 订单实体 包括订单编号、收货人、收件人电话、总金额、用户编号、付款方式、送货地址、下单 时间。
(5) 订单详情实体 包括订单详情编号、订单编号、商品名称、商品编号、商品数量。
(6) 商品实体 包括商品编号、价格、商品名称、分类编号、是否销售,规格、颜色。
(7) 评论实体 包括评论id、评论内容、评论时间、用户编号、商品编号
(8) 商品分类实体 包括类别编号、类别名称、父类别编号
这样细分之后,我们就可以重新设计电商业务了,ER模型如图:

8.5 ER模型图转换成数据表
通过绘制ER模型,我们已经理清了业务逻辑,现在,我们就要进行非常重要的一步了:把绘制好的ER模型,转换成具体的数据表,下面介绍下转换的原则:
(1)一个实体通常转换成一个数据表;
(2)一个多对多的关系,通常也转换成一个数据表;
(3)一个1 对 1,或者1 对多的关系,往往通过表的外键来表达,而不是设计一个新的数据表;
(4)属性转换成表的字段。
下面结合前面的ER模型,具体讲解一下怎么运用这些转换的原则,把ER模型转换成具体的数据表,从 而把抽象出来的数据模型,落实到具体的数据库设计当中。
1)一个实体转换成一个数据表
先来看一下强实体转换成数据表:
用户实体转换成用户表(user_info)的代码如下所示。
1 | CREATE TABLE `user_info` ( |
商品分类实体转换成商品分类表 (base_category),由于商品分类可以有一级分类和二级分类,比如一级分类有家居、手机等等分类,二级分类可以根据手机的一级分类分为手机配件,运营商等,这里我们把商品分类实体规划为两张表,分别是一级分类表和二级分类表,之所以这么规划是因为一级分类和二级分类都是有限的,存储为两张表业务结构更加清晰。
1 | # 一级分类表 |
那么如果规划为—张表呢,表结构如下所示。
1 | CREATE TABLE `base_category` ( |
如果这样分类的话,那么查询一级分类时候,就需要判断父分类编号是否为空,但是如果插入二级分类的时候也是空,就容易造成业务数据混乱。而且查询二级分类的时候IS NOT NULL条件是无法使用到索引的。同时,这样的设计也不符合第二范式(因为父分类编号并不依赖分类编号ID,因为父分类编号可以有很多数据为NULL),所以需要进行表的拆分。因此无论是业务需求还是数据库表的规范来看都应该拆分为两张表。
下面我们再把弱实体转换成数据表:
地址实体转换成地址表(user_address) ,如下所示。
1 | CREATE TABLE `user_address` ( |
订单实体转换成订单表(order_info),如下所示,实际业务中订单的信息会非常多,我们这里做了简化。
1 | CREATE TABLE `order_info` ( |
订单详情实体转换成订单详情表(order_detail),如下所示。(用于体现多对多关系的,见下节)
1 | # 订单详情表 |
购物车实体转换成购物车表(cart_info),如下所示。
1 | CREATE TABLE `cart_info` ( |
评论实体转换成评论表(members),如下所示。
1 | CREATE TABLE `sku_comments` ( |
商品实体转换成商品表(members),如下所示。
1 | CREATE TABLE `sku_info` ( |
2)一个多对多的关系转换成一个数据表
这个ER模型中的多对多的关系有1个,即商品和订单之间的关系,同品类的商品可以出现在不同的订单中,不同的订单也可以包含同一类型的商品,所以它们之间的关系是多对多。针对这种情况需要设计一个独立的表来表示,这种表一般称为中间表。
我们可以设计一个独立的订单详情表,来代表商品和订单之间的包含关系。这个表关联到2个实体,分别 是订单、商品。所以,表中必须要包括这2个实体转换成的表的主键。除此之外,我们还要包括该关系自有的属性:商品数量,商品下单价格以及商品名称。
1 | # 订单详情表 |
3)通过外键来表达1对多的关系
在上面的表的设计中,我们可以用外键来表达1对多的关系。比如在商品评论表sku_comments中我们分别把user_id、sku_id定义成外键,以使用下面的语句设置外键。
1 | CONSTRAINT fk_comment_user FOREIGN KEY (user_id) REFERENCES user_info (id), |
外键约束主要是在数据库层面上保证数据的一致性,但是因为插入和更新数据需要检查外键,理论上性能会有所下降,对性能是负面的影响。
实际的项目,不建议使用外键,一方面是降低开发的复杂度(有外键的话主从表类的操作必须先操作主表),另外是有外键在处理数据的时候非常麻烦。在电商平台,由于并发业务量比较大,所以一般不设置外键,以免影响数据库性能。
在应用层面做数据的一致性检查,本来就是一个正常的功能需求。如学生选课的场景,课程肯定不是输入的,而是通过下拉或查找等方式从系统中进行选取,就能够保证是合法的课程ID,因此就不需要靠数据库的外键来检查了。
4)把属性转换成表的字段
在刚刚的设计中,我们也完成了把属性都转换成了表的字段,比如把商品属性转换成了商品信息表中的字段。
5)总结
到这里,我们通过创建电商项目业务流程的ER模型,再把ER模型转换成具体的数据表的过程,完成利用ER模型设计电商项目数据库的工作。
其实,任何一个基于数据库的应用项目,都可以通过这种先建立ER模型,再转换成数据表的方式,完成数据库的设计工作。创建ER模型不是目的,目的是把业务逻辑梳理清楚,设计出优秀的数据库。我建议 你不是为了建模而建模,要利用创建 ER模型的过程来整理思路,这样创建ER模型才有意义。

9. 数据表的设计原则
数据表设计的一般原则:“三少一多”
① 数据表的个数越少越好
RDBMS的核心在于对实体和联系的定义,也就是E-R图(Entity Relationship Diagram),数据表越少,证明实体和联系设计得越简洁,既方便理解又方便操作。
② 数据表中的字段个数越少越好
字段个数越多,数据冗余的可能性越大。设置字段个数少的前提是各个字段相互独立,而不是某个字段的取值可以由其他字段计算出来。当然字段个数少是相对的,通常会在数据冗余和检索效率进行平衡
③ 数据表中联合主键的字段个数越少越好
设置主键是为了确定唯一性,当一个字段无法确定唯一性的时候,就需要采用联合主键的方式(也就是用多个字段来定义一个主健)。联合主键中的字段越多,占用的索列空间越大,不仅会加大理解难度,还会增加运行时间和索引空间,因此联合主键的字段个数越少越好。
④ 使用主键和外键越多越好
数据库的设计实际上就是定义各种表,以及各种字段之间的关系。这些关系越多,证明这些实体之间的冗余度越低,利用度越高。这样做的好处在于不仅保证了数据表之间的独立性,还能提升相互之间的关联使用率。这里的外键指业务上实现外键,也就是逻辑外键。不一定使用外键约束实现。
“三少一多"原则的核心就是简单可复用。简单指的是用更少的表、更少的字段、更少的联合主键字段来完成数据表的设计。可复用则是通过主键、外键的使用来增强数据表之间的复用率。因为一个主键可以理解是一张表的代表。键设计得越多,证明它们之间的利用率越高。
注意:这个原则并不是绝对的,有时候我们需要牺牲数据的冗余度来换取数据处理的效率。
10. 数据库对象编写建议
10.1 关于库
【强制】库的名称必须控制在32个字符以内,只能使用英文字母、数字和下划线,建议以英文字母开头。
【强制】库名中的英文一律小写,不同单词采用下划线分割。须见名知意。
【强制】库的名称格式:业务系统名称_子系统名。
【强制】库名禁止使用关键字(如type,order等)。
【强制】创建数据库时必须显式指定字符集,并且字符集只能是utf8或者utf8mb4。创建数据库SQL举例:CREATE DATABASE crm_fund DEFAULT CHARACTER SET 'utf8';
【建议】对于程序连接数据库账号,遵循权限最小原则。使用数据库账号只能在一个DB下使用,不准跨库。程序使用的账号原则上不准有drop权限。
【建议】临时库以tmp_为前缀,并以日期为后缀;备份库以bak_为前缀,并以日期为后缀。
10.2 关于表、列
【强制】表和列的名称必须控制在32个字符以内,表名只能使用英文字母、数字和下划线,建议以英文字母开头。
【强制】表名、列名一律小写,不同单词采用下划线分割。须见名知意。
【强制】表名要求有模块名强相关,同一模块的表名尽量使用统一前缀。比如:crm_fund_item
【强制】创建表时必须显式指定字符集为utf8或utf8mb4。
【强制】表名、列名禁止使用关键字(如type,order等)。
【强制】创建表时必须显式指定表存储引擎类型。如无特殊需求,一律为InnoDB。
【强制】建表必须有comment。
【强制】字段命名应尽可能使用表达实际含义的英文单词或缩写。如:公司ID,不要使用 corporation_id, 而用corp_id 即可。
【强制】布尔值类型的字段命名为is_描述。如member表上表示是否为enabled的会员的字段命名为 is_enabled。
【强制】禁止在数据库中存储图片、文件等大的二进制数据。
通常文件很大,短时间内造成数据量快速增长,数据库进行数据库读取时,通常会进行大量的随机IO操作,文件很大时,IO操作很耗时。通常存储于文件服务器,数据库只存储文件地址信息。
【建议】建表时关于主键:表必须有主键
(1)强制要求主键为id,类型为int或bigint,且为auto_increment建议使用unsigned无符号型。
(2)标识表里每一行主体的字段不要设为主键,建议设为其他字段如user_id,order_id等,并建立unique key索引。因为如果设为主键且主键值为随机插入,则会导致innodb内部页分裂和大量随机I/O,性能下降。
【建议】核心表(如用户表)必须有行数据的创建时间字段(create_time)和最后更新时间字段(update_time),便于查问题。
【建议】表中所有字段尽量都是NOT NULL属性,业务可以根据需要定义DEFAULT值。 因为使用NULL值会存在每一行都会占用额外存储空间、数据迁移容易出错、聚合函数计算结果偏差等问题。
【建议】所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)。
【建议】中间表(或临时表)用于保留中间结果集,名称以tmp_开头。备份表用于备份或抓取源表快照,名称以bak_开头。中间表和备份表定期清理。
【示范】一个较为规范的建表语句:
1 | CREATE TABLE user_info ( |
【建议】创建表时,可以使用可视化工具。这样可以确保表、字段相关的约定都能设置上。实际上,我们通常很少自己写 DDL 语句,可以使用一些可视化工具来创建和操作数据库和数据表。可视化工具除了方便,还能直接帮我们将数据库的结构定义转化成 SQL 语言,方便数据库和数据表结构的导出和导入。
10.3 关于索引
【强制】InnoDB表必须主键为id int/bigint auto_increment,且主键值禁止被更新。
【强制】InnoDB和MyISAM存储引擎表,索引类型必须为BTREE。
【建议】主键的名称以pk_开头,唯一键以uni_或uk_开头,普通索引以idx_开头,一律使用小写格式,以字段的名称或缩写作为后缀。
【建议】多单词组成的columnname,取前几个单词首字母,加末单词组成column_name。如: sample 表 member_id 上的索引:idx_sample_mid。
【建议】单个表上的索引个数不能超过6个。
【建议】在建立索引时,多考虑建立联合索引,并把区分度最高的字段放在最前面。
【建议】在多表JOIN的SQL里,保证被驱动表的连接列上有索引,这样JOIN执行效率最高。
【建议】建表或加索引时,保证表里互相不存在冗余索引。 比如:如果表里已经存在key(a,b), 则key(a)为冗余索引,需要删除。
10.4 SQL编写
【强制】程序端SELECT语句必须指定具体字段名称,禁止写成 *。
【建议】程序端insert语句指定具体字段名称,不要写成INSERT INTO t1 VALUES(…)。
【建议】除静态表或小表(100行以内),DML语句必须有WHERE条件,且使用索引查找。
【建议】INSERT INTO…VALUES(XX),(XX),(XX)… 这里XX的值不要超过5000个。值过多虽然上线很快,但会引起主从同步延迟。
【建议】SELECT语句不要使用UNION,推荐使用UNION ALL,并且UNION子句个数限制在5个以内。
【建议】线上环境,多表 JOIN 不要超过5个表。
【建议】减少使用ORDER BY,和业务沟通能不排序就不排序,或将排序放到程序端去做。ORDER BY、GROUP BY、DISTINCT 这些语句较为耗费CPU,数据库的CPU资源是极其宝贵的。
【建议】包含了ORDER BY、GROUP BY、DISTINCT 这些查询的语句,WHERE 条件过滤出来的结果集请保持在1000行以内,否则SQL会很慢。
【建议】对单表的多次alter操作必须合并为一次。对于超过100W行的大表进行alter table,必须经过DBA审核,并在业务低峰期执行,多个alter需整合在一起。 因为alter table会产生表锁,期间阻塞对于该表的所有写入,对于业务可能会产生极大影响。
【建议】批量操作数据时,需要控制事务处理间隔时间,进行必要的sleep。
【建议】事务里包含SQL不超过5个。因为过长的事务会导致锁数据较久,MySQL内部缓存、连接消耗过多等问题。
【建议】事务里更新语句尽量基于主键或UNIQUE KEY,如UPDATE… WHERE id=XX;否则会产生间隙锁,内部扩大锁定范围,导致系统性能下降,产生死锁。
第12章 数据库其它调优策略
1. 数据库调优的措施
1.1 调优的目标
- 尽可能
节省系统资源,以便系统可以提供更大负荷的服务。(吞吐量更大) - 合理的结构设计和参数调整,以提高用户操作
响应的速度。(响应速度更快) - 减少系统的瓶颈,提高MySQL数据库整体的性能。
1.2 如何定位调优问题
随着用户量的不断增加,以及应用程序复杂度的提升,我们很难用“更快”去定义数据库调优的目标,因为用户在不同时间段访问服务器遇到的瓶颈不同,比如双十一促销的时候会带来大规模的并发访问;还有用户在进行不同业务操作的时候,数据库的事务处理和SQL查询都会有所不同。因此我们还需要更加精细的定位,去确定调优的目标。
如何确定呢?一般情况下,有如下几种方式:
- 用户的反馈(主要)
用户是我们的服务对象,因此他们的反馈是最直接的。虽然他们不会直接提出技术建议,但是有些问题往往是用户第一时间发现的。我们要重视用户的反馈,找到和数据相关的问题。
- 日志分析(主要)
我们可以通过查看数据库日志和操作系统日志等方式找出异常情况,通过它们来定位遇到的问题。
- 服务器资源使用监控
通过监控服务器的 CPU、内存、I/O 等使用情况,可以实时了解服务器的性能使用,与历史情况进行对比。
- 数据库内部状况监控
在数据库的监控中,活动会话(Active Session)监控是一个重要的指标。通过它,可以清楚地了解数据库当前是否处于非常繁忙的状态,是否存在SQL堆积等。
- 其它
除了活动会话监控以外,我们也可以从事务、锁等的监控,这些都可以帮助我们对数据库的运行状况有更全面的认识。
1.3 调优的维度和步骤
我们需要调优的对象是整个数据库管理系统,它不仅包括SQL查询,还包括数据库的部署配置、架构等。从这个角度来说,我们思考的维度就不仅仅局限在SQL优化上了。通过如下的步骤我们进行梳理:
第1步:选择适合的DBMS
如果对事务性处理以及安全性要求高的话,可以选择商业的数据库产品。这些数据库在事务处理和查询性能上都比较强,比如采用SQL Server、Oracle,那么单表存储上亿条数据是没有问题的。如果数据库设计得好,即便不采用分库分表的方式,查询效率也不差。
除此以外,你也可以采用开源的MySQL进行存储,它有很多存储引擎可以选择,如果进行事务处理的话可以选择 InnoDB,非事务处理可以选择MyISAM。
NoSQL阵营包括键值型数据库、文档型数据库、搜索引擎、列式存储和图形数据库。这些数据库的优缺点和使用场景各有不同,比如列式存储数据库可以大幅度降低系统的I/O,适合于分布式文件系统,但如果数据需要频繁地增删改,那列式存储就不太适用了。
**DBMS的选择关系到了后面的整个设计过程,所以第一步就是要选择适合的DBMS。**如果已经确定好了DBMS,那么这步可以跳过。
第2步:优化表设计
选择了DBMS之后,我们就需要进行表设计了。而数据库的设计方式也直接影响了后续的SQL查询语句。DBMS中,每个对象都可以定义为一张表,表与表之间的关系代表了对象之间的关系。如果用的是MySQL,我们还可以 根据不同类的使用需求,选择不同的存储引擎。除此以外,还有一些优化的原则可以参考:
- 表结构要尽量
遵循三范式的原则。这样可以让数据结构更加清晰规范,减少冗余字段,同时也减少了在更新、插入和删除数据时的异常情况的发生。 - 如果
查询应用比较多,尤其是需要进行多表关联的时候,可以采用反范式进行优化。反范式采用空间换时间的方式,通过增加冗余字段提高查询的效率。 表字段的数据类型选择,关系到了查询效率的高低以及存储空间的大小。一般来说,如果字段可以采用数值 类型就不要用字符类型;字段长度要尽可能设计得短些。针对字符类型来说,当确定字段长度固定时,就可以采用CHAR类型;当长度不固定时,通常采用VARCHAR类型。
数据库的结构设计很基础,也很关键。好的表结构可以在业务发展和用户量增加的情况下依然发挥作用,不好的表结构设计会让数据表变得非常臃肿,查询效率会急剧下降。
第3步:优化逻辑查询
当我们建立好数据表之后,就可以对数据表进行增删改查的操作了。这时我们首先需要考虑的是逻辑查询优化。
SQL 查询优化,可以分为逻辑查询优化和物理查询优化。逻辑查询优化就是通过改变SQL语句的内容让SQL执行 效率更高效,采用的方式是对SQL语句进行等价变换,对查询进行重写。
SQL 的查询重写包括了子查询优化、等价谓词重写、视图重写、条件简化、连接消除和嵌套连接消除等。
比如我们在讲解EXISTS子查询和IN子查询的时候,会根据小表驱动大表的原则选择适合的子查询。在WHERE子句中会尽量避免对子字段进行函数运算,它们会让字段的索引失效。
举例:查询评论内容开头为 abc 的内容都有那些,如果在WHERE子句中使用了函数,语句就会写成下面这样:
1 | SELECT comment_id, comment_text, comment_time FROM product_comment |
采用查询重写的方式进行等价替换
1 | SELECT comment_id, comment_text, comment_time FROM product_comment |
第4步:优化物理查询
物理查询优化是在确定了逻辑查询优化之后,采用物理优化技术(比如索引等),通过计算代价模型对各种可能的访问路径进行估算,从而找到执行方式中代价最小的作为执行计划。在这个部分中,我们需要掌握的重点是对索引的创建和使用。
但索引不是万能的,我们需要根据实际情况来创建索引。那么有哪些情况需要考虑呢?我们在前面几章中已经进行了细致的剖析。
SQL查询时需要对不同的数据表进行查询,因此在物理查询优化阶段也需要确定这些查询所采用的路径,具体的 情况包括:
-
单表扫描:对于单表扫描来说,我们可以全表扫描所有的数据,也可以局部扫描。 -
两张表的连接:常用的连接方式包括了嵌套循环连接、HASH连接和合并连接。 -
多张表的连接:多张数据表进行连接的时候,顺序很重要,因为不同的连接路径查询的效率不同。搜索空间 也会不同。我们在进行多表连接的时候,搜索空间可能会达到很高的数量级,巨大的搜索空间显然会占用更多的资源,因此我们需要通过调整连接顺序,将搜索空间调整在一个可接受的范围内。
第5步:使用 Redis 或 Memcached 作为缓存
除了可以对SQL本身进行优化以外,我们还可以请外援提升查询的效率。
因为数据都是存放到数据库中,我们需要从数据库层中取出数据放到内存中进行业务逻辑的操作,当用户量增大的时候,如果频繁地进行数据查询,会消耗数据库的很多资源。如果我们将常用的数据直接放到内存中,就会大幅提升查询的效率。
键值存储数据库可以帮我们解决这个问题。
常用的键值存储数据库有 Redis 和 Memcached,它们都可以将数据存放到内存中。
从可靠性来说,Redis支持持久化,可以让我们的数据保存在硬盘上,不过这样一来性能消耗也会比较大。而 Memcached 仅仅是内存存储,不支持持久化。
从支持的数据类型来说,Redis 比 Memcached 要多,它不仅支持 key-value 类型的数据,还支持 List、Set、Hash 等数据结构。当我们有持久化需求或者是更高效的数据处理需求的时候,就可以使用 Redis。如果是简单的 key-value 存储,则可以使用 Memcached。
**通常我们对大并发查询响应要求高的场景(响应时间短、吞吐量大),可以考虑内存数据库,毕竟术业有专攻。**传统的RDBMS都是将数据存储在硬盘上,而内存数据库则存在内存中,查询起来要快得多。不过使用不同的工具,也增加了开发人员的使用成本。
第6步:库级优化
库级优化是站在数据库的维度上进行的优化策略,比如控制一个库中的数据表数量。另外,单一的数据库总会遇到各种限制,取短补长,利用“外援”的方式。通过主从架构优化我们的读写策略,通过对数据库进行垂直或者水平切分,突破单一数据库或数据表的访问限制,提升查询的性能。
1、读写分离
如果读和写的业务量都很大,并且它们都在同一个数据库服务器中进行操作,那么数据库的性能就会出现瓶颈, 这时为了提升系统的性能,优化用户体验,我们可以采用读写分离的方式降低主数据库的负载,比如用主数据库(master)完成写操作,用从数据库(slave)完成读操作。


2、数据分片
对数据库分库分表。当数据量级达到了千万级以上时,有时候我们需要把一个数据库切成多份,放到不同的数据库服务器上,减少对单一数据库服务器的访问压力。如果你使用的是 MySQL,就可以使用 MySQL 自带的分区表功能,当然你也可以考虑自己做垂直拆分(分库)、水平拆分(分表)、垂直+水平拆分(分库分表)。

需要注意的是,分拆在提升数据库性能的同时,也会增加维护和使用成本。
2. 优化MySQL服务器
优化MySQL服务器主要从两个方面来优化,一方面是对硬件进行优化;另一方面是对MySQL服务的参数进行优化。这部分的内容需要较全面的知识,一般只有专业的数据库管理员才能进行这一类的优化。对于可以定制参数 的操作系统,也可以针对MySQL进行操作系统优化。
2.1 优化服务器硬件
**服务器的硬件性能直接决定着MySQL数据库的性能。**硬件的性能瓶颈直接决定MySQL数据库的运行速度和效率。针对性能瓶颈提高硬件配置,可以提高MySQL数据库查询、更新的速度。
(1)配置较大的内存
足够大的显存是提高MySQL数据库性能的方法之一。内存的速度比磁盘I/O快得多,可以通过增加系统的缓冲区容量使数据在内存中停留的时间更长,以减少磁盘I/O。
(2)配置高速磁盘系统
以减少读盘的等待时间,提高响应速度。磁盘的I/O能力,也就是它的寻道能力,目前的SCSI高速旋转的是7200转/分钟,这样的速度,一旦访问的用户量上去,磁盘的压力就会过大,如果是每天的网站pv (page view) 在150w,这样的一般的配置就无法满足这样的需求了。现在SSD盛行,在SSD上随机访问和顺序访问性能差不多,使用SSD可以减少随机IO带来的性能损耗。
(3)合理分布磁盘I/O
把磁盘I/O分散在多个设备上,以减少资源竞争,提高并行操作能力。
(4)配置多处理器
MySQL是多线程的数据库,多处理器可同时执行多个线程。
2.2 优化MySQL的参数
通过优化MySQL的参数可以提高资源利用率,从而达到提高MySQL服务器性能的目的
MySQL服务的配置参数都在my.cnf或者my.ini文件的[mysqld]组中,配置完参数以后,需要重新启动 MySQL服务才会生效。
下面对几个对性能影响比较大的参数进行详细介绍。
-
innodb_buffer_pool_size:这个参数是Mysql数据库最重要的参数之一,表示InnoDB类型的表和索引的最大缓存。它不仅仅缓存索引数据,还会缓存表的数据。这个值越大,查询的速度就会越快。但是这个值太大会影响操作系统的性能。 -
key_buffer_size:表示索引缓冲区的大小。索引缓冲区是所有的线程共享。增加索引缓冲区可以得到更好处理的索引(对所有读和多重写)。当然,这个值不是越大越好,它的大小取决于内存的大小。如果这个值太大,就会导致操作系统频繁换页,也会降低系统性能。对于内存在4GB左右的服务器该参数可设置为256M或384M。 -
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关键字,查询才会使用查询缓存区。使用查询缓存区可以提高查询的速度,这种方式只适用于修改操作少且经常执行相同的查询操作的情况。
- 当query_cache_type=1时,所有的查询都将使用查询缓存区,除非在查询语句中指定
-
sort_buffer_size:表示每个需要进行排序的线程分配的缓冲区的大小。增加这个参数的值可以提高ORDER BY或GROUP BY操作的速度。默认数值是2097144字节(约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。可以通过如下几个MySQL状态值来适当调整线程池的大小:
1
2
3
4
5
6
7
8
9mysql> show global status like 'Thread%';
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_cached | 2 |
| Threads_connected | 1 |
| Threads_created | 3 |
| Threads_running | 2 |
+-------------------+-------+当 Threads_cached 越来越少,但 Threads_connected 始终不降,且 Threads_created 持续升高,可适当增加 thread_cache_size 的大小
-
wait_timeout:指定一个请求的最大连接时间,对于4GB左右内存的服务器可以设置为5-10。 -
interactive_timeout:表示服务器在关闭连接前等待行动的秒数。
这里给出一份my.cnf的参考配置:
1 | [mysqld] |
很多情况还需要具体情况具体分析!
2.3 举例
下面是一个电商平台,类似京东或天猫这样的平台。商家购买服务,入驻平台,开通之后,商家可以在系统中上架各种商品,客户通过手机App、微信小程序等渠道购买商品。商家接到订单以后安排快递送货。
刚刚上线的时候,系统运行状态良好。但是,随着入驻的商家不断增多,使用系统的用户量越来越多,每天的订单数据达到了5万条以上。这个时候,系统开始出现问题,CPU 使用率不断飙升。终于,双十一或者618活动高峰的时候,CPU 使用率达到99%,这实际上就意味着,系统的计算资源已经耗尽,再也无法处理任何新的订单了。
这个时候,我们想到了对系统参数进行调整,因为参数的值决定了资源配置的方式和投放的程度。
为了解决这个问题,一共调整3个系统参数,分别是
-
InnoDB_flush_log_at_trx_commit
-
InnoDB_buffer_pool_size
-
InnoDB_buffer_pool_instances
下面我们就说一说调整这三个参数的原因是什么。
1)调整系统参数 InnoDB_flush_log_at_trx_commit
这个参数适用于InnoDB存储引擎,电商平台系统中的表用的存储引擎都是InnoDB。默认的值是1,意思是每次 提交事务的时候,都把数据写入日志,并把日志写入磁盘。这样做的好处是数据安全性最佳,不足之处在于每次 提交事务,都要进行磁盘写入的操作。在大并发的场景下,过于频繁的磁盘读写会导致CPU资源浪费,系统效率变低。
这个参数的值还有2个可能的选项,分别是0和2。我们把这个参数的值改成了2。这样就不用每次提交事务的时候都启动磁盘读写了,在大并发的场景下,可以改善系统效率,降低CPU使用率。即便出现故障,损失的数据也比较小。
2)调整系统参数 InnoDB_buffer_pool_size
这个参数的意思是,InnoDB存储引擎使用缓存来存储索引和数据。这个值越大,可以加载到缓存区的索引和数据量就越多,需要的磁盘读写就越少。
因为我们的 MySQL 服务器是数据库专属服务器,只用来运行 MySQL 数据库服务,没有其他应用了,而我们的计算机是64位机器,内存也有128G。于是我们把这个参数的值调整为64G。这样一来,磁盘读写次数可以大幅降低,我们就可以充分利用内存,释放出一些CPU的资源。
3)调整系统参数 InnoDB_buffer_pool_instances
这个参数可以将InnoDB的缓存区分成几个部分,这样可以提高系统的并行处理能力,因为可以允许多个进程同时处理不同部分的缓存区。
我们把InnoDB_buffer_pool_instances的值修改为64,意思就是把InnoDB的缓存区分成64个分区,这样就可以同时有多个进程进行数据操作,CPU的效率就高多了。修改好了系统参数的值,要重启MySQL数据库服务器。
总结一下就是遇到 CPU 资源不足的问题,可以从下面 2 个思路去解决。
- 疏通拥堵路段,消除瓶颈,让等待的时间更短;
- 开拓新的通道,增加并行处理能力。
3. 优化数据库结构
一个好的数据库设计方案对于数据库的性能经常会起到事半功倍的效果。合理的数据库结构不仅可以使数据库占用更小的磁盘空间,而且能够使查询速度更快。数据库结构的设计需要考虑数据冗余、查询和更新的速度、字段的数据类型是否合理等多方面的内容。
3.1 拆分表:冷热数据分离
拆分表的思路是,把1个包含很多字段的表拆分成2个或者多个相对较小的表。这样做的原因是,这些表中某些 字段的操作频率很高(热数据),经常要进行查询或者更新操作,而另外一些字段的使用频率却很低(冷数据)。冷热数据分离,可以减小表的宽度。如果放在一个表里面,每次查询都要读取大量记录,会消耗较多的资源。
MySQL限制每个表最多存储4096列,并且每一行数据的大小不能超过65535字节。表越宽,把表装载进内存缓冲池时所占用的内存也就越大,也会消耗更多的IO。冷热数据分离的目的是:① 减少磁盘IO,保证热数据的内存缓存命中率。② 更有效的利用缓存,避免读入无用的冷数据。
举例1:
会员members表存储会员登录认证信息,该表中有很多字段,如id、姓名、密码、地址、电话、个人描述字段。其中地址、电话、个人描述等字段并不常用,可以将这些不常用的字段分解出另外一张表。将这个表取名叫 members_detail,表中有member_id, address,telephone,description等字段。这样就把会员表分成了两个表,分别为members表和members_detail表。
创建这两个表的SQL语句如下:
1 | CREATE TABLE members ( |
如果需要查询会员的基本信息或详细信息,那么可以用会员的id来查询。如果需要将会员的基本信息和详细信息同时显示,那么可以将members表和members_detail表进行联合查询,查询语句如下:
1 | SELECT * FROM members LEFT JOIN members_detail on members.id = members_detail.member_id; |
通过这种分解可以提高表的查询效率。对于字段很多且有些字段使用不频繁的表,可以通过这种分解的方式来优化数据库的性能。
3.2 增加中间表
对于需要经常联合查询的表,可以建立中间表以提高查询效率。通过建立中间表,把需要经常联合查询的数插入中间表中,然后将原来的联合查询改为对中间表的查询,以此来提高查询效率。
首先,分析经常联合查询表中的字段;然后,使用这些字段建立一个中间表,并将原来联合查询的表的数据插入 中间表中;最后,使用中间表来进行查询。
举例1:学生信息表和班级表的SQL语句如下:
1 | CREATE TABLE `class` ( |
现在有一个模块需要经常查询带有学生名称(name)、学生所在班级名称(className)、学生班级班长(monitor)的学生信息。根据这种情况可以创建一个temp_student表。temp_student表中存储学生名称(stu_name)、学生所在班级名称(className)和学生班级班长(monitor)信息。创建表的语句如下:
1 | CREATE TABLE `temp_student` ( |
接下来,从学生信息表和班级表中查询相关信息存储到临时表中:
1 | insert into temp_student(stu_name,className,monitor) |
以后,可以直接从temp_student表中查询学生名称、班级名称和班级班长,而不用每次都进行联合查询。这样可以提高数据库的查询速度。
如果用户信息修改了,是不是会导致temp_vip中的
数据不一致的问题呢?如何同步数据呢?方式1:清空数据->重新添加数据
方式2:使用视图(不推荐,查询效率上没有实质性的变化)
3.3 增加冗余字段
设计数据库表时应尽量遵循范式理论的规约,尽可能减少冗余字段,让数据库设计看起来精致、优雅。但是,合理地加入冗余字段可以提高查询速度。
表的规范化程度越高,表与表之间的关系就越多,需要连接查询的情况也就越多。尤其在数据量大,而且需要频繁进行连接的时候,为了提升效率,我们也可以考虑增加冗余字段来减少连接。
这部分内容在《数据库的设计规范》章节中反范式化小节中具体展开讲解了。这里省略。
3.4 优化数据类型
改进表的设计时,可以考虑优化字段的数据类型。这个问题在大家刚从事开发时基本不算是问题。但是,随着你的经验越来越丰富,参与的项目越来越大,数据量也越来越多的时候,你就不能只从系统稳定性的角度来思考问题了,还要考虑到系统整体的稳定性和效率。此时,优先选择符合存储需要的最小的数据类型。
列的字段越大,建立索引时所需要的空间也就越大,这样一页中所能存储的索引节点的数量也就越少,在遍历时 所需要的IO次数也就越多,索引的性能也就越差。
具体来说:
情况1:对整数类型数据进行优化。
遇到整数类型的字段可以用INT型。这样做的理由是,INT型数据有足够大的取值范围,不用担心数据超出取值范围的问题。刚开始做项目的时候,首先要保证系统的稳定性,这样设计字段类型是可以的。但在数据量很大的时候,数据类型的定义,在很大程度上会影响到系统整体的执行效率。
对于非负型的数据(如自增ID、整型IP)来说,要优先使用无符号整型UNSIGNED来存储。因为无符号相对于有符号,同样的字节数,存储的数值范围更大。如tinyint有符号为-128-127,无符号为0-255,多出一倍的存储空间。
情况2:既可以使用文本类型也可以使用整数类型的字段,要选择使用整数类型。
跟文本类型数据相比,大整数往往占用更少的存储空间,因此,在存取和比对的时候,可以占用更少的内存空间。所以,在二者皆可用的情况下,尽量使用整数类型,这样可以提高查询的效率。如:将IP地址转换成整型数据。
情况3:避免使用TEXT、BLOB数据类型
MySQL内存临时表不支持TEXT、BLOB这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。并且对于这种数据,MySQL 还是要进行二次查询,会使SQL性能变得很差,但是不是说一定不能使用这样的数据类型。
如果一定要使用,建议把BLOB或是TEXT列分离到单独的扩展表中,查询时一定不要使用select *,而只需要取出必要的列,不需要TEXT列的数据时不要对该列进行查询。
情况4:避免使用ENUM类型
修改ENUM值需要使用ALTER语句。
ENUM类型的ORDER BY操作效率低,需要额外操作。使用TINYINT来代替ENUM类型。
情况5:使用TIMESTAMP存储时间
TIMESTAMP存储的时间范围1970-01-01 00:00:01 ~ 2038-01-19-03:14:07。TIMESTAMP使用4字节,DATETIME使用8个字节,同时TIMESTAMP具有自动赋值以及自动更新的特性。
情况6:用DECIMAL代替FLOAT和DOUBLE存储精确浮点数
-
非精准浮点:float, double
-
精准浮点:decimal
Decimal类型为精准浮点数,在计算时不会丢失精度,尤其是财务相关的金融类数据。占用空间由定义的宽度决定,每4个字节可以存储9位数字,并且小数点要占用一个字节。可用于存储比bigint更大的整型数据。
总之,遇到数据量大的项目时,一定要在充分了解业务需求的前提下,合理优化数据类型,这样才能充分发挥资源的效率,使系统达到最优。
3.5 优化插入记录的速度
插入记录时,影响插入速度的主要是索引、唯一性校验、一次插入记录条数等。根据这些情况可以分别进行优化。这里我们分为MyISAM引擎和InnoDB引擎来讲。
1)MyISAM引擎的表:
① 禁用索引
对于非空表,插入记录时,MySQL会根据表的索引对插入的记录建立索引。如果插入大量数据,建立索引就会降低插入记录的速度。为了解决这种情况,可以在插入记录之前禁用索引,数据插入完毕后再开启索引。禁用索引的语句如下:
1 | ALTER TABLE table_name DISABLE KEYS; |
重新开启索引的语句如下:
1 | ALTER TABLE table_name ENABLE KEYS; |
若对于空表批量导入数据,则不需要进行此操作,因为MyISAM引擎的表是在导入数据之后才建立索引的。
② 禁用唯一性检查
插入数据时,MySQL会对插入的记录进行唯一性校验。这种唯一性校验会降低插入记录的速度。为了降低这种情况对查询速度的影响,可以在插入记录之前禁用唯一性检查,等到记录插入完毕后再开启。禁用唯一性检查的语句如下:
1 | SET UNIQUE_CHECKS=0; |
开启唯一性检查的语句如下:
1 | SET UNIQUE_CHECKS=1; |
③ 使用批量插入
插入多条记录时,可以使用一条INSERT语句插入一条数据,也可以使用一条INSERT语句插入多条数据。插入一条记录的INSERT语句情形如下:
1 | insert into student values(1,'zhangsan',18,1); |
使用一条INSERT语句插入多条记录的情形如下:
1 | insert into student values |
第2种情形的插入速度要比第1种情形快
④ 使用LOAD DATA INFILE 批量导入
当需要批量导入数据时,如果能用LOAD DATA INFILE语句,就尽量使用。因为LOAD DATA INFILE语句导入数据的速度比INSERT语句块。
2)InnoDB引擎的表:
① 禁用唯一性检查
插入数据之前执行set unique_checks=0来禁止对唯一索引的检查,数据导入完成之后再运行set unique_check=1。这个和MyISAM引擎的使用方法一样。
② 禁用外键检查
插入数据之前执行禁止对外键的检查,数据插入完成之后再恢复对外键的检查。禁用外键检查的语句如下:
1 | SET foreign_key_checks=0; |
恢复对外键的检查语句如下:
1 | SET foreign_key_checks=1; |
③ 禁止自动提交
插入数据之前禁止事务的自动提交,数据导入完成之后,执行恢复自动提交操作。禁止自动提交的语句如下:
1 | set autocommit=0; |
恢复自动提交的语句如下:
1 | set autocommit=1; |
3.6 使用非空约束
在设计字段的时候,如果业务允许,建议尽量使用非空约束。这样做的好处是:
① 进行比较和计算时,省去要对NULL值的字段判断是否为空的开销,提高存储效率。
② 非空字段也容易创建索引。因为索引NULL列需要额外的空间来保存,所以要占用更多的空间。使用非空约束,就可以节省存储空间(每个字段1个bit)。
3.7 分析表、检查表与优化表
MySQL提供了分析表、检查表和优化表的语句。分析表主要是分析关键字的分布,检查表主要是检查表是否存在错误,优化表主要是消除删除或者更新造成的空间浪费。
1)分析表
MySQL中提供了ANALYZE TABLE语句分析表,ANALYZE TABLE语句的基本语法如下
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类型的表,但是不能作用于视图。
1 | # 分析表,更新相关数据 |
上面结果显示的信息说明如下:
- Table: 表示分析的表的名称。
- Op: 表示执行的操作。analyze表示进行分析操作。
- Msg_type: 表示信息类型,其值通常是状态 (status) 、信息 (info) 、注意 (note) 、警告 (warning) 和 错误 (error) 之一。
- Msg_text: 显示信息。
ANALYZE TABLE分析后的统计结果会反应到cardinality的值,该值统计了表中某一键所在的列不重复的值的个数。**该值越接近表中的总行数,则在表连接查询或者索引查询时,就越优先被优化器选择使用。**也就是索引列的cardinality的值与表中数据的总条数差距越大,即使查询的时候使用了该索引作为查询条件,存储引擎实际查询的时候使用的概率就越小。下面通过例子来验证下。
cardinality可以通过 SHOW INDEX FROM 表名查看。
1 | # 查看表中字段的cardinality值 |
举例
下面我们举例说明,使用下面的语句创建一张user表
1 | CREATE TABLE `user` ( |
使用下面的存储过程插入1000条数据,其中name字段的数据保持一致。如下所示。
1 | DELIMITER // |
调用存储过程:
1 | CALL insert_user(1000); |
此时修改其中任意一条数据,把name中的atguigu修改为atguigu_test
1 | update user set name='atguigu_test' where id=2; |
接下来我们查看user表的cardinality
1 | SHOW INDEX FROM user; |
可以看到name字段的cardinality值仅为 1,id字段的cardinality值为1000。
下面使用 ANALYZE TABLE 来分析 user 表,执行的语句及结果如下:
1 | ANALYZE TABLE user; |
1 | SHOW INDEX FROM user; |
可以看到name字段的cardinality变为2,因为name字段有2个不一样的值,分别为atguigu和atguigu_test。
查看如下SQL语句的执行计划
1 | EXPLAIN SELECT * FROM user WHERE name = 'atguigu'; |
可以看到虽然name字段上面有索引,但是执行计划中显示type为ALL,表示并没有使用到索引。也就是说,在查询时,索引列的cardinality的值越小,该索引被优化器选择使用的概率越小。
2)检查表
MySQL中可以使用 CHECK TABLE 语句来检查表。CHECK TABLE语句能够检查InnoDB和MyISAM类型的表 是否存在错误。CHECK TABLE语句在执行过程中也会给表加上只读锁。
对于MyISAM类型的表,CHECK TABLE语句还会更新关键字统计数据。而且,CHECK TABLE也可以检查视图是否有错误,比如在视图定义中被引用的表已不存在。该语句的基本语法如下:
1 | CHECK TABLE tbl_name [, tbl_name] ... [option] ... |
其中,tbl_name是表名;option参数有5个取值,分别是QUICK、FAST、MEDIUM、EXTENDED和 CHANGED。各个选项的意义分别是:
QUICK:不扫描行,不检查错误的连接。FAST:只检查没有被正确关闭的表。CHANGED:只检查上次检查后被更改的表和没有被正确关闭的表。MEDIUM:扫描行,以验证被删除的连接是有效的。也可以计算各行的关键字校验和,并使用计算出的校验和验证这一点。EXTENDED:对每行的所有关键字进行一个全面的关键字查找。这可以确保表是100%一致的,但 是花的时间较长。
option只对MyISAM类型的表有效,对InnoDB类型的表无效。比如:
1 | check table user; |
该语句对于检查的表可能会产生多行信息。最后一行有一个状态的Msg_type值,Msg_text通常为OK。 如果得到的不是OK,通常要对其进行修复;是OK说明表已经是最新的了。表已经是最新的,意味着存储引擎对这张表不必进行检查。
3)优化表
方式1:OPTIMIZE TABLE
MySQL中使用OPTIMIZE TABLE语句来优化表。但是,OPTILMIZE TABLE语句只能优化表中的VARCHAR、BLOB或TEXT类型的字段。一个表使用了这些字段的数据类型,若已经删除了表的一大部分数据,或者已经对含有可变长度行的表(含有VARCHAR、BLOB或TEXT列的表)进行了很多更新,则应使用OPTIMIZE TABLE来重新利用未使用的空间,并整理数据文件的碎片。
OPTIMIZE TABLE 语句对InnoDB和MyISAM类型的表都有效。该语句在执行过程中也会给表加上只读锁。
OPTILMIZE TABLE语句的基本语法如下:
1 | OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... |
LOCAL | NO_WRITE_TO_BINLOG关键字的意义和分析表相同,都是指定不写入二进制日志。
1 | OPTIMIZE TABLE student; |
执行完毕,Msg_text显示,Table does not support optimize, doing recreate + analyze instead,原因是服务器上的MySQL是InnoDB存储引擎。
到底优化了没有呢?看官网!
在MyISAM中,是先分析这张表,然后会整理相关的MySQL datafile,之后回收未使用的空间;在InnoDB 中,回收空间是简单通过Alter table进行整理空间。在优化期间,MySQL会创建一个临时表,优化完成之后会删除原始表,然后会将临时表rename成为原始表。
说明:在多数的设置中,根本不需要运行OPTIMIZE TABLE。即使对可变长度的行进行了大量的更新,也不需要经常运行,
每周一次或每月一次即可,并且只需要对特定的表运行。
举例:
1 | 1. 新建一张表,使用存储过程往里面放入 100W 数据。或者更多一些,争取能够以兆的单位显示 |
方式二:使用mysqlcheck命令
1 | mysqlcheck -o DatabaseName TableName -u root -p****** |
mysqlcheck是Linux中的prompt,-o是代表 Optimize。
举例:优化所有的表
1 | mysqlcheck -o DatabaseName -u root -p****** |
3.8 小结
上述这些方法都是有利有弊的。比如:
- 修改数据类型,节省存储空间的同时,你要考虑到数据不能超过取值范围;
- 增加冗余字段的时候,不要忘了确保数据一致性;
- 把大表拆分,也意味着你的查询会增加新的连接,从而增加额外的开销和运维的成本。
因此,你一定要结合实际的业务需求进行权衡。
4. 大表优化
当MySQL单表记录数过大时,数据库的CRUD性能会明显下降,一些常见的优化措施如下:
4.1 限定查询的范围
**禁止不带任何限制数据范围条件的查询语句。**比如:我们当用户在查询订单历史的时候,我们可以控制在一个月的范围内;
4.2 读/写分离
经典的数据库拆分方案,主库负责写,从库负责读。
- 一主一从模式:

- 双主双从模式:

4.3 垂直拆分
当数据量级达到千万级以上时,有时候我们需要把一个数据库切成多份,放到不同的数据库服务器上, 减少对单一数据库服务器的访问压力。
- 如果数据库的数据表过多,可以采用
垂直分库的方式,将关联的数据库部署在同一个数据库上。 - 如果数据库中的列过多,可以采用
垂直分表的方式,将一张数据表分拆成多张数据表,把经常一起使用的列放在同一张表里。
垂直拆分的优点: 可以使得列数据变小,在查询时减少读取的Block数,减少I/O次数。此外,垂直分区可以简化表的结构,易于维护。
垂直拆分的缺点: 主键会出现冗余,需要管理冗余列,并会引起 JOIN 操作。此外,垂直拆分会让事务变得更加复杂。
4.4 水平拆分
-
尽量控制单表数据量的大小,建议控制在
1000万以内。1000万并不是MySQL数据库的限制,过大会造成修改表结构、备份、恢复都会有很大的问题。此时可以用历史数据归档(应用于日志数据)、水平分表(应用于业务数据)等手段来控制数据量大小。 -
这里我们主要考虑业务数据的水平分表策略。将大的数据表按照
某个属性维度分拆成不同的小表,每张小表 保持相同的表结构。比如你可以按照年份划分,把不同年份的数据放到不同的数据库中。2017年、2018年 和2019年的数据库就可以分别划分到三张数据表中。 -
水平分表仅是解决了一张表数据过大的问题,但由于表的数据还是在同一台机器上,实际上对提升MySQL并发能力没有什么意义,所以
水平拆分最好分库,从而达到分布式的目的。

水平拆分能够支持非常大的数据量存储,应用端改造也少,但分片事务难以解决,跨节点Join性能较差,逻辑复杂。《Java工程师修炼之道》的作者推荐尽量不要对数据进行分片,因为拆分会带来逻辑、部署、运维的各种复杂度,一般的数据库在优化得当的情况下支撑千万以下的数据量是没有太大问题的。如果实在要分片,尽量选择 客户端分片架构,这样可以减少一次和中间件的网络 I/O。
下面补充一下数据库分片的两种常见方案:
- 客户端代理: 分片逻辑在应用端,封装在jar包中,通过修改或者封装JDBC层来实现。 当当网的 Sharding-JDBC、阿里的TDDL是两种比较常用的实现。
- **中间件代理: 在应用和数据中间加了一个代理层。分片逻辑统一维护在中间件服务中。**我们现在谈的 Mycat、360的Atlas、网易的DDB等等都是这种架构的实现。
5. 其它调优策略
5.1 服务器语句超时处理
在MySQL 8.0中可以设置服务器语句超时的限制,单位可以达到毫秒级别。当中断的执行语句超过设置的毫秒数后,服务器将终止查询影响不大的事务或连接,然后将错误报给客户端。
设置服务器语句超时的限制,可以通过设置系统变量MAX_EXECUTION_TIME来实现。默认情况下, MAX_EXECUTION_TIME的值为0,代表没有时间限制。
例如:
1 | SET GLOBAL MAX_EXECUTION_TIME=2000; |
5.2 创建全局通用表空间
MySQL8.0使用CREATE TABLESPACE语句来创建一个全局通用表空间。全局表空间可以被所有的数据库的表共 享,而且相比于独享表空间,使用手动创建共享表空间可以节约元数据方面的内存。可以在创建表的时候,指定 属于哪个表空间,也可以对已有表进行表空间修改等。
下面创建名为atguigu1的共享表空间,SQL语句如下:
1 | mysql> CREATE TABLESPACE atguigu1 ADD datafile 'atguigu1.ibd' file_block_size=16k; |
指定表空间,SQL语句如下:
1 | mysql> CREATE TABLE test(id int,name varchar(10)) |
也可以通过ALTER TABLE语句指定表空间,SQL语句如下:
1 | mysql> alter table test tablespace atguigu1; |
如何删除创建的共享表空间?因为是共享表空间,所以不能直接通过drop table tbname删除,这样操作并不能回收空间。当确定共享表空间的数据都没用,并且依赖该表空间的表均已删除时,可以通过drop tablespace 删除共享表空间来释放空间,如果依赖该共享表空间的表存在,就会删除失败。如下所示:
1 | mysql> DROP TABLESPACE atguigu1; |
所以应该首先删除依赖该表空间的数据库表,SQL 语句如下:
1 | DROP TABLE test; |
最后即可删除表空间,SQL语句如下:
1 | DROP TABLESPACE atguigu1; |
5.3 MySQL 8.0新特性:隐藏索引对调优的帮助
不可见索引的特性对于性能调试非常有用。在MySQL8.0中,索引可以被隐藏和显示。**当一个索引被隐藏时,它不会被查询优化器所使用。**也就是说,管理员可以隐藏一个索引,然后观察对数据库的影响。如果数据库性能有所下降,就说明这个索引是有用的,于是将其“恢复显示”即可;如果数据库性能看不出变化,就说明这个索引 是多余的,可以删掉了。
需要注意的是当索引被隐藏时,它的内容仍然是和正常索引一样实时更新的。如果一个索引需要长期被隐藏,那么可以将其删除,因为索引的存在会影响插入、更新和删除的性能。
数据表中的主键不能被设置为invisible。
第13章 事务基础知识
1. 数据库事务概述
事务是数据库区别于文件系统的重要特性之一,当我们有了事务就会让数据库始终保持一致性,同时我们还能通过事务的机制恢复到某个时间点,这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。
1.1 存储引擎支持情况
SHOW ENGINES 命令来查看当前 MySQL 支持的存储引擎都有哪些,以及这些存储引擎是否支持事务。

能看出在MySQL中,只有InnoDB是支持事务的。
1.2 基本概念
**事务:**一组逻辑操作单元,使数据从一种状态变换到另一种状态。
**事务处理的原则:**保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方式。当在一个事务中执行多个操作时,要么所有的事务都被提交(commit),那么这些修改就永久地保存下来;要么数据库管理系统将放弃所作的所有修改,整个事务回滚(rollback)到最初状态。
1 | # 案例:AA用户给BB用户转账100 |
1.3 事务的ACID特性
- 原子性(atomicity):
原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚。
即要么转账成功,要么转账失败,是不存在中间的状态。如果无法保证原子性会怎么样?就会出现数据不一致的情形,A账户减去100元,而B账户增加100元操作失败,系统将无故丢失100元。
- 一致性(consistency):
一致性是指事务执行前后,数据从一个合法性状态变换到另外一个合法性状态。这种状态是语义上的而不是语法上的,跟具体的业务有关。
那什么是合法的数据状态呢?满足预定的约束的状态就叫做合法的状态。通俗一点,这状态是由你自己来定义的(比如满足现实世界中的约束)。满足这个状态,数据就是一致的,不满足这个状态,数据就是不一致的!如果事务中的某个操作失败了,系统就会自动撤销当前正在执行的事务,返回到事务操作之前的状态。
**举例1:**A账户有200元,转账300元出去,此时A账户余额为-100元。你自然就发现此时数据是不一致的,为什么呢?因为你定义了一个状态,余额这列必须>=0。
**举例2:**A账户有200元,转账50元给B账户,A账户的钱扣了,但是B账户因为各种意外,余额并没有增加。你也知道此时的数据是不一致的,为什么呢?因为你定义了一个状态,要求A+B的总余额必须不变。
**举例3:**在数据表中我们将姓名字段设置为唯一性约束,这时当事务进行提交或者事务发生回滚的时候,如果数据表的姓名不唯一,就破坏了事务的一致性要求。
国内很多网站上对一致性的阐述有误,具体可以参考 Wikipedia 对Consistency的阐述
- 隔离型(isolation):
事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
如果无法保证隔离性会怎么样?假设A账户有200元,B账户0元。A账户往B账户转账两次,每次金额为50元,分别在两个事务中执行。如果无法保证隔离性,会出现下面的情形:
1 | UPDATE accounts SET money = money - 50 WHERE NAME = 'AA'; |

- 持久性(durability):
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响。
持久性是通过事务日志来保证的。日志包括了重做日志和回滚日志。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库重启后也能找到没有更新到数据库系统中的重做日志,重新执行,从而使事务具有持久性。
总结
ACID是事务的四大特性,在这四个特性中,原子性是基础,隔离性是手段,一致性是约束条件,而持久性是 我们的目的。
数据库事务,其实就是数据库设计者为了方便,把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称为一个事务。
1.4 事务的状态
我们现在知道事务是一个抽象的概念,它其实对应着一个或多个数据库操作,MySQL根据这些操作所执行的不同阶段把事务大致划分成几个状态:
- 活动的(active)
事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。
- 部分提交的(partially committed)
当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态。
- 失败的(failed)
当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。
- 中止的(aborted)
如果事务执行了一部分而变为失败的状态,那么就需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态。
- 提交的(committed)
当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。
一个基本的状态转换图如下所示:
图中可见,只有当事务处于提交的或者中止的状态时,一个事务的生命周期才算是结束了。对于已经提交的事务来说,该事务对数据库所做的修改将永久生效,对于处于中止状态的事务,该事务对数据库所做的所有修改都会被回滚到没执行该事务之前的状态。
2. 如何使用事务
使用事务有两种方式,分别为显式事务和隐式事务。
2.1 显式事务
步骤1:START TRANSACTION或者BEGIN,作用是显式开启一个事务。
1 | mysql> BEGIN; |
START TRANSACTION语句相较于BEGIN特别之处在于,后边能跟随几个修饰符:
-
READ ONLY:标识当前事务是一个
只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。补充:只读事务中只是不允许修改那些其他事务也能访问到的表中的数据,对于临时表来说(我们使用 CREATE TMEPORARY TABLE 创建的表),由于它们只能再当前会话中可见,所有只读事务其实也是可以对临时表进行增、删、改操作的。
-
READ WRITE(默认值)标识当前事务是一个
读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。 -
WITH CONSISTENT SNAPSHOT:启动一致性读。
1 | START TRANSACTION READ ONLY; # 开启一个只读事务 |
注意:
READ ONLY和READ WRITE是用来设置所谓的事务访问模式的,就是以只读还是读写的方式来访问数据库中的数据,一个事务的访问模式不能同时既设置为只读的也设置为读写的,所以不能同时把READ ONLY和READ WRITE放到START TRANSACTION语句后边。如果我们不显式指定事务的访问模式,那么该事务的访问模式就是
读写模式
步骤2:一系列事务中的操作(主要是DML,不含DDL)
步骤3:提交事务 或 中止事务(即回滚事务)
1 | # 提交事务。当提交事务后,对数据库的修改是永久性的。 |
1 | # 回滚事务。即撤销正在进行的所有没有提交的修改 |
其中关于SAVEPOINT相关操作有:
1 | # 在事务中创建保存点,方便后续针对保存点进行回滚。一个事物中可以存在多个保存点。 |
1 | # 删除某个保存点 |
2.2 隐式事务
MySQL中有一个系统变量 autocommit :
1 | mysql> SHOW VARIABLES LIKE 'autocommit'; |
默认情况下,如果我们不显式的使用START TRANSACTION或者BEGIN语句开启一个事务,那么每一条语句都算 是一个独立的事务,这种特性称之为事务的自动提交。也就是说,不以 START TRANSACTION 或者 BEGIN 语句显式的开启一个事务,那么这两条语句就相当于放到两个独立的事务中去执行:
1 | UPDATE account SET balance = balance - 10 WHERE id = 1; |
当然,如果我们想关闭这种自动提交的功能,可以使用下边两种方法之一:
-
显式的的使用
START TRANSACTION或者BEGIN语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。 -
把系统变量
autocommit的值设置为OFF1
2
3SET autocommit = OFF;
#或
SET autocommit = 0;
这样的话,我们写入的多条语句就算是属于同一个事务了,直到我们显式的写出 COMMIT 语句来把这个事务提 交掉,或者显式的写出 ROLLBACK 语句来把这个事务回滚掉。
补充:Oracle 默认不自动提交,需要手写 COMMIT 命令,而 MySQL 默认自动提交。
2.3 隐式提交数据的情况
1)数据定义语言(Data definition language,缩写为:DDL)
定义或修改数据库对象的语句
数据库对象,指的就是数据库、表、视图、存储过程等结构。当我们CREATE、ALTER、DROP等语句去定义或者修改数据库对象时,就会隐式的提交前边语句所属于的事务。即:
1 | BEGIN; |
2)隐式使用或修改mysql数据库中的表
当我们使用ALTER USER、CREATE USER、DROP USER、GRANT、RENAME USER、REVOKE、SET PASSWORD等语句时也会隐式的提交前边语句所属于的事务。
3)事务控制或关于锁定的语句
-
当我们在一个事务还没提交或者回滚时就又使用
START TRANSACTION或者BEGIN语句开启了另一个事务时,会隐式的提交上一个事务。1
2
3
4
5
6
7BEGIN;
SELECT ... # 事务中的一条语句
UPDATE ... # 事务中的一条语句
... # 事务中的其他语句
BEGIN; # 此语句会隐式的提交前边语句所属于的事务 -
当前的
autocommit系统变量的值为OFF,我们手动把它调为ON时,也会隐式的提交前边语句所属的事务。 -
使用
LOCK TABLES、UNLOCK TABLES等关于锁定的语句也会隐式的提交前边语句所属的事务。
4)加载数据的语句
使用LOAD DATA语句来批量往数据库中导入数据时,也会隐式的提交前边语句所属的事务。
5)关于MySQL复制的一些语句
使用START SLAVE、STOP SLAVE、RESET SLAVE、CHANGE MASTER TO等语句会隐式的提交前边语句所属的事务
6)其他的一些语句
使用ANALYZE TABLE、CACHE INDEX、CHECK TABLE、FLUSH、LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE、RESET等语句也会隐式的提交前边语句所属的事务
2.4 使用举例1:提交与回滚
我们看下在MySQL的默认状态下,下面这个事务最后的处理结果是什么。
情况1
1 | CREATE TABLE user(name varchar(20), PRIMARY KEY (name)) ENGINE=InnoDB; |
运行结果(1行数据):
1 | mysql> commit; |
情况2
在没有使用BEGIN语句的情况下,DML语句会自动提交数据。
1 | CREATE TABLE user (name varchar(20), PRIMARY KEY (name)) ENGINE=InnoDB; |
运行结果(2行数据):
1 | mysql> SELECT * FROM user; |
情况3
1 | CREATE TABLE user(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB; |
运行结果(1行数据):
1 | mysql> SELECT * FROM user; |
MySQL中系统变量completion_type参数的作用,实际上这个参数有3种可能:
completion=0等同于NO_CHAIN,这是默认情况。当我们执行 COMMIT 的时候会提交事务,在执行下一个事务时,还需要使用START TRANSACTION或者BEGIN来开启。completion=1,这种情况下,当我们提交事务后,相当于执行了COMMIT AND CHAIN,也就是开启一个 链式事务,即当我们提交事务之后会开启一个相同隔离级别的事务。completion=2,这种情况下COMMIT=COMMIT AND RELEASE,也就是当我们提交后,会自动与服务器断 开连接。
当我们设置 autocommit=0 时,不论是否采用 START TRANSACTION 或者 BEGIN 的方式来开启事务,都是要用 COMMIT 进行提交,让事务生效,使用 ROLLBACK 对事务进行回滚。
当我们设置 autocommit=1 时,每条 SQL 语句都会自动进行提交。
不过这时,如果你采用 START TRANSACTION 或者 BEGIN 的方式来显式地开启事务,那么这个事务只有在 COMMIT 时才会生效,在 ROLLBACK 时才会回滚。
2.5 使用举例2:测试不支持事务的engine
1 | CREATE TABLE test1(i INT) ENGINE=InnoDB; |
针对于InnoDB表
1 | BEGIN; |
结果:没有数据
针对于MYISAM表:
1 | BEGIN; |
结果:有一条数据
结论:不支持事务的表无法回滚事务
2.6 使用举例3:SAVEPOINT
创建表并添加数据:
1 | CREATE TABLE account( |
1 | BEGIN; |
结果:张三:800.00
1 | # 中止事务,回滚到初始状态 |
结果:张三:1000.00
3. 事务隔离级别
MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称为一个会话( Session )。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。事务有隔离性的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,那就看二者如何权衡取舍了。
3.1 数据准备
创建一个表
1 | CREATE TABLE student ( |
然后向这个表里插入一条数据:
1 | INSERT INTO student VALUES(1, '小谷', '1班'); |
现在表里的数据就是这样的:
1 | mysql> select * from student; |
3.2 数据并发问题
1) 脏写(Dirty Write)
对于两个事务 Session A、Session B,如果事务Session A修改了另一个未提交事务Session B修改过的数据,那就意味着发生了脏写

Session A 和 Session B 各开启了一个事务,Sesssion B 中的事务先将studentno列为1的记录的name列更新为’李四’,然后Session A中的事务接着又把这条studentno列为1的记录的name列更新为’张三’。如果之后Session B中的事务进行了回滚,那么Session A中的更新也将不复存在,这种现象称之为脏写。这时Session A中的事务就没有效果了,明明把数据更新了,最后也提交事务了,最后看到的数据什么变化也没有。这里大家对事务的隔离性比较了解的话,会发现默认隔离级别下,上面Session A中的更新语句会处于等待状态,这里只是跟大家说明一下会出现这样的现象。
2) 脏读(Dirty Read)
对于两个事务 Session A、Session B,Session A读取了已经被 Session B更新但还没有被提交的字段。之后若 Session B回滚,Session A读取的内容就是临时且无效的。

Session A和Session B各开启了一个事务,Session B中的事务先将studentno列为1的记录的name列更新为’张三’,然后Session A中的事务再去查询这条studentno为1的记录,如果读到列name的值为’张三’,而 Session B 中的事务稍后进行了回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。
3)不可重复读(Non-Repeatable Read)
对于两个事务Session A、Session B,Session A读取了一个字段,然后 Session B更新了该字段。 之后Session A再次读取同一个字段,值就不同了。那就意味着发生了不可重复读。

我们在Session B中提交了几个隐式事务(注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了studentno列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看到最新的值,这种现象也被称之为不可重复读 。
4)幻读(Phantom)
对于两个事务Session A、Session B, Session A 从一个表中读取了一个字段, 然后 Session B 在该表中插入了一些新的行。之后, 如果 Session A再次读取同一个表,就会多出几行。那就意味着发生了幻读。

Session A 中的事务先根据条件studentno > 0这个条件查询表student,得到了name列值为’张三’的记录;之后Session B中提交了一个隐式事务,该事务向表student中插入了一条新记录;之后Session A中的事务再根据相同的条件 studentno > 0查询表student,得到的结果集中包含Session B中的事务新插入的那条记录,这种现象也被称之为幻读。我们把新插入的那些记录称之为幻影记录。
注意1:
有的同学会有疑问,那如果Session B中剔除了一些符合studentno > 0的记录而不是插入新记录,那么Session A之后再根据studentno > 0的条件读取的记录变少了,这种现象算不算幻读呢?这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。
注意2:
那对于先前已经读到的记录,之后又读取不到这种情况,算啥呢?这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到之前读取没有获取到的记录。
3.3 SQL中的四种隔离级别
上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题有轻重缓急之分,我们给这些问题按照严重性来排一下序:
1 | 脏写 > 脏读 > 不可重复读 > 幻读 |
我们愿意舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,并发问题发生的就越多。
SQL标准中设立了4个隔离级别:
READ UNCOMMITTED:读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。READ COMMITTED:读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读、幻读问题仍然存在。REPEATABLE READ:可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别。SERIALIZABLE:可串行化,确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。
SQL标准 中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:

脏写 怎么没涉及到?因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。
不同的隔离级别有不同的现象,并有不同的锁和并发机制,隔离级别越高,数据库的并发性能就越差,4 种事务隔离级别与并发性能的关系如下:
3.4 MySQL支持的四种隔离级别
不同的数据库厂商对SQL标准中规定的四种隔离级别支持不一样。比如,Oracle就只支持 READ COMMITTED(默认隔离级别)和SERIALIZABLE隔离级别。MySQL虽然支持4种隔离级别,但与SQL标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL在REPEATABLE READ隔离级别下,是可以禁止幻读问题的发生的,禁止幻读的原因我们在第16章讲解。
MySQL的默认隔离级别为REPEATABLE READ,我们可以手动修改一下事务的隔离级别。
1 | # 查看隔离级别,MySQL 5.7.20的版本之前: |
3.5 如何设置事务的隔离级别
通过下面的语句修改事务的隔离级别:
1 | SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 隔离级别; |
或者:
1 | SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '隔离级别' |
关于设置时使用GLOBAL或SESSION的影响:
-
使用
GLOBAL关键字(在全局范围影响):1
2
3SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
#或
SET GLOBAL TRANSACTION_ISOLATION = 'SERIALIZABLE';则:
- 当前已经存在的会话无效
- 只对执行完该语句之后产生的会话起作用
-
使用
SESSION关键字(在会话范围影响):1
2
3SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
#或
SET SESSION TRANSACTION_ISOLATION = 'SERIALIZABLE';则:
- 对当前会话的所有后续的事务有效
- 如果在事务之间执行,则对后续的事务有效
- 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务
如果在服务器启动时想改变事务的默认隔离级别,可以修改启动参数transaction_isolation的值。比如,在启动服务器时指定了transaction_isolation=SERIALIZABLE,那么事务的默认隔离界别就从原来的REPEATABLE-READ变成了SERIALIZABLE。
小结:
数据库规定了多种事务隔离级别,不同隔离级别对应不同的干扰程度,隔离级别越高,数据一致性就越好,但并发性越弱。
3.6 不同隔离级别举例
初始化数据:
1 | TRUNCATE TABLE account; |
表中的数据如下:
1 | mysql> select * from account; |
演示1. 读未提交之脏读
设置隔离级别为未提交读:

脏读就是指当前事务就在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问了这个数据,然后使用了这个数据。
演示2:读已提交和不可重复读

演示3. 可重复读
设置隔离级别为可重复读,事务的执行流程如下:

当我们将当前会话的隔离级别设置为可重复读的时候,当前会话可以重复读,就是每次读取的结果集都相同,而不管其他事务有没有提交。但是在可重复读的隔离级别上会出现幻读的问题。
演示4:幻读

这里要灵活的理解读取的意思,第一次select是读取,第二次的insert其实也属于隐式的读取,只不过是在mysql的机制中读取的,插入数据也是要先读取一下有没有主键冲突才能决定是否执行插入。
幻读,并不是说两次读取获取的结果集不一样,幻读侧重的方面是某一次的select操作得到的结果所表征的数据状态无法支持接后续的业务操作。更为具体一些:select某记录是否存在,不存在,准备插入此记录,但执行insert时发现此记录已经存在,无法插入,此时就发生了幻读。
在RR隔离级别下,step1,step2是会正常执行的,step3则会抛出主键冲突,对于事务1的业务来说是执行失败的,这里事务1就是发生了幻读,因为事务1在step1中读取的数据状态并不能支撑后续的业务操作,事务1:“见鬼了,我刚才读到的结果应该可以支持我这样操作,为什么现在不可以”。事务1不敢相信的又执行了step4,发现和step1读取的结果是一样的(RR下的MVCC机制)。此时,幻读无疑已经发生,事务1无论读取多少次,都查不到 id = 3 的记录,但它的确无法插入这条他通过读取来认定不存在的记录(此数据已被事务2插入),对于事务1来说,它幻读了。
其实RR也是可以避免幻读的,通过对select操作手动加行X锁(独占锁)(SELECT…FOR UPDATE 这也正是SERIALIZABLE隔离级别下会隐式为你做的事情)。同时,即便当前记录不存在,比如id = 3 是不存在的,当前事务也会获得一把记录锁(因为InnoDB的行锁锁定的是索引,故记录实体存在与否没关系,存在就加行X锁,不存在就加间隙锁),其他事务则无法插入此索引的记录,故杜绝了幻读。
在SERIALIZABLE隔离级别下,step1执行时是会隐式的添加行(X)锁 / gap(X)锁的,从而step2会被阻塞,step3会正常执行,待事务1提交后,事务2才能继续执行(主键冲突执行失败)。对于事务1来说业务是正确的,成功的扼杀了扰乱业务的事务2,对于事务1来说他前期读取的结果是可以支撑其后续业务的。
所以MySQL的幻读并非什么读取两次返回的结果集不同,而是事务在插入事先检测不存在的记录时,惊奇的发现这些数据已经存在了,之前的检测读取到的数据显示如同鬼影一般。
4. 事务的常见分类
从事务理论的角度来看,可以把事务分为以下几种类型:
- 扁平事务(Flat Transactions)
- 带有保存点的扁平事务(Flat Transactions with Savepoints)
- 链事务(Chained Transactions)
- 嵌套事务(Nested Transactions)
- 分布式事务(Distributed Transactions)
下面分别介绍着几种类型:
1)扁平事务
扁平事务 是事务类型中最简单的一种,但是在实际生产环境中,这可能是使用最频繁的事务。在扁平事务中,所有操作都处于同一层次,其由 BEGIN WORK 开始,由 COMMIT WORK 或 ROLLBACK WORK 结束,其间的操作是原子的,要么都执行,要么都回滚。因此,扁平事务是应用程序成为原子操作的基本组成模块。扁平事务虽然简单,但是在实际环境中使用最为频繁,也正因为其简单、使用频繁,故每个数据库系统都实现了对扁平事务的支持。扁平事务的主要限制是不能提交或者回滚事务的某一部分,或分几个步骤提交。
扁平事务一般有三种不同的结果:
① 事务成功完成。在平常应用中约占所有事务的 96%。
② 应用程序要求停止事务。比如应用程序在捕获到异常时会回滚事务,约占事务的 3%。
③ 外界因素强制终止事务。如连接超时或连接断开,约占所有事务的 1%。
2)带有保存点的扁平事务
带有保存点的扁平事务 除了支持扁平事务支持的操作以外,还允许在事务执行过程中回滚到同一事务中较早的一个状态。这是因为某些事务可能在执行过程中出现的错误并不会导致所有的操作都无效,放弃整个事务不合乎要求,开销太大。
保存点 (Savepoint) 用来通知事务系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当前的状态。对于扁平的事务来说,隐式的在事务的开始设置了一个保存点,然而在整个事务中,只有这一个保存点,因此,回滚只能会滚到事务开始的状态。
3)链事务
链事务 是指一个事务由多个事务链式组成,它可以被视为保存点模式的一个变种。带有保存点的扁平事务,当发生系统崩溃时,所有的保存点都将消失,这意味着当进行恢复时,事务需要从开始重新执行,而不能从最近的一个保存点继续执行。链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务,前一个子事务的提交操作和下一个事务的开始操作合并成一个原子操作,这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行一样。这样,**在提交子事务时就可以释放不需要的对象,而不必等到整个事务完成后才释放。**其工作方式如下:

链事务与带有保存点的扁平事务的不同之处体现在:
① 带有保存点的扁平事务能回滚到任意正确的保存点,而链事务中的回滚仅限于当前事务,即只能恢复到最近的 一个保存点。
② 对于锁的处理,两者也不相同,链事务在执行 COMMIT 后即释放了当前所持有的锁,而带有保存点的扁平事务不影响它迄今为止所持有的锁。
4)嵌套事务
嵌套事务 是一个层次结构框架,由一个顶层事务(Top-Level Transaction)控制着各个层次的事务,顶层事务之下嵌套的事务被称为子事务(Subtransaction),其控制着每一个局部的变换,子事务本身也可以是嵌套事务。因此,嵌套事务的层次结构可以看成是一棵树。
5)分布式事务
分布式事务 通常是在一个分布式环境下运行的扁平事务。因此,需要根据数据所在位置访问网络中不同节点的数据库资源。例如,一个银行用户从招商银行的账户向工商银行的账户转账1000元,这里需要用到分布式事务,因为不能仅调用某一家的数据库就完成任务。
第14章 MySQL事务日志
事务有4种特性:原子性、一致性、隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢?
- 事务的隔离性由
锁机制实现。 - 而事务的原子性、一致性和持久性由事务的 redo 日志和 undo 日志来保证。
- REDO LOG 称为
重做日志,提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性。 - UNDO LOG 称为
回滚日志,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。
- REDO LOG 称为
有的DBA或许会认为 UNDO 是 REDO 的逆过程,其实不然。REDO和UNDO都可以视为是一种恢复操作,但是:
- redo log: 是存储引擎层 (innodb) 生成的日志,记录的是
"物理级别"上的页修改操作,比如页号xxx,偏移量yyy写入了’zzz’数据。主要为了保证数据的可靠性。 - undo log: 是存储引擎层 (innodb) 生成的日志,记录的是
逻辑操作日志,比如对某一行数据进行了INSERT语句操作,那么undo log就记录一条与之相反的DELETE操作。主要用于事务的回滚(undo log 记录的是每个修改操作的逆操作) 和一致性非锁定读(undo log 回滚行记录到某种特定的版本——MVCC,即多版本并发控制)。
Redo日志保证提交操作
Undo日志保证中止回滚操作
1. Redo日志
InnoDB存储引擎是以页为单位来管理存储空间的。在真正访问页面之前需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。所有的变更都必须先更新缓冲池中的数据,然后缓冲池中的脏页会以一定的频率被刷入磁盘(checkPoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就可以保证整体的性能不会下降太快。
1.1 为什么需要Redo日志
一方面,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint并不是每次变更的时候就触发的,而是master线程隔一段时间去处理的。所以最坏的情况就是事务提交后,刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。
另一方面,事务包含持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。
那么如何保证这个持久性呢?一个简单的做法:在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题
-
修改量与刷新磁盘工作量严重不成比例
有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太小题大做了。
-
随机IO刷新较慢
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面
刷新到磁盘时需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。
另一个解决的思路:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。比如,某个事务将系统表空间中第10号页面中偏移量为100处的那个字节的值1改成2。我们只需要记录一下:将第0号表空间的10号页面的偏移量为100处的值更新为2。
InnoDB引擎的事务采用了WAL技术(Write-Ahead Logging),这种技术的思想就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log。当发生宕机且数据未刷到磁盘的时候,可以通过redo log来恢复,保证ACID中的D,这就是redo log的作用。

1.2 Redo日志的好处、特点
1)好处
- redo日志降低了刷盘频率
- redo日志占用的空间非常小
存储表空间ID、页号、偏移量以及需要更新的值,所需的存储空间是很小的,刷盘快。
2)特点
- redo日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO,效率比随机IO快。
- 事务执行过程中,redo log不断记录
redo log跟bin log的区别,redo log是存储引擎层产生的,而bin log是数据库层产生的。假设一个事务,对表做10万行的记录插入,在这个过程中,一直不断的往redo log顺序记录,而bin log不会记录,直到这个事务提交,才会一次写入到bin log文件中。
1.3 redo的组成
Redo log可以简单分为以下两个部分:
重做日志的缓冲 (redo log buffer),保存在内存中,是易失的。
在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区。这片内存空间被划分成若干个连续的redo log block。一个redo log block占用512字节大小。

参数设置:innodb_log_buffer_size:
redo log buffer 大小,默认16M,最大值是4096M,最小值为1M。
1 | mysql> show variables like '%innodb_log_buffer_size%'; |
重做日志文件 (redo log file),保存在硬盘中,是持久的。
REDO日志文件默认存储在MySQL数据目录下/var/lib/mysql,其中的ib_logfile0和ib_logfile1即为redo log日志。
重做日志文件,大小固定,在没有数据写入的情况下,会将空间,提前开辟出来
1.4 redo的整体流程
以一个更新事务为例,redo log流转过程,如下图所示:

1 | 第1步:先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝 |
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 | show variables like 'innodb_flush_log_at_trx_commit'; |
InnoDB存储引擎有一个后台线程,每隔1秒,就会把redo log buffer中的内容写到文件系统缓存(page cache),然后调用刷盘操作。

也就是说,一个没有提交事务的redo log记录,也可能会刷盘。因为在事务执行过程redo log记录是会写入redo log buffer 中,这些redo log记录会被后台线程刷盘。

除了后台线程每秒1次的轮询操作,还有一种情况,当redo log buffer占用的空间即将达到innodb_log_buffer_size(这个参数默认是16M)的一半的时候,后台线程会主动刷盘。
1.6 不同刷盘策略演示
1)流程图

小结: innodb_flush_log_at_trx_commit=1
为1时,只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。
如果事务执行期间MySQL挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。可以保证ACID的D,数据绝对不会丢失,但是效率最差的。
建议使用默认值,虽然操作系统宕机的概率理论小于数据库宕机的概率,但是一般既然使用了事务,那么数据的安全相对来说更重要些。

小结: innodb_flush_log_at_trx_commit=2
为2时,只要事务提交成功,
redo log buffer中的内容只写入文件系统缓存(page cache) 。如果仅仅只是
MySQL挂了不会有任何数据丢失,但是操作系统宕机可能会有1秒数据的丢失,这种情况下无法满足ACID中的D。但是数值2肯定是效率最高的

小结:innodb_flush_log_at_trx_commit=0
为
0时,master thread中每1秒进行一次重做日志的fsync操作,因此实例crash最多丢失1秒钟内的事务。
(master thread是负责将缓冲池中的数据异步刷新到磁盘,保证数据的一致性)数值0的话,是一种折中的做法,它的IO效率理论上高于1的,低于2的,这种策略也有丢失数据的风险,也无法保证D。
2)举例
比较 innodb_flush_log_at_trx_commit 对事务的影响。
1 | CREATE TABLE test_load( |
创建存储过程,用于向test_load中添加数据
1 | DELIMITER // |
存储过程代码中,每插入一条数据就进行一次显式的COMMIT操作。在默认的设置下,即参数
innodb_flush_log_at_trx_commit为1的情况下,InnoDB存储引擎将会将重做日志缓冲中的日志写入文件,并调用一次fsync操作。
执行命令CALL p_load(30000),向表中插入3万行的记录,并执行3万次的fsync操作。在默认情况下所需的时间:
1 | SET GLOBAL innodb_flush_log_at_trx_commit = 1; |
1 | CALL p_load(30000); # 2 min 10.26 sec |
造成时间比较长的原因就在于fsync操作所需的时间
修改参数innodb_flush_log_at_trx_commit,设置0和2,分别测试执行存储过程所需的时间
1 | TRUNCATE TABLE test_load; |
1 | SET GLOBAL innodb_flush_log_at_trx_commit = 0; |
1 | SET GLOBAL innodb_flush_log_at_trx_commit = 2; |
执行时间明显下降,原因是大大减少了fsync的次数,从而提高了数据库执行的性能
| innodb_flush_log_at_trx_commit | 执行所用的时间 |
|---|---|
| 0 | 1 min 4.31 sec |
| 1 | 2 min 10.26 sec |
| 2 | 1 min 5.82 sec |
而针对此存储过程,为了提高事务的提交性能,应该在将 3 万行记录插入表后进行一次的 COMMIT 操作,而不是每插入一条记录后进行一次 COMMIT 操作。这样做的好处是可以使事务方法在rollback时回滚到事务最开始的确定状态。
虽然可以通过设置参数 innodb_flush_log_at_trx_commit 为 0 或 2 来提高事务提交的性能,但需清楚,这种设置方法丧失了事务的 ACID 特性。
1.7 写入redo log buffer过程
1)补充概念:Mini-Transaction
MySQL把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr,比如,向某个索引对应的B+树中插入一条记录的过程就是一个Mini-Transaction。一个所谓的mtr可以包含一组redo日志,在进行崩溃恢复时这一组redo日志作为一个不可分割的整体。
一个事务可以包含若干条语句,每一条语句其实是由若干个 mtr 组成,每一个 mtr 又可以包含若干条redo日志,画个图表示它们的关系就是这样:
2)redo日志写入log buffer
向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以InnoDB的设计者特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写入到log buffer中的哪个位置,如图所示:

一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就将其插入到log buffer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer中。我们现在假设有两个名为T1、T2的事务,每个事务都包含2个mtr,我们给这几个mtr命名一下:
- 事务
T1的两个mtr分别称为mtr_T1_1和mtr_T1_2。 - 事务
T2的两个mtr分别称为mtr_T2_1和mtr_T2_2。
每个mtr都会产生一组redo日志,用示意图来描述一下这些mtr产生的日志情况:
不同的事务可能是并发执行的,所以事务T1、事务T2之间的mtr可能是交替执行的。每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):

有的 mtr 产生的 redo 日志量非常大,比如 mtr_t1_2 产生的 redo 日志占用空间比较大,占用了3个 block来存储。
3)redo log block的结构图
一个redo log block是由日志头、日志体、日志尾组成。日志头占用12字节,日志尾占用8字节,所以一个block真正能存储的数据就是512-12-8=492字节。
为什么一个block设计成512字节?
这个和磁盘的扇区有关,机械磁盘默认的扇区就是512字节,如果你要写入的数据大于512字节,那么要写入的扇区肯定不止一个,这时就要涉及到盘片的转动,找到下一个扇区,假设现在需要写入两个扇区A和B,如果扇区A写入成功,而扇区B写入失败,那么就会出现
非原子性的写入,而如果每次只写入和扇区的大小一样的512字节,那么每次的写入都是原子性的
真正的redo日志都是存储到占用496字节大小的log block body中,图中的log block header和logblock trailer存储的是一些管理信息。我们来看看这些所谓的管理信息都有什么。

log block header的属性分别如下:
-
LOG_BLOCK_HDR_NO:log buffer 是由 log block 组成,在内部 log buffer 就好似一个数组,因此 LOG_BLOCK_HDR_NO 用来标记这个数组中的位置。其是递增并且循环使用的,占用4个字节,但是由于第一个位用来判断是否是flush bit,所以最大值为2G。
-
LOG_BLOCK_HDR_DATA_LEN:表示 block 中已经使用了多少字节,初始值为 12(因为 log block body 从第 12 个字节处开始)。随着 block 中写入的 redo 日志越来越多,本属性值也跟着增长。如果 log block body 已经被全部写满,那么本属性的值被设置为 512。
-
LOG_BLOCK_FIRST_REC_GROUP:一条 redo 日志也可以称之为一条 redo 日志记录(redo log record),一个 mtr 会生产多条 redo 日志记录,这些 redo 日志记录被称之为一个 redo 日志记录组(redo log record group)。LOG_BLOCK_FIRST_REC_GROUP 就代表该 block 中第一个 mtr 生成的 redo 日志记录组的偏移量(其实也就是这个 block 里第一个 mtr 生成的第一条 redo 日志的偏移量)。如果该值的大小和 LOG_BLOCK_HDR_DATA_LEN 相同,则表示当前 log block 不包含新的日志。
-
LOG_BLOCK_CHECKPOINT_NO:占用 4 字节,表示该 log block 最后被写入时的 checkpoint。
log block trailer中属性的意思如下:LOG_BLOCK_CHECKSUM:表示 block 的校验值,用于正确性校验(其值和 LOG_BLOCK_HDR_NO 相同),我们暂时不关心它。
1.8 redo log file
1)相关参数设置
-
innodb_log_group_home_dir:指定 redo log 文件组所在的路径,默认值为./,表示在数据库的数据目录下。MySQL的默认数据目录(var/lib/mysql)下默认有两个名为ib_logfile0和ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。此redo日志文件位置还可以修改。 -
innodb_log_files_in_group:指明redo log file的个数,命名方式如:ib_logfile0,ib_logfile1… ib_logfilen。默认2个,最大100个。
1
2
3
4
5
6
7
8mysql> show variables like 'innodb_log_files_in_group';
+---------------------------+-------+
| Variable_name | Value |
+---------------------------+-------+
| innodb_log_files_in_group | 2 |
+---------------------------+-------+
#ib_logfile0
#ib_logfile1 -
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。1
2
3
4
5
6mysql> show variables like 'innodb_log_file_size';
+----------------------+----------+
| Variable_name | Value |
+----------------------+----------+
| innodb_log_file_size | 50331648 |
+----------------------+----------+根据业务修改其大小,以便容纳较大的事务。编辑my.cnf文件并重启数据库生效,如下所示
1
2[root@localhost ~]# vim /etc/my.cnf
innodb_log_file_size=200M在数据库实例更新比较频繁的情况下,可以适当加大 redo log 组数和大小。但也不推荐 redo log 设置过大,在MySQL崩溃恢复时会重新执行REDO日志中的记录。
2)日志文件组
从上边的描述中可以看到,磁盘上的redo日志文件不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile[数字](数字可以是0、1、2…)的形式进行命名,每个的redo日志文件大小都是一样的。
在将redo日志写入日志文件组时,是从ib_logfile0开始写,如果ib_logfile0写满了,就接着ib_logfile1写。同理,ib_logfile1.写满了就去写ib_logfile2,依此类推。如果写到最后一个文件该咋办?那就重新转到ib_logfile0继续写,所以整个过程如下图所示:

总共的redo日志文件大小其实就是: innodb_log_file_size × innodb_log_files_in_group 。
采用循环使用的方式向redo日志文件组里写数据的话,会导致后写入的redo日志覆盖掉前边写的redo日志?当然!所以InnoDB的设计者提出了checkpoint的概念。
3)checkpoint
在整个日志文件组中还有两个重要的属性,分别是write pos、checkpoint
write pos是当前记录的位置,一边写一边后移checkpoint是当前要擦除的位置,也是往后推移
每次刷盘redo log记录到日志文件组中,write pos位置就会后移更新。每次MySQL加载日志文件组恢复数据时,会清空加载过的redo log记录,并把 checkpoint后移更新。write pos和checkpoint之间的还空着的部分可以用来写入新的redo log记录。
如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。
1.9 redo log小结
相信大家都知道redo log的作用和它的刷盘时机、存储形式:
InnoDB的更新操作采用的是Write Ahead Log(预先日志持久化)策略,即先写日志,再写入磁盘。
2. Undo日志
redo log是事务持久性的保证,undo log是事务原子性的保证。在事务中更新数据的前置操作其实是要先写入一个undo log。
2.1 如何理解Undo日志
事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但有时候事务执行到一半会出现一些情况,比如:
- 情况一:事务执行过程中可能遇到各种错误,比如
服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。 - 情况二:程序员可以在事务执行过程中手动输入
ROLLBACK语句结束当前事务的执行。
以上情况出现,我们需要把数据改回原先的样子,这个过程称之为回滚,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。
每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要"留一手" — —把回滚时所需的东西记下来。比如:
-
你
插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。(对于每个INSERT, InnoDB存储引擎会完成一个DELETE) -
你
删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。(对于每个DELETE,InnoDB存储引擎会执行一个INSERT) -
你
修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。(对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改前的行放回去)
MySQL把这些为了回滚而记录的这些内容称之为撤销日志或者回滚日志(即undo log)。注意,由于查询操作( SELECT)并不会修改任何用户记录,所以在查询操作行时,并不需要记录相应的undo日志
此外,undo log 会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护
2.2 Undo日志的作用
- 作用1:回滚数据
用户对undo日志可能有误解: undo用于将数据库物理地恢复到执行语句或事务之前的样子。但事实并非如此。undo是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。
这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。
- 作用2:MVCC(详情看第16章)
undo的另一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。
2.3 undo的存储结构
1)回滚段与undo页
InnoDB对undo log的管理采用段的方式,也就是回滚段(rollback segment)。每个回滚段记录了1024个undo log segment,而在每个undo log segment段中进行undo页的申请。
-
在
InnoDB1.1版本之前(不包括1.1版本),只有一个rollback segment,因此支持同时在线的事务限制为1024。虽然对绝大多数的应用来说都已经够用。 -
从1.1版本开始InnoDB支持最大
128个rollback segment,故其支持同时在线的事务限制提高到了128*10241
2
3
4
5
6mysql> show variables like 'innodb_undo_logs';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| innodb_undo_logs | 128 |
+------------------+-------+
虽然InnoDB1.1版本支持了128个rollback segment,但是这些rollback segment都存储于共享表空间ibdata中。从lnnoDB1.2版本开始,可通过参数对rollback segment做进一步的设置。这些参数包括:
innodb_undo_directory:设置rollback segment文件所在的路径。这意味着rollback segment可以存放在共享表空间以外的位置,即可以设置为独立表空间。该参数的默认值为“./”,表示当前InnoDB存储引擎的目录。innodb_undo_logs:设置rollback segment的个数,默认值为128。在InnoDB1.2版本中,该参数用来替换之前版本的参数innodb_rollback_segments。innodb_undo_tablespaces: 设置构成rollback segment文件的数量,这样rollback segment可以较为平均地分布在多个文件中。设置该参数后,会在路径innodb_undo_directory看到undo为前缀的文件,该文件就代表rollback segment文件。
undo log 相关参数一般很少改动
undo页的重用
当我们开启一个事务需要写undo log的时候,就得先去undo log segment中去找到一个空闲的位置,当有空位的时候,就去申请undo页,在这个申请到的undo页中进行undo log的写入。我们知道mysql默认一页的大小是16k。
为每一个事务分配一个页,是非常浪费的(除非你的事务非常长),假设你的应用的TPS(每秒处理的事务数目)为1000,那么1s就需要1000个页,大概需要16M的存储,1分钟大概需要1G的存储。如果照这样下去除非MySQL清理的非常勤快,否则随着时间的推移,磁盘空间会增长的非常快,而且很多空间都是浪费的。
于是undo页就被设计的可以重用了,当事务提交时,并不会立刻删除undo页。因为重用,所以这个undo页可能混杂着其他事务的undo log。undo log在commit后,会被放到一个链表中,然后判断undo页的使用空间是否小于3/4,如果小于3/4的话,则表示当前的undo页可以被重用,那么它就不会被回收,其他事务的undo log可以记录在当前undo页的后面。由于undo log是离散的,所以清理对应的磁盘空间时,效率不高。

2)回滚段与事务
每个事务只会使用一个回滚段,一个回滚段在同一时刻可能会服务于多个事务。
当一个事务开始的时候,会制定一个回滚段,在事务进行的过程中,当数据被修改时,原始的数据会被复制到回滚段。
在回滚段中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前的盘区不够用,事务会在段中请求扩展下一个盘区,如果所有已分配的盘区都被用完,事务会覆盖最初的盘区或者在回滚段允许的情况下扩展新的盘区来使用。
回滚段存在于undo表空间中,在数据库中可以存在多个undo表空间,但同一时刻只能使用一个undo表空间。
1 | mysql> show variables like 'innodb_undo_tablespaces'; |
当事务提交时,InnoDB存储引擎会做以下两件事情:
- 将undo log放入列表中,以供之后的purge操作
- 判断undo log所在的页是否可以重用,若可以分配给下个事务使用
3)回滚段中的数据分类
-
未提交的回滚数据(uncommitted undo information)该数据所关联的事务并未提交,用于实现读一致性,所以该数据不能被其他事务的数据覆盖。 -
已经提交但未过期的回滚数据(committed undo information)该数据关联的事务已经提交,但是仍受到undo retention参数的保持时间的影响。
-
事务已经提交并过期的数据(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的生命周期
1)简要生成过程
以下是undo+redo事务的简化过程
假设有2个数值,分别为A=1和B=2,然后将A修改为3,B修改为4
1 | 1. start transaction; |
在1-8步骤的任意一步系统宕机,事务未提交,该事务就不会对磁盘上的数据做任何影响。
如果在8-9之间宕机。
- redo log 进行恢复
- undo log 发现有事务没完成进行回滚。
若在9之后系统宕机,内存映射中变更的数据还来不及刷回磁盘,那么系统恢复之后,可以根据redo log把数据刷回磁盘。
只有Buffer Pool的流程:

有了Redo Log和Undo Log之后:

在更新Buffer Pool中的数据之前,我们需要先将该数据事务开始之前的状态写入Undo Log中。假设更新到一半出错了,我们就可以通过Undo Log来回滚到事务开始前。
2)详细生成过程
对于InnoDB引擎来说,每个行记录除了记录本身的数据之外,还有几个隐藏的列:
-
DB_ROW_ID: 如果没有为表显式的定义主键,并且表中也没有定义唯一索引,那么InnoDB会自动为表添加一个row_id的隐藏列作为主键。 -
DB_TRX_ID︰每个事务都会分配一个事务ID,当对某条记录发生变更时,就会将这个事务的事务ID写入trx_id中。 -
DB_ROLL_PTR:回滚指针,本质上就是指向undo log的指针。
当我们执行INSERT时:
1 | begin; |
插入的数据都会生成一条insert undo log,并且数据的回滚指针会指向它。undo log会记录undo log的序号、插入主键的列和值…,那么在进行rollback的时候,通过主键直接把对应的数据删除即可。

当我们执行UPDATE时:
对于更新的操作会产生update undo log,并且会分更新主键的和不更新主键的,假设现在执行:
1 | UPDATE user SET name= "Sun" WHERE id=1; |

这时会把老的记录写入新的undo log,让回滚指针指向新的undo log,它的undo no是1,并且新的undo log会指向老的undo log (undo no=0)。
假设现在执行:
1 | UPDATE user SET id=2 WHERE id=1; |

对于更新主键的操作,会先把原来的数据deletemark标识打开,这时并没有真正的删除数据,真正的删除会交给清理线程去判断,然后在后面插入一条新的数据,新的数据也会产生undo log,并且undo log的序号会递增。
可以发现每次对数据的变更都会产生一个undo log,当一条记录被变更多次时,那么就会产生多条undo log,undo log记录的是变更前的日志,并且每个undo log的序号是递增的,那么当要回滚的时候,按照序号依次向前推,就可以找到我们的原始数据了。
3)undo log是如何回滚的
以上面的例子来说,假设执行rollback,那么对应的流程应该是这样:
通过undo no=3的日志把id=2的数据删除
通过undo no=2的日志把id=1的数据的deletemark还原成0
通过undo no=1的日志把id=1的数据的name还原成Tom
通过undo no=0的日志把id=1的数据删除
4)undo log的删除
- 针对于insert undo log
因为insert操作的记录,只对事务本身可见,对其他事务不可见。故该undo log可以在事务提交后直接删除,不需要进行purge操作。
- 针对于update undo log
该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。
补充:
purge线程两个主要作用是:
清理undo页和清除page里面带有Delete_Bit标识的数据行。在InnoDB中,事务中的Delete操作实际上并不是真正的删除掉数据行,而是一种Delete Mark操作,在记录上标识Delete_Bit,而不删除记录。是一种"假删除"只是做了个标记,真正的删除工作需要后台purge线程去完成。
2.6 小结
undo log是逻辑日志,对事务回滚时,只是将数据库逻辑地恢复到原来的样子。
redo log是物理日志,记录的是数据页的物理变化,undo log不是redo log的逆过程。
第15章 锁
事务的隔离性由这章讲述的锁来实现。
1. 概述
锁是计算机协调多个进程或线程并发访问某一资源的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程在访问,保证数据的完整性和一致性。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域中同样很重要。
在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制,因此产生了锁。同时锁机制也为实现MySQL的各个隔离级别提供了保证。锁冲突也是影响数据库并发访问性能的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。
2. MySQL并发事务访问相同记录
并发事务访问相同记录的情况大致可以划分为3种:
2.1 读-读情况
读-读情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。
2.2 写-写情况
写-写情况,即并发事务相继对相同的记录做出改动。
在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的,如图所示:
graph TD
A[这是一条记录]
style A fill:#bbdefb,stroke:#000000,stroke-width:1px;
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比如,事务T1要对这条记录做改动,就需要生成一个锁结构与之关联:
graph LR
A[这是一条记录] --- B[锁结构
trx信息: T1
is_waiting: false]
style A fill:#bbdefb,stroke:#000000,stroke-width:1px;
style B fill:#ffcdd2,stroke:#000000,stroke-width:1px;
classDef default font-family:Consolas;
在锁结构里有很多信息,为了简化理解,只把两个比较重要的属性拿了出来:
-
trx信息:代表这个锁结构是哪个事务生成的。 -
is_waiting:代表当前事务是否在等待。
当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true ,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,图示:
graph LR
A[这是一条记录] ---|获取锁成功,事务继续运行| B[锁结构
trx信息: T1
is_waiting: false]
A ---|获取锁失败,事务开始等待| C[锁结构
trx信息: T2
is_waiting: true]
style A fill:#bbdefb,stroke:#000000,stroke-width:1px;
style B fill:#ffcdd2,stroke:#000000,stroke-width:1px;
style C fill:#ffcdd2,stroke:#000000,stroke-width:1px;
classDef default font-family:Consolas;
在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:
graph LR
A[这是一条记录] --- B[锁结构
trx信息: T2
is_waiting: false]
style A fill:#bbdefb,stroke:#000000,stroke-width:1px;
style B fill:#ffcdd2,stroke:#000000,stroke-width:1px;
classDef default font-family:Consolas;
小结几种说法:
-
不加锁
意思就是不需要在内存中生成对应的
锁结构,可以直接执行操作。 -
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的
锁结构,而且锁结构的is_waiting属性为false,也就是事务
可以继续执行操作。 -
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的锁结构,不过锁结构的
is_waiting属性为true,也就是事务
需要等待,不可以继续执行操作。
2.3 读-写或写-读情况
读-写或写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读、不可重复读、幻读的问题。
各个数据库厂商对SQL标准的支持都可能不一样。比如MySQL在REPEATABLE READ隔离级别上就已经解决了幻读问题。
2.4 并发问题的解决方案
怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:
- 方案一:读操作利用多版本并发控制(
MVCC,下章讲解),写操作进行加锁。
所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo日志构建)。查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。
普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。
- 在
READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;- 在
REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这ReadView,这样也就避免了不可重复读和幻读的问题。
- 方案二:读、写操作都采用
加锁的方式。
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。
脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。
不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。
幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)。
- 小结对比发现:
- 采用
MVCC方式的话,读-写操作彼此并不冲突,性能更高。 - 采用
加锁方式的话,读-写操作彼此需要排队执行,影响性能。
- 采用
一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。
针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读。因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,查询不出来这条数据的,所以就很好了避免幻读问题。
针对当前读(select … for update 等语句),是通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读。因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
3. 锁的不同角度分类
锁的分类图,如下:

3.1 从数据操作的类型划分:读锁、写锁
对于数据库中并发事务的读-读情况并不会引起什么问题。对于写-写、读-写或写-读这些情况可能会引起一些问题,需要使用MVCC或者加锁的方式来解决它们。在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以MySQL实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为共享锁(Shared Lock,SLock)和排他锁(Exclusive Lock,XLock),也叫读锁(readlock)和写锁(write lock)。
读锁:也称为共享锁、英文用S表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。写锁:也称为排他锁、英文用X表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。
举例(行级读写锁)∶如果一个事务T1已经获得了某个行r的读锁,那么此时另外的一个事务T2是可以去获得这个行r的读锁的,因为读取操作并没有改变行r的数据;但是,如果某个事务T3想获得行r的写锁,则它必须等待事务T1、T2释放掉行r上的读锁才行。
总结:这里的兼容是指对同一张表或记录的锁的兼容性情况。
| X锁 | S锁 | |
|---|---|---|
| X锁 | 不兼容 | 不兼容 |
| S锁 | 不兼容 | 兼容 |
1)锁定读
在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取该记录的S锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:
- 对读取的记录加
S锁:
1 | SELECT ... LOCK IN SHARE MODE; |
在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT ... LOCK IN SHAREMODE语句来读取这些记录),但是不能获取这些记录的X锁(比如使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。
- 对读取的记录加
X锁:
1 | SELECT ... FOR UPDATE; |
在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比如使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
MySQL8.0新特性:NOWAIT、SKIP LOCKED
在5.7及之前的版本,SELECT ... FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。在8.0版本中,SELECT ... FOR UPDATE, SELECT ... FOR SHARE 可以通过添加NOWAIT、SKIP LOCKED语法,跳过锁等待,或者跳过锁定。
- NOWAIT:如果查询的行已经加锁,会立即报错返回
- SKIP LOCKED:如果查询的行已经加锁,只返回结果中不包含被锁定的行
1 | # session1: |
2)写操作
平常所用到的写操作无非是DELETE、UPDATE、INSERT这三种:
-
DELETE:
对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。 -
UPDATE∶在对一条记录做UPDATE操作时分为三种情况:-
情况1: 未修改该记录的
键值,并且被更新的列占用的存储空间在修改前后未发生变化。则先在
B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。 -
情况2∶未修改该记录的
键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程可以看成是一个获取
X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。 -
情况3∶修改了该记录的键值,则相当于在原记录上做
DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。
-
-
INSERT:一般情况下,新插入一条记录的操作并不加锁,通过一种称之为
隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。
3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作)(越小消耗越大)。因此数据库系统需要在并发响应和系统性能两方面进行平衡,这样就产生了“锁粒度(Lock granularity)”的概念。
对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁。
3.2.1 表锁(Table Lock)
该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎,并且表锁是开销最少的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁的问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣。
① 表级别的S锁、X锁
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。在对某个表执行一些诸如ALTER TABLE、DROP 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存储引擎才能识别MySQL添加的表级锁,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写:
-
LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。 -
LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。
不过尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是在于实现了更细粒度的行锁,关于InnoDB表级别的S锁和X锁了解一下就可以了。
举例:下面我们讲解MyISAM引擎下的表锁。
步骤1:创建表并添加数据
1 | CREATE TABLE mylock( |
步骤2:查看表上加过的锁
1 | SHOW OPEN TABLES; # 主要关注 In_use 字段的值 |
步骤3:手动增加表锁命令
1 | LOCK TABLES t READ; # 存储引擎会对表t加表级别的共享锁。共享锁也叫读锁或 S 锁 (Share的缩写) |
比如:
1 | mysql> lock tables mylock write; |
步骤4:释放表锁
1 | UNLOCK TABLES; # 使用此命令解锁当前加锁的表 |
比如:
1 | mysql> unlock tables; |
步骤5:加读锁
我们为mylock表加上read锁(读阻塞写),观察阻塞的情况,流程如下:

步骤6:加写锁
为mylock表加上write锁,观察阻塞的情况,流程如下:

MyISAM存储引擎的表在执行查询语句前,会自动给涉及的所有表加读锁,在执行增删改操作前,会自动给涉及的表加写锁。用户一般不需要直接使用LOCK TABLES语句来显式加锁。
InnoDB存储引擎是不会为这个表添加表级别的读锁或者写锁的。
MySQL的表级锁有两种模式:(以MyISAM表进行操作的演示)
- 表共享读锁(Table Read Lock)
- 表独占写锁(Table Write Lock)
| 锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
|---|---|---|---|---|---|
| 读锁 | 是 | 否 | 否 | 是 | 否,等 |
| 写锁 | 是 | 是 | 否 | 否,等 | 否,等 |
② 意向锁 (intention lock)
InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁与表级锁共存,而意向锁就是其中的一种表锁。
-
意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。
-
意向锁是一种
不与行级锁冲突的表级锁,这一点非常重要。 -
表明“某个事务正在某些行持有了锁或该事务准备去持有锁”
意向锁分为两种:
- 意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)
1 | -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 |
- 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)
1 | -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。 |
即:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在数据表的对应意向锁。
意向锁要解决的问题
现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级级别的空间示意里面是否已经上过锁。
在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排它锁即可。
- 如果事务想要获取数据表中某些记录的共享锁,就需要在数据表上
添加意向共享锁 - 如果事务想要获取数据表中某些记录的排它锁,就需要在数据表上
添加意向排他锁
这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。
**举例:**创建表teacher,插入6条数据,事务的隔离级别默认为Repeatable-Read,如下所示。
1 | CREATE TABLE `teacher` ( |
假设事务A获取了某一行的排他锁,并未提交,语句如下所示。
1 | begin ; |
事务B想要获取teacher 表的表读锁,语句如下。
1 | begin; |
因为共享锁与排他锁互斥,所以事务B在试图对teacher表加共享锁的时候,必须保证两个条件。
-
当前没有其他事务持有teacher表的排他锁
-
当前没有其他事务持有teacher表中任意一行的排他锁。
为了检测是否满足第二个条件,事务B必须在确保teacher表不存在任何排他锁的前提下,去检测表中的每一行是否存在排他锁。很明显这是一个效率很差的做法,但是有了意向锁之后,情况就不一样了。
意向锁是怎么解决这个问题的呢?首先,我们需要知道意向锁之间的兼容互斥性,如下所示。
| 意向共享锁(lS) | 意向排他锁(IX) | |
|---|---|---|
| 意向共享锁(IS) | 兼容 | 兼容 |
| 意向排他锁(IX) | 兼容 | 兼容 |
即意向锁之间是互相兼容的,虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥
| 意向共享锁(lS) | 意向排他锁(IX) | |
|---|---|---|
| 共享锁(S)表 | 兼容 | 互斥 |
| 排他锁(X)表 | 互斥 | 互斥 |
注意这里的排他/共享锁指的都是表锁,意向锁不会与行级的共享/排他锁互斥。回到刚才teacher表的例子。
事务A获取了某一行的排他锁,并未提交:
1 | # 事务A |
此时teacher表存在两把锁: teacher表上的意向排他锁与id为6的数据行上的排他锁。事务B想要获取teacher表的共享锁。
1 | # 事务B |
此时事务B检测事务A持有teacher表的意向排他锁,就可以得知事务A必然持有该表中某些数据行的排他锁,那么事务B对teacher表的加锁请求就会被排斥(阻塞),而无需去检测表中的每一行数据是否存在排他锁。
意向锁的并发性
意向锁不会与行级的共享/排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。(不然我们直接用普通的表锁就行了)
我们扩展一下上面 teacher表的例子来概括一下意向锁的作用(一条数据从被锁定到被释放的过程中,可能存在多种不同锁,但是这里我们只着重表现意向锁)。
事务 A 先获取了一行的排他锁,并未提交:
1 | BEGIN; |
事务 A 获取了 teacher 表上的意向排他锁,事务 A 获取了 id 为 6 的数据行上的排他锁。之后事务 B 想要获取 teacher 表的共享锁。
1 | BEGIN; |
事务 B 检测到事务 A 持有 teacher 表的意向排他锁。事务 B 对 teacher 表的加锁请求被阻塞(排斥)。最后事务 C 也想获取 teacher 表中某一行的排他锁。
1 | BEGIN; |
事务 C 申请 teacher 表的意向排他锁。事务C 检测到事务A 持有 teacher 表的意向排他锁。因为意向锁之间并不互斥,所以事务C 获取到了 teacher 表的意向排他锁。因为id为5的数据行上不存在任何排他锁,最终事务C 成功获取到了该数据行上的排他锁。
从上面的案例可以得到如下结论:
InnoDB 支持多粒度锁,特定场景下,行级锁可以与表级锁共存。
意向锁之间互不排斥,但除了IS与S兼容外,意向锁会与 共享锁 / 排他锁 互斥。
IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。
意向锁在保证并发性的前提下,实现了行锁和表锁共存且满足事务隔离性的要求。
③ 自增锁(AUTO-INC锁)
在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性。举例:
1 | CREATE TABLE `teacher` ( |
由于这个表的id字段声明了AUTO_INCREMENT,意味着在书写插入语句时不需要为其赋值
1 | INSERT INTO `teacher` (name) VALUES ('zhangsan'), ('lisi'); |
上边的插入语句并没有为id列显式赋值,所以系统会自动为它赋上递增的值,结果如下所示。
1 | mysql> select * from teacher; |
现在我们看到的上面插入数据只是一种简单的插入模式,所有插入数据的方式总共分为三类,分别是“Simple inserts”,“Bulk inserts”和“Mixed-mode inserts”。
“Simple inserts”(简单插入)
可以预先确定要插入的行数(当语句被初始处理时)的语句。包括没有嵌套子查询的单行和多行INSERT...VALUES()和REPLACE语句。不包括INSERT...ON DUPLICATE KEY UPDATE语句。
“Bulk inserts” (批量插入)
事先不知道要插入的行数(和所需自动递增值的数量)的语句。比如INSERT ... SELECT,REPLACE ... SELECT和LOAD DATA语句,但不包括纯INSERT。 InnoDB在每处理一行,为AUTO_INCREMENT列分配一个新值。
“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语句中是唯一且单调递增的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号),为任何给定语句插入的行生成的值可能不是连续的。
如果执行的语句是“simple inserts”,其中要插入的行数已提前知道,除了“Mixed-mode inserts"之外,为单个语句生成的数字不会有间隙。然而,当执行“bulk inserts"时,在由任何给定语句分配的自动递增值中可能存在间隙。
④ 元数据锁(MDL锁)
MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。不需要显式使用,在访问一个表的时候会被自动加上。
举例:元数据锁的使用场景模拟
会话A:从表中查询数据
1 | mysql> begin; |
会话B:修改表结构,增加新列
1 | begin; |
会话C:查看当前MySQL的进程
1 | # 查看线程,以及线程等待具体锁的状态 |

通过会话C可以看出会话B被阻塞,这是由于会话A拿到了teacher表的元数据读锁,会话B想申请teacher表的元数据写锁,由于读写锁互斥,会话B需要等待会话A释放元数据锁才能执行。
元数据锁可能带来的问题
| Session A | Session B | Session C |
|---|---|---|
| begin;select * from teacher; | ||
| alter table teacher add age int; | ||
| select * from teacher; |
我们可以看到session A 会对表teacher加一个MDL读锁,之后 session B 要加 MDL写锁 会被blocked,因为 session A 的MDL读锁还没有释放,而session C要在表teacher上新申请 MDL读锁 的请求也会被 session B 阻塞。前面我们说了,所有对表的增删改查操作都需要先申请 MDL 读锁,就都被阻塞,等于这个表现在完全不可读写了。
3.2.2 InnoDB中的行锁
行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。
**优点:**锁定力度小,发生锁冲突概率低,可以实现的并发度高
**缺点:**对于锁的开销比较大,加锁会比较慢,容易出现死锁情况
InnoDB与MyISAM的最大不同有两点:一是支持事务;二是采用了行级锁。
创建学生表student,并插入数据,用于讲解以下行锁
1 | CREATE TABLE student ( |
1 | INSERT INTO student VALUES |
① 记录锁(Record Locks)
记录锁用于锁定某一条记录,官方的类型名称为:LOCK_REC_NOT_GAP。
记录锁仅锁定一条记录,对其他记录没有影响。
记录锁是有S锁和X锁之分的,称之为S型记录锁和X型记录锁。
- 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
- 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。

设置非自动提交事务;在会话1中更新id=1的记录但不提交;
在会话2中更新id=3的记录,没有被阻塞。
在会话2中更新id=1的记录,被阻塞,超时后再次发出更新请求,此时会话1提交事务,会发现会话2中的语句解除了阻塞。这说明只有id=1的记录被锁定了,也就是只锁定了一条记录。
② 间隙锁(Gap Locks)
MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁。
**间隙锁用于锁定索引记录之间的间隙,**其主要目的是为了防止在当前事务执行期间有其他事务在这些间隙中插入新的记录,从而维持事务的一致性。
举例
表student中存在 (Infimum,1]、(1,3]、(3,8]、(8,15]、(15,20] 和(20,Supremum)等间隙,其中, Infimum 表示该页面中的最小记录,Supremum 表示该页面中的最大记录。
给 id=8 的记录加间隙锁的示意图如图所示,锁定区间是(3,8),注意是开区间。

当另外一个事务再想插入一条id值为4的新记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交之后,id列的值在区间(3,8)中的新记录才可以被插入。
1 | BEGIN; |
5 这条记录不存在,所以是间隙锁,锁的是(3,8)这个区间。
如果 5 这条记录存在,则不是间隙锁,而是记录锁。
LOCK_DATA显示的是该间隙的右边界
**gap锁的提出仅仅是为了防止插入幻影记录而提出的。**虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。
| 会话1 session1 | 会话2 session2 |
|---|---|
| BEGIN; SELECT * FROM student WHERE id=5 LOCK IN SHARE MODE; |
|
| BEGIN SELECT * FROM student WHERE id = 7 FOR UPDATE; |
这里 session 2 并不会被堵住。因为表里并没有 id=5 这个记录,因此 session 1 加的是间隙锁(3,8)。而 session 2 也是在这个间隙加的间隙锁。它们有共同的目标,即,保护这个间隙,不允许插入值。但,它们之间是不冲突的。
1 | SELECT * FROM performance_schema.data_locks\G |
实际结果会显示4把锁,两把意向锁 IS IX,两把间隙锁
锁定区间 (最后一条记录,Supremum 记录)
给一条记录加了gap锁后,不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是student表中id值为20的记录之后的间隙。给哪条记录加gap锁才能阻止其他事务插入id值在(20,正无穷)这个区间的新记录呢?这时候我们在讲数据页时介绍的两条伪记录派上用场了:
Infimum记录,表示该页面中最小的记录。Supremum记录,表示该页面中最大的记录。
只要锁定的记录的id值,大于当前数据库最大的id值,就可以锁定该间隙。实现阻止其他事务插入id值在(20,正无穷)这个区间的新记录
1 | BEGIN; |
检测:
1 | SELECT * FROM performance_schema.data_locks\G |
间隙锁的死锁问题
间隙锁的引入,可能会导致同样的语句锁定更大的范围,影响并发度。下面的例子就会产生死锁。
| 会话1 | 会话2 |
|---|---|
| BEGIN; SELECT * FROM student WHERE id =5 FOR UPDATE; |
BEGIN; SELECT * FROM student WHERE id =5 FOR UPDATE; |
| INSERT INTO student VALUES(5,‘test’,‘二班’);#阻塞 | |
INSERT INTO student VALUES(5,‘test’,‘二班’);#阻塞ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
插入语句执行成功 |
- 会话 1 执行
SELECT...FOR UPDATE语句,由于id=5的记录并不存在,因此会加上间隙锁(3,8)。 - 会话 2 执行
SELECT...FOR UPDATE语句,同样会加上间隙锁(3,8)。由于间隙锁之间不会产生冲突,因此这条语句可以执行成功。 - 会话 2 试图插入一条
id=5的记录,被会话 1 的间隙锁阻塞了,只好进入等待状态。 - 会话 1 试图插入一条
id=5的记录,被会话 2 的间隙锁阻塞了,也只好进入等待状态。
至此,两个会话进入互相等待状态,产生了死锁。当然,InnoDB 存储引擎中的死锁检测机制马上就会检测到这对死锁关系,会话 1 中的 INSERT 语句会返回报错信息。会话 2 中的语句返回插入成功的信息。
③ 临键锁(Next-Key Locks)
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。
比如,我们把id值为8的那条记录加一个next-key锁。

next-key锁的本质就是一个记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。
1 | begin; |
④ 插入意向锁(Insert Intention Locks)
我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁(next-key锁也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁。
插入意向锁是在插入一条记录行前,由INSERT操作产生的一种锁,该锁用于表示插入的意向。
插入意向锁是一种声明式的锁。插入意向锁之间互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身 (主键、唯一索引) 不冲突, 那么事务之间就不会出现冲突等待。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
插入意向锁和意向锁
虽然插入意向锁中含有“意向锁”三个字,但它不同于意向锁,意向锁是表锁而插入意向锁是行锁。
插入意向锁一种特殊的间隙锁
插入意向锁一种特殊的间隙锁,用来配合间隙锁或临键锁一起防止幻读操作。
1 | const ulint type_mode = |
在排他(LOCK_X)间隙锁(LOCK_GAP)的基础上增加 LOCK_INSERT_INTENTION 标志,就得到了插入意向锁,所以,从本质上来说,插入意向锁是个特殊的间隙锁。
插入意向锁也不同于普通间隙锁,插入意向锁,是一种声明式的锁,用来告知数据库当前事务打算在某个间隙进行插入,它不阻止其他事务的插入操作。而间隙锁会阻塞其他事务的插入操作。
其他事务的插入操作前,会获取插入意向锁,间隙锁会阻塞插入意向锁的获取。
案例
举例,表 student 中存在id值为3和8的两条记录,两个不同的事务分别试图插入id值为5和6的两条记录, 每个事务在获取插入行上的独占锁前,都会先检查 (3,8) 区间内的间隙锁。如果此时该区间内存在间隙锁, 那么 MySQL会为插入记录的两个事务生成插入意向锁, 并且使这两个事务都处于等待状态,它们都要等待间隙锁被释放才能继续执行插入操作。需要注意的是,因为插入的记录之间并不冲突, 所以两个插入记录的事务之间并不会互相阻塞。
现在为id值为8的记录加的锁的示意图就如下所示:
从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到id值为8的插入意向锁,然后执行插入操作。
1 | ##############################SessionA#################################### |
3.2.3 页锁
页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。
3.3 从对待锁的态度划分:乐观锁、悲观锁
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。
1)悲观锁(Pessimistic Locking)
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改保持保守态度,依靠数据库自身的锁机制来实现,从而保证数据操作的排它性。
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
秒杀案例1
在商品秒杀过程中,库存数量会逐渐减少,要避免出现超卖的情况。比如,商品表中有一个字段为quantity,表示当前该商品的库存量。假设商品为华为mate40,id为1001,quantity=100个。如果不使用锁的情况下,操作方法如下所示
1 | #第1步:查出商品库存 |
这样写的话,在并发量小的公司没有大的问题,但是如果在高并发环境下可能出现以下问题
| 序号 | 线程A | 线程B |
|---|---|---|
| 1 | step1(查询还有100部手机) | step1(查询还有100部手机) |
| 2 | step2(生成订单) | |
| 3 | step2(生成订单) | |
| 4 | step3(减库存1,减100台) | |
| 5 | step3(减库存2,减100台) |
其中线程B此时已经下单并且减完库存,这个时候线程A依然根据它最初查询到的库存(100台)去执行step3,就造成了超卖!
我们使用悲观锁可以解决这个问题,商品信息从查询出来到修改,中间有一个生成订单的过程,使用悲观锁的原理就是,当我们在查询items信息后就把当前的数据锁定,直到我们修改完毕后再解锁。那么整个过程中,因为数据被锁定了,就不会出现有第三者来对其进行修改了。而这样做的前提是需要将要执行的SQL语句放在同一个事务中,否则达不到锁定数据的目的。
修改如下:
1 | #第1步:查出商品库存 |
select .... for update是MySQL中悲观锁。此时在items表中,id为1001的那条数据就被我们锁定了,其他的要执行select quantity from items where id = 1001 for update;语句的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。
注意,当执行
select quantity from items where id = 1001 for update;语句之后,如果在其他事务中执行select quantity from items where id = 1001;语句,并不会受第一个事务的影响,仍然可以正常查询出数据。不过读取的数据可能是已提交的旧值,具体取决于数据库的隔离级别。
[!important]
注意: select … for update语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。
悲观锁不适用的场景较多,它存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问性,同时这样对数据库性能的影响也很大,特别是长事务而言,这样的开销往往无法承受,这时就需要乐观锁。
2)乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在Java中java.util.concurrent.atomic包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。
① 乐观锁的版本号机制
在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
② 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
秒杀案例2
依然使用上面秒杀的案例,执行流程如下
1 | #第1步:查出商品库存 |
注意,如果数据表是读写分离的表,当master表中写入的数据没有及时同步到slave表中时,会造成更新一直失败的问题。此时需要强制读取master表中的数据(即将select语句放到事务中即可,这时候查询的就是master主库了。)
读写分离时,主机负责写,从机负责读
从机读取到旧的版本号version1,但是主机上的版本号,已经更新过了,更新到了version3。
导致更新操作失败,因为version1!=version3
重新到从机读取版本号,因为从机没及时更新数据,又读取到版本号version1,再次更新失败。
如果我们对同一条数据进行频繁的修改的话,那么就会出现这么一种场景,每次修改都只有一个事务能更新成功,在业务感知上面就有大量的失败操作。我们把代码修改如下:
1 | #第1步:查出商品库存 |
这样就会使每次修改都能成功,而且不会出现超卖的现象。
存疑,猜测如下
UPDATE会获取当前记录行的X锁,每次只有一个事务,持有当前记录行的X锁。可以避免超卖。
也就是采取了悲观锁的策略。
3)两种锁的适用场景
从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:
-
乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。 -
悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。
我们把乐观锁和悲观锁总结如下图所示。
3.4 按加锁的方式划分:显式锁、隐式锁
1)隐式锁
一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。
那如果一个事务首先插入了一条记录(此时并没有在内存中存在与该记录关联的锁结构),然后另一个事务:
-
立即使用
SELECT ... LOCK IN SHARE MODE语句读取这条记录,也就是要获取这条记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条记录,也就是要获取这条记录的X锁,怎么办?如果允许这种情况的发生,那么可能产生脏读问题。
-
立即修改这条记录,也就是要获取这条记录的X锁,怎么办?
如果允许这种情况的发生,那么可能产生脏写的问题。
这时候我们前边提到过的事务id又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下:
-
**情景一:**对于聚簇索引记录来说,有一个
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锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁是一种延迟加锁的机制,从而来减少加锁的数量。
隐式锁在实际内存对象中并不含有这个锁信息。只有当产生锁等待时,隐式锁转化为显式锁。
InnoDB 的 insert 操作,对插入的记录不加锁,但是此时如果另一个线程进行当前读,类似以下的用例,session 2会锁等待 session 1,那么这是如何实现的呢?
session 1:
1 | begin; |
session2
1 | begin; |
执行下述语句,输出结果:
1 | # 只执行session1,session2没有执行之前 |
隐式锁的逻辑过程如下:
A. InnoDB的每条记录中都含有一个隐含的trx_id字段,这个字段存在于聚簇索引的B+Tree中。
B. 在操作一条记录前,首先根据记录中的trx_id检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将隐式锁转换为显式锁(就是为该事务添加一个锁)。
C. 检查是否有锁冲突,如果有冲突,创建锁,并设置waiting状态。如果没有冲突不加锁,跳到E。
D. 等待加锁成功,被唤醒,或者超时。
E. 写数据,并将自己的trx_id写入trx_id字段。
2)显式锁
通过特定的语句进行加锁,我们一般称之为显示加锁。例如:
显示加共享锁:
1 | select .... lock in share mode |
显示加排它锁:
1 | select .... for update |
3.5 其它锁之:全局锁
全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份。
全局锁的命令:
1 | Flush tables with read lock |
3.6 其它锁之:死锁
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。
1)概念
两个事务都持有对方需要的锁,并且在等待对方释放锁,并且双方都不会释放自己的锁。
举例:
| 事务1 | 事务2 | |
|---|---|---|
| 1 | start transaction; update account set money=100 where id=1; |
start transaction; |
| 2 | update account set money=100 where id=2; | |
| 3 | update account set money=200 where id=2; | |
| 4 | update account set money=200 where id=1; |
2)产生死锁的必要条件
- 两个或者两个以上事务
- 每个事务都已经持有锁并且申请新的锁
- 锁资源同时只能被同一个事务持有或者不兼容
- 事务之间因为持有锁和申请锁导致彼此循环等待
死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。
3)如何处理死锁
**方式1:**等待,直到超时(innodb_lock_wait_timeout=50s)
即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。这种方法简单有效,在innodb中,参数innodb_lock_wait_timeout用来设置超时时间。
缺点: 对于在线服务来说,这个等待时间往往是无法接受的。
那将此值修改短一些,比如1s,0.1s是否合适?不合适,容易误伤到普通的锁等待。
1 | show variables like 'innodb_lock_wait_timeout'; |
**方式2:**使用死锁检测进行死锁处理
方式1检测死锁太过被动,innodb还提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph 算法都会被触发。
这是一种较为主动的死锁检测机制,要求数据库保存锁的信息链表和事务等待链表两部分信息。
基于这两个信息,可以绘制wait-for graph(等待图)

死锁检测的原理是构建一个以事务为顶点、锁为边的有向图,判断有向图是否存在环,存在即有死锁。
一旦检测到回路,有死锁,这时候InnoDB存储引擎会选择回滚undo量最小的事务,让其他事务继续执行(innodb_deadlock_detect=on 表示开启这个逻辑)。
缺点:每个新的被阻塞的线程,都要判断是不是由于自己加入导致了死锁,这个操作时间复杂度是 O(n)。如果 100个并发线程同时更新同一行,意味着要检测 100 * 100 = 1 万次,1 万个线程就会有 1 千万次检测。
如何解决
-
方式1:关闭死锁检测,但意味着可能会出现大量的超时,会导致业务有损。
-
方式2:控制并发访问的数量。比如在中间件中实现对于相同行的更新,在进入引擎之前排队,这样在InnoDB内部就不会有大量的死锁检测工作。
进一步的思路:
可以考虑通过将一行改成逻辑上的多行来减少锁冲突。比如,连锁超市账户总额的记录,可以考虑放到多条记录上。账户总额等于这多条记录的值的总和。
4)如何避免死锁
- 合理设计索引,使业务 SQL 尽可能通过索引定位更少的行,减少锁竞争。
- 调整业务逻辑 SQL 执行顺序,避免 update/delete 长时间持有锁的 SQL 在事务前面。
- 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。
- 在并发比较高的系统中,不要显式加锁,特别是在事务里显式加锁。如 select … for update 语句,如果是在事务里运行了 start transaction 或设置了 autocommit 等于0,那么就会锁定所查找到的记录。
- 降低隔离级别。如果业务允许,将隔离级别调低也是很好的选择,比如将隔离级别从 RR 调整为 RC,可以避免掉很多因为 gap 锁造成的死锁。
4. 锁的内存结构
对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比如:
1 | # 事务T1 |
理论上创建多个锁结构没问题,但是如果一个事务要获取10000条记录的锁,生成10000个锁结构也太崩溃了!
所以决定对不同记录加锁时,符合下边这些条件的记录会放到一个锁结构中。
- 在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的
InnoDB存储引擎中的锁结构如下:

结构解析:
1)锁所在的事务信息:
不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记录这个事务的信息。
此锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。
2)索引信息
对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。
3)表锁/行锁信息
表锁结构和行锁结构在这个位置的内容是不同的:
-
表锁:记载着是对哪个表加的锁,还有其他的一些信息。
-
行锁:记载了三个重要的信息:
-
Space ID:记录所在表空间。 -
Page Number:记录所在页号。 -
n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。n_bits的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后也不至于重新分配锁结构
-
4)type_mode
这是一个32位的数,被分成了lock_mode、lock_type和rec_lock_type三个部分,如图所示:

- 锁的模式(
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_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waiting为false,也就是当前事务获取锁成功。
5)其他信息
为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。
6) 一堆比特位
如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits属性表示的。InnoDB数据页中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,即一个比特位映射到页内的一条记录。
5. 锁监控
关于MySQL锁的监控,我们一般可以通过检查InnoDB_row_lock等状态变量来分析系统上的行锁的争夺情况
1 | 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_TRX、INNODB_LOCKS和INNODB_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所代替。
- 查询正在被锁阻塞的sql语句。
1 | SELECT * FROM information_schema.INNODB_TRX\G |
- 查询锁等待情况
1 | SELECT * FROM performance_schema.data_lock_waits\G |
1 | # 查看线程,以及线程等待具体锁的状态 |
- 查询锁的情况
1 | # mysql8.0以后 |
MyISAM 的表锁,要通过
performance_schema.metadata_locks查询
data_locks查不到
- 查MyISAM引擎,数据表的锁
1 | SELECT * FROM performance_schema.metadata_locks |
6. 增删改查语句设置的锁(InnoDB)
MySQL 8.4 Reference Manual :: 17.7.3 Locks Set by Different SQL Statements in InnoDB
1 | SELECT * FROM performance_schema.data_locks\G |
6.1 通用的加锁规则
锁定读取、更新或删除操作会在处理SQL语句时对每个扫描到的索引记录设置锁。这与语句中是否包含可以排除该行的 WHERE 条件无关。InnoDB 不记得确切的 WHERE 条件,而仅知道扫描了哪些索引范围。
**加锁的基本单位是临键锁。阻止在记录前的间隙中插入新数据。临键锁是前开后闭区间。**每个被扫描到的索引记录,是加锁区间的右确界。不过,可以显式禁用间隙锁定,这样就不会使用临键锁定。
想象一个前开后闭的区间,一直向右滑动。(上一条索引记录,当前的索引记录]
1)没有使用索引情况
如果SQL语句没有合适的索引,导致 MySQL 必须扫描整个表以处理该语句,那么表的每一行都会被锁定。
如何避免这种事故的发生?
可以将 MySQL 里的 sql_safe_updates 参数设置为 1,开启安全更新模式。
update 语句必须满足如下条件之一才能执行成功:
- 使用 where,并且 where 条件中必须有索引列;
- 使用 limit;
- 同时使用 where 和 limit,此时 where 条件中可以没有索引列;
delete 语句必须满足以下条件才能执行成功:
- 同时使用 where 和 limit,此时 where 条件中可以没有索引列;
如果 where 条件带上了索引列,但是优化器最终扫描选择的是全表,而不是索引的话,我们可以使用 force index([index_name]) 可以告诉优化器使用哪个索引,以此避免有几率锁全表带来的隐患。
2)唯一索引VS非唯一索引,等值查询VS范围查询
锁定读取(SELECT ... FOR UPDATE 或 FOR SHARE)、UPDATE 和 DELETE 语句,所采取的锁取决于where子句中的搜索条件(唯一索引等值查询或范围类型的搜索条件)
-
唯一索引等值查询
-
当查询的记录存在,
next-key lock会退化成记录锁,InnoDB 只锁定找到的索引记录 -
当查询的记录不存在,
next-key lock会退化成间隙锁
-
-
非唯一索引等值查询
会给每条扫描到的记录,加上临键锁
(上一条记录,当前扫描到的记录]。一直向右扫描,直到扫到不相等的记录。不相等的记录上,不加锁。所以表现上来看。-
当查询的记录存在,会给查询到的等值记录,加临键锁,因为非唯一,所以要给区间加锁,所以是临键锁,区间内包含多条相同值。
因为非唯一,所以向右继续扫描,直到扫到不相等记录为止,右区间退化为间隙锁。
- 当查询的记录如果不存在,临键锁退化为间隙锁。
为什么要向右扫描直到遇上不等值记录,然后加间隙锁
- 防止幻读:确保在事务期间,不会有新的
=5记录插入 - 保证可重复读:避免其他事务修改或插入影响查询结果
- 索引覆盖查询优化:如果查询只访问索引列,可能只在索引上加锁
-
-
唯一索引范围查询
先拆分条件,拆出一个等于,比如
>=拆成=和>等值按照之前的规则,如果查询的值存在降级为行锁,查询的值不存在,降级为间隙锁
不等值查询,碰到第一个不满足的记录时,降级为间隙锁。
唯一索引范围查询加锁规则
1. 规则概述
唯一索引范围查询的加锁行为是:
- 等值条件:按照唯一索引等值查询规则
- 范围条件:向右扫描,对扫描到的满足条件的记录加临键锁
2. 关键点澄清
关于"退化"的理解
实际上,唯一索引范围查询中没有"退化"到间隙锁的必然规则。需要分情况讨论:
1
-- 示例表:id (主键): 1, 5, 10, 15, 20, 25
场景分析
场景1:
id >= 10(包含等值)1
SELECT * FROM t WHERE id >= 10 FOR UPDATE;
加锁过程:
- 先处理等值部分
id = 10(记录存在)- 加 记录锁 在
id=10上
- 加 记录锁 在
- 再处理范围部分
id > 10- 从
10之后开始扫描 id=15: 加临键锁(10,15]id=20: 加临键锁(15,20]id=25: 加临键锁(20,25]- 继续扫描到
supremum(正无穷伪记录): 加临键锁(25,+∞]
- 从
最终锁定:
id=10的记录锁 +(10,+∞]的临键锁场景2:
id > 10(不包含等值)1
SELECT * FROM t WHERE id > 10 FOR UPDATE;
加锁过程:
- 定位到第一个
id>10的记录(id=15) - 从
id=15开始,对每个满足条件的记录加临键锁:id=15: 加临键锁(10,15]id=20: 加临键锁(15,20]id=25: 加临键锁(20,25]- 扫描到
supremum: 加临键锁(25,+∞]
最终锁定:
(10,+∞]的临键锁场景3:查询值不存在时的范围查询
1
2-- 查询 id >= 22(22不存在)
SELECT * FROM t WHERE id >= 22 FOR UPDATE;加锁过程:
- 等值部分
id = 22(不存在)- 加间隙锁
(20,25)(在20和25之间)
- 加间隙锁
- 范围部分
id > 22- 从
22之后扫描,找到id=25 id=25: 加临键锁(20,25]- 扫描到
supremum: 加临键锁(25,+∞]
- 从
最终锁定:间隙锁
(20,25)+ 临键锁(20,25]+(25,+∞]
实际效果就是锁住了(20,+∞]3. 重要纠正
关于"碰到第一个不满足的记录时,降级为间隙锁"
这个说法不完全准确。实际上:
- 对于 扫描到的满足条件的记录:加临键锁
- 对于 扫描过程中遇到的第一个不满足条件的记录:通常不加锁或加间隙锁,取决于具体情况
示例说明:
1
2-- 查询 id < 15
SELECT * FROM t WHERE id < 15 FOR UPDATE;加锁过程:
- 从最小值开始扫描(
id=1) - 对每个满足条件的记录加临键锁:
id=1: 临键锁(-∞,1]id=5: 临键锁(1,5]id=10: 临键锁(5,10]
- 遇到第一个不满足条件的记录
id=15- 加间隙锁
(10,15)(不是临键锁)
- 加间隙锁
最终锁定:
(-∞,15)的间隙和临键锁组合4. 边界情况
场景4:包含等值且记录不存在
1
2-- 查询 id BETWEEN 22 AND 24(都不存在)
SELECT * FROM t WHERE id BETWEEN 22 AND 24 FOR UPDATE;加锁过程:
- 定位到
id=20和id=25之间 - 加间隙锁
(20,25) - 因为没有扫描到任何满足条件的记录,所以只有间隙锁
5. 为什么这样设计?
- 保证范围查询的正确性
- 需要锁定整个查询范围,防止其他事务插入新记录
- 唯一性约束的利用
- 对于等值条件,可以利用唯一性减少锁范围
- 但对于范围条件,无法利用唯一性,必须锁区间
- 防止幻读
- 在 RR 隔离级别下,需要锁定整个扫描范围
6. 验证示例
1
2
3
4
5
6
7
8
9
10
11-- 会话1
BEGIN;
SELECT * FROM t WHERE id >= 15 AND id < 20 FOR UPDATE;
-- 会话2(测试是否被阻塞)
INSERT INTO t VALUES (14); -- 成功
INSERT INTO t VALUES (15); -- 被阻塞(15在范围内)
INSERT INTO t VALUES (18); -- 被阻塞(在15-20之间)
INSERT INTO t VALUES (20); -- 成功(20不在范围内,但间隙可能被锁)
UPDATE t SET ... WHERE id = 10; -- 成功
UPDATE t SET ... WHERE id = 15; -- 被阻塞7. 总结规则表格
查询类型 等值部分 范围部分 第一个不满足的记录 id >= X(X存在)记录锁 临键锁 继续扫描到 supremum id >= X(X不存在)间隙锁 临键锁 继续扫描到 supremum id > X无 临键锁 继续扫描到 supremum id <= X无 临键锁 间隙锁 id < X无 临键锁 间隙锁 关键要点:
- 唯一索引范围查询不会简单退化为间隙锁
- 对于满足条件的记录都加临键锁
- 对于第一个不满足条件的记录,通常加间隙锁(停止扫描)
- 对于正无穷方向,会一直扫描到 supremum 并加临键锁
你的理解方向正确,但需要明确:唯一索引范围查询中,只有停止扫描的那个边界记录可能会退化为间隙锁,而中间扫描到的满足条件的记录都是临键锁。
-
非唯一索引范围查询
先拆分条件,拆成等于和大于和小于,比如
>=拆成=和>等值按照之前的规则,如果查询的值存在加临建锁,右区间加间隙锁,查询的值不存在,降级为间隙锁
不等值查询,碰到第一个不满足的索引记录时,加临键锁
非唯一索引范围查询加锁详解
1. 核心规则修正
你提到的"碰到第一个不满足的索引记录时,加临键锁"需要修正:
- 对于第一个不满足条件的记录,通常是加间隙锁,而不是临键锁
- 例外情况:当查询是
> >=正向扫描时,会一直扫描到正无穷,对supremum加临键锁
2. 场景分析
场景1:
k >= 20(k是非唯一索引)假设数据:
k=10,20,20,20,30,401
SELECT * FROM t WHERE k >= 20 FOR UPDATE;
拆解:
k = 20(等值) +k > 20(范围)加锁过程:
- 等值部分
k = 20(存在):- 找到第一个
k=20,加临键锁(10,20] - 继续向右扫描所有
k=20,都加记录锁 - 遇到第一个
k=30,加间隙锁(20,30)
- 找到第一个
- 范围部分
k > 20:- 从
k=30开始(第一个大于20的记录) k=30:加临键锁(20,30](与上面的间隙锁(20,30)重叠)k=40:加临键锁(30,40]- 扫描到
supremum:加临键锁(40,+∞]
- 从
最终锁定:所有
k=20的记录 +(20,+∞]的临键锁场景2:
k > 20(只有范围)1
SELECT * FROM t WHERE k > 20 FOR UPDATE;
加锁过程:
- 定位到第一个大于20的记录
k=30 k=30:加临键锁(20,30]k=40:加临键锁(30,40]- 扫描到
supremum:加临键锁(40,+∞]
最终锁定:
(20,+∞]的临键锁场景3:
k <= 30(反向边界)1
SELECT * FROM t WHERE k <= 30 FOR UPDATE;
加锁过程:
- 从最小值开始扫描
k=10:加临键锁(-∞,10]k=20:加临键锁(10,20]- 继续扫描所有
k=20(有3条):都加记录锁 - 遇到
k=30(满足条件):加临键锁(20,30] - 继续扫描,遇到
k=40(第一个不满足条件的记录)- 加间隙锁
(30,40)(注意:这里是间隙锁,不是临键锁)
- 加间隙锁
最终锁定:所有满足条件的记录(
k<=30)+ 间隙锁(30,40)3. 重要澄清
关于"第一个不满足的记录"加锁类型
1
2-- 查询 k < 30
SELECT * FROM t WHERE k < 30 FOR UPDATE;加锁过程:
- 扫描并锁定所有
k<30的记录(加临键锁) - 遇到第一个不满足条件的记录(
k=30)- 加间隙锁
(最后一个满足条件的记录, 30) - 在这个例子中,最后一个满足条件的可能是
k=20,所以间隙锁是(20,30)
- 加间隙锁
结论:对于范围查询的终止边界(第一个不满足条件的记录),加的是间隙锁,不是临键锁。
4. 与唯一索引范围查询的对比
对比项 唯一索引 非唯一索引 等值部分锁 记录锁 临键锁 + 间隙锁 范围扫描 临键锁 临键锁 终止边界 间隙锁 间隙锁 防止幻读 依赖唯一性 必须锁整个区间 5. 特殊边界情况
场景4:查询值不存在且范围查询
1
2-- 查询 k >= 25(25不存在)
SELECT * FROM t WHERE k >= 25 FOR UPDATE;拆解:
k = 25(不存在) +k > 25加锁过程:
- 等值部分
k=25(不存在):- 加间隙锁
(20,30)(在20和30之间)
- 加间隙锁
- 范围部分
k > 25:- 从第一个大于25的记录开始(
k=30) k=30:加临键锁(20,30](重叠)k=40:加临键锁(30,40]supremum:加临键锁(40,+∞]
- 从第一个大于25的记录开始(
最终锁定:
(20,+∞]的临键锁6. 为什么这样设计?
- 非唯一性带来的复杂性:
- 由于可能存在重复值,必须锁住一个范围来保证一致性
- 不能像唯一索引那样只锁记录
- 防止幻读的需求:
- 必须确保在事务执行期间,不会有新的满足条件的记录插入
- 锁优化:
- 虽然加锁范围较大,但可以防止死锁和提高并发控制的可预测性
7. 实际验证示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18-- 准备数据
CREATE TABLE t (
id INT PRIMARY KEY,
k INT,
INDEX idx_k(k)
);
INSERT INTO t VALUES (1,10), (2,20), (3,20), (4,20), (5,30), (6,40);
-- 会话1
BEGIN;
SELECT * FROM t WHERE k >= 20 FOR UPDATE;
-- 会话2(测试)
INSERT INTO t VALUES (7, 15); -- 成功(15<20)
INSERT INTO t VALUES (8, 20); -- 被阻塞(k=20被锁)
INSERT INTO t VALUES (9, 25); -- 被阻塞((20,30)间隙被锁)
INSERT INTO t VALUES (10, 35); -- 被阻塞((30,40)间隙被锁)
INSERT INTO t VALUES (11, 45); -- 被阻塞((40,+∞)被锁)8. 修正后的规则总结
- 等值部分:
- 存在:临键锁(包含所有重复值)+ 右区间间隙锁
- 不存在:间隙锁
- 范围部分:
- 对扫描到的满足条件的记录:加临键锁
- 对第一个不满足条件的记录(停止扫描点):加间隙锁
- 如果扫描到
supremum:加临键锁(最后记录,+∞]
- 终止边界总是间隙锁:
- 无论是唯一索引还是非唯一索引,范围查询的终止边界都是加间隙锁
关键修正:你提到的"碰到第一个不满足的索引记录时,加临键锁"应该改为"加间隙锁"。这是因为临键锁是给满足条件的记录加的,而不满足条件的记录只需要用间隙锁防止插入即可。
3)limit 规则
满足limit条件后,终止范围扫描
比如:非唯一索引,等值查询,当查询的记录存在,会给查询到的等值记录,加临键锁
无limit条件的情况下,会继续向右扫描,直到不符合条件为止。向右扫描的区间,加间隙锁。
如果存在limit条件,当符合limit条件之后,将不继续扫描。
4)order 规则
注意倒序,前开后闭的区间,向左滑动,直到右边界,直到碰到不符合的记录,会给不符合的记录加上间隙锁。
5)主键索引和非主键规则
如果在查询中使用了二级索引并且需要设置独占的索引记录锁,InnoDB 还会检索相应的聚簇索引记录并对其设置锁。
因为修改操作,最终要回表到聚簇索引,在聚簇索引上修改数据。所以对二级索引加独占锁的同时,会对主键索引加行锁。
查询中使用二级索引,但非覆盖索引,则会对主键索引同时进行加锁,因为需要回表到主键索引上。主键索引只加记录锁,二级索引可以加范围锁+记录锁。
查询中使用二级索引,并且是覆盖索引,如果二级索引上是共享锁,那么不会对主键索引进行加锁,因为不会访问主键索引上的数据。如果二级索引上加了独占锁,则对应的主键索引,也会被上锁,主键索引只加记录锁,二级索引可以是范围锁+记录锁。
二级索引与主键索引加锁规则详解
1. 基本规则
情况一:非覆盖索引(需要回表)
1
2 -- 表结构:id(PK), k(INDEX), name(无索引)
SELECT * FROM t WHERE k = 20 FOR UPDATE;加锁过程:
- 二级索引
idx_k:
- 对
k=20的记录加锁(根据条件加相应的锁)- 可能加临键锁、记录锁或间隙锁
- 主键索引:
- 通过二级索引记录中的主键值(id)回表
- 在聚簇索引上对对应的行加记录锁
- 即使二级索引加的是范围锁,主键索引也只加记录锁
情况二:覆盖索引(不需要回表)
1
2 -- 假设查询列都在索引中 (k, id)
SELECT id, k FROM t WHERE k = 20 FOR UPDATE;2. 关键澄清点
点1:共享锁 vs 独占锁的区别
1
2
3
4
5 -- 共享锁(LOCK IN SHARE MODE)
SELECT * FROM t WHERE k = 20 LOCK IN SHARE MODE;
-- 独占锁(FOR UPDATE)
SELECT * FROM t WHERE k = 20 FOR UPDATE;实际加锁行为:
- 共享锁:只锁二级索引和主键索引的记录锁,不锁间隙(除特殊情况)
- 独占锁:在二级索引上可能加范围锁(间隙锁/临键锁),在主键索引上加记录锁
但注意:即使是共享锁,如果需要回表,也会在主键索引上加共享记录锁。
点2:覆盖索引的特殊性
1
2 -- 覆盖索引查询 + 共享锁
SELECT id, k FROM t WHERE k = 20 LOCK IN SHARE MODE;这种情况下,MySQL 可能(取决于版本和优化器):
- 只在二级索引上加锁
- 不在主键索引上加锁
因为:
- 数据已经在二级索引中获得
- 共享锁只读不写,不需要防止主键索引被修改
- 其他事务仍可通过主键修改该行(但修改会等待或失败,因为二级索引上有共享锁)
点3:更新操作的加锁
1 UPDATE t SET name = 'new' WHERE k = 20;加锁过程:
- 在二级索引
idx_k上找到k=20的记录,加独占锁- 回表到主键索引,对对应的行加独占记录锁
- 如果更新了索引列,可能需要在其他索引上加锁
3. 实际测试示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 -- 准备数据
CREATE TABLE t (
id INT PRIMARY KEY,
k INT,
name VARCHAR(20),
INDEX idx_k(k)
);
INSERT INTO t VALUES (1,10,'A'), (2,20,'B'), (3,20,'C'), (4,30,'D');
-- 会话1:使用二级索引查询(非覆盖索引)
BEGIN;
SELECT * FROM t WHERE k = 20 FOR UPDATE;
-- 锁定:
-- 1. idx_k: 临键锁 (10,20], (20,20], 间隙锁 (20,30)
-- 2. 主键索引: id=2, id=3 的记录锁
-- 会话2:测试锁
UPDATE t SET name = 'X' WHERE id = 2; -- 被阻塞(主键记录锁)
INSERT INTO t VALUES (5, 25, 'E'); -- 被阻塞(间隙锁(20,30))
UPDATE t SET k = 25 WHERE id = 4; -- 成功(k=30不在间隙内)4. 为什么这样设计?
锁的主键索引原因:
- 防止通过主键修改:如果不锁主键,其他事务可以通过主键直接修改或删除该行
- 保持一致性:确保通过不同路径访问同一行数据时的一致性
- 简化锁管理:统一的锁协议,减少死锁可能性
覆盖索引的优化:
1
2
3 -- 对于覆盖索引的只读查询,可以优化
SELECT id, k FROM t WHERE k = 20 LOCK IN SHARE MODE;
-- 可能只在二级索引上加锁,提高并发性5. 例外情况和注意事项
情况1:唯一二级索引
1
2 -- 如果k是UNIQUE索引
SELECT * FROM t WHERE k = 20 FOR UPDATE;加锁:
- 二级索引:记录锁(因为唯一)
- 主键索引:记录锁(回表)
情况2:范围查询
1 SELECT * FROM t WHERE k BETWEEN 20 AND 30 FOR UPDATE;加锁:
- 二级索引:范围锁(临键锁+间隙锁)
- 主键索引:只对实际扫描到的行加记录锁
情况3:索引下推(ICP)
1
2 -- 假设有索引(k, name)
SELECT * FROM t WHERE k = 20 AND name LIKE 'B%' FOR UPDATE;加锁:
- 可能先在二级索引上过滤,减少回表和加锁行数
6. 性能影响和建议
优化建议:
使用覆盖索引减少回表和加锁
1
2
3 -- 创建覆盖索引
CREATE INDEX idx_k_name ON t(k, name);
SELECT id, k, name FROM t WHERE k = 20 FOR UPDATE;尽量使用主键查询锁定最少的记录
1
2
3
4 -- 通过主键查询更高效
SELECT * FROM t WHERE id IN (
SELECT id FROM t WHERE k = 20
) FOR UPDATE;合理设计索引避免过多回表
7. 总结表格
查询类型 二级索引锁 主键索引锁 是否需要回表 非覆盖索引 + FOR UPDATE 范围锁/记录锁 记录锁 是 非覆盖索引 + LOCK IN SHARE MODE 记录锁 共享记录锁 是 覆盖索引 + FOR UPDATE 范围锁/记录锁 记录锁 否(但仍加锁) 覆盖索引 + LOCK IN SHARE MODE 记录锁 可能不加锁 否 唯一索引等值查询 + FOR UPDATE 记录锁 记录锁 是/否 关键结论:
- FOR UPDATE总是会在主键索引上加锁(即使是覆盖索引)
- LOCK IN SHARE MODE在覆盖索引时可能优化主键索引加锁
- 二级索引的锁类型取决于查询条件和索引类型
- 主键索引只加记录锁,不加范围锁
你的理解基本正确,主要修正点是:即使是覆盖索引,当使用
FOR UPDATE时,InnoDB仍然会对主键索引加记录锁,以确保数据一致性。
6.2 SELECT语句设置的锁
SELECT ... FROM,它是一个一致性读取,会读取数据库的快照,读取的过程中,忽略读取视图中存在的记录上的任何锁。并且不会设置任何锁。除非事务隔离级别设置为SERIALIZABLE。在 SERIALIZABLE 级别下,搜索会对其遇到的索引记录设置共享的临键锁。对于使用唯一索引来查找唯一行的语句,只添加记录锁。
SELECT ... FOR UPDATE 和 SELECT ... FOR SHARE 语句在使用唯一索引时会为扫描到的行获取锁,并对不符合结果集条件的行(例如不满足 WHERE 子句中的条件)释放锁。
主键索引和二级索引加锁情况
SELECT ... FOR UPDATE 在对二级索引加锁的同时,会对主键索引加锁。二级索引上如果加了临键锁,回表到主键索引加的是行锁。
SELECT ... FOR SHARE 如果是覆盖索引,则只在二级索引上加锁,不会对主键索引加锁。二级索引上如果加了临键锁,回表到主键索引加的是行锁。
注意:在某些情况下,由于在查询执行过程中结果行与其原始来源的关系丢失,行可能不会立即解锁。
例如,在
UNION中,从表中扫描(并锁定)的行可能会在插入临时表后才被评估是否符合结果集条件。在这种情况下,临时表中的行与原始表中的行之间的关系丢失,因此原始表中的行在查询执行结束之前不会被解锁。
SELECT语句加锁规则详解
1. 一致性读取(快照读)
1
2 -- 在 RR/RC 隔离级别下(除 SERIALIZABLE)
SELECT * FROM t WHERE ...;特点:
- 读取事务开始时的快照(MVCC)
- 不加任何锁
- 忽略记录上的锁(通过版本控制读取旧版本)
2. SERIALIZABLE 级别的 SELECT
1
2 -- 在 SERIALIZABLE 隔离级别下
SELECT * FROM t WHERE k = 20;自动转换为:
1 SELECT * FROM t WHERE k = 20 LOCK IN SHARE MODE;加锁规则:
- 非唯一索引:临键锁
- 唯一索引:记录锁
3. SELECT … FOR UPDATE / FOR SHARE
锁释放时机
1 SELECT * FROM t WHERE k > 20 FOR UPDATE;重要规则:扫描过程中对不满足条件的行会立即释放锁。
示例:
1
2 -- 数据:id=1(k=10), id=2(k=20), id=3(k=30), id=4(k=40)
SELECT * FROM t WHERE k > 20 FOR UPDATE;加锁过程:
- 从 k=10 开始扫描(不满足,不加锁或加锁后立即释放)
- 扫描 k=20(不满足,不加锁或加锁后立即释放)
- 扫描 k=30(满足,加锁并保持)
- 扫描 k=40(满足,加锁并保持)
4. 特殊情况:锁延迟释放
UNION 示例
1
2
3 (SELECT * FROM t1 WHERE k = 10 FOR UPDATE)
UNION
(SELECT * FROM t2 WHERE k = 20 FOR UPDATE);问题:
- 扫描 t1 中 k=10 的行并加锁
- 将结果插入临时表
- 执行 UNION 的去重和合并
- 查询结束后才释放 t1 的锁
结果:锁持有时间可能比预期长
其他可能延迟的情况:
- 子查询
- 派生表(Derived tables)
- 复杂连接查询
5. 主键索引与二级索引加锁对比
SELECT … FOR UPDATE
1
2 -- 非覆盖索引查询
SELECT * FROM t WHERE k = 20 FOR UPDATE;加锁:
- 二级索引 idx_k:
- k=20 的临键锁(如果是非唯一索引)
- 向右扫描的间隙锁
- 主键索引:
- 对应 id 的记录锁(独占锁)
SELECT … FOR SHARE
1
2 -- 覆盖索引查询
SELECT id, k FROM t WHERE k = 20 FOR SHARE;加锁:
- 二级索引 idx_k:
- k=20 的共享锁(可能是临键锁)
- 主键索引:
- 可能不加锁(因为是覆盖索引+共享锁)
- 如果优化器认为需要,可能还是加共享记录锁
6. 实际应用中的锁行为
测试场景1:FOR UPDATE 范围查询
1
2
3
4
5
6
7
8
9
10 -- 会话1
BEGIN;
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁定 id=10,11,...,20 的行
-- 会话2
BEGIN;
SELECT * FROM t WHERE id = 5 FOR UPDATE; -- 成功
SELECT * FROM t WHERE id = 10 FOR UPDATE; -- 被阻塞
SELECT * FROM t WHERE id = 25 FOR UPDATE; -- 成功测试场景2:FOR SHARE 与 FOR UPDATE 冲突
1
2
3
4
5
6
7
8
9 -- 会话1
BEGIN;
SELECT * FROM t WHERE id = 10 FOR SHARE;
-- 会话2
BEGIN;
SELECT * FROM t WHERE id = 10 FOR SHARE; -- 成功(共享锁兼容)
SELECT * FROM t WHERE id = 10 FOR UPDATE; -- 被阻塞(需要等待共享锁释放)
UPDATE t SET ... WHERE id = 10; -- 被阻塞7. 优化建议和陷阱
陷阱1:全表扫描的锁
1
2 -- 没有使用索引
SELECT * FROM t WHERE name = 'John' FOR UPDATE;结果:
- 扫描全表每一行
- 对不满足条件的行加锁后释放
- 但可能持有大量锁,影响性能
陷阱2:覆盖索引的误解
1
2 -- 假设有索引 (k, id)
SELECT id, k FROM t WHERE k = 20 FOR UPDATE;常见误解:认为不会锁主键
实际:FOR UPDATE 仍然会在主键上加锁,确保数据一致性优化建议1:使用合适的索引
1
2
3 -- 创建合适索引
CREATE INDEX idx_name ON t(name);
SELECT * FROM t WHERE name = 'John' FOR UPDATE;优化建议2:减少锁范围
1
2
3
4 -- 使用更精确的条件
SELECT * FROM t WHERE id IN (
SELECT id FROM t WHERE k = 20
) FOR UPDATE;8. 锁兼容性矩阵
当前锁状态 SELECT … FOR SHARE SELECT … FOR UPDATE UPDATE/DELETE 无锁 允许 允许 允许 共享锁 允许 阻塞 阻塞 独占锁 阻塞 阻塞 阻塞 9. 隔离级别的影响
隔离级别 SELECT 加锁 SELECT … FOR UPDATE 幻读防护 READ UNCOMMITTED 无锁 记录锁(可能不准确) 无 READ COMMITTED 无锁 记录锁(当前读) 无 REPEATABLE READ 无锁(快照读) 临键锁/间隙锁 有 SERIALIZABLE 共享锁 临键锁/间隙锁 有 10. 实际案例分析
死锁场景
1
2
3
4
5
6
7
8
9
10
11
12
13 -- 会话1
BEGIN;
SELECT * FROM t WHERE k = 20 FOR UPDATE;
-- 会话2
BEGIN;
SELECT * FROM t WHERE k = 30 FOR UPDATE;
-- 会话1
SELECT * FROM t WHERE k = 30 FOR UPDATE; -- 等待会话2
-- 会话2
SELECT * FROM t WHERE k = 20 FOR UPDATE; -- 死锁!解决方法:
- 按相同顺序访问记录
- 减少事务执行时间
- 使用更细粒度的锁
11. 重要结论
- 普通SELECT:RR级别下是快照读,不加锁(SERIALIZABLE除外)
- FOR UPDATE:总是加独占锁,二级索引+主键索引都加锁
- FOR SHARE:加共享锁,覆盖索引时可能优化主键索引加锁
- 锁释放:不满足条件的行会立即释放,但复杂查询可能延迟释放
- 性能影响:不合理的使用可能导致锁竞争和死锁
你的理解基本正确,关键是记住:
- FOR UPDATE 总是更重(主键+二级索引都加独占锁)
- FOR SHARE 在某些情况下可以优化(覆盖索引时可能不锁主键)
- 复杂查询要注意锁的持有时间(UNION、子查询等可能延长锁时间)
6.3 UPDATE语句设置的锁
UPDATE ... WHERE ... 会对搜索遇到的每个记录设置独占的临键锁。
唯一索引等值查询,情况同通用规则。查询记录存在,临键锁退化为独占的记录锁。查询记录不存在,临键锁退化为独占的间隙锁。
当 UPDATE 修改聚簇索引记录时,会对受影响的二级索引记录隐式加锁。
UPDATE 修改唯一索引时,需要删除旧的索引,插入新的索引。旧的索引记录上会加上记录锁,插入的新索引记录则隐式加锁。加锁的逻辑 = DELETE操作 + INSERT操作。
在唯一索引上插入新的索引记录之前,UPDATE 操作还会在执行重复检查扫描时对受影响的索引记录加共享锁。
UPDATE语句加锁详解
1. UPDATE语句基本加锁流程
1 UPDATE t SET col = new_value WHERE condition;加锁步骤:
- WHERE条件扫描:对扫描到的记录加临键锁
- 唯一索引等值查询优化:退化为记录锁或间隙锁
- 修改操作:
- 修改聚簇索引记录:加独占锁
- 修改二级索引:删除旧值 + 插入新值
- 重复检查:唯一索引插入前加共享锁检查
2. 详细场景分析
场景1:更新主键(唯一索引)
1
2 -- 表结构:id(PK), k(UNIQUE), name
UPDATE t SET name = 'new' WHERE id = 10;加锁过程:
- 主键索引上定位
id=10(记录存在)
- 加独占记录锁
- 修改聚簇索引记录
id=10- 如果
name有二级索引:
- 删除旧索引记录(如果存在)
- 插入新索引记录
场景2:更新唯一索引列
1 UPDATE t SET k = 25 WHERE k = 20;加锁过程(更复杂):
- WHERE条件扫描:
- 在唯一索引
k上找到k=20(记录存在)- 加独占记录锁(退化)
- 重复检查(
k=25是否已存在):
- 在唯一索引
k上检查k=25- 加共享锁进行重复检查
- 删除旧索引记录:
- 在唯一索引
k上删除k=20- 保持独占锁(已存在)
- 插入新索引记录:
- 在唯一索引
k上插入k=25- 加独占锁
- 回表更新聚簇索引:
- 在主键索引上对应行加独占锁
场景3:更新非唯一索引列
1
2 UPDATE t SET c = 1500 WHERE c = 1000;
-- 假设c是非唯一索引加锁过程:
- WHERE条件扫描:
- 在非唯一索引
c上找到所有c=1000的记录- 加临键锁(因为非唯一索引)
- 删除旧索引记录:
- 对每个
c=1000的记录加独占锁并删除- 插入新索引记录:
- 插入
c=1500的记录- 加独占锁(无需重复检查,因为非唯一)
3. 重复检查的共享锁机制
为什么需要共享锁?
1
2
3
4 -- 事务1
UPDATE t SET k = 25 WHERE k = 20;
-- 事务2同时执行
INSERT INTO t VALUES (..., 25, ...);如果没有共享锁:
- 事务1检查
k=25不存在- 事务2插入
k=25成功- 事务1插入
k=25违反唯一约束解决方案:
- 事务1在检查
k=25时加共享锁- 事务2的插入需要加独占锁,会被阻塞
- 保证了唯一性约束
4. 更新操作中的锁升级
更新导致的锁范围扩大
1
2 -- 初始:k=10,20,30,40
UPDATE t SET k = k + 1 WHERE k >= 20;加锁情况:
WHERE条件:临键锁
(10,+∞]更新每个满足条件的记录时:
- 删除旧值:独占锁
- 插入新值:独占锁 + 重复检查共享锁
可能产生死锁风险:
1
2
3
4 -- 事务1
UPDATE t SET k = k + 1 WHERE k >= 20;
-- 事务2同时执行
UPDATE t SET k = k + 2 WHERE k >= 20;5. UPDATE与DELETE的对比
操作 WHERE条件锁 修改操作锁 重复检查锁 UPDATE 临键锁 删除旧值+插入新值 唯一索引需要 DELETE 临键锁 删除记录锁 不需要 6. 实际案例演示
案例1:更新唯一索引冲突
1
2
3
4
5
6
7
8 -- 会话1
BEGIN;
UPDATE t SET k = 30 WHERE k = 20; -- 修改k=20为k=30
-- 会话2
BEGIN;
UPDATE t SET k = 30 WHERE k = 25; -- 试图修改为相同的30
-- 被阻塞:等待会话1的重复检查共享锁释放案例2:批量更新死锁
1
2
3
4
5
6
7
8
9
10
11 -- 表数据:id=1(k=10), id=2(k=20), id=3(k=30)
-- 事务1
BEGIN;
UPDATE t SET k = k + 10 WHERE id = 1; -- 10→20
UPDATE t SET k = k + 10 WHERE id = 2; -- 20→30
-- 事务2同时执行
BEGIN;
UPDATE t SET k = k + 10 WHERE id = 2; -- 20→30(等待事务1)
UPDATE t SET k = k + 10 WHERE id = 1; -- 10→20(死锁!)7. 锁优化建议
建议1:避免热点更新
1
2
3
4
5 -- 不好的做法:频繁更新同一行
UPDATE counters SET value = value + 1 WHERE name = 'visits';
-- 更好的做法:使用更分散的计数器
-- 或使用队列异步更新建议2:合理使用索引
1
2
3 -- 没有索引的更新会锁全表
UPDATE t SET status = 'active' WHERE create_time < '2023-01-01';
-- 在create_time上加索引可以减少锁范围建议3:控制事务大小
1
2
3
4
5
6 -- 大批量更新分批次进行
UPDATE large_table SET flag = 1 WHERE id BETWEEN 1 AND 10000;
-- 改为
UPDATE large_table SET flag = 1 WHERE id BETWEEN 1 AND 1000;
UPDATE large_table SET flag = 1 WHERE id BETWEEN 1001 AND 2000;
-- ...8. 特殊UPDATE语句
带有LIMIT的UPDATE
1 UPDATE t SET status = 'processed' WHERE status = 'pending' LIMIT 100;加锁特点:
- 只扫描并锁定100行
- 扫描到第101行不满足条件的记录时停止
- 对第101行加间隙锁
JOIN UPDATE
1
2
3 UPDATE t1 JOIN t2 ON t1.id = t2.t1_id
SET t1.col = t2.col
WHERE t1.status = 'active';加锁:
- 两个表都会根据WHERE条件加锁
- 连接条件可能导致额外的锁
9. 性能监控和排查
查看UPDATE锁信息
1
2
3
4
5
6 -- 查看锁等待
SHOW ENGINE INNODB STATUS\G
-- 查看当前锁
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;常见问题排查
- 锁等待超时:增加
innodb_lock_wait_timeout- 死锁频繁:优化事务顺序,减少锁持有时间
- 锁范围过大:优化索引,使用更精确的条件
10. 总结表格
更新类型 WHERE条件锁 修改操作锁 重复检查锁 回表锁 更新主键 记录锁/间隙锁 记录锁 无 无(自身) 更新唯一索引 记录锁/间隙锁 删除+插入锁 共享锁 记录锁 更新非唯一索引 临键锁 删除+插入锁 无 记录锁 更新无索引列 全表临键锁 记录锁 无 记录锁 关键要点:
- UPDATE的加锁 = SELECT FOR UPDATE + 实际修改的锁
- 唯一索引更新需要额外的重复检查共享锁
- 修改二级索引需要删除旧记录+插入新记录两套锁
- 批量更新容易产生死锁,需要特别注意
你的总结完全正确,UPDATE语句的加锁确实是最复杂的,因为它结合了查询的锁和修改的锁,还需要考虑唯一性约束的检查。理解这些机制对于设计高性能的数据库应用至关重要。
6.4 DELETE语句设置的锁
DELETE FROM ... WHERE ... 对搜索遇到的每个记录设置独占的临键锁。
唯一索引等值查询,情况同通用规则。查询记录存在,临键锁退化为独占的记录锁。查询记录不存在,临键锁退化为独占的间隙锁。
DELETE语句加锁详解
1. DELETE语句基本加锁流程
1 DELETE FROM t WHERE condition;加锁步骤:
- WHERE条件扫描:对扫描到的记录加独占的临键锁
- 唯一索引等值查询优化:退化为记录锁或间隙锁
- 删除操作:
- 删除聚簇索引记录:保持独占锁直到事务结束
- 删除二级索引记录:保持独占锁
- 外键约束检查:如果存在外键,可能在其他表上加锁
2. 详细场景分析
场景1:删除主键(唯一索引)记录
1 DELETE FROM t WHERE id = 10;加锁过程:
- 主键索引上定位
id=10(记录存在)
- 加独占记录锁(临键锁退化)
- 删除聚簇索引记录
id=10- 删除所有二级索引中的对应记录
- 在每个二级索引记录上加独占锁
场景2:删除非唯一索引记录
1
2 DELETE FROM t WHERE k = 100;
-- 假设k是非唯一索引,且有多条k=100的记录加锁过程:
- WHERE条件扫描:
- 在非唯一索引
k上找到第一个k=100的记录- 加临键锁
(上一个值, 100]- 继续扫描所有
k=100的记录,都加临键锁- 遇到第一个
k≠100的记录(如k=200),加间隙锁(100, 200)- 删除操作:
- 对每个匹配的记录,在主键索引上加独占记录锁
- 在二级索引
k上删除记录,保持独占锁场景3:删除范围记录
1 DELETE FROM t WHERE id BETWEEN 10 AND 20;加锁过程:
- 范围扫描:
- 从
id=10开始扫描- 对每个满足条件的记录加临键锁
- 扫描到
id=21(第一个不满足条件的记录)时,加间隙锁(20, 21)- 批量删除:
- 对每一条删除的记录保持独占锁直到事务结束
3. DELETE与UPDATE的对比
特性 DELETE UPDATE WHERE条件锁 临键锁 临键锁 唯一索引等值(存在) 记录锁 记录锁 唯一索引等值(不存在) 间隙锁 间隙锁 修改操作 删除记录 删除旧值+插入新值 重复检查锁 不需要 唯一索引需要 锁保持时间 直到事务结束 直到事务结束 二级索引锁 删除时加锁 删除旧值+插入新值时加锁 4. 批量DELETE的锁行为
场景:批量删除重复记录
1
2
3
4 -- 删除所有重复的email记录,保留id最小的
DELETE t1 FROM users t1
INNER JOIN users t2
WHERE t1.id > t2.id AND t1.email = t2.email;锁的复杂性:
自连接扫描多次访问同一表
可能产生死锁:
1
2
3
4
5 -- 事务1
DELETE FROM t WHERE id = 10;
-- 事务2同时执行
DELETE FROM t WHERE id = 20;
-- 如果两者都扫描到了对方的记录,可能死锁5. 外键约束的影响
场景:有外键约束的DELETE
1
2
3
4 -- 父表 orders (id)
-- 子表 order_items (order_id REFERENCES orders(id))
DELETE FROM orders WHERE id = 100;加锁过程:
- 检查子表
order_items是否有相关记录
- 对子表中
order_id=100的记录加共享锁- 如果存在记录且使用
ON DELETE CASCADE,则加独占锁删除- 删除父表记录
- 对
orders.id=100加独占记录锁- 如果外键约束是
ON DELETE RESTRICT,且子表有记录,则删除失败6. DELETE操作的风险和优化
风险1:锁表风险
1
2
3 -- 无索引条件删除
DELETE FROM logs WHERE create_date < '2023-01-01';
-- 全表扫描,锁全表解决方案:
1
2
3 -- 分批删除
DELETE FROM logs WHERE create_date < '2023-01-01' LIMIT 1000;
-- 循环执行,直到影响行数为0风险2:回滚空间不足
1
2
3 -- 大表删除可能产生大量undo
DELETE FROM large_table;
-- 可能导致undo表空间耗尽解决方案:
1
2
3
4
5 -- 使用truncate(DDL,不可回滚)
TRUNCATE TABLE large_table;
-- 或使用分区表s
ALTER TABLE large_table DROP PARTITION p_old;7. DELETE与MVCC
场景:RR隔离级别下的DELETE
1
2
3
4
5
6
7
8
9
10
11
12 -- 会话1
BEGIN;
SELECT COUNT(*) FROM t; -- 返回100
-- 会话2
BEGIN;
DELETE FROM t WHERE id = 10;
COMMIT;
-- 会话1再次查询
SELECT COUNT(*) FROM t; -- 仍然返回100(快照读)
DELETE FROM t WHERE id = 10; -- 影响0行(已删除)关键点:
- SELECT快照读看不到已删除的记录
- 但DELETE是当前读,会看到最新提交的数据
- 可能导致"明明看到有数据,但删除时却删不到"的现象
8. 性能优化建议
建议1:使用合适的索引
1
2
3 -- 在status和create_time上建立复合索引
DELETE FROM orders WHERE status = 'cancelled' AND create_date < '2023-01-01';
CREATE INDEX idx_status_time ON orders(status, create_date);建议2:避免热点行竞争
1
2
3
4
5
6 -- 热点删除问题
DELETE FROM sessions WHERE expired_at < NOW();
-- 解决方案:使用随机延迟
DELETE FROM sessions WHERE expired_at < NOW() AND RAND() < 0.1;
-- 或分时段删除建议3:归档代替删除
1
2
3
4
5
6 -- 使用归档表
INSERT INTO logs_archive SELECT * FROM logs WHERE create_date < '2023-01-01';
DELETE FROM logs WHERE create_date < '2023-01-01';
-- 或使用分区表
ALTER TABLE logs DROP PARTITION p_2022;9. 监控和诊断
*查看DELETE锁信息
1
2
3
4
5
6
7 -- 查看锁等待
SELECT * FROM performance_schema.data_locks
WHERE LOCK_TYPE = 'RECORD' AND THREAD_ID = PS_CURRENT_THREAD_ID();
-- 查看长时间运行的DELETE
SELECT * FROM information_schema.innodb_trx
WHERE trx_query LIKE '%DELETE%' AND trx_state = 'RUNNING';诊断死锁
1
2
3 -- 死锁日志
SHOW ENGINE INNODB STATUS\G
-- 查看LATEST DETECTED DEADLOCK部分10. 特殊DELETE语句
LIMIT DELETE
1 DELETE FROM t WHERE status = 'pending' LIMIT 100;加锁特点:
- 只扫描并锁定100行
- 扫描到第101行时停止并加间隙锁
- 减少锁竞争,适合批量处理
JOIN DELETE
1
2
3
4 DELETE t1, t2
FROM orders t1
JOIN order_items t2 ON t1.id = t2.order_id
WHERE t1.status = 'cancelled';加锁:
- 两个表都会根据WHERE条件加锁
- 小心死锁风险
11. 实际案例
案例1:循环删除死锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 -- 会话1
BEGIN;
DELETE FROM t WHERE id = 1;
-- 持有id=1的独占锁
-- 会话2
BEGIN;
DELETE FROM t WHERE id = 2;
-- 持有id=2的独占锁
-- 会话1尝试删除id=2(需要会话2的锁)
DELETE FROM t WHERE id = 2; -- 等待
-- 会话2尝试删除id=1(需要会话1的锁)
DELETE FROM t WHERE id = 1; -- 死锁!解决方案:
- 按相同顺序访问记录
- 使用更小的事务
案例2:间隙锁导致的插入阻塞
1
2
3
4
5
6
7
8
9 -- 数据:id=1,3,5,7,9
-- 会话1
BEGIN;
DELETE FROM t WHERE id = 6; -- 不存在,加间隙锁(5,7)
-- 会话2
BEGIN;
INSERT INTO t VALUES (6, 'test'); -- 被阻塞,等待间隙锁释放12. 总结表格
删除类型 WHERE条件锁 删除操作锁 外键检查锁 性能影响 主键等值删除 记录锁 记录锁 如有外键 小 唯一索引等值删除 记录锁/间隙锁 记录锁 如有外键 小 非唯一索引等值删除 临键锁+间隙锁 记录锁 如有外键 中 无索引删除 全表临键锁 记录锁 如有外键 非常大 关键要点:
- DELETE的加锁 = SELECT FOR UPDATE的加锁 + 删除记录的锁
- 删除操作会同时锁住所有相关索引的记录
- 外键约束会增加额外的锁
- 批量删除需要特别注意锁竞争和死锁
- 无索引的DELETE性能极差,应避免
你的总结非常准确,DELETE语句虽然看起来简单,但加锁机制相当复杂,特别是在有外键、二级索引和并发访问的情况下。正确理解DELETE的加锁行为对于设计高性能、高并发的数据库应用至关重要。
6.5 INSERT语句设置的锁
参考插入意向锁和隐式锁
普通INSERT语句,只有隐式锁,参考隐式锁章节。
当插入的间隙,存在意向锁的时候,则去获取插入意向锁。参考插入意向锁章节。
7. 附录
间隙锁加锁规则(共11个案例)
间隙锁是在可重复读隔离级别下才会生效的:next-key lock实际上是由间隙锁加行锁实现的,如果切换到读提交隔离级别 (read-committed) 的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。而在读已提交隔离级别下间隙锁就没有了,**为了解决可能出现的数据和日志不一致问题,需要把binlog 格式设置为 row **。也就是说,许多公司的配置为:读已提交隔离级别加 binlog_format=row。业务不需要可重复读的保证,这样考虑到读已提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。
next-key lock的加锁规则
总结的加锁规则里面,包含了两个原则、两个优化。
- 原则1 :加锁的基本单位是
next-key lock。next-key lock是前开后闭区间。 - 原则2 :查找过程中访问到的对象才会加锁。
- 优化1 :索引上的等值查询,给唯一索引加锁的时候,
next-key lock退化为行锁。 - 优化2 :索引上的等值查询,给非唯一索引加锁的时候,向右遍历时且最后一个值不满足等值条件的时候,
next-keylock退化为间隙锁。
我们以表test作为例子,建表语句和初始化语句如下:其中id为主键索引
1 | CREATE TABLE `test` ( |
案例1:唯一索引等值查询
| sessionA | sessionB | sessionC |
|---|---|---|
| begin; update test set col2 = col2+1 where id=7; |
||
| insert into test values(8,8,8) (blocked) |
||
| update test set col2 = col2+1 where id=10; (Query OK) |
sessionA 唯一索引等值查询,查询的记录不存在,临键锁退化为间隙锁,锁定间隙(5,10),因为是Update操作,所以设置是独占的间隙锁。
sessionC 唯一索引等值查询,查询的记录存在,临键锁退化为记录锁,只锁定一条记录id=10。
1 | *************************** Session A *************************** |
案例2:非唯一索引等值查询
| sessionA | sessionB | sessionC |
|---|---|---|
| begin; select id from test where col1 = 5 lock in share mode; |
||
| update test set col2 = col2+1 where id=5; (Query OK) |
||
| insert into test values(7,7,7); (blocked) |
- SessionA 非唯一索引等值查询
查询的值 col1=5 存在,加临键锁 (0,5]
因为是非唯一索引,继续向右遍历,直到遇到第一条不满足条件的索引col1=10,临键锁降级为间隙锁(5,10)
因为是覆盖索引,且是共享锁,并不需要访问主键索引,所以不对主键索引进行加锁
- SessionB 唯一索引等值查询
因为,SessionA只在索引c上加锁,没有对主键索引进行加锁
所以 SessionB 对主键索引加锁,不会被阻塞
如果 SessionA 是 for update,独占锁,会同时对主键索引加行锁,则 SessionB 的操作会被阻塞
- Session C 要插入一个 (7,7,7) 的记录,被 Session A 的间隙锁 (5,10) 锁住
[!important]
如果要用 lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,因为覆盖索引不会访问主键索引,不会给主键索引上加锁
案例3:唯一索引范围查询1
上面两个例子是等值查询的,这个例子是关于范围查询的,也就是说下面的语句
1 | select * from test where id=10 for update |
这两条查语句肯定是等价的,但是它们的加锁规则不太一样
| sessionA | sessionB | sessionC |
|---|---|---|
| begin; select * from test where id>=10 and id<11 for update; |
||
| insert into test values(8,8,8); (Query OK) insert into test values(13,13,13); (blocked) |
||
| update test set col2=col2+1 where id=15; (Query OK) |
先拆分出等值条件 id = 10,唯一索引等值查询,查询的值存在,降级为行锁。锁住id=10这一行
因为是范围查询,从左往右遍历,直到遇到第一条不满足查询条件的索引记录id=15,临键锁降级为间隙锁。锁住间隙(10,15)
最终,session A 锁的范围就是主键索引上,行锁 id=10 和 间隙锁(10,15)。
首次 session A 定位查找id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
案例4:非唯一索引范围查询
与案例三不同的是,案例四中查询语句的 where 部分用的是字段 col1 ,它是普通索引
这两条查语句肯定是等价的,但是它们的加锁规则不太一样
1 | # IX表级锁 索引C上 (5,10] (10,15) 主键索引上 行级锁 10 |
| sessionA | sessionB | sessionC |
|---|---|---|
| begin; select * from test where col1>=10 and col1<11 for update; |
||
| insert into test values(8,8,8); (blocked) |
||
| update test set col2=col2+1 where id=15; (Query OK) |
||
| update test set col1=col1+1 where id=15; (blocked) |
非唯一索引范围查询,从左往右扫描,直到遇到第一条不满足查询条件的索引记录,扫到的索引记录,全加临键锁。因此锁定的范围是索引c上 (5,10] 和 (10,15]
同时因为是二级索引上的排他锁,同时需要对主键索引记录,id=10 和 id=15 加行锁。
案例5:唯一索引范围查询锁2
| sessionA | sessionB | sessionC |
|---|---|---|
| begin; select * from test where id>10 and id<=15 for update; |
||
| update test set col2=col2+1 where id=20; (query ok) | ||
| insert into test values(16,16,16); (query ok) |
拆分条件,拆成 id = 15 和 10<id<15
id=15,唯一索引等值查询,行锁锁定15
10<id<15,遇到第一个不符合条件的值,15,降级为间隙锁,锁定(10,15)
因此总的锁定范围为 (10,15] 临键锁
案例6:非唯一索引上存在"等值"的例子
给表 t 插入一条新记录:insert into test values(30,10,30);
也就是说,现在表里面有两个col1=10的行。但是它们的主键值 id 是不同的(分别是 10 和 30 ),因此这两个col1=10 的记录之间,也是有间隙的。
| sessionA | sessionB | sessionC |
|---|---|---|
| begin; delete from test where col1=10; |
||
| insert into test values(12,12,12); (blocked) |
||
| update test set col2=col2+1 where col1=15; (query ok) |
这次我们用 delete 语句来验证。注意,delete 语句加锁的逻辑,其实跟 select … for update 是类似的。
这时,session A 在遍历的时候,先访问第一个 col1=10 的记录。同样地,根据原则 1 ,这里加的是
(col1=5,id=5) 到 (col1=10,id=10) 这个 next-key lock 。
由于c是普通索引,所以继续向右查找,直到碰到 (col1=15,id=15) 这一行循环才结束。根据优化 2 ,这是
一个等值查询,向右查找到了不满足条件的行,所以会退化成 (col1=10,id=10) 到 (col1=15,id=15) 的间隙锁。

这个 delete 语句在索引 c 上的加锁范围,就是上面图中蓝色区域覆盖的部分。这个蓝色区域左右两边都
是虚线,表示开区间,即 (col1=5,id=5) 和 (col1=15,id=15) 这两行上都没有锁
在非聚簇索引上,锁住(col1=5,id=5) 到 (col1=15,id=15)
在聚簇索引上,只锁住 col=10 的那两行
案例7:limit语句加锁
例子6也有一个对照案例,场景如下所示:
| sessionA | sessionB |
|---|---|
| begin; delete from test where col1=10 limit 2; |
|
| insert into test values(12,12,12); (Query OK) |
session A 的 delete语句加了 limit 2。你知道表 t里 c=10的记录其实只有两条,因此加不加 limit2,删除的效果都是一样的。但是加锁效果却不一样
这是因为,案例七里的 delete语句明确加了 limit2的限制,因此在遍历到 (col1=10,id=30)这一行之后, 满足条件的语句已经有两条,循环就结束了。因此,索引 col1上的加锁范围就变成了从(col1=5,id=5) 到(col1=10,id=30)这个前开后闭区间,如下图所示:

这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。
这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
1 | select col1 from test where col1 = 5 for share; |
语句1:没有limit的情况下,索引C上锁定范围 (0,5] (5,10)
语句2:存在limit的情况下,索引C上锁定范围 (0,5] ,limit限制只扫描一条,不需要再继续向扫描了。
语句3:存在limit的情况下,索引C上锁定范围 (0,5] (5,10) 因为limit要扫两条,找不到第二条,一直扫到遇上第一条不符合的记录为止,临键锁降级为间隙锁。
案例8:一个死锁的例子
| sessionA | sessionB |
|---|---|
| begin; select id from test where col1=10 lock in share mode; |
|
| update test set col2=col2+1 where col1=10; (blocked) |
|
| insert into test values(8,8,8); | |
| ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
- sessionA启动事务后执行查询语句加 lock in share mode,在索引col1上加了next-keylock(5,10]和间隙锁 (10,15)(索引向右遍历退化为间隙锁);
- sessionB的update语句也要在索引c上加next-keylock(5,10],进入锁等待;实际上分成了两步,先是加 (5,10)的间隙锁,加锁成功;然后加col1=10的行锁,因为sessionA上已经给这行加上了读锁,此时申请死锁时会被阻塞
- sessionA要再插入(8,8,8)这一行,被 sessionB的间隙锁锁住。由于出现了死锁,InnoDB让 sessionB回滚
案例9:order by索引排序的间隙锁1
如下面一条语句
1 | begin; |
下图为这个表的索引id的示意图。

首先这个查询语句的语义是 order by id desc ,要拿到满足条件的所有行,优化器必须先找到第一个 id<12 的值。
这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到 id=12 的这个值,只是最终没找到,但找到了 (10,15) 这个间隙。id=15 不满足条件,所以 next-key lock 退化为了间隙锁 (10,15)
然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到 id=5 这一行,又因为区间是左开右
闭的,所以会加一个next-key lock (0,5] 。 也就是说,在执行过程中,通过树搜索的方式定位记录
的时候,用的是 “ 等值查询 ” 的方法。
案例10:order by索引排序的间隙锁2
| sessionA | sessionB |
|---|---|
| begin; select * from test where col1>=15 and col1<=20 order by col1 desc lock in share mode; |
|
| insert into test values(6,6,6); (blocked) |
|
| insert into test values(11,11,11); (blocked) |
|
| update test set col2 = col2 +1 where id =10;(Query OK) | |
| update test set col2 = col2 +1 where col1=10;(blocked) |
-
由于是order by col1 desc,第一个要定位的是索引col1上最右边的col1=20的行。这是一个非唯一索引的等值查询:
左开右闭区间,首先加上 next-keylock(15,20]。向右遍历,col1=25不满足条件,退化为间隙锁所以会 加上间隙锁(20,25)和 next-keylock(15,20]。
-
在索引 col1 上向左遍历,要扫描到 col1=10 才停下来。同时又因为左开右闭区间,所以 next-key lock会加到 (10,15],这正是阻塞 session B 的 insert into test values(11,11,11)语句的原因。
-
在扫描过程中,col1=20、col1=15、col1=10这三行都存在值,由于是 select *,所以会在主键id上加上行锁。因此,session A的 select语句锁的范围就是:
-
- 索引 c 上 (5,10] (10,15] (15,20](20,25) ;
- 主键索引上 id=15、20两个行锁。
案例11:update的例子
当 UPDATE 修改聚簇索引记录时,会对受影响的二级索引记录隐式加锁。
| sessionA | sessionB |
|---|---|
| begin; update test set col1=1 where id=5; |
|
| update test set col1=5 where col1=1;(blocked) |
SessionA执行Update语句,唯一索引等值查询,会在聚簇索引上加一条行锁,锁id=5这条记录
在二级索引C上,并没有显示的加锁
1 | *************************** row *************************** |
SessionB尝试修改二级索引,导致二级索引上的隐式锁,变为记录锁
1 | *************************** row *************************** |
案例12 Update例子——非唯一索引上的操作
| Session A | SessionB |
|---|---|
| begin update test set col1=4 where col1=5; |
|
非唯一索引等值查询,col1=5存在,索引C上锁定范围 (0,5] (5,10)
插入新的二级索引col1=4,锁定范围(0,4)
1 | *************************** 2. row *************************** |
唯一索引的话,update操作,删除的记录加行锁,新的记录加隐式锁
第16章 多版本并发控制
1. 什么是MVCC
MVCC(Multiversion Concurrency Control)多版本并发控制。顾名思义,MVCC是通过数据行的多个版本管理来实现数据库的并发控制。这项技术使得在InnoDB的事务隔离级别下执行一致性读操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁。
MVCC 没有正式的标准,在不同的 DBMS 中 MVCC 的实现方式可能是不同的,也不是普遍使用的(大家可以参考相关的 DBMS 文档)。这里讲解InnoDB中 MVCC 的实现机制(MySQL其它的存储引擎并不支持它)。
2. 快照读与当前读
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读,而这个读指的就是快照读, 而非当前读。当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC本质是采用乐观锁思想的一种方式。
2.1 快照读
快照读又叫一致性读,读取的是快照数据。不加锁的简单的SELECT都属于快照读,即不加锁的非阻塞读。
1 | SELECT * FROM player WHERE ... |
之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于MVCC,它在很多情况下,避免了加锁操作,降低了开销。
既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。
2.2 当前读
当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的SELECT,或者对数据进行增删改都会进行当前读。
1 | SELECT * FROM student LOCK IN SHARE MODE; # 共享锁 |
3. 复习
3.1 再谈隔离级别
我们知道事务有4个隔离级别,可能存在三种并发问题
在MySQL中,默认的隔离级别是可重复读,可以解决脏读和不可重复读的问题,如果仅从定义的角度来看,它并不能解决幻读问题。如果我们想要解决幻读问题,就需要采用串行化的方式,也就是将隔离级别提升到最高,但这样一来就会大幅降低数据库的事务并发能力。
MVCC可以不采用锁机制,而是通过乐观锁的方式来解决不可重复读和幻读问题!它可以在大多数情况下替代行级锁,降低系统的开销。
针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读。因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,查询不出来这条数据的,所以就很好了避免幻读问题。
针对当前读(select … for update 等语句),是通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读。因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
3.2 隐藏字段、Undo Log版本链
回顾一下undo日志的版本链,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列。
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
举例:student表数据如下
1 | mysql> select *from student; |
假设插入该记录的事务id为8,那么此刻该条记录的示意图如下所示:
insert undo 只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo
Log Segment也会被系统回收(也就是该undo日志占用的Undo页面链表要么被重用,要么被释放)。
假设之后两个事务id分别为10、20的事务对这条记录进行UPDATE操作,操作流程如下:
| 发生时间顺序 | 事务10 | 事务20 |
|---|---|---|
| 1 | BEGIN; | |
| 2 | BEGIN; | |
| 3 | UPDATE student SET name=“李四” WHERE id=1; | |
| 4 | UPDATE student SET name=“王五” WHERE id=1; | |
| 5 | COMMIT; | |
| 6 | UPDATE student SET name=“钱七” WHERE id=1; | |
| 7 | UPDATE student SET name=“宋八” WHERE id=1; | |
| 8 | COMMIT; |
能不能在两个事务中交叉更新同一条记录呢?不能!这不就是一个事务修改了另一个未提交事务修改过的数据,脏写。
InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表:

对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。
每个版本中还包含生成该版本时对应的事务id。
4. MVCC实现原理之ReadView
MVCC 的实现依赖于:隐藏字段、Undo Log、Read View。
4.1 什么是ReadView
在MVCC机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在Undo Log里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到ReadView了,它帮我们解决了行的可见性问题。
ReadView就是事务在使用MVCC机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃”指的就是,启动了但还没提交)。
4.2 设计思路
使用READ UNCOMMITTED隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
使用SERIALIZABLE隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。
使用READ COMMITTED和REPEATABLE READ隔离级别的事务,都必须保证读到已经提交了的事务修改过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView要解决的主要问题。
这个ReadView中主要包含4个比较重要的内容,分别如下:
creator_trx_id,创建这个 Read View 的事务ID。
说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。
-
trx_ids,表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。 -
up_limit_id,活跃的事务中最小的事务ID。 -
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。
举例:
trx_ids为tr2、tr3、tr5和trx8的集合,系统的最大事务ID (low_limit_id)为trx8+1(如果之前没有其他的新增事务),活跃的最小事务ID (up_limit_id)为trx2。
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_id和low_limit_id之间,那就需要判断一下trx_id属性值是不是在trx_ids列表中。- 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
4.4 MVCC整体操作流程
了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过MVCC找到它:
-
首先获取事务自己的版本号,也就是事务ID;
-
获取 ReadView;
-
查询得到的数据,然后与 ReadView 中的事务版本号进行比较;
-
如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;
-
最后返回符合规则的数据。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
InnoDB中,MVCC是通过Undo Log + Read View进行数据读取,Undo Log保存了历史快照,而Read View规则帮我们判断当前版本的数据是否可见。
在隔离级别为读已提交(Read Committed)时,一个事务中的每一次 SELECT 查询都会重新获取一次Read View。
如表所示:
| 事务 | 说明 |
|---|---|
| begin; | |
| select * from student where id >2; | 获取一次Read View |
| … | |
| select * from student where id >2; | 获取一次Read View |
| commit; |
注意,此时同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。
当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次SELECT的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:
| 事务 | 说明 |
|---|---|
| begin; | |
| select * from student where id >2; | 只在第一次 SELECT 的时候 获取一次Read View |
| … | |
| select * from student where id >2; | |
| commit; |
5. 举例说明
假设现在student表中只有一条由事务id为8的事务插入的一条记录:
1 | mysql> select *from student; |
MVCC只能在READ COMMITTED和REPEATABLE READ两个隔离级别下工作。接下来看一下READ COMMITTED和REPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。
5.1 READ COMMITTED隔离级别下
READ COMMITTED :每次读取数据前都生成一个ReadView。
现在有两个事务id分别为10、20的事务在执行:
1 | # Transaction 10 |
说明:事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在事务2中更新些别的表的记录,目的是让它分
配事务id。
此刻,表student 中id为1的记录得到的版本链表如下所示:

假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:
1 | # 使用READ COMMITTED隔离级别的事务 |
这个SELECT1的执行过程如下:
步骤1: 在执行SELECT语句时会先生成一个ReadView, ReadView的trx_ids列表的内容就是[10,20],up_limit_id为10, low_limit_id为21, creator_trx_id为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’王五’,该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name的内容是’李四’,该版本的trx_id值也为10,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
步骤4:下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘张三’的记录。
之后,我们把事务id为 10 的事务提交一下:
1 | # Transaction 10 |
然后再到事务id为 20 的事务中更新一下表student中id为 1 的记录:
1 | # Transaction 20 |
此刻,表student中id为 1 的记录的版本链就长这样:

然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个id为 1 的记录,如下:
1 | # 使用READ COMMITTED隔离级别的事务 |
这个SELECT2的执行过程如下:
步骤1:在执行SELECT语句时会又会单独生成一个ReadView,该ReadView的trx_ids列表的内容就是[20],up_limitid为20,low_limit_id为21, creator_trx_id为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是‘宋八‘,该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name的内容是‘钱七’,该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
步骤4:下一个版本的列name的内容是’王五’,该版本的trx_id值为10,小于ReadView中的up_limit_id值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘王五‘的记录。
以此类推,如果之后事务id为20的记录也提交了,再次在使用READ COMMITTED隔离级别的事务中查询表student中id值为1的记录时,得到的结果就是‘宋八’了,具体流程我们就不分析了。
强调: 使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
5.2 REPEATABLE READ隔离级别下
使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView ,之后的查询就不会重复生成了。
比如,系统里有两个事务id分别为 10 、 20 的事务在执行:
1 | # 开始记录 |
1 | # Transaction 10 |
此刻,表student 中id为 1 的记录得到的版本链表如下所示:

假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:
1 | # 使用REPEATABLE READ隔离级别的事务 |
这个SELECT1的执行过程如下(第一个ReadView和读已提交是一样的):
步骤1: 在执行SELECT语句时会先生成一个ReadView , ReadView的 trx_ids列表的内容就是[10,20],up_limit_id为10, low_limit_id为21, creator_trx_id为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’王五’,该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name的内容是’李四’,该版本的trx_id值也为10,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
步骤4:下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘张三’的记录。
之后,我们把事务id为10的事务提交一下:
1 | # Transaction 10 |
然后再到事务id为 20 的事务中更新一下表student中id为 1 的记录:
1 | # Transaction 20 |
此刻,表student 中id为 1 的记录的版本链长这样:

然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为 1 的记录,如下:
1 | # 使用REPEATABLE READ隔离级别的事务 |
这个SELECT2的执行过程如下:
步骤1:因为当前事务的隔离级别是REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时会直接复用之前的ReadView,之前的ReadView的trx_ids列表的内容是[10,20],up_limit_id为10, low_limit_id为21, creator_trx_id为0。
步骤2:然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’宋八’,该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name的内容是’钱七’,该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
步骤4∶下一个版本的列name的内容是’王五’,该版本的trx_id值为10,而trx_ids列表中是包含值为10的事务id的,所以该版本也不符合要求,同理下一个列name的内容是’李四’的版本也不符合要求。继续跳到下一个版本。
步骤5∶下一个版本的列name的内容是‘张三’,该版本的trx_id值为80,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为’张三’的记录。
两次SELECT查询得到的结果是重复的,记录的列c值都是’张三’,这就是可重复读的含义。如果我们之后再把事务id为20的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是‘张三’,具体执行过程大家可以自己分析一下。
5.3 如何解决幻读
接下来说明InnoDB是如何解决幻读的。
假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图所示。

假设现在有事务 A 和事务 B 并发执行,事务 A的事务 id 为20,事务 B的事务 id 为30。
步骤1:事务 A 开始第一次查询数据,查询的 SQL 语句如下。
1 | select * from student where id >= 1; |
在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 ReadView 的内容如下:trx_ids= [20,30],up_limit_id=20,low_limit_id=31,creator_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 中新插入两条数据,并提交事务。
1 | insert into student(id,name) values(2,'李四'); |
此时表student 中就有三条数据了,对应的 undo 如下图所示:

步骤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 看见。

结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。
6. 总结
这里介绍了MVCC在READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行快照读操作时访问记录的版本链的过程。这样使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
核心点在于 ReadView 的原理,READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同:
READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadViewREPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView。
说明: 我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的。
通过MVCC我们可以解决:
-
读写之间阻塞的问题。通过MVCC可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事
务并发处理能力。 -
降低了死锁的概率。这是因为MVCC采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁
定必要的行。 -
解决快照读的问题。当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结
果,而不能看到这个时间点之后事务提交的更新结果。
第17章 其它数据库日志
在数据库事务篇,讲过两种日志:重做日志、回滚日志。
对于线上数据库应用系统,突然遭遇数据库宕机怎么办?在这种情况下,定位宕机的原因就非常关键。我们可以查看数据库的错误日志。因为日志中记录了数据库运行中的诊断信息,包括了错误、警告和注释等信息。比如:从日志中发现某个连接中的SQL操作发生了死循环,导致内存不足,被系统强行终止了。明确了原因,处理起来也就轻松了,系统很快就恢复了运行。
除了发现错误,日志在数据复制、数据恢复、操作审计,以及确保数据的永久性和一致性等方面,都有着不可替代的作用。
千万不要小看日志 。很多看似奇怪的问题,答案往往就藏在日志里。很多情况下,只有通过查看日志才能发现问题的原因,真正解决问题。所以,一定要学会查看日志,养成检查日志的习惯,对提升你的数据库应用开发能力至关重要。
MySQL8.0 官网日志地址:https://dev.mysql.com/doc/refman/8.0/en/server-logs.html
1. MySQL支持的日志
1.1 日志类型
MySQL有不同类型的日志文件,用来存储不同类型的日志,分为二进制日志、错误日志、通用查询日志和慢查询日志,这也是常用的4种。MySQL8 又新增两种支持的日志:中继日志和数据定义语句日志。使用这些日志文件,可以查看MySQL内部发生的事情。
-
**慢查询日志:**记录所有执行时间超过 long_query_time 的所有查询,方便我们对查询进行优化。
-
**通用查询日志:**记录所有连接的起始时间和终止时间,以及连接发送给数据库服务器的所有指令,对我们复原操作的实际场景、发现问题,甚至是对数据库操作的审计都有很大的帮助。
-
**错误日志:**记录MySQL服务的启动、运行或停止MySQL服务时出现的问题,方便我们了解服务器的状态,从而对服务器进行维护。
-
**二进制日志:**记录所有更改数据的语句,可以用于主从服务器之间的数据同步,以及服务器遇到故障时数据的无损失恢复。
-
**中继日志:**用于主从服务器架构中,从服务器用来存放主服务器二进制日志内容的一个中间文件。从服务器通过读取中继日志的内容,来同步主服务器上的操作。
-
**数据定义语句日志:**记录数据定义语句执行的元数据操作。
除二进制日志外,其他日志都是文本文件。默认情况下,所有日志创建于MySQL数据目录中。
1.2 日志的弊端
-
日志功能会
降低MySQL数据库的性能。例如,在查询非常频繁的MySQL数据库系统中,如果开启了通用查询日志和慢查询日志,MySQL数据库会花费很多时间记录日志。 -
日志会
占用大量的磁盘空间。对于用户量非常大、操作非常频繁的数据库,日志文件需要的存储空 间设置比数据库文件需要的存储空间还要大。
2. 慢查询日志(slow query log)
前面章节《第09章_性能分析工具的使用》已经详细讲述。
3. 通用查询日志(general query log)
通用查询日志用来记录用户的所有操作,包括启动和关闭MySQL服务、所有用户的连接开始时间和截止时间、发给 MySQL 数据库服务器的所有 SQL 指令等。当我们的数据发生异常时,查看通用查询日志,还原操作时的具体场景,可以帮助我们准确定位问题。
3.0 问题场景
在电商系统中,购买商品并且使用微信支付完成以后,却发现支付中心的记录并没有新增,此时用户再次使用支付宝支付,就会出现重复支付的问题。但是当去数据库中查询数据的时候,会发现只有一条记录存在。那么此时给到的现象就是只有一条支付记录,但是用户却支付了两次。
对系统进行了仔细检查,没有发现数据问题,因为用户编号和订单编号以及第三方流水号都是对的。可是用户确实支付了两次,这个时候,我们想到了检查通用查询日志,看看当天到底发生了什么。
查看之后,发现: 1月1日下午2点,用户使用微信支付完以后,但是由于网络故障,支付中心没有及时收到微信支付的回调通知,导致当时没有写入数据。1月1日下午2点30,用户又使用支付宝支付,此时记录更新到支付中心。1月1日晚上9点,微信的回调通知过来了,但是支付中心已经存在了支付宝的记录,所以只能覆盖记录了。
由于网络的原因导致了重复支付。至于解决问题的方案就很多了,这里省略。
可以看到通用查询日志可以帮助我们了解操作发生的具体时间和操作的细节,对找出异常发生的原因极其关键。
3.1 查看当前状态
1 | mysql> SHOW VARIABLES LIKE '%general%'; |
说明1∶ 系统变量 general_log 的值是OFF,即通用查询日志处于关闭状态。在MySQL中,这个参数的默认值是关闭的。因为一旦开启记录通用查询日志,MySQL会记录所有的连接起止和相关的SQL操作,这样会消耗系统资源并且占用磁盘空间。我们可以通过手动修改变量的值,在需要的时候开启日志。
说明2: 通用查询日志文件的名称是主机.log。存储路径是/var/lib/mysql/,也是默认的数据路径。
3.2 启动日志
方式1:永久性方式
修改my.cnf或者my.ini配置文件来设置。在[mysqld]组下加入log选项,并重启MySQL服务。格式如下:
1 | [mysqld] |
如果不指定目录和文件名,通用查询日志将默认存储在MySQL数据目录中的hostname.log文件中,hostname表示主机名。
方式2:临时性方式
1 | SET GLOBAL general_log=on; # 开启通用查询日志 |
3.3 停止日志
方式1:永久性方式
修改my.cnf或者my.ini文件,把[mysqld]组下的general_log值设置为OFF或者把general_log一项注释掉。修改保存后,再重启MySQL服务,即可生效。
1 | [mysqld] |
方式2:临时性方式
使用SET语句停止MySQL通用查询日志功能:
1 | SET GLOBAL general_log=off; |
3.4 查看通用日志
通用查询日志是以文本文件的形式存储在文件系统中的,可以使用文本编辑器直接打开日志文件。每台MySQL服务器的通用查询日志内容是不同的。
从SHOW VARIABLES LIKE 'general_log%';结果中可以看到通用查询日志的位置。
1 | more /var/lib/mysql/主机名.log |
在通用查询日志里面,我们可以清楚地看到,什么时候开启了新的客户端登陆数据库,登录之后做了什么SQL操作,针对的是哪个数据表等信息。
3.5 删除\刷新日志
如果数据的使用非常频繁,那么通用查询日志会占用服务器非常大的磁盘空间。数据管理员可以删除很长时间之前的查询日志,以保证MySQL服务器上的硬盘空间。
手动删除文件
1 | SHOW VARIABLES LIKE 'general_log%'; |
可以看出,通用查询日志的目录默认为MySQL数据目录。在该目录下手动删除通用查询日志 主机名.log。
重新生成查询日志文件
手动删除通用查询日志之后,可以使用此命令重新生成通用查询日志
1 | mysqladmin -uroot -p flush-logs |
刷新MySQL数据目录,发现创建了新的日志文件。前提一定要开启通用日志。
4. 错误日志(error log)
错误日志记录了MySQL服务器启动、停止运行的时间,以及系统启动、运行和停止过程中的诊断信息,包括错误 、警告和提示等。
通过错误日志可以查看系统的运行状态,便于即时发现故障、修复故障。如果MysQL服务出现异常,错误日志是发现问题、解决故障的首选。
4.1 启动日志
在MySQL数据库中,错误日志功能是默认开启的。而且,错误日志无法被禁止。
默认情况下,错误日志存储在MySQL数据库的数据文件夹下,名称默认为mysqld.log(Linux系统)或hostname.err(mac系统)。如果需要制定文件名,则需要在my.cnf或者my.ini中做如下配置:
1 | [mysqld] |
修改配置项后,需要重启MySQL服务以生效。
4.2 查看日志
MySQL错误日志是以文本文件形式存储的,可以使用文本编辑器直接查看。
查询错误日志的存储路径:
1 | SHOW VARIABLES LIKE 'log_err%'; |
执行结果中可以看到错误日志文件是 mysqld.log ,位于MySQL默认的数据目录下。
错误日志文件中记录了服务器启动的时间,以及存储引擎InnoDB启动和停止等,我们在做初
始化时候生成的数据库初始密码也是记录在error.log中。
log_error_verbosity 的取值范围:
1:错误(ERROR)级别2:错误和警告(ERROR, WARNING)3:错误、警告和通知(ERROR, WARNING, NOTE)
1 | [mysqld] |
4.3 删除\刷新日志
对于很久以前的错误日志,数据库管理员查看这些错误日志的可能性不大,可以将这些错误日志删除,以保证MySQL服务器上的硬盘空间。MySQL的错误日志是以文本文件的形式存储在文件系统中的,可以直接删除。
- 第1步(方式1):删除操作
1 | rm -f /var/log/mysqld.log |
在运行状态下删除错误日志文件后,MySQL并不会自动创建日志文件
- 第1步(方式2)︰重命名文件
1 | mv /var/lib/mysql/mysql.log /var/lib/mysql/mysql.log.old |
- 第2步:重建日志
1 | mysqladmin -uroot -p flush-logs |
可能报错
1 | # mysqladmin -uroot -p flush-logs |
补充操作
1 | install -omysql -gmysql -m0644 /dev/null /var/log/mysqld.log |
flush-logs指令操作:
MySQL 5.5.7以前的版本,flush-logs将错误日志文件重命名为filename.err_old,并创建新的日志文件。
从 MySQL 5.5.7开始,flush-logs只是重新打开日志文件,并不做日志备份和创建的操作。
如果日志文件不存在,MySQL启动或者执行flush-logs时会自动创建新的日志文件。重新创建错误日志,大小为0字节。
4.4 MySQL8.0新特性
MySQL8.0里对错误日志的改进。
MySQL8.0的错误日志可以理解为一个全新的日志,在这个版本里,接受了来自社区的广泛批评意见,在这些意见和建议的基础上生成了新的日志。
下面这些是来自社区的意见:
- 默认情况下内容过于冗长
- 遗漏了有用的信息
- 难以过滤某些信息
- 没有标识错误信息的子系统源
- 没有错误代码,解析消息需要识别错误
- 引导消息可能会丢失
- 固定格式
针对这些意见,MySQL做了如下改变:
-
采用组件架构,通过不同的组件执行日志的写入和过滤功能
-
写入错误日志的全部信息都具有唯一的错误代码从10000开始
-
增加了一个新的消息分类《system》用于在错误日志中始终可见的非错误但服务器状态更改事件的消息。
-
增加了额外的附加信息,例如关机时的版本信息,谁发起的关机等等
-
两种过滤方式,Internal和Dragnet
-
三种写入形式,经典、JSON和syseventlog
小结: 通常情况下,管理员不需要查看错误日志。但是,MySQL服务器发生异常时,管理员可以从错误日志中找到发生异常的时间、原因,然后根据这些信息来解决异常。
5. 二进制日志(bin log)
binlog可以说是MySQL中比较重要的日志了,在日常开发及运维过程中,经常会遇到。
binlog即binary log,二进制日志文件,也叫作变更日志(update log)。它记录了数据库所有执行的DDL和DML等数据库更新事件的语句,但是不包含没有修改任何数据的语句(如数据查询语句select、show等)。
它以事件形式记录并保存在二进制文件中。通过这些信息,我们可以再现数据更新操作的全过程。
如果想要记录所有语句(例如,为了识别有问题的查询),需要使用通用查询日志。
binlog主要应用场景:
-
一是用于
数据恢复,如果MySQL数据库意外停止,可以通过二进制日志文件来查看用户执行了哪些操作,对数据库服务器文件做了哪些修改,然后根据二进制日志文件中的记录来恢复数据库服务器。 -
二是用于
数据复制,由于日志的延续性和时效性,master把它的二进制日志传递给slaves来达到master-slave数据一致的目的。
可以说MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。

5.1 查看默认情况
1 | show variables like '%log_bin%'; |
log_bin_basename:是 binlog 日志的基本文件名,后面会追加标识来表示每一个文件
log_bin_index:是 binlog 文件的索引文件,这个文件管理了所有的 binlog 文件的目录
log_bin_trust_function_creators:限制存储过程,前面我们已经讲过了,这是因为二进制日志的一个重要功能是用于主从复制,而存储函数有可能导致主从的数据不一致。所以当开启二进制日志后,需要限制存储函数的创建、修改、调用
log_bin_use_v1_row_events 此只读系统变量已弃用。ON 表示使用版本1二进制日志行,OFF 表示使用版本2二进制日志行(MySQL5.6的默认值为2)。
每次mysql重启都会创建一个新的binlog文件
5.2 日志参数设置
方式1:永久性方式
修改MySQL的my.cnf或my.ini文件可以设置二进制日志的相关参数:
1 | [mysqld] |
提示:
log-bin=mysql-bin #打开日志(主机需要打开),这个mysql-bin也可以自定义,这里也可以加上路径,如:/home/www/mysql_bin_log/mysql-bin
binlog_expire_logs_seconds: 此参数控制二进制日志文件保留的时长单位是
秒默认2592000 30天
–14400 4小时;86400 1天; 259200 3天;
- max_binlog_size: 控制单个二进制日志大小,当前日志文件大小超过此变量时,执行切换动作。此参数的
最大和默认值是1GB,该设置并不能严格控制Binlog的大小,尤其是Binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,可能不做切换日志的动作,只能将该事务的所有SQL都记录进当前日志,直到事务结束。一般情况下可采取默认值。
重新启动MySQL服务,查询二进制日志的信息,执行结果:
1 | show variables like '%log_bin%'; |
设置带文件夹的bin-log日志存放目录
1 | [mysqld] |
注意:新建的文件夹需要使用mysql用户,使用下面的命令即可。
1 | chown -R -v mysql:mysql binlog |
[!important]
数据库文件最好不要与日志文件放在同一个磁盘上!这样,当数据库文件所在的磁盘发生故障时,可以使用日志文件恢复数据。
方式2:临时性方式
如果不希望通过修改配置文件并重启的方式设置二进制日志的话,还可以使用如下指令,需要注意的是在mysql 8中只有会话级别的设置,没有了global级别的设置。
1 | # global 级别 |
5.3 查看日志
当 MySQL 创建二进制日志文件时,先创建一个以 filename 为名称、以 .index 为后缀的文件,再创建一个以 filename 为名称、以 “.000001” 为后缀的文件。
MySQL服务重新启动一次,以数字为后缀的文件就会增加一个,并且后缀名按1递增。即日志文件的个数与MySQL服务启动的次数相同;如果日志长度超过了max_binlog_size的上限(默认是1GB),就会创建一个新的日志文件。
查看当前的二进制日志文件列表及大小。指令如下:
1 | mysql> SHOW BINARY LOGS; |
所有对数据库的修改都会记录在binglog中。但binlog是二进制文件,无法直接查看,需要借助mysqlbinlog命令。
mysqlbinlog 命令
先执行一条SQL语句
1 | insert into test values (1); |
查看binlog
1 | mysqlbinlog "/var/lib/mysql/binlog.000001"; |
1 | at 235 |
执行结果可以看到,这是一个简单的日志文件,日志中记录了用户的一些操作,这里并没有出现具体的SQL语句,这是因为binlog关键字后面的内容是经过编码后的二进制日志。
① 一条Insert语句包含的事件分析
一条Insert语句包含如下事件
- Query事件负责开始一个事务(BEGIN)
- Table_map事件负责映射需要的表
- Write_rows事件负责写入数据**(行事件)**
- Xid事件负责结束事务
1 | mysqlbinlog -v "/var/lib/mysql/binlog.000001"; |
② 查看行事件伪SQL语句
下面命令将行事件以伪SQL的形式表现出来
1 | mysqlbinlog -v "/var/lib/mysql/binlog.000001"; |
-
--base64-output:指定如何处理事件行的 Base64 编码输出。 -
DECODE-ROWS:告诉mysqlbinlog对 Base64 编码的行事件(如Write_rows、Update_rows和Delete_rows)进行解码,并以可读的伪SQL格式输出。
仅使用 -v 会同时输出行时间 BINLOG语句 和 伪SQL语句
同时使用 -v --base64-output=DECODE-ROWS 则只输出伪SQL语句
1 | mysqlbinlog -v "/var/lib/mysql/binlog.000001"; |
可以看到,之前插入语句的伪码形式
1 | ### INSERT INTO `dbtest_log`.`test` |
使用 -v --base64-output=DECODE-ROWS 以下部分会消失
1 | BINLOG ' |
[!important]
BINLOG语句,虽然人不可读,但是可以在mysql中执行。可以用于数据的恢复。
所以恢复数据时,切记不要加
--base64-output=DECODE-ROWS选项
③ 常用的语句参考
1 | # 可查看参数帮助 |
show binlog events
上面这种办法读取出binlog日志的全文内容比较多,不容易分辨查看到pos点信息,下面介绍一种更为方便的查询命令:
1 | 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:查询总条数(不指定就是所有行)
1 | mysql> show binlog events in 'binlog.000001'; |

这里一个Insert语句包含如下事件
- Query事件负责开始一个事务(BEGIN)
- Table_map事件负责映射需要的表
- Write_rows事件负责写入数据
- Xid事件负责结束事务
上面这条语句可以将指定的binlog日志文件,分成有效事件行的方式返回,并可使用limit指定pos点的起始偏移,查询条数。其它举例:
1 | #a、查询第一个最早的binlog日志: |
上面我们讲了这么多都是基于binlog的默认格式,binlog格式查看
1 | show variables like 'binlog_format'; |
除此之外,binlog 还有 2 种格式,分别是 Statement 和 Mixed
-
Statement每一条会修改数据的sql都会记录在binlog中。
优点:不需要记录每一行的变化,减少了binlog日志量,节约了IO,提高性能。
-
Row5.1.5版本的MySQL才开始支持 row level 的复制,它不记录sql语句上下文相关信息,仅保存哪条记录被修改。
优点:row level 的日志内容会非常清楚的记录下每一行数据修改的细节。而且不会出现某些特定情况下的存储过程,或function,以及trigger的调用和触发无法被正确复制的问题。
-
Mixed从5.1.8版本开始,MySQL提供了Mixed格式,实际上就是Statement与Row的结合。
详细情况,下章讲解。
比如:一条SQL语句修改了10行数据,Statement 只记录,这条SQL语句。Row不记录SQL语句,但会记录每一行数据的变化。
5.4 删除二进制日志
MySQL的二进制文件可以配置自动删除,同时MySQL也提供了安全的手动删除二进制文件的方法。PURGE MASTER LOGS只删除指定部分的二进制日志文件,RESET MASTER删除所有的二进制日志文件。具体如下:
1)PURGE MASTER LOGS:删除指定日志文件
1 | -- 删除指定文件之前的所有日志 |
cron 计划任务每天执行一次删除任务,只保留一天的binlog日志
1 | mysql -u root -p -e "PURGE BINARY LOGS BEFORE NOW() - INTERVAL 1 DAY;" |
举例
SHOW语句显示二进制日志文件列表
1 | SHOW BINARY LOGS; |
执行PURGE MASTER LOGS语句删除创建时间比binlog.000005早的所有日志
1 | PURGE MASTER LoGS TO "binlog.000005"; # 不会删除005 |
使用PURGE MASTER LOGs语句删除2022年1月05日前创建的所有日志文件
1 | PURGE MASTER LOGS before "20220105"; |
2)RESET MASTER:删除所有二进制日志文件
使用 RESET MASTER 语句,清空所有的binlog日志。MySQL会重新创建二进制文件,新的日志文件扩展 名将重新从000001开始编号。慎用!
1 | RESET MASTER |
5.5 使用日志恢复数据
如果MySQL服务器启用了二进制日志,在数据库出现意外丢失数据时,可以使用MySQLbinlog工具从指定的时间点开始(例如,最后一次备份)直到现在或另一个指定的时间点的日志中恢复数据。
不是从无到有恢复数据,是从备份点开始恢复数据,比如每日备份数据库
当天的数据库数据丢失,就从昨天的备份点开始,
从binlog中提取今天所有执行过的更新操作,然后执行在昨天的备份的基础上。
mysqlbinlog恢复数据的语法如下:
1 | 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之前恢复。
1)恢复数据前的操作
在恢复数据前,先刷新日志,生成新的二进制日志文件
执行 FLUSH LOGS 关闭当前的 binlog 文件并创建一个新的 binlog 文件。
目的是为了和之前的二进制日志文件分隔,因为恢复操作,也会写入到二进制日志中
1 | mysql> flush logs; |
1 | mysql> show binary logs; |
2)通过position恢复数据
通过 show binlog events 查看SQL的position
1 | mysql>show binlog events IN 'log_name'; |
连续的语句,XID 是挨着的,可以根据 XID 去查找事务开始的 BEGIN 的 POSITION
XID 代表一个事务的编号,XID的BEGIN,代表事务的开始。XID的End_lod_pos是事务结束的位置。
一个事务结束的位置End_log_pos也是下一个事务的BEGIN位置。
1 | /usr/bin/mysqlbinlog |
[!important]
恢复数据时,切记不要加此选项
--base64-output=DECODE-ROWS此选项会导致Insert语句恢复失效。
因为此参数为了方便人为查看,将人不可读但mysql可以执行的SQL语句,转换成了伪SQL语句,伪SQL语句不可执行,显示在注释部分,仅方便查看。
可以使用
-v参数,此参数会同时保留SQL和可以查看的伪SQL语句
3)根据时间恢复数据
从指定的二进制日志文件中提取在特定时间范围内的SQL语句,并将这些语句保存到binlog.sql文件中
1 | mysqlbinlog --no-defaults \ |
从多个二进制日志(binlog)文件中批量提取SQL语句
- 可以在
mysqlbinlog命令中直接列出所有需要处理的binlog文件
1 | mysqlbinlog --no-defaults \ |
- 使用通配符匹配多个binlog文件
1 | mysqlbinlog --no-defaults \ |
注意
binlog.0*,避免将binlog.index匹配进去注意
binlog.00000[0-8]可以指定具体的范围
- 使用循环批量处理
通过Shell脚本或命令行循环处理多个文件
1 | for binlog in /path/to/binlog.*; do |
- 使用binlog索引文件
MySQL会维护一个二进制日志索引文件(默认为/var/lib/mysql/binlog.index)
可以利用它来读取所有binlog文件
1 | mysqlbinlog --no-defaults \ |
执行SQL脚本
使用 source 命令执行 SQL 文件
1 | mysql -u user -p |
输入重定向
1 | mysql -u 用户名 -p 数据库名 < /path/to/your_file.sql |
管道符
可以通过管道符(|)将提取 SQL 语句的命令和执行 SQL 脚本的命令连接起来
1 | usr/bin/mysqlbinlog |
4)时间恢复 VS Position恢复
精确性:时间点恢复包含时间范围内的多个事务,而某些情况下只需要恢复部分事务。使用 POS 能更精确地控制恢复范围。
主从复制场景:在主从复制的修复中,时间点无法直接用作 CHANGE MASTER TO 的参数,必须使用日志文件名和 POS。
1 | CHANGE MASTER TO |
事务一致性:使用时间点恢复时,可能会不小心跨越事务边界。通过 POS 可以确保完整的事务被恢复。
可以先根据时间点提取日志,确定大致POS范围。再根据POS精准恢复。
5)模拟案例
1 | # 建库建表 |
模拟删除表
1 | drop table test; |
通过position恢复
1 | flush logs; |

1 | /usr/bin/mysqlbinlog \ |
分两步也是可以的
1 | /usr/bin/mysqlbinlog \ |
1 | mysql> source /root/binlog.sql |
通过时间恢复
根据大致的时间点,提取sql语句
1 | mysqlbinlog --no-defaults \ |
查看binlog.sql,确定更加精确的时间范围,或者手动删除不需要的sql语句。
1 | source /root/binlog.sql |
6)第三方binlog恢复工具
原生的mysqlbinlog无法根据某张表去筛选
binlog2sql MyFlash
5.6 其他场景
二进制日志可以通过数据库的全量备份和二进制日志中保存的增量信息,完成数据库的无损失恢复。但是,如果遇到数据量大、数据库和数据表很多(比如分库分表的应用)的场景,用二进制日志进行数据恢复,是很有挑战性的,因为起止位置不容易管理。
在这种情况下,一个有效的解决办法是配置主从数据库服务器,甚至是一主多从的架构,把二进制日志文件的内容通过中继日志,同步到从数据库服务器中,这样就可以有效避免数据库故障导致的数据异常等问题。
6. 再谈二进制日志(binlog)
6.1 写入机制
binlog的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache。
我们可以通过binlog_cache_size参数控制单个线程binlog cache大小,如果存储内容超过了这个参 数,就要暂存到磁盘(Swap)。binlog日志刷盘流程如下:

上图的write,是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快
上图的fsync,才是将数据持久化到磁盘的操作
write和fsync的时机,可以由参数sync_binlog控制,默认是0。为0的时候,表示每次提交事务都只write,由系统自行判断什么时候执行fsync。虽然性能得到提升,但是机器宕机,page cache里面的binglog 会丢失。如下图:

为了安全起见,可以设置为1,表示每次提交事务都会执行fsync,就如同 redo log 刷盘流程一样。
最后还有一种折中方式,可以设置为N(N>1),表示每次提交事务都write,但累积N个事务后才fsync。

在出现IO瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。同样的,如果机器宕机,会丢失最近N个事务的binlog日志。
6.2 binlog与redolog对比
- redo log 它是
物理日志,记录内容是“在某个数据页上做了什么修改”,属于InnoDB存储引擎层产生的。 - 而 binlog 是
逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server 层。 - 虽然它们都属于持久化的保证,但是侧重点不同。
- redo log 让InnoDB存储引擎拥有了崩溃恢复能力。
- binlog保证了MySQL集群架构的数据一致性
6.3 两阶段提交
在执行更新语句过程,会记录redo log与binlog两块日志,以基本的事务为单位,redo log在事务执行过程中可以不断写入,而binlog只有在提交事务时才写入,所以redo log与binlog的写入时机不一样。

redo log与binlog两份日志之间的逻辑不一致,会出现什么问题?
以update语句为例,假设id=2的记录,字段c值是0,把字段c值更新成1,SQL语句为update T set c=1 where id=2。
假设执行过程中写完redo log日志后,binlog日志写期间发生了异常,会出现什么情况呢?

由于binlog没写完就异常,这时候binlog里面没有对应的修改记录。
因此,之后用binlog日志恢复数据时,就会少这一次更新,恢复出来的这一行c值是0,而原库因为redo log日志恢复,这一行c值是1,最终数据不一致。

为了解决两份日志之间的逻辑一致问题,InnoDB存储引擎使用两阶段提交方案。原理很简单,将redo log的写入拆成了两个步骤prepare和commit,这就是两阶段提交。

使用两阶段提交后,写入binlog时发生异常也不会有影响,因为MySQL根据redo log日志恢复数据时, 发现redolog还处于prepare阶段,并且没有对应binlog日志,就会回滚该事务。

另一个场景,redo log设置commit阶段发生异常,那会不会回滚事务呢?

并不会回滚事务,它会执行上图框住的逻辑,虽然redo log是处于prepare阶段,但是能通过事务id找到对应的binlog日志,所以MySQL认为是完整的,就会提交事务恢复数据。
7. 中继日志(relay log)
7.1 介绍
中继日志只在主从服务器架构的从服务器上存在。从服务器为了与主服务器保持一致,要从主服务器读取二进制日志的内容,并且把读取到的信息写入本地的日志文件中,这个从服务器本地的日志文件就叫中继日志。然后,从服务器读取中继日志,并根据中继日志的内容对从服务器的数据进行更新,完成主从服务器的数据同步。
搭建好主从服务器之后,中继日志默认会保存在从服务器的数据目录下。
文件名的格式是:从服务器名-relay-bin.序号。
中继日志还有一个索引文件:从服务器名-relaybin.index,用来定位当前正在使用的中继日志。
7.2 查看中继日志
中继日志与二进制日志的格式相同,可以用mysqlbinlog工具进行查看。下面是中继日志的一个片段:
1 | SET TIMESTAMP=1618558728/*!*/; |
这一段的意思是,主服务器(“server id 1”)对表 atguigu.test 进行了 2 步操作:
1 | 定位到表 atguigu.test 编号是 91 的记录,日志位置是 832; |
7.3 恢复的典型错误
如果从服务器宕机,有的时候为了系统恢复,要重装操作系统,这样就可能会导致你的服务器名称与之前不同。而中继日志里是包含从服务器名的。在这种情况下,就可能导致你恢复从服务器的时候,无法从宕机前的中继日志里读取数据,以为是日志文件损坏了,其实是名称不对了。
解决的方法也很简单,把从服务器的名称改回之前的名称。
第18章 主从复制
1. 主从复制概述
1.1 如何提升数据库并发能力
在实际工作中常常将Redis作为缓存与MySQL配合来使用,当有请求的时候,首先会从缓存中进行查找,如果存在就直接取出。如果不存在再访问数据库,这样就提升了读取的效率,也减少了对后端数据库的访问压力 。Redis的缓存架构是高并发架构中非常重要的一环。

此外,一般应用对数据库而言都是“读多写少”,也就说对数据库读取数据的压力比较大,有一个思路就是采用数据库集群的方案,做主从架构、进行读写分离,这样同样可以提升数据库的并发处理能力。但并不是所有的应用都需要对数据库进行主从架构的设置,毕竟设置架构本身是有成本的。
如果我们的目的在于提升数据库高并发访问的效率,那么首先考虑的是如何优化SQL和索引,这种方式简单有效;其次才是采用缓存的策略,比如使用Redis将热点数据保存在内存数据库中,提升读取的效率;最后才是对数据库采用主从架构,进行读写分离。
按照上面的方式进行优化,使用和维护的成本是由低到高的。
1.2 主从复制的作用
主从同步设计不仅可以提高数据库的吞吐量,还有以下3个方面的作用。
第1个作用:读写分离。
我们可以通过主从复制的方式来同步数据,然后通过读写分离提高数据库并发处理能力。
其中一个是Master主库,负责写入数据,我们称之为:写库。
其它都是Slave从库,负责读取数据,我们称之为:读库。
当主库进行更新的时候,会自动将数据复制到从库中,而在客户端读取数据的时候,会从从库中进行读取。
面对读多写少的需求,采用读写分离的方式,可以实现更高的并发访问。同时还能对从服务器进行负载均衡,让不同的读请求按照策略均匀地分发到不同的从服务器上,让读取更加顺畅。读取顺畅的另一个原因,就是 减少了锁表的影响,比如我们让主库负责写,当主库出现写锁的时候,不会影响到从库进行SELECT的读取。
第2个作用就是数据备份。
通过主从复制将主库上的数据复制到了从库上,相当于是一种热备份机制,也就是在主库正常运行的情况下进行的备份,不会影响到服务。
第3个作用是具有高可用性。
数据备份实际上是一种冗余的机制,通过这种冗余的方式可以换取数据库的高可用性,也就是当服务器出现故障或宕机的情况下,可以切换到从服务器上,保证服务的正常运行。
关于高可用性的程度,我们可以用一个指标衡量,即正常可用时间/全年时间。比如要达到全年99.999%的时间都可用,就意味着系统在一年中的不可用时间不得超过365*24*60*(1-99.999%)=5.256分钟(含系统崩溃的时间、日常维护操作导致的停机时间等),其他时间都需要保持可用的状态。
实际上,更高的高可用性,意味着需要付出更高的成本代价。在现实中需要结合业务需求和成本来进行选择。
2. 主从复制的原理
Slave会从Master读取binlog来进行数据同步。
2.1 原理剖析
三个线程
实际上主从同步的原理就是基于binlog进行数据同步的。在主从复制过程中,会基于3个线程来操作,一个主库线程,两个从库线程。

二进制日志转储线程(Binlog dump thread)是一个主库线程。当从库线程连接的时候,主库可以将二进制日志发送给从库,当主库读取事件(Event)的时候,会在 Binlog 上加锁,读取完成之后,再将锁释放掉。
从库 I/O 线程 会连接到主库,向主库发送请求更新Binlog。这时从库的 I/O 线程就可以读取到主库的二进制日志转储线程发送的 Binlog 更新部分,并且拷贝到本地的中继日志(Relay log)。
从库 SQL 线程 会读取从库中的中继日志,并且执行日志中的事件,将从库中的数据与主库保持同步。

注意:
不是所有版本的MySQL都默认开启服务器的二进制日志。在进行主从同步的时候,需要先检查服务器是否已经开启了二进制日志。
除非特殊指定,默认情况下从服务器会执行所有主服务器中保存的事件。也可以通过配置,使从服务器执行特定的事件。
复制三步骤
步骤1:Master将写操作记录到二进制日志(binlog)。这些记录叫做二进制日志事件 (binary log events);
步骤2:Slave将Master的binary log events拷贝到它的中继日志(relay log);
步骤3:Slave重做中继日志中的事件,将改变应用到自己的数据库中。MySQL复制是异步的且串行化的,而且重启后从接入点开始复制。
复制的问题
复制的最大问题:延时
2.2 复制的基本原则
-
每个
Slave只有一个Master -
每个
Slave只能有一个唯一的服务器ID -
每个
Master可以有多个Slave
3. 一主一从架构搭建
一台主机用于处理所有写请求,一台从机负责所有读请求,架构图如下:

3.1 准备工作
-
准备2台CentOS 虚拟机
-
每台虚拟机上需要安装好MySQL (可以是MySQL8.0 )
可以在一台CentOS上安装好MySQL,进而通过克隆的方式复制出1台包含MySQL的虚拟机。
注意:克隆的方式需要修改新克隆出来主机的:①MAC地址 ②hostname ③IP 地址 ④UUID。
hostname
1 | vim /etc/hostname |
IP地址和主机的UUID
1 | vim /etc/sysconfig/network-scripts/ifcfg-ens33 |
MySQLServer的UUID
此外,克隆的方式生成的虚拟机(包含MySQL Server),则克隆的虚拟机MySQL Server的UUID相同,必
须修改,否则在有些场景会报错。比如:show slave status\G,报如下的错误:
1 | Last_IO_Error: Fatal error: The slave I/O thread stops because master and slave have |
修改MySQL Server 的UUID方式:
1 | vim /var/lib/mysql/auto.cnf |
3.2 主机配置文件
配置文件/etc/my.cnf
建议mysql版本一致且后台以服务运行,主从所有配置项都配置在[mysqld]节点下,且都是小写字母。
具体参数配置如下:
- 必选
1 | #[必须]主服务器唯一ID |
- 可选
1 | #[可选] 0(默认)表示读写(主机),1表示只读(从机) |
binlog-do-db 用于指定需要写入二进制日志的数据库。只有在这个选项中列出的数据库的更改才会记录到二进制日志中。
binlog-do-db 的行为与当前使用的数据库有关,即 USE 语句选择的数据库。如果没有在 USE 中选择目标数据库,即使影响到了 binlog-do-db 指定的数据库,也不会被记录。因为 MySQL 检查的是“当前选中数据库”是否匹配 binlog-do-db,而不是检查 SQL 语句中是否直接涉及了相关的数据库。
重启后台mysql服务,使配置生效
注意:
先搭建完主从复制,再创建数据库。
MySQL主从复制起始时,从机不继承主机数据。
排除系统库
主从复制,可以排除mysql的系统库
1 | replicate-ignore-db = mysql |
系统库如 mysql、information_schema、performance_schema 和 sys 包含了元数据、性能统计信息和系统视图,这些数据是特定于每个服务器的,在从库上同步这些数据通常是无意义的。
如果同步 mysql 数据库,可能会导致主从库的用户、权限信息不一致或被覆盖。例如,从库的用户权限可能需要与主库不同。
系统库中的表(如 performance_schema)记录动态性能数据,这些数据更新频繁且仅对本地有意义,同步它们会增加不必要的负载。
binlog格式设置:
1 | SHOW VARIABLES LIKE 'binlog_format'; |
格式1:STATEMENT模式(基于SQL语句的复制(statement-based replication, SBR))
1 | binlog_format=STATEMENT |
每一条会修改数据的sql语句会记录到binlog中。这是默认的binlog格式。
-
SBR 的优点:
-
- 历史悠久,技术成熟
- 不需要记录每一行的变化,减少了binlog日志量,文件较小
- binlog中包含了所有数据库更改信息,可以据此来审核数据库的安全等情况
- binlog可以用于实时的还原,而不仅仅用于复制
- 主从版本可以不一样,从服务器版本可以比主服务器版本高
-
SBR 的缺点:
-
不是所有的UPDATE语句都能被复制,尤其是包含不确定操作的时候
使用以下函数的语句也无法被复制:LOAD_FILE()、UUID()、USER()、FOUND_ROWS()、SYSDATE() (除非启动时启用了 --sysdate-is-now 选项)
-
-
- INSERT … SELECT 会产生比 RBR 更多的行级锁
- 复制需要进行全表扫描(WHERE 语句中没有使用到索引)的 UPDATE 时,需要比 RBR 请求更多的
行级锁 - 对于有 AUTO_INCREMENT 字段的 InnoDB表而言,INSERT 语句会阻塞其他 INSERT 语句
- 对于一些复杂的语句,在从服务器上的耗资源情况会更严重,而 RBR 模式下,只会对那个发生变化的记录产生影响
- 执行复杂语句如果出错的话,会消耗更多资源
- 数据表必须几乎和主服务器保持一致才行,否则可能会导致复制出错
② ROW模式(基于行的复制(row-based replication, RBR))
1 | binlog_format=ROW |
5.1.5版本的MySQL才开始支持,不记录每条sql语句的上下文信息,仅记录哪条数据被修改了,修改成什
么样了。
- RBR 的优点:
- 任何情况都可以被复制,这对复制来说是最
安全可靠的。(比如:不会出现某些特定情况下的存储过程、function、trigger的调用和触发无法被正确复制的问题) - 多数情况下,从服务器上的表如果有主键的话,复制就会快了很多
- 复制以下几种语句时的行锁更少:INSERT … SELECT、包含 AUTO_INCREMENT 字段的 INSERT、没有附带条件或者并没有修改很多记录的 UPDATE 或 DELETE 语句
- 执行 INSERT,UPDATE,DELETE 语句时锁更少
- 从服务器上采用
多线程来执行复制成为可能
- 任何情况都可以被复制,这对复制来说是最
- RBR 的缺点:
-
- binlog大了很多
- 复杂的回滚时binlog中会包含大量的数据
- 主服务器上执行UPDATE语句时,所有发生变化的记录都会写到binlog中,而SBR只会写一次,这会导致频繁发生binlog的并发写问题
- 无法从binlog中看到都复制了些什么语句
③ MIXED模式(混合模式复制(mixed-based replication, MBR))
1 | binlog_format=MIXED |
从5.1.8版本开始,MySQL提供了Mixed格式,实际上就是Statement与Row的结合。
在Mixed模式下,一般的语句修改使用statment格式保存binlog。如一些函数,statement无法完成主从 复制的操作,则采用row格式保存binlog。
MySQL会根据执行的每一条具体的sql语句来区分对待记录的日志形式,也就是在Statement和Row之间选择一种。
3.3 从机配置文件
要求主从所有配置项都配置在my.cnf的[mysqld]栏位下,且都是小写字母。
- 必选
1 | #[必须]从服务器唯一ID |
- 可选
1 | #[可选]启用中继日志 |
重启后台mysql服务,使配置生效。
3.4 主机:建立账户并授权
1 | # 5.5,5.7 |
注意:如果使用的是MySQL8,需要如下的方式建立账户,并授权slave:
1 | CREATE USER 'slave1'@'%' IDENTIFIED BY '123456'; |
注意:在从机执行show slave status\G时报错:
Last_IO_Error: error connecting to master ‘slave1@192.168.1.150:3306’ - retry-time:60 retries:1 message: Authentication plugin ‘caching_sha2_password’ reported error:Authentication requires secure connection.
查询Master的状态,并记录下File和Position的值。
1 | show master status; |
记录下File和Position的值
注意:执行完此步骤后不要再操作主服务器MySQL,防止主服务器状态值变化。
3.5 从机:配置需要复制的主机
步骤1:从机上复制主机的命令
1 | CHANGE MASTER TO |
如果报以下错误,说明以前配置过从库,从库还在运行,需要先停止
1 | ERROR 1198 (HY000): This operation cannot be performed with a running slave; run STOP SLAVE first |
1 | mysql> STOP SLAVE; |
步骤2:启动slave同步 START SLAVE;
1 | #启动slave同步 |
如果报以下错误,说明存在以前的中继日志,需要先重置
1 | ERROR 1872 (HY000): Slave failed to initialize relay log info structure from the repository |
可以执行如下操作,删除之前的relay_log信息。然后重新执行CHANGE MASTER TO ...语句即可。
1 | reset slave; # 删除SLAVE数据库的relaylog日志文件,并重新启用新的relaylog文件 |
重置从机配置
1 | # 必须先停止从机复制,才能重置从机的配置 |
RESET SLAVE 作用:清除从库的复制状态,但不删除 MASTER_HOST 等主库配置信息。
- 删除从库中保存的二进制日志的相关位置和状态。
- 但保留通过
CHANGE MASTER TO配置的主库信息(如MASTER_HOST、MASTER_USER、MASTER_PASSWORD等)
RESET SLAVE ALL 作用:清除从库的复制状态,同时删除所有主库配置信息。
- 删除从库中保存的二进制日志相关位置和状态。
- 清除主库配置信息(
MASTER_HOST、MASTER_USER、MASTER_PASSWORD、MASTER_LOG_FILE等)。 - 完全将从库恢复到未配置复制的初始状态。
接着,查看同步状态:
1 | SHOW SLAVE STATUS\G; |
1 | # 如果两个结果都是yes,说明从机同步成功 |
显式如下的情况,就是不正确的。可能错误的原因有:
1 | Slave_IO_Running: Connecting |
- 网络不通
- 账户密码错误
- 防火墙
- mysql配置文件问题
- 连接服务器时语法
- 主服务器mysql权限
3.6 查看主从同步情况
1 | SHOW SLAVE STATUS\G; |
Replica_IO_Running: IO 线程是否运行。Replica_SQL_Running: SQL 线程是否运行。
1 | # 如果两个结果都是yes,说明从机同步成功 |
-
Replica_IO_State: IO 线程的状态,显示是否正在从主库读取 binlog。 -
Seconds_Behind_Master: 副本与主库的延迟时间,单位为秒。 -
Last_IO_Error / Last_SQL_Error: 最近一次 IO 或 SQL 错误的信息。如果从机同步失败查看此参数。
3.7 测试
主机新建库、新建表、insert记录,从机复制:
1 | CREATE DATABASE dbtest_master_slave; |
1 | SHOW VARIABLES LIKE 'hostname'; |
3.8 停止主从同步
1 | stop slave; |
1 | # 删除Master中所有的binglog文件,并将日志索引文件清空,重新开始所有新的日志文件 |
3.9 搭建主从架构常见问题
1)caching_sha2_password
1 | SHOW SLAVE STATUS\G |
mysql8.0以后,caching_sha2_password 成为了默认的用户认证插件。MySQL主从服务器之间的用户认证也使用此插件。这种认证方式默认要求加密连接(如 SSL/TLS),以提高安全性。如果未启用加密连接,从库在同步时会报此错误。
可能报错 Access denied for user ‘slave1’@‘xxx’ (using password: YES)
这个也可能是SSL连接的问题
处理方案
- 方法一:在主服务器上修改认证插件
1 | ALTER USER 'slave1'@'%' IDENTIFIED WITH 'mysql_native_password' BY 'your_password'; |
修改my.cnf文件,启动mysql_native_password插件
1 | [mysqld] |
- 方案二:从机
CHANGE MASTER命令配置SSL连接参数
1 | CHANGE MASTER TO |
只要执行过一次,就算之后
RESET REPLICA ALL第二次change master就不要加MASTER_SSL=1;了
- 禁用加密连接要求
如果确定主从连接在受信任的网络环境中运行,可以禁用加密连接要求。
这个方法在对插件caching_sha2_password 无效,默认就是REQUIRE NONE
但是改为REQUIRE SSL,必须要求从机使用MASTER_SSL=1;,否则报错Access denied for user 'slave1'@'xxx' (using password: YES)
在主库上修改用户认证的安全连接需求
1 | ALTER USER 'slave1'@'%' REQUIRE NONE; # 默认的 |
此命令不改变加密插件的类型,可以通过如下命令验证
1 | SELECT user, host, plugin |
2)联通性问题
1 | Slave_IO_Running: Connecting |
在从库服务器尝试手动连接主库,确认网络连通性和用户认证是否正常
1 | mysql -h <ip> -P 3306 -u slave1 -p |
3.10 后续
搭建主从复制:双主双
一个主机m1用于处理所有写请求,它的从机s1和另一台主机m2还有它的从机s2负责所有读请求。当m1主机宕机后,m2主机负责写请求,m1、m2互为备机。架构图如下:


4. 同步数据一致性问题
主从同步的要求:
-
读库和写库的数据一致(最终一致);
-
写数据必须写到写库;
-
读数据必须到读库(不一定);
4.1 理解主从延迟问题
进行主从同步的内容是二进制日志,它是一个文件,在进行网络传输的过程中就一定会存在主从延迟(比如 500ms),这样就可能造成用户在从库上读取的数据不是最新的数据,也就是主从同步中的数据不一致性问题。
**举例:**导致主从延迟的时间点主要包括以下三个:
- 主库A执行完成一个事务,写入binlog,我们把这个时刻记为T1;
- 之后传给从库B,我们把从库B接收完这个binlog的时刻记为T2;
- 从库B执行完成这个事务,我们把这个时刻记为T3。
4.2 主从延迟问题原因
在网络正常的时候,日志从主库传给从库所需的时间是很短的,即T2-T1的值是非常小的。即,网络正常情况下,主备延迟的主要来源是备库接收完binlog和执行完这个事务之间的时间差。
**主备延迟最直接的表现是,从库消费中继日志(relay log)的速度,比主库生产binlog的速度要慢。**造成原因:
- 从库的机器性能比主库要差
- 从库的压力大
- 大事务的执行
**举例1:**一次性用delete语句删除太多数据
结论:后续再删除数据的时候,要控制每个事务删除的数据量,分成多次删除。
**举例2:**一次性用insert…select插入太多数据
举例3: 大表DDL
比如在主库对一张500W的表添加一个字段耗费了10分钟,那么从节点上也会耗费10分钟。
4.3 如何减少主从延迟
若想要减少主从延迟的时间,可以采取下面的办法:
-
降低多线程大事务并发的概率,优化业务逻辑
-
优化SQL,避免慢SQL,
减少批量操作,建议写脚本以update-sleep这样的形式完成。 -
提高从库机器的配置,减少主库写binlog和从库读binlog的效率差。 -
尽量采用
短的链路,也就是主库和从库服务器的距离尽量要短,提升端口带宽,减少binlog传输的网络延时。 -
实时性要求的业务读强制走主库,从库只做灾备,备份。
4.4 如何解决一致性问题
如果操作的数据存储在同一个数据库中,那么对数据进行更新的时候,可以对记录加写锁,这样在读取的时候就不会发生数据不一致的情况。但这时从库的作用就是备份,并没有起到读写分离,分担主库读压力的作用。
读写分离情况下,解决主从同步中数据不一致的问题,就是解决主从之间数据复制方式的问题,如果按照数据一致性从弱到强来进行划分,有以下3种复制方式。
方法1:异步复制
异步模式就是客户端提交COMMIT之后不需要等从库返回任何结果,而是直接将结果返回给客户端,这样做的好处是不会影响主库写的效率,但可能会存在主库宕机,而Binlog还没有同步到从库的情况,也就是此时的主库和从库数据不一致。这时候从从库中选择一个作为新主,那么新主则可能缺少原来主服务器中已提交的事务。所以,这种复制模式下的数据一致性是最弱的。

方法2:半同步复制
MySQL5.5版本之后开始支持半同步复制的方式。原理是在客户端提交COMMIT之后不直接将结果返回给客户端,而是等待至少有一个从库接收到了Binlog,并且写入到中继日志中,再返回给客户端。
这样做的好处就是提高了数据的一致性,当然相比于异步复制来说,至少多增加了一个网络连接的延迟,降低了主库写的效率。
在MySQL5.7版本中还增加了一个rpl_semi_sync_master_wait_for_slave_count参数,可以对应答的 从库数量进行设置,默认为1,也就是说只要有1个从库进行了响应,就可以返回给客户端。如果将这个参数调大,可以提升数据一致性的强度,但也会增加主库等待从库响应的时间。【以时间换取一致性】

方法3:组复制
异步复制和半同步复制都无法最终保证数据的一致性问题,半同步复制是通过判断从库响应的个数来决定是否返回给客户端,虽然数据一致性相比于异步复制有提升,但仍然无法满足对数据一致性要求高的场景,比如金融领域。MGR 很好地弥补了这两种复制模式的不足。
组复制技术,简称 MGR(MySQL Group Replication)。是 MySQL 在 5.7.17 版本中推出的一种新的数据复制技术,这种复制技术是基于 Paxos 协议的状态机复制。
MGR是如何工作的
首先我们将多个节点共同组成一个复制组,在执行读写(RW)事务的时候,需要通过一致性协议层 (Consensus层)的同意,也就是读写事务想要进行提交,必须要经过组里“大多数人”(对应Node节点)的同意,大多数指的是同意的节点数量需要大于(N/2+1),这样才可以进行提交,而不是原发起方一个说了算。而针对只读(RO)事务 则不需要经过组内同意,直接COMMIT即可。
在一个复制组内有多个节点组成,它们各自维护了自己的数据副本,并且在一致性协议层实现了原子消息和全局有序消息,从而保证组内数据的一致性。

MGR 将 MySQL 带入了数据强一致性的时代,是一个划时代的创新,其中一个重要的原因就是 MGR 是基于 Paxos 协议的。Paxos 算法是由 2013 年的图灵奖获得者 Leslie Lamport 于 1990 年提出的,有关这个算法的决策机制可以搜一下。事实上,Paxos 算法提出来之后就作为分布式一致性算法被广泛应用,比如 Apache 的 ZooKeeper 也是基于 Paxos 实现的。
5. 知识延伸
在主从架构的配置中,如果想要采取读写分离的策略,我们可以自己编写程序,也可以通过第三方的中间件来实现。
- 自己编写程序的好处就在于比较自主,我们可以自己判断哪些查询在从库上来执行,针对实时性要求高的需求,我们还可以考虑哪些查询可以在主库上执行。同时,程序直接连接数据库,减少了中间件层,相当于减少了性能损耗。
- 采用中间件的方法有很明显的优势,
功能强大,使用简单。但因为在客户端和数据库之间增加了中间件层会有一些性能损耗,同时商业中间件也是有使用成本的。我们也可以考虑采取一些优秀的开源工具。

① Cobar 属于阿里B2B事业群,始于2008年,在阿里服役3年多,接管3000+个MySQL数据库的schema,集群日处理在线SQL请求50亿次以上。由于Cobar发起人的离职,Cobar停止维护。
② Mycat 是开源社区在阿里cobar基础上进行二次开发,解决了cobar存在的问题,并且加入了许多新的功能在其中。青出于蓝而胜于蓝
③ OneProxy 基于MySQL官方的proxy思想利用c语言进行开发的,OneProxy是一款商业收费的中间件。舍弃了一些功能,专注在性能和稳定性上。
④ kingshard 由小团队用go语言开发,还需要发展,需要不断完善。
⑤ Vitess 是Youtube生产在使用,架构很复杂。不支持MySQL原生协议,使用需要大量改造成本。
⑥ Atlas 是360团队基于mysql proxy改写,功能还需完善,高并发下不稳定。
⑦ MaxScale 是mariadb(MySQL原作者维护的一个版本)研发的中间件
⑧ MySQLRoute 是MySQL官方Oracle公司发布的中间件
主备切换:

- 主动切换
- 被动切换
- 如何判断主库出问题了?如何解决过程中的数据不一致性问题?
第19章 数据库备份与恢复
在任何数据库环境中,总会有不确定的意外情况发生,比如例外的停电、计算机系统中的各种软硬件故障、人为破坏、管理员误操作等是不可避免的,这些情况可能会导致数据的丢失、服务器瘫痪等严重的后果。存在多个服务器时,会出现主从服务器之间的数据同步问题。
为了有效防止数据丢失,并将损失降到最低,应定期对MySQL数据库服务器做备份。如果数据库中的数据丢失或者出现错误,可以使用备份的数据进行恢复。主从服务器之间的数据同步问题可以通过复制功能实现。
1. 物理备份与逻辑备份
物理备份:备份数据文件,转储数据库物理文件到某一目录。物理备份恢复速度比较快,但占用空间比较大,MySQL中可以用xtrabackup工具来进行物理备份。
逻辑备份:对数据库对象利用工具进行导出工作,汇总入备份文件内。逻辑备份恢复速度慢,但占用空间小,更灵活。MySQL中常用的逻辑备份工具为mysqldump。逻辑备份就是备份sql语句,在恢复的时候执行备份的sql语句实现数据库数据的重现。
2. mysqldump实现逻辑备份
mysqldump是MySQL提供的一个非常有用的数据库备份工具。
2.0 准备数据
1 | CREATE DATABASE dbtest_backup; |
2.1 备份一个数据库
mysqldump命令执行时,可以将数据库备份成一个文本文件,该文件中实际上包含多个 CREATE 和 INSERT 语句,使用这些语句可以重新创建表和插入数据。
- 查出需要备份的表的结构,在文本文件中生成一个CREATE语句。
- 将表中的所有记录转换成一条INSERT语句。
基本语法:
1 | mysqldump –u 用户名称 –h 主机名称 –p密码 待备份的数据库名称[tbname, [tbname...]]> 备份文件名称.sql |
说明:
备份的文件并非一定要求后缀名为.sql,例如后缀名为.txt的文件也是可以的。
举例:使用root用户备份dbtest_backup数据库:
1 | mysqldump -uroot -p dbtest_backup>dbtest_backup.sql #备份文件存储在当前目录下 |
备份文件剖析:
1 | -- MySQL dump 10.13 Distrib 8.4.3, for Linux (x86_64) |
-
--开头的都是SQL语句的注释; -
以
/*!开头、*/结尾的语句为可执行的MySQL注释,这些语句可以被MySQL执行,但在其他数据库管理系统中被作为注释忽略,这可以提高数据库的可移植性; -
文件开头指明了备份文件使用的MySQLdump工具的版本号;接下来是备份账户的名称和主机信息,以及备份的数据库的名称;最后是MySQL服务器的版本号,在这里为8.4.3。
-
备份文件接下来的部分是一些SET语句,这些语句将一些系统变量值赋给用户定义变量,以确保被恢复的数据库的系统变量和原来备份时的变量相同,例如:
1
2
3# 该SET语句将当前系统变量character_set_client的值赋给用户定义变量@old_character_set_client,其他变量与此类似。
/*!40101SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT*/; -
备份文件的最后几行MySQL使用SET语句恢复服务器系统变量原来的值,例如:
1
2
3# 该语句将用户定义的变量@old_character_set_client中保存的值赋给实际的系统变量character_set_client。
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -
后面的DROP语句、CREATE语句和INSERT语句都是还原时使用的。例如,
DROP TABLE IF EXISTS student语句用来判断数据库中是否还有名为student的表,如果存在,就删除这个表;CREATE语句用来创建student的表;INSERT语句用来还原数据。 -
备份文件开始的一些语句以数字开头。这些数字代表了MySQL版本号,告诉我们这些语句只有在制定的MySQL版本或者比该版本高的情况下才能执行。例如,40101表明这些语句只有在MySQL版本号为4.01.01或者更高的条件下才可以被执行。文件的最后记录了备份的时间。
2.2 备份全部数据库
若想用mysqldump备份整个实例,可以使用--all-databases或-A参数:
1 | mysqldump -uroot -pxxxxxx --all-databases > all_database.sql |
备份除了系统库以外的所有库
1 | mysqldump -uroot -pxxxxxx --databases $(mysql -uroot -pxxxxxx -N -e "SHOW DATABASES WHERE \`Database\` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')") > user_database.sql |
2.3 备份部分数据库
使用 --databases 或 -B 参数,该参数后面跟数据库名称,多个数据库间用空格隔开。如果指定
databases参数,备份文件中会存在创建数据库的语句,如果不指定参数,则不存在。语法如下:
1 | mysqldump –u user –h host –p --databases [数据库的名称1 [数据库的名称2...]] > 备份文件名 称.sql |
1 | mysqldump -uroot -p --databases atguigu atguigu2 > two_database.sql |
2.4 备份部分表
比如,在表变更前做个备份。语法如下:
1 | mysqldump –u user –h host –p 数据库的名称 [表名1 [表名2...]] > 备份文件名称.sql |
举例:备份dbtest_backup数据库下的class表
1 | mysqldump -uroot -p dbtest_backup class > class.sql |
备份表的备份文件不存在数据库的删除和创建语句。
2.5 备份单表的部分数据
有些时候一张表的数据量很大,我们只需要部分数据。这时就可以使用--where选项。where后面附带需要满足的条件。
1 | mysqldump -uroot -p dbtest_backup class --where="id <= 3 " > dbtest_backup_part_id3_low_bak.sql |
2.6 排除某些表的备份
如果我们想备份某个库,但是某些表数据量很大或者与业务关联不大,这个时候可以考虑排除掉这些表,同样的,选项 --ignore-table 可以完成这个功能。
1 | mysqldump -uroot -p dbtest_backup --ignore-table=dbtest_backup.student > no_stu_bak.sql |
通过如下命令判断备份文件中没有student表结构
1 | grep "student" no_stu_bak.sql |
2.7 只备份结构或只备份数据
只备份结构的话可以使用--no-data简写为-d选项;
只备份数据可以使用--no-create-info简写为-t选项。
- 只备份结构
1 | mysqldump -uroot -p dbtest_backup --no-data > dbtest_backup_no_data_bak.sql |
1 | 使用grep命令,没有找到insert相关语句,表示没有数据备份。 |
- 只备份数据
1 | mysqldump -uroot -p dbtest_backup --no-create-info > dbtest_backup_no_create_info_bak.sql |
1 | # 使用grep命令,没有找到create相关语句,表示没有数据结构。 |
2.8 备份中包含存储过程、函数、事件
mysqldump备份默认是不包含存储过程,自定义函数及事件的。可以使用--routines或-R选项来备份存储过程及函数,使用--events或-E参数来备份事件。
举例:备份整个dbtest_backup库,包含存储过程及事件:
- 使用下面的SQL可以查看当前库有哪些存储过程或者函数
1 | SELECT SPECIFIC_NAME,ROUTINE_TYPE,ROUTINE_SCHEMA |
下面备份dbtest_backup库的数据,函数以及存储过程。
1 | mysqldump -uroot -p -R -E --databases dbtest_backup > fun_dbtest_backup_bak.sql |
查询备份文件中是否存在函数
1 | grep -C 5 "rand_string" fun_dbtest_backup_bak.sql |
2.9 mysqldump常用选项
运行帮助命令 mysqldump --help ,可以获得特定版本的完整选项列表。
mysqldump其他常用选项如下:
1 | --add-drop-database:在每个CREATE DATABASE语句前添加DROP DATABASE语句。 |
提示
如果运行mysqldump没有–quick或–opt选项,mysqldump在转储结果前将整个结果集装入内存。如果转储大数据库可能会出现问题,该选项默认启用,但可以用–skip-opt禁用。如果使用最新版本的mysqldump程序备份数据,并用于恢复到比较旧版本的MySQL服务器中,则不要使用–opt或-e选项。
3. mysql命令恢复数据
使用mysqldump命令将数据库中的数据备份成一个文本文件。需要恢复时,可以使用mysql命令来恢复备份的数据。
mysql命令可以执行备份文件中的CREATE语句和INSERT语句。通过CREATE语句来创建数据库和表。通过INSERT语句来插入备份的数据。
基本语法:
1 | mysql –u root –p [dbname] < backup.sql |
其中,dbname参数表示数据库名称。该参数是可选参数,可以指定数据库名,也可以不指定。指定数据库名时,表示还原该数据库下的表。此时需要确保MySQL服务器中已经创建了该名的数据库。不指定数据库名时,表示还原文件中所有的数据库。此时SQL文件中包含有CREATE DATABASE语句,不需要MySQL服务器中已存在这些数据库。
3.1 单库备份中恢复单库
如果备份文件中包含了创建数据库的语句,则恢复的时候不需要指定数据库名称,否则需要指定数据库名称
1 | 备份文件中包含了创建数据库的语句 |
3.2 全量备份恢复
1 | mysql –u root –p < all.sql |
3.3 从全量备份中恢复单库
可能有这样的需求,比如说我们只想恢复某一个库,但是我们有的是整个实例的备份,这个时候我们可
以从全量备份中分离出单个库的备份。
1 | sed -n '/^-- Current Database: `dbtest_backup`/,/^-- Current Database: `/p' all_database.sql > dbtest_backup.sql |
3.4 从单库备份中恢复单表
这个需求还是比较常见的。比如说我们知道哪个表误操作了,那么就可以用单表恢复的方式来恢复。
举例:我们有dbtest_backup整库的备份,但是由于class表误操作,需要单独恢复出这张表。
1 | cat dbtest_backup.sql | sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `class`/!d;q' > class_structure.sql |
4. MyISAM存储引擎类型的表物理备份和恢复
4.1 MyISAM表数据备份
直接将MySQL中的数据库文件复制出来。这种方法最简单,速度也最快。MySQL的数据库目录位置不一定相同:
- 在Windows平台下,MySQL8.0存放数据库的目录通常默认为
C:\ProgramData\MySQL\MySQL Server 8.0\Data或者其他用户自定义目录; - 在Linux平台下,数据库目录位置通常为
/var/lib/mysql/; - 在MAC OSX平台下,数据库目录位置通常为
/usr/local/mysql/data
如果在备份数据库的过程中还有数据的写入,会造成数据不一致。为了保证数据的一致性,可以采用以下两种方法。
- 方式1:备份前,将服务器停止。
- 方式2:备份前,对相关表执行
FLUSH TABLES WITH READ LOCK操作。这样当复制数据库目录中的文件时,允许其他客户继续查询表。同时,FLUSH TABLES语句来确保开始备份前将所有激活的索引页写入硬盘。
这种方式方便、快速,但不是最好的备份方法,因为实际情况可能不允许停止MySQL服务器或者锁住表,而且这种方法对InnoDB存储引擎的表不适用。对于MyISAM存储引擎的表,这样备份和还原很方便,但是还原时最好是相同版本的MySQL数据库,否则可能会存在文件类型不同的情况。
注意,物理备份完毕后,执行UNLOCK TABLES来结算其他客户对表的修改行为。
说明:在MySQL版本号中,第一个数字表示主版本号,主版本号相同的MySQL数据库文件格式相同。
此外,还可以考虑使用相关工具实现备份。比如,MySQLhotcopy工具。MySQLhotcopy是一个Perl脚本,它使用LOCK TABLES、FLUSH TABLES和cp或scp来快速备份数据库。它是备份数据库或单个表最快的途径,但它只能运行在数据库目录所在的机器上,并且只能备份MyISAM类型的表。多用于mysql5.5之前。
4.2 MyISAM存储引擎类型的表的物理备份恢复流程
前面说过,可以通过直接复制数据的操作备份数据。通过这种方式备份的数据,可以直接被复制到 MySQL 的数据目录下。
在MySQL服务器停止运行后,将备份的数据库文件复制到MySQL的数据目录下,重启MySQL服务即可。
注意点:
-
通过这种方式恢复数据时,必须确保备份数据的数据库和待恢复的数据库服务器的主版本号相同。
因为只有MySQL数据库主版本号相同时,才能保证这两个MySQL数据库文件类型是相同的。
-
这种方式对
MyISAM类型的表比较有效,对于InnoDB类型的表则不可用。因为InnoDB表的表空间不能直接复制。
-
Linux操作系统下的权限设置非常严格。通常情况下,MySQL数据库只有root用户和mysql用户组下的mysql用户才可以访问,因此将数据库目录复制到指定文件夹后,一定要使用chown命令将文件夹的用户组变为mysql,将用户变为mysql。
1
chown -R mysql.mysql /var/lib/mysql/dbname
其中,两个mysql分别表示组和用户;“-R”参数可以改变文件夹下的所有子文件的用户组;“dbname”参数表示数据库目录。
演示案例
现在的需求是先备份dbtest_myisam库,然后使用rm命令删除该库,最后通过备份的数据库文件恢复该库,具体操作步骤如下。
(0)建库建表插数据
1 | create database dbtest_myisam; |
(1)dbtest_myisam库想要获得一致的备份,需要关闭该库,或者锁定并刷新相关表。执行如下语句。
1 | mysql> USE dbtest_myisam; |
FLUSH TABLES ... WITH READ LOCK可以指定表名,也可以不指定表名
当指定表名时,
FLUSH TABLES会将指定的表关闭,将表的任何未刷新的数据刷新到磁盘。并对指定的表加读锁。如果不指定表名,
FLUSH TABLES会关闭所有打开的表,并将所有表的更改刷新到磁盘。会加一个全局的读锁。MyISAM引擎的表,只支持表级锁,不支持行级锁
SELECT * FROM performance_schema.metadata_locks能查到部分锁结构
(2)把数据目录下的 dbtest_myisam 文件复制到/opt目录下,如下所示,完成数据库备份。
1 | cd /var/lib/mysql |
(3)在dbtest_myisam库中执行UNLOCK TABLES命令,如下所示。
1 | mysql> UNLOCK TABLES; |
(4)在dbtest_myisam库中执行删除数据操作,如下所示,这时表test中的数据会被删除。
1 | mysql> DELETE FROM test; |
(5)删除数据目录下的dbtest_myisam文件夹及其中的文件,如下所示。
1 | cd /var/lib/mysql/ |
(6)重新复制文件到数据目录下,如下所示。
1 | cd /var/lib/mysql/ |
(7)重启服务器,查看表test中的数据,如下所示。这时会报错,因为该表处于只读状态。
1 | systemctl restart mysqld |
1 | mysql>select * FROM test; |
(8)在dbtest_myisam库中给目标端文件授予权限,如下所示
1 | chown -R mysql.mysql /var/lib/mysql/dbtest_myisam |
(9)在dbtest_myisam库中再次查看test中的数据,如下所示。
1 | select * FROM test; |
至此,MyISAM存储引擎类型的表的物理备份和恢复就完成了。
5.InnoDB存储引擎类型的表的物理导入导出
MyISAM 存储引擎类型的表的物理备份和恢复方案并不适用于 InnoDB 存储引擎类型的表。
假设现在把dbtest库中表test的.ibd文件复制到目标库dbtest2的数据目录下。对于MyISAM存储引擎类型的表,这样操作是没问题的;但是,对于InnoDB存储引擎类型的表,这样操作是不可行的。
因为一张InnoDB存储引擎类型的表除了包含着两个物理文件,还需要在数据字典中进行注册。如果直接将这两个物理文件拷贝到目标库的数据目录中,目标库的数据字典中没有对应的表test,系统是不会识别和接受的。
在 MySQL5.6 中引入了可传输表空间(Transportable Tablespace),可以通过导出和导入表空间的方式来实现物理复制表的功能。
**注意:**可传输表空间仅支持独立表空间中的表,不支持系统表空间和通用表空间中的表。
假设现在的需求是在 dbtest2 库中复制一张与表 test 相同的表 test_bak,具体操作步骤如下:
(0)建库建表插入数据
1 | create database dbtest; |
(1)在 dbtest2 库中执行如下 SQL 语句,创建一张具有相同表结构的空表。
1 | USE dbtest2; |
(2)在 dbtest2 库中执行如下 SQL 语句,这时 test_bak.ibd 文件会被删除。
1 | ALTER TABLE test_bak DISCARD TABLESPACE; |
(3)在 dbtest 库中执行如下 SQL 语句,这时在 dbtest 库的数据目录下会生成一个 test.cfg 文件。
1 | USE dbtest; |
(4)在 dbtest 库的数据目录下复制物理文件,如下所示:
1 | cp test.cfg ../dbtest2/test_bak.cfg |
(5)在 dbtest2 库中执行 UNLOCK TABLES 命令,如下所示,这时 test.cfg 文件会被删除。
1 | UNLOCK TABLES; |
(6)在 dbtest2 库的数据目录下给目标端文件授予权限,如下所示:
1 | cd /var/lib/mysql/dbtest2 |
(7)在 dbtest2 库中执行如下SQL语句,将 test_bak.ibd 文件作为表 test_bak 的表空间。由于这个文件中的内容和 test.ibd 文件中的内容是相同的,因此表 test_bak 拥有表 test 相同的结构和数据。
1 | ALTER TABLE test_bak IMPORT TABLESPACE; |
(8)在 dbtest2 库中查看表test_bak中的数据。如下所示:
1 | select * from test; |
6. 表的导出与导入
6.1 表的导出
1. 使用SELECT…INTO OUTFILE导出文本文件
在MySQL中,可以使用 SELECT…INTO OUTFILE 语句将表中的数据导出到文本文件中。其语法如下所示。
1 | SELECT columnlist FROM table WHERE condition INTO OUTFILE 'filename' [OPTIONS] |
SELECT columnlist FROM table WHERE condition 是一条查询语句,查询结果返回满足指定条件的一条或多条记录;
INTO OUTFILE 语句的作用是把前面SELECT语句查询出来的结果导出到名为 filename 的外部文件中;
[OPTIONS] 为可选项。OPTIONS 部分的语法包括 FIELDS 和 LINES 子句,其可能的取值如下
FIELDS TERMINATED BY 'value':用于设置字段之间的分隔字符。value 取值可以是单个或多个字符,默认为制表符’\t’。FIELDS [OPTIONALLY] ENCLOSED BY 'value':用于设置字段的包围字符。value 取值只能是单个字符。如果使用了 OPTIONALLY 关键字,则只能包括 CHAR、VARCHAR 等字符字段。FIELDS ESCAPED BY 'value':用于设置如何写入或读取特殊字符。value取值只能是单个字符,即设置转义字符,默认为反斜杠’\'。LINES STARTING BY 'value':用于设置每行数据开头的字符。value取值可以是单个或多个字符,在默认情况下不使用任何字符。LINES TERMINATED BY 'value':用于设置每行数据结尾的字符。value取值可以是单个或多个字符,默认值为换行符’\n’。
注意,FIELDS 和 LINES 子句都是可选的。如果这两个子句都被指定了,那么 FIELDS 子句必须位于LINES 子句的前面。
使用 SELECT…INTO OUTFILE 语句可以非常快速地把一张表转储到服务器上。想要在服务器端主机之外的部分客户端主机上创建结果文件,就不能使用 SELECT...INTO OUTFILE 语句。在这种情况下,应该在客户端主机上使用 MySQL -e "SELECT..." > filename 这样的命令来生成文件。
下面使用 SELECT…INTO OUTFILE 将 dbtest 数据库下表test中的数据导出到文本文件。
(1)选择数据库dbtest,并查询test表,执行结果如下所示。
1 | mysql>use dbtest; |
(2)mysql默认对导出的目录有权限限制,也就是说,在使用命令行进行导出的时候,需要指定目录进行操作。查询secure_file_priv参数的值:
1 | SHOW GLOBAL VARIABLES LIKE '%secure%'; |
参数secure_file_priv的可选值和作用分别是:
- 如果设置为empty,表示不限制文件生成的位置,这是不安全的设置;
- 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
- 如果设置为NULL,就表示禁止在这个MySQL实例上执行
select...into outfile操作。
(3)上面结果中显示,secure_file_priv变量的值为 /var/lib/mysql-files/,将导出目录设置为该目录,SQL语句如下。
1 | SELECT * FROM test INTO OUTFILE "/var/lib/mysql-files/test.txt"; |
(4)查看 /var/lib/mysql-files/test.txt 文件。
1 | more /var/lib/mysql-files/test.txt |
2. 使用mysqldump命令导出文本文件
使用 mysqldump 命令可以备份数据库,将数据导出为包含 CREATE、INSERT 语句的SQL文件。不仅如此,使用 mysqldump 命令还可以将数据导出为纯文本文件,语法如下所示。
1 | mysqldump -u root -p password -T path dbname [tables] [OPTIONS] |
只有指定了-T参数才可以导出文本文件
path 表示导出数据的目录;tables 为要导出的表名,如果不指定表名,则将导出数据库 dbname 中所有的表;[OPTIONS]为可选项,这些选项需要结合-T 参数使用。OPTIONS 部分的语法与 SELECT…INTO OUTFILE 语句中的 OPTIONS 部分的语法相同,这里不再赘述。
例如,将 dbtest 库下表 test 中的数据导出到文本文件中,命令如下所示。
1 | mysqldump -uroot -p -T "/var/lib/mysql-files/" dbtest test |
执行上述命令后,将在指定目录/var/lib/mysql-files/下生成 test.sql 和 test.txt 文件。
打开 test.sql 文件,其中包含创建 test 的 CREATE 语句,如下所示。
1 | # more test.sql |
打开 test.txt 文件,其中只包含表text中的数据,如下所示
1 | # more test.txt |
可以对导出的数据设置特定格式。例如,使用FIELDS选项,要求字段之间用使用逗号分隔,字符串类型的字段值使用双引号引起来。命令如下所示:
1 | mysqldump -uroot -p -T "/var/lib/mysql-files/" dbtest test |
3. 使用mysql命令导出文本文件
mysql 是一个功能丰富的工具命令,使用该命令可以在命令模式下执行 SQL 语句,并将查询结果导入文本文件中。相比 mysqldump 命令,mysql 命令导出的结果的可读性更强。mysql 命令的语法如下所示。
1 | mysql -u root -p --execute="SELECT 语句" dbname > filename.txt |
该命令使用了–execute 选项,表示执行该选项后面的语句并退出,该语句必须用双引号引起来;
dbname 为要导出的数据库名;在导出的文件中,不同的列之间使用制表符分隔,第一行中包含各个字
段名。
举例1: 使用 MySQL 命令将 dbtest 库中 test 表的数据导出到文本文件中,具体操作步骤如下:
1 | mysql -uroot -p --execute="SELECT * FROM test;" dbtest > "/var/lib/mysql-files/test.txt" |
可以看到,text.txt 文件中包含每个字段名和各条记录,显示格式与 MySQL 命令行的 SELECT 查询结果格式一致。
在使用 MySQL 命令时,还可以指定查询结果显示格式。如果表中一条记录包含的字段较多,一行不能完全显示,则可以使用 --vertical 参数将一条记录分多行显示。
举例2: 分行导出 dbtest 库下 test 表的数据到文本文件中,具体操作步骤如下:
使用 --vertical 参数分行导出
1 | mysql -uroot -p --vertical --execute="SELECT * FROM test;" dbtest > "/var/lib/mysql-files/test2.txt" |
示例3: 导出 dbtest 库下 test 表的数据到 HTML 文件中,具体操作步骤如下:
使用 --html 参数
1 | mysql -uroot -p --html --execute="SELECT * FROM test;" dbtest > "/var/lib/mysql-files/test3.html" |
| id |
|---|
| 1 |
| 2 |
| 3 |
举例4:导出dbtest库下表test的数据到XML文件中,具体操作步骤如下:
使用--XML参数
1 | mysql -uroot -p --xml --execute="SELECT * FROM test;" dbtest > "/var/lib/mysql-files/test4.xml" |
1 | # more test4.xml |
6.2 导入文本文件
在 MySQL 中,既可以将数据导出到外部文件中,也可以从外部文件导入数据。MySQL 提供了导入数据的工具,包括 LOAD DATA INFILE 语句和 mysqlimport 命令。
1. 使用LOAD DATA INFILE语句导入文本文件
LOAD DATA INFILE 语句用于从一个文本文件中读取行,并装入一张表中,文件名必须是字符串。
LOAD DATA INFILE 语句的语法如下所示:
1 | LOAD DATA [LOCAL] INFILE filename INTO TABLE tablename [OPTIONS] [IGNORE number LINES] |
在 LOAD DATA INFILE 语句中,关键字 INFILE 后面的 filename 文件为导入数据的来源;tablename 表示待导入的表名;[OPTIONS] 为可选项,OPTIONS 部分的语法与 SELECT…INTO OUTFILE 语句中的 OPTIONS 部分相同,这里不再赘述;IGNORE number LINES 表示忽略文件开头处的行数,number 表示忽略的行数。执行该语句需要 FILE 权限。
1)简单案例:不使用FIELDS选项备份、导入数据
使用 SELECT…INTO OUTFILE 语句将 atguigu 库下表 account 中的数据导出到文本文件中,如下所示:
1 | mysql> SELECT * FROM dbtest.test INTO OUTFILE '/var/lib/mysql-files/test.txt'; |
删除表 test 中的数据,执行完后查看表中的数据,如下所示
1 | mysql> DELETE FROM dbtest.test; |
从文本文件 test.txt 中恢复数据,如下所示:
1 | mysql> LOAD DATA INFILE '/var/lib/mysql-files/test.txt' INTO TABLE dbtest.test; |
再次查询表 test 中的数据,如下所示:
1 | mysql> SELECT * FROM test; |
2)复杂案例:导入CSV文件
1 | LOAD DATA INFILE '/path/to/yourfile.csv' |
FIELDS TERMINATED BY ',':指定CSV文件中的字段分隔符。
ENCLOSED BY '"':指定字段值的引号。
LINES TERMINATED BY '\n':指定行结束符。
IGNORE 1 ROWS:忽略第一行表头。
如果需要导入的表结构和数据并不匹配,比如表结构存在自增的id字段。就需要显式的指定字段名。
注意换行符
windows 的换行符是
\r\nLinux 的换行符是\n
2. 使用mysqlimport语句导入文本文件
mysqlimport命令也可以导入文本文件,而且不需要登录MySQL客户端。mysqlimport命令提供了许多和LOAD DATA INFILE 语句相同的功能,大多数参数直接对应LOAD DATA INFILE 语句。
mysqlimport命令的语法如下所示:
1 | mysqlimport -uroot -p dbname filename.txt [OPTIONS] |
其中,dbname 为导入的表所在的数据库名。注意,mysqlimport命令不指定导入数据库的表名,表名由导入文件名确定,即文件名作为表名,在导入数据之前该表必须存在。
[OPTIONS]为可选项,OPTIONS 部分的语法与 SELECT...INTO OUTFILE 语句中 OPTIONS 部分的语法相同,这里不再赘述。
例如,使用 mysqlimport 命令将 account.txt 文件的内容导入 atguigu 库下的表 account 中,字段之间使用逗号分隔,字符串类型的字段值使用双引号引起来,具体操作步骤如下。
(1) 将 atguigu 库下表 account 中的数据导出到 account.txt 文件中,字段之间使用逗号分隔,字符串类型的字段值使用双引号引起来,如下所示。
1 | mysql> SELECT * FROM atguigu.account INTO OUTFILE '/var/lib/mysql-files/account.txt' |
(2) 删除表 account 中的数据,执行完后查看表中的数据,如下所示。
1 | mysql> DELETE FROM atguigu.account; |
(3) 使用 mysqlimport 命令将 account.txt 文件的内容导入 atguigu 库下的表 account 中,如下所示。
1 | mysqlimport -uroot -p atguigu '/var/lib/mysql-files/account.txt' --fields-terminated-by=',' --fields-optionally-enclosed-by='"' |
(4) 再次查询表 account 中的数据,如下所示。
1 | mysql> SELECT * FROM account; |
除了前面介绍的几个选项之外,mysqlimport支持其他选项,常见的选项有:
- –columns=column_list,-c column_list:该选项采用逗号分隔的列名作为其值。列名的顺序只是如何匹配数据文件列和表列。
- –compress,-C:压缩在客户端和服务器之间发送的所有信息(如果二者均支持压缩)
- -d,–delete:导入文本文件前清空表。
- –force,-f:忽视错误。例如,如果某个文本文件的表不存在,就继续处理其他文件。不使用–
force,若表不存在,则mysqlimport退出。 - –host=host_name,-h host host_name:将数据导入给定主机上的MySQL服务器,默认主机是
localhost。 - –ignore,-i:参见–replace选项的描述。
- –ignore-lines=n:忽视数据文件的前n行。
- –local,-L:从本地客户端读入输入文件。
- –lock-tables,-l:处理文本文件前锁定所有表,以便写入。这样可以确保所有表在服务器上保持同
步。 - –password[=password],-p[password]:当连接服务器时使用的密码。如果使用短选项形式(-p),选项和密码之间不能有空格。如果在命令行中–password或-p选项后面没有密码值,就提示输入一个密码。
- –port=port_num,-P port_num:用户连接的TCP/IP端口号。
- –protocol={TCP|SOCKET|PIPE|MEMORY}:使用的连接协议。
- -replace,-r–replace和–ignore选项控制复制唯一键值已有记录的输入记录的处理。如果指定–
replace,新行替换有相同唯一键值的已有行;如果指定–ignore,复制已有唯一键值的输入行被跳
过;如果不指定这两个选项,当发现一个复制键值时会出现一个错误,并且忽视文本文件的剩余部分。 - –silent,-S:沉默模式。只有出现错误时才输出信息。
- –user=username,-u user_name:当连接服务器时MysQL使用的用户名。
- –verbose,-V:冗长模式。打印出程序操作的详细信息。
- –version,-V:显示版本信息并退出。
7. 数据库的迁移
7.1 概述
数据迁移(data migration)是指选择、准备、提取和转换数据,并将数据从一个计算机存储系统永久
地传输到另一个计算机存储系统的过程。此外,验证迁移数据的完整性和退役原来旧的数据存储,也被认为是整个数据迁移过程的一部分。
数据库迁移的原因是多样的,包括服务器或存储设备更换、维护或升级,应用程序迁移,网站集成,灾难恢复和数据中心迁移。
根据不同的需求可能要采取不同的迁移方案,但总体来讲,MySQL数据迁移方案大致可以分为物理迁移和逻辑迁移两类。通常以尽可能自动化的方式执行,从而将人力资源从繁琐的任务中解放出来。
7.2 迁移方案
- 物理迁移
物理迁移适用于大数据量下的整体迁移。使用物理迁移方案的优点是比较快速,但需要停机迁移并且要求MySQL版本及配置必须和原服务器相同,也可能引起未知问题。
物理迁移包括拷贝数据文件和使用XtraBackup备份工具两种。
不同服务器之间可以采用物理迁移,我们可以在新的服务器上安装好同版本的数据库软件,创建好相同目录,建议配置文件也要和原数据库相同,然后从原数据库方拷贝来数据文件及日志文件,配置好文件组权限,之后在新服务器这边使用mysqld命令启动数据库。
- 逻辑迁移
逻辑迁移适用范围更广,无论是部分迁移还是全量迁移,都可以使用逻辑迁移。逻辑迁移中使用最多的就是通过mysqldump等备份工具。
7.3 迁移注意点
1. 相同版本的数据库之间迁移注意点
指的是在主版本号相同的MySQL数据库之间进行数据库移动。
**方式 1:**因为迁移前后MySQL数据库的主版本号相同,所以可以通过复制数据库目录来实现数据库迁移,但是物理迁移方式只适用于MyISAM引擎的表。对于InnoDB表,不能用直接复制文件的方式备份数据库。
**方式 2:**最常见和最安全的方式是使用mysqldump命令导出数据,然后在目标数据库服务器中使用MySQL命令导入。
举例:
1 | # host1的机器中备份所有数据库 ,并将数据库迁移到名为 host2的机器上 |
在上述语句中,“|”符号表示管道,其作用是将mysqldump备份的文件给mysql命令
--all-databases 表示要迁移所有的数据库。通过这种方式可以直接实现迁移。
2. 不同版本的数据库之间迁移注意点
例如,原来很多服务器使用5.7版本的MySQL数据库,在8.0版本推出来以后,改进了5.7版本的很多缺陷,因此需要把数据库升级到8.0版本。
旧版本与新版本的MySQL可能使用不同的默认字符集,例如有的旧版本中使用latin1作为默认字符集,而最新版本的MySQL默认字符集为utf8mb4。如果数据库中有中文数据,那么迁移过程中需要对默认字符集进行修改,不然可能无法正常显示数据。
高版本的MySQL数据库通常都会兼容低版本,因此可以从低版本的MySQL数据库迁移到高版本的MySQL数据库。
3. 不同数据库之间迁移注意点
不同数据库之间迁移是指从其他类型的数据库迁移到MySQL数据库,或者从MySQL数据库迁移到其他类型的数据库。这种迁移没有普适的解决方法。
迁移之前,需要了解不同数据库的架构, 比较它们之间的差异。不同数据库中定义相同类型的数据的关键字可能会不同。例如,MySQL中日期字段分为DATE和TIME两种,而ORACLE日期字段只有DATE;SQL Server数据库中有ntext、Image等数据类型,MySQL数据库没有这些数据类型;MySQL支持的ENUM和SET类型,这些SQLServer数据库不支持。
另外,数据库厂商并没有完全按照SQL标准来设计数据库系统,导致不同的数据库系统的SQL语句有差别。例如,微软的SQLServer软件使用的是T-SQL语句,T-SQL中包含了非标准的SQL语句,不能和MySQL的SQL语句兼容。
不同类型数据库之间的差异造成了互相迁移的困难,这些差异其实是商业公司故意造成的技术壁垒。但是不同类型的数据库之间的迁移并不是完全不可能。例如,可以使用 MyODBC 实现MySQL和SQLServer之间的迁移。MySQL官方提供的工具MySQL Migration Toolkit 也可以在不同数据库之间进行数据迁移。MySQL迁移到Oracle时,需要使用mysqldump命令导出sql文件,然后, 手动更改sql文件中的CREATE语句。
7.4 迁移小结

8. 误删数据的预防方案和恢复方案
传统的高可用架构是不能预防误删数据的,因为主库的一个drop table命令,会通过binlog传给所有从库
和级联从库,进而导致整个集群的实例都会执行这个命令。
为了找到解决误删数据的更高效的方法,我们需要先对和MySQL相关的误删数据,做下分类:
- 使用delete语句误删数据行;
- 使用drop table或者truncate table语句误删数据表;
- 使用drop database语句误删数据库;
- 使用rm命令误删整个MySQL实例。
8.1 DELETE 误删数据行
预防方案
- 代码上线前,必须
SQL审查、审计。 - 建议开启安全模式,把
sql_safe_updates参数的值设置为ON。强制要求每次执行UPDATE/DELETE操作的时候,WHERE 条件后面都需要跟索引字段。如果没有加 WHERE 条件,或者 WHERE 条件后面没有索引字段,必须使用 LIMIT 关键字。
数据恢复方案
使用Flashback工具恢复数据。
原理:修改 binlog 内容,拿回原库重放。如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。
使用前提:binlog_format=row 和 binlog_row_image=FULL。
8.2 truncate/drop:误删数据库/表
背景
delete全表是很慢的,需要生成回滚日志、写redo、写binlog。所以,从性能角度考虑,优先考虑使用truncate table或者drop table命令。
使用delete命令删除的数据,你还可以用Flashback来恢复。而使用truncate/drop table和drop database命令删除的数据,就没办法通过Flashback来恢复了。因为,即使我们配置了binlog_format=row,执行这三个命令时,记录的binlog还是statement格式。binlog里面就只有一个truncate/drop语句,这些信息是恢复不出数据的。
预防方案
在生产环境中,可以采用下面建议的方案来尽量避免使用 TRUNCATE/DROP 语句误删数据库/表。
(1)账户权限分离。
对于核心的数据库,一般不能随便分配写权限。想要获取写权限,需要进行审批,并且不同的账号、不同的数据库之间要进行权限分离,避免一个账户可以删除所有数据库。例如,只授予业务开发人员 DML 权限,而不给予其 TRUNCATE/DROP 权限。即使是 DBA 团队人员,日常也只能使用只读账号,在必要的时候才能使用有更新权限的账号。
(2)制定操作规范。
例如,在删除数据库之前,先对该表执行执行重命名操作(比如加_to_be_deleted),确保对业务无影响后再删除该张表。
(3)设置延迟复制备库。
简单地说,延迟复制就是设置一个固定的延迟时间,通过 CHANGE MASTER TO MASTER_DELAY=N 命令可以指定这个备库持续保持跟主库有 N 秒的延迟,例如,设置为 3600 秒,表示让从库落后主库 1 小时。延迟复制可以在数据库被误操作后,快速地恢复数据。例如,有人误操作了主库中的某张表,那么,在延迟时间内,从库中的数据并没有发生改变,就可以用从库中的数据进行快速恢复。
此外,延迟复制还可以用来解决以下问题:
- 用来做
延迟测试,比如做好的数据库读写分离,把从库作为读库,那么想知道当数据产生延迟的时候到底会发生什么,就可以使用这个特性模拟延迟。 - 用于
老数据的查询等需求,比如你经常需要查看某天前一个表或者字段的数值,你可能需要把备份恢复后进行查看,如果有延迟从库,比如延迟一周,那么就可以解决这样类似的需求。
数据恢复方案
使用 TRUNCATE/DROP 语句删掉的数据是没法通过二进制日志恢复的。因为二进制日志里面只有一条 TRUNCATE/DROP 语句,单凭这些信息是无法恢复数据的。
在这种情况下,要想恢复数据,就需要采用全量备份+增量日志的方案。该方案要求线上定期的全量备份,并且实时备份二进制日志。在这两个条件都具备的情况下,可以使用二进制日志恢复数据;否则使用本章讲到的物理备份或逻辑备份的方式恢复误操作的数据。
例如: 有人误删了一个库,时间为下午3点。步骤如下:
- 取最近一次
全量备份。假设设置数据库备份是一天一备,最近备份数据是当天凌晨2点; - 用备份恢复出一个
临时库;(注意:这里选择临时库,而不是直接操作主库) - 取出凌晨2点之后的binlog日志;
- 剔除误删除数据的语句外,其它语句全部应用到临时库。(前面讲过binlog的恢复)
- 最后恢复到主库
8.3 rm: 误删 MySQL 实例
对于一个有高可用机制的 MySQL 集群来说,不用担心 rm删除数据。因为只删除掉其中某一个节点数据的话,HA 系统就会选出一个新的主库,从而保证整个集群的正常工作。我们把这个节点上的数据恢复回来后,再接入整个集群就好了。
但如果是恶意地把整个集群删除,那就需要考虑跨机房备份、跨城市备份。

