SQL——性能优化篇(中)

目录

索引的使用原则

创建索引有哪些规律?

创建索引有一定的规律。当这些规律出现的时候,我们就可以通过创建索引提升查询效率,下面我们来看看什么情况下可以创建索引:

字段的数值有唯一性的限制,比如用户名

索引本身可以起到约束的作用,比如唯一索引、主键索引都是可以起到唯一性约束的,因此在我们的数据表中,如果某个字段是唯一性的,就可以直接创建唯一性索引,或者主键索引。

频繁作为 WHERE 查询条件的字段,尤其在数据表大的情况下

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

之前列举了 product_comment 数据表,这张数据表中一共有 100 万条数据,假设我们想要查询 user_id=785110 的用户对商品的评论。

如果我们没有对 user_id 字段创建索引,进行如下查询:

SELECT comment_id, product_id, comment_text, comment_time, user_id FROM product_comment WHERE user_id = 785110

运行结果:

运行时间为 0.699s,你能看到查询效率还是比较低的。当我们对 user_id 字段创建索引之后,运行时间为 0.047s,不到原来查询时间的 1/10,效率提升还是明显的。

需要经常 GROUP BY 和 ORDER BY 的列

索引就是让数据按照某种顺序进行存储或检索,因此当我们使用 GROUP BY 对数据进行分组查询,或者使用 ORDER BY 对数据进行排序的时候,就需要对分组或者排序的字段进行索引。

比如我们按照 user_id 对商品评论数据进行分组,显示不同的 user_id 和商品评论的数量,显示 100 个即可。

如果我们不对 user_id 创建索引,执行下面的 SQL 语句:

SELECT user_id, count(*) as num FROM product_comment group by user_id limit 100

运行结果(100 条记录,运行时间 1.666s):

如果我们对 user_id 创建索引,再执行 SQL 语句:

SELECT user_id, count(*) as num FROM product_comment group by user_id limit 100

运行结果(100 条记录,运行时间 0.042s):

你能看到当对 user_id 创建索引后,得到的结果中 user_id 字段的数值也是按照顺序展示的,运行时间却不到原来时间的 1/40,效率提升很明显。

同样,如果是 ORDER BY,也需要对字段创建索引。我们再来看下同时有 GROUP BY 和 ORDER BY 的情况。

比如我们按照 user_id 进行评论分组,同时按照评论时间降序的方式进行排序,这时我们就需要同时进行 GROUP BY 和 ORDER BY,那么是不是需要单独创建 user_id 的索引和 comment_time 的索引呢?

当我们对 user_id 和 comment_time 分别创建索引,执行下面的 SQL 查询:

SELECT user_id, count(*) as num FROM product_comment group by user_id order by comment_time desc limit 100

运行结果(运行时间 >100s):

实际上多个单列索引在多条件查询时只会生效一个索引(MySQL 会选择其中一个限制最严格的作为索引),所以在多条件联合查询的时候最好创建联合索引。在这个例子中,我们创建联合索引 (user_id, comment_time),再来看下查询的时间,查询时间为 0.775s,效率提升了很多。

如果我们创建联合索引的顺序为 (comment_time, user_id) 呢?

运行时间为 1.990s,同样比两个单列索引要快,但是会比顺序为 (user_id, comment_time) 的索引要慢一些。这是因为在进行 SELECT 查询的时候,先进行 GROUP BY,再对数据进行 ORDER BY 的操作,所以按照这个联合索引的顺序效率是最高的。

UPDATE、DELETE 的 WHERE 条件列,一般也需要创建索引

我们刚才说的是数据检索的情况。那么当我们对某条数据进行 UPDATE 或者 DELETE 操作的时候,是否也需要对 WHERE 的条件列创建索引呢?

我们先看一下对数据进行 UPDATE 的情况。

如果我们想要把 comment_text 为 462eed7ac6e791292a79 对应的 product_id 修改为 10002,当我们没有对 comment_text 进行索引的时候,执行 SQL 语句:

UPDATE product_comment SET product_id = 10002 WHERE comment_text = '462eed7ac6e791292a79'

运行结果为 Affected rows: 1,运行时间为 1.173s。

你能看到效率不高,但如果我们对 comment_text 字段创建了索引,然后再把刚才那条记录更新回 product_id=10001,执行 SQL 语句:

UPDATE product_comment SET product_id = 10001 WHERE comment_text = '462eed7ac6e791292a79'

运行结果为 Affected rows: 1,运行时间仅为 0.1110s。你能看到这个运行时间是之前的 1/10,效率有了大幅的提升。

如果我们对某条数据进行 DELETE,效率如何呢?

比如我们想删除 comment_text 为 462eed7ac6e791292a79 的数据。当我们没有对 comment_text 字段进行索引的时候,执行 SQL 语句:

DELETE FROM product_comment WHERE comment_text = '462eed7ac6e791292a79'

运行结果为 Affected rows: 1,运行时间为 1.027s,效率不高。

如果我们对 comment_text 创建了索引,再来执行这条 SQL 语句,运行时间为 0.032s,时间是原来的 1/32,效率有了大幅的提升。

你能看到,对数据按照某个条件进行查询后再进行 UPDATE 或 DELETE 的操作,如果对 WHERE 字段创建了索引,就能大幅提升效率。

原理是因为我们需要先根据 WHERE 条件列检索出来这条记录,然后再对它进行更新或删除。

如果进行更新的时候,更新的字段是非索引字段,提升的效率会更明显,这是因为非索引字段更新不需要对索引进行维护。

不过在实际工作中,我们也需要注意平衡,如果索引太多了,在更新数据的时候,如果涉及到索引更新,就会造成负担。

DISTINCT 字段需要创建索引

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

比如我们想要查询商品评论表中不同的 user_id 都有哪些,如果我们没有对 user_id 创建索引,执行 SQL 语句,看看情况是怎样的。

SELECT DISTINCT(user_id) FROM `product_comment`

运行结果(600637 条记录,运行时间 2.283s):

如果我们对 user_id 创建索引,再执行 SQL 语句,看看情况又是怎样的。

SELECT DISTINCT(user_id) FROM `product_comment`

运行结果(600637 条记录,运行时间 0.627s):

你能看到 SQL 查询效率有了提升,同时显示出来的 user_id 还是按照递增的顺序进行展示的。这是因为索引会对数据按照某种顺序进行排序,所以在去重的时候也会快很多。

做多表 JOIN 连接操作时,创建索引需要注意以下的原则

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

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

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

举个例子,如果我们只对 user_id 创建索引,执行 SQL 语句:

SELECT comment_id, comment_text, product_comment.user_id, user_name FROM product_comment JOIN user ON product_comment.user_id = user.user_id
WHERE comment_text = '462eed7ac6e791292a79'

运行结果(1 条数据,运行时间 0.810s):

这里我们对 comment_text 创建索引,再执行上面的 SQL 语句,运行时间为 0.046s。

如果我们不使用 WHERE 条件查询,而是直接采用 JOIN…ON…进行连接的话,即使使用了各种优化手段,总的运行时间也会很长(>100s)。

什么时候不需要创建索引

我之前讲到过索引不是万能的,有一些情况是不需要创建索引的,这里再进行一下说明。

WHERE 条件(包括 GROUP BY、ORDER BY)里用不到的字段不需要创建索引,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的。举个例子:

SELECT comment_id, product_id, comment_time FROM product_comment WHERE user_id = 41251

因为我们是按照 user_id 来进行检索的,所以不需要对其他字段创建索引,即使这些字段出现在 SELECT 字段中。

第二种情况是,如果表记录太少,比如少于 1000 个,那么是不需要创建索引的。我之前讲过一个 SQL 查询的例子(第 23 篇中的 heros 数据表查询的例子,一共 69 个英雄不用索引也很快),表记录太少,是否创建索引对查询效率的影响并不大。

第三种情况是,字段中如果有大量重复数据,也不用创建索引,比如性别字段。不过我们也需要根据实际情况来做判断。

最后一种情况是,频繁更新的字段不一定要创建索引。因为更新数据的时候,也需要更新索引,如果索引太多,在更新索引的时候也会造成负担,从而影响效率。

什么情况下索引失效

我们创建了索引,还要避免索引失效,你可以先思考下都有哪些情况会造成索引失效呢?下面是一些常见的索引失效的例子:

如果索引进行了表达式计算,则会失效

我们可以使用 EXPLAIN 关键字来查看 MySQL 中一条 SQL 语句的执行计划,比如:

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id+1 = 900001
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE product_comment NULL ALL NULL NULL NULL NULL 996663 100.00 Using where

你能看到如果对索引进行了表达式计算,索引就失效了。这是因为我们需要把索引字段的取值都取出来,然后依次进行表达式的计算来进行条件判断,因此采用的就是全表扫描的方式,运行时间也会慢很多,最终运行时间为 2.538 秒。

为了避免索引失效,我们对 SQL 进行重写:

SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900000

运行时间为 0.039 秒。

如果对索引使用函数,也会造成失效

比如我们想要对 comment_text 的前三位为 abc 的内容进行条件筛选,这里我们来查看下执行计划:

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'

运行结果:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE product_comment NULL ALL NULL NULL NULL NULL 996663 100.00 Using where

你能看到对索引字段进行函数操作,造成了索引失效,这时可以进行查询重写:

SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_text LIKE 'abc%'

使用 EXPLAIN 对查询语句进行分析:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE product_comment NULL range comment_text comment_text 767 NULL 213 100.00 Using index condition

你能看到经过查询重写后,可以使用索引进行范围检索,从而提升查询效率。

在 WHERE 子句中,如果在 OR 前的条件列进行了索引,而在 OR 后的条件列没有进行索引,那么索引会失效

比如下面的 SQL 语句,comment_id 是主键,而 comment_text 没有进行索引,因为 OR 的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描,因此索引的条件列也会失效:

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900001 OR comment_text = '462eed7ac6e791292a79'

运行结果:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE product_comment NULL ALL PRIMARY NULL NULL NULL 996663 10.00 Using where

如果我们把 comment_text 创建了索引会是怎样的呢?

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE product_comment NULL index_merge PRIMARY,comment_text PRIMARY,comment_text 4,767 NULL 2 100.00 Using union(PRIMARY,comment_text); Using where

你能看到这里使用到了 index merge,简单来说 index merge 就是对 comment_id 和 comment_text 分别进行了扫描,然后将这两个结果集进行了合并。这样做的好处就是避免了全表扫描。

当我们使用 LIKE 进行模糊查询的时候,后面不能是 %

EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_text LIKE '%abc'

运行结果:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE product_comment NULL ALL NULL NULL NULL NULL 996663 11.11 Using where

这个很好理解,如果一本字典按照字母顺序进行排序,我们会从首位开始进行匹配,而不会对中间位置进行匹配,否则索引就失效了。

索引列与 NULL 或者 NOT NULL 进行判断的时候也会失效

这是因为索引并不存储空值,所以最好在设计数据表的时候就将字段设置为 NOT NULL 约束,比如你可以将 INT 类型的字段,默认值设置为 0。将字符类型的默认值设置为空字符串 (’’)。

我们在使用联合索引的时候要注意最左原则

最左原则也就是需要从左到右的使用索引中的字段,一条 SQL 语句可以只使用联合索引的一部分,但是需要从最左侧开始,否则就会失效。

总结

对索引的使用原则进行了梳理,使用好索引可以提升 SQL 查询的效率,但同时 也要注意索引不是万能的。为了避免全表扫描,我们还需要注意有哪些情况可能会导致索引失效,这时就需要进行查询重写,让索引发挥作用。

实际工作中,查询的需求多种多样,创建的索引也会越来越多。这时还需要注意,我们要尽可能扩展索引,而不是新建索引,因为索引数量过多需要维护的成本也会变大,导致写效率变低。同时,我们还需要定期查询使用率低的索引,对于从未使用过的索引可以进行删除,这样才能让索引在 SQL 查询中发挥最大价值。

从数据页的角度理解B+树查询

对数据库的存储结构以及页结构的底层进行了解,可以加深我们对索引运行机制的认识,从而你对索引的存储、查询原理,以及对 SQL 查询效率有更深的理解。

数据库中的存储结构是怎样的

记录是按照来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。因此在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page)。

一个页中可以存储多个行记录(Row),同时在数据库中,还存在着区(Extent)、段(Segment)和表空间(Tablespace)。行、页、区、段、表空间的关系如下图所示:

从图中你能看到一个表空间包括了一个或多个段,一个段包括了一个或多个区,一个区包括了多个页,而一个页中可以有多行记录,这些概念我简单给你讲解下。

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

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

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

在 InnoDB 中存在两种表空间的类型:共享表空间和独立表空间。如果是共享表空间就意味着多张表共用一个表空间。如果是独立表空间,就意味着每张表有一个独立的表空间,也就是数据和索引信息都会保存在自己的表空间中。独立的表空间可以在不同的数据库之间进行迁移。

你可以通过下面的命令来查看 InnoDB 的表空间类型:

mysql > show variables like 'innodb_file_per_table';

你能看到 innodb_file_per_table=ON,这就意味着每张表都会单独保存为一个.ibd 文件。

数据页内的结构是怎样的

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

表页的大小限定了表行的最大长度,不同 DBMS 的表页大小不同。比如在 MySQL 的 InnoDB 存储引擎中,默认页的大小是 16KB,我们可以通过下面的命令来进行查看:

mysql> show variables like '%innodb_page_size%';

在 SQL Server 的页大小为 8KB,而在 Oracle 中我们用术语“块”(Block)来代表“页”,Oralce 支持的块大小为 2KB,4KB,8KB,16KB,32KB 和 64KB。

数据库 I/O 操作的最小单位是页,与数据库相关的内容都会存储在页结构里。

数据页包括七个部分,分别是文件头(File Header)、页头(Page Header)、最大最小记录(Infimum+supremum)、用户记录(User Records)、空闲空间(Free Space)、页目录(Page Directory)和文件尾(File Tailer)。

页结构的示意图如下所示:

这 7 个部分到底有什么作用呢?我简单梳理下:

实际上,我们可以把这 7 个数据页分成 3 个部分。

首先是文件通用部分,也就是文件头和文件尾。它们类似集装箱,将页的内容进行封装,通过文件头和文件尾校验的方式来确保页的传输是完整的。

在文件头中有两个字段,分别是 FIL_PAGE_PREV 和 FIL_PAGE_NEXT,它们的作用相当于指针,分别指向上一个数据页和下一个数据页。连接起来的页相当于一个双向的链表,如下图所示:

需要说明的是采用链表的结构让数据页之间不需要是物理上的连续,而是逻辑上的连续。

我们之前讲到过 Hash 算法,这里文件尾的校验方式就是采用 Hash 算法进行校验。

举个例子,当我们进行页传输的时候,如果突然断电了,造成了该页传输的不完整,这时通过文件尾的校验和(checksum 值)与文件头的校验和做比对,如果两个值不相等则证明页的传输有问题,需要重新进行传输,否则认为页的传输已经完成。

第二个部分是记录部分,页的主要作用是存储记录,所以“最小和最大记录”和“用户记录”部分占了页结构的主要空间。另外空闲空间是个灵活的部分,当有新的记录插入时,会从空闲空间中进行分配用于存储新记录,如下图所示:

第三部分是索引部分,这部分重点指的是页目录,它起到了记录的索引作用,因为在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索,因此在页目录中提供了二分查找的方式,用来提高记录的检索效率。这个过程就好比是给记录创建了一个目录:

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

  2. 第 1 组,也就是最小记录所在的分组只有 1 个记录;

    最后一组,就是最大记录所在的分组,会有 1-8 条记录;

    其余的组记录数量在 4-8 条之间。

    这样做的好处是,除了第 1 组(最小记录所在组)以外,其余组的记录数会尽量平分。

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

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

    如下图所示:

页目录存储的是槽,槽相当于分组记录的索引。我们通过槽查找记录,实际上就是在做二分查找。

这里以上面的图示进行举例,5 个槽的编号分别为 0,1,2,3,4,我想查找主键为 9 的用户记录,我们初始化查找的槽的下限编号,设置为 low=0,然后设置查找的槽的上限编号 high=4,然后采用二分查找法进行查找。

首先找到槽的中间位置 p=(low+high)/2=(0+4)/2=2,这时我们取编号为 2 的槽对应的分组记录中最大的记录,取出关键字为 8。因为 9 大于 8,所以应该会在槽编号为 (p,high] 的范围进行查找

接着重新计算中间位置 p’=(p+high)/2=(2+4)/2=3,我们查找编号为 3 的槽对应的分组记录中最大的记录,取出关键字为 12。因为 9 小于 12,所以应该在槽 3 中进行查找。

遍历槽 3 中的所有记录,找到关键字为 9 的记录,取出该条记录的信息即为我们想要查找的内容。

从数据页的角度看 B+ 树是如何进行查询的

MySQL 的 InnoDB 存储引擎采用 B+ 树作为索引,而索引又可以分成聚集索引和非聚集索引(二级索引),这些索引都相当于一棵 B+ 树,如图所示。

一棵 B+ 树按照节点类型可以分成两部分:

  1. 叶子节点,B+ 树最底层的节点,节点的高度为 0,存储行记录。
  2. 非叶子节点,节点的高度大于 0,存储索引键页面指针,并不存储行记录本身。

我们刚才学习了页结构的内容,你可以用页结构对比,看下 B+ 树的结构

在一棵 B+ 树中,每个节点都是一个页,每次新建节点的时候,就会申请一个页空间。

同一层上的节点之间,通过页的结构构成一个双向的链表(页文件头中的两个指针字段)。

非叶子节点,包括了多个索引行,每个索引行里存储索引键和指向下一层页面的页面指针

最后是叶子节点,它存储了关键字和行记录,在节点内部(也就是页结构的内部)记录之间是一个单向的链表,但是对记录进行查找,则可以通过页目录采用二分查找的方式来进行。

当我们从页结构来理解 B+ 树的结构的时候,可以帮我们理解一些通过索引进行检索的原理:

1.B+ 树是如何进行记录检索的?

如果通过 B+ 树的索引查询行记录,首先是从 B+ 树的根开始,逐层检索,直到找到叶子节点,也就是找到对应的数据页为止,将数据页加载到内存中,页目录中的槽(slot)采用二分查找的方式先找到一个粗略的记录分组,然后再在分组中通过链表遍历的方式查找记录。

2. 普通索引和唯一索引在查询效率上有什么不同?

我们创建索引的时候可以是普通索引,也可以是唯一索引,那么这两个索引在查询效率上有什么不同呢?

唯一索引就是在普通索引上增加了约束性,也就是关键字唯一,找到了关键字就停止检索。

而普通索引,可能会存在用户记录中的关键字相同的情况,根据页结构的原理,当我们读取一条记录的时候,不是单独将这条记录从磁盘中读出去,而是将这个记录所在的页加载到内存中进行读取。

InnoDB 存储引擎的页大小为 16KB,在一个页中可能存储着上千个记录,因此在普通索引的字段上进行查找也就是在内存中多几次“判断下一条记录”的操作,对于 CPU 来说,这些操作所消耗的时间是可以忽略不计的。所以对一个索引字段进行检索,采用普通索引还是唯一索引在检索效率上基本上没有差别。

总结

学习了数据库中的基本存储单位,也就是页(Page),磁盘 I/O 都是基于页来进行读取的,在页之上还有区、段和表空间,它们都是更大的存储单位。我们在分配空间的时候会按照页为单位来进行分配,同一棵树上同一层的页与页之间采用双向链表,而在页里面,记录之间采用的单向链表的方式。

链表这种数据结构的特点是增加、删除比较方便,所以在对记录进行删除的时候,有时候并不是真的删除了记录,而只是逻辑上的删除,也就是在标记为上标记为“已删除”。但链表还有个问题就是查找效率低,因此在页结构中还专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索提升效率。

从磁盘I/O的角度理解SQL查询的成本

数据库缓冲池

磁盘 I/O 需要消耗的时间很多,而在内存中进行操作,效率则会高很多,为了能让数据表或者索引中的数据随时被我们所用,DBMS 会申请占用内存来作为数据缓冲池,这样做的好处是可以让磁盘活动最小化,从而减少与磁盘直接进行 I/O 的时间。要知道,这种策略对提升 SQL 语句的查询性能来说至关重要。如果索引的数据在缓冲池里,那么访问的成本就会降低很多。

那么缓冲池如何读取数据呢?

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

缓存在数据库中的结构和作用如下图所示:

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

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

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

查看缓冲池的大小

如果你使用的是 MySQL MyISAM 存储引擎,它只缓存索引,不缓存数据,对应的键缓存参数为 key_buffer_size,你可以用它进行查看。

如果你使用的是 InnoDB 存储引擎,可以通过查看 innodb_buffer_pool_size 变量来查看缓冲池的大小,命令如下:

mysql > show variables like 'innodb_buffer_pool_size'

你能看到此时 InnoDB 的缓冲池大小只有 8388608/1024/1024=8MB,我们可以修改缓冲池大小为 128MB,方法如下:

然后再来看下修改后的缓冲池大小,此时已成功修改成了 128MB:

在 InnoDB 存储引擎中,我们可以同时开启多个缓冲池,这里我们看下如何查看缓冲池的个数,使用命令:

mysql > show variables like 'innodb_buffer_pool_instances'

你能看到当前只有一个缓冲池。实际上innodb_buffer_pool_instances默认情况下为 8,为什么只显示只有一个呢?这里需要说明的是,如果想要开启多个缓冲池,你首先需要将innodb_buffer_pool_size参数设置为大于等于 1GB,这时innodb_buffer_pool_instances才会大于 1。你可以在 MySQL 的配置文件中对innodb_buffer_pool_size进行设置,大于等于 1GB,然后再针对innodb_buffer_pool_instances参数进行修改。

数据页加载的三种方式

如果缓冲池中没有该页数据,那么缓冲池有以下三种读取数据的方式,每种方式的读取效率都是不同的:

内存读取

如果该数据存在于内存中,基本上执行时间在 1ms 左右,效率还是很高的。

随机读取

如果数据没有在内存中,就需要在磁盘上对该页进行查找,整体时间预估在 10ms 左右,这 10ms 中有 6ms 是磁盘的实际繁忙时间(包括了寻道和半圈旋转时间),有 3ms 是对可能发生的排队时间的估计值,另外还有 1ms 的传输时间,将页从磁盘服务器缓冲区传输到数据库缓冲区中。这 10ms 看起来很快,但实际上对于数据库来说消耗的时间已经非常长了,因为这还只是一个页的读取时间。

顺序读取

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

通过 last_query_cost 统计 SQL 语句的查询成本

我们先前已经讲过,一条 SQL 查询语句在执行前需要确定查询计划,如果存在多种查询计划的话,MySQL 会计算每个查询计划所需要的成本,从中选择成本最小的一个作为最终执行的查询计划。

如果我们想要查看某条 SQL 语句的查询成本,可以在执行完这条 SQL 语句之后,通过查看当前会话中的 last_query_cost 变量值来得到当前查询的成本。这个查询成本对应的是 SQL 语句所需要读取的页的数量。

我以 product_comment 表为例,如果我们想要查询 comment_id=900001 的记录,然后看下查询成本,我们可以直接在聚集索引上进行查找:

mysql> SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 900001;

运行结果(1 条记录,运行时间为 0.042s):

然后再看下查询优化器的成本,实际上我们只需要检索一个页即可:

mysql> SHOW STATUS LIKE 'last_query_cost';

如果我们想要查询 comment_id 在 900001 到 9000100 之间的评论记录呢?

mysql> SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id BETWEEN 900001 AND 900100;

运行结果(100 条记录,运行时间为 0.046s):

然后再看下查询优化器的成本,这时我们大概需要进行 20 个页的查询。

mysql> SHOW STATUS LIKE 'last_query_cost';

你能看到页的数量是刚才的 20 倍,但是查询的效率并没有明显的变化,实际上这两个 SQL 查询的时间基本上一样,就是因为采用了顺序读取的方式将页面一次性加载到缓冲池中,然后再进行查找。虽然页数量(last_query_cost)增加了不少,但是通过缓冲池的机制,并没有增加多少查询时间。

总结

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

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

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

没有理想的索引

索引片和过滤因子

索引片就是 SQL 查询语句在执行中需要扫描的一个索引片段,我们会根据索引片中包含的匹配列的数量不同,将索引分成窄索引(比如包含索引列数为 1 或 2)和宽索引(包含的索引列数大于 2)。

如果索引片越宽,那么需要顺序扫描的索引页就越多;如果索引片越窄,就会减少索引访问的开销。比如在 product_comment 数据表中,我们将 comment_id 设置为主键,然后执行下面的 SQL 查询语句:

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id between 100001 and 100100

img

针对这条 SQL 查询语句,我们可以设置窄索引(user_id)。需要说明的是,每个非聚集索引保存的数据都会存储主键值,然后通过主键值,来回表查找相应的数据,因此每个索引都相当于包括了主键,也就是(comment_id, user_id)

同样我们可以设置宽索引(user_id, product_id, comment_text),相当于包括了主键,也就是(comment_id, user_id, product_id, comment_text)

如何通过宽索引避免回表

刚才讲到了宽索引需要顺序扫描的索引页很多,不过它也可以避免通过索引找到主键,再通过主键回表进行数据查找的情况。回表指的就是数据库根据索引找到了数据行之后,还需要通过主键再次到数据表中读取数据的情况。

我们可以用不同索引片来运行下刚才的 SQL 语句,比如我们采用窄索引(user_id)的方式,来执行下面这条语句:

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id between 100001 and 100100

运行结果(110 条记录,运行时间 0.062s):

img

同样,如果我们设置宽索引(user_id, product_id, comment_text),然后执行相同的 SQL 语句,运行结果相同,运行时间为 0.043s,你能看到查询效率有了一些提升。这就是因为我们可以通过宽索引将 SELECT 中需要用到的列(主键列可以除外)都设置在宽索引中,这样就避免了回表扫描的情况,从而提升 SQL 查询效率。

什么是过滤因子

在索引片的设计中,我们还需要考虑一个因素,那就是过滤因子,它描述了谓词的选择性。在 WHERE 条件语句中,每个条件都称为一个谓词,谓词的选择性也等于满足这个条件列的记录数除以总记录数的比例。

举个例子,我们在 player 数据表中,定义了 team_id 和 height 字段,我们也可以设计个 gender 字段,这里 gender 的取值都为 male。

在 player 表中记录比较少,一共 37 条记录,不过我们也可以统计以下字段:gender、team_id、height 和 name,以便评估过滤因子的筛选能力,如下表所示:

img

你能看到gender='male'不是个好过滤因子,因为所有球员都是男性,同样team_id=1001也不是个好过滤因子,因为这个比例在这个特定的数据集中高达 54%,相比之下height=2.08具有一定的筛选性,过滤因子能力最强的是 name 字段。

这时如果我们创建一个联合的过滤条件(height, team_id),那么它的过滤能力是怎样的呢?

img

联合过滤因子有更高的过滤能力,这里还需要注意一个条件,那就是条件列的关联性应该尽量相互独立,否则如果列与列之间具有相关性,联合过滤因子的能力就会下降很多。比如城市名称和电话区号就有强相关性,这两个列组合到一起不会加强过滤效果。

你能看到过滤因子决定了索引片的大小(注意这里不是窄索引和宽索引),过滤因子的条件过滤能力越强,满足条件的记录数就越少,SQL 查询需要扫描的索引片也就越小。同理,如果我们没有选择好索引片中的过滤因子,就会造成索引片中的记录数过多的情况。

针对 SQL 查询的理想索引设计:三星索引

刚才介绍了宽索引和窄索引,有些时候宽索引可以提升 SQL 的查询效率,那么你可能会问,如果针对 SQL 查询来说,有没有一个标准能让 SQL 查询效率最大化呢?

实际上,存在着一个三星索引的标准,这就好比我们在学习数据表设计时提到的三范式一样。三星索引具体指的是:

  1. 在 WHERE 条件语句中,找到所有等值谓词中的条件列,将它们作为索引片中的开始列;
  2. 将 GROUP BY 和 ORDER BY 中的列加入到索引中;
  3. 将 SELECT 字段中剩余的列加入到索引片中。

你能看到这样操作下来,索引片基本上会变成一个宽索引,把能添加的相关列都加入其中。为什么对于一条 SQL 查询来说,这样做的效率是最高的吗?

首先,如果我们要通过索引查找符合条件的记录,就需要将 WHERE 子句中的等值谓词列加入到索引片中,这样索引的过滤能力越强,最终扫描的数据行就越少。

另外,如果我们要对数据记录分组或者排序,都需要重新扫描数据记录。为了避免进行 file sort 排序,可以把 GROUP BY 和 ORDER BY 中涉及到的列加入到索引中,因为创建了索引就会按照索引的顺序来存储数据,这样再对这些数据按照某个字段进行分组或者排序的时候,就会提升效率。

img

最后,我们取数据的时候,可能会存在回表情况。回表就是通过索引找到了数据行,但是还需要通过主键的方式在数据表中查找完成的记录。这是因为 SELECT 所需的字段并不都保存在索引中,因此我们可以将 SELECT 中的字段都保存在索引中避免回表的情况,从而提升查询效率。

为什么很难存在理想的索引设计

从三星索引的创建过程中,你能看到三星索引实际上分析了在 SQL 查询过程中所有可能影响效率的环节,通过在索引片中添加索引的方式来提升效率。通过上面的原则,我们可以很快创建一个 SQL 查询语句的三星索引(有时候可能只有两星,比如同时拥有范围谓词和 ORDER BY 的时候)。

但就同三范式一样,很多时候我们并没有遵循三范式的设计原则,而是采用了反范式设计。同样,有时候我们并不能需要完全遵循三星索引的原则,原因主要有以下两点:

  1. 采用三星索引会让索引片变,这样每个页能够存储的索引数据就会变少,从而增加了页加载的数量。从另一个角度来看,如果数据量很大,比如有 1000 万行数据,过多索引所需要的磁盘空间可能会成为一个问题,对缓冲池所需空间的压力也会增加。

  2. 增加了索引维护的成本。如果我们为所有的查询语句都设计理想的三星索引,就会让数据表中的索引个数过多,这样索引维护的成本也会增加。

    举个例子,当我们添加一条记录的时候,就需要在每一个索引上都添加相应的行(存储对应的主键值),假设添加一行记录的时间成本是 10ms(磁盘随机读取一个页的时间),那么如果我们创建了 10 个索引,添加一条记录的时间就可能变成 0.1s,如果是添加 10 条记录呢?就会花费近 1s 的时间。从索引维护的成本来看消耗还是很高的。当然对于数据库来说,数据的更新不一定马上回写到磁盘上,但即使不及时将脏页进行回写,也会造成缓冲池中的空间占用过多,脏页过多的情况。

总结

你能看到针对一条 SQL 查询来说,三星索引是个理想的方式,但实际运行起来我们要考虑更多维护的成本,在索引效率和索引维护之间进行权衡。

三星索引会让索引变宽,好处就是不需要进行回表查询,减少了磁盘 I/O 的次数,弊端就是会造成频繁的页分裂和页合并,对于数据的插入和更新来说,效率会降低不少。

那我们该如何设计索引呢?

首先一张表的索引个数不宜过多,否则一条记录的增加和修改,会因为过多的索引造成额外的负担。针对这个情况,当你需要新建索引的时候,首先考虑在原有的索引片上增加索引,也就是采用复合索引的方式,而不是新建一个新的索引。另外我们可以定期检查索引的使用情况,对于很少使用到的索引可以及时删除,从而减少索引数量。

同时,在索引片中,我们也需要控制索引列的数量,通常情况下我们将 WHERE 里的条件列添加到索引中,而 SELECT 中的非条件列则不需要添加。除非 SELECT 中的非条件列数少,并且该字段会经常使用到。

另外单列索引和复合索引的长度也需要控制,在 MySQL InnoDB 中,系统默认单个索引长度最大为 767 bytes,如果单列索引长度超过了这个限制,就会取前缀索引,也就是取前 255 字符。这实际上也是告诉我们,字符列会占用较大的空间,在数据表设计的时候,尽量采用数值类型替代字符类型,尽量避免用字符类型做主键,同时针对字符字段最好只建前缀索引。

悲观锁和乐观锁

索引和锁是数据库中的两个核心知识点,不论在工作中,还是在面试中,我们都会经常跟它们打交道。事务的 4 大原则以及不同的隔离级别,这些隔离级别的实现都是通过锁来完成的。

加锁是为了保证数据的一致性,这个思想在程序开发领域中同样很重要。在程序开发中也会存在多线程同步的问题。当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程在进行访问,保证数据的完整性和一致性。

按照锁粒度进行划分

锁用来对数据进行锁定,我们可以从锁定对象的粒度大小来对锁进行划分,分别为行锁、页锁和表锁。

  • 行锁按照行的粒度对数据进行锁定。锁定力度小,发生锁冲突概率低,可以实现的并发度高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。

  • 页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。

    页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

  • 表锁就是对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。

行锁、页锁和表锁是相对常见的三种锁,除此以外我们还可以在区和数据库的粒度上锁定数据,对应区锁和数据库锁。不同的数据库和存储引擎支持的锁粒度不同,InnoDB 和 Oracle 支持行锁和表锁。而 MyISAM 只支持表锁,MySQL 中的 BDB 存储引擎支持页锁和表锁。SQL Server 可以同时支持行锁、页锁和表锁,如下表所示:

img

这里需要说明下,每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

从数据库管理的角度对锁进行划分

除了按照锁粒度大小对锁进行划分外,我们还可以从数据库管理的角度对锁进行划分。共享锁和排它锁,是我们经常会接触到的两把锁。

共享锁也叫读锁或 S 锁,共享锁锁定的资源可以被其他用户读取,但不能修改。在进行SELECT的时候,会将对象进行共享锁锁定,当数据读取完毕之后,就会释放共享锁,这样就可以保证数据在读取时不被修改。

比如我们想给 product_comment 在表上加共享锁,可以使用下面这行命令:

LOCK TABLE product_comment READ;

当对数据表加上共享锁的时候,该数据表就变成了只读模式,此时我们想要更新 product_comment 表中的数据,比如下面这样:

UPDATE product_comment SET product_id = 10002 WHERE user_id = 912178;

系统会做出如下提示:

ERROR 1099 (HY000): Table 'product_comment' was locked with a READ lock and can't be updated

也就是当共享锁没有释放时,不能对锁住的数据进行修改。

如果我们想要对表上的共享锁进行解锁,可以使用下面这行命令:

UNLOCK TABLE;

如果我们想要给某一行加上共享锁呢,比如想对 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;

这时只有获得排它锁的事务可以对 product_comment 进行查询或修改,其他事务如果想要在 product_comment 表上查询数据,则需要等待。你可以自己开两个 MySQL 客户端来模拟下。

这时我们释放掉排它锁,使用这行命令即可。

UNLOCK TABLE;

同样的,如果我们想要在某个数据行上添加排它锁,比如针对 user_id=912178 的数据行,则写成如下这样:

SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 FOR UPDATE;

另外当我们对数据进行更新的时候,也就是INSERTDELETE或者UPDATE的时候,数据库也会自动使用排它锁,防止其他事务对该数据行进行操作。

当我们想要获取某个数据表的排它锁的时候,需要先看下这张数据表有没有上了排它锁。如果这个数据表中的某个数据行被上了行锁,我们就无法获取排它锁。这时需要对数据表中的行逐一排查,检查是否有行锁,如果没有,才可以获取这张数据表的排它锁。这个过程是不是有些麻烦?这里就需要用到意向锁。

意向锁(Intent Lock),简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子,你可以给整个房子设置一个标识,告诉它里面有人,即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权,只需要看这个房子的标识即可,不需要再对房子中的每个房间进行查找。这样是不是很方便?

返回数据表的场景,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。

如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。

为什么共享锁会发生死锁的情况?

当我们使用共享锁的时候会出现死锁的风险,下面我们用两个 MySQL 客户端来模拟一下事务查询。

首先客户端 1 开启事务,然后采用读锁的方式对user_id=912178的数据行进行查询,这时事务没有提交的时候,这两行数据行上了读锁。

然后我们用客户端 2 开启事务,同样对user_id=912178获取读锁,理论上获取读锁后还可以对数据进行修改,比如执行下面这条语句:

UPDATE product_comment SET product_i = 10002 WHERE user_id = 912178;

当我们执行的时候客户端 2 会一直等待,因为客户端 1 也获取了该数据的读锁,不需要客户端 2 对该数据进行修改。这时客户端 2 会提示等待超时,重新执行事务。

你能看到当有多个事务对同一数据获得读锁的时候,可能会出现死锁的情况。

从程序员的角度对进行划分

如果从程序员的视角来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。

乐观锁(Optimistic Locking)认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现。

乐观锁的版本号机制

在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

这种方式类似我们熟悉的 SVN、CVS 版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。

乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。

悲观锁(Pessimistic Locking)也是一种思想,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。


从这两种锁的设计思想中,你能看出乐观锁和悲观锁的适用场景:

  1. 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
  2. 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。

总结

讲解了数据库中锁的划分,你能看到从不同维度都可以对锁进行划分,需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想

既然有锁的存在,就有可能发生死锁的情况。死锁就是多个事务(如果是在程序层面就是多个进程)在执行过程中,因为竞争某个相同的资源而造成阻塞的现象。发生死锁,往往是因为在事务中,锁的获取是逐步进行的。

在客户端 1 获取某数据行共享锁的同时,另一个客户端 2 也获取了该数据行的共享锁,这时任何一个客户端都没法对这个数据进行更新,因为共享锁会阻止其他事务对数据的更新,当某个客户端想要对锁定的数据进行更新的时候,就出现了死锁的情况。当死锁发生的时候,就需要一个事务进行回滚,另一个事务获取锁完成事务,然后将锁释放掉,很像交通堵塞时候的解决方案。

MVCC

我们知道事务有 4 个隔离级别,以及可能存在的三种异常问题,如下图所示:

在 MySQL 中,默认的隔离级别是可重复读,可以解决脏读和不可重复读的问题,但不能解决幻读问题。如果我们想要解决幻读问题,就需要采用串行化的方式,也就是将隔离级别提升到最高,但这样一来就会大幅降低数据库的事务并发能力。

有没有一种方式,可以不采用锁机制,而是通过乐观锁的方式来解决不可重复读和幻读问题呢?实际上 MVCC 机制的设计,就是用来解决这个问题的,它可以在大多数情况下替代行级锁,降低系统的开销。

MVCC 是什么,解决了什么问题

MVCC 的英文全称是 Multiversion Concurrency Control,中文翻译过来就是多版本并发控制技术。从名字中也能看出来,MVCC 是通过数据行的多个版本管理来实现数据库的并发控制,简单来说它的思想就是保存数据的历史版本。这样我们就可以通过比较版本号决定数据是否显示出来,读取数据的时候不需要加锁也可以保证事务的隔离效果。

通过 MVCC 我们可以解决以下几个问题:

  1. 读写之间阻塞的问题,通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事务并发处理能力。
  2. 降低了死锁的概率。这是因为 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。
  3. 解决一致性读的问题。一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。

什么是快照读,什么是当前读

那么什么是快照读呢?快照读读取的是快照数据。不加锁的简单的 SELECT 都属于快照读,比如这样:

SELECT * FROM player WHERE ...

当前读就是读取最新数据,而不是历史版本的数据。加锁的 SELECT,或者对数据进行增删改都会进行当前读,比如:

SELECT * FROM player LOCK IN SHARE MODE;

SELECT * FROM player FOR UPDATE;

INSERT INTO player values ...

DELETE FROM player WHERE ...

UPDATE player SET ...

这里需要说明的是,快照读就是普通的读操作,而当前读包括了加锁的读取和 DML 操作。

上面讲 MVCC 的作用,你可能觉得有些抽象。我们用具体的例子体会一下。

比如我们有个账户金额表 user_balance,包括三个字段,分别是 username 用户名、balance 余额和 bankcard 卡号,具体的数据示意如下:

为了方便,我们假设 user_balance 表中只有用户 A 和 B 有余额,其他人的账户余额均为 0。下面我们考虑一个使用场景。

用户 A 和用户 B 之间进行转账,此时数据库管理员想要查询 user_balance 表中的总金额:

SELECT SUM(balance) FROM user_balance

你可以思考下,如果数据库不支持 MVCC 机制,而是采用自身的锁机制来实现的话,可能会出现怎样的情况呢?

情况 1:因为需要采用加行锁的方式,用户 A 给 B 转账时间等待很久,如下图所示。

你能看到为了保证数据的一致性,我们需要给统计到的数据行都加上行锁。这时如果 A 所在的数据行加上了行锁,就不能给 B 转账了,只能等到所有操作完成之后,释放行锁再继续进行转账,这样就会造成用户事务处理的等待时间过长。

情况 2:当我们读取的时候用了加行锁,可能会出现死锁的情况,如下图所示。比如当我们读到 A 有 1000 元的时候,此时 B 开始执行给 A 转账:

UPDATE user_balance SET balance=balance-100 WHERE username ='B'

执行完之后马上执行下一步:

UPDATE user_balance SET balance=balance+100 WHERE username ='A'

我们会发现此时 A 被锁住了,而管理员事务还需要对 B 进行访问,但 B 被用户事务锁住了,此时就发生了死锁。

MVCC 可以解决读写互相阻塞的问题,这样提升了效率,同时因为采用了乐观锁的思想,降低了死锁的概率。

InnoDB 中的 MVCC 是如何实现的?

我刚才讲解了 MVCC 的思想和作用,实际上 MVCC 没有正式的标准,所以在不同的 DBMS 中,MVCC 的实现方式可能是不同的,你可以参考相关的 DBMS 文档。今天我来讲一下 InnoDB 中 MVCC 的实现机制。

在了解 InnoDB 中 MVCC 的实现方式之前,我们需要了解 InnoDB 是如何存储记录的多个版本的。这里的多版本对应的就是 MVCC 前两个字母的释义:Multi Version,我们需要了解和它相关的数据都有哪些,存储在哪里。这些数据包括事务版本号、行记录中的隐藏列和 Undo Log。

事务版本号

每开启一个事务,我们都会从数据库中获得一个事务 ID(也就是事务版本号),这个事务 ID 是自增长的,通过 ID 大小,我们就可以判断事务的时间顺序。

行记录的隐藏列

InnoDB 的叶子段存储了数据页,数据页中保存了行记录,而在行记录中有一些重要的隐藏字段,如下图所示:

  1. db_row_id:隐藏的行 ID,用来生成默认聚集索引。如果我们创建数据表的时候没有指定聚集索引,这时 InnoDB 就会用这个隐藏 ID 来创建聚集索引。采用聚集索引的方式可以提升数据的查找效率。
  2. db_trx_id:操作这个数据的事务 ID,也就是最后一个对该数据进行插入更新的事务 ID。
  3. db_roll_ptr:回滚指针,也就是指向这个记录的 Undo Log 信息。

Undo Log

InnoDB 将行记录快照保存在了 Undo Log 里,我们可以在回滚段中找到它们,如下图所示:

从图中你能看到回滚指针将数据行的所有快照记录都通过链表的结构串联了起来,每个快照的记录都保存了当时的 db_trx_id,也是那个时间点操作这个数据的事务 ID。这样如果我们想要找历史快照,就可以通过遍历回滚指针的方式进行查找。

Read View 是如何工作的

在 MVCC 机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在 Undo Log 里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?

这时就需要用到 Read View 了,它帮我们解决了行的可见性问题。Read View 保存了当前事务开启时所有活跃(还没有提交)的事务列表,换个角度你可以理解为 Read View 保存了不应该让这个事务看到的其他的事务 ID 列表。

在 Read VIew 中有几个重要的属性:

  1. trx_ids,系统当前正在活跃的事务 ID 集合。
  2. low_limit_id,活跃的事务中最大的事务 ID。
  3. up_limit_id,活跃的事务中最小的事务 ID。
  4. creator_trx_id,创建这个 Read View 的事务 ID。

如图所示,trx_ids 为 trx2、trx3、trx5 和 trx8 的集合,活跃的最大事务 ID(low_limit_id)为 trx8,活跃的最小事务 ID(up_limit_id)为 trx2。

假设当前有事务 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 已经提交了,该行记录可见。

了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过多版本并发控制技术找到它:

  1. 首先获取事务自己的版本号,也就是事务 ID;
  2. 获取 Read View;
  3. 查询得到的数据,然后与 Read View 中的事务版本号进行比较;
  4. 如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;
  5. 最后返回符合规则的数据。

你能看到 InnoDB 中,MVCC 是通过 Undo Log + Read View 进行数据读取,Undo Log 保存了历史快照,而 Read View 规则帮我们判断当前版本的数据是否可见。

需要说明的是,在隔离级别为读已提交(Read Commit)时,一个事务中的每一次 SELECT 查询都会获取一次 Read View。如表所示:

你能看到,在读已提交的隔离级别下,同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。

当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:

InnoDB 是如何解决幻读的

不过这里需要说明的是,在可重复读的情况下,InnoDB 可以通过 Next-Key 锁 +MVCC 来解决幻读问题。

在读已提交的情况下,即使采用了 MVCC 方式也会出现幻读。如果我们同时开启事务 A 和事务 B,先在事务 A 中进行某个条件范围的查询,读取的时候采用排它锁,在事务 B 中增加一条符合该条件范围的数据,并进行提交,然后我们在事务 A 中再次查询该条件范围的数据,就会发现结果集中多出一个符合条件的数据,这样就出现了幻读。

出现幻读的原因是在读已提交的情况下,InnoDB 只采用记录锁(Record Locking)。这里要介绍下 InnoDB 三种行锁的方式:

  1. 记录锁:针对单个行记录添加锁。
  2. 间隙锁(Gap Locking):可以帮我们锁住一个范围(索引之间的空隙),但不包括记录本身。采用间隙锁的方式可以防止幻读情况的产生。
  3. Next-Key 锁:帮我们锁住一个范围,同时锁定记录本身,相当于间隙锁 + 记录锁,可以解决幻读的问题。

在隔离级别为可重复读时,InnoDB 会采用 Next-Key 锁的机制,帮我们解决幻读问题。

还是这个例子,我们能看到当我们想要插入球员艾利克斯·伦(身高 2.16 米)的时候,事务 B 会超时,无法插入该数据。这是因为采用了 Next-Key 锁,会将 height>2.08 的范围都进行锁定,就无法插入符合这个范围的数据了。然后事务 A 重新进行条件范围的查询,就不会出现幻读的情况。

总结

MVCC 的核心就是 Undo Log+ Read View,“MV”就是通过 Undo Log 来保存数据的历史版本,实现多版本的管理,“CC”是通过 Read View 来实现管理,通过 Read View 原则来决定数据是否显示。同时针对不同的隔离级别,Read View 的生成策略不同,也就实现了不同的隔离级别。

MVCC 是一种机制,MySQL、Oracle、SQL Server 和 PostgreSQL 的实现方式均有不同,我们在学习的时候,更主要的是要理解 MVCC 的设计思想。

posted @ 2020-04-26 19:21  小萝卜鸭  阅读(497)  评论(0编辑  收藏  举报