[TOC]
MyIsAM 与 Innodb 的区别
MyISAM是MySQL的默认数据库引擎(5.5版之前)。虽然性能极佳,而且提供了大量的特性,包括全文索引、压缩、空间函数等,但MyISAM不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。不过,5.5版本之后,MySQL引入了InnoDB(事务性数据库引擎),MySQL 5.5版本后默认的存储引擎为InnoDB。
大多数时候我们使用的都是 InnoDB 存储引擎,但是在某些情况下使用 MyISAM 也是合适的比如读密集的情况下。(如果你不介意 MyISAM 崩溃恢复问题的话)。
两者的对比:
- 是否支持行级锁 : MyISAM 只有表级锁(table-level locking),而InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。
- 是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行速度比InnoDB类型更快,但是不提供事务支持。但是InnoDB 提供事务支持事务,外部键等高级数据库功能。 具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。
- 是否支持外键: MyISAM不支持,而InnoDB支持。
-
是否支持MVCC :仅 InnoDB 支持。应对高并发事务, MVCC比单纯的加锁更高效;MVCC只在
READ COMMITTED
和REPEATABLE READ
两个隔离级别下工作;MVCC可以使用 乐观(optimistic)锁 和 悲观(pessimistic)锁来实现;各数据库中MVCC实现并不统一。推荐阅读:MySQL-InnoDB-MVCC多版本并发控制 - ......
《MySQL高性能》上面有一句话这样写到:
不要轻易相信“MyISAM比InnoDB快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB的速度都可以让MyISAM望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。
一般情况下我们选择 InnoDB 都是没有问题的,但是某些情况下你并不在乎可扩展能力和并发能力,也不需要事务支持,也不在乎崩溃后的安全恢复问题的话,选择MyISAM也是一个不错的选择。但是一般情况下,我们都是需要考虑到这些问题的。
一条 SQL 语句执行过程
一、Mysql基本架构
本质:客户端进程向服务器进程发送一段文本(MySQL语句),服务器进程处理后再向客户端进程发送一段文本(处理结果)。
组件的简单介绍:
- 连接器: 身份认证和权限相关(登录 MySQL 的时候)。
- 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。
- 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。
- 优化器: 按照 MySQL 认为最优的方案去执行。
- 执行器: 执行语句,然后从存储引擎返回数据。
简单来说 MySQL 主要分为 Server 层和存储引擎层:
- Server 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binlog 日志模块。
- 存储引擎: 主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自有的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始就被当做默认存储引擎了。
二、Server 层基本组件
1. 连接器
连接器主要和身份认证和权限相关的功能相关,就好比一个级别很高的门卫一样。
主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即时管理员修改了该用户的权限,该用户也是不受影响的。
2. 查询缓存(MySQL 8.0 版本后移除)
查询缓存主要用来缓存我们所执行的 SELECT 语句以及该语句的结果集。
连接建立后,执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 sql 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询预计,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。
MySQL 查询不建议使用缓存,因为查询缓存失效在实际业务场景中可能会非常频繁,假如你对一个表更新的话,这个表上的所有的查询缓存都会被清空。对于不经常更新的数据来说,使用缓存还是可以的。
所以,一般在大多数情况下我们都是不推荐去使用查询缓存的。
MySQL 8.0 版本后删除了缓存的功能,官方也是认为该功能在实际的应用场景比较少,所以干脆直接删掉了。
3. 分析器
MySQL 没有命中缓存,那么就会进入分析器,分析器主要是用来分析 SQL 语句是来干嘛的,分析器也会分为几步:
第一步,词法分析,一条 SQL 语句有多个字符串组成,首先要提取关键字,比如 select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。
第二步,语法分析,主要就是判断你输入的 sql 是否正确,是否符合 MySQL 的语法。
完成这 2 步之后,MySQL 就准备开始执行了,但是如何执行,怎么执行是最好的结果呢?这个时候就需要优化器上场了。
4. 优化器
优化器的作用就是它认为的最优的执行方案去执行(有时候可能也不是最优,这篇文章涉及对这部分知识的深入讲解),比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。
可以说,经过了优化器之后可以说这个语句具体该如何执行就已经定下来。
5. 执行器
当选择了执行方案后,MySQL 就准备开始执行了,首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果。
三 、语句分析
1. 查询语句
说了以上这么多,那么究竟一条 sql 语句是如何执行的呢?其实我们的 sql 可以分为两种,一种是查询,一种是更新(增加,更新,删除)。我们先分析下查询语句,语句如下:
select * from tb_student A where A.age=‘18‘ and A.name=‘ 张三 ‘;
结合上面的说明,我们分析下这个语句的执行流程:
-
先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在 MySQL8.0 版本以前,会先查询缓存,以这条 sql 语句为 key 在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。
-
通过分析器进行词法分析,提取 sql 语句的关键元素,比如提取上面这个语句是查询 select,提取需要查询的表名为 tb_student,需要查询所有的列,查询条件是这个表的 id=‘1‘。然后判断这个 sql 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。
-
接下来就是优化器进行确定执行方案,上面的 sql 语句,可以有两种执行方案:
a.先查询学生表中姓名为“张三”的学生,然后判断是否年龄是 18。 b.先找出学生中年龄 18 岁的学生,然后再查询姓名为“张三”的学生。
那么优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了。
-
进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果。
2. 更新语句
以上就是一条查询 sql 的执行流程,那么接下来我们看看一条更新语句如何执行的呢?sql 语句如下:
update tb_student A set A.age=‘19‘ where A.name=‘ 张三 ‘;
我们来给张三修改下年龄,在实际数据库肯定不会设置年龄这个字段的,不然要被技术负责人打的。其实条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块式 binlog(归档日志) ,所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 redo log(重做日志),我们就以 InnoDB 模式下来探讨这个语句的执行流程。流程如下:
- 先查询到张三这一条数据,如果有缓存,也是会用到缓存。
- 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。
- 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。
- 更新完成。
为什么要用两个日志模块,用一个日志模块不行吗?
这是因为最开始 MySQL 并没与 InnoDB 引擎( InnoDB 引擎是其他公司以插件形式插入 MySQL 的) ,MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档(存档)。
并不是说只用一个日志模块不可以,只是 InnoDB 引擎就是通过 redo log 来支持事务的。那么,又会有同学问,我用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?这里我们用反证法来说明下为什么要这么做?
- 先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
- 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。
如果采用 redo log 两阶段提交的方式就不一样了,写完 binglog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binglog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:
- 判断 redo log 是否完整,如果判断是完整的,就立即提交。
- 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。
这样就解决了数据一致性的问题。
四、总结
- MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。
- 引擎层是插件式的,目前主要包括,MyISAM,InnoDB,Memory 等。
- 查询语句的执行流程如下:权限校验(如果命中缓存) → 查询缓存 → 分析器 → 优化器 → 权限校验 → 执行器 → 引擎
- 更新语句执行流程如下:分析器 → 权限校验 → 执行器 → 引擎---redo log(prepare 状态) → binlog → redo log(commit状态)
参考:《MySQL实战45讲》
Innodb 引擎
Innodb 记录结构
-
页是
MySQL
中磁盘和内存交互的基本单位,也是MySQL
是管理存储空间的基本单位。 -
InnoDB
采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。 -
指定和修改行格式的语法如下:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称 ALTER TABLE 表名 ROW_FORMAT=行格式名称
-
InnoDB
目前定义了4种行格式-
COMPACT行格式
具体组成如图:
-
Redundant行格式
具体组成如图:
-
Dynamic和Compressed行格式
这两种行格式类似于
COMPACT行格式
,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字符串的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。另外,
Compressed
行格式会采用压缩算法对页面进行压缩。
-
- 一个页一般是
16KB
,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为行溢出
。
Innodb 数据页
- 各个数据页可以组成一个双向链表
- 每个数据页中的记录又可以组成一个单向链表
- 每个数据页都会为存储在它里边儿的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录
- 以其他列(非主键)作为搜索条件:只能从最小记录开始依次遍历单链表中的每条记录。
所以说,如果我们写 select * from user where indexname = ‘xxx‘
这样没有进行任何优化的sql语句,默认会这样做:
- 定位到记录所在的页:需要遍历双向链表,找到所在的页
- 从所在的页内中查找相应的记录:由于不是根据主键查询,只能遍历所在页的单链表了
很明显,在数据量很大的情况下这样查找会很慢!这样的时间复杂度为O(n)。
页目录
记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?比如说这样的查询语句:
SELECT * FROM page_demo WHERE c1 = 3;
最笨的办法:从 Infimum
记录(最小记录)开始,沿着链表一直往后找,总有一天会找到(或者找不到[摊手]),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。
为了加快搜索速度,引入了页目录,页目录的创建如下:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的
n_owned
属性表示该记录拥有多少条记录,也就是该组内共有几条记录。 - 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近
页
的尾部的地方,这个地方就是所谓的Page Directory
,也就是页目录
(此时应该返回头看看页面各个部分的图)。页面目录中的这些地址偏移量被称为槽
(英文名:Slot
),所以这个页面目录就是由槽
组成的。
对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。(下面图有 16 条记录)
按主键值查找
比方说找主键值为 6 的记录:
- 计算中间槽的位置:
(0 4)/2=2
,所以查看槽2
对应记录的主键值为8
,又因为8 > 6
,所以设置high=2
,low
保持不变。 - 重新计算中间槽的位置:
(0 2)/2=1
,所以查看槽1
对应的主键值为4
,又因为4 < 6
,所以设置low=1
,high
保持不变。 - 因为
high - low
的值为1,所以确定主键值为6
的记录在槽2
对应的组中。此刻我们需要找到槽2
中主键值最小的那条记录,然后沿着单向链表遍历槽2
中的记录。但是我们前边又说过,每个槽对应的记录都是该组中主键值最大的记录,这里槽2
对应的记录是主键值为8
的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1
对应的记录(主键值为4
),该条记录的下一条记录就是槽2
中主键值最小的记录,该记录的主键值为5
。所以我们可以从这条主键值为5
的记录出发,遍历槽2
中的各条记录,直到找到主键值为6
的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。
总结:
- 通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录。
- 通过记录的
next_record
属性遍历该槽所在的组中的各个记录。
总结
- InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页叫做
数据页
。 - 一个数据页可以被大致划分为7个部分,分别是
-
File Header
,表示页的一些通用信息,占固定的38字节。 -
Page Header
,表示数据页专有的一些信息,占固定的56个字节。 -
Infimum Supremum
,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的26
个字节。 -
User Records
:真实存储我们插入的记录的部分,大小不固定。 -
Free Space
:页中尚未使用的部分,大小不确定。 -
Page Directory
:页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。 -
File Trailer
:用于检验页是否完整的部分,占用固定的8个字节。
-
- 每个记录的头信息中都有一个
next_record
属性,从而使页中的所有记录串联成一个单链表
。 -
InnoDB
会把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个槽
,存放在Page Directory
中,所以在一个页中根据主键查找记录是非常快的,分为两步:- 通过二分法确定该记录所在的槽。
- 通过记录的next_record属性遍历该槽所在的组中的各个记录。
- 每个数据页的
File Header
部分都有上一个和下一个页的编号,所以所有的数据页会组成一个双链表
。 - 为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和页面最后修改时对应的
LSN
值,如果首部和尾部的校验和和LSN
值校验不成功的话,就说明同步过程出现了问题。
Innodb 的 Buffer Pool
缓存的重要性
InnoDB
存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存
起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO
的开销了。
free链表的管理
把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表
(或者说空闲链表)。
缓存页的哈希
我们怎么知道该页在不在Buffer Pool
中呢?难不成需要依次遍历Buffer Pool
中各个缓存页么?一个Buffer Pool
中的缓存页这么多都遍历完岂不是要累死?
我们其实是根据表空间号 页号
来定位一个页的,也就相当于表空间号 页号
是一个key
,缓存页
就是对应的value
,怎么通过一个key
来快速找着一个value
呢?哈哈,那肯定是哈希表喽~
flush链表的管理
如果我们修改了Buffer Pool
中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页
(英文名:dirty page
)。如果每次产生脏页就立即同步到磁盘上的话会严重影响程序性能
凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表
。
Buffer Pool 的 LRU 链表
挺多的,详情看掘金的 mysql 小册 https://juejin.im/book/5bffcbc9f265da614b11b731/section/5c238f0851882521eb44c51f
总结
-
磁盘太慢,用内存作为缓存很有必要。
-
Buffer Pool
本质上是InnoDB
向操作系统申请的一段连续的内存空间,可以通过innodb_buffer_pool_size
来调整它的大小。 -
Buffer Pool
向操作系统申请的连续内存由控制块和缓存页组成,每个控制块和缓存页都是一一对应的,在填充足够多的控制块和缓存页的组合后,Buffer Pool
剩余的空间可能产生不够填充一组控制块和缓存页,这部分空间不能被使用,也被称为碎片
。 -
InnoDB
使用了许多链表
来管理Buffer Pool
。 -
free链表
中每一个节点都代表一个空闲的缓存页,在将磁盘中的页加载到Buffer Pool
时,会从free链表
中寻找空闲的缓存页。 -
为了快速定位某个页是否被加载到
Buffer Pool
,使用表空间号 页号
作为key
,缓存页作为value
,建立哈希表。 -
在
Buffer Pool
中被修改的页称为脏页
,脏页并不是立即刷新,而是被加入到flush链表
中,待之后的某个时刻同步到磁盘上。 -
LRU链表
分为young
和old
两个区域,可以通过innodb_old_blocks_pct
来调节old
区域所占的比例。首次从磁盘上加载到Buffer Pool
的页会被放到old
区域的头部,在innodb_old_blocks_time
间隔时间内访问该页不会把它移动到young
区域头部。在Buffer Pool
没有可用的空闲缓存页时,会首先淘汰掉old
区域的一些页。 -
我们可以通过指定
innodb_buffer_pool_instances
来控制Buffer Pool
实例的个数,每个Buffer Pool
实例中都有各自独立的链表,互不干扰。 -
自
MySQL 5.7.5
版本之后,可以在服务器运行过程中调整Buffer Pool
大小。每个Buffer Pool
实例由若干个chunk
组成,每个chunk
的大小可以在服务器启动时通过启动参数调整。 -
可以用下边的命令查看
Buffer Pool
的状态信息:SHOW ENGINE INNODB STATUSG
B 树索引
没有索引的查找
在一个页中的查找
假设目前表中的记录比较少,所有的记录都可以被存放到一个页中,在查找记录的时候可以根据搜索条件的不同分为两种情况:
-
以主键为搜索条件
这个查找过程我们已经很熟悉了,可以在
页目录
中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。 -
以其他列作为搜索条件
对非主键列的查找的过程可就不这么幸运了,因为在数据页中并没有对非主键列建立所谓的
页目录
,所以我们无法通过二分法快速定位相应的槽
。这种情况下只能从最小记录
开始依次遍历单链表中的每条记录,然后对比每条记录是不是符合搜索条件。很显然,这种查找的效率是非常低的。
在多个页中的查找
大部分情况下我们表中存放的记录都是非常多的,需要好多的数据页来存储这些记录。在很多页中查找记录的话可以分为两个步骤:
- 定位到记录所在的页。
- 从所在的页内中查找相应的记录。
在没有索引的情况下,不论是根据主键列或者其他列的值进行查找,由于我们并不能快速的定位到记录所在的页,所以只能从第一个页沿着双向链表一直往下找,在每一个页中根据我们刚刚唠叨过的查找方式去查找指定的记录。因为要遍历所有的数据页,所以这种方式显然是超级耗时的,如果一个表有一亿条记录,使用这种方式去查找记录那要等到猴年马月才能等到查找结果。所以祖国和人民都在期盼一种能高效完成搜索的方法,索引
同志就要亮相登台了。
索引(B 树)详解
mysql> CREATE TABLE index_demo(
-> c1 INT,
-> c2 INT,
-> c3 CHAR(1),
-> PRIMARY KEY(c1)
-> ) ROW_FORMAT = Compact;
Query OK, 0 rows affected (0.03 sec)
一个简单的索引方案
1. 下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。
假设一个页只能放 3 条记录,当我们要插入第四条记录时,就要进行页分裂了(页分裂之后也要满足下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值)
2. 给所有的页建立一个目录项。
由于数据页的编号可能并不是连续的,所以在向index_demo
表中插入许多条记录后,可能是这样的效果:
因为这些16KB
的页在物理存储上可能并不挨着,所以如果想从这么多页中根据主键值快速定位某些记录所在的页,我们需要给它们做个目录,每个页对应一个目录项,每个目录项包括下边两个部分:
- 页的用户记录中最小的主键值,我们用
key
来表示。 - 页号,我们用
page_no
表示。
以页28
为例,它对应目录项2
,这个目录项中包含着该页的页号28
以及该页中用户记录的最小主键值5
。我们只需要把几个目录项在物理存储器上连续存储,比如把他们放到一个数组里,就可以实现根据主键值快速查找某条记录的功能了。比方说我们想找主键值为20
的记录,具体查找过程分两步:
- 先从目录项中根据二分法快速确定出主键值为
20
的记录在目录项3
中(因为12 < 20 < 209
),它对应的页是页9
。 - 再根据前边说的在页中查找记录的方式去
页9
中定位具体的记录。
这个简易的索引方案存在的缺点:
-
InnoDB
是使用页来作为管理存储空间的基本单位,也就是最多能保证16KB
的连续存储空间,而随着表中记录数量的增多,需要非常大的连续的存储空间才能把所有的目录项都放下,这对记录数量非常多的表是不现实的。 - 我们时常会对记录进行增删,假设我们把
页28
中的记录都删除了,页28
也就没有存在的必要了,那意味着目录项2
也就没有存在的必要了,这就需要把目录项2
后的目录项都向前移动一下,这种牵一发而动全身的设计不是什么好主意~
Innodb 的索引方案
复用了之前存储用户记录的数据页来存储目录项,为了和用户记录做一下区分,我们把这些用来表示目录项的记录称为目录项记录。用 record_type
区分用户记录和目录项记录记录头信息里的 record_type
属性
-
0
:普通的用户记录 -
1
:目录项记录 -
2
:最小记录 -
3
:最大记录
目录页和数据页的区别
-
目录项记录
的record_type
值是1,而普通用户记录的record_type
值是0。 -
目录项记录
只有主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列,另外还有InnoDB
自己添加的隐藏列。 - 还记得我们之前在唠叨记录头信息的时候说过一个叫
min_rec_mask
的属性么,只有在存储目录项记录
的页中的主键值最小的目录项记录
的min_rec_mask
值为1
,其他别的记录的min_rec_mask
值都是0
。
主键查询过程简述
很明显的是:没有用索引我们是需要遍历双向链表来定位对应的页,现在通过 “目录” 就可以很快地定位到对应的页上了!(二分查找,时间复杂度近似为O(logn))
其实底层结构就是B 树,B 树作为树的一种实现,能够让我们很快地查找出对应的记录。
聚簇索引
-
使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:
- 页内的记录是按照主键的大小顺序排成一个单向链表。
- 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。
- 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。
-
B
树的叶子节点存储的是完整的用户记录。所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。
我们把具有这两种特性的B
树称为聚簇索引
,所有完整的用户记录都存放在这个聚簇索引
的叶子节点处。这种聚簇索引
并不需要我们在MySQL
语句中显式的使用INDEX
语句去创建(后边会介绍索引相关的语句),InnoDB
存储引擎会自动的为我们创建聚簇索引。另外有趣的一点是,在InnoDB
存储引擎中,聚簇索引
就是数据的存储方式(所有的用户记录都存储在了叶子节点
),也就是所谓的索引即数据,数据即索引。
二级索引
聚簇索引只适用于主键查询,当想要使用非主键列作为查询条件时就需要重新构建 B 树了(二级索引)
- 使用记录
c2
列的大小进行记录和页的排序,这包括三个方面的含义:- 页内的记录是按照
c2
列的大小顺序排成一个单向链表。 - 各个存放用户记录的页也是根据页中记录的
c2
列大小顺序排成一个双向链表。 - 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的
c2
列大小顺序排成一个双向链表。
- 页内的记录是按照
-
B
树的叶子节点存储的并不是完整的用户记录,而只是c2列 主键
这两个列的值。 - 目录项记录中不再是
主键 页号
的搭配,而变成了c2列 主键 页号
的搭配。
查找过程 & 回表
以查找c2
列的值为4
的记录为例,查找过程如下:
-
确定
目录项记录
页根据
根页面
,也就是页44
,可以快速定位到目录项记录
所在的页为页42
(因为2 < 4 < 9
)。 -
通过
目录项记录
页确定用户记录真实所在的页。在
页42
中可以快速定位到实际存储用户记录的页,但是由于c2
列并没有唯一性约束,所以c2
列值为4
的记录可能分布在多个数据页中,又因为2 < 4 ≤ 4
,所以确定实际存储用户记录的页在页34
和页35
中。 -
在真实存储用户记录的页中定位到具体的记录。
到
页34
和页35
中定位到具体的记录。 -
但是这个
B
树的叶子节点中的记录只存储了c2
和c1
(也就是主键
)两个列,所以我们必须再根据主键值去聚簇索引中再查找一遍完整的用户记录(回表)。
也就是根据c2
列的值查询一条完整的用户记录需要使用到2
棵B
树!!!这种B
树也被称为二级索引
(英文名secondary index
),或者辅助索引
。
联合索引
让B
树按照c2
和c3
列的大小进行排序,这个包含两层含义:
- 先把各个记录和页按照
c2
列进行排序。 - 在记录的
c2
列相同的情况下,采用c3
列进行排序
- 每条
目录项记录
都由c2
、c3
、页号
这三个部分组成,各条记录先按照c2
列的值进行排序,如果记录的c2
列相同,则按照c3
列的值进行排序。 -
B
树叶子节点处的用户记录由c2
、c3
和主键c1
列组成。
以c2和c3列的大小为排序规则建立的B 树称为联合索引,本质上也是一个二级索引。它的意思与分别为c2和c3列分别建立索引的表述是不同的,不同点如下:
- 建立
联合索引
只会建立如上图一样的1棵B
树。 - 为c2和c3列分别建立索引会分别以
c2
和c3
列的大小为排序规则建立2棵B
树。
MyISAM中的索引方案简单介绍
InnoDB
中索引即数据,也就是聚簇索引的那棵B
树的叶子节点中已经把所有完整的用户记录都包含了,而MyISAM
的索引方案虽然也使用树形结构,但是却将索引和数据分开存储:
-
将表中的记录按照记录的插入顺序单独存储在一个文件中,称之为
数据文件
。这个文件并不划分为若干个数据页,有多少记录就往这个文件中塞多少记录就成了。我们可以通过行号而快速访问到一条记录。 -
使用
MyISAM
存储引擎的表会把索引信息另外存储到一个称为索引文件
的另一个文件中。MyISAM
会单独为表的主键创建一个索引,只不过在索引的叶子节点中存储的不是完整的用户记录,而是主键值 行号
的组合。也就是先通过索引找到对应的行号,再通过行号去找对应的记录!这一点和
InnoDB
是完全不相同的,在InnoDB
存储引擎中,我们只需要根据主键值对聚簇索引
进行一次查找就能找到对应的记录,而在MyISAM
中却需要进行一次回表
操作,意味着MyISAM
中建立的索引相当于全部都是二级索引
! -
如果有需要的话,我们也可以对其它的列分别建立索引或者建立联合索引,原理和
InnoDB
中的索引差不多,不过在叶子节点处存储的是相应的列 行号
。这些索引也全部都是二级索引
。
为表字段添加索引
-
InnoDB
和MyISAM
会自动为主键或者声明为UNIQUE
的列去自动建立B
树索引 -
每建立一个索引都会建立一棵
B
树,每插入一条记录都要维护各个记录、数据页的排序关系,这是很费性能和存储空间的。
1.添加PRIMARY KEY(主键索引)
ALTER TABLE `table_name` ADD PRIMARY KEY ( `column` )
2.添加UNIQUE(唯一索引)
ALTER TABLE `table_name` ADD UNIQUE ( `column` )
3.添加INDEX(普通索引)
ALTER TABLE `table_name` ADD INDEX index_name ( `column` )
4.添加FULLTEXT(全文索引)
ALTER TABLE `table_name` ADD FULLTEXT ( `column`)
5.添加多列索引
ALTER TABLE `table_name` ADD INDEX index_name ( `column1`, `column2`, `column3` )
B 树索引的使用
索引的代价
- 空间上的代价
每建立一个索引都要为它建立一棵B 树,每一棵B 树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B 树由许多数据页组成,那可是很大的一片存储空间呢。
- 时间上的代价
每次对表中的数据进行增、删、改操作时,都需要去修改各个B 树索引。而且我们讲过,B 树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位,页面分裂、页面回收啥的操作来维护好节点和记录的排序。、
B 树的使用条件
CREATE TABLE person_info(
id INT NOT NULL auto_increment,
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
PRIMARY KEY (id),
KEY idx_name_birthday_phone_number (name, birthday, phone_number)
);
全值匹配
- 如果我们的搜索条件中的列和索引列一致的话,这种情况就称为全值匹配
匹配最左连续的列
-
SELECT * FROM person_info WHERE name = ‘Ashburn‘ AND birthday = ‘1990-09-27‘;
- 可以使用索引
-
SELECT * FROM person_info WHERE birthday = ‘1990-09-27‘;
- 不能使用索引
- 因为B 树的数据页和记录先是按照name列的值排序的,在name列的值相同的情况下才使用birthday列进行排序,也就是说name列的值不同的记录中birthday的值可能是无序的。
- 如果我们想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中从最左边连续的列。
-
SELECT * FROM person_info WHERE name = ‘Ashburn‘ AND phone_number = ‘15123983239‘;
- 因为中间跳过了 birthday 索引,所以只能用到 name 索引
-
匹配列前缀
- 也就是说这些字符串的前n个字符,也就是前缀都是排好序的,所以对于字符串类型的索引列来说,我们只匹配它的前缀也是可以快速定位记录的,比方说我们想查询名字以‘As‘开头的记录,那就可以这么写查询语句:
SELECT * FROM person_info WHERE name LIKE ‘As%‘;
- 但是需要注意的是,如果只给出后缀或者中间的某个字符串,比如这样:
SELECT * FROM person_info WHERE name LIKE ‘%As%‘;
匹配范围值
-
SELECT * FROM person_info WHERE name > ‘Asa‘ AND name < ‘Barlow‘;
- 由于B 树中的数据页和记录是先按name列排序的,所以我们上边的查询过程其实是这样的:
- 找到name值为Asa的记录。
- 找到name值为Barlow的记录。
- 哦啦,由于所有记录都是由链表连起来的(记录之间用单链表,数据页之间用双链表),所以他们之间的记录都可以很容易的取出来喽~
- 找到这些记录的主键值,再到聚簇索引中回表查找完整的记录。
- 由于B 树中的数据页和记录是先按name列排序的,所以我们上边的查询过程其实是这样的:
- 如果对多个列同时进行范围查找的话,只有对索引最左边的那个列进行范围查找的时候才能用到B 树索引
-
SELECT * FROM person_info WHERE name > ‘Asa‘ AND name < ‘Barlow‘ AND birthday > ‘1980-01-01‘;
- 通过条件
name > ‘Asa‘ AND name < ‘Barlow‘
来对name进行范围,查找的结果可能有多条name值不同的记录, - 对这些name值不同的记录继续通过
birthday > ‘1980-01-01‘
条件继续过滤。
- 通过条件
- 这样子对于联合索引idx_name_birthday_phone_number来说,只能用到name列的部分,而用不到birthday列的部分,因为只有name值相同的情况下才能用birthday列的值进行排序,而这个查询中通过name进行范围查找的记录中可能并不是按照birthday列进行排序的,所以在搜索条件中继续以birthday列进行查找时是用不到这个B 树索引的。
-
精确匹配某一列并范围匹配另外一列
-
SELECT * FROM person_info WHERE name = ‘Ashburn‘ AND birthday > ‘1980-01-01‘ AND birthday < ‘2000-12-31‘ AND phone_number > ‘15100000000‘;
- name = ‘Ashburn‘,对name列进行精确查找,当然可以使用B 树索引了。
- birthday > ‘1980-01-01‘ AND birthday < ‘2000-12-31‘,由于name列是精确查找,所以通过name = ‘Ashburn‘条件查找后得到的结果的name值都是相同的,它们会再按照birthday的值进行排序。所以此时对birthday列进行范围查找是可以用到B 树索引的。
- phone_number > ‘15100000000‘,通过birthday的范围查找的记录的birthday的值可能不同,所以这个条件无法再利用B 树索引了,只能遍历上一步查询得到的记录。
用于排序(含文件排序)
文件排序
- 我们在写查询语句的时候经常需要对查询出来的记录通过ORDER BY子句按照某种规则进行排序。一般情况下,我们只能把记录都加载到内存中,再用一些排序算法,比如快速排序、归并排序、吧啦吧啦排序等等在内存中对这些记录进行排序,有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端。在MySQL中,把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort),跟文件这个词儿一沾边儿,就显得这些排序操作非常慢了(磁盘和内存的速度比起来,就像是飞机和蜗牛的对比)。但是如果ORDER BY子句里使用到了我们的索引列,就有可能省去在内存或文件中排序的步骤,比如下边这个简单的查询语句:
SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;
这个查询的结果集需要先按照name
值排序,如果记录的name
值相同,则需要按照birthday
来排序,如果birthday
的值相同,则需要按照phone_number
排序。大家可以回过头去看我们建立的idx_name_birthday_phone_number
索引的示意图,因为这个B
树索引本身就是按照上述规则排好序的,所以直接从索引中提取数据,然后进行回表
操作取出该索引中不包含的列就好了。简单吧?是的,索引就是这么牛逼。
使用联合索引进行排序注意事项
- ORDER BY的子句后边的列的顺序也必须按照索引列的顺序给出,如果给出ORDER BY phone_number, birthday, name的顺序,那也是用不了B 树索引
- 同理,ORDER BY name、ORDER BY name, birthday这种匹配索引左边的列的形式可以使用部分的B 树索引。当联合索引左边列的值为常量,也可以使用后边的列进行排序
不可以使用索引进行排序的几种情况
ASC、DESC混用
- ORDER BY name, birthday LIMIT 10
- 这种情况直接从索引的最左边开始往右读10行记录就可以了。
- ORDER BY name DESC, birthday DESC LIMIT 10,
- 这种情况直接从索引的最右边开始往左读10行记录就可以了。
- (混用 desc、asc)SELECT * FROM person_info ORDER BY name, birthday DESC LIMIT 10;
- 先从索引的最左边确定name列最小的值,然后找到name列等于该值的所有记录,然后从name列等于该值的最右边的那条记录开始往左找10条记录。
- 如果name列等于最小的值的记录不足10条,再继续往右找name值第二小的记录,重复上边那个过程,直到找到10条记录为止。
- 累不累?累!重点是这样不能高效使用索引,而要采取更复杂的算法去从索引中取数据,设计MySQL的大叔觉得这样还不如直接文件排序来的快,所以就规定使用联合索引的各个排序列的排序顺序必须是一致的。
WHERE子句中出现非排序使用到的索引列
- SELECT * FROM person_info WHERE country = ‘China‘ ORDER BY name LIMIT 10;
- 如果WHERE子句中出现了非排序使用到的索引列,那么排序依然是使用不到索引的
- 这个查询只能先把符合搜索条件country = ‘China‘的记录提取出来后再进行排序,是使用不到索引。注意和下边这个查询作区别:
- SELECT * FROM person_info WHERE name = ‘A‘ ORDER BY birthday, phone_number LIMIT 10;
- 虽然这个查询也有搜索条件,但是name = ‘A‘可以使用到索引idx_name_birthday_phone_number,而且过滤剩下的记录还是按照birthday、phone_number列排序的,所以还是可以使用索引进行排序的。
排序列包含非同一个索引的列
- SELECT * FROM person_info ORDER BY name, country LIMIT 10;
排序列使用了复杂的表达式
- SELECT * FROM person_info ORDER BY UPPER(name) LIMIT 10;
- 使用了UPPER函数修饰过的列就不是单独的列啦,这样就无法使用索引进行排序啦。
用于分组
SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number
这个查询语句相当于做了3次分组操作:
- 先把记录按照name值进行分组,所有name值相同的记录划分为一组。
- 将每个name值相同的分组里的记录再按照birthday的值进行分组,将birthday值相同的记录放到一个小分组里,所以看起来就像在一个大分组里又化分了好多小分组。
- 再将上一步中产生的小分组按照phone_number的值分成更小的分组,所以整体上看起来就像是先把记录分成一个大分组,然后把大分组分成若干个小分组,然后把若干个小分组再细分成更多的小小分组。
和使用B 树索引进行排序是一个道理,分组列的顺序也需要和索引列的顺序一致,也可以只使用索引列中左边的列进行分组,吧啦吧啦的~
回表的代价
idx_name_birthday_phone_number
索引为例,看下边这个查询:
SELECT * FROM person_info WHERE name > ‘Asa‘ AND name < ‘Barlow‘;
在使用idx_name_birthday_phone_number
索引进行查询时大致可以分为这两个步骤:
- 从索引
idx_name_birthday_phone_number
对应的B
树中取出name
值在Asa
~Barlow
之间的用户记录。 - 由于索引
idx_name_birthday_phone_number
对应的B
树用户记录中只包含name
、birthday
、phone_number
、id
这4个字段,而查询列表是*
,意味着要查询表中所有字段,也就是还要包括country
字段。这时需要把从上一步中获取到的每一条记录的id
字段都到聚簇索引对应的B
树中找到完整的用户记录,也就是我们通常所说的回表
,然后把完整的用户记录返回给查询用户。
顺序 I/O
由于索引idx_name_birthday_phone_number
对应的B
树中的记录首先会按照name
列的值进行排序,所以值在Asa
~Barlow
之间的记录在磁盘中的存储是相连的,集中分布在一个或几个数据页中,我们可以很快的把这些连着的记录从磁盘中读出来。
随机 I/O
根据第1步中获取到的记录的id
字段的值可能并不相连,而在聚簇索引中记录是根据id
(也就是主键)的顺序排列的,所以根据这些并不连续的id
值到聚簇索引中访问完整的用户记录可能分布在不同的数据页中,这样读取完整的用户记录可能要访问更多的数据页。
所以这个使用索引idx_name_birthday_phone_number
的查询有这么两个特点:
- 会使用到两个
B
树索引,一个二级索引,一个聚簇索引。 - 访问二级索引使用
顺序I/O
,访问聚簇索引使用随机I/O
。
查询优化器
-
顺序I/O比随机I/O的性能高很多
-
需要回表的记录越多,使用二级索引的性能就越低
-
查询优化器会事先对表中的记录计算一些统计数据,然后再利用这些统计数据根据查询的条件来计算一下需要回表的记录数,需要回表的记录数越多,就越倾向于使用全表扫描,反之倾向于使用
二级索引 回表
的方式。
可以限制查询获取较少的记录数让优化器更倾向于选择使用二级索引 回表
的方式进行查询,因为回表的记录越少,性能提升就越高
SELECT * FROM person_info WHERE name > ‘Asa‘ AND name < ‘Barlow‘ LIMIT 10;
覆盖索引
为了彻底告别回表
操作带来的性能损耗,我们建议:最好在查询列表里只包含索引列,比如这样:
SELECT name, birthday, phone_number FROM person_info WHERE name > ‘Asa‘ AND name < ‘Barlow‘
因为我们只查询name
, birthday
, phone_number
这三个索引列的值,所以在通过idx_name_birthday_phone_number
索引得到结果后就不必到聚簇索引
中再查找记录的剩余列,也就是country
列的值了,这样就省去了回表
操作带来的性能损耗。
索引的选择
只为用于搜索、排序或分组的列创建索引
只为出现在WHERE子句中的列、连接子句中的连接列,或者出现在ORDER BY或GROUP BY子句中的列创建索引。而出现在查询列表中的列就没必要建立索引了:
考虑列的基数
- 列的基数指的是某一列中不重复数据的个数,比方说某个列包含值2, 5, 8, 2, 5, 8, 2, 5, 8,虽然有9条记录,但该列的基数却是3。
- 最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。
索引列的类型要尽量的小
在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如我们能使用INT就不要使用BIGINT,能使用MEDIUMINT就不要使用INT~
索引字符串值的前缀
只对字符串的前几个字符进行索引——也就是说在二级索引的记录中只保留字符串前几个字符。
CREATE TABLE person_info(
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
KEY idx_name_birthday_phone_number (name(10), birthday, phone_number)
);
name(10)
就表示在建立的B
树索引中只保留记录的前10
个字符的编码,这种只索引字符串值的前缀的策略是非常鼓励的,尤其是在字符串类型能存储的字符比较多的时候。
索引列前缀对排序的影响
如果使用了索引列前缀,比方说前边只把name
列的前10个字符放到了二级索引中,下边这个查询可能就有点儿尴尬了:
SELECT * FROM person_info ORDER BY name LIMIT 10;
因为二级索引中不包含完整的name
列信息,所以无法对前十个字符相同,后边的字符不同的记录进行排序,也就是使用索引列前缀的方式无法支持使用索引排序,只好乖乖的用文件排序喽。
让索引列在比较表达式中单独出现
如果索引列在比较表达式中不是以单独列的形式出现,而是以某个表达式,或者函数调用形式出现的话,是用不到索引的。
-
WHERE my_col * 2 < 4
不行 -
WHERE my_col < 4/2
行
主键插入顺序
如果数据页满了,会导致页分裂和记录位移,也就意味着性能损耗。
**建议:**让主键具有AUTO_INCREMENT
,让存储引擎自己为表生成主键,而不是我们手动插入
冗余和重复索引
CREATE TABLE person_info(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
PRIMARY KEY (id),
KEY idx_name_birthday_phone_number (name(10), birthday, phone_number),
KEY idx_name (name(10))
);
我们知道,通过idx_name_birthday_phone_number
索引就可以对name
列进行快速搜索,再创建一个专门针对name
列的索引就算是一个冗余
索引,维护这个索引只会增加维护的成本,并不会对搜索有什么好处。
总结
-
B
树索引在空间和时间上都有代价,所以没事儿别瞎建索引。 -
B
树索引适用于下边这些情况:- 全值匹配
- 匹配左边的列
- 匹配范围值
- 精确匹配某一列并范围匹配另外一列
- 用于排序
- 用于分组
- 在使用索引时需要注意下边这些事项:
- 只为用于搜索、排序或分组的列创建索引
- 为列的基数大的列创建索引
- 索引列的类型尽量小
- 可以只对字符串值的前缀建立索引
- 只有索引列在比较表达式中单独出现才可以适用索引
- 为了尽可能少的让
聚簇索引
发生页面分裂和记录移位的情况,建议让主键拥有AUTO_INCREMENT
属性。 - 定位并删除表中的重复和冗余索引
- 尽量使用
覆盖索引
进行查询,避免回表
带来的性能损耗。
事务
事务特性(ACID)
- 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
- 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
- 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;(通过锁实现,其余三个通过 redo 和 undo 日志实现)
- 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
并发事务带来的问题
- 脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
- 丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。
- 不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
- 幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
不可重复度和幻读区别:
不可重复读的重点是修改,幻读的重点在于新增或者删除。
例1(同样的条件, 你读取过的数据, 再次读取出来发现值不一样了 ):事务1中的A先生读取自己的工资为 1000的操作还没完成,事务2中的B先生就修改了A的工资为2000,导 致A再读自己的工资时工资变为 2000;这就是不可重复读。
例2(同样的条件, 第1次和第2次读出来的记录数不一样 ):假某工资单表中工资大于3000的有4人,事务1读取了所有工资大于3000的人,共查到4条记录,这时事务2 又插入了一条工资大于3000的记录,事务1再次读取时查到的记录就变为了5条,这样就导致了幻读。
事务隔离级别
SQL 标准定义了四个隔离级别:
- READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
- READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
- REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
- SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
隔离级别 | 脏读 | 不可重复读 | 幻影读 |
---|---|---|---|
READ-UNCOMMITTED | √ | √ | √ |
READ-COMMITTED | × | √ | √ |
REPEATABLE-READ | × | × | √ |
SERIALIZABLE | × | × | × |
MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。`
与 SQL 标准不同的地方在于InnoDB 存储引擎在 **REPEATABLE-READ(可重读)事务隔离级别下使用的是Next-Key Lock
锁算法,因此可以避免幻读的产生,这与其他数据库系统(如 SQL Server)是不同的。所以说InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读) 已经可以完全保证事务的隔离性要求,即达到了 SQL标准的SERIALIZABLE(可串行化)**隔离级别。
因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是READ-COMMITTED(读取提交内容):,但是你要知道的是InnoDB 存储引擎默认使用 **REPEATABLE-READ(可重读)**并不会有任何性能损失。
InnoDB 存储引擎在 分布式事务 的情况下一般会用到**SERIALIZABLE(可串行化)**隔离级别。
MVCC 原理
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
版本链
对于使用InnoDB
存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列
-
trx_id
:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id
赋值给trx_id
隐藏列。 -
roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志
中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
比方说我们的表hero
现在只包含一条记录:
mysql> SELECT * FROM hero;
-------- -------- ---------
| number | name | country |
-------- -------- ---------
| 1 | 刘备 | 蜀 |
-------- -------- ---------
1 row in set (0.07 sec)
假设插入该记录的事务id
为80
,那么此刻该条记录的示意图如下所示:
假设之后两个事务id
分别为100
、200
的事务对这条记录进行UPDATE
操作,操作流程如下:
每次对记录进行改动,都会记录一条undo日志
,每条undo日志
也都有一个roll_pointer
属性(INSERT
操作对应的undo日志
没有该属性,因为该记录并没有更早的版本),可以将这些undo日志
都连起来,串成一个链表,所以现在的情况就像下图一样:
对该记录每次更新后,都会将旧值放到一条undo日志
中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer
属性连接成一个链表,我们把这个链表称之为版本链
,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id
,这个信息很重要,我们稍后就会用到。
ReadView
为什么需要 ReadView?
- 对于使用**READ UNCOMMITTED(未提交读)**隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;
- 对于使用**SERIALIZABLE(可串行化)**隔离级别的事务来说,设计InnoDB的大叔规定使用加锁的方式来访问记录(加锁是啥我们后续文章中说哈);
- 对于使用**READ COMMITTED(已提交读)和REPEATABLE READ(可重复读)**隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的,为此,提出了 ReadVIew
ReadView 四个比较重要的内容:
-
m_ids
:表示在生成ReadView
时当前系统中活跃的读写事务的事务id
列表。 -
min_trx_id
:表示在生成ReadView
时当前系统中活跃的读写事务中最小的事务id
,也就是m_ids
中的最小值。 -
max_trx_id
:表示生成ReadView
时系统中应该分配给下一个事务的id
值。 -
creator_trx_id
:表示生成该ReadView
的事务的事务id
。(只读事务默认都为 0)
利用 ReadView 判断版本的可见性
- 如果被访问版本的
trx_id
属性值与ReadView
中的creator_trx_id
值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
属性值小于ReadView
中的min_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
属性值大于ReadView
中的max_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的
trx_id
属性值在ReadView
的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。 - 如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
READ COMMITTED 和 REPEATABLE READ 隔离级别的的区别
READ COMMITTED
和REPEATABLE READ
隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。
以表hero
为例来,假设现在表hero
中只有一条由 事务id
为 80
的事务插入的一条记录:
mysql> SELECT * FROM hero;
-------- -------- ---------
| number | name | country |
-------- -------- ---------
| 1 | 刘备 | 蜀 |
-------- -------- ---------
1 row in set (0.07 sec)
READ COMMITTED —— 每次读取数据前都生成一个ReadView
比方说现在系统里有两个事务id
分别为100
、200
的事务在执行:
# Transaction 100
BEGIN;
UPDATE hero SET name = ‘关羽‘ WHERE number = 1;
UPDATE hero SET name = ‘张飞‘ WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
小贴士: 再次强调一遍,事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在Transaction 200中更新一些别的表的记录,目的是让它分配事务id。
此刻,表hero
中number
为1
的记录得到的版本链表如下所示:
假设现在有一个使用READ COMMITTED
隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为‘刘备‘
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是‘张飞‘
,该版本的trx_id
值为100
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是‘关羽‘
,该版本的trx_id
值也为100
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是‘刘备‘
,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为‘刘备‘
的记录。
之后,我们把事务id
为100
的事务提交一下,就像这样:
# Transaction 100
BEGIN;
UPDATE hero SET name = ‘关羽‘ WHERE number = 1;
UPDATE hero SET name = ‘张飞‘ WHERE number = 1;
COMMIT;
然后再到事务id
为200
的事务中更新一下表hero
中number
为1
的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = ‘赵云‘ WHERE number = 1;
UPDATE hero SET name = ‘诸葛亮‘ WHERE number = 1;
此刻,表hero
中number
为1
的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED
隔离级别的事务中继续查找这个number
为1
的记录,如下:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为‘刘备‘
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为‘张飞‘
这个SELECT2
的执行过程如下:
- 在执行
SELECT
语句时会又会单独生成一个ReadView
,该ReadView
的m_ids
列表的内容就是[200]
(事务id
为100
的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id
为200
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是‘诸葛亮‘
,该版本的trx_id
值为200
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是‘赵云‘
,该版本的trx_id
值为200
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是‘张飞‘
,该版本的trx_id
值为100
,小于ReadView
中的min_trx_id
值200
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为‘张飞‘
的记录。
以此类推,如果之后事务id
为200
的记录也提交了,再此在使用READ COMMITTED
隔离级别的事务中查询表hero
中number
值为1
的记录时,得到的结果就是‘诸葛亮‘
了,具体流程我们就不分析了。总结一下就是:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
REPEATABLE READ —— 在第一次读取数据时生成一个ReadView
对于使用REPEATABLE READ
隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView
,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。
比方说现在系统里有两个事务id
分别为100
、200
的事务在执行:
# Transaction 100
BEGIN;
UPDATE hero SET name = ‘关羽‘ WHERE number = 1;
UPDATE hero SET name = ‘张飞‘ WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
此刻,表hero
中number
为1
的记录得到的版本链表如下所示:
假设现在有一个使用REPEATABLE READ
隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为‘刘备‘
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是‘张飞‘
,该版本的trx_id
值为100
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是‘关羽‘
,该版本的trx_id
值也为100
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是‘刘备‘
,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为‘刘备‘
的记录。
之后,我们把事务id
为100
的事务提交一下,就像这样:
# Transaction 100
BEGIN;
UPDATE hero SET name = ‘关羽‘ WHERE number = 1;
UPDATE hero SET name = ‘张飞‘ WHERE number = 1;
COMMIT;
然后再到事务id
为200
的事务中更新一下表hero
中number
为1
的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = ‘赵云‘ WHERE number = 1;
UPDATE hero SET name = ‘诸葛亮‘ WHERE number = 1;
此刻,表hero
中number
为1
的记录的版本链就长这样:
然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1
的记录,如下:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为‘刘备‘
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为‘刘备‘
这个SELECT2
的执行过程如下:
- 因为当前事务的隔离级别为
REPEATABLE READ
,而之前在执行SELECT1
时已经生成过ReadView
了,所以此时直接复用之前的ReadView
,之前的ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是‘诸葛亮‘
,该版本的trx_id
值为200
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是‘赵云‘
,该版本的trx_id
值为200
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是‘张飞‘
,该版本的trx_id
值为100
,而m_ids
列表中是包含值为100
的事务id
的,所以该版本也不符合要求,同理下一个列name
的内容是‘关羽‘
的版本也不符合要求。继续跳到下一个版本。 - 下一个版本的列
name
的内容是‘刘备‘
,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c
为‘刘备‘
的记录。
也就是说两次SELECT
查询得到的结果是重复的,记录的列c
值都是‘刘备‘
,这就是可重复读
的含义。如果我们之后再把事务id
为200
的记录提交了,然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1
的记录,得到的结果还是‘刘备‘
,具体执行过程大家可以自己分析一下。
MVCC 小结
所谓的MVCC
(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD
、REPEATABLE READ
这两种隔离级别的事务在执行普通的SEELCT
操作时访问记录的版本链的过程,这样子可以使不同事务的读-写
、写-读
操作并发执行,从而提升系统性能。
READ COMMITTD
、REPEATABLE READ
这两个隔离级别的一个很大不同就是:生成ReadView的时机不同
- READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView
- 而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。
小贴士: 我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的,大家可以对比上边举的例子自己试想一下怎么使用。 另外,所谓的MVCC只是在我们进行普通的SEELCT查询时才生效,截止到目前我们所见的所有SELECT语句都算是普通的查询,至于啥是个不普通的查询,我们稍后再说哈~
关于 purge
大家有没有发现两件事儿:
- 我们说
insert undo
在事务提交之后就可以被释放掉了,而update undo
由于还需要支持MVCC
,不能立即删除掉。 - 为了支持
MVCC
,对于delete mark
操作来说,仅仅是在记录上打一个删除标记,并没有真正将它删除掉。
随着系统的运行,在确定系统中包含最早产生的那个ReadView
的事务不会再访问某些update undo日志
以及被打了删除标记的记录后,有一个后台运行的purge线程
会把它们真正的删除掉。
说过的话一定要办到——redo 日志
什么是 redo 日志?
我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1
改成2
我们只需要记录一下:
将第0号表空间的100号页面的偏移量为1000处的值更新为
2
。
系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性
的要求。因为在系统奔溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志
,英文名为redo log
,我们也可以土洋结合,称之为redo日志
。与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的redo
日志刷新到磁盘的好处如下:
-
redo
日志占用的空间非常小存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的
-
redo
日志是顺序写入磁盘的在执行事务的过程中,每执行一条语句,就可能产生若干条
redo
日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。
redo 日志格式
-
type
:该条redo
日志的类型。在
MySQL 5.7.21
这个版本中,设计InnoDB
的大叔一共为redo
日志设计了53种不同的类型,稍后会详细介绍不同类型的redo
日志。 -
space ID
:表空间ID。 -
page number
:页号。 -
data
:该条redo
日志的具体内容。
redo 日志小结
redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来。
以组的形式写入 redo 日志
语句在执行过程中可能修改若干个页面。也就会产生多个 redo 日志,在恢复这些 redo 日志的时候,必须保证这个过程是原子性的,Innodb 的大叔们规定在执行这些需要保证原子性的操作时必须以组
的形式来记录的redo
日志,在进行系统奔溃重启恢复时,针对某个组中的redo
日志,要么把全部的日志都恢复掉,要么一条也不恢复。
如何把这些redo
日志划分到一个组里边儿呢?
设计InnoDB
的大叔做了一个很简单的小把戏,就是在该组中的最后一条redo
日志后边加上一条特殊类型的redo
日志,该类型名称为MLOG_MULTI_REC_END
,这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END
的redo
日志,才认为解析到了一组完整的redo
日志,才会进行恢复。否则的话直接放弃前边解析到的redo
日志。
Mini-Transaction的概念
- 对底层页面中的一次原子访问的过程称之为一个Mini-Transaction
- 修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B 树中插入一条记录的过程也算是一个Mini-Transaction。
- 一个所谓的mtr可以包含一组redo日志,在进行奔溃恢复时这一组redo日志作为一个不可分割的整体。
- 一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志
redo 日志的写入过程
redo log block
设计InnoDB
的大叔为了更好的进行系统奔溃恢复,他们把通过mtr
生成的redo
日志都放在了大小为512字节
的页
中。为了和我们前边提到的表空间中的页做区别,我们这里把用来存储redo
日志的页称为block
(你心里清楚页和block的意思其实差不多就行了)。
redo 日志缓冲区
设计InnoDB
的大叔为了解决磁盘速度过慢的问题而引入了Buffer Pool
。同理,写入redo
日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer
的连续内存空间,翻译成中文就是redo日志缓冲区
,我们也可以简称为log buffer
。这片内存空间被划分成若干个连续的redo log block
,就像这样:
redo日志写入log buffer
向log buffer
中写入redo
日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。
后悔了怎么办——undo 日志
事务回滚的需求
事务
需要保证原子性
,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚
(英文名:rollback
),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性
要求。
每当我们要对一条记录做改动时(这里的改动
可以指INSERT
、DELETE
、UPDATE
),都需要留一手 —— 把回滚时所需的东西都给记下来。
这些为了回滚而记录的这些东东称之为撤销日志,英文名为undo log
,我们也可以土洋结合,称之为undo日志
。
事务id
事务分为只读事务和读写事务。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB
存储引擎就会给它分配一个独一无二的事务id
,分配方式如下:
- 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个
事务id
,否则的话是不分配事务id
的。 - 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个
事务id
,否则的话也是不分配事务id
的。
undo 日志格式
每个表都会被分配一个唯一的table id
,这个后面会用到
CREATE TABLE undo_demo (
id INT NOT NULL,
key1 VARCHAR(100),
col VARCHAR(100),
PRIMARY KEY (id),
KEY idx_key1 (key1)
)Engine=InnoDB CHARSET=utf8;
mysql> SELECT * FROM information_schema.innodb_sys_tables WHERE name = ‘xiaohaizi/undo_demo‘;
---------- --------------------- ------ -------- ------- ------------- ------------ --------------- ------------
| TABLE_ID | NAME | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |
---------- --------------------- ------ -------- ------- ------------- ------------ --------------- ------------
| 138 | xiaohaizi/undo_demo | 33 | 6 | 482 | Barracuda | Dynamic | 0 | Single |
---------- --------------------- ------ -------- ------- ------------- ------------ --------------- ------------
1 row in set (0.01 sec)
INSERT操作对应的undo日志
DELETE 操作对应的 undo 日志
UPDATE 操作对应的 undo 日志
不更新主键值的情况
更新主键值的情况
常考内容:
数据库的事务,事物的特性,事务的隔离级别分别解决了哪些问题,理解什么是脏读,幻读! 事务的实现原理通过什么要保证的事务的特性?
什么是左连接,什么是右连接,什么是全连接,什么是内连接?
数据库的索引有什么作用?用什么来实现的?好处坏处是啥?
索引的种类,原理,索引存了哪些内容,什么时候索引会失效?唯一索引和主键索引的区别!单列和联合索引,最左匹配原则,什么时候该用联合索引?
怎么看这个表是否加了索引?
B树和B 树有什么区别?为什么索引不用B树?那B 树的叶子结点上存了哪些信息?
数据库的锁?乐观锁悲观锁,共享锁和排它锁。
数据库范式
数据库五大约束?
数据库连接池:工作原理,参数,种类,会出现的问题
数据库的读写分离,数据切分(数据库分库分表,水平切分垂直切分啊)
数据库的主从:实现原理,mysql主从复制的方式,如何配置主从同步,主从同步会出现的问题
Mysql:简单的sql语句至少能手写(分组,连接,子查询等)、sql语句的执行过程、、B/B 树相关问题,索引原理、聚集索引/非聚集索引区别、联合索引、explain、sql优化、数据库事务、乐观锁和悲观锁、脏读、虚读和不可重复读、隔离级别、MVCC、表锁/行锁/间隙锁、慢查询日志等。