SQL必知必会
一些SQL知识,出自极客时间陈旸老师《SQL必知必会》
https://time.geekbang.org/column/intro/100029501
基础
视图
视图作为一张虚拟表
,帮我们封装了底层与数据表的接口。它相当于是一张表或多张表的数据结果集。视图的这一特点,可以帮我们简化复杂的 SQL 查询,比如在编写视图后,我们就可以直接重用它,而不需要考虑视图中包含的基础查询的细节。同样,我们也可以根据需要更改数据格式,返回与底层数据表格式不同的数据。
创建:
CREATE VIEW player_above_avg_height AS
SELECT player_id, height
FROM player
WHERE height > (SELECT AVG(height) from player)
修改:
ALTER VIEW view_name AS
SELECT column1, column2
FROM table
WHERE condition
删除:
Drop VIEW view_name
使用视图有很多好处,比如安全、简单清晰。它可以将原本复杂的 SQL 查询简化,在编写好查询之后,我们就可以直接重用它而不必要知道基本的查询细节。
存储过程
存储过程的英文是 Stored Procedure。它的思想很简单,就是SQL语句的封装。一旦存储过程被创建出来,使用它就像使用函数一样简单,我们直接通过调用存储过程名即可。
创建:
CREATE PROCEDURE 存储过程名称 ([参数列表])
BEGIN
需要执行的语句
END
示例:计算1到n的和
CREATE PROCEDURE `add_num`(IN n INT)
BEGIN
DECLARE i INT;
DECLARE sum INT;
SET i = 1;
SET sum = 0;
WHILE i <= n DO
SET sum = sum + i;
SET i = i +1;
END WHILE;
SELECT sum;
END
当我们需要再次使用这个存储过程的时候,直接使用 CALL add_num(50);
即可
示例:
CREATE PROCEDURE `get_hero_scores`(
OUT max_max_hp FLOAT,
OUT min_max_mp FLOAT,
OUT avg_max_attack FLOAT,
s VARCHAR(255)
)
BEGIN
SELECT MAX(hp_max), MIN(mp_max), AVG(attack_max) FROM heros WHERE role_main = s INTO max_max_hp, min_max_mp, avg_max_attack;
END
使用:
CALL get_hero_scores(@max_max_hp, @min_max_mp, @avg_max_attack, '战士');
SELECT @max_max_hp, @min_max_mp, @avg_max_attack;
除此之外还有一些其他的控制语句。
优点: 存储过程可以一次编译多次使用。存储过程只在创造时进行编译,之后的使用都不需要重新编译,这就提升了 SQL 的执行效率。其次它可以减少开发工作量。将代码封装成模块,实际上是编程的核心思想之一,这样可以把复杂的问题拆解成不同的模块,然后模块之间可以重复使用,在减少开发工作量的同时,还能保证代码的结构清晰。还有一点,存储过程的安全性强,我们在设定存储过程的时候可以设置对用户的使用权限,这样就和视图一样具有较强的安全性。最后它可以减少网络传输量,因为代码封装到存储过程中,每次使用只需要调用存储过程即可,这样就减少了网络传输量。同时在进行相对复杂的数据库操作时,原本需要使用一条一条的 SQL 语句,可能要连接多次数据库才能完成的操作,现在变成了一次存储过程,只需要连接一次即可。
缺点: 存储过程可移植性差,不能跨数据库移植,比如在 MySQL、Oracle 和 SQL Server 里编写的存储过程,在换成其他数据库时都需要重新编写。其次调试困难,只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说,开发和维护都不容易。此外,存储过程的版本管理也很困难,比如数据表索引发生变化了,可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理,但是存储过程本身没有版本控制,版本迭代更新的时候很麻烦。最后它不适合高并发的场景,高并发的场景需要减少数据库的压力,有时数据库会采用分库分表的方式,而且对可扩展性要求很高,在这种情况下,存储过程会变得难以维护,增加数据库的压力,显然就不适用了。
事务
事务的特性:要么完全执行,要么都不执行。不过要对事务进行更深一步的理解,还要从事务的 4 个特性说起,这 4 个特性用英文字母来表达就是 ACID。
- A,也就是原子性(Atomicity)。原子的概念就是不可分割,是我们进行数据处理操作的基本单位。
- C,就是一致性(Consistency)。一致性指的就是数据库在进行事务操作后,会由原来的一致状态,变成另一种一致的状态。也就是说当事务提交后,或者当事务发生回滚后,数据库的完整性约束不能被破坏。
- I,就是隔离性(Isolation)。它指的是每个事务都是彼此独立的,不会受到其他事务的执行影响。也就是说一个事务在提交之前,对其他事务都是不可见的。
- 最后一个 D,指的是持久性(Durability)。事务提交之后对数据的修改是持久性的,即使在系统出故障的情况下,比如系统崩溃或者存储介质发生故障,数据的修改依然是有效的。因为当事务完成,数据库的日志就会被更新,这时可以通过日志,让系统恢复到最后一次成功的更新状态。
使用SHOW VARIABLES LIKE 'transaction_isolation';
查询数据库的隔离级别,Mysql默认是可重复读。
不可重复读与幻读的主要区别:
不可重复读侧重于我第一次查询的结果和第二次查询的结果发生了变化,比如我先查询id=1的记录,在下次查询时发现id=1的记录字段发生了变化或者不存在了,发生了UPDATE
或DELETE
操作。
幻读是查询某一个范围的数据行变多了或者少了,重点在于INSERT
操作
举例:我在事务中首先查询某条记录,发现不存在,我进行了插入操作,但我在插入操作时报错说记录已存在,属于幻读
。原因是当你INSERT
的时候,也需要隐式的读取,比如插入数据时需要读取有没有主键冲突,然后再决定是否能执行插入。如果这时发现已经有这个记录了,就没法插入。
SELECT
显示不存在,但是INSERT
的时候发现已存在,说明符合条件的数据行发生了变化,也就是幻读的情况,而不可重复读指的是同一条记录的内容被修改了。
游标
在数据库中,游标提供了一种灵活的操作方式,可以让我们从数据结果集中每次提取一条数据记录进行操作
。游标让 SQL 这种面向集合的语言有了面向过程开发的能力。可以说,游标是面向过程的编程方式,这与面向集合的编程方式有所不同。
比如有个这样的需求:
如果这个英雄原有的物攻成长小于 5,那么将在原有基础上提升 7%-10%。如果物攻成长的提升空间(即最高物攻 attack_max- 初始物攻 attack_start)大于 200,那么在原有的基础上提升 10%;如果物攻成长的提升空间在 150 到 200 之间,则提升 8%;如果物攻成长的提升空间不足 150,则提升 7%。如果原有英雄的物攻成长在 5—10 之间,那么将在原有基础上提升 5%。如果原有英雄的物攻成长大于 10,则保持不变。
注意:在实际使用中,最好先备份之前的表,以进行操作后的比对。
CREATE PROCEDURE `alter_attack_growth`()
BEGIN
-- 创建接收游标的变量
DECLARE temp_id INT;
DECLARE temp_growth, temp_max, temp_start, temp_diff FLOAT;
-- 创建结束标志变量
DECLARE done INT DEFAULT false;
-- 定义游标
DECLARE cur_hero CURSOR FOR SELECT id, attack_growth, attack_max, attack_start FROM heros;
-- 指定游标循环结束时的返回值
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;
OPEN cur_hero;
FETCH cur_hero INTO temp_id, temp_growth, temp_max, temp_start;
REPEAT
IF NOT done THEN
SET temp_diff = temp_max - temp_start;
IF temp_growth < 5 THEN
IF temp_diff > 200 THEN
SET temp_growth = temp_growth * 1.1;
ELSEIF temp_diff >= 150 AND temp_diff <=200 THEN
SET temp_growth = temp_growth * 1.08;
ELSEIF temp_diff < 150 THEN
SET temp_growth = temp_growth * 1.07;
END IF;
ELSEIF temp_growth >=5 AND temp_growth <=10 THEN
SET temp_growth = temp_growth * 1.05;
END IF;
UPDATE heros SET attack_growth = ROUND(temp_growth,3) WHERE id = temp_id;
END IF;
FETCH cur_hero INTO temp_id, temp_growth, temp_max, temp_start;
UNTIL done = true END REPEAT;
CLOSE cur_hero;
DEALLOCATE PREPARE cur_hero;
END
(个人理解:如果在业务层面做这个操作,首先查询出所有记录的相关字段,在某个函数里挨个进行判断,执行对应的update操作。此例子中游标作用类似,只是把判断放在了存储过程中做。)
在处理某些复杂的数据情况下,使用游标可以更灵活,但同时也会带来一些性能问题,比如在使用游标的过程中,会对数据行进行加锁,这样在业务并发量大的时候,不仅会影响业务之间的效率,还会消耗系统资源,造成内存不足,这是因为游标是在内存中进行的处理。如果有游标的替代方案,我们可以采用替代方案。
基础篇答疑
问:列式数据库是将数据按照列存储到数据库中,这样做的好处是可以大量降低系统的 I/O
答:列式存储是把一列的数据都串起来进行存储,然后再存储下一列。这样做的话,相邻数据的数据类型都是一样的,更容易压缩,压缩之后就自然降低了 I/O。我们还需要从数据处理的需求出发,去理解行式存储和列式存储。数据处理可以分为 OLTP(联机事务处理)和 OLAP(联机分析处理)两大类。OLTP 一般用于处理客户的事务和进行查询,需要随时对数据表中的记录进行增删改查,对实时性要求高。OLAP 一般用于市场的数据分析,通常数据量大,需要进行复杂的分析操作,可以对大量历史数据进行汇总和分析,对实时性要求不高。那么对于 OLTP 来说,由于随时需要对数据记录进行增删改查,更适合采用行式存储,因为一行数据的写入会同时修改多个列。传统的 RDBMS 都属于行式存储,比如 Oracle、SQL Server 和 MySQL 等。对于 OLAP 来说,由于需要对大量历史数据进行汇总和分析,则适合采用列式存储,这样的话汇总数据会非常快,但是对于插入(INSERT)和更新(UPDATE)会比较麻烦,相比于行式存储性能会差不少。所以说列式存储适合大批量数据查询,可以降低 I/O,但如果对实时性要求高,则更适合行式存储。
问:在 MySQL 中统计数据表的行数,可以使用三种方式:SELECT COUNT(*)
、SELECT COUNT(1)
和SELECT COUNT(具体字段)
,使用这三者之间的查询效率是怎样的?
答:在 MySQL InnoDB 存储引擎中,COUNT(*)
和COUNT(1)
都是对所有结果进行COUNT
,因此COUNT(*)
和COUNT(1)
本质上并没有区别,执行的复杂度都是O(N)
,也就是采用全表扫描,进行循环 + 计数的方式进行统计。如果是 MySQL MyISAM 存储引擎,统计数据表的行数只需要O(1)
的复杂度,这是因为每张 MyISAM 的数据表都有一个 meta 信息存储了row_count
值,而一致性则由表级锁来保证。因为 InnoDB 支持事务,采用行级锁和 MVCC 机制,所以无法像 MyISAM 一样,只维护一个row_count
变量,因此需要采用扫描全表,进行循环 + 计数的方式来完成统计。另外在 InnoDB 引擎中,如果采用COUNT(*)
和COUNT(1)
来统计数据行数,要尽量采用二级索引。因为主键采用的索引是聚簇索引,聚簇索引包含的信息多,明显会大于二级索引(非聚簇索引)。对于COUNT(*)
和COUNT(1)
来说,它们不需要查找具体的行,只是统计行数,系统会自动采用占用空间更小的二级索引来进行统计。
问:可以理解在 WHERE 条件字段上加索引,但是为什么在 ORDER BY 字段上还要加索引呢?
答:在 MySQL 中,支持两种排序方式,分别是 FileSort 和 Index 排序。在 Index 排序中,索引可以保证数据的有序性,不需要再进行排序,效率更高。而 FileSort 排序则一般在内存中进行排序,占用 CPU 较多。如果待排结果较大,会产生临时文件 I/O 到磁盘进行排序的情况,效率较低。
问:关于 SELECT 语句内部的执行步骤
答:From子句组装(包括join连接)->Where条件筛选->Group By分组->聚集函数计算->Having对分组进行筛选->计算表达式的值->Select的字段->Order By排序->Limit筛选
问:不太理解哪种情况下应该使用 EXISTS,哪种情况应该用 IN
SELECT * FROM A WHERE cc IN (SELECT cc FROM B)
SELECT * FROM A WHERE EXISTS (SELECT cc FROM B WHERE B.cc = A.cc)
答:当 A 小于 B 时,用 EXISTS。因为 EXISTS 的实现,相当于外表循环,实现的逻辑类似于:
for i in A
for j in B
if j.cc == i.cc then ...
当 B 小于 A 时用 IN,因为实现的逻辑类似于:
for i in B
for j in A
if j.cc == i.cc then ...
哪个表小就用哪个表来驱动,A 表小就用 EXISTS,B 表小就用 IN。
优化
简单来说,数据库优化的目的就是让响应的时间更快,吞吐量更大。
可以选择的维度
- 选择合适的DBMS
- 优化表的设计
- 优化逻辑查询
- 优化物理查询
- 使用缓存
- 库级优化:分库分表,读写分离
SQL 查询优化,可以分为逻辑查询优化和物理查询优化。
逻辑查询优化就是通过改变 SQL 语句的内容让 SQL 执行效率更高效,采用的方式是对 SQL 语句进行等价变换,对查询进行重写。SQL 的查询重写包括了子查询优化、等价谓词重写、视图重写、条件简化、连接消除和嵌套连接消除等。
物理查询优化是将逻辑查询的内容变成可以被执行的物理操作符,从而为后续执行器的执行提供准备。它的核心是高效地建立索引,并通过这些索引来做各种优化。
从功能逻辑上说,索引主要有 4 种,分别是普通索引、唯一索引、主键索引和全文索引。
普通索引是基础的索引,没有任何约束,主要用于提高查询效率。唯一索引就是在普通索引的基础上增加了数据唯一性的约束,在一张数据表里可以有多个唯一索引。主键索引在唯一索引的基础上增加了不为空的约束,也就是 NOT NULL+UNIQUE,一张表里最多只有一个主键索引。全文索引用的不多,MySQL 自带的全文索引只支持英文。我们通常可以采用专门的全文搜索引擎,比如 ES(ElasticSearch) 和 Solr。
聚集索引指表中数据行按索引的排序方式进行存储,对查找行很有效。只有当表包含聚集索引时,表内的数据行才会按找索引列的值在磁盘上进行物理排序和存储。每一个表只能有一个聚集索引,因为数据行本身只能按一个顺序存储。
聚集索引:如果是一本汉语字典,我们想要查找“数”这个字,直接在书中找汉语拼音的位置即可,也就是拼音“shu”。这样找到了索引的位置,在它后面就是我们想要找的数据行。非聚集索引:我们还以汉语字典为例,如果想要查找“数”字,那么按照部首查找的方式,先找到“数”字的偏旁部首,然后这个目录会告诉我们“数”字存放到第多少页,我们再去指定的页码找这个字。
聚集索引与非聚集索引的原理不同,在使用上也有一些区别:
- 聚集索引的叶子节点存储的就是我们的数据记录,非聚集索引的叶子节点存储的是数据位置。非聚集索引不会影响数据表的物理存储顺序。
- 一个表只能有一个聚集索引,因为只能有一种排序存储的方式,但可以有多个非聚集索引,也就是多个索引目录提供数据检索。
- 使用聚集索引的时候,数据的查询效率高,但如果对数据进行插入,删除,更新等操作,效率会比非聚集索引低。
使用索引可以帮助我们从海量的数据中快速定位想要查找的数据,不过索引也存在一些不足,比如占用存储空间、降低数据库写操作的性能等,如果有多个索引还会增加索引选择的时间。当我们使用索引时,需要平衡索引的利(提升查询效率)和弊(维护索引所需的代价)。
为什么我们常用 B+ 树作为索引的数据结构
由于索引需要持久化到硬盘上,因此根据索引查找数据时的IO操作次数几乎决定了查询消耗时间。当磁盘 I/O 次数越多,所消耗的时间也就越大。
二叉树的局限性
对上述二叉树进行搜索时,最多进行3次磁盘IO就可以找到节点,世界复杂度(Olog2 n)
但是存在特殊的情况,就是有时候二叉树的深度非常大
这时二叉树性能上已经退化成了一条链表,查找数据的时间复杂度变成了 O(n)
平衡二叉搜索树(AVL 树)在二分搜索树的基础上增加了约束,每个节点的左子树和右子树的高度差不能超过 1,也就是说节点的左子树和右子树仍然为平衡二叉树。同时树的深度也 O(log2n),当 n 比较大时,深度也是比较高的,这就意味着磁盘 I/O 操作次数多,会影响整体数据查询的效率。
B树
如果用二叉树作为索引的实现结构,会让树变得很高,增加硬盘的 I/O 次数,影响数据查询的时间。因此一个节点就不能只有 2 个子节点,而应该允许有 M 个子节点 (M>2)。
B 树的出现就是为了解决这个问题,B 树的英文是 Balance Tree,也就是平衡的多路搜索树,它的高度远小于平衡二叉树的高度。在文件系统和数据库系统中的索引结构经常采用 B 树来实现。
这是一个3阶的B树,每个节点3个指针p,2个关键字key,其中 P[1] 指向关键字小于 Key[1] 的子树,P[i] 指向关键字属于 (Key[i-1], Key[i]) 的子树,P[k] 指向关键字大于 Key[k-1] 的子树。
假设我们想要查找的关键字是 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 操作要少,在数据查询中比平衡二叉树效率要高。
B+树
B+ 树和 B 树的差异在于以下几点:
- 有 k 个孩子的节点就有 k 个关键字。也就是孩子数量 = 关键字数,而 B 树中,孩子数量 = 关键字数 +1。
- 非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。
- 非叶子节点仅用于索引,不保存数据记录,跟记录有关的信息都放在叶子节点中。而 B 树中,非叶子节点既保存索引,也保存数据记录。
- 所有关键字都在叶子节点出现,叶子节点构成一个有序链表,而且叶子节点本身按照关键字的大小从小到大顺序链接。
比如,我们想要查找关键字 16,B+ 树会自顶向下逐层进行查找:
- 与根节点的关键字 (1,18,35) 进行比较,16 在 1 和 18 之间,得到指针 P1(指向磁盘块 2)
- 找到磁盘块 2,关键字为(1,8,14),因为 16 大于 14,所以得到指针 P3(指向磁盘块 7)
- 找到磁盘块 7,关键字为(14,16,17),然后我们找到了关键字 16,所以可以找到关键字 16 所对应的数据。
整个过程一共进行了 3 次 I/O 操作,看起来 B+ 树和 B 树的查询过程差不多,但是 B+ 树和 B 树有个根本的差异在于,B+ 树的中间节点并不直接存储数据。这样的好处都有什么呢?
- B+树的查询会更加稳定,因为B+树只有访问到叶子节点才会找到对应的数据,而在 B 树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况,有时候访问到了非叶子节点就可以找到关键字,而有时需要访问到叶子节点才能找到关键字。
- B+树的查询效率会更高,由于中间节点并不直接存取数据,因此相同的磁盘页存到索引数据会更多,一般阶数更大,深度更低,更矮胖,查询所需要的IO次数也更少。另外所有关键字都出现在 B+ 树的叶子节点中,并通过有序链表进行了链接,查询范围上效率也更高。
Hash索引对比B+树
键值 key 通过 Hash 映射找到桶 bucket。在这里桶(bucket)指的是一个能存储一条或多条记录的存储单位。一个桶的结构包含了一个内存指针数组,桶中的每行数据都会指向下一行,形成链表结构,当遇到 Hash 冲突时,会在桶中进行键值的查找。
- Hash 索引不能进行范围查询,而 B+ 树可以。这是因为 Hash 索引指向的数据是无序的,而 B+ 树的叶子节点是个有序的链表。
- Hash 索引不支持联合索引的最左侧原则(即联合索引的部分索引无法使用),而 B+ 树可以。对于联合索引来说,Hash 索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值,所以不会针对每个索引单独计算 Hash 值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。
- Hash 索引不支持 ORDER BY 排序,因为 Hash 索引指向的数据是无序的,因此无法起到排序优化的作用,而 B+ 树索引数据是有序的,可以起到对该字段 ORDER BY 排序优化的作用。同理,我们也无法用 Hash 索引进行模糊查询,而 B+ 树使用 LIKE 进行模糊查询的时候,LIKE 后面前模糊查询(比如 % 开头)的话就可以起到优化作用。
对于等值查询来说,通常 Hash 索引的效率更高,不过也存在一种情况,就是索引列的重复值如果很多,效率就会降低。
创建索引的原则
1. 字段的数值有唯一性的限制,比如用户名
2. 频繁作为 WHERE 查询条件的字段,尤其在数据表大的情况下
3. 需要经常 GROUP BY 和 ORDER BY 的列
多个单列索引在多条件查询时只会生效一个索引(MySQL 会选择其中一个限制最严格的作为索引),所以在多条件联合查询的时候最好创建联合索引。优先选择(group by,order by),因为在进行 SELECT 查询的时候,对数据先进行 GROUP BY,再进行 ORDER BY 的操作。
4.UPDATE、DELETE 的 WHERE 条件列,一般也需要创建索引
5.DISTINCT 字段需要创建索引
6.多表联合查询时(表尽量不要超过三张),WHERE条件列、连接列(类型相同)创建索引
什么情况下不需要索引
1.WHERE 条件(包括 GROUP BY、ORDER BY)里用不到的字段
2.表记录太少
3.重复率高
什么情况下索引失效
1.对索引进行了表达式计算
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id+1 = 900001
重写
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900000
2.对索引使用函数
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'
重写
SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_text LIKE 'abc%'
3.在 WHERE 子句中,如果在 OR 前的条件列进行了索引,而在 OR 后的条件列没有进行索引,那么索引会失效
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900001 OR comment_text = '462eed7ac6e791292a79'
因为 OR 的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描,因此索引的条件列也会失效
4.使用 LIKE 进行模糊查询的时候,后面不能是 %
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_text LIKE '%abc'
5. 索引列与 NULL 或者 NOT NULL 进行判断的时候也会失效
因为索引并不存储空值,所以最好在设计数据表的时候就将字段设置为 NOT NULL 约束,比如你可以将 INT 类型的字段,默认值设置为 0。将字符类型的默认值设置为空字符串 (’’)
6. 使用联合索引的时候要注意最左原则
提升
数据页
在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page)。
页(Page)如果按类型划分的话,常见的有数据页(保存 B+ 树节点)、系统页、Undo 页和事务数据页等。数据页是我们最常使用的页。
查看表空间类型:
show variables like 'innodb_file_per_table';
查询结果为on意味着每张表都会单独保存为一个.ibd 文件,也就是数据和索引信息都会保存在自己的表空间中。独立的表空间可以在不同的数据库之间进行迁移。
按照作用来讲,我们一般把这7项分为三部分:
- 文件通用部分,也就是文件头和文件尾。它们类似集装箱,将页的内容进行封装,通过文件头和文件尾校验的方式来确保页的传输是完整的。文件头中有两个指针分别指向上一个数据页和下一个数据页,连接起来形成双向链表。
- 记录部分,页的主要作用是存储记录,所以“最小和最大记录”和“用户记录”部分占了页结构的主要空间。另外空闲空间是个灵活的部分,当有新的记录插入时,会从空闲空间中进行分配用于存储新记录。
- 索引部分,这部分重点指的是页目录,因为在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索,因此在页目录中提供了二分查找的方式,用来提高记录的检索效率。
从数据页的角度看B+树是如何进行查询的
如果通过 B+ 树的索引查询行记录,首先是从 B+ 树的根开始,逐层检索,直到找到叶子节点,也就是找到对应的数据页为止,将数据页加载到内存中,页目录中的槽(slot)采用二分查找的方式先找到一个粗略的记录分组,然后再在分组中通过链表遍历的方式查找记录。
普通索引和唯一索引为什么差别很小
由于数据库的读取操作是基于页的,唯一索引是找到了这条记录就停止,而普通索引也就是在内存中多几次“判断下一条记录”的操作,因此二者在检索效率上几乎没有差别。
缓冲池与磁盘I/O
数据库缓冲池
磁盘 I/O 需要消耗的时间很多,而在内存中进行操作,效率则会高很多,为了能让数据表或者索引中的数据随时被我们所用,DBMS 会申请占用内存来作为数据缓冲池,这样做的好处是可以让磁盘活动最小化,从而减少与磁盘直接进行 I/O 的时间。要知道,如果索引的数据在缓冲池里,那么访问的成本就会降低很多。
当我们对数据库中的记录进行修改的时候,首先会修改缓冲池中页里面的记录信息,然后数据库会以一定的频率刷新到磁盘上。注意并不是每次发生更新操作,都会立刻进行磁盘回写。缓冲池会采用一种叫做 checkpoint
的机制将数据回写到磁盘上,这样做的好处就是提升了数据库的整体性能。
在MyISAM引擎中,缓冲池只存放索引,不存放数据
# 查看缓冲池大小
show variables like 'innodb_buffer_pool_size'
# 查看缓冲池数量
show variables like 'innodb_buffer_pool_instances'
数据页加载的方式
- 内存读取:如果该数据存在于内存中,基本上执行时间在 1ms 左右,效率还是很高的。
- 随机读取:整体时间预估在 10ms 左右,其中包括6ms(寻道和半圈旋转时间),3ms(可能的排队时间),1ms(数据从磁盘传输到数据库缓冲区)
- 顺序读取:其实是一种批量读取的方式,因为我们请求的数据在磁盘上往往都是相邻存储的,顺序读取可以帮我们批量读取页面,这样相邻的其他页面就不需要再进行IO了,某些情况下效率其实比从内存中只单独读取一个页的效率要高。
last_query_cost
如果我们想要查看某条 SQL 语句的查询成本,可以在执行完这条 SQL 语句之后,通过查看当前会话中的 last_query_cost 变量值来得到当前查询的成本。这个查询成本对应的是 SQL 语句所需要读取的页的数量。
SHOW STATUS LIKE 'last_query_cost';
锁
按照锁的力度度来分,可以分为行锁、页锁和表锁。
- 行锁,发生冲突的概率低,并发度高,但是对于锁的开销比较大,加锁会比较慢,易死锁。
- 页锁,开销介于表锁和行锁之间,会出现死锁,并发度一般
- 表锁,对数据表进行锁定,锁定粒度很大,冲突概率高,并发度低,优点是对锁的使用开销小,加锁会很快。
每一层的锁是有数量限制的,当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。用更大粒度的锁来替代多个更小粒度的锁。
按照数据库管理的角度对锁进行划分,分为共享锁和排它锁。
- 共享锁也叫读锁或 S 锁,共享锁锁定的资源可以被其他用户读取,但不能修改。在进行
SELECT
的时候,会将对象进行共享锁锁定,当数据读取完毕之后,就会释放共享锁,这样就可以保证数据在读取时不被修改。
# 给product_comment表加共享锁
LOCK TABLE product_comment READ;
# 给user_id = 912178的行加共享锁
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 LOCK IN SHARE MODE
- 排它锁也叫独占锁、写锁或 X 锁。排它锁锁定的数据只允许进行锁定操作的事务使用,其他事务无法对已锁定的数据进行查询或修改。
# 给product_comment表加排他锁
LOCK TABLE product_comment WRITE;
# 给user_id = 912178的行加排他锁
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 FOR UPDATE;
当我们对数据进行更新的时候,也就是INSERT
、DELETE
或者UPDATE
的时候,数据库也会自动使用排它锁,防止其他事务对该数据行进行操作。
当我们想要获取某个数据表的排它锁的时候,需要先看下这张数据表有没有上了排它锁。如果这个数据表中的某个数据行被上了行锁,我们就无法获取排它锁。这时需要对数据表中的行逐一排查,检查是否有行锁,如果没有,才可以获取这张数据表的排它锁。这个过程是不是有些麻烦?这里就需要用到意向锁。
意向锁(Intent Lock),简单来说就是给更大一级别的空间示意里面是否已经上过锁。如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
从程序员角度划分,可以分为乐观锁和悲观锁。
- 乐观锁(Optimistic Locking)认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现。(类似于svn中的版本控制概念)
- 悲观锁(Pessimistic Locking)对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性
乐观锁和悲观锁的适用场景:
- 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
- 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。
死锁的四个必要条件:
- 资源互斥:资源只能被一个用户所独占
- 请求保持:每个用户在想请求的互斥资源得不到时会陷入阻塞,并保持已有资源不释放
- 不可剥夺:每个用户所拥有的资源只能等操作完成后主动释放,不可强行剥夺
- 循环等待:死锁中的每个用户都拥有其他用户请求的的资源,同时在等待其他用户释放自己想要的资源
MVCC
MVCC 的英文全称是 Multiversion Concurrency Control,中文翻译过来就是多版本并发控制技术。从名字中也能看出来,MVCC 是通过数据行的多个版本管理来实现数据库的并发控制,简单来说它的思想就是保存数据的历史版本。这样我们就可以通过比较版本号决定数据是否显示出来,读取数据的时候不需要加锁也可以保证事务的隔离效果。
Undo Log
每开启一个事务,我们都会从数据库中获得一个事务 ID(也就是事务版本号),这个事务 ID 是自增长的,通过 ID 大小,我们就可以判断事务的时间顺序。
InnoDB的叶子段存储了数据页,而页中存储了行记录,而在行记录中有一些重要的隐藏字段:
- db_row_id:隐藏的行 ID,用来生成默认聚集索引。如果我们创建数据表的时候没有指定聚集索引,这时 InnoDB 就会用这个隐藏 ID 来创建聚集索引。采用聚集索引的方式可以提升数据的查找效率。
- db_trx_id:操作这个数据的事务 ID,也就是最后一个对该数据进行插入或更新的事务 ID。
- db_row_ptr:回滚指针,也就是指向这个记录的 Undo Log 信息
回滚指针将数据行的所有快照记录都通过链表的结构串联了起来,每个快照的记录都保存了当时的 db_trx_id,也是那个时间点操作这个数据的事务 ID。这样如果我们想要找历史快照,就可以通过遍历回滚指针的方式进行查找。
Read View
在 MVCC 机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在 Undo Log 里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到 Read View 了,它帮我们解决了行的可见性问题。Read View 保存了当前事务开启时所有活跃(还没有提交)的事务列表,换个角度你可以理解为 Read View 保存了不应该让这个事务看到的其他的事务 ID 列表。
在 Read VIew 中有几个重要的属性:
- trx_ids,系统当前正在活跃的事务 ID 集合。
- low_limit_id,活跃的事务中最大的事务 ID。
- up_limit_id,活跃的事务中最小的事务 ID。
- creator_trx_id,创建这个 Read View 的事务 ID。
假设当前有事务 creator_trx_id 想要读取某个行记录,这个行记录的事务 ID 为 trx_id,那么会出现以下几种情况。
- 如果 trx_id < 活跃的最小事务 ID(up_limit_id),也就是说这个行记录在这些活跃的事务创建之前就已经提交了,那么这个行记录对该事务是可见的。
- 如果 trx_id > 活跃的最大事务 ID(low_limit_id),这说明该行记录在这些活跃的事务创建之后才创建,那么这个行记录对当前事务不可见。
- 如果 up_limit_id < trx_id < low_limit_id,说明该行记录所在的事务 trx_id 在目前 creator_trx_id 这个事务创建的时候,可能还处于活跃的状态,因此我们需要在 trx_ids 集合中进行遍历,如果 trx_id 存在于 trx_ids 集合中,证明这个事务 trx_id 还处于活跃状态,不可见。否则,如果 trx_id 不存在于 trx_ids 集合中,证明事务 trx_id 已经提交了,该行记录可见。
了解了这些概念之后,当查询一条记录的时候,系统是如何通过多版本并发控制技术找到它的呢:
- 首先获取事务自己的版本号,也就是事务 ID;
- 获取 Read View;
- 查询得到的数据,然后与 Read View 中的事务版本号进行比较;
- 如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;
- 最后返回符合规则的数据。
在读已提交的隔离级别下,同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。
当隔离级别为可重复读的时候,可以避免不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View。
在隔离级别为可重复读时,InnoDB 会采用 Next-Key 锁的机制,帮我们解决幻读
问题。(Next-Key 是行锁中的一种。记录锁:锁定记录行本身;间隙锁:锁住一个范围(索引之间的空隙),但不包括记录本身,可以用来防止幻读;Next-Key 锁:锁住一个范围,同时锁定记录本身,相当于记录锁+间隙锁)
在读已提交的情况下,即使采用了 MVCC 方式也会出现幻读
。原因是这个隔离级别下InnoDB采用的是记录锁,只锁定这些记录,其他事务依然可以新增数据。
MVCC 的核心就是 Undo Log+ Read View,“MV”就是通过 Undo Log 来保存数据的历史版本,实现多版本的管理,“CC”是通过 Read View 来实现管理,通过 Read View 原则来决定数据是否显示。同时针对不同的隔离级别,Read View 的生成策略不同,也就实现了不同的隔离级别。
MVCC 是一种机制,MySQL、Oracle、SQL Server 和 PostgreSQL 的实现方式均有不同,我们在学习的时候,更主要的是要理解 MVCC 的设计思想。