对于一位后端开发者,或者说Java后端开发者,每天都需要和数据库打交道,而我们常常接触的MySQL原理,你是否了解过呢?本文将对MySQL的一些原理进行描述,希望能帮助大家了解数据库。
注:本文中对于数据库的介绍以及研究都是在 MySQL 上进行的,如果涉及到了其他数据库的内容或者实现会在文中单独指出。
非完全原创,大部分内容引自他人博客。
很多开发者在最开始时其实都对数据库有一个比较模糊的认识,觉得数据库就是一堆数据的集合,但是实际却比这复杂的多,数据库领域中有两个词非常容易混淆,也就是数据库和实例:
数据库:物理操作文件系统或其他形式文件类型的集合;
实例:MySQL 数据库由后台线程以及一个共享内存区组成;
对于数据库和实例的定义都来自于
《MySQL 技术内幕:InnoDB 存储引擎》
一书,想要了解 InnoDB 存储引擎的读者可以阅读这本书籍。
在 MySQL 中,实例和数据库往往都是一一对应的,而我们也无法直接操作数据库,而是要通过数据库实例来操作数据库文件,可以理解为数据库实例是数据库为上层提供的一个专门用于操作的接口。
在 Unix 上,启动一个 MySQL 实例往往会产生两个进程, mysqld 就是真正的数据库服务守护进程,而 mysqld_safe是一个用于检查和设置 mysqld 启动的控制程序,它负责监控 MySQL 进程的执行,当 mysqld 发生错误时, mysqld_safe 会对其状态进行检查并在合适的条件下重启。
MySQL 从第一个版本发布到现在已经有了 20 多年的历史,在这么多年的发展和演变中,整个应用的体系结构变得越来越复杂:
支持接口是第三方语言对数据库的操作接口,这里不再赘述。
最上层的连接池是一些连接服务,包含本地sock通信和大多数基于C/S工具实现的类似于TCP/IP的通信。主要完成一些类似于连接处理、授权认证及相关的安全方案。在该层上引入了线程池的概念,为通过认证安全接入的客户端提供线程。同样在该层上可以实现基于SSL的安全连接。服务器也会为安全接入的每个客户端验证它所具有的操作权限。
第二层架构主要完成大多数的核心服务功能,如SQL接口、缓存的查询、SQL的分析和优化、内置函数等。所有跨存储引擎的功能也在这一层实现,如过程、函数等。在该层,服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化如确定查询表的顺序,是否利用索引等,最后生成相应的执行操作。如果是select语句,服务器还会查询内部的缓存,如果缓存空间足够大,这样在频繁读操作的环境中能够很好的提升系统的性能。
存储引擎真正的负责MySQL中数据的存储和提取,服务器通过API与存储引擎进行通信,不同的存储引擎具有的特性不同,我们可以根据实际需进行选取。下文将对相关存储引擎进行具体介绍。
数据存储层,主要是将数据存储在运行于裸设备的文件系统之上,并完成与存储引擎的交互。
数据库通常不会被单独使用,而是由其它编程语言通过SQL支持接口调用MySQL,由MySQL处理并返回执行结果。首先,其它编程语言通过SQL支持接口调用MySQL,MySQL收到请求后,会将该请求暂时放在连接池,并由管理服务与工具进行管理。当该请求从等待队列进入到处理队列时,管理器会将该请求传给SQL接口,SQL接口接收到请求后,它会将请求进行hash处理并与缓存中的数据进行对比,如果匹配则通过缓存直接返回处理结果;否则,去文件系统查询:由SQL接口传给后面的解析器,解析器会判断SQL语句是否正确,若正确则将其转化为数据结构。解析器处理完毕后,便将处理后的请求传给优化器控制器,它会产生多种执行计划,最终数据库会选择最优的方案去执行。确定最优执行计划后,SQL语句交由存储引擎处理,存储引擎将会到文件系统中取得相应的数据,并原路返回。
MySQL在5.1版本之前默认存储引擎为MyISAM,在5.1版本版本之后为InnoDB。
MyIsam 的存储文件有三个,后缀名分别是 .frm、.MYD、MYI,其中 .frm 是表的定义文件,.MYD 是数据文件,.MYI 是索引文件。MyIsam 只支持表锁,不支持事务。MyIsam 由于有单独的索引文件,在读取数据方面的性能很高。Myisam是以堆结构进行组织数据,其表容易损坏。
InnoDB 的存储文件有两个,后缀名分别是 .frm 和 .idb,其中 .frm 是表的定义文件,而 idb 是数据文件。InnoDB 中存在表锁和行锁,不过行锁是在命中索引的情况下才会起作用。InnoDB 支持事务,且支持四种隔离级别(读未提交、读已提交、可重复读、串行化),默认的为可重复读。
两种存储引擎的对比:从MyISAM和InnoDB的存储文件可看出,MyISAM注重的是对数据的快速读取,但由于MyISAM不支持事务,同时缺乏灵活性。而InnoDB支持事务和行级锁,因此在5.1之后MySQL的默认存储引擎为InnoDB。
在MySQL 8.0 版本中,InnoDB存储引擎架构分为 内存区架构 与 磁盘区架构。
·
Buffer Pool
理解了缓存池 BP(Buffer Pool)的原理,对于 InnoDB 架构中数据存储原理也差不多理解了一半了。BP 主要结构是个 List,用的是LRU算法的变种(least recently used)。
查看下图可以看到,整个 BP 分为两个 Sublist,上端为热端,缓存热点数据(频繁被访问);另外一端为冷端,随时被驱逐出内存。
BP 的主要作用是 提升访问效率,因为磁盘访问数据速度不如内存。所有用户访问的数据,都要先经过 BP ,但如果走全表扫描,按道理也会把数据缓存在 BP 中,那么势必会把大量数据驱逐出去,当然我们也可以通过调整参数来优化。优化的相关知识不在本文作详细介绍。
Change Buffer
可变缓冲区(Change Buffer),在内存中,可变缓冲区是InnoDB缓冲池的一部分,在磁盘上,它是系统表空间的一部分,因此即使在数据库重新启动之后,索引更改也会保持缓冲状态。
可变缓冲区是一种特殊的数据结构,当受影响的页不在缓冲池中时,缓存对辅助索引页的更改。
Log Buffer
日志缓冲区(Log Buffer ),主要保存写到redo log(重放日志)的数据。周期性的将缓冲区内的数据写入redo日志中。将内存中的数据写入磁盘的行为由innodb_log_at_trx_commit
和 innodb_log_at_timeout
调节。较大的redo日志缓冲区允许大型事务在事务提交前不进行写磁盘操作。
变量:innodb_log_buffer_size
(默认 16M)
Adaptive Hash Index
自适应哈希索引(Adaptive Hash Index)功能可以让 InnoDB 在不牺牲事务功能或可靠性的情况下,在工作负载和缓冲池有足够内存的适当组合的系统上,更像是内存数据库。
哈希索引是根据对经常访问的索引页面的需求而建立的,在某些情况下哈希索引的查找速度更快。当查询的是热点数据时,可以根据hash值快速从 BP 中匹配到值。
根据观察到的搜索模式,使用索引关键字的前缀构建哈希索引。该前缀可以是任何长度,并且可能是哈希树索引中仅B树中的某些值出现。
自适应哈希索引功能由innodb_adaptive_hash_index 变量启用 ,或在服务器启动时由禁用 --skip-innodb-adaptive-hash-index。
·
系统表空间(System Tablespace)
除了存储表数据之外,InnoDB也支持查找表元信息,存储和检索MVCC信息以兑现服从ACID和事务隔离性等原则。它包含几种类型的InnoDB对象信息。
通用表空间(General Tablespace)
共享的 Tablespace 存储多个表信息,在MySQL 5.7.6时引入。用户只能使用CREATE TABLESPACE创建一个这样的表空间。 TABLESPACE选项可以在使用CREATE TABLE命令创建一个表然后 ALTER TABLE 将表移入通用空间时发挥作用。
独立表空间(File-Per-Table Tablespace)
独立表空间是一种单表表空间,它创建在自有的数据文件中而不是在系统表空间中。当innodb_file_per_table选项启用时,表会创建在独立表空间中。否则,InnoDB表会创建在系统表空间中。每一个独立表空间都由一个.ibd数据文件表示,它在默认情况下会创建在数据库目录中,在MySQL 5.7时引入。
只有在innodb_file_per_table选项启用后,才会开启独立表空间,此后创建的表会在独立空间内,之前创建的表仍在System Tablespace。
双写缓冲区文件(Doublewrite Buffer Files)
系统 Tablespace 的存储区域,InnoDB在写入物理文件之前先将页从InnoDB Buffer pool写入此空间。mysqld进程突然崩溃会导致部分写问题。InnoDB可以从这个区域拿到一个备份。 变量: inndb_doublewrite (默认开启)。
Redo日志(Redo Log)
用于灾难恢复。mysqld启动的时候,InnoDB会尝试执行自动恢复,将不完整的事务更改矫正。还未完成更新数据文件的事务会在mysqld启动时会根据此日志记录中的信息被重放。它使用 LSN(Log Sequence Number)值来重放信息,因为mySQL会为每个事务赋予一个ID。因为大量数据更改不可能及时写道磁盘,所以得先记录到redo日志,然后再写入磁盘。
在Redo日志里,所有更改都会带有 row_id, 旧的列值,新的列值, session_id 和时间。
UNDO日志和UNDO表空间
UNDO Tablespaces 包含一个或多个Undo日志文件。UNDO通过为事务(MVCC)保存被更改还未提交的值保持读一致性。未提交值从这个存储区域读取。UNDO日志也被叫做回滚数据段。
默认地,UNDO日志是系统表空间的一部分。但MySQL允许UNDO日志置于一个单独的表空间中。这需要在初始化mysqld之前进行更改才起作用。
当我们配置单独UNDO表空间时,系统表空间的UNDO日志就被抑制了,但是一旦配置成单独的,我们只能删除UNDO日志的一部分,比如过期日志,而不能删除它。
临时表空间(Temporary Tablespace)
为临时表和相关对象提供存储功能,存储包括临时表未提交的数据。在MySQL 5.7.2引入,用于对临时表修改的回滚。 ibtmp1每次系统启动被重新创建,避免Redo日志对临时表的I/O操作。
在整个数据库体系结构中,我们可以使用不同的存储引擎来存储数据,而绝大多数存储引擎都以二进制的形式存储数据;这一节会介绍 InnoDB 中对数据是如何存储的。
注:这一节中关于数据库中数据存储、结构的内容全是基于InnoDB 引擎的。
在 InnoDB 存储引擎中,所有的数据都被逻辑地存放在表空间中,表空间(tablespace)是存储引擎中最高的存储逻辑单位,在表空间的下面又包括段(segment)、区(extent)、页(page):
InnoDB存储空间被切分成 Tablespace,Tablespace是一个与多个数据文件相关联的逻辑结构。
页是 InnoDB 最小的数据存储单元,也称作块。默认的页框是16KB,一个页包含多行。
可用页大小: 4kb,8kb,16kb,32kb,64kb。
配置变量名 : innodb_page_size
,在初始化mysqld时配置
一组页组成一个区,InnoDB为了更好的I/O吞吐率,每次读写都是按照区为单位。
一组16KB的页,一个区可以1MB,双写缓冲区(Doublewrite buffer )每次分配/读/写都是以区为单位。
4个区构成一个Segment。
同一个数据库实例的所有表空间都有相同的页大小;默认情况下,表空间中的页大小都为 16KB,当然也可以通过改变 innodb_page_size 选项对默认大小进行修改,需要注意的是不同的页大小最终也会导致区大小的不同:
从图中可以看出,在 InnoDB 存储引擎中,一个区的大小最小为 1MB,页的数量最少为 64 个。
MySQL 使用 InnoDB 存储表时,会将表的定义和数据索引等信息分开存储,其中 表的定义 存储在 .frm 文件中,数据索引 存储在 .ibd 文件中,这一节就会对这两种不同的文件分别进行介绍。
无论在 MySQL 中选择了哪个存储引擎,所有的 MySQL 表都会在硬盘上创建一个 .frm 文件用来描述表的格式或者说定义; .frm 文件的格式在不同的平台上都是相同的。
CREATE TABLE test_frm(
column1 CHAR(5),
column2 INTEGER
);
当我们使用上面的代码创建表时,会在磁盘上的 datadir文件夹中生成一个 test_frm.frm 的文件,这个文件中就包含了表结构相关的信息:
MySQL 官方文档中的 11.1 MySQL .frm File Format 一文对于 .frm 文件格式中的二进制的内容有着非常详细的表述,在这里就不展开介绍了。
InnoDB 中用于存储数据的文件总共有两个部分,一是系统表空间文件,包括 ibdata1、 ibdata2 等文件,其中存储了 InnoDB 系统信息和用户数据库表数据和索引,是所有表公用的。
当打开 innodb_file_per_table
选项时, .ibd
文件就是每一个表独有的表空间,文件存储了当前表的数据和相关的索引数据。
与现有的大多数存储引擎一样,InnoDB 使用页作为磁盘管理的最小单位;数据在 InnoDB 存储引擎中都是按行存储的,每个 16KB 大小的页中可以存放 2-200 行的记录。
当 InnoDB 存储数据时,它可以使用不同的行格式进行存储;MySQL 5.7 版本开始支持以下格式的行存储方式:
Antelope 是 InnoDB 最开始支持的文件格式,它包含两种行格式 Compact 和 Redundant,它最开始并没有名字;Antelope 的名字是在新的文件格式 Barracuda 出现后才起的,Barracuda 的出现引入了两种新的行格式 Compressed 和 Dynamic;InnoDB 对于文件格式都会向前兼容,而官方文档中也对之后会出现的新文件格式预先定义好了名字:Cheetah、Dragon、Elk 等等。
两种行记录格式 Compact 和 Redundant 在磁盘上按照以下方式存储:
Compact 和 Redundant 格式最大的不同就是记录格式的第一个部分;在 Compact 中,行记录的第一部分倒序存放了一行数据中列的长度(Length),而 Redundant 中存的是每一列的偏移量(Offset),从总体上上看,Compact 行记录格式相比 Redundant 格式能够减少 20% 的存储空间。
当 InnoDB 使用 Compact 或者 Redundant 格式存储极长的 VARCHAR 或者 BLOB 这类大对象时,我们并不会直接将所有的内容都存放在数据页节点中,而是将行数据中的前 768 个字节存储在数据页中,后面会通过偏移量指向溢出页。
但是当我们使用新的行记录格式 Compressed 或者 Dynamic 时都只会在行记录中保存 20 个字节的指针,实际的数据都会存放在溢出页面中。
当然在实际存储中,可能会对不同长度的 TEXT 和 BLOB 列进行优化,不过这就不是本文关注的重点了。
想要了解更多与 InnoDB 存储引擎中记录的数据格式的相关信息,可以阅读 InnoDB Record Structure
页是 InnoDB 存储引擎管理数据的最小磁盘单位,而 B-Tree 节点就是实际存放表中数据的页面,我们在这里将要介绍页是如何组织和存储记录的;首先,一个 InnoDB 页有以下七个部分:
每一个页中包含了两对 header/trailer:内部的 Page Header/Page Directory
关心的是页的状态信息,而 Fil Header/Fil Trailer
关心的是记录页的头信息。
在页的头部和尾部之间就是用户记录和空闲空间了,每一个数据页中都包含 Infimum
和 Supremum
这两个 虚拟 的记录(可以理解为占位符),Infimum 记录是比该页中任何主键值都要小的值,Supremum 是该页中的最大值。
User Records
就是整个页面中真正用于存放行记录的部分,而 Free Space
就是空余空间了,它是一个链表的数据结构,为了保证插入和删除的效率,整个页面并不会按照主键顺序对所有记录进行排序,它会自动从左侧向右寻找空白节点进行插入,行记录在物理存储上并不是按照顺序的,它们之间的顺序是由 next_record 这一指针控制的。
B+ 树在查找对应的记录时,并不会直接从树中找出对应的行记录,它只能获取记录所在的页,将整个页加载到内存中,再通过 Page Directory
中存储的稀疏索引和 n_owned、 next_record 属性取出对应的记录,不过因为这一操作是在内存中进行的,所以通常会忽略这部分查找的耗时。
InnoDB 存储引擎中对数据的存储是一个非常复杂的话题,这一节中也只是对表、行记录以及页面的存储进行一定的分析和介绍,虽然作者相信这部分知识对于大部分开发者已经足够了,但是想要真正消化这部分内容还需要很多的努力和实践。
索引是数据库中非常非常重要的概念,它是存储引擎能够快速定位记录的秘密武器,对于提升数据库的性能、减轻数据库服务器的负担有着非常重要的作用;索引优化是对查询性能优化的最有效手段,它能够轻松地将查询的性能提高几个数量级。
索引在官⽅介绍中也叫做“键”或者"key"(primary key,unique key,还有一个index key),是存储引擎用于快速找到记录的一种数据结构。更通俗的说,数据库索引就像是目录,能快速定位到指定的数据内容。
MyISAM和InnoDB两种存储引擎都采用了B+树的数据结构,但具体实现方式完全不同。
MySQL的索引类型:PRIMARY
主键索引、 INDEX
普通索引、UNIQUE
唯一索引、FULLTEXT
全文索引、SPAIAL
空间索引。
单列索引
其中primary key、unique key,有约束的效果,primary key 不为空且唯一,unique key 唯一,而index key只有加速查询的效果,没有约束效果
组合索引
最左匹配原则:组合索引具有优先级,需从左边第一个列开始组合匹配。例如在A,B,C三列上建一个组合索引add index(A,B,C),那么生效的索引为(A)、(A,B)、(A,B,C),连接符为and。
有时候看到(A,C)也生效了,仔细查看其实只有A生效,C无效。
全文索引
空间索引
InnoDB 存储引擎在绝大多数情况下使用 B+ 树建立索引,这是关系型数据库中查找最为常用和有效的索引,但是 B+ 树索引并不能找到一个给定键对应的具体值,它只能找到数据行对应的页,然后正如上一节所提到的,数据库把整个页读入到内存中,并在内存中查找具体的数据行。
B+ 树是平衡树,它查找任意节点所耗费的时间都是完全相同的,比较的次数就是 B+ 树的高度。
InnoDB引擎中 数据和索引是在⼀起的。
聚集索引的 叶⼦节点会存储数据⾏ (整行数据)。在 MySQL 数据库 InnoDB 引擎里面,主键索引是聚集索引。
对于Innodb,主键毫无疑问是一个聚集索引。但是当一个表没有主键,或者没有一个索引,Innodb会如何处理呢。请看如下规则:
- 如果一个主键被定义了,那么这个主键就是作为聚集索引
- 如果没有主键被定义,MySQL取第一个唯一索引(unique)而且只含非空列(NOT NULL)作为主键,InnoDB使用它作为聚簇索引。
- 如果没有主键也没有合适的唯一索引,那么innodb内部会生成一个隐藏的主键作为聚集索引,这个隐藏的主键是一个6个字节的列,改列的值会随着数据的插入自增。
- 自增主键会把数据自动向后插入,避免了插入过程中的聚集索引排序问题。聚集索引的排序,必然会带来大范围的数据的物理移动,这里面带来的磁盘IO性能损耗是非常大的。 而如果聚集索引上的值可以改动的话,那么也会触发物理磁盘上的移动,于是就可能出现page分裂,表碎片横生。所以不应该修改聚集索引。
聚集索引会使用 id 作为索引的键,并在叶子节点中存储一行记录中的所有信息。
聚集索引与表的物理存储方式有着非常密切的关系,所有正常的表应该有且仅有一个聚集索引(绝大多数情况下都是主键),表中的所有行记录数据都是按照 聚集索引 的顺序存放的。
当我们使用聚集索引对表中的数据进行检索时,可以直接获得聚集索引所对应的整条行记录数据所在的页,不需要进行第二次(回表)操作。
InnoDB引擎中将所有的非聚集索引都划分为辅助索引(Secondary Index,也称为非聚集索引)。
辅助索引的存在并不会影响聚集索引,因为聚集索引构成的 B+ 树是数据实际存储的形式,而辅助索引只用于加速数据的查找,所以一张表上往往有多个辅助索引以此来提升数据库的性能。
一张表一定包含一个聚集索引构成的 B+ 树,还可能会有若干辅助索引的构成的 B+ 树。
辅助索引的叶子节点中仅存了索引列和id列的值,辅助索引会存在回表查询的情况。当查询的某一个字段(例如sex)没有在辅助索引中时,而查询的辅助索引又生效,就需要先在辅助索引的B+树中找到对应的id,然后再到聚集索引中去查询,这就需要二次查询,这种情况称为 回表查询。
使用辅助索引查找一条表记录的过程:通过辅助索引查找到对应的主键,最后在聚集索引中使用主键获取对应的行记录,这也是通常情况下行记录的查找方式。
MyISAM引擎中的索引结构与InnoDB不同,B+树叶⼦节点只会存储数据⾏(数据⽂件)的 指针,简单来说数据和索引不在⼀起。
MyISAM引擎中主键索引的结构和辅助索引的结构一致,只是辅助索引的值可以重复,其他没有区别,不再重复作图。
我们都知道锁的种类一般分为 乐观锁 和 悲观锁 两种,InnoDB
存储引擎中使用的就是悲观锁,而按照锁的粒度划分,也可以分成行锁和表锁。
乐观锁和悲观锁其实都是并发控制的机制,同时它们在原理上就有着本质的差别;
乐观锁 是一种思想,它其实并不是一种真正的『锁』,它会先尝试对资源进行修改,在写回时判断资源是否进行了改变,如果没有发生改变就会写回,否则就会进行重试,在整个的执行过程中其实都 没有对数据库进行加锁;
悲观锁 就是一种真正的锁了,它会在获取资源前对资源进行加锁,确保同一时刻只有有限的线程能够访问该资源,其他想要尝试获取资源的操作都会进入等待状态,直到该线程完成了对资源的操作并且释放了锁后,其他线程才能重新操作资源;
虽然乐观锁和悲观锁在本质上并不是同一种东西,一个是一种思想,另一个是一种真正的锁,但是它们都是一种并发控制机制。
乐观锁不会存在死锁的问题,但是由于更新后验证,所以当冲突频率和重试成本较高时更推荐使用悲观锁,而需要非常高的响应速度并且并发量非常大的时候使用乐观锁就能较好的解决问题,在这时使用悲观锁就可能出现严重的性能问题;在选择并发控制机制时,需要综合考虑上面的四个方面(冲突频率、重试成本、响应速度和并发量)进行选择。
对数据的操作其实只有两种,也就是读和写,而数据库在实现锁时,也会对这两种操作使用不同的锁;InnoDB 实现了标准的行级锁,也就是共享锁(Shared Lock)和互斥锁(Exclusive Lock);共享锁和互斥锁的作用其实非常好理解:
共享锁(读锁):允许事务对一条行数据进行读取;
互斥锁(写锁):允许事务对一条行数据进行删除或更新;
而它们的名字也暗示着各自的另外一个特性,共享锁之间是兼容的,而互斥锁与其他任意锁都不兼容:
稍微对它们的作用进行思考就能想明白它们为什么要这么设计,因为共享锁代表了读操作、互斥锁代表了写操作,所以我们可以在数据库中并行读,但是只能串行写,只有这样才能保证不会发生线程竞争,实现线程安全。
无论是共享锁还是互斥锁其实都只是对某一个数据行进行加锁,InnoDB 支持多种粒度的锁,也就是行锁和表锁;为了支持多粒度锁定,InnoDB 存储引擎引入了意向锁(Intention Lock),意向锁就是一种表级锁。
意向锁是 MySQL内部自动实现 的,不需要⽤户⼲预。意向锁和⾏锁可以共存。
与上一节中提到的两种锁的种类相似的是,意向锁也分为两种:
意向共享锁:事务想要在获得表中某些记录的共享锁,需要在表上先加意向共享锁;
意向互斥锁:事务想要在获得表中某些记录的互斥锁,需要在表上先加意向互斥锁;
随着意向锁的加入,锁类型之间的兼容矩阵也变得愈加复杂:
意向锁其实不会阻塞全表扫描之外的任何请求,它们的主要目的是为了表示是否有人请求锁定表中的某一行数据。
有的人可能会对意向锁的目的并不是完全的理解,我们在这里可以举一个例子:如果没有意向锁,当已经有人使用行锁对表中的某一行进行修改时,如果另外一个请求要对全表进行修改,那么就需要对所有的行是否被锁定进行扫描,在这种情况下,效率是非常低的;不过,在引入意向锁之后,当有人使用行锁对表中的某一行进行修改之前,会先为表添加意向互斥锁(IX),再为行记录添加互斥锁(X),在这时如果有人尝试对全表进行修改就不需要判断表中的每一行数据是否被加锁了,只需要通过等待意向互斥锁被释放就可以了。
到目前为止已经对 InnoDB 中锁的粒度有一定的了解,也清楚了在对数据库进行读写时会获取不同的锁,在这一小节将介绍MySQL中的各种锁的实现。
按照锁的粒度进行分类,MySQL主要包含三种类型(级别)的锁定机制:
表级锁:开销⼩,加锁快;不会出现死锁;锁定粒度⼤,发⽣锁冲突的概率最⾼,并发度最低;
⾏级锁:开销⼤,加锁慢;会出现死锁;锁定粒度最⼩,发⽣锁冲突的概率最低,并发度也最⾼。
我们创建一张用户表users来介绍各种锁的实现。
CREATE TABLE users(
id INT NOT NULL AUTO_INCREMENT,
age INT,
last_name VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
PRIMARY KEY(id),
KEY(last_name),
KEY(age)
);
MySQL的表级锁有两种:
给表加上表锁之后,整个表被锁定,在表释放前只能操作锁定的表。表锁有两种表现形式:
加锁操作:
#同时给多个表手动加表锁
lock table 表名称 read(write),表名称2 read(write);
#查看表锁情况
show open tables;
#释放表锁
unlock tables;
元数据锁不需要显式使⽤,在 访问⼀个表的时候会被⾃动加上。MDL的作⽤是,保证读写的正确性。你可以想象⼀下,如果⼀个查询正在遍历⼀个表中的数据,⽽执⾏期间另⼀个线程对这个表结构做变更,例如删了⼀列,那么查询线程拿到的结果跟表结构对不上,这是不允许的。
因此,在 MySQL 5.5 版本中引⼊了 MDL,当对⼀个表做增删改查操作(操作数据,行级)的时候,加 MDL 读锁;当要对表做结构变更操作(更改表结构,表级)的时候,加 MDL 写锁。
MySQL的⾏级锁,是由存储引擎来实现的,利⽤存储引擎锁住索引项来实现的。接下来主要介绍InnoDB引擎中的⾏级锁。
InnoDB的⾏级锁,按照锁定范围来说,分为三种:
在说明行级锁之前,先说说查看行级锁状态。
mysql> show status like 'innodb_row_lock%';
+------------------------------------+-------+
| Variable_name | Value |
+------------------------------------+-------+
| innodb_row_lock_current_waits | 0 | #当前正在等待锁定的数量;
| innodb_row_lock_time | 0 | #从系统启动到现在锁定总时间⻓度;
| innodb_row_lock_time_avg | 0 | #每次等待所花平均时间;
| innodb_row_lock_time_max | 0 | #从系统启动到现在等待最常的⼀次所花的时间;
| innodb_row_lock_waits | 0 | #系统启动后到现在总共等待的次数;
+------------------------------------+-------+
对于这5个状态变量,⽐较重要的主要是:
尤其是当等待次数很⾼,⽽且每次等待时⻓也不⼩的时候,我们就需要分析系统中为什么会有如此多的
等待,然后根据分析结果着⼿指定优化计划。
记录锁(Record Lock)是加到索引记录上的锁,锁住了具有索引的指定行。有行读锁和行写锁两种。
注:在MySQL 8.0.1版本中,新增行读锁写法
select... for share
,当然旧的写法select... lock in share mode
依然可以使用。
1)行读锁加锁过程 图示:
2)⾏读锁升级为表锁。即使用没有索引的列作为过滤条件时,由于 InnoDB 不知道待修改的记录具体存放的位置,也无法对将要修改哪条记录提前做出判断,就会锁定整个表。 过程图示:
3)行写锁加锁过程 图示:
间隙锁(Gap Lock)是对索引记录中的一段连续区域的锁。间隙锁是存储引擎对于性能和并发做出的权衡,并且只用于某些事务隔离级别。
虽然间隙锁中也分为共享锁和互斥锁,不过它们之间并不是互斥的,也就是不同的事务可以同时持有一段相同范围的共享锁和互斥锁,它唯一阻止的就是其他事务向这个范围中添加新的记录。因此 行写锁在要插入新行的时候即为间隙锁。
间隙锁存在三种情况:
注:在MySQL下,begin和start transcation没有区别。
- begin、commit、rollback
- start transaction、commit work、rollback work
Next-Key 锁相比前两者就稍微有一些复杂,它是记录锁和记录前的间隙锁的结合。
为了解决幻读问题,MySQL事务使用了Next-Key锁。在下一节谈事务的时候具体介绍。
既然 InnoDB 中实现的锁是悲观的,那么不同事务之间就可能会互相等待对方释放锁造成死锁,最终导致事务发生错误;想要在 MySQL 中制造死锁的问题其实非常容易:
死锁简单来说就是 互相不释放资源,又相互获取对方资源,形成的死循环现象。一般是顺序问题,或者代码实现不严谨。
在介绍了锁之后,我们再来谈谈数据库中一个非常重要的概念 —— 事务。事务需遵循包括原子性在内的 ACID 四大特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),这四大特性不在本文进行介绍。
数据库为了维护这些性质,尤其是一致性和隔离性,一般使用加锁这种方式。数据库为了避免死锁,遵循的是 两段锁协议2PL (Two-Phase Locking,⼆阶段锁)。将事务分成两个阶段,加锁阶段和解锁阶段。
事务的隔离性是数据库处理数据的几大基础之一,而隔离级别其实就是提供给用户用于在性能和可靠性做出选择和权衡的配置项。
ISO 和 ANIS SQL 标准制定了四种事务隔离级别,而 InnoDB 遵循了 SQL:1992 标准中的四种隔离级别;每个事务的隔离级别其实都比上一级多解决了一个问题:
MySQL 中默认的事务隔离级别就是 可重复读(REPEATABLE READ),但是它通过 Next-Key 锁也能够在某种程度上解决幻读的问题。
接下来,我们将数据库中创建如下的表并通过个例子来展示在不同的事务隔离级别之下,会发生什么样的问题:
CREATE TABLE test(
id INT NOT NULL,
UNIQUE(id)
);
脏读(Dirty Read),也就是可能读取到其他会话中未提交事务修改的数据。又称无效数据读出(读出了脏数据)。
当事务的隔离级别为 读未提交(READ UNCOMMITED) 时,我们在 SESSION2 中插入的未提交数据在 SESSION1 中是可以访问的。
不可重复读(Non-Repeatable Read)是指在同一个事务内,两次相同的查询返回了不同的结果。出现的原因多是因为修改。
当事务的隔离级别为 读已提交(READ COMMITED) 时,虽然解决了脏读的问题,但是如果在 SESSION1 先查询了一个范围的数据,在这之后 SESSION2 中插入一条数据并且提交了修改,在这时,如果 SESSION1 中再次使用相同的查询语句,就会发现两次查询的结果不一样。
不可重复读的原因就是,在 读已提交(READ COMMITED) 的隔离级别下,存储引擎不会在查询记录时添加间隙锁,锁定 id<5 这个范围。
幻读(Phantom Read)指当事务不独立执行时,插入或者删除另一个事务当前影响的数据,查询结果不正确,发生的一种类似幻觉的现象。出现的原因多是因为新增、删除。
重新开启了两个会话 SESSION1 和 SESSION2,在 SESSION1中我们查询全表的信息,没有得到任何记录;在 SESSION2中向表中插入一条数据并提交;由于 REPEATABLE READ 的原因,再次查询全表的数据时,我们获得到的仍然是空集,但是在向表中插入同样的数据却出现了错误。
虽然我们使用查询语句得到了一个空的集合,但是插入数据时却返回了错误,好像之前的查询是幻觉一样。
在标准的事务隔离级别中,幻读是由更高的隔离级别 序列化(SERIALIZABLE) 解决的,但是它也可以通过 MySQL 提供的 Next-Key 锁解决:
可重复读(REPERATABLE READ) 和 读未提交(READ UNCOMMITED) 其实是矛盾的,如果保证了前者就看不到已经提交的事务,如果保证了后者,就会导致两次查询的结果不同,MySQL 为我们提供了一种折中的方式,能够在 可重复读(REPERATABLE READ) 模式下加锁访问已经提交的数据,其本身并不能解决幻读的问题,而是通过文章前面提到的 Next-Key 锁来解决。
InnoDB是一个 多版本存储引擎:它保留有关已更改行的旧版本的信息,以支持诸如并发和回滚之类的事务功能 。
在InnoDB中,会在每行数据后添加额外的隐藏的值来实现多版本并发控制(Multi-Version Concurrency Control,MVCC)。
DB_TRX_ID
:6字节的DB_TRX_ID
字段表示插入或更新该行的最后一次操作的标识符。每次对某条聚集索引记录进行改动时,都会把对应的事务id赋值给DB_TRX_ID
隐藏列。
删除在内部被视为更新,在该更新中,行中的特殊位被设置为将其标记为已删除。
DB_ROLL_PTR
:7字节的 DB_ROLL_PTR
字段,称为回滚指针。回滚指针指向写入回滚段的撤消日志记录。每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到Undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
DB_ROW_ID
:6字节的DB_ROW_ID字段包含一个行ID,该ID在插入新行时会自动增加。此字段不是必须的,当聚集索引中的主键是由MySQL自动生成的自增主键时,才会存在。
通过这几个隐藏字段,配合Undo日志,对数据可以进行回滚和恢复,从而实现MVCC。
关于MVCC具体实现原理可以参考博主的另一篇文——图解MySQL 8.0 中的MVCC原理。
InnoDB多版本并发控制(MVCC)对辅助索引(Secondary Index)的处理方式不同于对聚集索引的处理方式。聚集索引中的记录将就地更新,其隐藏的系统列指向撤消日志记录,可以从中回滚到记录的早期版本。与聚集索引记录不同,辅助索引记录不包含隐藏的系统列,也不会就地更新,而是依赖聚集索引来级联控制。
当辅助索引被更新、删除时,不会覆盖辅助索引,不会直接从覆盖索引返回值,而是到聚集索引中查找记录。在聚集索引中,DB_TRX_ID
检查记录的记录,如果在启动读取事务后修改了记录,则从Redo日志中检索记录的正确版本。即 辅助索引下的MVCC依赖于主键索引的级联变动。
但是,如果启用了 索引条件下推(index condition pushdown ,ICP)优化,并且WHERE仅可以使用索引中的字段来评估部分条件,则MySQL服务器仍会将WHERE条件的这一部分下推到存储引擎,在其中使用索引。如果找不到匹配的记录,则避免到聚集索引查找。如果找到了匹配的记录,即使在删除标记的记录中,也要在 聚集索引中查找记录。
索引的设计其实是一个非常重要的内容,同时也是一个非常复杂的内容;接下来将对索引的设计做一个简单的介绍。
更多详细有关索引的设计与优化可以阅读 数据库索引设计与优化 一书,书中提供了一种非常科学合理的方法能够帮助我们在数据库中建立最适合的索引。
·
需创建索引的情况:
不需要创建索引的情况:
组合索引的作用:
MySQL 提供了⼀个 explain
命令, 它可以对 SELECT 语句的执⾏计划进⾏分析,并输出 SELECT 执⾏的详细信息,以供开发⼈员针对性优化。
使⽤explain
这个命令来查看⼀个这些SQL语句的执⾏计划,查看该SQL语句有没有使⽤上了索引,有没有做全表扫描,这都可以通过explain
命令来查看。
可以通过explain
命令深⼊了解MySQL的基于开销的优化器,还可以获得很多可能被优化器考虑到的访问策略的细节,以及当运⾏SQL语句时哪种策略预计会被优化器采⽤。
explain
命令⽤法⼗分简单, 在 SELECT 语句前加上 explain 就可以了, 例如:
本文参考:
[1] @Draveness :『浅入浅出』MySQL 和 InnoDB
[2] @超级战斗王 :MySQL底层架构原理,工作流程和存储引擎的数据结构讲解
[3] @阿里云小秘 :MySQL8.0InnoDB的架构介绍
[4] @MySQL官网 :官方手册:MySQL 8.0 InnoDB 存储架构
[5] @oneslide :MySQL架构图解
[6] @开课吧 :带你锤碎MySQL 讲义