数据库的服务端,可分为执行器(Execution Engine) 和 存储引擎(Storage Engine) 两部分:
- 执行器负责解析SQL执行查询
- 存储引擎负责保存数据
1 SQL如何在执行器中执行
# 查询用户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后,生成一个逻辑执行计划,即如何一步步执行查询和计算,最终得到执行结果的一个分步骤的计划。这个逻辑执行计划:
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不同,这个逻辑执行计划已经很像可以执行的程序代码了。这执行计划像编程语言的函数调用栈,外层方法调用内层方法。所以,得从内往外看:
- 最内层的2个LogicalTableScan:把USERS和ORDERS这两个表的数据都读出来
- 拿这两个表所有数据做一个LogicalJoin,JOIN条件:第0列(u.id)=第6列(o.user_id)
- 再执行一个LogicalFilter过滤器,过滤条件:第0列(u.id)>50
- 做个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不同优化方法,优化总体思路:在执行计划中,尽早减少须处理的数据量。即尽量在执行计划最内层减少要处理的数据量。
简单优化后的逻辑执行计划:
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])
对比原始逻辑执行计划,两点简单优化:
- 尽早执行投影,去除不需要的列
- 尽早执行数据过滤,去除不需要的行
JOIN前,把要JOIN的数据尽量减少,显然比原始执行计划快。
到这,执行器只在逻辑层分析SQL,优化查询执行逻辑,执行计划中操作的数据,仍是表、行和列。在数据库中,表、行、列都是逻辑概念,所以,这个执行计划叫“逻辑执行计划”。
执行查询接下来的部分,涉及数据库的物理存储结构。
2 SQL是如何在存储引擎中执行
数据真正存储时,无论在磁盘or内存中,都没法直接存储这种带行列的二维表。数据库中的二维表存储就是存储引擎负责,存储引擎主要功能就是把逻辑的表行列,用合适物理存储结构保存到文件。
不同数据库,物理存储结构完全不一样,各种数据库之间巨大性能差距的根本原因。MySQL在设计层对存储引擎抽象,存储引擎可替换。默认InnoDB,InnoDB中数据表的物理存储结构是以主键为关键字的B+树,每行数据直接就保存在B+树的叶节点。如上面的订单表组织成B+树:
这树以订单表的主键orders.id为关键字组织,其中“62:[row data]”,表示的是订单号为62的一行订单数据。在InnoDB中,表的索引也是以B+树的方式来存储的,和存储数据的B+树的区别是,在索引树中,叶子节点保存的不是行数据,而是行的主键值。
若通过索引检索记录,需先后查询索引树、数据树两棵树:
- 先在索引树检索到行记录的主键值
- 再用主键值去数据树中去查找这行数据
优化后的逻辑执行计划将会被转换成物理执行计划,物理执行计划和数据的物理存储结构相关。InnoDB直接将逻辑执行计划转换为物理执行计划:
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])
物理执行计划同样可以根据数据的物理存储结构、是否存在索引以及数据多少等各种因素进行优化。这一块儿的优化规则同样是非常复杂的,如把对用户树的全树扫描再按照主键过滤这两个步骤,优化为对树的范围查找:
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索引实现后,就很容易明白为何主键不能太长,因为表的每个索引保存的都是主键值,过长主键会导致每个索引都很大。
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转换为逻辑执行计划,逻辑执行计划经优化后,转换为物理执行计划,再经物理执行计划优化后,按照优化后的物理执行计划执行完成数据的查询。数据库都由执行器和存储引擎两部分组成:
- 执行器负责执行计算
- 存储引擎负责保存数据