This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="20丨当我们思考数据库调优的时候都有哪些维度可以选择" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/55/ec/55d54688c3f64ce3ad39fdd774df65ec.mp3"></audio>
从这一篇开始我们正式进入了SQL性能优化篇。在这一模块中我们会关注如何提升SQL查询的效率。你可以思考一下如何你是一名DBA或者开发人员都有哪些维度可以进行数据库调优
其实关于数据库调优的知识点非常分散。不同的DBMS不同的公司不同的职位不同的项目遇到的问题都不尽相同。为了能让你对数据库调优有一个整体的概览我把这些知识点做了一个梳理希望能对你有一些帮助。
今天的课程你需要掌握以下几个方面的内容:
1. 数据库调优的目标是什么?
1. 如果要进行调优,都有哪些维度可以选择?
1. 如何思考和分析数据库调优这件事?
## 数据库调优的目标
简单来说,数据库调优的目的就是要让数据库运行得更快,也就是说响应的时间更快,吞吐量更大。
不过随着用户量的不断增加以及应用程序复杂度的提升我们很难用“更快”去定义数据库调优的目标因为用户在不同时间段访问服务器遇到的瓶颈不同比如双十一促销的时候会带来大规模的并发访问还有用户在进行不同业务操作的时候数据库的事务处理和SQL查询都会有所不同。因此我们还需要更加精细的定位去确定调优的目标。
如何确定呢?一般情况下,有两种方式可以得到反馈。
### 用户的反馈
用户是我们的服务对象,因此他们的反馈是最直接的。虽然他们不会直接提出技术建议,但是有些问题往往是用户第一时间发现的。我们要重视用户的反馈,找到和数据相关的问题。
### 日志分析
我们可以通过查看数据库日志和操作系统日志等方式找出异常情况,通过它们来定位遇到的问题。
除了这些具体的反馈以外,我们还可以通过监控运行状态来整体了解服务器和数据库的运行情况。
### 服务器资源使用监控
通过监控服务器的CPU、内存、I/O等使用情况可以实时了解服务器的性能使用与历史情况进行对比。
### 数据库内部状况监控
在数据库的监控中活动会话Active Session监控是一个重要的指标。通过它你可以清楚地了解数据库当前是否处于非常繁忙的状态是否存在SQL堆积等。
除了活动会话监控以外,我们也可以对事务、锁等待等进行监控,这些都可以帮助我们对数据库的运行状态有更全面的认识。
## 对数据库进行调优,都有哪些维度可以进行选择?
我们需要调优的对象是整个数据库管理系统它不仅包括SQL查询还包括数据库的部署配置、架构等。从这个角度来说我们思考的维度就不仅仅局限在SQL优化上了。
听起来比较复杂,但其实我们可以一步步通过下面的步骤进行梳理。
### 第一步选择适合的DBMS
我们之前讲到了SQL阵营和NoSQL阵营。在RDBMS中常用的有OracleSQL Server和MySQL等。如果对事务性处理以及安全性要求高的话可以选择商业的数据库产品。这些数据库在事务处理和查询性能上都比较强比如采用SQL Server那么单表存储上亿条数据是没有问题的。如果数据表设计得好即使不采用分库分表的方式查询效率也不差。
除此以外你也可以采用开源的MySQL进行存储我们之前讲到过它有很多存储引擎可以选择如果进行事务处理的话可以选择InnoDB非事务处理可以选择MyISAM。
NoSQL阵营包括键值型数据库、文档型数据库、搜索引擎、列式存储和图形数据库。这些数据库的优缺点和使用场景各有不同比如列式存储数据库可以大幅度降低系统的I/O适合于分布式文件系统和OLAP但如果数据需要频繁地增删改那么列式存储就不太适用了。原因我在答疑篇已经讲过这里不再赘述。
DBMS的选择关系到了后面的整个设计过程所以第一步就是要选择适合的DBMS。如果已经确定好了DBMS那么这步可以跳过但有时候我们要根据业务需求来进行选择。
### 第二步,优化表设计
选择了DBMS之后我们就需要进行表设计了。RDBMS中每个对象都可以定义为一张表表与表之间的关系代表了对象之间的关系。如果用的是MySQL我们还可以根据不同表的使用需求选择不同的存储引擎。除此以外还有一些优化的原则可以参考
1. 表结构要尽量遵循第三范式的原则(关于第三范式,我在后面章节会讲)。这样可以让数据结构更加清晰规范,减少冗余字段,同时也减少了在更新,插入和删除数据时等异常情况的发生。
1. 如果分析查询应用比较多,尤其是需要进行多表联查的时候,可以采用反范式进行优化。反范式采用空间换时间的方式,通过增加冗余字段提高查询的效率。
1. 表字段的数据类型选择关系到了查询效率的高低以及存储空间的大小。一般来说如果字段可以采用数值类型就不要采用字符类型字符长度要尽可能设计得短一些。针对字符类型来说当确定字符长度固定时就可以采用CHAR类型当长度不固定时通常采用VARCHAR类型。
数据表的结构设计很基础,也很关键。好的表结构可以在业务发展和用户量增加的情况下依然发挥作用,不好的表结构设计会让数据表变得非常臃肿,查询效率也会降低。
### 第三步,优化逻辑查询
当我们建立好数据表之后,就可以对数据表进行增删改查的操作了。这时我们首先需要考虑的是逻辑查询优化,什么是逻辑查询优化呢?
SQL查询优化可以分为逻辑查询优化和物理查询优化。逻辑查询优化就是通过改变SQL语句的内容让SQL执行效率更高效采用的方式是对SQL语句进行等价变换对查询进行重写。重写查询的数学基础就是关系代数。
SQL的查询重写包括了子查询优化、等价谓词重写、视图重写、条件简化、连接消除和嵌套连接消除等。
比如我们在讲解EXISTS子查询和IN子查询的时候会根据小表驱动大表的原则选择适合的子查询。在WHERE子句中会尽量避免对字段进行函数运算它们会让字段的索引失效。
我举一个例子假设我想对商品评论表中的评论内容进行检索查询评论内容开头为abc的内容都有哪些如果在WHERE子句中使用了函数语句就会写成下面这样
```
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'
```
我们可以采用查询重写的方式进行等价替换:
```
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE comment_text LIKE 'abc%'
```
你会发现在数据量大的情况下第二条SQL语句的查询效率要比前面的高很多执行时间为前者的1/10。
### 第四步,优化物理查询
物理查询优化是将逻辑查询的内容变成可以被执行的物理操作符,从而为后续执行器的执行提供准备。它的核心是高效地建立索引,并通过这些索引来做各种优化。
但你要知道索引不是万能的,我们需要根据实际情况来创建索引。那么都有哪些情况需要考虑呢?
1. 如果数据重复度高就不需要创建索引。通常在重复度超过10%的情况下,可以不创建这个字段的索引。比如性别这个字段(取值为男和女)。
1. 要注意索引列的位置对索引使用的影响。比如我们在WHERE子句中对索引字段进行了表达式的计算会造成这个字段的索引失效。
1. 要注意联合索引对索引使用的影响。我们在创建联合索引的时候会对多个字段创建索引这时索引的顺序就很重要了。比如我们对字段x, y, z创建了索引那么顺序是(x,y,z)还是(z,y,x),在执行的时候就会存在差别。
1. 要注意多个索引对索引使用的影响。索引不是越多越好,因为每个索引都需要存储空间,索引多也就意味着需要更多的存储空间。此外,过多的索引也会导致优化器在进行评估的时候增加了筛选出索引的计算时间,影响评估的效率。
查询优化器在对SQL语句进行等价变换之后还需要根据数据表的索引情况和数据情况确定访问路径这就决定了执行SQL时所需要消耗的资源。SQL查询时需要对不同的数据表进行查询因此在物理查询优化阶段也需要确定这些查询所采用的路径具体的情况包括
1. 单表扫描:对于单表扫描来说,我们可以全表扫描所有的数据,也可以局部扫描。
1. 两张表的连接常用的连接方式包括了嵌套循环连接、HASH连接和合并连接。
1. 多张表的连接:多张数据表进行连接的时候,顺序很重要,因为不同的连接路径查询的效率不同,搜索空间也会不同。我们在进行多表连接的时候,搜索空间可能会达到很高的数据量级,巨大的搜索空间显然会占用更多的资源,因此我们需要通过调整连接顺序,将搜索空间调整在一个可接收的范围内。
物理查询优化是在确定了逻辑查询优化之后,采用物理优化技术(比如索引等),通过计算代价模型对各种可能的访问路径进行估算,从而找到执行方式中代价最小的作为执行计划。在这个部分中,我们需要掌握的重点是对索引的创建和使用。
### 第五步使用Redis或Memcached作为缓存
除了可以对SQL本身进行优化以外我们还可以请外援提升查询的效率。
因为数据都是存放到数据库中,我们需要从数据库层中取出数据放到内存中进行业务逻辑的操作,当用户量增大的时候,如果频繁地进行数据查询,会消耗数据库的很多资源。如果我们将常用的数据直接放到内存中,就会大幅提升查询的效率。
键值存储数据库可以帮我们解决这个问题。
常用的键值存储数据库有Redis和Memcached它们都可以将数据存放到内存中。
从可靠性来说Redis支持持久化可以让我们的数据保存在硬盘上不过这样一来性能消耗也会比较大。而Memcached仅仅是内存存储不支持持久化。
从支持的数据类型来说Redis比Memcached要多它不仅支持key-value类型的数据还支持ListSetHash等数据结构。 当我们有持久化需求或者是更高级的数据处理需求的时候就可以使用Redis。如果是简单的key-value存储则可以使用Memcached。
通常我们对于查询响应要求高的场景响应时间短吞吐量大可以考虑内存数据库毕竟术业有专攻。传统的RDBMS都是将数据存储在硬盘上而内存数据库则存放在内存中查询起来要快得多。不过使用不同的工具也增加了开发人员的使用成本。
### 第六步,库级优化
库级优化是站在数据库的维度上进行的优化策略,比如控制一个库中的数据表数量。另外我们可以采用主从架构优化我们的读写策略。
如果读和写的业务量都很大并且它们都在同一个数据库服务器中进行操作那么数据库的性能就会出现瓶颈这时为了提升系统的性能优化用户体验我们可以采用读写分离的方式降低主数据库的负载比如用主数据库master完成写操作用从数据库slave完成读操作。
除此以外我们还可以对数据库分库分表。当数据量级达到亿级以上时有时候我们需要把一个数据库切成多份放到不同的数据库服务器上减少对单一数据库服务器的访问压力。如果你使用的是MySQL就可以使用MySQL自带的分区表功能当然你也可以考虑自己做垂直切分和水平切分。
什么情况下做垂直切分,什么情况下做水平切分呢?
如果数据库中的数据表过多,可以采用垂直分库的方式,将关联的数据表部署在一个数据库上。
如果数据表中的列过多,可以采用垂直分表的方式,将数据表分拆成多张,把经常一起使用的列放到同一张表里。
如果数据表中的数据达到了亿级以上可以考虑水平切分将大的数据表分拆成不同的子表每张表保持相同的表结构。比如你可以按照年份来划分把不同年份的数据放到不同的数据表中。2017年、2018年和2019年的数据就可以分别放到三张数据表中。
采用垂直分表的形式,就是将一张数据表分拆成多张表,采用水平拆分的方式,就是将单张数据量大的表按照某个属性维度分成不同的小表。
但需要注意的是,分拆在提升数据库性能的同时,也会增加维护和使用成本。
## 我们该如何思考和分析数据库调优这件事
做任何事情之前,我们都需要确认目标。在数据库调优中,我们的目标就是响应时间更快,吞吐量更大。利用宏观的监控工具和微观的日志分析可以帮我们快速找到调优的思路和方式。
虽然每个人的情况都不一样,但我们同样需要对数据库调优这件事有一个整体的认知。在思考数据库调优的时候,可以从三个维度进行考虑。
**首先,选择比努力更重要。**
在进行SQL调优之前可以先选择DBMS和数据表的设计方式。你能看到不同的DBMS直接决定了后面的操作方式数据表的设计方式也直接影响了后续的SQL查询语句。
**另外你可以把SQL查询优化分成两个部分逻辑查询优化和物理查询优化。**
虽然SQL查询优化的技术有很多但是大方向上完全可以分成逻辑查询优化和物理查询优化两大块。逻辑查询优化就是通过SQL等价变换提升查询效率直白一点就是说换一种查询写法执行效率可能更高。物理查询优化则是通过索引和表连接方式等技术来进行优化这里重点需要掌握索引的使用。
**最后,我们可以通过外援来增强数据库的性能。**
单一的数据库总会遇到各种限制,不如取长补短,利用外援的方式。
另外通过对数据库进行垂直或者水平切分,突破单一数据库或数据表的访问限制,提升查询的性能。
本篇文章中涉及到的概念和知识点比较多,也有可能出现纰漏,不过没有关系,我会在在后续的文章中陆续进行讲解。希望这篇文章可以让你站在一个宏观的角度对数据库的调优有系统性的认知,对今后的工作有一些启发。
<img src="https://static001.geekbang.org/resource/image/d3/b0/d3bc10314c3532f053304a00765183b0.jpg" alt=""><br>
你不妨说一下在日常的工作中你是如何发现数据库性能瓶颈的又是怎么解决这个问题的另外我在文章中从6个维度阐述了如何对数据库进行调优前两个维度在于选择中间两个维度在于SQL的查询优化后两个维度在于外援技术。你可以说一说你对这些维度的理解吗
欢迎你在评论区分享你的心得,也欢迎把这篇文章分享给你的朋友或者同事。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="21丨范式设计数据表的范式有哪些3NF指的是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/96/d7e62e9125d028405ee15ce3f8a12796.mp3"></audio>
在日常工作中,我们都需要遵守一定的规范,比如签到打卡、审批流程等,这些规范虽然有一定的约束感,却是非常有必要的,这样可以保证正确性和严谨性,但有些情况下,约束反而会带来效率的下降,比如一个可以直接操作的任务,却需要通过重重审批才能执行。
实际上,数据表的设计和工作流程的设计很像,我们既需要规范性,也要考虑到执行时的方便性。
今天,我来讲解一下数据表的设计范式。范式是数据表设计的基本原则,又很容易被忽略。很多时候,当数据库运行了一段时间之后,我们才发现数据表设计得有问题。重新调整数据表的结构,就需要做数据迁移,还有可能影响程序的业务逻辑,以及网站正常的访问。所以在开始设置数据库的时候,我们就需要重视数据表的设计。
今天的课程你需要掌握以下几个方面的内容:
1. 数据库的设计范式都有哪些?
1. 数据表的键都有哪些?
1. 1NF、2NF和3NF指的是什么
## 数据库的设计范式都包括哪些
我们在设计关系型数据库模型的时候需要对关系内部各个属性之间联系的合理化程度进行定义这就有了不同等级的规范要求这些规范要求被称为范式NF。你可以把范式理解为一张数据表的设计结构需要满足的某种设计标准的级别。
目前关系型数据库一共有6种范式按照范式级别从低到高分别是1NF第一范式、2NF第二范式、3NF第三范式、BCNF巴斯-科德范式、4NF第四范式和5NF第五范式又叫做完美范式
数据库的范式设计越高阶冗余度就越低同时高阶的范式一定符合低阶范式的要求比如满足2NF的一定满足1NF满足3NF的一定满足2NF依次类推。
你可能会问,这么多范式是不是都要掌握呢?
一般来说数据表的设计应尽量满足3NF。但也不绝对有时候为了提高某些查询性能我们还需要破坏范式规则也就是反规范化。
<img src="https://static001.geekbang.org/resource/image/42/9b/4299e5030169710d5b1d29fd0729879b.jpg" alt="">
## 数据表中的那些键
范式的定义会使用到主键和候选键因为主键和候选键可以唯一标识元组数据库中的键Key由一个或者多个属性组成。我总结了下数据表中常用的几种键和属性的定义
- 超键:能唯一标识元组的属性集叫做超键。
- 候选键:如果超键不包括多余的属性,那么这个超键就是候选键。
- 主键:用户可以从候选键中选择一个作为主键。
- 外键如果数据表R1中的某属性集不是R1的主键而是另一个数据表R2的主键那么这个属性集就是数据表R1的外键。
- 主属性:包含在任一候选键中的属性称为主属性。
- 非主属性:与主属性相对,指的是不包含在任何一个候选键中的属性。
通常,我们也将候选键称之为“码”,把主键也称为“主码”。因为键可能是由多个属性组成的,针对单个属性,我们还可以用主属性和非主属性来进行区分。
看到上面的描述你可能还是有点懵,我举个简单的例子。
我们之前用过NBA的球员表player和球队表team。这里我可以把球员表定义为包含球员编号、姓名、身份证号、年龄和球队编号球队表包含球队编号、主教练和球队所在地。
对于球员表来说,超键就是包括球员编号或者身份证号的任意组合,比如(球员编号)(球员编号,姓名)(身份证号,年龄)等。
候选键就是最小的超键,对于球员表来说,候选键就是(球员编号)或者(身份证号)。
主键是我们自己选定,也就是从候选键中选择一个,比如(球员编号)。
外键就是球员表中的球队编号。
在player表中主属性是球员编号身份证号其他的属性姓名年龄球队编号都是非主属性。
## 从1NF到3NF
了解了数据表中的4种键之后我们再来看下1NF、2NF和3NFBCNF我们放在后面讲。
**1NF指的是数据库表中的任何属性都是原子性的不可再分**。这很好理解我们在设计某个字段的时候对于字段X来说就不能把字段X拆分成字段X-1和字段X-2。事实上任何的DBMS都会满足第一范式的要求不会将字段进行拆分。
**2NF指的数据表里的非主属性都要和这个数据表的候选键有完全依赖关系**。所谓完全依赖不同于部分依赖,也就是不能仅依赖候选键的一部分属性,而必须依赖全部属性。
这里我举一个没有满足2NF的例子比如说我们设计一张球员比赛表player_game里面包含球员编号、姓名、年龄、比赛编号、比赛时间和比赛场地等属性这里候选键和主键都为球员编号比赛编号我们可以通过候选键来决定如下的关系
(球员编号, 比赛编号) → (姓名, 年龄, 比赛时间, 比赛场地,得分)
上面这个关系说明球员编号和比赛编号的组合决定了球员的姓名、年龄、比赛时间、比赛地点和该比赛的得分数据。
但是这个数据表不满足第二范式,因为数据表中的字段之间还存在着如下的对应关系:
(球员编号) → (姓名,年龄)
(比赛编号) → (比赛时间, 比赛场地)
也就是说候选键中的某个字段决定了非主属性。你也可以理解为,对于非主属性来说,并非完全依赖候选键。这样会产生怎样的问题呢?
1. 数据冗余如果一个球员可以参加m场比赛那么球员的姓名和年龄就重复了m-1次。一个比赛也可能会有n个球员参加比赛的时间和地点就重复了n-1次。
1. 插入异常:如果我们想要添加一场新的比赛,但是这时还没有确定参加的球员都有谁,那么就没法插入。
1. 删除异常:如果我要删除某个球员编号,如果没有单独保存比赛表的话,就会同时把比赛信息删除掉。
1. 更新异常:如果我们调整了某个比赛的时间,那么数据表中所有这个比赛的时间都需要进行调整,否则就会出现一场比赛时间不同的情况。
为了避免出现上述的情况,我们可以把球员比赛表设计为下面的三张表。
球员player表包含球员编号、姓名和年龄等属性比赛game表包含比赛编号、比赛时间和比赛场地等属性球员比赛关系player_game表包含球员编号、比赛编号和得分等属性。
这样的话每张数据表都符合第二范式也就避免了异常情况的发生。某种程度上2NF是对1NF原子性的升级。1NF告诉我们字段属性需要是原子性的而2NF告诉我们一张表就是一个独立的对象也就是说一张表只表达一个意思。
**3NF在满足2NF的同时对任何非主属性都不传递依赖于候选键**。也就是说不能存在非主属性 A 依赖于非主属性 B非主属性 B 依赖于候选键的情况。
我们用球员player表举例子这张表包含的属性包括球员编号、姓名、球队名称和球队主教练。现在我们把属性之间的依赖关系画出来如下图所示
<img src="https://static001.geekbang.org/resource/image/42/33/4286e4c955589ab7145ee36ab135b933.jpg" alt=""><br>
你能看到球员编号决定了球队名称同时球队名称决定了球队主教练非主属性球队主教练就会传递依赖于球员编号因此不符合3NF的要求。
如果要达到3NF的要求需要把数据表拆成下面这样
球员表的属性包括球员编号、姓名和球队名称;球队表的属性包括球队名称、球队主教练。
我再总结一下1NF需要保证表中每个属性都保持原子性2NF需要保证表中的非主属性与候选键完全依赖3NF需要保证表中的非主属性与候选键不存在传递依赖。
## 总结
我们今天讲解了数据表设计的三种范式。关系型数据库的设计都是基于关系模型的在关系模型中存在着4种键这些键的核心作用就是标识。
在这些概念的基础上我又讲了1NF2NF和3NF。我们经常会与这三种范式打交道利用它们建立冗余度小、结构合理的数据库。
有一点需要注意的是,这些范式只是提出了设计的标准,实际上设计数据表时,未必要符合这些原则。一方面是因为这些范式本身存在一些问题,可能会带来插入,更新,删除等的异常情况(这些会在下一讲举例说明),另一方面,它们也可能降低会查询的效率。这是为什么呢?因为范式等级越高,设计出来的数据表就越多,进行数据查询的时候就可能需要关联多张表,从而影响查询效率。
<img src="https://static001.geekbang.org/resource/image/e7/11/e775113e733020a7810196afd4f58711.jpg" alt=""><br>
2NF和3NF相对容易混淆根据今天的内容你能说下这两个范式之间的区别吗另外如果我们现在有一张学生选课表包含的属性有学号、姓名、课程名称、分数、系别和系主任如果要改成符合3NF要求的设计需要怎么修改呢
欢迎你在评论区写下你的答案,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="22丨反范式设计3NF有什么不足为什么有时候需要反范式设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e0/4f/e03c1e077cc21e8e723c2bf04bdf754f.mp3"></audio>
上一篇文章中,我们介绍了数据表设计的三种范式。作为数据库的设计人员,理解范式的设计以及反范式优化是非常有必要的。
为什么这么说呢?了解以下几个方面的内容之后你就明白了。
1. 3NF有什么不足除了3NF我们为什么还需要BCNF
1. 有了范式设计,为什么有时候需要进行反范式设计?
1. 反范式设计适用的场景是什么?又可能存在哪些问题?
## BCNF巴斯范式
如果数据表的关系模式符合3NF的要求就不存在问题了吗我们来看下这张仓库管理关系warehouse_keeper表
<img src="https://static001.geekbang.org/resource/image/8b/17/8b543855d7c005b3e1b0ee3fbb308b17.png" alt=""><br>
在这个数据表中,一个仓库只有一个管理员,同时一个管理员也只管理一个仓库。我们先来梳理下这些属性之间的依赖关系。
仓库名决定了管理员,管理员也决定了仓库名,同时(仓库名,物品名)的属性集合可以决定数量这个属性。
这样,我们就可以找到数据表的候选键是(管理员,物品名)和(仓库名,物品名),
然后我们从候选键中选择一个作为主键,比如(仓库名,物品名)。
在这里,主属性是包含在任一候选键中的属性,也就是仓库名,管理员和物品名。非主属性是数量这个属性。
如何判断一张表的范式呢?我们需要根据范式的等级,从低到高来进行判断。
首先数据表每个属性都是原子性的符合1NF的要求其次数据表中非主属性”数量“都与候选键全部依赖仓库名物品名决定数量管理员物品名决定数量因此数据表符合2NF的要求最后数据表中的非主属性不传递依赖于候选键。因此符合3NF的要求。
既然数据表已经符合了3NF的要求是不是就不存在问题了呢我们来看下下面的情况
1. 增加一个仓库,但是还没有存放任何物品。根据数据表实体完整性的要求,主键不能有空值,因此会出现插入异常;
1. 如果仓库更换了管理员,我们就可能会修改数据表中的多条记录;
1. 如果仓库里的商品都卖空了,那么此时仓库名称和相应的管理员名称也会随之被删除。
你能看到即便数据表符合3NF的要求同样可能存在插入更新和删除数据的异常情况。
这种情况下该怎么解决呢?
首先我们需要确认造成异常的原因主属性仓库名对于候选键管理员物品名是部分依赖的关系这样就有可能导致上面的异常情况。人们在3NF的基础上进行了改进提出了**BCNF也叫做巴斯-科德范式它在3NF的基础上消除了主属性对候选键的部分依赖或者传递依赖关系**。
根据BCNF的要求我们需要把仓库管理关系warehouse_keeper表拆分成下面这样
仓库表:(仓库名,管理员)
库存表:(仓库名,物品名,数量)
这样就不存在主属性对于候选键的部分依赖或传递依赖上面数据表的设计就符合BCNF。
## 反范式设计
尽管围绕着数据表的设计有很多范式,但事实上,我们在设计数据表的时候却不一定要参照这些标准。
我们在之前已经了解了越高阶的范式得到的数据表越多,数据冗余度越低。但有时候,我们在设计数据表的时候,还需要为了性能和读取效率违反范式化的原则。反范式就是相对范式化而言的,换句话说,就是允许少量的冗余,通过空间来换时间。
如果我们想对查询效率进行优化,有时候反范式优化也是一种优化思路。
比如我们想要查询某个商品的前1000条评论会涉及到两张表。
商品评论表product_comment对应的字段名称及含义如下
<img src="https://static001.geekbang.org/resource/image/4c/b1/4c08f4fe07414ef26a48c1c0c52590b1.png" alt="">
用户表user对应的字段名称及含义如下
<img src="https://static001.geekbang.org/resource/image/7d/f2/7daa1d6b0f5b42cd79e024d49ecf91f2.png" alt=""><br>
下面,我们就用这两张表模拟一下反范式优化。
## 实验数据:模拟两张百万量级的数据表
为了更好地进行SQL优化实验我们需要给用户表和商品评论表随机模拟出百万量级的数据。我们可以通过存储过程来实现模拟数据。
下面是给用户表随机生成100万用户的代码
```
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_user`(IN start INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE date_start DATETIME DEFAULT ('2017-01-01 00:00:00');
DECLARE date_temp DATETIME;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, interval RAND()*60 second);
INSERT INTO user(user_id, user_name, create_time)
VALUES((start+i), CONCAT('user_',i), date_temp);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
```
我用date_start变量来定义初始的注册时间时间为2017年1月1日0点0分0秒然后用date_temp变量计算每个用户的注册时间新的注册用户与上一个用户注册的时间间隔为60秒内的随机值。然后使用REPEAT … UNTIL … END REPEAT循环对max_num个用户的数据进行计算。在循环前我们将autocommit设置为0这样等计算完成再统一插入执行效率更高。
然后我们来运行call insert_many_user(10000, 1000000);调用存储过程。这里需要通过start和max_num两个参数对初始的user_id和要创建的用户数量进行设置。运行结果
<img src="https://static001.geekbang.org/resource/image/e7/36/e716bfa9153fea2c4bc524946796a036.png" alt=""><br>
你能看到在MySQL里创建100万的用户数据用时1分37秒。
接着我们再来给商品评论表product_comment随机生成100万条商品评论。这里我们设置为给某一款商品评论比如product_id=10001。评论的内容为随机的20个字母。以下是创建随机的100万条商品评论的存储过程
```
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_product_comments`(IN START INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE date_start DATETIME DEFAULT ('2018-01-01 00:00:00');
DECLARE date_temp DATETIME;
DECLARE comment_text VARCHAR(25);
DECLARE user_id INT;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, INTERVAL RAND()*60 SECOND);
SET comment_text = substr(MD5(RAND()),1, 20);
SET user_id = FLOOR(RAND()*1000000);
INSERT INTO product_comment(comment_id, product_id, comment_text, comment_time, user_id)
VALUES((START+i), 10001, comment_text, date_temp, user_id);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
```
同样的我用date_start变量来定义初始的评论时间。这里新的评论时间与上一个评论的时间间隔还是60秒内的随机值商品评论表中的user_id为随机值。我们使用REPEAT … UNTIL … END REPEAT循环来对max_num个商品评论的数据进行计算。
然后调用存储过程,运行结果如下:
<img src="https://static001.geekbang.org/resource/image/23/c1/2346454ce5be3e50e1419960b15b60c1.png" alt=""><br>
MySQL一共花了2分7秒完成了商品评论数据的创建。
## 反范式优化实验对比
如果我们想要查询某个商品ID比如10001的前1000条评论需要写成下面这样
```
SELECT p.comment_text, p.comment_time, u.user_name FROM product_comment AS p
LEFT JOIN user AS u
ON p.user_id = u.user_id
WHERE p.product_id = 10001
ORDER BY p.comment_id DESC LIMIT 1000
```
运行结果1000条数据行
<img src="https://static001.geekbang.org/resource/image/18/ae/18b242e516e0e9ebd68ca7b7d1e1d9ae.png" alt=""><br>
运行时长为0.395秒,查询效率并不高。
这是因为在实际生活中我们在显示商品评论的时候通常会显示这个用户的昵称而不是用户ID因此我们还需要关联product_comment和user这两张表来进行查询。当表数据量不大的时候查询效率还好但如果表数据量都超过了百万量级查询效率就会变低。这是因为查询会在product_comment表和user表这两个表上进行聚集索引扫描然后再嵌套循环这样一来查询所耗费的时间就有几百毫秒甚至更多。对于网站的响应来说这已经很慢了用户体验会非常差。
如果我们想要提升查询的效率可以允许适当的数据冗余也就是在商品评论表中增加用户昵称字段在product_comment数据表的基础上增加user_name字段就得到了product_comment2数据表。
你可以在[百度网盘](https://pan.baidu.com/s/104t0vIlrA4nypu_PZIXG0w)中下载这三张数据表product_comment、product_comment2和user表密码为n3l8。
这样一来,只需单表查询就可以得到数据集结果:
```
SELECT comment_text, comment_time, user_name FROM product_comment2 WHERE product_id = 10001 ORDER BY comment_id DESC LIMIT 1000
```
运行结果1000条数据
<img src="https://static001.geekbang.org/resource/image/af/2c/af1be5874a9e20414de1ec48775e392c.png" alt=""><br>
优化之后只需要扫描一次聚集索引即可运行时间为0.039秒查询时间是之前的1/10。 你能看到,在数据量大的情况下,查询效率会有显著的提升。
## 反范式存在的问题&amp;适用场景
从上面的例子中可以看出,反范式可以通过空间换时间,提升查询的效率,但是反范式也会带来一些新问题。
在数据量小的情况下,反范式不能体现性能的优势,可能还会让数据库的设计更加复杂。比如采用存储过程来支持数据的更新、删除等额外操作,很容易增加系统的维护成本。
比如用户每次更改昵称的时候,都需要执行存储过程来更新,如果昵称更改频繁,会非常消耗系统资源。
那么反范式优化适用于哪些场景呢?
在现实生活中,我们经常需要一些冗余信息,比如订单中的收货人信息,包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照,需要进行保存,但用户可以随时修改自己的信息,这时保存这些冗余信息是非常有必要的。
当冗余信息有价值或者能大幅度提高查询效率的时候,我们就可以采取反范式的优化。
此外反范式优化也常用在数据仓库的设计中,因为数据仓库通常存储历史数据,对增删改的实时性要求不强,对历史数据的分析需求强。这时适当允许数据的冗余度,更方便进行数据分析。
我简单总结下数据仓库和数据库在使用上的区别:
1. 数据库设计的目的在于捕获数据,而数据仓库设计的目的在于分析数据;
1. 数据库对数据的增删改实时性要求强,需要存储在线的用户数据,而数据仓库存储的一般是历史数据;
1. 数据库设计需要尽量避免冗余,但为了提高查询效率也允许一定的冗余度,而数据仓库在设计上更偏向采用反范式设计。
## 总结
今天我们讲了BCNF它是基于3NF进行的改进。你能看到设计范式越高阶数据表就会越精细数据的冗余度也就越少在一定程度上可以让数据库在内部关联上更好地组织数据。但有时候我们也需要采用反范进行优化通过空间来换取时间。
范式本身没有优劣之分,只有适用场景不同。没有完美的设计,只有合适的设计,我们在数据表的设计中,还需要根据需求将范式和反范式混合使用。
<img src="https://static001.geekbang.org/resource/image/ac/fb/acbb07c269c85683cc981c7f677d32fb.jpg" alt=""><br>
我们今天举了一个反范式设计的例子,你在工作中是否有做过反范式设计的例子?欢迎你在评论区与我们一起分享,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,202 @@
<audio id="audio" title="23丨索引的概览用还是不用索引这是一个问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/0b/7fb9ac418c790c8e96c8c67c4681d30b.mp3"></audio>
提起优化SQL你可能会把它理解为优化索引。简单来说这也不算错索引在SQL优化中占了很大的比重。索引用得好可以将SQL查询的效率提升10倍甚至更多。但索引是万能的吗既然索引可以提升效率只要创建索引不就好了吗实际上在有些情况下创建索引反而会降低效率。
今天我们就来讲一下索引,索引涉及到的内容比较多,今天先来对索引有个整体的认知。
1. 什么情况下创建索引,什么时候不需要索引?
1. 索引的种类有哪些?
索引的原理很好理解在今天的内容里我依然会通过SQL查询实验验证今天的内容帮你进一步加深理解。
## 索引是万能的吗?
首先我们需要了解什么是索引Index。数据库中的索引就好比一本书的目录它可以帮我们快速进行特定值的定位与查找从而加快数据查询的效率。
索引就是帮助数据库管理系统高效获取数据的数据结构。
如果我们不使用索引就必须从第1条记录开始扫描直到把所有的数据表都扫描完才能找到想要的数据。既然如此如果我们想要快速查找数据就只需要创建更多的索引就好了呢
其实**索引不是万能的,在有些情况下使用索引反而会让效率变低**。
索引的价值是帮我们从海量数据中找到想要的数据,如果数据量少,那么是否使用索引对结果的影响并不大。
在数据表中的数据行数比较少的情况下比如不到1000行是不需要创建索引的。另外当数据重复度大比如高于10%的时候也不需要对这个字段使用索引。我之前讲到过如果是性别这个字段就不需要对它创建索引。这是为什么呢如果你想要在100万行数据中查找其中的50万行比如性别为男的数据一旦创建了索引你需要先访问50万次索引然后再访问50万次数据表这样加起来的开销比不使用索引可能还要大。
当然,空口无凭,我们来做两个实验,更直观地了解索引。
### 实验1数据行数少的情况下索引效率如何
我在[百度网盘](https://pan.baidu.com/s/1X47UAx6EWasYLLU91RYHKQ)上提供了数据表heros_without_index.sql 和 heros_with_index.sql提取码为wxho。
在第一个数据表中除了自增的id以外没有建立额外的索引。第二张数据表中我对name字段建立了唯一索引。
heros数据表一共有69个英雄数据量很少。当我们对name进行条件查询的时候我们观察一下创建索引前后的效率。
```
SELECT id, name, hp_max, mp_max FROM heros_without_index WHERE name = '刘禅'
```
运行结果1条数据运行时间0.072s
<img src="https://static001.geekbang.org/resource/image/c5/e4/c5e0f02544f241d45ccac40e70a56be4.png" alt=""><br>
我对name字段建立索引后再进行查询
```
SELECT id, name, hp_max, mp_max FROM heros_with_index WHERE name = '刘禅'
```
运行结果1条数据运行时间0.080s
<img src="https://static001.geekbang.org/resource/image/78/e8/782190bdc5120b40727fc8d28d9a35e8.png" alt=""><br>
你能看到运行结果相同但是创建了name字段索引的效率比没有创建索引时效率更低。在数据量不大的情况下索引就发挥不出作用了。
### 实验2性别男或女字段真的不应该创建索引吗
如果一个字段的取值少,比如性别这个字段,通常是不需要创建索引的。那么有没有特殊的情况呢?
下面我们来看一个例子假设有一个女儿国人口总数为100万人男性只有10个人也就是占总人口的10万分之1。
女儿国的人口数据表user_gender见百度网盘中的user_gender.sql。其中数据表中的user_gender字段取值为0或10代表女性1代表男性。
如果我们要筛选出这个国家中的男性,可以使用:
```
SELECT * FROM user_gender WHERE user_gender = 1
```
运行结果10条数据运行时间0.696s
<img src="https://static001.geekbang.org/resource/image/34/b4/34355803b19fb7f460cf0aad1d8bf2b4.png" alt=""><br>
你能看到在未创建索引的情况下运行的效率并不高。如果我们针对user_gender字段创建索引呢
```
SELECT * FROM user_gender WHERE user_gender = 1
```
同样是10条数据运行结果相同时间却缩短到了0.052s,大幅提升了查询的效率。
其实通过这两个实验你也能看出来索引的价值是帮你快速定位。如果想要定位的数据有很多那么索引就失去了它的使用价值比如通常情况下的性别字段。不过有时候我们还要考虑这个字段中的数值分布的情况在实验2中性别字段的数值分布非常特殊男性的比例非常少。
我们不仅要看字段中的数值个数,还要根据数值的分布情况来考虑是否需要创建索引。
## 索引的种类有哪些?
虽然使用索引的本质目的是帮我们快速定位想要查找的数据,但实际上,索引有很多种类。
从功能逻辑上说索引主要有4种分别是普通索引、唯一索引、主键索引和全文索引。
普通索引是基础的索引没有任何约束主要用于提高查询效率。唯一索引就是在普通索引的基础上增加了数据唯一性的约束在一张数据表里可以有多个唯一索引。主键索引在唯一索引的基础上增加了不为空的约束也就是NOT NULL+UNIQUE一张表里最多只有一个主键索引。全文索引用的不多MySQL自带的全文索引只支持英文。我们通常可以采用专门的全文搜索引擎比如ES(ElasticSearch)和Solr。
其实前三种索引(普通索引、唯一索引和主键索引)都是一类索引,只不过对数据的约束性逐渐提升。在一张数据表中只能有一个主键索引,这是由主键索引的物理实现方式决定的,因为数据存储在文件中只能按照一种顺序进行存储。但可以有多个普通索引或者多个唯一索引。
按照物理实现方式索引可以分为2种聚集索引和非聚集索引。我们也把非聚集索引称为二级索引或者辅助索引。
聚集索引可以按照主键来排序存储数据这样在查找行的时候非常有效。举个例子如果是一本汉语字典我们想要查找“数”这个字直接在书中找汉语拼音的位置即可也就是拼音“shu”。这样找到了索引的位置在它后面就是我们想要找的数据行。
非聚集索引又是什么呢?
在数据库系统会有单独的存储空间存放非聚集索引,这些索引项是按照顺序存储的,但索引项指向的内容是随机存储的。也就是说系统会进行两次查找,第一次先找到索引,第二次找到索引对应的位置取出数据行。非聚集索引不会把索引指向的内容像聚集索引一样直接放到索引的后面,而是维护单独的索引表(只维护索引,不维护索引指向的数据),为数据检索提供方便。我们还以汉语字典为例,如果想要查找“数”字,那么按照部首查找的方式,先找到“数”字的偏旁部首,然后这个目录会告诉我们“数”字存放到第多少页,我们再去指定的页码找这个字。
聚集索引指表中数据行按索引的排序方式进行存储,对查找行很有效。只有当表包含聚集索引时,表内的数据行才会按找索引列的值在磁盘上进行物理排序和存储。每一个表只能有一个聚集索引,因为数据行本身只能按一个顺序存储。
聚集索引与非聚集索引的原理不同,在使用上也有一些区别:
1. 聚集索引的叶子节点存储的就是我们的数据记录,非聚集索引的叶子节点存储的是数据位置。非聚集索引不会影响数据表的物理存储顺序。
1. 一个表只能有一个聚集索引,因为只能有一种排序存储的方式,但可以有多个非聚集索引,也就是多个索引目录提供数据检索。
1. 使用聚集索引的时候,数据的查询效率高,但如果对数据进行插入,删除,更新等操作,效率会比非聚集索引低。
### 实验3使用聚集索引和非聚集索引的查询效率
还是针对刚才的user_gender数据表我们来看下使用聚集索引和非聚集索引的查询效率有什么区别。在user_gender表中我设置了user_id为主键也就是聚集索引的字段是user_id。这里我们查询下user_id=90001的用户信息
```
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001
```
运行结果1条数据运行时间0.043s
<img src="https://static001.geekbang.org/resource/image/6a/d1/6a9d1c1ab9315f2c77b81799f08b6ed1.png" alt=""><br>
我们再直接对user_name字段进行条件查询此时user_name字段没有创建索引
```
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'
```
运行结果1条数据运行时间0.961s
<img src="https://static001.geekbang.org/resource/image/81/a0/817979acbd125773405604b8e86d06a0.png" alt=""><br>
你能看出对没有建立索引的字段进行条件查询,查询效率明显降低了。
然后我们对user_name字段创建普通索引进行SQL查询
```
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'
```
运行结果1条数据运行时间0.050s
<img src="https://static001.geekbang.org/resource/image/9f/0f/9f6be9d6ebeff6e574687923085a670f.png" alt=""><br>
通过对这3次SQL查询结果的对比我们可以总结出以下两点内容
1. 对WHERE子句的字段建立索引可以大幅提升查询效率。
1. 采用聚集索引进行数据查询,比使用非聚集索引的查询效率略高。如果查询次数比较多,还是尽量使用主键索引进行数据查询。
除了业务逻辑和物理实现方式,索引还可以按照字段个数进行划分,分成单一索引和联合索引。
索引列为一列时为单一索引;多个列组合在一起创建的索引叫做联合索引。
创建联合索引时,我们需要注意创建时的顺序问题,因为联合索引(x, y, z)和(z, y, x)在使用的时候效率可能会存在差别。
这里需要说明的是联合索引存在**最左匹配原则**,也就是按照最左优先的方式进行索引的匹配。比如刚才举例的(x, y, z)如果查询条件是WHERE x=1 AND y=2 AND z=3就可以匹配上联合索引如果查询条件是 WHERE y=2就无法匹配上联合索引。
### 实验4联合索引的最左原则
还是针对user_gender数据表我们把user_id和user_name字段设置为联合主键然后看下SQL查询效率有什么区别。
```
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001 AND user_name = 'student_890001'
```
运行结果1条数据运行时间0.046s
<img src="https://static001.geekbang.org/resource/image/70/7d/704d1018a33b819828c879eb983f2f7d.png" alt="">
```
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001
```
运行结果1条数据运行时间0.046s
<img src="https://static001.geekbang.org/resource/image/cc/e6/cc709a46717a58e22d25a2556b8fdee6.png" alt=""><br>
我们再来看下普通的条件查询是什么样子的:
```
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'
```
运行结果1条数据运行时间0.943s
<img src="https://static001.geekbang.org/resource/image/cc/e6/cc709a46717a58e22d25a2556b8fdee6.png" alt=""><br>
你能看到当我们使用了联合索引(user_id, user_name)的时候在WHERE子句中对联合索引中的字段user_id和user_name进行条件查询或者只对user_id进行查询效率基本上是一样的。当我们对user_name进行条件查询时效率就会降低很多这是因为根据联合索引的最左原则user_id在user_name的左侧如果没有使用user_id而是直接使用user_name进行条件查询联合索引就会失效。
## 总结
使用索引可以帮助我们从海量的数据中快速定位想要查找的数据,不过索引也存在一些不足,比如占用存储空间、降低数据库写操作的性能等,如果有多个索引还会增加索引选择的时间。当我们使用索引时,需要平衡索引的利(提升查询效率)和弊(维护索引所需的代价)。
在实际工作中,我们还需要基于需求和数据本身的分布情况来确定是否使用索引,尽管索引不是万能的,但数据量大的时候不使用索引是不可想象的,毕竟索引的本质,是帮助我们提升数据检索的效率。
<img src="https://static001.geekbang.org/resource/image/7c/57/7c46394b6a09ba83befe2d18e466c957.jpg" alt=""><br>
今天的内容到这里就结束了,给你留个问题。关于联合索引的最左原则指的是什么?在使用联合索引时,有哪些需要注意的地方呢?
欢迎你在评论区写下你的答案,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="24丨索引的原理我们为什么用B+树来做索引?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/31/4beb84455d0574fa7370522f05e19c31.mp3"></audio>
上节课我讲到了索引的作用,是否需要建立索引,以及建立什么样的索引,需要我们根据实际情况进行选择。我之前说过,索引其实就是一种数据结构,那么今天我们就来看下,索引的数据结构究竟是怎样的?对索引底层的数据结构有了更深入的了解后,就会更了解索引的使用原则。
今天的文章内容主要包括下面几个部分:
1. 为什么索引要存放到硬盘上?如何评价索引的数据结构设计的好坏?
1. 使用平衡二叉树作为索引的数据结构有哪些不足?
1. B树和B+树的结构是怎样的为什么我们常用B+树作为索引的数据结构?
## 如何评价索引的数据结构设计好坏
数据库服务器有两种存储介质,分别为硬盘和内存。内存属于临时存储,容量有限,而且当发生意外时(比如断电或者发生故障重启)会造成数据丢失;硬盘相当于永久存储介质,这也是为什么我们需要把数据保存到硬盘上。
虽然内存的读取速度很快但我们还是需要将索引存放到硬盘上这样的话当我们在硬盘上进行查询时也就产生了硬盘的I/O操作。相比于内存的存取来说硬盘的I/O存取消耗的时间要高很多。我们通过索引来查找某行数据的时候需要计算产生的磁盘I/O次数当磁盘I/O次数越多所消耗的时间也就越大。如果我们能让索引的数据结构尽量减少硬盘的I/O操作所消耗的时间也就越小。
## 二叉树的局限性
二分查找法是一种高效的数据检索方式时间复杂度为O(log2n),是不是采用二叉树就适合作为索引的数据结构呢?
我们先来看下最基础的二叉搜索树Binary Search Tree搜索某个节点和插入节点的规则一样我们假设搜索插入的数值为key
1. 如果key大于根节点则在右子树中进行查找
1. 如果key小于根节点则在左子树中进行查找
1. 如果key等于根节点也就是找到了这个节点返回根节点即可。
举个例子我们对数列3422895237791创造出来的二分查找树如下图所示
<img src="https://static001.geekbang.org/resource/image/19/69/19dedac56fdba8e7119352e84eb7af69.jpg" alt=""><br>
但是存在特殊的情况,就是有时候二叉树的深度非常大。比如我们给出的数据顺序是(5, 22, 23, 34, 77, 89, 91),创造出来的二分搜索树如下图所示:
<img src="https://static001.geekbang.org/resource/image/ae/33/aedbdcc05f4a05177f1b599a59581133.jpg" alt=""><br>
你能看出来第一个树的深度是3也就是说最多只需3次比较就可以找到节点而第二个树的深度是7最多需要7次比较才能找到节点。
第二棵树也属于二分查找树但是性能上已经退化成了一条链表查找数据的时间复杂度变成了O(n)。为了解决这个问题人们提出了平衡二叉搜索树AVL树它在二分搜索树的基础上增加了约束每个节点的左子树和右子树的高度差不能超过1也就是说节点的左子树和右子树仍然为平衡二叉树。
这里说一下常见的平衡二叉树有很多种包括了平衡二叉搜索树、红黑树、数堆、伸展树。平衡二叉搜索树是最早提出来的自平衡二叉搜索树当我们提到平衡二叉树时一般指的就是平衡二叉搜索树。事实上第一棵树就属于平衡二叉搜索树搜索时间复杂度就是O(log2n)。
我刚才提到过数据查询的时间主要依赖于磁盘I/O的次数如果我们采用二叉树的形式即使通过平衡二叉搜索树进行了改进树的深度也是O(log2n)当n比较大时深度也是比较高的比如下图的情况
<img src="https://static001.geekbang.org/resource/image/78/ea/78154f20220d6fedb95ebbac61bd5cea.jpg" alt=""><br>
每访问一次节点就需要进行一次磁盘I/O操作对于上面的树来说我们需要进行5次I/O操作。虽然平衡二叉树比较的效率高但是树的深度也同样高这就意味着磁盘I/O操作次数多会影响整体数据查询的效率。
针对同样的数据如果我们把二叉树改成M叉树M&gt;2当M=3时同样的31个节点可以由下面的三叉树来进行存储
<img src="https://static001.geekbang.org/resource/image/64/c4/6458c1f525befd735d3ce420b10729c4.jpg" alt=""><br>
你能看到此时树的高度降低了当数据量N大的时候以及树的分叉数M大的时候M叉树的高度会远小于二叉树的高度。
## 什么是B树
如果用二叉树作为索引的实现结构会让树变得很高增加硬盘的I/O次数影响数据查询的时间。因此一个节点就不能只有2个子节点而应该允许有M个子节点(M&gt;2)。
B树的出现就是为了解决这个问题B树的英文是Balance Tree也就是平衡的多路搜索树它的高度远小于平衡二叉树的高度。在文件系统和数据库系统中的索引结构经常采用B树来实现。
B树的结构如下图所示
<img src="https://static001.geekbang.org/resource/image/18/44/18031c20f9a4be3e858743ed99f3c144.jpg" alt=""><br>
B树作为平衡的多路搜索树它的每一个节点最多可以包括M个子节点M称为B树的阶。同时你能看到每个磁盘块中包括了关键字和子节点的指针。如果一个磁盘块中包括了x个关键字那么指针数就是x+1。对于一个100阶的B树来说如果有3层的话最多可以存储约100万的索引数据。对于大量的索引数据来说采用B树的结构是非常适合的因为树的高度要远小于二叉树的高度。
一个M阶的B树M&gt;2有以下的特性
1. 根节点的儿子数的范围是[2,M]。
1. 每个中间节点包含k-1个关键字和k个孩子孩子的数量=关键字的数量+1k的取值范围为[ceil(M/2), M]。
1. 叶子节点包括k-1个关键字叶子节点没有孩子k的取值范围为[ceil(M/2), M]。
1. 假设中间节点节点的关键字为Key[1], Key[2], …, Key[k-1]且关键字按照升序排序即Key[i]&lt;Key[i+1]。此时k-1个关键字相当于划分了k个范围也就是对应着k个指针即为P[1], P[2], …, P[k]其中P[1]指向关键字小于Key[1]的子树P[i]指向关键字属于(Key[i-1], Key[i])的子树P[k]指向关键字大于Key[k-1]的子树。
1. 所有叶子节点位于同一层。
上面那张图所表示的B树就是一棵3阶的B树。我们可以看下磁盘块2里面的关键字为812它有3个孩子(35)(910) 和 (1315),你能看到(35)小于8(910)在8和12之间而(1315)大于12刚好符合刚才我们给出的特征。
然后我们来看下如何用B树进行查找。假设我们想要查找的关键字是9那么步骤可以分为以下几步
1. 我们与根节点的关键字(1735进行比较9小于17那么得到指针P1
1. 按照指针P1找到磁盘块2关键字为812因为9在8和12之间所以我们得到指针P2
1. 按照指针P2找到磁盘块6关键字为910然后我们找到了关键字9。
你能看出来在B树的搜索过程中我们比较的次数并不少但如果把数据读取出来然后在内存中进行比较这个时间就是可以忽略不计的。而读取磁盘块本身需要进行I/O操作消耗的时间比在内存中进行比较所需要的时间要多是数据查找用时的重要因素B树相比于平衡二叉树来说磁盘I/O操作要少在数据查询中比平衡二叉树效率要高。
### 什么是B+树
B+树基于B树做出了改进主流的DBMS都支持B+树的索引方式比如MySQL。B+树和B树的差异在于以下几点
1. 有 k 个孩子的节点就有k个关键字。也就是孩子数量=关键字数而B树中孩子数量=关键字数+1。
1. 非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。
1. 非叶子节点仅用于索引不保存数据记录跟记录有关的信息都放在叶子节点中。而B树中非叶子节点既保存索引也保存数据记录。
1. 所有关键字都在叶子节点出现,叶子节点构成一个有序链表,而且叶子节点本身按照关键字的大小从小到大顺序链接。
下图就是一棵B+树阶数为3根节点中的关键字1、18、35分别是子节点1814182431354153中的最小值。每一层父节点的关键字都会出现在下一层的子节点的关键字中因此在叶子节点中包括了所有的关键字信息并且每一个叶子节点都有一个指向下一个节点的指针这样就形成了一个链表。
<img src="https://static001.geekbang.org/resource/image/55/32/551171d94a69fbbfc00889f8b1f45932.jpg" alt=""><br>
比如我们想要查找关键字16B+树会自顶向下逐层进行查找:
1. 与根节点的关键字(11835)进行比较16在1和18之间得到指针P1指向磁盘块2
1. 找到磁盘块2关键字为1814因为16大于14所以得到指针P3指向磁盘块7
1. 找到磁盘块7关键字为141617然后我们找到了关键字16所以可以找到关键字16所对应的数据。
整个过程一共进行了3次I/O操作看起来B+树和B树的查询过程差不多但是B+树和B树有个根本的差异在于B+树的中间节点并不直接存储数据。这样的好处都有什么呢?
首先B+树查询效率更稳定。因为B+树每次只有访问到叶子节点才能找到对应的数据而在B树中非叶子节点也会存储数据这样就会造成查询效率不稳定的情况有时候访问到了非叶子节点就可以找到关键字而有时需要访问到叶子节点才能找到关键字。
其次B+树的查询效率更高这是因为通常B+树比B树更矮胖阶数更大深度更低查询所需要的磁盘I/O也会更少。同样的磁盘页大小B+树可以存储更多的节点关键字。
不仅是对单个关键字的查询上在查询范围上B+树的效率也比B树高。这是因为所有关键字都出现在B+树的叶子节点中并通过有序链表进行了链接。而在B树中则需要通过中序遍历才能完成查询范围的查找效率要低很多。
## 总结
磁盘的I/O操作次数对索引的使用效率至关重要。虽然传统的二叉树数据结构查找数据的效率高但很容易增加磁盘I/O操作的次数影响索引使用的效率。因此在构造索引的时候我们更倾向于采用“矮胖”的数据结构。
B树和B+树都可以作为索引的数据结构在MySQL中采用的是B+树B+树在查询性能上更稳定在磁盘页大小相同的情况下树的构造更加矮胖所需要进行的磁盘I/O次数更少更适合进行关键字的范围查询。
<img src="https://static001.geekbang.org/resource/image/92/90/922bfe97e007d24f4467f5af4e1a0790.jpg" alt=""><br>
今天我们对索引的底层数据结构进行了学习你能说下为什么数据库索引采用B+树而不是平衡二叉搜索树吗另外B+树和B树在构造和查询性能上有什么差异呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="25丨Hash索引的底层原理是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/c5/05fb7f595c3c71cb071d442e8d43bfc5.mp3"></audio>
我们上节课讲解了B+树的原理今天我们来学习下Hash的原理和使用。Hash本身是一个函数又被称为散列函数它可以帮助我们大幅提升检索数据的效率。打个比方Hash就好像一个智能前台你只要告诉它想要查找的人的姓名它就会告诉你那个人坐在哪个位置只需要一次交互就可以完成查找效率非常高。大名鼎鼎的MD5就是Hash函数的一种。
Hash算法是通过某种确定性的算法比如MD5、SHA1、SHA2、SHA3将输入转变为输出。相同的输入永远可以得到相同的输出假设输入内容有微小偏差在输出中通常会有不同的结果。如果你想要验证两个文件是否相同那么你不需要把两份文件直接拿来比对只需要让对方把Hash函数计算得到的结果告诉你即可然后在本地同样对文件进行Hash函数的运算最后通过比较这两个Hash函数的结果是否相同就可以知道这两个文件是否相同。
Hash可以高效地帮我们完成验证的工作它在数据库中有广泛的应用。今天的课程主要包括下面几个部分
1. 动手写程序统计一下Hash检索的效率。
1. 了解MySQL中的Hash索引理解使用它的优点和不足。
1. Hash索引和B+树索引的区别以及使用场景。
## 动手统计Hash检索效率
我们知道Python的数据结构中有数组和字典两种其中数组检索数据类似于全表扫描需要对整个数组的内容进行检索而字典是由Hash表实现的存储的是key-value值对于数据检索来说效率非常快。
对于Hash的检索效率我们来个更直观的认知。下面我们分别看一下采用数组检索数据和采用字典Hash检索数据的效率到底有怎样的差别。
实验1在数组中添加10000个元素然后分别对这10000个元素进行检索最后统计检索的时间。
代码如下:
```
import time
# 插入数据
result = []
for i in range(10000):
result.append(i)
# 检索数据
time_start=time.time()
for i in range(10000):
temp = result.index(i)
time_end=time.time()
print('检索时间', time_end-time_start)
```
运行结果:
检索时间为1.2436728477478027秒
实验2采用Hash表的形式存储数据即在Python中采用字典方式添加10000个元素然后检索这10000个数据最后再统计一下时间。代码如下
```
import time
# 插入数据
result = {}
for i in range(1000000):
result[i] = i
# 检索数据
time_start=time.time()
for i in range(10000):
temp = result[i]
time_end=time.time()
print('检索时间:',time_end-time_start)
```
运行结果:
检索时间为0.0019941329956054688秒。
你能看到Hash方式检索差不多用了2毫秒的时间检索效率提升得非常明显。这是因为Hash只需要一步就可以找到对应的取值算法复杂度为O(1)而数组检索数据的算法复杂度为O(n)。
## MySQL中的Hash索引
采用Hash进行检索效率非常高基本上一次检索就可以找到数据而B+树需要自顶向下依次查找多次访问节点才能找到数据中间需要多次I/O操作从效率来说Hash比B+树更快。
我们来看下Hash索引的示意图
<img src="https://static001.geekbang.org/resource/image/d8/b6/d8ef0bc1ea85b9e5408fcf0126b2a2b6.png" alt=""><br>
键值key通过Hash映射找到桶bucket。在这里桶bucket指的是一个能存储一条或多条记录的存储单位。一个桶的结构包含了一个内存指针数组桶中的每行数据都会指向下一行形成链表结构当遇到Hash冲突时会在桶中进行键值的查找。
那么什么是Hash冲突呢
如果桶的空间小于输入的空间不同的输入可能会映射到同一个桶中这时就会产生Hash冲突如果Hash冲突的量很大就会影响读取的性能。
通常Hash值的字节数比较少简单的4个字节就够了。在Hash值相同的情况下就会进一步比较桶Bucket中的键值从而找到最终的数据行。
Hash值的字节数多的话可以是16位、32位等比如采用MD5函数就可以得到一个16位或者32位的数值32位的MD5已经足够安全重复率非常低。
我们模拟一下Hash索引。关键字如下所示每个字母的内部编码为字母的序号比如A为01Y为25。我们统计内部编码平方的第8-11位从前向后作为Hash值
<img src="https://static001.geekbang.org/resource/image/6b/3d/6bff085844127931e59e6faa368e223d.png" alt="">
## Hash索引与B+树索引的区别
我们之前讲到过B+树索引的结构Hash索引结构和B+树的不同,因此在索引使用上也会有差别。
1. Hash索引不能进行范围查询而B+树可以。这是因为Hash索引指向的数据是无序的而B+树的叶子节点是个有序的链表。
1. Hash索引不支持联合索引的最左侧原则即联合索引的部分索引无法使用而B+树可以。对于联合索引来说Hash索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值所以不会针对每个索引单独计算Hash值。因此如果用到联合索引的一个或者几个索引时联合索引无法被利用。
1. Hash索引不支持ORDER BY排序因为Hash索引指向的数据是无序的因此无法起到排序优化的作用而B+树索引数据是有序的可以起到对该字段ORDER BY排序优化的作用。同理我们也无法用Hash索引进行模糊查询而B+树使用LIKE进行模糊查询的时候LIKE后面前模糊查询比如%开头)的话就可以起到优化作用。
对于等值查询来说通常Hash索引的效率更高不过也存在一种情况就是索引列的重复值如果很多效率就会降低。这是因为遇到Hash冲突时需要遍历桶中的行指针来进行比较找到查询的关键字非常耗时。所以Hash索引通常不会用到重复值多的列上比如列为性别、年龄的情况等。
## 总结
我今天讲了Hash索引的底层原理你能看到Hash索引存在着很多限制相比之下在数据库中B+树索引的使用面会更广不过也有一些场景采用Hash索引效率更高比如在键值型Key-Value数据库中Redis存储的核心就是Hash表。
另外MySQL中的Memory存储引擎支持Hash存储如果我们需要用到查询的临时表时就可以选择Memory存储引擎把某个字段设置为Hash索引比如字符串类型的字段进行Hash计算之后长度可以缩短到几个字节。当字段的重复度低而且经常需要进行等值查询的时候采用Hash索引是个不错的选择。
另外MySQL的InnoDB存储引擎还有个“自适应Hash索引”的功能就是当某个索引值使用非常频繁的时候它会在B+树索引的基础上再创建一个Hash索引这样让B+树也具备了Hash索引的优点。
<img src="https://static001.geekbang.org/resource/image/88/90/8893fcfee2c8c374e9c7ae7e66f2cf90.jpg" alt=""><br>
今天的内容到这里就结束了我留两道思考题吧。查找某个固定值时Hash索引比B+树更快为什么MySQL还要采用B+树的存储索引呢另外当两个关键字的Hash值相同时会发生什么
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,342 @@
<audio id="audio" title="26丨索引的使用原则如何通过索引让SQL查询效率最大化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/09/76d7fd6cfa00f429f1f30313b193de09.mp3"></audio>
我之前讲了索引的使用和它的底层原理今天我来讲一讲索引的使用原则。既然我们的目标是提升SQL的查询效率那么该如何通过索引让效率最大化呢
今天的课程主要包括下面几个部分:
1. 什么情况下使用索引?当我们进行数据表查询的时候,都有哪些特征需要我们创建索引?
1. 索引不是万能的,索引设计的不合理可能会阻碍数据库和业务处理的性能。那么什么情况下不需要创建索引?
1. 创建了索引不一定代表一定用得上,甚至在有些情况下索引会失效。哪些情况下,索引会失效呢?又该如何避免这一情况?
## 创建索引有哪些规律?
创建索引有一定的规律。当这些规律出现的时候,我们就可以通过创建索引提升查询效率,下面我们来看看什么情况下可以创建索引:
**1.字段的数值有唯一性的限制,比如用户名**
索引本身可以起到约束的作用,比如唯一索引、主键索引都是可以起到唯一性约束的,因此在我们的数据表中,如果某个字段是唯一性的,就可以直接创建唯一性索引,或者主键索引。
**2.频繁作为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
```
运行结果:
<img src="https://static001.geekbang.org/resource/image/3b/25/3b79db6d310ade55958199cc60f16525.png" alt=""><br>
运行时间为0.699s你能看到查询效率还是比较低的。当我们对user_id字段创建索引之后运行时间为0.047s不到原来查询时间的1/10效率提升还是明显的。
**3.需要经常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
<img src="https://static001.geekbang.org/resource/image/19/31/196f6848455aff7fe8b2a60b1fc81731.png" alt=""><br>
如果我们对user_id创建索引再执行SQL语句
```
SELECT user_id, count(*) as num FROM product_comment group by user_id limit 100
```
运行结果100条记录运行时间0.042s
<img src="https://static001.geekbang.org/resource/image/61/73/616c6c2dec1e700f80815e9bf34c5a73.png" alt=""><br>
你能看到当对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
```
运行结果(运行时间&gt;100s
<img src="https://static001.geekbang.org/resource/image/2e/f4/2ee78c19372613d84c4b10cf27037ef4.png" alt=""><br>
实际上多个单列索引在多条件查询时只会生效一个索引MySQL会选择其中一个限制最严格的作为索引所以在多条件联合查询的时候最好创建联合索引。在这个例子中我们创建联合索引(user_id, comment_time)再来看下查询的时间查询时间为0.775s,效率提升了很多。如果我们创建联合索引的顺序为(comment_time, user_id)呢运行时间为1.990s,同样比两个单列索引要快,但是会比顺序为(user_id, comment_time)的索引要慢一些。这是因为在进行SELECT查询的时候先进行GROUP BY再对数据进行ORDER BY的操作所以按照这个联合索引的顺序效率是最高的。
<img src="https://static001.geekbang.org/resource/image/bd/5a/bd960478632418c1e99fc0915398425a.png" alt=""><br>
**4.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条件列检索出来这条记录然后再对它进行更新或删除。如果进行更新的时候更新的字段是非索引字段提升的效率会更明显这是因为非索引字段更新不需要对索引进行维护。
不过在实际工作中,我们也需要注意平衡,如果索引太多了,在更新数据的时候,如果涉及到索引更新,就会造成负担。
**5.DISTINCT字段需要创建索引**
有时候我们需要对某个字段进行去重使用DISTINCT那么对这个字段创建索引也会提升查询效率。
比如我们想要查询商品评论表中不同的user_id都有哪些如果我们没有对user_id创建索引执行SQL语句看看情况是怎样的。
```
SELECT DISTINCT(user_id) FROM `product_comment`
```
运行结果600637条记录运行时间2.283s
<img src="https://static001.geekbang.org/resource/image/38/d2/38f7685bc8befad4bb6e5c8d5c6f7ed2.png" alt=""><br>
如果我们对user_id创建索引再执行SQL语句看看情况又是怎样的。
```
SELECT DISTINCT(user_id) FROM `product_comment`
```
运行结果600637条记录运行时间0.627s
<img src="https://static001.geekbang.org/resource/image/c2/5e/c2b42b9308735a141848c176294d0d5e.png" alt=""><br>
你能看到SQL查询效率有了提升同时显示出来的user_id还是按照递增的顺序进行展示的。这是因为索引会对数据按照某种顺序进行排序所以在去重的时候也会快很多。
**6.做多表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
<img src="https://static001.geekbang.org/resource/image/d8/13/d8d0804d09f264db7846209498ddb813.png" alt=""><br>
这里我们对comment_text创建索引再执行上面的SQL语句运行时间为0.046s。
如果我们不使用WHERE条件查询而是直接采用JOIN…ON…进行连接的话即使使用了各种优化手段总的运行时间也会很长&gt;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个英雄不用索引也很快表记录太少是否创建索引对查询效率的影响并不大。
第三种情况是,字段中如果有大量重复数据,也不用创建索引,比如性别字段。不过我们也需要根据实际情况来做判断,这一点我在之前的文章里已经进行了说明,这里不再赘述。
最后一种情况是,频繁更新的字段不一定要创建索引。因为更新数据的时候,也需要更新索引,如果索引太多,在更新索引的时候也会造成负担,从而影响效率。
## 什么情况下索引失效
我们创建了索引,还要避免索引失效,你可以先思考下都有哪些情况会造成索引失效呢?下面是一些常见的索引失效的例子:
**1.如果索引进行了表达式计算,则会失效**
我们可以使用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秒。
**2.如果对索引使用函数,也会造成失效**
比如我们想要对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 |
+----+-------------+-----------------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+
```
你能看到经过查询重写后,可以使用索引进行范围检索,从而提升查询效率。
**3.在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分别进行了扫描然后将这两个结果集进行了合并。这样做的好处就是避免了全表扫描。
**4.当我们使用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 |
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
```
这个很好理解,如果一本字典按照字母顺序进行排序,我们会从首位开始进行匹配,而不会对中间位置进行匹配,否则索引就失效了。
**5.索引列尽量设置为NOT NULL约束。**
[MySQL官方文档](https://dev.mysql.com/doc/refman/5.5/en/data-size.html)建议我们尽量将数据表的字段设置为NOT NULL约束这样做的好处是可以更好地使用索引节省空间甚至加速SQL的运行。<br>
判断索引列是否为NOT NULL往往需要走全表扫描因此我们最好在设计数据表的时候就将字段设置为NOT NULL约束比如你可以将INT类型的字段默认值设置为0。将字符类型的默认值设置为空字符串(`''`)。
**6.我们在使用联合索引的时候要注意最左原则**
最左原则也就是需要从左到右的使用索引中的字段一条SQL语句可以只使用联合索引的一部分但是需要从最左侧开始否则就会失效。我在讲联合索引的时候举过索引失效的例子。
## 总结
今天我们对索引的使用原则进行了梳理使用好索引可以提升SQL查询的效率但同时 也要注意索引不是万能的。为了避免全表扫描,我们还需要注意有哪些情况可能会导致索引失效,这时就需要进行查询重写,让索引发挥作用。
实际工作中查询的需求多种多样创建的索引也会越来越多。这时还需要注意我们要尽可能扩展索引而不是新建索引因为索引数量过多需要维护的成本也会变大导致写效率变低。同时我们还需要定期查询使用率低的索引对于从未使用过的索引可以进行删除这样才能让索引在SQL查询中发挥最大价值。
<img src="https://static001.geekbang.org/resource/image/81/f4/81147e99e2533126533500a087086ef4.jpg" alt=""><br>
针对product_comment数据表其中comment_time已经创建了普通索引。假设我想查询评论时间在2018年10月1日上午10点到2018年10月2日上午10点之间的评论SQL语句为
```
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE DATE(comment_time) &gt;= '2018-10-01 10:00:00' AND comment_time &lt;= '2018-10-02 10:00:00'
```
你可以想一下这时候索引是否会失效,为什么?如果失效的话,要进行查询重写,应该怎样写?
欢迎你在评论区写下你的答案,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,126 @@
<audio id="audio" title="27丨从数据页的角度理解B+树查询" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/92/72959d1159c0805ae1fd365f0a35b392.mp3"></audio>
我们之前已经了解了B+树和Hash索引的原理这些索引结构给我们提供了高效的索引方式不过这些索引信息以及数据记录都是保存在文件上的确切说是存储在页结构中。
对数据库的存储结构以及页结构的底层进行了解可以加深我们对索引运行机制的认识从而你对索引的存储、查询原理以及对SQL查询效率有更深的理解。
今天的课程主要包括下面几个部分:
1. 数据库中的存储结构是怎样的?页、区、段和表空间分别指的是什么?
1. 为什么页Page是数据库存储空间的基本单位
1. 从数据页的角度来看B+树是如何进行查询的?
## 数据库中的存储结构是怎样的
记录是按照行来存储的但是数据库的读取并不以行为单位否则一次读取也就是一次I/O操作只能处理一行数据效率会非常低。因此**在数据库中不论读一行还是读多行都是将这些行所在的页进行加载。也就是说数据库管理存储空间的基本单位是页Page。**
一个页中可以存储多个行记录Row同时在数据库中还存在着区Extent、段Segment和表空间Tablespace。行、页、区、段、表空间的关系如下图所示
<img src="https://static001.geekbang.org/resource/image/11/b7/112d7669450e3968e63e9de524ab13b7.jpg" alt=""><br>
从图中你能看到一个表空间包括了一个或多个段,一个段包括了一个或多个区,一个区包括了多个页,而一个页中可以有多行记录,这些概念我简单给你讲解下。
Extent是比页大一级的存储结构在InnoDB存储引擎中一个区会分配64个连续的页。因为InnoDB中的页大小默认是16KB所以一个区的大小是64*16KB=1MB。
Segment由一个或多个区组成区在文件系统是一个连续分配的空间在InnoDB中是连续的64个页不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候就会相应创建对应的段比如创建一张表时会创建一个表段创建一个索引时会创建一个索引段。
表空间Tablespace是一个逻辑容器表空间存储的对象是段在一个表空间中可以有一个或多个段但是一个段只能属于一个表空间。数据库由一个或多个表空间组成表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。
在InnoDB中存在两种表空间的类型共享表空间和独立表空间。如果是共享表空间就意味着多张表共用一个表空间。如果是独立表空间就意味着每张表有一个独立的表空间也就是数据和索引信息都会保存在自己的表空间中。独立的表空间可以在不同的数据库之间进行迁移。
你可以通过下面的命令来查看InnoDB的表空间类型
```
mysql &gt; show variables like 'innodb_file_per_table';
```
<img src="https://static001.geekbang.org/resource/image/b3/2f/b3b3d8f54a10dfb17005df9bd275502f.png" alt=""><br>
你能看到innodb_file_per_table=ON这就意味着每张表都会单独保存为一个.ibd文件。
## 数据页内的结构是怎样的
Page如果按类型划分的话常见的有数据页保存B+树节点、系统页、Undo页和事务数据页等。数据页是我们最常使用的页。
表页的大小限定了表行的最大长度不同DBMS的表页大小不同。比如在MySQL的InnoDB存储引擎中默认页的大小是16KB我们可以通过下面的命令来进行查看
```
mysql&gt; show variables like '%innodb_page_size%';
```
<img src="https://static001.geekbang.org/resource/image/2e/16/2e5a0928bdc9ca3d18421f9db1eda416.png" alt=""><br>
在SQL Server的页大小为8KB而在Oracle中我们用术语“块”Block来代表“页”Oralce支持的块大小为2KB4KB8KB16KB32KB和64KB。
数据库I/O操作的最小单位是页与数据库相关的内容都会存储在页结构里。数据页包括七个部分分别是文件头File Header、页头Page Header、最大最小记录Infimum+supremum、用户记录User Records、空闲空间Free Space、页目录Page Directory和文件尾File Tailer
页结构的示意图如下所示:
<img src="https://static001.geekbang.org/resource/image/94/53/9490bd9641f6a9be208a6d6b2d1b1353.jpg" alt=""><br>
这7个部分到底有什么作用呢我简单梳理下
<img src="https://static001.geekbang.org/resource/image/e9/9f/e9508936a6d79f4635ecf5d5fea4149f.png" alt="">
实际上我们可以把这7个数据页分成3个部分。
首先是文件通用部分,也就是文件头和文件尾。它们类似集装箱,将页的内容进行封装,通过文件头和文件尾校验的方式来确保页的传输是完整的。
在文件头中有两个字段分别是FIL_PAGE_PREV和FIL_PAGE_NEXT它们的作用相当于指针分别指向上一个数据页和下一个数据页。连接起来的页相当于一个双向的链表如下图所示
<img src="https://static001.geekbang.org/resource/image/34/dd/3457fd927f1fc022cb062457bd823cdd.jpg" alt=""><br>
需要说明的是采用链表的结构让数据页之间不需要是物理上的连续,而是逻辑上的连续。
我们之前讲到过Hash算法这里文件尾的校验方式就是采用Hash算法进行校验。举个例子当我们进行页传输的时候如果突然断电了造成了该页传输的不完整这时通过文件尾的校验和checksum值与文件头的校验和做比对如果两个值不相等则证明页的传输有问题需要重新进行传输否则认为页的传输已经完成。
第二个部分是记录部分,页的主要作用是存储记录,所以“最小和最大记录”和“用户记录”部分占了页结构的主要空间。另外空闲空间是个灵活的部分,当有新的记录插入时,会从空闲空间中进行分配用于存储新记录,如下图所示:
<img src="https://static001.geekbang.org/resource/image/1a/22/1a9ce654a978aea20a51a7e357a2a322.jpg" alt=""><br>
第三部分是索引部分,这部分重点指的是页目录,它起到了记录的索引作用,因为在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索,因此在页目录中提供了二分查找的方式,用来提高记录的检索效率。这个过程就好比是给记录创建了一个目录:
1. 将所有的记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录。
1. 第1组也就是最小记录所在的分组只有1个记录最后一组就是最大记录所在的分组会有1-8条记录其余的组记录数量在4-8条之间。这样做的好处是除了第1组最小记录所在组以外其余组的记录数会尽量平分。
1. 在每个组中最后一条记录的头信息中会存储该组一共有多少条记录作为n_owned字段。
1. 页目录用来存储每组最后一条记录的地址偏移量这些地址偏移量会按照先后顺序存储起来每组的地址偏移量也被称之为槽slot每个槽相当于指针指向了不同组的最后一个记录。如下图所示
<img src="https://static001.geekbang.org/resource/image/cc/77/ccfaffc92b9414db3fe68d2ad9df2577.jpg" alt=""><br>
页目录存储的是槽槽相当于分组记录的索引。我们通过槽查找记录实际上就是在做二分查找。这里我以上面的图示进行举例5个槽的编号分别为01234我想查找主键为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存储行记录。
1. 非叶子节点节点的高度大于0存储索引键和页面指针并不存储行记录本身。
<img src="https://static001.geekbang.org/resource/image/a8/3f/a83a47f8f6a341835fa08d33ff18093f.jpg" alt=""><br>
我们刚才学习了页结构的内容,你可以用**页结构对比看下B+树的结构**。
在一棵B+树中,每个节点都是一个页,每次新建节点的时候,就会申请一个页空间。同一层上的节点之间,通过页的结构构成一个双向的链表(页文件头中的两个指针字段)。非叶子节点,包括了多个索引行,每个索引行里存储索引键和指向下一层页面的页面指针。最后是叶子节点,它存储了关键字和行记录,在节点内部(也就是页结构的内部)记录之间是一个单向的链表,但是对记录进行查找,则可以通过页目录采用二分查找的方式来进行。
当我们从页结构来理解B+树的结构的时候,可以帮我们理解一些通过索引进行检索的原理:
**1.B+树是如何进行记录检索的?**
如果通过B+树的索引查询行记录首先是从B+树的根开始逐层检索直到找到叶子节点也就是找到对应的数据页为止将数据页加载到内存中页目录中的槽slot采用二分查找的方式先找到一个粗略的记录分组然后再在分组中通过链表遍历的方式查找记录。
**2.普通索引和唯一索引在查询效率上有什么不同?**
我们创建索引的时候可以是普通索引,也可以是唯一索引,那么这两个索引在查询效率上有什么不同呢?
唯一索引就是在普通索引上增加了约束性也就是关键字唯一找到了关键字就停止检索。而普通索引可能会存在用户记录中的关键字相同的情况根据页结构的原理当我们读取一条记录的时候不是单独将这条记录从磁盘中读出去而是将这个记录所在的页加载到内存中进行读取。InnoDB存储引擎的页大小为16KB在一个页中可能存储着上千个记录因此在普通索引的字段上进行查找也就是在内存中多几次“判断下一条记录”的操作对于CPU来说这些操作所消耗的时间是可以忽略不计的。所以对一个索引字段进行检索采用普通索引还是唯一索引在检索效率上基本上没有差别。
## 总结
今天我们学习了数据库中的基本存储单位也就是页Page磁盘I/O都是基于页来进行读取的在页之上还有区、段和表空间它们都是更大的存储单位。我们在分配空间的时候会按照页为单位来进行分配同一棵树上同一层的页与页之间采用双向链表而在页里面记录之间采用的单向链表的方式。
链表这种数据结构的特点是增加、删除比较方便,所以在对记录进行删除的时候,有时候并不是真的删除了记录,而只是逻辑上的删除,也就是在标记为上标记为“已删除”。但链表还有个问题就是查找效率低,因此在页结构中还专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索提升效率。
<img src="https://static001.geekbang.org/resource/image/c1/74/c127149aad62be7a1ee2c366757a2e74.jpg" alt=""><br>
今天的内容到这里就结束了最后我给你留两道思考题吧。按照聚集索引存储的行记录在物理上连续的还是逻辑上连续的另外通过B+树进行记录的检索流程是怎样的?
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="28丨从磁盘I/O的角度理解SQL查询的成本" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/52/16678aa507d1c3adc521f2fbc451eb52.mp3"></audio>
在开始今天的内容前,我们先来回忆一下之前的内容。
数据库存储的基本单位是页对于一棵B+树的索引来说是先从根节点找到叶子节点也就是先查找数据行所在的页再将页读入到内存中在内存中对页的记录进行查找从而得到想要数据。你看虽然我们想要查找的只是一行记录但是对于磁盘I/O来说却需要加载一页的信息因为页是最小的存储单位。
那么对于数据库来说,如果我们想要查找多行记录,查询时间是否会成倍地提升呢?其实数据库会采用缓冲池的方式提升页的查找效率。
为了更好地理解SQL查询效率是怎么一回事今天我们就来看看磁盘I/O是如何加载数据的。
这部分的内容主要包括以下几个部分:
1. 数据库的缓冲池在数据库中起到了怎样的作用?如果我们对缓冲池内的数据进行更新,数据会直接更新到磁盘上吗?
1. 对数据页进行加载都有哪些方式呢?
1. 如何查看一条SQL语句需要在缓冲池中进行加载的页的数量呢
## 数据库缓冲池
磁盘I/O需要消耗的时间很多而在内存中进行操作效率则会高很多为了能让数据表或者索引中的数据随时被我们所用DBMS会申请占用内存来作为数据缓冲池这样做的好处是可以让磁盘活动最小化从而减少与磁盘直接进行I/O的时间。要知道这种策略对提升SQL语句的查询性能来说至关重要。如果索引的数据在缓冲池里那么访问的成本就会降低很多。
那么缓冲池如何读取数据呢?
缓冲池管理器会尽量将经常使用的数据保存起来,在数据库进行页面读操作的时候,首先会判断该页面是否在缓冲池中,如果存在就直接读取,如果不存在,就会通过内存或磁盘将页面存放到缓冲池中再进行读取。
缓存在数据库中的结构和作用如下图所示:
<img src="https://static001.geekbang.org/resource/image/05/9b/05e1692282d25768c87bbc31a1479b9b.png" alt=""><br>
如果我们执行SQL语句的时候更新了缓存池中的数据那么这些数据会马上同步到磁盘上吗
实际上当我们对数据库中的记录进行修改的时候首先会修改缓冲池中页里面的记录信息然后数据库会以一定的频率刷新到磁盘上。注意并不是每次发生更新操作都会立刻进行磁盘回写。缓冲池会采用一种叫做checkpoint的机制将数据回写到磁盘上这样做的好处就是提升了数据库的整体性能。
比如当缓冲池不够用时需要释放掉一些不常用的页就可以采用强行采用checkpoint的方式将不常用的脏页回写到磁盘上然后再从缓冲池中将这些页释放掉。这里脏页dirty page指的是缓冲池中被修改过的页与磁盘上的数据页不一致。
### 查看缓冲池的大小
了解完缓冲池的工作原理后,你可能想问,我们如何判断缓冲池的大小?
如果你使用的是MySQL MyISAM存储引擎它只缓存索引不缓存数据对应的键缓存参数为key_buffer_size你可以用它进行查看。
如果你使用的是InnoDB存储引擎可以通过查看innodb_buffer_pool_size变量来查看缓冲池的大小命令如下
```
mysql &gt; show variables like 'innodb_buffer_pool_size'
```
<img src="https://static001.geekbang.org/resource/image/93/16/93b1b8acaf39f46245c6b751200e8316.png" alt=""><br>
你能看到此时InnoDB的缓冲池大小只有8388608/1024/1024=8MB我们可以修改缓冲池大小为128MB方法如下
<img src="https://static001.geekbang.org/resource/image/78/70/78fd83a2ff4f0a10ed20ec147ffbac70.png" alt=""><br>
然后再来看下修改后的缓冲池大小此时已成功修改成了128MB
<img src="https://static001.geekbang.org/resource/image/a3/cc/a364961bda6d5c4742460620053ec1cc.png" alt=""><br>
在InnoDB存储引擎中我们可以同时开启多个缓冲池这里我们看下如何查看缓冲池的个数使用命令
```
mysql &gt; show variables like 'innodb_buffer_pool_instances'
```
<img src="https://static001.geekbang.org/resource/image/d1/27/d1f7896087c58bfef202f9a21e96ed27.png" alt="">
你能看到当前只有一个缓冲池。实际上`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`参数进行修改。
### 数据页加载的三种方式
我们刚才已经对缓冲池有了基本的了解。
如果缓冲池中没有该页数据,那么缓冲池有以下三种读取数据的方式,每种方式的读取效率都是不同的:
**1. 内存读取**
如果该数据存在于内存中基本上执行时间在1ms左右效率还是很高的。
<img src="https://static001.geekbang.org/resource/image/a5/4f/a5d16af8d34ebdcfe327ef7b4841ad4f.png" alt=""><br>
**2. 随机读取**
如果数据没有在内存中就需要在磁盘上对该页进行查找整体时间预估在10ms左右这10ms中有6ms是磁盘的实际繁忙时间包括了寻道和半圈旋转时间有3ms是对可能发生的排队时间的估计值另外还有1ms的传输时间将页从磁盘服务器缓冲区传输到数据库缓冲区中。这10ms看起来很快但实际上对于数据库来说消耗的时间已经非常长了因为这还只是一个页的读取时间。
<img src="https://static001.geekbang.org/resource/image/50/49/50fb2657341103548a76fce6f7769149.png" alt=""><br>
**3. 顺序读取**
顺序读取其实是一种批量读取的方式因为我们请求的数据在磁盘上往往都是相邻存储的顺序读取可以帮我们批量读取页面这样的话一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘I/O操作了。如果一个磁盘的吞吐量是40MB/S那么对于一个16KB大小的页来说一次可以顺序读取256040MB/16KB个页相当于一个页的读取时间为0.4ms。采用批量读取的方式,即使是从磁盘上进行读取,效率也比从内存中只单独读取一个页的效率要高。
## 通过last_query_cost统计SQL语句的查询成本
我们先前已经讲过一条SQL查询语句在执行前需要确定查询计划如果存在多种查询计划的话MySQL会计算每个查询计划所需要的成本从中选择成本最小的一个作为最终执行的查询计划。
如果我们想要查看某条SQL语句的查询成本可以在执行完这条SQL语句之后通过查看当前会话中的last_query_cost变量值来得到当前查询的成本。这个查询成本对应的是SQL语句所需要读取的页的数量。
我以product_comment表为例如果我们想要查询comment_id=900001的记录然后看下查询成本我们可以直接在聚集索引上进行查找
```
mysql&gt; SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 900001;
```
运行结果1条记录运行时间为0.042s
<img src="https://static001.geekbang.org/resource/image/da/fd/da50c6f039b431fbab963be16f16b5fd.png" alt=""><br>
然后再看下查询优化器的成本,实际上我们只需要检索一个页即可:
```
mysql&gt; SHOW STATUS LIKE 'last_query_cost';
```
<img src="https://static001.geekbang.org/resource/image/cd/43/cde712f5d9fc42f31310511677811543.png" alt=""><br>
如果我们想要查询comment_id在900001到9000100之间的评论记录呢
```
mysql&gt; SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id BETWEEN 900001 AND 900100;
```
运行结果100条记录运行时间为0.046s
<img src="https://static001.geekbang.org/resource/image/be/a0/be4d6cd476fd0c9898df5f785aa0c0a0.png" alt=""><br>
然后再看下查询优化器的成本这时我们大概需要进行20个页的查询。
```
mysql&gt; SHOW STATUS LIKE 'last_query_cost';
```
<img src="https://static001.geekbang.org/resource/image/62/fe/628138aa22d3244f8d08dc701a1f61fe.png" alt=""><br>
你能看到页的数量是刚才的20倍但是查询的效率并没有明显的变化实际上这两个SQL查询的时间基本上一样就是因为采用了顺序读取的方式将页面一次性加载到缓冲池中然后再进行查找。虽然页数量last_query_cost增加了不少但是通过缓冲池的机制并没有增加多少查询时间。
## 总结
上一节我们了解到了页是数据库存储的最小单位这一节我们了解了在数据库中是如何加载使用页的。SQL查询是一个动态的过程从页加载的角度来看我们可以得到以下两点结论
1. 位置决定效率。如果页就在数据库缓冲池中,那么效率是最高的,否则还需要从内存或者磁盘中进行读取,当然针对单个页的读取来说,如果页存在于内存中,会比在磁盘中读取效率高很多。
1. 批量决定效率。如果我们从磁盘中对单一页进行随机读那么效率是很低的差不多10ms而采用顺序读取的方式批量对页进行读取平均一页的读取效率就会提升很多甚至要快于单个页面在内存中的随机读取。
所以说遇到I/O并不用担心方法找对了效率还是很高的。我们首先要考虑数据存放的位置如果是经常使用的数据就要尽量放到缓冲池中其次我们可以充分利用磁盘的吞吐能力一次性批量读取数据这样单个页的读取效率也就得到了提升。
<img src="https://static001.geekbang.org/resource/image/f2/77/f254372aac175d6ac571ebe9ec024777.jpg" alt=""><br>
最后给你留两道思考题吧。你能解释下相比于单个页面的随机读,为什么顺序读取时平均一个页面的加载效率会提高吗?另外,对于今天学习的缓冲池机制和数据页加载的方式,你有什么心得体会吗?
欢迎在评论区写下你的答案,如果你觉得这篇文章有帮助,不妨把它分享给你的朋友或者同事吧。

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="29丨为什么没有理想的索引" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/4c/03af891d7bd9fb1a111fab78aa3deb4c.mp3"></audio>
我之前讲过页这个结构表和索引都会存储在页中不同的DBMS默认的页的大小是不同的同时我们也了解到DBMS会有缓冲池的机制在缓冲池里需要有足够多的空间存储经常被使用到的页尽可能减少直接的磁盘I/O操作。这种策略对SQL查询的底层执行来说非常重要可以从物理层面上最大程度提升SQL的查询效率。
但同时我们还需要关注索引的设计如果只是针对SQL查询我们是可以设计出理想的索引的不过在实际工作中这种理想的索引往往会带来更多的资源消耗。这是为什么呢今天我们就来对这部分内容进行学习内容包括以下几个部分
1. 什么是索引片?如何计算过滤因子?
1. 设计索引的时候,可以遵循哪些原则呢?
1. 为什么理想的索引很难在实际工作中应用起来?
## 索引片和过滤因子
索引片就是 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 src="https://static001.geekbang.org/resource/image/61/e9/6139464adb6a068be03db23fe33ac2e9.jpg" alt=""><br>
针对这条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 src="https://static001.geekbang.org/resource/image/65/19/65b3b85e33f377c417eb0354d9fd7119.png" alt=""><br>
同样,如果我们设置宽索引`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 src="https://static001.geekbang.org/resource/image/b0/f1/b01c2d5a77c4d57388b88d93f70298f1.png" alt=""><br>
你能看到`gender='male'`不是个好过滤因子,因为所有球员都是男性,同样`team_id=1001`也不是个好过滤因子因为这个比例在这个特定的数据集中高达54%,相比之下`height=2.08`具有一定的筛选性过滤因子能力最强的是name字段。
这时如果我们创建一个联合的过滤条件`height, team_id`,那么它的过滤能力是怎样的呢?
<img src="https://static001.geekbang.org/resource/image/3d/7c/3dabf8870e8ba8f65ad8c40ca89a287c.png" alt=""><br>
联合过滤因子有更高的过滤能力,这里还需要注意一个条件,那就是条件列的关联性应该尽量相互独立,否则如果列与列之间具有相关性,联合过滤因子的能力就会下降很多。比如城市名称和电话区号就有强相关性,这两个列组合到一起不会加强过滤效果。
你能看到过滤因子决定了索引片的大小注意这里不是窄索引和宽索引过滤因子的条件过滤能力越强满足条件的记录数就越少SQL查询需要扫描的索引片也就越小。同理如果我们没有选择好索引片中的过滤因子就会造成索引片中的记录数过多的情况。
## 针对SQL查询的理想索引设计三星索引
刚才我介绍了宽索引和窄索引有些时候宽索引可以提升SQL的查询效率那么你可能会问如果针对SQL查询来说有没有一个标准能让SQL查询效率最大化呢
实际上,存在着一个三星索引的标准,这就好比我们在学习数据表设计时提到的三范式一样。三星索引具体指的是:
1. 在WHERE条件语句中找到所有等值谓词中的条件列将它们作为索引片中的开始列
1. 将 GROUP BY和ORDER BY中的列加入到索引中
1. 将SELECT字段中剩余的列加入到索引片中。
你能看到这样操作下来索引片基本上会变成一个宽索引把能添加的相关列都加入其中。为什么对于一条SQL查询来说这样做的效率是最高的吗
首先如果我们要通过索引查找符合条件的记录就需要将WHERE子句中的等值谓词列加入到索引片中这样索引的过滤能力越强最终扫描的数据行就越少。
另外如果我们要对数据记录分组或者排序都需要重新扫描数据记录。为了避免进行file sort排序可以把GROUP BY和ORDER BY中涉及到的列加入到索引中因为创建了索引就会按照索引的顺序来存储数据这样再对这些数据按照某个字段进行分组或者排序的时候就会提升效率。
<img src="https://static001.geekbang.org/resource/image/34/c7/340a26c1be1b2c40ab6dff50d521cfc7.png" alt=""><br>
最后我们取数据的时候可能会存在回表情况。回表就是通过索引找到了数据行但是还需要通过主键的方式在数据表中查找完成的记录。这是因为SELECT所需的字段并不都保存在索引中因此我们可以将SELECT中的字段都保存在索引中避免回表的情况从而提升查询效率。
## 为什么很难存在理想的索引设计
从三星索引的创建过程中你能看到三星索引实际上分析了在SQL查询过程中所有可能影响效率的环节通过在索引片中添加索引的方式来提升效率。通过上面的原则我们可以很快创建一个SQL查询语句的三星索引有时候可能只有两星比如同时拥有范围谓词和ORDER BY的时候
但就同三范式一样,很多时候我们并没有遵循三范式的设计原则,而是采用了反范式设计。同样,有时候我们并不能需要完全遵循三星索引的原则,原因主要有以下两点:
1. 采用三星索引会让索引片变宽这样每个页能够存储的索引数据就会变少从而增加了页加载的数量。从另一个角度来看如果数据量很大比如有1000万行数据过多索引所需要的磁盘空间可能会成为一个问题对缓冲池所需空间的压力也会增加。
1. 增加了索引维护的成本。如果我们为所有的查询语句都设计理想的三星索引就会让数据表中的索引个数过多这样索引维护的成本也会增加。举个例子当我们添加一条记录的时候就需要在每一个索引上都添加相应的行存储对应的主键值假设添加一行记录的时间成本是10ms磁盘随机读取一个页的时间那么如果我们创建了10个索引添加一条记录的时间就可能变成0.1s如果是添加10条记录呢就会花费近1s的时间。从索引维护的成本来看消耗还是很高的。当然对于数据库来说数据的更新不一定马上回写到磁盘上但即使不及时将脏页进行回写也会造成缓冲池中的空间占用过多脏页过多的情况。
## 总结
你能看到针对一条SQL查询来说三星索引是个理想的方式但实际运行起来我们要考虑更多维护的成本在索引效率和索引维护之间进行权衡。
三星索引会让索引变宽好处就是不需要进行回表查询减少了磁盘I/O的次数弊端就是会造成频繁的页分裂和页合并对于数据的插入和更新来说效率会降低不少。
那我们该如何设计索引呢?
首先一张表的索引个数不宜过多,否则一条记录的增加和修改,会因为过多的索引造成额外的负担。针对这个情况,当你需要新建索引的时候,首先考虑在原有的索引片上增加索引,也就是采用复合索引的方式,而不是新建一个新的索引。另外我们可以定期检查索引的使用情况,对于很少使用到的索引可以及时删除,从而减少索引数量。
同时在索引片中我们也需要控制索引列的数量通常情况下我们将WHERE里的条件列添加到索引中而SELECT中的非条件列则不需要添加。除非SELECT中的非条件列数少并且该字段会经常使用到。
另外单列索引和复合索引的长度也需要控制在MySQL InnoDB中系统默认单个索引长度最大为767 bytes如果单列索引长度超过了这个限制就会取前缀索引也就是取前 255 字符。这实际上也是告诉我们,字符列会占用较大的空间,在数据表设计的时候,尽量采用数值类型替代字符类型,尽量避免用字符类型做主键,同时针对字符字段最好只建前缀索引。
<img src="https://static001.geekbang.org/resource/image/f4/f4/f417b01c6e4d560b2cbe3c54c6e9dbf4.jpg" alt=""><br>
给你留一道思考题吧针对下面的SQL语句如果创建三星索引该如何创建使用三星索引和不使用三星索引在查询效率上又有什么区别呢
```
SELECT comment_id, comment_text, user_id FROM product_comment where user_id BETWEEN 100000 AND 200000
```
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,175 @@
<audio id="audio" title="30丨锁悲观锁和乐观锁是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/e9/916edcd61be50916e937c486a09007e9.mp3"></audio>
索引和锁是数据库中的两个核心知识点不论在工作中还是在面试中我们都会经常跟它们打交道。之前我们已经从不同维度对索引进行了了解比如B+树、Hash索引、页结构、缓冲池和索引原则等了解它们的工作原理可以加深我们对索引的理解。同时在基础篇的部分中我也讲解了事务的4大原则以及不同的隔离级别。这些隔离级别的实现都是通过锁来完成的你可以思考下为什么我们需要给数据加锁呢
实际上加锁是为了保证数据的一致性,这个思想在程序开发领域中同样很重要。在程序开发中也会存在多线程同步的问题。当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程在进行访问,保证数据的完整性和一致性。
今天的内容主要包括以下几个方面:
1. 就分类而言,锁的划分有多种方式,这些划分方式都包括哪些?
1. 为什么共享锁会发生死锁?
1. 乐观锁和悲观锁的思想是什么?乐观锁有两种实现方式,这两种实现方式是什么?
1. 多个事务并发,发生死锁时该如何解决?怎样降低死锁发生的概率?
## 按照锁粒度进行划分
锁用来对数据进行锁定,我们可以从锁定对象的粒度大小来对锁进行划分,分别为行锁、页锁和表锁。
顾名思义,行锁就是按照行的粒度对数据进行锁定。锁定力度小,发生锁冲突概率低,可以实现的并发度高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。
页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
表锁就是对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。
行锁、页锁和表锁是相对常见的三种锁除此以外我们还可以在区和数据库的粒度上锁定数据对应区锁和数据库锁。不同的数据库和存储引擎支持的锁粒度不同InnoDB和Oracle支持行锁和表锁。而MyISAM只支持表锁MySQL中的BDB存储引擎支持页锁和表锁。SQL Server可以同时支持行锁、页锁和表锁如下表所示
<img src="https://static001.geekbang.org/resource/image/ca/b2/ca5598e46ab7ec59c3d3721e337a1ab2.png" alt=""><br>
这里需要说明下每个层级的锁数量是有限制的因为锁会占用内存空间锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁比如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;
```
另外当我们对数据进行更新的时候,也就是`INSERT``DELETE`或者`UPDATE`的时候,数据库也会自动使用排它锁,防止其他事务对该数据行进行操作。
当我们想要获取某个数据表的排它锁的时候,需要先看下这张数据表有没有上了排它锁。如果这个数据表中的某个数据行被上了行锁,我们就无法获取排它锁。这时需要对数据表中的行逐一排查,检查是否有行锁,如果没有,才可以获取这张数据表的排它锁。这个过程是不是有些麻烦?这里就需要用到意向锁。
意向锁Intent Lock简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子你可以给整个房子设置一个标识告诉它里面有人即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权只需要看这个房子的标识即可不需要再对房子中的每个房间进行查找。这样是不是很方便
返回数据表的场景,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。
## 为什么共享锁会发生死锁的情况?
当我们使用共享锁的时候会出现死锁的风险下面我们用两个MySQL客户端来模拟一下事务查询。
首先客户端1开启事务然后采用读锁的方式对`user_id=912178`的数据行进行查询,这时事务没有提交的时候,这两行数据行上了读锁。
<img src="https://static001.geekbang.org/resource/image/94/48/94f4e7c282b6cbeae64f0bad5ac6cb48.png" alt=""><br>
然后我们用客户端2开启事务同样对`user_id=912178`获取读锁,理论上获取读锁后还可以对数据进行修改,比如执行下面这条语句:
```
UPDATE product_comment SET product_i = 10002 WHERE user_id = 912178;
```
当我们执行的时候客户端2会一直等待因为客户端1也获取了该数据的读锁不需要客户端2对该数据进行修改。这时客户端2会提示等待超时重新执行事务。
<img src="https://static001.geekbang.org/resource/image/10/16/10b9d284597012bcd237041d7c573a16.png" alt=""><br>
你能看到当有多个事务对同一数据获得读锁的时候,可能会出现死锁的情况。
## 从程序员的角度对进行划分
如果从程序员的视角来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。
乐观锁Optimistic Locking认为对同一数据的并发操作不会总发生属于小概率事件不用每次都对数据上锁也就是不采用数据库自身的锁机制而是通过程序来实现。在程序上我们可以采用版本号机制或者时间戳机制实现。
### 乐观锁的版本号机制
在表中设计一个版本字段version第一次读的时候会获取version字段的取值。然后对数据进行更新或删除操作时会执行`UPDATE ... SET version=version+1 WHERE version=version`。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
这种方式类似我们熟悉的SVN、CVS版本管理系统当我们修改了代码进行提交时首先会检查当前版本号与服务器上的版本号是否一致如果一致就可以直接提交如果不一致就需要更新服务器上的最新代码然后再进行提交。
### 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
悲观锁Pessimistic Locking也是一种思想对数据被其他事务的修改持保守态度会通过数据库自身的锁机制来实现从而保证数据操作的排它性。
<img src="https://static001.geekbang.org/resource/image/0d/e3/0dd602515faab3b6b0cd0d42adaa87e3.png" alt=""><br>
从这两种锁的设计思想中,你能看出乐观锁和悲观锁的适用场景:
<li>
乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
</li>
<li>
悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读-写和写-写的冲突。
</li>
## 总结
今天我们讲解了数据库中锁的划分,你能看到从不同维度都可以对锁进行划分,需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。
既然有锁的存在,就有可能发生死锁的情况。死锁就是多个事务(如果是在程序层面就是多个进程)在执行过程中,因为竞争某个相同的资源而造成阻塞的现象。发生死锁,往往是因为在事务中,锁的获取是逐步进行的。
我在文章中举了一个例子在客户端1获取某数据行共享锁的同时另一个客户端2也获取了该数据行的共享锁这时任何一个客户端都没法对这个数据进行更新因为共享锁会阻止其他事务对数据的更新当某个客户端想要对锁定的数据进行更新的时候就出现了死锁的情况。当死锁发生的时候就需要一个事务进行回滚另一个事务获取锁完成事务然后将锁释放掉很像交通堵塞时候的解决方案。
<img src="https://static001.geekbang.org/resource/image/97/7f/9794e3a155edbf8d7b68a7ff8910fc7f.jpg" alt=""><br>
我们都不希望出现死锁的情况,可以采取一些方法避免死锁的发生:
1. 如果事务涉及多个表,操作比较复杂,那么可以尽量一次锁定所有的资源,而不是逐步来获取,这样可以减少死锁发生的概率;
1. 如果事务需要更新数据表中的大部分数据,数据表又比较大,这时可以采用锁升级的方式,比如将行级锁升级为表级锁,从而减少死锁产生的概率;
1. 不同事务并发读写多张数据表,可以约定访问表的顺序,采用相同的顺序降低死锁发生的概率。
当然在数据库中也有一些情况是不会发生死锁的比如采用乐观锁的方式。另外在MySQL MyISAM存储引擎中也不会出现死锁这是因为MyISAM总是一次性获得全部的锁这样的话要么全部满足可以执行要么就需要全部等待。
最后你有没有想过使用MySQL InnoDB存储引擎时为什么对某行数据添加排它锁之前会在数据表上添加意向排他锁呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来进步。

View File

@@ -0,0 +1,206 @@
<audio id="audio" title="31丨为什么大部分RDBMS都会支持MVCC" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/d7/1d78247871bc416f46dfaeb8e3aa5cd7.mp3"></audio>
上一篇文章中我们讲到了锁的划分以及乐观锁和悲观锁的思想。今天我们就来看下MVCC它就是采用乐观锁思想的一种方式。那么它到底有什么用呢
我们知道事务有4个隔离级别以及可能存在的三种异常问题如下图所示
<img src="https://static001.geekbang.org/resource/image/c2/9b/c2e9a4ce5793b031f3846890d0f6189b.png" alt=""><br>
在MySQL中默认的隔离级别是可重复读可以解决脏读和不可重复读的问题但不能解决幻读问题。如果我们想要解决幻读问题就需要采用串行化的方式也就是将隔离级别提升到最高但这样一来就会大幅降低数据库的事务并发能力。
有没有一种方式可以不采用锁机制而是通过乐观锁的方式来解决不可重复读和幻读问题呢实际上MVCC机制的设计就是用来解决这个问题的它可以在大多数情况下替代行级锁降低系统的开销。
<img src="https://static001.geekbang.org/resource/image/56/a0/568bb507e1edb431d8121a2cb5c7caa0.png" alt=""><br>
今天的课程主要包括以下几个方面的内容:
1. MVCC机制的思想是什么为什么RDBMS会采用MVCC机制
1. 在InnoDB中MVCC机制是如何实现的
1. Read View是如何工作的
## MVCC是什么解决了什么问题
MVCC的英文全称是Multiversion Concurrency Control中文翻译过来就是多版本并发控制技术。从名字中也能看出来MVCC是通过数据行的多个版本管理来实现数据库的并发控制简单来说它的思想就是保存数据的历史版本。这样我们就可以通过比较版本号决定数据是否显示出来具体的规则后面会介绍到读取数据的时候不需要加锁也可以保证事务的隔离效果。
通过MVCC我们可以解决以下几个问题
1. 读写之间阻塞的问题通过MVCC可以让读写互相不阻塞即读不阻塞写写不阻塞读这样就可以提升事务并发处理能力。
1. 降低了死锁的概率。这是因为MVCC采用了乐观锁的方式读取数据时并不需要加锁对于写操作也只锁定必要的行。
1. 解决一致性读的问题。一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。
### 什么是快照读,什么是当前读
那么什么是快照读呢快照读读取的是快照数据。不加锁的简单的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卡号具体的数据示意如下
<img src="https://static001.geekbang.org/resource/image/be/46/bec7af5d84bc7d4295fd205491d85e46.png" alt=""><br>
为了方便我们假设user_balance表中只有用户A和B有余额其他人的账户余额均为0。下面我们考虑一个使用场景。
用户A和用户B之间进行转账此时数据库管理员想要查询user_balance表中的总金额
```
SELECT SUM(balance) FROM user_balance
```
你可以思考下如果数据库不支持MVCC机制而是采用自身的锁机制来实现的话可能会出现怎样的情况呢
情况1因为需要采用加行锁的方式用户A给B转账时间等待很久如下图所示。
<img src="https://static001.geekbang.org/resource/image/2d/27/2df06836628ce9735364ca932003f927.png" alt=""><br>
你能看到为了保证数据的一致性我们需要给统计到的数据行都加上行锁。这时如果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被用户事务锁住了此时就发生了死锁。
<img src="https://static001.geekbang.org/resource/image/3b/41/3b6c7ae9db7e1952fd9ae264e7147b41.png" alt=""><br>
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来创建聚集索引。采用聚集索引的方式可以提升数据的查找效率。
1. db_trx_id操作这个数据的事务ID也就是最后一个对该数据进行插入或更新的事务ID。
1. db_roll_ptr回滚指针也就是指向这个记录的Undo Log信息。
<img src="https://static001.geekbang.org/resource/image/d1/20/d15a5d0a313492b208d2aad410173b20.png" alt="">
### Undo Log
InnoDB将行记录快照保存在了Undo Log里我们可以在回滚段中找到它们如下图所示
<img src="https://static001.geekbang.org/resource/image/47/81/4799c77b8cdfda50e49a391fea727281.png" alt=""><br>
从图中你能看到回滚指针将数据行的所有快照记录都通过链表的结构串联了起来每个快照的记录都保存了当时的db_trx_id也是那个时间点操作这个数据的事务ID。这样如果我们想要找历史快照就可以通过遍历回滚指针的方式进行查找。
## Read View是如何工作的
在MVCC机制中多个事务对同一个行记录进行更新会产生多个历史快照这些历史快照保存在Undo Log里。如果一个事务想要查询这个行记录需要读取哪个版本的行记录呢这时就需要用到Read View了它帮我们解决了行的可见性问题。Read View保存了当前事务开启时所有活跃还没有提交的事务列表换个角度你可以理解为Read View保存了不应该让这个事务看到的其他的事务ID列表。
在Read VIew中有几个重要的属性
1. trx_ids系统当前正在活跃的事务ID集合。
1. low_limit_id活跃的事务中最大的事务ID。
1. up_limit_id活跃的事务中最小的事务ID。
1. creator_trx_id创建这个Read View的事务ID。
如图所示trx_ids为trx2、trx3、trx5和trx8的集合活跃的最大事务IDlow_limit_id为trx8活跃的最小事务IDup_limit_id为trx2。
<img src="https://static001.geekbang.org/resource/image/a7/00/a7fe7d4a0e25fce469c3d00e5e3ec600.png" alt=""><br>
假设当前有事务creator_trx_id想要读取某个行记录这个行记录的事务ID为trx_id那么会出现以下几种情况。
如果trx_id &lt; 活跃的最小事务IDup_limit_id也就是说这个行记录在这些活跃的事务创建之前就已经提交了那么这个行记录对该事务是可见的。
如果trx_id &gt; 活跃的最大事务IDlow_limit_id这说明该行记录在这些活跃的事务创建之后才创建那么这个行记录对当前事务不可见。
如果up_limit_id &lt; trx_id &lt; low_limit_id说明该行记录所在的事务trx_id在目前creator_trx_id这个事务创建的时候可能还处于活跃的状态因此我们需要在trx_ids集合中进行遍历如果trx_id存在于trx_ids集合中证明这个事务trx_id还处于活跃状态不可见。否则如果trx_id不存在于trx_ids集合中证明事务trx_id已经提交了该行记录可见。
了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过多版本并发控制技术找到它:
1. 首先获取事务自己的版本号也就是事务ID
1. 获取Read View
1. 查询得到的数据然后与Read View中的事务版本号进行比较
1. 如果不符合ReadView规则就需要从Undo Log中获取历史快照
1. 最后返回符合规则的数据。
你能看到InnoDB中MVCC是通过Undo Log + Read View进行数据读取Undo Log保存了历史快照而Read View规则帮我们判断当前版本的数据是否可见。
需要说明的是在隔离级别为读已提交Read Commit一个事务中的每一次SELECT查询都会获取一次Read View。如表所示
<img src="https://static001.geekbang.org/resource/image/5d/5c/5dfd6b7491484ad49efc5f08b214bf5c.png" alt=""><br>
你能看到在读已提交的隔离级别下同样的查询语句都会重新获取一次Read View这时如果Read View不同就可能产生不可重复读或者幻读的情况。
当隔离级别为可重复读的时候就避免了不可重复读这是因为一个事务只在第一次SELECT的时候会获取一次Read View而后面所有的SELECT都会复用这个Read View如下表所示
<img src="https://static001.geekbang.org/resource/image/7b/f2/7b9c80fb892f858e08a64fc8ef7257f2.png" alt="">
## InnoDB是如何解决幻读的
不过这里需要说明的是在可重复读的情况下InnoDB可以通过Next-Key锁+MVCC来解决幻读问题。
在读已提交的情况下即使采用了MVCC方式也会出现幻读。如果我们同时开启事务A和事务B先在事务A中进行某个条件范围的查询读取的时候采用排它锁在事务B中增加一条符合该条件范围的数据并进行提交然后我们在事务A中再次查询该条件范围的数据就会发现结果集中多出一个符合条件的数据这样就出现了幻读。
<img src="https://static001.geekbang.org/resource/image/4f/cb/4f112ba8411db336f58c8db9c9e8f0cb.png" alt=""><br>
出现幻读的原因是在读已提交的情况下InnoDB只采用记录锁Record Locking。这里要介绍下InnoDB三种行锁的方式
1. 记录锁:针对单个行记录添加锁。
1. 间隙锁Gap Locking可以帮我们锁住一个范围索引之间的空隙但不包括记录本身。采用间隙锁的方式可以防止幻读情况的产生。
1. Next-Key锁帮我们锁住一个范围同时锁定记录本身相当于间隙锁+记录锁,可以解决幻读的问题。
在隔离级别为可重复读时InnoDB会采用Next-Key锁的机制帮我们解决幻读问题。
还是这个例子我们能看到当我们想要插入球员艾利克斯·伦身高2.16米的时候事务B会超时无法插入该数据。这是因为采用了Next-Key锁会将height&gt;2.08的范围都进行锁定就无法插入符合这个范围的数据了。然后事务A重新进行条件范围的查询就不会出现幻读的情况。
<img src="https://static001.geekbang.org/resource/image/3b/4e/3b876d3a4d5c11352ba10cebf9c7ab4e.png" alt="">
## 总结
今天关于MVCC的内容有些多通过学习你应该能对采用MVCC这种乐观锁的方式来保证事务的隔离效果更有体会。
我们需要记住MVCC的核心就是Undo Log+ Read View“MV”就是通过Undo Log来保存数据的历史版本实现多版本的管理“CC”是通过Read View来实现管理通过Read View原则来决定数据是否显示。同时针对不同的隔离级别Read View的生成策略不同也就实现了不同的隔离级别。
MVCC是一种机制MySQL、Oracle、SQL Server和PostgreSQL的实现方式均有不同我们在学习的时候更主要的是要理解MVCC的设计思想。
<img src="https://static001.geekbang.org/resource/image/4f/5a/4f1cb2414cae9216ee6b3a5fa19a855a.jpg" alt=""><br>
最后给你留几道思考题吧为什么隔离级别为读未提交时不适用于MVCC机制呢第二个问题是读已提交和可重复读这两个隔离级别的Read View策略有何不同
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,让我们一起来学习进步。

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="32丨查询优化器是如何工作的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0e/e0/0ee4889b72141f4094d9ddcb104a15e0.mp3"></audio>
我们总是希望数据库可以运行得更快,也就是响应时间更快,吞吐量更大。想要达到这样的目的,我们一方面需要高并发的事务处理能力,另一方面需要创建合适的索引,让数据的查找效率最大化。事务和索引的使用是数据库中的两个重要核心,事务可以让数据库在增删查改的过程中,保证数据的正确性和安全性,而索引可以帮数据库提升数据的查找效率。
如果我们想要知道如何获取更高的SQL查询性能最好的方式就是理解数据库是如何进行查询优化和执行的。
今天我们就来看看查询优化的原理是怎么一回事。今天的主要内容包括以下几个部分:
1. 什么是查询优化器一条SQL语句的执行流程都会经历哪些环节在查询优化器中都包括了哪些部分
1. 查询优化器的两种优化方式分别是什么?
1. 基于代价的优化器是如何统计代价的?总的代价又如何计算?
## 什么是查询优化器
了解查询优化器的作用之前我们先来看看一条SQL语句的执行都需要经历哪些环节如下图所示
<img src="https://static001.geekbang.org/resource/image/67/31/6776cd76ea50db263bfd9d58c4d98631.png" alt=""><br>
你能看到一条SQL查询语句首先会经过分析器进行语法分析和语义检查。我们之前讲过语法分析是检查SQL拼写和语法是否正确语义检查是检查SQL中的访问对象是否存在。比如我们在写SELECT语句的时候列名写错了系统就会提示错误。语法检查和语义检查可以保证SQL语句没有错误最终得到一棵语法分析树然后经过查询优化器得到查询计划最后交给执行器进行执行。
查询优化器的目标是找到执行SQL查询的最佳执行计划执行计划就是查询树它由一系列物理操作符组成这些操作符按照一定的运算关系组成查询的执行计划。在查询优化器中可以分为逻辑查询优化阶段和物理查询优化阶段。
逻辑查询优化就是通过改变SQL语句的内容来使得SQL查询更高效同时为物理查询优化提供更多的候选执行计划。通常采用的方式是对SQL语句进行等价变换对查询进行重写而查询重写的数学基础就是关系代数。对条件表达式进行等价谓词重写、条件简化对视图进行重写对子查询进行优化对连接语义进行了外连接消除、嵌套连接消除等。
逻辑查询优化是基于关系代数进行的查询重写,而关系代数的每一步都对应着物理计算,这些物理计算往往存在多种算法,因此需要计算各种物理路径的代价,从中选择代价最小的作为执行计划。在这个阶段里,对于单表和多表连接的操作,需要高效地使用索引,提升查询效率。
<img src="https://static001.geekbang.org/resource/image/65/fa/650cd690c532161de1b0e856d4b067fa.png" alt=""><br>
在这两个阶段中,查询重写属于代数级、语法级的优化,也就是属于逻辑范围内的优化,而基于代价的估算模型是从连接路径中选择代价最小的路径,属于物理层面的优化。
## 查询优化器的两种优化方式
查询优化器的目的就是生成最佳的执行计划,而生成最佳执行计划的策略通常有以下两种方式。
第一种是基于规则的优化器RBORule-Based Optimizer规则就是人们以往的经验或者是采用已经被证明是有效的方式。通过在优化器里面嵌入规则来判断SQL查询符合哪种规则就按照相应的规则来制定执行计划同时采用启发式规则去掉明显不好的存取路径。
第二种是基于代价的优化器CBOCost-Based Optimizer这里会根据代价评估模型计算每条可能的执行计划的代价也就是COST从中选择代价最小的作为执行计划。相比于RBO来说CBO对数据更敏感因为它会利用数据表中的统计信息来做判断针对不同的数据表查询得到的执行计划可能是不同的因此制定出来的执行计划也更符合数据表的实际情况。
但我们需要记住SQL是面向集合的语言并没有指定执行的方式因此在优化器中会存在各种组合的可能。我们需要通过优化器来制定数据表的扫描方式、连接方式以及连接顺序从而得到最佳的SQL执行计划。
你能看出来RBO的方式更像是一个出租车老司机凭借自己的经验来选择从A到B的路径。而CBO更像是手机导航通过数据驱动来选择最佳的执行路径。
## CBO是如何统计代价的
大部分RDBMS都支持基于代价的优化器CBOCBO随着版本的迭代也越来越成熟但是CBO依然存在缺陷。通过对CBO工作原理的了解我们可以知道CBO可能存在的不足有哪些有助于让我们知道优化器是如何确定执行计划的。
### 能调整的代价模型的参数有哪些
首先我们先来了解下MySQL中的`COST Model``COST Model`就是优化器用来统计各种步骤的代价模型在5.7.10版本之后MySQL会引入两张数据表里面规定了各种步骤预估的代价Cost Value ,我们可以从`mysql.server_cost``mysql.engine_cost`这两张表中获得这些步骤的代价:
```
SQL &gt; SELECT * FROM mysql.server_cost
```
<img src="https://static001.geekbang.org/resource/image/d5/e7/d571ea13f753ed24eedf6f20183e2ae7.png" alt=""><br>
server_cost数据表是在server层统计的代价具体的参数含义如下
1. `disk_temptable_create_cost`表示临时表文件MyISAM或InnoDB的创建代价默认值为20。
1. `disk_temptable_row_cost`表示临时表文件MyISAM或InnoDB的行代价默认值0.5。
1. `key_compare_cost`表示键比较的代价。键比较的次数越多这项的代价就越大这是一个重要的指标默认值0.05。
1. `memory_temptable_create_cost`表示内存中临时表的创建代价默认值1。
1. `memory_temptable_row_cost`表示内存中临时表的行代价默认值0.1。
1. `row_evaluate_cost`统计符合条件的行代价如果符合条件的行数越多那么这一项的代价就越大因此这是个重要的指标默认值0.1。
由这张表中可以看到,如果想要创建临时表,尤其是在磁盘中创建相应的文件,代价还是很高的。
然后我们看下在存储引擎层都包括了哪些代价:
```
SQL &gt; SELECT * FROM mysql.engine_cost
```
<img src="https://static001.geekbang.org/resource/image/96/72/969377b6a1348175ce211f137e2cf572.png" alt=""><br>
`engine_cost`主要统计了页加载的代价我们之前了解到一个页的加载根据页所在位置的不同读取的位置也不同可以从磁盘I/O中获取也可以从内存中读取。因此在`engine_cost`数据表中对这两个读取的代价进行了定义:
1. `io_block_read_cost`从磁盘中读取一页数据的代价默认是1。
1. `memory_block_read_cost`从内存中读取一页数据的代价默认是0.25。
既然MySQL将这些代价参数以数据表的形式呈现给了我们我们就可以根据实际情况去修改这些参数。因为随着硬件的提升各种硬件的性能对比也可能发生变化比如针对普通硬盘的情况可以考虑适当增加`io_block_read_cost`的数值,这样就代表从磁盘上读取一页数据的成本变高了。当我们执行全表扫描的时候,相比于范围查询,成本也会增加很多。
比如我想将`io_block_read_cost`参数设置为2.0,那么使用下面这条命令就可以:
```
UPDATE mysql.engine_cost
SET cost_value = 2.0
WHERE cost_name = 'io_block_read_cost';
FLUSH OPTIMIZER_COSTS;
```
<img src="https://static001.geekbang.org/resource/image/d5/96/d5e43099f925fc5d271055d69c0d6996.png" alt=""><br>
我们对`mysql.engine_cost`中的`io_block_read_cost`参数进行了修改,然后使用`FLUSH OPTIMIZER_COSTS`更新内存,然后再查看`engine_cost`数据表,发现`io_block_read_cost`参数中的`cost_value`已经调整为2.0。
如果我们想要专门针对某个存储引擎比如InnoDB存储引擎设置`io_block_read_cost`比如设置为2可以这样使用
```
INSERT INTO mysql.engine_cost(engine_name, device_type, cost_name, cost_value, last_update, comment)
VALUES ('InnoDB', 0, 'io_block_read_cost', 2,
CURRENT_TIMESTAMP, 'Using a slower disk for InnoDB');
FLUSH OPTIMIZER_COSTS;
```
然后我们再查看一下`mysql.engine_cost`数据表:
<img src="https://static001.geekbang.org/resource/image/4d/75/4def533e214f9a99f39d6ad62cc2b775.png" alt=""><br>
从图中你能看到针对InnoDB存储引擎可以设置专门的`io_block_read_cost`参数值。
### 代价模型如何计算
总代价的计算是一个比较复杂的过程,上面只是列出了一些常用的重要参数,我们可以根据情况对它们进行调整,也可以使用默认的系统参数值。
那么总的代价是如何进行计算的呢?
在论文[《Access Path Selection-in a Relational Database Management System》](http://dbis.rwth-aachen.de/lehrstuhl/staff/li/resources/download/AccessPathSelectionInRelationalDatabase.pdf%EF%BC%89)中给出了计算模型,如下图所示:
<img src="https://static001.geekbang.org/resource/image/38/b0/387aaaeb22f50168402018d8891ca5b0.png" alt=""><br>
你可以简单地认为总的执行代价等于I/O代价+CPU代价。在这里PAGE FETCH就是I/O代价也就是页面加载的代价包括数据页和索引页加载的代价。W*(RSI CALLS)就是CPU代价。W在这里是个权重因子表示了CPU到I/O之间转化的相关系数RSI CALLS代表了CPU的代价估算包括了键比较compare key以及行估算row evaluating的代价。
为了让你更好地理解我说下关于W和RSI CALLS的英文解释W is an adjustable weight between I/O and CPU utilization. The number of RSI calls is used to approximate CPU utilization。
这样你应该能明白为了让CPU代价和I/O代价放到一起来统计我们使用了转化的系数W
另外需要说明的是在MySQL5.7版本之后代价模型又进行了完善不仅考虑到了I/O和CPU开销还对内存计算和远程操作的代价进行了统计也就是说总代价的计算公式演变成下面这样
总代价 = I/O代价 + CPU代价 + 内存代价 + 远程代价
这里对内存代价和远程代价不进行讲解我们只需要关注I/O代价和CPU代价即可。
## 总结
我今天讲解了查询优化器它在RDBMS中是个非常重要的角色。在优化器中会经历逻辑查询优化和物理查询优化阶段。
最后我们只是简单梳理了下CBO的总代价是如何计算的以及包括了哪些部分。CBO的代价计算是个复杂的过程细节很多不同优化器的实现方式也不同。另外随着优化器的逐渐成熟考虑的因素也会越来越多。在某些情况下MySQL还会把RBO和CBO组合起来一起使用。RBO是个简单固化的模型在Oracle 8i之前采用的就是RBO在优化器中一共包括了15种规则输入的SQL会根据符合规则的情况得出相应的执行计划在Oracle 10g版本之后就用CBO替代了RBO。
CBO中需要传入的参数除了SQL查询以外还包括了优化器参数、数据表统计信息和系统配置等这实际上也导致CBO出现了一些缺陷比如统计信息不准确参数配置过高或过低都会导致路径选择的偏差。除此以外查询优化器还需要在优化时间和执行计划质量之间进行平衡比如一个执行计划的执行时间是10秒钟就没有必要花1分钟优化执行计划除非该SQL使用频繁高后续可以重复使用该执行计划。同样CBO也会做一些搜索空间的剪枝以便在有效的时间内找到一个“最优”的执行计划。这里其实也是在告诉我们为了得到一个事物付出的成本过大即使最终得到了有时候也是得不偿失的。
<img src="https://static001.geekbang.org/resource/image/1b/36/1be7b4fcd9ebdbfcc4f203a6c5e4a836.jpg" alt=""><br>
最后留两道思考题吧RBO和CBO各自的特点是怎样的呢为什么CBO也存在不足你能用自己的话描述一下其中的原因吗
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来学习进步。

View File

@@ -0,0 +1,224 @@
<audio id="audio" title="33丨如何使用性能分析工具定位SQL执行慢的原因" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/12/74/12c6c50ae92bf4121ece98e17218a074.mp3"></audio>
在上一篇文章中我们了解了查询优化器知道在查询优化器中会经历逻辑查询优化和物理查询优化。需要注意的是查询优化器只能在已经确定的情况下SQL语句、索引设计、缓冲池大小、查询优化器参数等已知的情况决定最优的查询执行计划。
但实际上SQL执行起来可能还是很慢那么到底从哪里定位SQL查询慢的问题呢是索引设计的问题服务器参数配置的问题还是需要增加缓存的问题呢今天我们就从性能分析来入手定位导致SQL执行慢的原因。
今天的内容主要包括以下几个部分:
1. 数据库服务器的优化分析的步骤是怎样的?中间有哪些需要注意的地方?
1. 如何使用慢查询日志查找执行慢的SQL语句
1. 如何使用EXPLAIN查看SQL执行计划
1. 如何使用SHOW PROFILING分析SQL执行步骤中的每一步的执行时间
## 数据库服务器的优化步骤
当我们遇到数据库调优问题的时候,该如何思考呢?我把思考的流程整理成了下面这张图。
整个流程划分成了观察Show status和行动Action两个部分。字母S的部分代表观察会使用相应的分析工具字母A代表的部分是行动对应分析可以采取的行动<br>
<img src="https://static001.geekbang.org/resource/image/99/37/998b1a255fe608856ac043eb9c36d237.png" alt=""><br>
我们可以通过观察了解数据库整体的运行状态通过性能分析工具可以让我们了解执行慢的SQL都有哪些查看具体的SQL执行计划甚至是SQL执行中的每一步的成本代价这样才能定位问题所在找到了问题再采取相应的行动。
我来详细解释一下这张图。
首先在S1部分我们需要观察服务器的状态是否存在周期性的波动。如果存在周期性波动有可能是周期性节点的原因比如双十一、促销活动等。这样的话我们可以通过A1这一步骤解决也就是加缓存或者更改缓存失效策略。
如果缓存策略没有解决或者不是周期性波动的原因我们就需要进一步分析查询延迟和卡顿的原因。接下来进入S2这一步我们需要开启慢查询。慢查询可以帮我们定位执行慢的SQL语句。我们可以通过设置`long_query_time`参数定义“慢”的阈值如果SQL执行时间超过了`long_query_time`,则会认为是慢查询。当收集上来这些慢查询之后,我们就可以通过分析工具对慢查询日志进行分析。
在S3这一步骤中我们就知道了执行慢的SQL语句这样就可以针对性地用EXPLAIN查看对应SQL语句的执行计划或者使用SHOW PROFILE查看SQL中每一个步骤的时间成本。这样我们就可以了解SQL查询慢是因为执行时间长还是等待时间长。
如果是SQL等待时间长我们进入A2步骤。在这一步骤中我们可以调优服务器的参数比如适当增加数据库缓冲池等。如果是SQL执行时间长就进入A3步骤这一步中我们需要考虑是索引设计的问题还是查询关联的数据表过多还是因为数据表的字段设计问题导致了这一现象。然后在这些维度上进行对应的调整。
如果A2和A3都不能解决问题我们需要考虑数据库自身的SQL查询性能是否已经达到了瓶颈如果确认没有达到性能瓶颈就需要重新检查重复以上的步骤。如果已经达到了性能瓶颈进入A4阶段需要考虑增加服务器采用读写分离的架构或者考虑对数据库分库分表比如垂直分库、垂直分表和水平分表等。
以上就是数据库调优的流程思路。当我们发现执行SQL时存在不规则延迟或卡顿的时候就可以采用分析工具帮我们定位有问题的SQL这三种分析工具你可以理解是SQL调优的三个步骤慢查询、EXPLAIN和SHOW PROFILE。
## 使用慢查询定位执行慢的SQL
慢查询可以帮我们找到执行慢的SQL在使用前我们需要先看下慢查询是否已经开启使用下面这条命令即可
```
mysql &gt; show variables like '%slow_query_log';
```
<img src="https://static001.geekbang.org/resource/image/9e/35/9efe05b732290a3ed0132597e2ca0f35.png" alt=""><br>
我们能看到`slow_query_log=OFF`也就是说慢查询日志此时是关上的。我们可以把慢查询日志打开注意设置变量值的时候需要使用global否则会报错
```
mysql &gt; set global slow_query_log='ON';
```
然后我们再来查看下慢查询日志是否开启,以及慢查询日志文件的位置:
<img src="https://static001.geekbang.org/resource/image/20/11/20d327118c221ada2bb123a4ce975e11.png" alt=""><br>
你能看到这时慢查询分析已经开启同时文件保存在DESKTOP-4BK02RP-slow文件中。
接下来我们来看下慢查询的时间阈值设置,使用如下命令:
```
mysql &gt; show variables like '%long_query_time%';
```
<img src="https://static001.geekbang.org/resource/image/a7/7a/a752b54a3fa38af449b55ccd0e628e7a.png" alt="">
这里如果我们想把时间缩短比如设置为3秒可以这样设置
```
mysql &gt; set global long_query_time = 3;
```
<img src="https://static001.geekbang.org/resource/image/a1/49/a17e97c29e0c66177e2844ae96189449.png" alt=""><br>
我们可以使用MySQL自带的mysqldumpslow工具统计慢查询日志这个工具是个Perl脚本你需要先安装好Perl
mysqldumpslow命令的具体参数如下
- -s采用order排序的方式排序方式可以有以下几种。分别是c访问次数、t查询时间、l锁定时间、r返回记录、ac平均查询次数、al平均锁定时间、ar平均返回记录数和at平均查询时间。其中at为默认排序方式。
- -t返回前N条数据 。
- -g后面可以是正则表达式对大小写不敏感。
比如我们想要按照查询时间排序查看前两条SQL语句这样写即可
```
perl mysqldumpslow.pl -s t -t 2 &quot;C:\ProgramData\MySQL\MySQL Server 8.0\Data\DESKTOP-4BK02RP-slow.log&quot;
```
<img src="https://static001.geekbang.org/resource/image/5b/44/5bded5220b76bfe20c59bb2968cc3744.png" alt=""><br>
你能看到开启了慢查询日志并设置了相应的慢查询时间阈值之后只要查询时间大于这个阈值的SQL语句都会保存在慢查询日志中然后我们就可以通过mysqldumpslow工具提取想要查找的SQL语句了。
## 如何使用EXPLAIN查看执行计划
定位了查询慢的SQL之后我们就可以使用EXPLAIN工具做针对性的分析比如我们想要了解product_comment和user表进行联查的时候所采用的的执行计划可以使用下面这条语句
```
EXPLAIN SELECT comment_id, product_id, comment_text, product_comment.user_id, user_name FROM product_comment JOIN user on product_comment.user_id = user.user_id
```
<img src="https://static001.geekbang.org/resource/image/ab/13/ab63d280a507eeb327bf154a0e87bf13.png" alt=""><br>
EXPLAIN可以帮助我们了解数据表的读取顺序、SELECT子句的类型、数据表的访问类型、可使用的索引、实际使用的索引、使用的索引长度、上一个表的连接匹配条件、被优化器查询的行的数量以及额外的信息比如是否使用了外部排序是否使用了临时表等等。
SQL执行的顺序是根据id从大到小执行的也就是id越大越先执行当id相同时从上到下执行。
数据表的访问类型所对应的type列是我们比较关注的信息。type可能有以下几种情况
<img src="https://static001.geekbang.org/resource/image/22/92/223e8c7b863bd15c83f25e3d93958692.png" alt=""><br>
在这些情况里all是最坏的情况因为采用了全表扫描的方式。index和all差不多只不过index对索引表进行全扫描这样做的好处是不再需要对数据进行排序但是开销依然很大。如果我们在Extral列中看到Using index说明采用了索引覆盖也就是索引可以覆盖所需的SELECT字段就不需要进行回表这样就减少了数据查找的开销。
比如我们对product_comment数据表进行查询设计了联合索引`composite_index (user_id, comment_text)`,然后对数据表中的`comment_id``comment_text``user_id`这三个字段进行查询最后用EXPLAIN看下执行计划
```
EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment
```
<img src="https://static001.geekbang.org/resource/image/07/8f/07a47b0146b0e881381f78812914568f.png" alt=""><br>
你能看到这里的访问方式采用了index的方式key列采用了联合索引进行扫描。Extral列为Using index告诉我们索引可以覆盖SELECT中的字段也就不需要回表查询了。
range表示采用了索引范围扫描这里不进行举例从这一级别开始索引的作用会越来越明显因此我们需要尽量让SQL查询可以使用到range这一级别及以上的type访问方式。
index_merge说明查询同时使用了两个或以上的索引最后取了交集或者并集。比如想要对`comment_id=500000` 或者`user_id=500000`的数据进行查询数据表中comment_id为主键user_id是普通索引我们可以查看下执行计划
```
EXPLAIN SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 500000 OR user_id = 500000
```
<img src="https://static001.geekbang.org/resource/image/4d/7e/4d07052ad81616f1d3b37bdb0a32067e.png" alt=""><br>
你能看到这里同时使用到了两个索引分别是主键和user_id采用的数据表访问类型是index_merge通过union的方式对两个索引检索的数据进行合并。
ref类型表示采用了非唯一索引或者是唯一索引的非唯一性前缀。比如我们想要对`user_id=500000`的评论进行查询使用EXPLAIN查看执行计划
```
EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment WHERE user_id = 500000
```
<img src="https://static001.geekbang.org/resource/image/0a/b6/0a98105a776ce82bf6503fcad2ebe2b6.png" alt=""><br>
这里user_id为普通索引因为user_id在商品评论表中可能是重复的因此采用的访问类型是ref同时在ref列中显示const表示连接匹配条件是常量用于索引列的查找。
eq_ref类型是使用主键或唯一索引时产生的访问方式通常使用在多表联查中。假设我们对`product_comment`表和user表进行联查关联条件是两张表的user_id相等使用EXPLAIN进行执行计划查看
```
EXPLAIN SELECT * FROM product_comment JOIN user WHERE product_comment.user_id = user.user_id
```
<img src="https://static001.geekbang.org/resource/image/59/33/59a1c808e79e2462fa6ffe5d7623d433.png" alt=""><br>
const类型表示我们使用了主键或者唯一索引所有的部分与常量值进行比较比如我们想要查看`comment_id=500000`,查看执行计划:
```
EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment WHERE comment_id = 500000
```
<img src="https://static001.geekbang.org/resource/image/6c/34/6c95ac56a800f1a89e3fb461418a4334.png" alt=""><br>
需要说明的是const类型和eq_ref都使用了主键或唯一索引不过这两个类型有所区别const是与常量进行比较查询效率会更快而eq_ref通常用于多表联查中。
system类型一般用于MyISAM或Memory表属于const类型的特例当表只有一行时连接类型为system我在GitHub上上传了test_myisam数据表该数据表只有一行记录下载地址[https://github.com/cystanford/SQL_MyISAM](https://github.com/cystanford/SQL_MyISAM))。我们查看下执行计划:
```
EXPLAIN SELECT * FROM test_myisam
```
<img src="https://static001.geekbang.org/resource/image/9c/c6/9c2160c4f11d023ece153fcf34f639c6.png" alt=""><br>
你能看到除了all类型外其他类型都可以使用到索引但是不同的连接方式的效率也会有所不同效率从低到高依次为all &lt; index &lt; range &lt; index_merge &lt; ref &lt; eq_ref &lt; const/system。我们在查看执行计划的时候通常希望执行计划至少可以使用到range级别以上的连接方式如果只使用到了all或者index连接方式我们可以从SQL语句和索引设计的角度上进行改进。
## 使用SHOW PROFILE查看SQL的具体执行成本
SHOW PROFILE相比EXPLAIN能看到更进一步的执行解析包括SQL都做了什么、所花费的时间等。默认情况下profiling是关闭的我们可以在会话级别开启这个功能。
```
mysql &gt; show variables like 'profiling';
```
<img src="https://static001.geekbang.org/resource/image/db/96/db06756d6d8c0614c2a7574b0f6ba896.png" alt=""><br>
通过设置`profiling='ON'`来开启show profile
```
mysql &gt; set profiling = 'ON';
```
<img src="https://static001.geekbang.org/resource/image/11/bd/113b83ee204c9e9bbd4d8bd406abafbd.png" alt=""><br>
我们可以看下当前会话都有哪些profiles使用下面这条命令
```
mysql &gt; show profiles;
```
<img src="https://static001.geekbang.org/resource/image/d0/80/d0f2ceae31f260f7b3e5f4e2a96e7280.png" alt=""><br>
你能看到当前会话一共有2个查询如果我们想要查看上一个查询的开销可以使用
```
mysql &gt; show profile;
```
<img src="https://static001.geekbang.org/resource/image/80/0d/80a0962163ddd49e728f45d2bcd9fc0d.png" alt=""><br>
我们也可以查看指定的Query ID的开销比如`show profile for query 2`查询结果是一样的。在SHOW PROFILE中我们可以查看不同部分的开销比如cpu、block.io等
<img src="https://static001.geekbang.org/resource/image/ec/83/ec4a633bff55a96aa831155a59bc1e83.png" alt=""><br>
通过上面的结果我们可以弄清楚每一步骤的耗时以及在不同部分比如CPU、block.io 的执行时间这样我们就可以判断出来SQL到底慢在哪里。
不过SHOW PROFILE命令将被弃用我们可以从information_schema中的profiling数据表进行查看。
## 总结
我今天梳理了SQL优化的思路从步骤上看我们需要先进行观察和分析分析工具的使用在日常工作中还是很重要的。今天只介绍了常用的三种分析工具实际上可以使用的分析工具还有很多。
我在这里总结一下今天文章里提到的三种分析工具。我们可以通过慢查询日志定位执行慢的SQL然后通过EXPLAIN分析该SQL语句是否使用到了索引以及具体的数据表访问方式是怎样的。我们也可以使用SHOW PROFILE进一步了解SQL每一步的执行时间包括I/O和CPU等资源的使用情况。
<img src="https://static001.geekbang.org/resource/image/47/91/47946dd726bd1b68a13799562f747291.png" alt=""><br>
今天我介绍了EXPLAIN和SHOW PROFILE这两个工具你还使用过哪些分析工具呢
另外我们在进行数据表连接的时候会有多种访问类型你可以讲一下ref、eq_ref和 const 这三种类型的区别吗?查询效率有何不同?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步。

View File

@@ -0,0 +1,159 @@
<audio id="audio" title="34丨答疑篇关于索引以及缓冲池的一些解惑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/0f/fa4af3b805cdbe60de8ab9fa7d4c420f.mp3"></audio>
这篇文章是进阶篇的最后一篇在这一模块中我主要针对SQL运行的底层原理进行了讲解其中还有很多问题没有回答我总结了进阶篇中常见的一些问题希望能对你有所帮助。下面的内容主要包括了索引原则、自适应Hash、缓冲池机制和存储引擎等。
## 关于索引B+树索引和Hash索引以及索引原则
### 什么是自适应 Hash 索引?
在回答这个问题前让我们先回顾下B+树索引和Hash索引
因为B+树可以使用到范围查找同时是按照顺序的方式对数据进行存储因此很容易对数据进行排序操作在联合索引中也可以利用部分索引键进行查询。这些情况下我们都没法使用Hash索引因为Hash索引仅能满足=&lt;&gt;和IN查询不能使用范围查询。此外Hash索引还有一个缺陷数据的存储是没有顺序的在ORDER BY的情况下使用Hash索引还需要对数据重新排序。而对于联合索引的情况Hash值是将联合索引键合并后一起来计算的无法对单独的一个键或者几个索引键进行查询。
MySQL默认使用B+树作为索引因为B+树有着Hash索引没有的优点那么为什么还需要自适应Hash索引呢这是因为Hash索引在进行数据检索的时候效率非常高通常只需要O(1)的复杂度也就是一次就可以完成数据的检索。虽然Hash索引的使用场景有很多限制但是优点也很明显所以MySQL提供了一个自适应Hash索引的功能Adaptive Hash Index。注意这里的自适应指的是不需要人工来制定系统会根据情况自动完成。
什么情况下才会使用自适应Hash索引呢如果某个数据经常被访问当满足一定条件的时候就会将这个数据页的地址存放到Hash表中。这样下次查询的时候就可以直接找到这个页面的所在位置。
需要说明的是自适应Hash索引只保存热数据经常被使用到的数据并非全表数据。因此数据量并不会很大因此自适应Hash也是存放到缓冲池中这样也进一步提升了查找效率。
InnoDB中的自适应Hash相当于“索引的索引”采用Hash索引存储的是B+树索引中的页面的地址。如下图所示:
<img src="https://static001.geekbang.org/resource/image/69/e0/692193e1df655561619cb464201ba3e0.jpg" alt=""><br>
你能看到采用自适应Hash索引目的是方便根据SQL的查询条件加速定位到叶子节点特别是当B+树比较深的时候通过自适应Hash索引可以明显提高数据的检索效率。
我们来看下自适应Hash索引的原理。
自适应Hash采用Hash函数映射到一个Hash表中如下图所示查找字典类型的数据非常方便。
Hash表是数组+链表的形式。通过Hash函数可以计算索引键值所对应的bucket的位置如果产生Hash冲突就需要遍历链表来解决。
<img src="https://static001.geekbang.org/resource/image/a6/ae/a6d510f8ca80feef8cb21b5fe55ef0ae.jpg" alt=""><br>
我们可以通过`innodb_adaptive_hash_index`变量来查看是否开启了自适应Hash比如
```
mysql&gt; show variables like '%adaptive_hash_index';
```
<img src="https://static001.geekbang.org/resource/image/70/99/70e9907a16ec51f03cb99295fafd0899.png" alt=""><br>
我来总结一下InnoDB本身不支持Hash索引但是提供自适应Hash索引不需要用户来操作存储引擎会自动完成。自适应Hash是InnoDB三大关键特性之一另外两个分别是插入缓冲和二次写。
### 什么是联合索引的最左原则?
关于联合索引的最左原则,读者@老毕 给出了一个非常形象的解释:
假设我们有x、y、z三个字段创建联合索引x, y, z之后我们可以把x、y、z分别类比成“百分位”、“十分位”和“个位”。
查询“x=9 AND y=8 AND z=7”的过程就是在一个由小到大排列的数值序列中寻找“987”可以很快找到。
查询“y=8 AND z=7”就用不上索引了因为可能存在187、287、387、487………这样就必须扫描所有数值。
我在这个基础上再补充说明一下。
查询“z=7 AND y=8 AND x=9”的时候如果三个字段x、y、z在条件查询的时候是乱序的但采用的是等值查询=或者是IN查询那么MySQL的优化器可以自动帮我们调整为可以使用联合索引的形式。
当我们查询“x=9 AND y&gt;8 AND z=7”的时候如果建立了(x,y,z)顺序的索引这时候z是用不上索引的。这是因为MySQL在匹配联合索引最左前缀的时候如果遇到了范围查询比如&lt;&gt;和between等就会停止匹配。索引列最多作用于一个范围列对于后面的Z来说就没法使用到索引了。
通过这个我们也可以知道联合索引的最左前缀匹配原则针对的是创建的联合索引中的顺序如果创建了联合索引x,y,z那么这个索引的使用顺序就很重要了。如果在条件语句中只有y和z那么就用不上联合索引。
此外SQL条件语句中的字段顺序并不重要因为在逻辑查询优化阶段会自动进行查询重写。
最后你需要记住,如果我们遇到了范围条件查询,比如(&lt;&lt;=&gt;&gt;=和between等那么范围列后的列就无法使用到索引了。
### Hash索引与B+树索引是在建索引的时候手动指定的吗?
如果使用的是MySQL的话我们需要了解MySQL的存储引擎都支持哪些索引结构如下图所示参考来源 [https://dev.mysql.com/doc/refman/8.0/en/create-index.html](https://dev.mysql.com/doc/refman/8.0/en/create-index.html)。如果是其他的DBMS可以参考相关的DBMS文档。
<img src="https://static001.geekbang.org/resource/image/f7/38/f7706327f9ebc7488653d69b4cd5f438.png" alt=""><br>
你能看到针对InnoDB和MyISAM存储引擎都会默认采用B+树索引无法使用Hash索引。InnoDB提供的自适应Hash是不需要手动指定的。如果是Memory/Heap和NDB存储引擎是可以进行选择Hash索引的。
## 关于缓冲池
### 缓冲池和查询缓存是一个东西吗?
首先我们需要了解在InnoDB存储引擎中缓冲池都包括了哪些。
在InnoDB存储引擎中有一部分数据会放到内存中缓冲池则占了这部分内存的大部分它用来存储各种数据的缓存如下图所示
<img src="https://static001.geekbang.org/resource/image/0e/dc/0eb57c0d0ea7611b16ac6efa76771bdc.jpg" alt=""><br>
从图中你能看到InnoDB缓冲池包括了数据页、索引页、插入缓冲、锁信息、自适应Hash和数据字典信息等。
我们之前讲过使用缓冲池技术的原因。这里重新回顾一下。InnoDB存储引擎基于磁盘文件存储访问物理硬盘和在内存中进行访问速度相差很大为了尽可能弥补这两者之间I/O效率的差值我们就需要把经常使用的数据加载到缓冲池中避免每次访问都进行磁盘I/O。
“频次*位置”这个原则可以帮我们对I/O访问效率进行优化。
首先,位置决定效率,提供缓冲池就是为了在内存中可以直接访问数据。
其次频次决定优先级顺序。因为缓冲池的大小是有限的比如磁盘有200G但是内存只有16G缓冲池大小只有1G就无法将所有数据都加载到缓冲池里这时就涉及到优先级顺序会优先对使用频次高的热数据进行加载。
了解了缓冲池的作用之后,我们还需要了解缓冲池的另一个特性:预读。
缓冲池的作用就是提升I/O效率而我们进行读取数据的时候存在一个“局部性原理”也就是说我们使用了一些数据大概率还会使用它周围的一些数据因此采用“预读”的机制提前加载可以减少未来可能的磁盘I/O操作。
那么什么是查询缓存呢?
查询缓存是提前把查询结果缓存起来这样下次不需要执行就可以直接拿到结果。需要说明的是在MySQL中的查询缓存不是缓存查询计划而是查询对应的结果。这就意味着查询匹配的鲁棒性大大降低只有相同的查询操作才会命中查询缓存。因此MySQL的查询缓存命中率不高在MySQL8.0版本中已经弃用了查询缓存功能。
查看是否使用了查询缓存,使用命令:
```
show variables like '%query_cache%';
```
<img src="https://static001.geekbang.org/resource/image/cb/c7/cb590bd0aac9751401943487534360c7.png" alt=""><br>
缓冲池并不等于查询缓存它们的共同点都是通过缓存的机制来提升效率。但缓冲池服务于数据库整体的I/O操作而查询缓存服务于SQL查询和查询结果集的因为命中条件苛刻而且只要数据表发生变化查询缓存就会失效因此命中率低。
## 其他
很多人对InnoDB和MyISAM的取舍存在疑问到底选择哪个比较好呢
我们需要先了解InnoDB和MyISAM各自的特点。InnoDB支持事务和行级锁是MySQL默认的存储引擎MyISAM只支持表级锁不支持事务更适合读取数据库的情况。
如果是小型的应用需要大量的SELECT查询可以考虑MyISAM如果是事务处理应用需要选择InnoDB。
这两种引擎各有特点当然你也可以在MySQL中针对不同的数据表可以选择不同的存储引擎。
最后给大家提供一下专栏中学习资料的下载。
如果你想导入文章中的“product_comment”表结构和数据点击[这里](https://github.com/cystanford/product_comment)即可。你也可以在[网盘](https://pan.baidu.com/s/1LBEAm50DDP9AjErLtGplLg)里下载提取码为32ep。
关于文章中涉及到的思维导图,点击[这里](https://github.com/cystanford/SQL-XMind)下载即可。
最后留一道思考题,供你消化今天答疑篇里的内容。
假设我们有x、y、z三个字段创建联合索引x, y, z。数据表中的数据量比较大那么对下面语句进行SQL查询的时候哪个会使用联合索引如果使用了联合索引分别使用到了联合索引的哪些部分
A
```
SELECT x, y, z FROM table WHERE y=2 AND x&gt;1 AND z=3
```
B
```
SELECT x, y, z FROM table WHERE y=2 AND x=1 AND z&gt;3
```
C
```
SELECT x, y, z FROM table WHERE y=2 AND x=1 AND z=3
```
D
```
SELECT x, y, z FROM table WHERE y&gt;2 AND x=1 AND z=3
```
欢迎你在评论区写下你的答案,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,102 @@
<audio id="audio" title="35丨数据库主从同步的作用是什么如何解决数据不一致问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/c3/3b716c725310d70b0ae6b8985fdd6bc3.mp3"></audio>
我们之前讲解了Redis它是一种高性能的内存数据库而MySQL是基于磁盘文件的关系型数据库相比于Redis来说读取速度会慢一些但是功能强大可以用于存储持久化的数据。在实际工作中我们常常将Redis作为缓存与MySQL配合来使用当有数据访问请求的时候首先会从缓存中进行查找如果存在就直接取出如果不存在再访问数据库这样就提升了读取的效率也减少了对后端数据库的访问压力。可以说使用Redis这种缓存架构是高并发架构中非常重要的一环。
<img src="https://static001.geekbang.org/resource/image/96/ad/968bc668e91383a203cbd811021fb9ad.jpg" alt=""><br>
当然我们也可以对MySQL做主从架构并且进行读写分离让主服务器Master处理写请求从服务器Slave处理读请求这样同样可以提升数据库的并发处理能力。不过主从架构的作用不止于此我们今天就从以下几个方面了解一下它
1. 为什么需要主从同步,设置主从同步有什么样的作用?
1. 主从同步的原理是怎样的?在进行主从同步的同时,会引入哪些问题?
1. 为了保证主从同步的数据一致性,都有哪些方案?
## 为什么需要主从同步
首先不是所有的应用都需要对数据库进行主从架构的设置毕竟设置架构本身是有成本的如果我们的目的在于提升数据库高并发访问的效率那么首先需要考虑的应该是如何优化你的SQL和索引这种方式简单有效其次才是采用缓存的策略比如使用Redis通过Redis高性能的优势将热点数据保存在内存数据库中提升读取的效率最后才是对数据库采用主从架构进行读写分离。
按照上面的方式进行优化,使用和维护的成本是由低到高的。
主从同步设计不仅可以提高数据库的吞吐量还有以下3个方面的作用。
首先是可以读写分离。我们可以通过主从复制的方式来同步数据,然后通过读写分离提高数据库并发处理能力。
简单来说就是同一份数据被放到了多个数据库中其中一个数据库是Master主库其余的多个数据库是Slave从库。当主库进行更新的时候会自动将数据复制到从库中而我们在客户端读取数据的时候会从从库中进行读取也就是采用读写分离的方式。互联网的应用往往是一些“读多写少”的需求采用读写分离的方式可以实现更高的并发访问。原本所有的读写压力都由一台服务器承担现在有多个“兄弟”帮忙处理读请求这样就减少了对后端大哥Master的压力。同时我们还能对从服务器进行负载均衡让不同的读请求按照策略均匀地分发到不同的从服务器上让读取更加顺畅。读取顺畅的另一个原因就是减少了锁表的影响比如我们让主库负责写当主库出现写锁的时候不会影响到从库进行SELECT的读取。
第二个作用就是数据备份。我们通过主从复制将主库上的数据复制到了从库上,相当于是一种热备份机制,也就是在主库正常运行的情况下进行的备份,不会影响到服务。
第三个作用是具有高可用性。我刚才讲到的数据备份实际上是一种冗余的机制,通过这种冗余的方式可以换取数据库的高可用性,也就是当服务器出现故障或宕机的情况下,可以切换到从服务器上,保证服务的正常运行。
关于高可用性的程度,我们可以用一个指标衡量,即正常可用时间/全年时间。比如要达到全年99.999%的时间都可用就意味着系统在一年中的不可用时间不得超过5.256分钟也就365*24*60*1-99.999%=5.256分钟其他时间都需要保持可用的状态。需要注意的是这5.256分钟包括了系统崩溃的时间,也包括了日常维护操作导致的停机时间。
实际上,更高的高可用性,意味着需要付出更高的成本代价。在现实中我们需要结合业务需求和成本来进行选择。
## 主从同步的原理是怎样的
提到主从同步的原理我们就需要了解在数据库中的一个重要日志文件那就是Binlog二进制日志它记录了对数据库进行更新的事件。实际上主从同步的原理就是基于Binlog进行数据同步的。在主从复制过程中会基于3个线程来操作一个主库线程两个从库线程。
二进制日志转储线程Binlog dump thread是一个主库线程。当从库线程连接的时候主库可以将二进制日志发送给从库当主库读取事件的时候会在Binlog上加锁读取完成之后再将锁释放掉。
从库I/O线程会连接到主库向主库发送请求更新Binlog。这时从库的I/O线程就可以读取到主库的二进制日志转储线程发送的Binlog更新部分并且拷贝到本地形成中继日志Relay log
从库SQL线程会读取从库中的中继日志并且执行日志中的事件从而将从库中的数据与主库保持同步。
<img src="https://static001.geekbang.org/resource/image/63/31/637d392dbcdacf14cbb2791085a62b31.jpg" alt=""><br>
所以你能看到主从同步的内容就是二进制日志Binlog它虽然叫二进制日志实际上存储的是一个又一个事件Event这些事件分别对应着数据库的更新操作比如INSERT、UPDATE、DELETE等。另外我们还需要注意的是不是所有版本的MySQL都默认开启服务器的二进制日志在进行主从同步的时候我们需要先检查服务器是否已经开启了二进制日志。
进行主从同步的内容是二进制日志它是一个文件在进行网络传输的过程中就一定会存在延迟比如500ms这样就可能造成用户在从库上读取的数据不是最新的数据也就是主从同步中的数据不一致性问题。比如我们对一条记录进行更新这个操作是在主库上完成的而在很短的时间内比如100ms又对同一个记录进行了读取这时候从库还没有完成数据的更新那么我们通过从库读到的数据就是一条旧的记录。
这种情况下该怎么办呢?
## 如何解决主从同步的数据一致性问题
可以想象下,如果我们想要操作的数据都存储在同一个数据库中,那么对数据进行更新的时候,可以对记录加写锁,这样在读取的时候就不会发生数据不一致的情况,但这时从库的作用就是备份,并没有起到读写分离,分担主库读压力的作用。
<img src="https://static001.geekbang.org/resource/image/5e/47/5ec767c975f834a494596f1640e9fa47.jpg" alt=""><br>
因此我们还需要继续想办法在进行读写分离的同时解决主从同步中数据不一致的问题也就是解决主从之间数据复制方式的问题如果按照数据一致性从弱到强来进行划分有以下3种复制方式。
### 方法1异步复制
异步模式就是客户端提交COMMIT之后不需要等从库返回任何结果而是直接将结果返回给客户端这样做的好处是不会影响主库写的效率但可能会存在主库宕机而Binlog还没有同步到从库的情况也就是此时的主库和从库数据不一致。这时候从从库中选择一个作为新主那么新主则可能缺少原来主服务器中已提交的事务。所以这种复制模式下的数据一致性是最弱的。
<img src="https://static001.geekbang.org/resource/image/16/85/1664bdb81d017359a126030ee08e0a85.png" alt="">
### 方法2半同步复制
MySQL5.5版本之后开始支持半同步复制的方式。原理是在客户端提交COMMIT之后不直接将结果返回给客户端而是等待至少有一个从库接收到了Binlog并且写入到中继日志中再返回给客户端。这样做的好处就是提高了数据的一致性当然相比于异步复制来说至少多增加了一个网络连接的延迟降低了主库写的效率。
在MySQL5.7版本中还增加了一个`rpl_semi_sync_master_wait_for_slave_count`参数我们可以对应答的从库数量进行设置默认为1也就是说只要有1个从库进行了响应就可以返回给客户端。如果将这个参数调大可以提升数据一致性的强度但也会增加主库等待从库响应的时间。
<img src="https://static001.geekbang.org/resource/image/08/a1/08566325d0933775d13196330596a1a1.jpg" alt="">
### 方法3组复制
组复制技术简称MGRMySQL Group Replication。是MySQL在5.7.17版本中推出的一种新的数据复制技术这种复制技术是基于Paxos协议的状态机复制。
我刚才介绍的异步复制和半同步复制都无法最终保证数据的一致性问题半同步复制是通过判断从库响应的个数来决定是否返回给客户端虽然数据一致性相比于异步复制有提升但仍然无法满足对数据一致性要求高的场景比如金融领域。MGR很好地弥补了这两种复制模式的不足。
下面我们来看下MGR是如何工作的如下图所示
首先我们将多个节点共同组成一个复制组在执行读写RW事务的时候需要通过一致性协议层Consensus层的同意也就是读写事务想要进行提交必须要经过组里“大多数人”对应Node节点的同意大多数指的是同意的节点数量需要大于N/2+1这样才可以进行提交而不是原发起方一个说了算。而针对只读RO事务则不需要经过组内同意直接COMMIT即可。
在一个复制组内有多个节点组成,它们各自维护了自己的数据副本,并且在一致性协议层实现了原子消息和全局有序消息,从而保证组内数据的一致性。(具体原理[点击这里](https://dev.mysql.com/doc/refman/5.7/en/group-replication-summary.html)可以参考。)
<img src="https://static001.geekbang.org/resource/image/39/ab/39cc2dd2a96e27dbdef3dc87aa8d15ab.png" alt=""><br>
MGR将MySQL带入了数据强一致性的时代是一个划时代的创新其中一个重要的原因就是MGR是基于Paxos协议的。Paxos算法是由2013年的图灵奖获得者Leslie Lamport于1990年提出的有关这个算法的决策机制你可以去网上搜一下。或者[点击这里](http://lamport.azurewebsites.net/pubs/lamport-paxos.pdf)查看具体的算法另外作者在2001年发布了一篇[简化版的文章](http://lamport.azurewebsites.net/pubs/paxos-simple.pdf),你如果感兴趣的话,也可以看下。
事实上Paxos算法提出来之后就作为分布式一致性算法被广泛应用比如Apache的ZooKeeper也是基于Paxos实现的。
## 总结
我今天讲解了数据库的主从同步如果你的目标仅仅是数据库的高并发那么可以先从SQL优化索引以及Redis缓存数据库这些方面来考虑优化然后再考虑是否采用主从架构的方式。
在主从架构的配置中,如果想要采取读写分离的策略,我们可以自己编写程序,也可以通过第三方的中间件来实现。
自己编写程序的好处就在于比较自主,我们可以自己判断哪些查询在从库上来执行,针对实时性要求高的需求,我们还可以考虑哪些查询可以在主库上执行。同时,程序直接连接数据库,减少了中间件层,相当于减少了性能损耗。
采用中间件的方法有很明显的优势功能强大使用简单。但因为在客户端和数据库之间增加了中间件层会有一些性能损耗同时商业中间件也是有使用成本的。我们也可以考虑采取一些优秀的开源工具比如MaxScale。它是MariaDB开发的MySQL数据中间件。比如在下图中使用MaxScale作为数据库的代理通过路由转发完成了读写分离。同时我们也可以使用MHA工具作为强一致的主从切换工具从而完成MySQL的高可用架构。
<img src="https://static001.geekbang.org/resource/image/39/94/392a43c1d483392349c165f9f9f1d994.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/b7/48/b77a5eadce72eef7a56211ba2a1bf548.png" alt=""><br>
今天讲的概念有点多你能说一下主从复制、读写分离、负载均衡的概念吗另外你不妨用自己的话说一下你对MGR的理解。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="36丨数据库没有备份没有使用Binlog的情况下如何恢复数据" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/19/4d3a8cabc41a443b20e7bbba50646e19.mp3"></audio>
我们上节课讲解了MySQL的复制技术通过主从同步可以实现读写分离热备份让服务器更加高可用。MySQL的复制主要是通过Binlog来完成的Binlog记录了数据库更新的事件从库I/O线程会向主库发送Binlog更新的请求同时主库二进制转储线程会发送Binlog给从库作为中继日志进行保存然后从库会通过中继日志重放完成数据库的同步更新。这种同步操作是近乎实时的同步然而也有人为误操作情况的发生比如DBA人员为了方便直接在生产环境中对数据进行操作或者忘记了当前是在开发环境还是在生产环境中就直接对数据库进行操作这样很有可能会造成数据的丢失情况严重时误操作还有可能同步给从库实时更新。不过我们依然有一些策略可以防止这种误操作比如利用延迟备份的机制。延迟备份最大的作用就是避免这种“手抖”的情况让我们在延迟从库进行误操作前停止下来进行数据库的恢复。
当然如果我们对数据库做过时间点备份也可以直接恢复到该时间点。不过我们今天要讨论的是一个特殊的情况也就是在没做数据库备份没有开启使用Binlog的情况下尽可能地找回数据。
今天的内容主要包括以下几个部分:
1. InnoDB存储引擎中的表空间是怎样的两种表空间存储方式各有哪些优缺点
1. 如果.ibd文件损坏了数据该如何找回
1. 如何模拟InnoDB文件的损坏与数据恢复
## InnoDB存储引擎的表空间
InnoDB存储引擎的文件格式是.ibd文件数据会按照表空间tablespace进行存储分为共享表空间和独立表空间。如果想要查看表空间的存储方式我们可以对`innodb_file_per_table`变量进行查询,使用`show variables like 'innodb_file_per_table';`。ON表示独立表空间而OFF则表示共享表空间。
<img src="https://static001.geekbang.org/resource/image/7d/0d/7dc5aae75a7d5d2c599c7cb8dd97440d.png" alt=""><br>
如果采用共享表空间的模式InnoDB存储的表数据都会放到共享表空间中也就是多个数据表共用一个表空间同时表空间也会自动分成多个文件存放到磁盘上。这样做的好处在于单个数据表的大小可以突破文件系统大小的限制最大可以达到64TB也就是InnoDB存储引擎表空间的上限。不足也很明显多个数据表存放到一起结构不清晰不利于数据的找回同时将所有数据和索引都存放到一个文件中也会使得共享表空间的文件很大。
采用独立表空间的方式可以让每个数据表都有自己的物理文件也就是table_name.ibd的文件在这个文件中保存了数据表中的数据、索引、表的内部数据字典等信息。它的优势在于每张表都相互独立不会影响到其他数据表存储结构清晰利于数据恢复同时数据表还可以在不同的数据库之间进行迁移。
## 如果.ibd文件损坏了数据如何找回
如果我们之前没有做过全量备份也没有开启Binlog那么我们还可以通过.ibd文件进行数据恢复采用独立表空间的方式可以很方便地对数据库进行迁移和分析。如果我们误删除DELETE某个数据表或者某些数据行也可以采用第三方工具回数据。
我们这里可以使用Percona Data Recovery Tool for InnoDB工具能使用工具进行修复是因为我们在使用DELETE的时候是逻辑删除。我们之前学习过InnoDB的页结构在保存数据行的时候还有个删除标记位对应的是页结构中的delete_mask属性该属性为1的时候标记了记录已经被逻辑删除实际上并不是真的删除。不过当有新的记录插入的时候被删除的行记录可能会被覆盖掉。所以当我们发生了DELETE误删除的时候一定要第一时间停止对误删除的表进行更新和写入及时将.ibd文件拷贝出来并进行修复。
如果已经开启了Binlog就可以使用闪回工具比如mysqlbinlog或者binlog2sql从工具名称中也能看出来它们都是基于Binlog来做的闪回。原理就是因为Binlog文件本身保存了数据库更新的事件Event通过这些事件可以帮我们重现数据库的所有更新变化也就是Binlog回滚。
下面我们就来看下没有做过备份也没有开启Binlog的情况下如果.ibd文件发生了损坏如何通过数据库自身的机制来进行数据恢复。
实际上InnoDB是有自动恢复机制的如果发生了意外InnoDB可以在读取数据表时自动修复错误。但有时候.ibd文件损坏了会导致数据库无法正常读取数据表这时我们就需要人工介入调整一个参数这个参数叫做`innodb_force_recovery`
我们可以通过命令`show variables like 'innodb_force_recovery';`来查看当前参数的状态你能看到默认为0表示不进行强制恢复。如果遇到错误比如ibd文件中的数据页发生损坏则无法读取数据会发生MySQL宕机的情况此时会将错误日志记录下来。
<img src="https://static001.geekbang.org/resource/image/6e/ba/6edf81f6402311ca7f8ee8619b656fba.png" alt=""><br>
`innodb_force_recovery`参数一共有7种状态除了默认的0以外还可以为1-6的取值分别代表不同的强制恢复措施。
当我们需要强制恢复的时候,可以将`innodb_force_recovery`设置为1表示即使发现了损坏页也可以继续让服务运行这样我们就可以读取数据表并且对当前损坏的数据表进行分析和备份。
通常`innodb_force_recovery`参数设置为1只要能正常读取数据表即可。但如果参数设置为1之后还无法读取数据表我们可以将参数逐一增加比如2、3等。一般来说不需要将参数设置到4或以上因为这有可能对数据文件造成永久破坏。另外当`innodb_force_recovery`设置为大于0时相当于对InnoDB进行了写保护只能进行SELECT读取操作还是有限制的读取对于WHERE条件以及ORDER BY都无法进行操作。
当我们开启了强制恢复之后,数据库的功能会受到很多限制,我们需要尽快把有问题的数据表备份出来,完成数据恢复操作。整体的恢复步骤可以按照下面的思路进行:
1.使用`innodb_force_recovery`启动服务器
`innodb_force_recovery`参数设置为1启动数据库。如果数据表不能正常读取需要调大参数直到能读取数据为止。通常设置为1即可。
2.备份数据表
在备份数据之前需要准备一个新的数据表这里需要使用MyISAM存储引擎。原因很简单InnoDB存储引擎已经写保护了无法将数据备份出来。然后将损坏的InnoDB数据表备份到新的MyISAM数据表中。
3.删除旧表,改名新表
数据备份完成之后我们可以删除掉原有损坏的InnoDB数据表然后将新表进行改名。
4.关闭`innodb_force_recovery`,并重启数据库
`innodb_force_recovery`大于1的时候会有很多限制我们需要将该功能关闭然后重启数据库并且将数据表的MyISAM存储引擎更新为InnoDB存储引擎。
## InnoDB文件的损坏与恢复实例
我们刚才说了InnoDB文件损坏时的人工操作过程下面我们用一个例子来模拟下。
### 生成InnoDB数据表
为了简便我们创建一个数据表t1只有id一个字段类型为int。使用命令`create table t1(id int);`即可。
<img src="https://static001.geekbang.org/resource/image/0c/34/0c2790f539d4f35aec6dd0703b059134.png" alt=""><br>
然后创建一个存储过程帮我们生成一些数据:
```
BEGIN
-- 当前数据行
DECLARE i INT DEFAULT 0;
-- 最大数据行数
DECLARE max_num INT DEFAULT 100;
-- 关闭自动提交
SET autocommit=0;
REPEAT
SET i=i+1;
-- 向t1表中插入数据
INSERT INTO t1(id) VALUES(i);
UNTIL i = max_num
END REPEAT;
-- 提交事务
COMMIT;
END
```
然后我们运行`call insert_t1()`这个存储过程帮我们插入了100条数据这样我们就有了t1.ibd这个文件。
### 模拟损坏.ibd文件
实际工作中我们可能会遇到各种各样的情况,比如.ibd文件损坏等如果遇到了数据文件的损坏MySQL是无法正常读取的。在模拟损坏.ibd文件之前我们需要先关闭掉MySQL服务然后用编辑器打开t1.ibd类似下图所示
<img src="https://static001.geekbang.org/resource/image/d3/7c/d34734f6dfbf0e01cf4a40bb549e147c.png" alt=""><br>
文件是有二进制编码的看不懂没有关系我们只需要破坏其中的一些内容即可比如我在t1.ibd文件中删除了2行内容文件大部分内容为0我们在文件中间部分找到一些非0的取值然后删除其中的两行4284行与4285行原ibd文件和损坏后的ibd文件见[GitHub](https://github.com/cystanford/innodb_force_recovery)[地址](https://github.com/cystanford/innodb_force_recovery)。其中t1.ibd为创建的原始数据文件,t1-损坏.ibd为损坏后的数据文件你需要自己创建t1数据表然后将t1-损坏.ibd拷贝到本地并改名为t1.ibd
然后我们保存文件,这时.ibd文件发生了损坏如果我们没有打开`innodb_force_recovery`那么数据文件无法正常读取。为了能读取到数据表中的数据我们需要修改MySQL的配置文件找到`[mysqld]`的位置,然后再下面增加一行`innodb_force_recovery=1`
<img src="https://static001.geekbang.org/resource/image/a3/94/a3a226bbd1b435393039b75618be6e94.png" alt="">
### 备份数据表
当我们设置`innodb_force_recovery`参数为1的时候可以读取到数据表t1中的数据但是数据不全。我们使用`SELECT * FROM t1 LIMIT 10;`读取当前前10条数据。
<img src="https://static001.geekbang.org/resource/image/76/0a/761a5495f9769de35f60f112e4d94b0a.png" alt=""><br>
但是如果我们想要完整的数据,使用`SELECT * FROM t1 LIMIT 100;`就会发生如下错误。
<img src="https://static001.geekbang.org/resource/image/62/93/623a6cd6987f5ac11aad432257edcc93.png" alt=""><br>
这是因为读取的部分包含了已损坏的数据页我们可以采用二分查找判断数据页损坏的位置。这里我们通过实验可以得出只有最后一个记录行收到了损坏而前99条记录都可以正确读出具体实验过程省略
这样我们就能判断出来有效的数据行的位置从而将它们备份出来。首先我们创建一个相同的表结构t2存储引擎设置为MyISAM。我刚才讲过这里使用MyISAM存储引擎是因为在`innodb_force_recovery=1`的情况下无法对innodb数据表进行写数据。使用命令`CREATE TABLE t2(id int) ENGINE=MyISAM;`
然后我们将数据表t1中的前99行数据复制给t2数据表使用
```
INSERT INTO t2 SELECT * FROM t1 LIMIT 99;
```
<img src="https://static001.geekbang.org/resource/image/a9/38/a91d4a2d259bba1c2aeb9a5b95619238.png" alt=""><br>
我们刚才讲过在分析t1数据表的时候无法使用WHERE以及ORDER BY等子句这里我们可以实验一下如果想要查询id&lt;10的数据行都有哪些那么会发生如下错误。原因是损坏的数据页无法进行条件判断。
<img src="https://static001.geekbang.org/resource/image/fc/9d/fc00a89e22a301826bbdd7b37d7b7c9d.png" alt="">
### 删除旧表,改名新表
刚才我们已经恢复了大部分的数据。虽然还有一行记录没有恢复,但是能找到绝大部分的数据也是好的。然后我们就需要把之前旧的数据表删除掉,使用`DROP TABLE t1;`
<img src="https://static001.geekbang.org/resource/image/f3/47/f30d79228572382810fd388ded1ff447.png" alt=""><br>
更新表名将数据表名称由t2改成t1使用`RENAME TABLE t2 to t1;`
<img src="https://static001.geekbang.org/resource/image/23/98/23af300e09c2fd5afafe1cec849d7f98.png" alt=""><br>
将新的数据表t1存储引擎改成InnoDB不过直接修改的话会报如下错误
<img src="https://static001.geekbang.org/resource/image/d1/a6/d102e7ec02a228529b26bd070601b9a6.png" alt="">
### 关闭`innodb_force_recovery`,并重启数据库
因为上面报错所以我们需要将MySQL配置文件中的`innodb_force_recovery=1`删除掉然后重启数据库。最后将t1的存储引擎改成InnoDB即可使用`ALTER TABLE t1 engine = InnoDB;`
<img src="https://static001.geekbang.org/resource/image/ab/1b/abc06822471b4eb4036de1504840df1b.png" alt="">
## 总结
我们刚才人工恢复了损坏的ibd文件中的数据虽然没有100%找回但是相比于束手无措来说已经是不幸中的万幸至少我们还可以把正确的数据页中的记录成功备份出来尽可能恢复原有的数据表。在这个过程中相信你应该对ibd文件以及InnoDB自身的强制恢复Force Recovery机制有更深的了解。
数据表损坏以及人为的误删除都不是我们想要看到的情况但是我们不能指望运气或者说我们不能祈祷这些事情不会发生。在遇到这些情况的时候应该通过机制尽量保证数据库的安全稳定运行。这个过程最主要的就是应该及时备份并且开启二进制日志这样当有误操作的时候就可以通过数据库备份以及Binlog日志来完成数据恢复。同时采用延迟备份的策略也可以尽量抵御误操作。总之及时备份是非常有必要的措施同时我们还需要定时验证备份文件的有效性保证备份文件可以正常使用。
如果你遇到了数据库ibd文件损坏的情况并且没有采用任何的备份策略可以尝试使用InnoDB的强制恢复机制启动MySQL并且将损坏的数据表转储到MyISAM数据表中尽可能恢复已有的数据。总之机制比人为更靠谱我们要为长期的运营做好充足的准备。一旦发生了误操作这种紧急情况不要慌张及时采取对应的措施才是最重要的。
<img src="https://static001.geekbang.org/resource/image/0b/b0/0bce932aae90fbd40a72454d84fad9b0.png" alt=""><br>
今天的内容到这里就结束了,我想问问,在日常工作中,你是否遇到过误操作的情况呢?你又是如何解决的?除了我上面介绍的机制外,还有哪些备份的机制可以增强数据的安全性?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,256 @@
<audio id="audio" title="37丨SQL注入你的SQL是如何被注入的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/64/3531b6c1e8853fb630352cb04dcea964.mp3"></audio>
我们之前已经讲解了SQL的使用及优化正常的SQL调用可以帮我们从数据库中获取想要的数据然而我们构建的Web应用是个应用程序本身也可能存在安全漏洞如果不加以注意就会出现Web安全的隐患比如通过非正常的方式注入SQL。
在过去的几年中我们也能经常看到用户信息被泄露出现这种情况很大程度上和SQL注入有关。所以了解SQL注入的原理以及防范还是非常有必要的。
今天我们就通过一个简单的练习看下SQL注入的过程是怎样的内容主要包括以下几个部分
1. SQL注入的原理。为什么用户可以通过URL请求或者提交Web表单的方式提交非法SQL命令从而访问数据库
1. 如何使用sqli-labs注入平台进行第一个SQL注入实验
1. 如何使用SQLmap完成SQL注入检测
## SQL注入的原理
SQL注入也叫作SQL Injection它指的是将非法的SQL命令插入到URL或者Web表单中进行请求而这些请求被服务器认为是正常的SQL语句从而进行执行。也就是说如果我们想要进行SQL注入可以将想要执行的SQL代码隐藏在输入的信息中而机器无法识别出来这些内容是用户信息还是SQL代码在后台处理过程中这些输入的SQL语句会显现出来并执行从而导致数据泄露甚至被更改或删除。
为什么我们可以将SQL语句隐藏在输入的信息中呢这里举一个简单的例子。
比如下面的PHP代码将浏览器发送过来的URL请求通过GET方式获取ID参数赋值给$id变量然后通过字符串拼接的方式组成了SQL语句。这里我们没有对传入的ID参数做校验而是采用了直接拼接的方式这样就可能产生SQL注入。
```
$id=$_GET['id'];
$sql=&quot;SELECT * FROM users WHERE id='$id' LIMIT 0,1&quot;;
$result=mysql_query($sql);
$row = mysql_fetch_array($result);
```
如果我们在URL中的?id=后面输入’ or 1=1 --+那么SQL语句就变成了下面这样
```
SELECT * FROM users WHERE id='' or 1=1 -- LIMIT 0,1
```
其中我们输入的(+在浏览器URL中相当于空格而输入的在SQL中表示注释语句它会将后面的SQL内容都注释掉这样整个SQL就相当于是从users表中获取全部的数据。然后我们使用mysql_fetch_array从结果中获取一条记录这时即使ID输入不正确也没有关系同样可以获取数据表中的第一行记录。
## 一个SQL注入的实例
通常我们希望通过SQL注入可以获取更多的信息比如数据库的名称、数据表名称和字段名等。下面我们通过一个简单的SQL实例来操作一下。
### 搭建sqli-labs注入环境
首先我们需要搭建sqli-labs注入环境在这个项目中我们会面临75个SQL注入的挑战你可以像游戏闯关一样对SQL注入的原理进行学习。
下面的步骤是关于如何在本地搭建sqli-labs注入环境的成功搭建好的环境类似[链接](http://43.247.91.228:84/)里展现的。
第一步下载sqli-labs。
sqli-labs是一个开源的SQL注入平台你可以从[GitHub](https://github.com/audi-1/sqli-labs)上下载它。
第二步配置PHP、Apache环境可以使用phpStudy工具
运行sqli-labs需要PHP、Apache环境如果你之前没有安装过它们可以直接使用phpStudy这个工具它不仅集成了PHP、Apache和MySQL还可以方便地指定PHP的版本。在今天的项目中我使用的是PHP5.4.45版本。
<img src="https://static001.geekbang.org/resource/image/ec/f4/ecd7853d8aa4523735643d0aa67ce5f4.png" alt=""><br>
第三步配置sqli-labs及MySQL参数。
首先我们需要给sqli-labs指定需要访问的数据库账户密码对应`sqli-labs-master\sql-connections\db-creds.inc`文件,这里我们需要修改`$dbpass`参数改成自己的MySQL的密码。
<img src="https://static001.geekbang.org/resource/image/a2/84/a2b0bcedf13d2f6d4d0e77553c4a9484.png" alt=""><br>
此时我们访问本地的`sqli-labs`项目`http://localhost/sqli-labs-master/`出现如下页面,需要先启动数据库,选择`Setup/reset Database for labs`即可。
<img src="https://static001.geekbang.org/resource/image/3d/a9/3d6bdc6a81df7ad708903a015233cba9.png" alt=""><br>
如果此时提示数据库连接错误可能需要我们手动修改MySQL的配置文件需要调整的参数如下所示修改MySQL密码验证方式为使用明文同时设置MySQL默认的编码方式
```
[client]
default-character-set=utf8
[mysql]
default-character-set=utf8
[mysqld]
character-set-server = utf8
default_authentication_plugin = mysql_native_password
```
### 第一个SQL注入挑战
在我们成功对sqli-labs进行了配置现在可以进入到第一关挑战环节。访问本地的`http://localhost/sqli-labs-master/Less-1/`页面,如下所示:
<img src="https://static001.geekbang.org/resource/image/42/74/4274d6de133192f7a1dd0348c75c0e74.png" alt=""><br>
我们可以在URL后面加上ID参数获取指定ID的信息比如`http://localhost/sqli-labs-master/Less-1/?id=1`
这些都是正常的访问请求现在我们可以通过1 or 1=1来判断ID参数的查询类型访问`http://localhost/sqli-labs-master/Less-1/?id=1 or 1=1`
<img src="https://static001.geekbang.org/resource/image/ea/41/eab6701bd0a40980b180d99e295b1841.png" alt=""><br>
你可以看到依然可以正常访问证明ID参数不是数值查询然后我们在1后面增加个单引号来查看下返回结果访问`http://localhost/sqli-labs-master/Less-1/?id=1'`
这时数据库报错并且在页面上返回了错误信息You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 1 LIMIT 0,1 at line 1。
我们对这个错误进行分析,首先`''1'' LIMIT 0,1'`这个语句,我们去掉最外层的单引号,得到`'1'' LIMIT 0,1`,因为我们输入的参数是`1'`,继续去掉`1'`,得到`'' LIMIT 0,1`。这样我们就能判断出后台的SQL语句类似于下面这样
```
$sql=&quot;SELECT ... FROM ... WHERE id='$id' LIMIT 0,1&quot;;
```
两处省略号的地方分别代表SELECT语句中的字段名和数据表名称。
### 判断查询语句的字段数
现在我们已经对后台的SQL查询已经有了大致的判断它是通过字符串拼接完成的SQL查询。现在我们再来判断下这个查询语句中的字段个数通常可以在输入的查询内容后面加上 ORDER BY X这里X是我们估计的字段个数。如果X数值大于SELECT查询的字段数则会报错。根据这个原理我们可以尝试通过不同的X来判断SELECT查询的字段个数这里我们通过下面两个URL可以判断出来SELECT查询的字段数为3个
报错:
```
http://localhost/sqli-labs-master/Less-1/?id=1' order by 4 --+
```
正确:
```
http://localhost/sqli-labs-master/Less-1/?id=1' order by 3 --+
```
### 获取当前数据库和用户信息
下面我们通过SQL注入来获取想要的信息比如想要获取当前数据库和用户信息。
这里我们使用UNION操作符。在MySQL中UNION操作符前后两个SELECT语句的查询结构必须一致。刚才我们已经通过实验判断出查询语句的字段个数为3因此在构造UNION后面的查询语句时也需要查询3个字段。这里我们可以使用`SELECT 1,database(),user()`也就是使用默认值1来作为第一个字段整个URL为`http://localhost/sqli-labs-master/Less-1/?id=' union select 1,database(),user() --+`
<img src="https://static001.geekbang.org/resource/image/a1/cd/a186b5758d80983b4d34818169b719cd.png" alt=""><br>
页面中显示的`security`即为当前的数据库名称,`root@localhost`为当前的用户信息。
### 获取MySQL中的所有数据库名称
我们还想知道当前MySQL中所有的数据库名称都有哪些数据库名称数量肯定会大于1因此这里我们需要使用`GROUP_CONCAT`函数,这个函数可以将`GROUP BY`产生的同一个分组中的值连接起来,并以字符串形式返回。
具体使用如下:
```
http://localhost/sqli-labs-master/Less-1/?id=' union select 1,2,(SELECT GROUP_CONCAT(schema_name) FROM information_schema.schemata)--+
```
这样我们就可以把多个数据库名称拼接在一起作为字段3返回给页面。
<img src="https://static001.geekbang.org/resource/image/7e/a7/7e1aa81c623e28c87666cfdd290462a7.png" alt=""><br>
你能看到这里我使用到了MySQL中的`information_schema`数据库这个数据库是MySQL自带的数据库用来存储数据库的基本信息比如数据库名称、数据表名称、列的数据类型和访问权限等。我们可以通过访问`information_schema`数据库,获得更多数据库的信息。
### 查询wucai数据库中所有数据表
在上面的实验中我们已经得到了MySQL中所有的数据库名称这里我们能看到wucai这个数据库。如果我们想要看wucai这个数据库中都有哪些数据表可以使用
```
http://localhost/sqli-labs-master/Less-1/?id=' UNION SELECT 1,2,(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema='wucai') --+
```
这里我们同样将数据表名称使用GROUP_CONCAT函数拼接起来作为字段3进行返回。
<img src="https://static001.geekbang.org/resource/image/7e/a7/7e1aa81c623e28c87666cfdd290462a7.png" alt="">
### 查询heros数据表中所有字段名称
在上面的实验中我们从wucai数据库中找到了熟悉的数据表heros现在就来通过information_schema来查询下heros数据表都有哪些字段使用下面的命令即可
```
http://localhost/sqli-labs-master/Less-1/?id=' UNION SELECT 1,2,(SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='heros') --+
```
这里会将字段使用GROUP_CONCAT函数进行拼接并将结果作为字段3进行返回返回的结果如下所示
```
attack_growth,attack_max,attack_range,attack_speed_max,attack_start,birthdate,defense_growth,defense_max,defense_start,hp_5s_growth,hp_5s_max,hp_5s_start,hp_growth,hp_max,hp_start,id,mp_5s_growth,mp_5s_max,mp_5s_start,mp_growth,mp_max,mp_start,name,role_assist,role_main
```
<img src="https://static001.geekbang.org/resource/image/f3/bf/f30c7a68fba4fd8eff3ea8370aca00bf.png" alt="">
## 使用SQLmap工具进行SQL注入检测
经过上面的实验你能体会到如果我们编写的代码存在着SQL注入的漏洞后果还是很可怕的。通过访问`information_schema`就可以将数据库的信息暴露出来。
了解到如何完成注入SQL后我们再来了解下SQL注入的检测工具它可以帮我们自动化完成SQL注入的过程这里我们使用的是SQLmap工具。
下面我们使用SQLmap再模拟一遍刚才人工SQL注入的步骤。
### 获取当前数据库和用户信息
我们使用`sqlmap -u`来指定注入测试的URL使用`--current-db`来获取当前的数据库名称,使用`--current-user`获取当前的用户信息,具体命令如下:
```
python sqlmap.py -u &quot;http://localhost/sqli-labs-master/Less-1/?id=1&quot; --current-db --current-user
```
然后你能看到SQLmap帮我们获取了相应的结果
<img src="https://static001.geekbang.org/resource/image/42/05/42626ab164f9935e0c22ced9fa5f8105.png" alt="">
### 获取MySQL中的所有数据库名称
我们可以使用`--dbs`来获取DBMS中所有的数据库名称这里我们使用`--threads`参数来指定SQLmap最大并发数设置为5通常该参数不要超过10具体命令为下面这样
```
python sqlmap.py -u &quot;http://localhost/sqli-labs-master/Less-1/?id=1&quot; --threads=5 --dbs
```
同样SQLmap帮我们获取了MySQL中存在的8个数据库名称
<img src="https://static001.geekbang.org/resource/image/b9/53/b9f9b624e863cd507c1cb1c4b1fc1853.png" alt="">
### 查询wucai数据库中所有数据表
当我们知道DBMS中存在的某个数据库名称时可以使用-D参数对数据库进行指定然后使用`--tables`参数显示出所有的数据表名称。比如我们想要查看wucai数据库中都有哪些数据表使用
```
python sqlmap.py -u &quot;http://localhost/sqli-labs-master/Less-1/?id=1&quot; --threads=5 -D wucai --tables
```
<img src="https://static001.geekbang.org/resource/image/11/16/119174ae050e5a27e70ebc5537d7b116.png" alt="">
### 查询heros数据表中所有字段名称
我们也可以对指定的数据表比如heros表进行所有字段名称的查询使用`-D`指定数据库名称,`-T`指定数据表名称,`--columns`对所有字段名称进行查询,命令如下:
```
python sqlmap.py -u &quot;http://localhost/sqli-labs-master/Less-1/?id=1&quot; --threads=5 -D wucai -T heros --columns
```
<img src="https://static001.geekbang.org/resource/image/68/6d/681d394e31a6ac44ac85f2349282886d.png" alt="">
### 查询heros数据表中的英雄信息
当我们了解了数据表中的字段之后,就可以对指定字段进行查询,使用`-C`参数进行指定。比如我们想要查询heros数据表中的`id``name``hp_max`字段的取值,这里我们不采用多线程的方式,具体命令如下:
```
python sqlmap.py -u &quot;http://localhost/sqli-labs-master/Less-1/?id=1&quot; -D wucai -T heros -C id,name,hp_max --dump
```
<img src="https://static001.geekbang.org/resource/image/ce/33/ced56f2e12f6c1afccadac6be231a533.png" alt=""><br>
完整的结果一共包括69个英雄信息都显示出来了这里我只截取了部分的英雄结果。
## 总结
在今天的内容中我使用了sqli-labs注入平台作为实验数据使用了SQLmap工具自动完成SQL注入。SQL注入的方法还有很多我们今天讲解的只是其中一个方式。你如果对SQL注入感兴趣也可以对sqli-labs中其他例子进行学习了解更多SQL注入的方法。
在这个过程中最主要的是理解SQL注入的原理。在日常工作中我们需要对用户提交的内容进行验证以防止SQL注入。当然很多时候我们都在使用编程框架这些框架已经极大地降低了SQL注入的风险但是只要有SQL拼接的地方这种风险就可能存在。
总之代码规范性对于Web安全来说非常重要尽量不要采用直接拼接的方式进行查询。同时在Web上线之后还需要将生产环境中的错误提示信息关闭以减少被SQL注入的风险。此外我们也可以采用第三方的工具比如SQLmap来对Web应用进行检测以增强Web安全性。
<img src="https://static001.geekbang.org/resource/image/f5/01/f54cad8278f70197ca2fc268ce2d1901.png" alt=""><br>
你不妨思考下为什么开发人员的代码规范对于Web安全来说非常重要以及都有哪些方式可以防止SQL注入呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。