SQL如何在数据库中执行

2023-01-06 09:10:54 浏览数 (1)

数据库的服务端,可分为执行器(Execution Engine)存储引擎(Storage Engine) 两部分:

  • 执行器负责解析SQL执行查询
  • 存储引擎负责保存数据

1 SQL如何在执行器中执行

代码语言:javascript复制
# 查询用户ID大于50的用户的所有订单
SELECT u.id AS user_id, u.name AS user_name, o.id AS order_id
FROM users u INNER JOIN orders o ON u.id = o.user_id
WHERE u.id > 50

很简单的一个联查。DB收到查询请求后,先解析SQL语句,把这一串文本解析成便于程序处理的结构化数据,这是通用的语法解析过程。跟编程语言的编译器编译时,解析源代码过程一样。

转换后的结构化数据,就是抽象语法树(AST,Abstract Syntax Tree)。上面这SQL的AST:

执行器解析AST后,生成一个逻辑执行计划,即如何一步步执行查询和计算,最终得到执行结果的一个分步骤的计划。这个逻辑执行计划:

代码语言:javascript复制
LogicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
    LogicalFilter(condition=[$0 > 50])
        LogicalJoin(condition=[$0 == $6], joinType=[inner])
            LogicalTableScan(table=[users])
            LogicalTableScan(table=[orders])

和SQL、AST不同,这个逻辑执行计划已经很像可以执行的程序代码了。这执行计划像编程语言的函数调用栈,外层方法调用内层方法。所以,得从内往外看:

  1. 最内层的2个LogicalTableScan:把USERS和ORDERS这两个表的数据都读出来
  2. 拿这两个表所有数据做一个LogicalJoin,JOIN条件:第0列(u.id)=第6列(o.user_id)
  3. 再执行一个LogicalFilter过滤器,过滤条件:第0列(u.id)>50
  4. 做个LogicalProject投影,只保留第0(user_id)、1(user_name)、5(order_id)三列。“投影(Project)”:把不需要的列过滤

把这个逻辑执行计划翻译成代码,然后按照顺序执行,就正确查询出数据。但按执行计划,要执行2个全表扫描,再把2个表的所有数据做一个JOIN操作,性能差。

如user表1,000条数据,订单表10,000条数据,JOIN要遍历行数1,000 x 10,000 = 10,000,000行

这种从SQL的AST直译过来的逻辑执行计划,一般性能差,所以,要对执行计划优化。不同DB不同优化方法,优化总体思路:在执行计划中,尽早减少须处理的数据量。即尽量在执行计划最内层减少要处理的数据量。

简单优化后的逻辑执行计划:

代码语言:javascript复制
LogicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
    LogicalJoin(condition=[$0 == $6], joinType=[inner])
        LogicalProject(id=[$0], name=[$1])              // 尽早执行投影
            LogicalFilter(condition=[$0 > 50])          // 尽早执行过滤
                LogicalTableScan(table=[users])
        LogicalProject(id=[$0], user_id=[$1])           // 尽早执行投影
            LogicalTableScan(table=[orders])

对比原始逻辑执行计划,两点简单优化:

  1. 尽早执行投影,去除不需要的列
  2. 尽早执行数据过滤,去除不需要的行

JOIN前,把要JOIN的数据尽量减少,显然比原始执行计划快。

到这,执行器只在逻辑层分析SQL,优化查询执行逻辑,执行计划中操作的数据,仍是表、行和列。在数据库中,表、行、列都是逻辑概念,所以,这个执行计划叫“逻辑执行计划”。

执行查询接下来的部分,涉及数据库的物理存储结构。

2 SQL是如何在存储引擎中执行

数据真正存储时,无论在磁盘or内存中,都没法直接存储这种带行列的二维表。数据库中的二维表存储就是存储引擎负责,存储引擎主要功能就是把逻辑的表行列,用合适物理存储结构保存到文件。

不同数据库,物理存储结构完全不一样,各种数据库之间巨大性能差距的根本原因。MySQL在设计层对存储引擎抽象,存储引擎可替换。默认InnoDB,InnoDB中数据表的物理存储结构是以主键为关键字的B 树,每行数据直接就保存在B 树的叶节点。如上面的订单表组织成B 树:

这树以订单表的主键orders.id为关键字组织,其中“62:[row data]”,表示的是订单号为62的一行订单数据。在InnoDB中,表的索引也是以B 树的方式来存储的,和存储数据的B 树的区别是,在索引树中,叶子节点保存的不是行数据,而是行的主键值。

若通过索引检索记录,需先后查询索引树、数据树两棵树:

  • 先在索引树检索到行记录的主键值
  • 再用主键值去数据树中去查找这行数据

优化后的逻辑执行计划将会被转换成物理执行计划,物理执行计划和数据的物理存储结构相关。InnoDB直接将逻辑执行计划转换为物理执行计划:

代码语言:javascript复制
InnodbProject(user_id=[$0], user_name=[$1], order_id=[$5])
    InnodbJoin(condition=[$0 == $6], joinType=[inner])
        InnodbTreeNodesProject(id=[key], name=[data[1]])
            InnodbFilter(condition=[key > 50])
                InnodbTreeScanAll(tree=[users])
        InnodbTreeNodesProject(id=[key], user_id=[data[1]])
            InnodbTreeScanAll(tree=[orders])

物理执行计划同样可以根据数据的物理存储结构、是否存在索引以及数据多少等各种因素进行优化。这一块儿的优化规则同样是非常复杂的,如把对用户树的全树扫描再按照主键过滤这两个步骤,优化为对树的范围查找:

代码语言:javascript复制
PhysicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
    PhysicalJoin(condition=[$0 == $6], joinType=[inner])
        InnodbTreeNodesProject(id=[key], name=[data[1]])
            InnodbTreeRangeScan(tree=[users], range=[key > 50])  // 全树扫描再按照主键过滤,直接可以优化为对树的范围查找
        InnodbTreeNodesProject(id=[key], user_id=[data[1]])
            InnodbTreeScanAll(tree=[orders])

按优化后的物理执行计划,一步步执行查找和计算,就得到SQL查询结果。

知道InnoDB索引实现后,就很容易明白为何主键不能太长,因为表的每个索引保存的都是主键值,过长主键会导致每个索引都很大。

代码语言:javascript复制
SELECT * FROM user WHERE left(department_code, 5) = '00028';
SELECT * FROM user WHERE department_code LIKE '00028%';

为什么一个不能命中索引,一个能命中?InnoDB对物理执行计划进行优化的时候,能识别LIKE这种过滤条件,转换为对索引树的范围查找。第一条SQL,优化规则就没那么“智能”。

它并没有识别出来,这条件同样可转换为对索引树的范围查找,而走全表扫描。并不是说第一个SQL写不好,而是数据库不智能。能做的就是了解数据库脾气,按它能力,尽量写出它能优化的SQL。

总结

一条SQL在数据库中执行,经过语法解析成AST,然后AST转换为逻辑执行计划,逻辑执行计划经优化后,转换为物理执行计划,再经物理执行计划优化后,按照优化后的物理执行计划执行完成数据的查询。数据库都由执行器存储引擎两部分组成:

  • 执行器负责执行计算
  • 存储引擎负责保存数据

0 人点赞