在数据库(二),数据库的起源里面我们说到了,数据库实际上就是在底层文件上加的一个中间层,其目的在于抽象了很多数据常用的操作,同时加上了锁、事务、权限管理等功能。
下面我们来看看数据是由哪些组件组成的,
最核心的是客户端管理器、进程管理器、文件系统管理器、内存管理器等。
然后就是查询管理器,可以对查询操作进行检查、优化。
还有就是数据管理器,主要是对数据IO、缓存加速以及事务管理方面。
最后就是一些备份、监控管理器。
下面对各部分进行简单的讲解。
客户端管理器
客户端管理器,顾名思义,就是对连接到数据库的连接请求进行处理的。
当有请求来的时候,
客户端管理器首先进行权限的验证,
然后把查询请求送到查询管理器中,当查询管理器返回结果了以后,会将数据放到缓冲队列中。
最后关闭连接释放资源。
客户端管理器会把查询请求送到下一站查询管理器,那查询管理器又是怎么处理的呢?
查询管理器
在数据库(二),数据库的起源里面我们说到过,如果没有数据库这个中间层,所有的查询操作都需要自己通过代码来编写,然后编译成二进制文件进行执行。而现在查询管理器将这一切进行了抽象。所有的查询请求会被它转换为代码,然后也是由他来返回后端的结果。
查询管理器的处理流程主要由如下几个步骤组成。
解析:主要是检查语法,并且判断表是否存在、字段是否存在等逻辑上的问题。
预优化(重写)
优化
编译
执行
其实这几个步骤与程序从代码到执行的过程差不多,区别在于优化的过程。所以下面主要介绍一下重写和优化部分
预优化
比如说从深圳的南山到福田区有很多条路,那么如果我们不规划一下,随便走哪一条路,则有可能就堵在路上了。所以说预先估计一下行走难度很重要。
同样,进行查询之前,如果不预估一下查询需要多少工作量,可能就会被某些烂代码给拖垮。
那么预优化一般会怎么重写原来的查询命令呢?比如会去除不必要的运算符,排除冗余联接等。
统计
在正式开始优化之前,数据库会收集
表中行和页(保存数据的最小单位)的数量
表中的列的唯一值、数据长度、数据范围等。
表的索引
有什么用呢?它们会保证优化器估计查询所需的磁盘IO、CPU、内存使用等。
比如说,一个表中有两个列last_name,first_name,last_name表示名字,不容易重复,而first_name表示姓,重复的概率会非常高。此时他们需要联接起来,那么是last_name连接first_name还是first_name联接last_name就很有讲究了。
比如使用first_name联接last_name,因为first_name很容易重复,所以可能需要比较first_name的所有字符才能完全区分开。
那如果使用last_name,first_name联接,因为last_name不大可能重复,所以多数情况只需要比较last_name的前几个字符就可以了,这将大大减少比较的次数。
所以统计信息对后续的优化过程意义重大。
实际上我们可以对之前这种简单的统计再扩充一下,可以直方图,这样就可以找出那些值出现最频繁,分位数等。
创建计划
通过预优化以及统计基本信息后,现在我们可以正式开始创建执行计划。此时数据库(三),底层算法这一章讲的算法就派上用场了。
数据库一般会为每个运算设置一个成本,然后尽可能选用成本最低的运算,这样就可以找到最省成本的方法呢。
我们主要以联接查询为例,因为联接会涉及到大量的磁盘IO。而磁盘IO速度相当的慢,所以对联接查询进行优化非常重要。
在正式开始讲解之前,我们假定外关系是左侧数据集,内关系是右侧数据集。比如,A JOIN B中A是外关系,B是内关系。外关系有N个元素,内关系有M个元素。
而联接主要做的事情就是联接表A和表B,找出符合条件的元素。至于为什么要拆分成两个表,然后用联接查找这这么麻烦的方式来查询,可以参考数据库(一),范式
那么我们可以怎么进行联接呢
- 最容易想到的就是嵌套循环联接,其实就是内外两个循环,我们可以对外关系的每一行,查看内关系里面是否有匹配条件的。
这样针对外关系的每一行,需要把内关系的每一行都读出。
如果内关系足够小,可以把内关系先读入内存中,避免每次都访问磁盘。所以此时内关系需要最够小。
- 哈希联接:
嵌套联接需要对外关系的每一行,查找内关系表,瓶颈在于,每次都需要读内关系。所以主要矛盾在如何根据外关系表的元素快速的在内关系里面找到匹配的元素。这就是个查找的问题了。而查找最高效的方式自然是数据库(三),底层算法讲过的哈希算法。
因此我们完全可以把内关系取出来,构造一个哈希表。对每个外关系元素,先对它进行哈希运算,再去哈希表中查找对应的元素即可。
所以步骤为:
读取内关系的值,建立hash表
对外关系的每一行进行hash,得到一个地址
查找该地址是否有对应的内关系元素。
- 合并联接
如何数据集已经是有序的,我们可以借用合并排序算法来进行联接。
每次只比较两个序列的当前值,如果相同则放入结果中,如果不同则后移一位。因为两个序列都有序,所以不需要回头去找,效率是比较高的。
那什么时候数据表已经有序呢?比如要联接的表是一个索引,或者说是已经排序了的中间结果。
那么那种算法最好了?没有最好,只有最适合。在不同的场景,可以使用不同的算法。
如果空闲内存比较多,当然选哈希联接。
如果一个大表联接一个小表,此时当选嵌套联接,因为哈希联接虽然好,但是创建哈希需要极大的成本。
如果有索引、或者已经排序,当然选合并联接了。
如果数据中重复的元素特别多,此时选哈希函数产生的分布极不均匀,此时当然不能选哈希函数
执行计划
比如我们需要联接5张表:MOBILES,MAILS,ADDRESSES,BANK_ACCOUNTS
要联接这么多张表,存在多种可能,比如
那到底选哪一种呢?如果一一把所有的方式的成本算一次,则消耗的时间、性能将无法估计。
此时我们可以使用动态编程
观察这几种执行计划,我们发现他们都有共同的子树,也就意味着很多过程是重复的,完全可以对这部分的结果进行重复使用。
当查询非常大的时候,去找里面相似的部分不太现实。我们可以使用另一种理念:贪婪算法,也就是按照一个规则一步一步的寻找最佳算法,这就好比我们不知道应该怎么做的时候,完全可以摸着石头过河,走一步算一步。
回到上面的例子,我们可以直接算一个表开始,假设现在选了A,然后以A作为外关系,一一比较另外的表与他联接起来的成本,此时发现A JOIN B成本最低,
同理再计算"A JOIN B"与剩下的表联接之后的成本,发现"(A JOIN B ) JOIN C"成本最低,这样一步一步的来,保证每一步都是最优的,最后的结果当然是最优的。
因为创建计划是比较耗时间的,我们可以把计划保存在缓存中,这样可以避免重复计算。
执行
现在有了执行计划,再编译为可执行代码,然后执行即可。
查询优化部分介绍到此,下面再讨论如何管理数据。
数据管理器
这部分主要是讨论数据库如何从表和索引获取数据,所以会涉及到大量的磁盘IO。
我们知道磁盘的速度相当慢,所以我们需要使用缓存来加速,同时可能会存在多个连接同时访问一个数据,所以需要考虑事务
缓存管理
我们知道磁盘的速度非常慢,大量的磁盘IO将成为整个系统的瓶颈。所以我们可以在内存中分配一个缓冲区,将数据先放到缓存中,然后查询执行器从缓存拿数据,这样可以减少内存与磁盘速度不匹配的问题。
那么要查询一个数据,一般优先从缓存中取,如果没取到的话,才去磁盘中读。如果在缓存中找到了数据,我们称为缓存命中。那么如果缓存命中率很低的话,也就意味着数据基本上都是从磁盘上读的,整体工作效率自然很低下。
不过这就有一个问题,缓存管理器需要提前将数据读到内存中,但是缓存管理器并不知道需要提前加载哪些数据。有两种读缓存的方法:
顺序预读法:先将一批数据加载到缓存中,然后简单的取下一批数据即可。
推测预读:比如现在要数据1、3、5,我们推测后续他需要7、9、11数据。
另外,缓存的空间非常有限,为了加载新数据,需要移除一些数据,如果把后续要用的数据移掉了,则意味着查询执行器只能从磁盘中取数据了。
所以缓存的置换策略非常重要。最常用的算法是LRU(Least Recently Used)算法。其基本思想是,最先进到缓存的数据一般不怎么常用,所以当然优先把它们替换掉。这样缓存里面总是保留着最近使用的数据。这也比较符合常识。
具体过程如下。首先元素1、4、3依次进入缓存中,此时1自然是最早进入的,当要再进入9的时候,优先把1替换掉了。
然后因为要再用到4,所以把4的优先级提高了一下。
这种算法也有限制,如果表的大小超过了缓存区,则使用LRU算法会清除掉之前缓存的所有数据。
事务管理
事务管理我们将在下一章数据库(五),事务里面更详细的解释。
主要参考
本文根据如果有人问你数据库的原理,叫他看这篇文章如果有人问你数据库的原理,叫他看这篇文章改编。