21/8/3 读书笔记 数据库查询处理与优化 记忆与缓求值

21/8/3 读书笔记

数据库系统概论 查询处理与查询优化

查询处理是指数据库系统将SQL查询语句翻译为系统具体的执行命令,并在物理机上执行,我们称这一套的执行命令为查询执行计划

查询处理的步骤可以大致分为:

  1. 查询分析:对语句进行词法分析、语法分析,来确定语句是否符合SQL的语法规则
  2. 查询检查:对语句的语义进行分析,判断其语义是否在当前数据库系统中有效,检查内容包括完整性约束、存取权限、对象是否存在等。这一步结束后,数据库系统正式将SQL查询语句翻译成系统的内部表示,呈现为语法分析树(syntax tree)
  3. 查询优化:对语法分析树进行进一步优化,包括逻辑优化和物理优化两种。最终产出一个执行策略,其独立于数据库具体的实现语言。
  4. 查询执行:将优化的执行策略通过代码生成器翻译为数据库实现语言下的代码,并加以执行。

接下来我们重点介绍查询优化:

查询优化

我们应该意识到,为了实现某种数据库操作,数据库系统所能够采用的算法是多种多样的。比如按条件筛选,我们可以直接逐个元组进行测试,或者也可以用索引辅助扫描。因此,如何将数据库操作翻译成更高效率的实际算法,就是查询优化的根本目的。

关系型数据库的一大优点就是非过程化,让程序员指定“干什么”而不是“怎么干”,因此数据的具体存取路径由数据库系统给出,而不需要程序员指定,这也使得数据库系统能够在优化上具有更多权利。

联想函数式编程中,更高的抽象程度为语言和运行时提供了更多优化的权利,使得其优化更容易进行,与之有异曲同工之妙。

将优化的权利交给数据库系统而不是程序员,有以下几点好处:

  • 数据库优化器可以基于数据库系统的数据字典中的统计信息,用户想要分析和处理这些信息比较麻烦,而对于数据库系统自身来说更容易。这些统计信息可以让优化器做出更加准确的估算。
  • 数据库优化器通常包括很多复杂的优化思想,而普通程序员不一定具有使用这些复杂优化思想的能力(别骂了)。将繁琐的优化细节丢给数据库系统自身,既解放了程序员,又能让真正懂优化的dalao来帮所有数据库使用者做优化。

在数据库优化问题中,其总代价主要有:I/O代价、CPU代价、内存代价、通信代价。其中I/O代价最为主要,因此我们一般使用查询处理所读写的磁盘块个数作为查询计划的性能指标。

查询优化主要包括代数优化和物理优化两种手段,对应不同的优化层次。

代数优化

代数优化将语法分析树(即关系代数表达式)进行等价变换,使得新的语法分析树在执行时更为简洁高效。

代数优化的基础是数学层面上的等价交换概念,即在连接、投影、选择、笛卡尔积等操作上的各种定律,具体的等价交换规则略。

对于语法分析树的优化通常是基于启发式规则的:

  • 选择运算尽可能提前。有助于让中间结果规模减小。
  • 投影运算与选择运算尽可能同时进行。对关系进行一次扫描同时完成投影和选择。
  • 投影运算可以与前或后的双目运算符结合。
  • 把某些选择运算与其之前执行的笛卡尔积运算组合成连接运算。连接运算通常比笛卡尔积运算快。
  • 找出公共的子表达式。如果一个中间变量经常被使用,比如某个视图或派生表频繁出现,就可以将其固定下来,而避免重复求值。

物理优化

物理优化对数据库操作的具体实现进行优化,使得执行方案效率更高。

物理优化的基础是同一个数据库运算操作本身可以有多种不同的存取路径。比如对于选择操作,就可以采用全表顺序扫描和索引扫描两种方案。在不同的情况下,每种存取路径的效率不同,因此需要找到效率最高的那个。

物理优化通常基于启发式规则代价估算的结合。启发式规则能够初步得出一些较好的存取路径方案,而再使用代价估算来得出其中最好的那个方案。而两种方式的根本还是在于对数据库统计信息的分析,如果脱离了数据库当前的状态,就无法进行物理优化。

查询执行

查询优化提供了执行计划,该计划表现为一个语法分析树,每个父结点需要子结点的运算结果才能完成运算。

查询执行有两种方案:

  • 自顶向下:最先执行根节点运算。父结点运算先执行,然后根据所需要的的子结点结果来启动子结点运算。子结点运算结点全部返回到父结点的缓冲区后,父结点完成运算并返回结果。这是一种被动的需求驱动的方式,所有结点(除了根结点)都是被父结点的需求所唤醒的,否则不会执行。
  • 自底向上:父子结点各自启动。子结点获得运行机会时,将结果放到父结点的缓冲区里,且必须等父结点将该结果取走后才能继续执行。父结点获得运行机会时,查看缓冲区是否准备完成,如果完成后再进行运算。这是一种主动的方式,所有结点都是主动开始运算的,且只当缓冲区准备完成时才能完成运算。

函数式编程思想 记忆与缓求值

记忆

记忆(memoization,没错就是这个,是一个英国人生造的)。记忆指的是在函数级别对需要多次使用的值进行缓存的机制。事实上,你可以在任何范式的语言下使用缓存技术,以利用空间换取时间。

我们认为只有纯函数才能使用缓存技术,即没有副作用。我们认为没有副作用的表现就是:

  • 不引用任何值可变的字段
  • 除了返回值以外不设置任何其他的变量

这两个要求使得纯函数对同一组参数始终返回相同的结果。

为了说明函数式编程在记忆上的优势,我们首先来看一看非函数式编程下的缓存实现(以Java为例):

private static Map<Integer,Integer> cache = new HashMap();
public static Integer factorNum(Integer n) {
    if(!cache.containsKey(n)) {
        Integer total = 0;
        for(Integer i;i<n;i++) {
            if(n % i==0) total++;
        }
        cache.put(n,total);
    }
    return cache.get(n);
}

然而,我们将会遇到一些可能的问题:

  • 我们需要管理total和i这两个临时变量,这个过程中我们可能会犯错误。
  • 我们需要知道Java中的HashMap到底能支持多大的缓存容量,这依赖于平台。
  • 其他可能的缓存相关的牵连关系

我们将这些因素视作缓存过程中的不稳定因素。函数式编程就是致力于消除这些不稳定因素,而采用的方法就是在编程语言中内置记忆特性,使得缓存机制的管理从开发者让渡到语言设计人员,这样的好处在于:

  • 语言开发人员设计的机制总是比开发者效率更高,因为他们能跳出语言本身的限制,去触碰编译的底层设施。
  • 开发者能够不用考虑缓存的具体实现机制,而专注于解决更高抽象程度的问题。

对于函数式语言,缓存机制的引入十分简洁,以Groovy为例:

def static factorNum = {n -> (1..n).findAll(i -> n % i == 0).size()}.memorize();
def static factorNum = {n -> (1..n).findAll(i -> n % i == 0).size()}.memorizeAtMost(1000); // 为了防止缓存过大,指定缓存上限,很方便

但是开发者需要注意进行记忆的函数必须满足纯函数的性质!

缓求值

缓求值(lazy evaluation)是函数式编程中常见的一种特性,我们在之前的Java 8的map和filter中有提到这两个高阶函数的结果都属于缓求值类型。准确来说,缓求值用于描述一个集合,该集合中的元素只有在使用时才会被落实,否则保持未求值的状态。一般来说,对于缓求值的集合,我们保持对应的计算方法而不保存对应的结果

我们称缓求值为非严格求值,反之为严格求值。以下例来简要说明:

print([2+1,1/0,"rushB"].size()); # 伪代码

对于严格求值,其在集合生成时就对所有元素求值,而1/0会触发运行异常;而对于非严格求值,其在集合生成后并不计算各个元素,而仅保存了获取元素值的各个方法,因此能够正常输出。

缓求值的列表可以用来构造无限的序列。比如我们描述一个无限大的斐波那契数列,只需要递归地声明每个元素的推导方式即可,而在取出一个特定值时才进行运算。同时,缓求值也因此能够减少该列表所占用的存储空间。

最重要地,缓求值集合能够在运行时产生更高执行效率的代码。考虑下列操作,该操作将一个长字符串按特定字符分割并筛选:

str.split(",").filter(e->qualified(e)).collect(toList()) // 伪代码

对于不支持缓求值的语言来说,如果str中具有极多个分割块,那么在进入filter之前就会有存在一个极大的中间集合用于存放所有分割块;而对于支持缓求值的语言来说,split、filter的结果都是缓求值的,在collect时才会被真正求值,因此可以看着是一个一个元素在“流动”,而不存在一个异常臃肿的中间结果,这极大地提高的代码的执行效率。

posted @ 2021-08-03 11:07  neumy  阅读(94)  评论(0)    收藏  举报