关于join:亿级大表毫秒关联荔枝微课基于Apache-Doris-统一实时数仓建设实践

本文导读: Apache Doris 助力荔枝微课构建了标准的、计算对立的实时数仓平台,目前 Apache Doris 曾经撑持了荔枝微课外部 90% 以上的业务场景,整体可达到毫秒级的查问响应,数据时效性实现 T+1 到分钟级的晋升,开发效率更是实现了 50% 的增长,满足了各业务场景需要、实现降本提效,深得十方融海各数据部门高度认可。 作者: 陈城,数据中台组组长 深圳十方融海科技有限公司成立于 2016 年,是一家数字职业在线教育头部企业,业务涵盖“数字职业技能课程、常识分享平台「荔枝微课」、智慧教育解决方案「女娲云教室」”,推出了多类数字素养与数字技能课程服务,助力用户在数字时代实现技能进阶与职业进阶。2016年上线荔枝微课,已倒退成为国内头部常识分享平台。2021年上线女娲云教室,实现了“教学练”一体化模式,填补了国内在线教学与实操脱轨的空白。 业务介绍荔枝微课隶属于深圳十方融海科技有限公司,是一个收费应用的在线教育平台。荔枝微课领有海量的常识内容,包含直播视频、录播视频、音频等多种形式。 通过技术和数据的赋能,推动荔枝微课继续翻新,也为微课平台方和合作伙伴在视频的翻新和销售方面提供了更强劲的反对。在业务经营过程中咱们须要对用户进行全方位剖析,高效为业务赋能。数据平台旨在集成各种数据源的数据,整合造成数据资产,为业务提供用户全链路生命周期、实时指标剖析、标签圈选等剖析服务。 晚期架构及痛点 晚期架构选用的是 Hadoop 生态圈组件,以 Spark 批计算引擎为外围构建了最后的离线数仓架构,基于 Flink 计算引擎进行实时处理。从源端采集到的业务数据和日志数据将分为实时和离线两条链路: 在实时局部,业务库数据通过 Binlog 的形式接入,日志数据应用 Flume-Kafka-Sink 进行实时采集,利用 Flink 将数据计算写入到 Kafka 和 MySQL中。在实时数仓的外部,恪守数据分层的实践以实现最大水平的数据复用。在离线局部,利用 Sqoop 和 DataX 对全量和增量业务库中的数据进行定时同步,日志数据通过 Flume 和日志服务进行采集。当不同数据源进入到离线数仓后,首先应用 Hive on Spark/Tez 进行定时调度解决,接着依据维度建模通过 ODS、DWD、DWS、ADS 层数据,这些数据存储在 HDFS 和对象存储 COS 上,最终利用 Presto 进行数据查问展现,并通过 Metabase 提供交互式剖析服务。同时为了保障数据的一致性,咱们会通过离线数据对实时数据进行定期笼罩。问题与挑战: 基于 Hadoop 的晚期架构能够满足咱们的初步需要,而面对较为简单的剖析诉求则显得爱莫能助,再加上近年来,荔枝微课用户体量一直回升,数据量呈指数级回升,为了更好的为业务赋能,进步用户应用体验,业务侧对数据的实时性、可用性、响应速度也提出了更高的要求。在这样的背景下,晚期架构裸露的问题也越发显著: 组件繁多,保护简单,运维难度十分高数据处理链路过长,导致查问提早变高当有新的数据需要时,牵一发而动全身,所需开发周期比拟长数据时效性低,只可满足 T+1 的数据需要,从而也导致数据分析效率低下技术选型通过对数据规模及晚期架构存在的问题进行评估,咱们决定引入一款实时数仓来搭建新的数据平台,同时心愿新的 OLAP 引擎能够具备以下能力: 反对 Join 操作,可满足不同业务用户灵便多变的剖析需要反对高并发查问,可满足日常业务的报表剖析需要性能强悍,能够在海量数据场景下实现疾速响应运维简略,缩减运维人力的投入和老本的收入,实现降本提效对立数仓构建,简化繁琐的大数据软件栈社区沉闷,在应用过程中遇到问题,可迅速与社区取得联系基于以上要求,咱们疾速定位了 Apache Doris 和 ClickHouse 这两款开源 OLAP 引擎 ,这两款引擎都是当下应用较为宽泛、口碑不错的产品。在调研中发现, ClickHouse 对 Join 能力的反对较为个别,而咱们在大多数业务场景中都须要基于明细数据进行大数据量的 Join,同时 ClickHouse 不反对高并发,这也与 Apache Doris 造成显明的比照,Apache Doris 多表 Join 能力强悍,高并发能力优异,齐全能够满足咱们日常的业务报表剖析需要。除此之外,Apache Doris 能够同时反对实时数据服务、交互数据分析和离线数据处理多种场景,并且反对 Multi Catalog ,能够实现对立的数据门户,这几个特点都是咱们外围思考的几个能力。 ...

May 11, 2023 · 2 min · jiezi

关于join:技术分享-咬文嚼字之驱动表-outer表

作者:胡呈清 爱可生 DBA 团队成员,善于故障剖析、性能优化,集体博客:https://www.jianshu.com/u/a95...,欢送探讨。 本文起源:原创投稿 *爱可生开源社区出品,原创内容未经受权不得随便应用,转载请分割小编并注明起源。 什么是驱动表? 什么是 outer 表和 inner 表? outer 表等同于驱动表吗? 在MySQL中这个问题的脉络MySQL的 join 算法:Nested-Loop 和其变种算法 Block Nested-LoopMySQL8.0.18 引入的 hash join 其实能够算是对 Block Nested-Loop 的优化(MySQL8.0.20 之前 explain 输入显示的还是 Block Nested Loop,须要用 explain format=tree 能力看到应用了 hash join),直到 MySQL8.0.20 删除了 Block Nested-Loop,hash join 正式上位。 Nested-Loop 算法:外循环和内循环t1、t2 两表关联时,最简略的 Nested-Loop 的算法如下: for each row in t1 matching range { for each row in t2 { if row satisfies join conditions, send to client }}这个算法的意思就是:每次将一行数据从外循环传递到内循环进行比照。而外循环中的表就叫 outer 表,内循环中的表就是 inner 表。 ...

December 15, 2021 · 4 min · jiezi

EMR-Spark-Runtime-Filter性能优化

背景Join是一个非常耗费资源耗费时间的操作,特别是数据量很大的情况下。一般流程上会涉及底层表的扫描/shuffle/Join等过程, 如果我们能够尽可能的在靠近源头上减少参与计算的数据,一方面可以提高查询性能,另一方面也可以减少资源的消耗(网络/IO/CPU等),在同样的资源的情况下可以支撑更多的查询。 目前在SparkSQL中有Filter下推优化,包括两个维度: 生成FilterSparkSQL会从用户的SQL语句中获取到Filter 直接显示获取 select * from A where a=1生成Filter(a=1) on A 隐式推断 select * from A, B where A.a = B.b and A.a=1推断出Filter(b=1) on B Filter优化利用生成的Filter算子可以优化,比如: 将Filter尽量下推到靠近DataSource端如果Filter中的列是分区列,可以提前对DataSource进行分区裁剪,只扫描需要的分区数据Runtime Filter是针对Equi-Join场景提出的一种新的生成Filter的方式,通过动态获取Filter内容来做相关优化。 Runtime Filter原理优化对象Equi Join, 形如 select x,y from A join B on A.a = B.b其中A是一个小表(如维表),B是一个大表(如事实表)备注: A/B也可以是一个简单的子查询 优化思路如上述小表A和大表B进行Join,Join条件为A.a=B.b,实际Join过程中需要对大表进行全表扫描才能完成Join操作,极端情况下如A.a仅仅只有一条记录,也需要对B表全表扫描,影响性能。 如果在B表扫描之前,能获取A表的a的相关信息(如所有的a值,或者a的min/max/Bloomfilter等统计信息),并在实际执行Join之前将这些信息对B表的数据进行过滤,而不是全表扫描,可以大大提高性能。 两种场景根据大表B参与join的key(b)的属性,可以分别采集小表A参与join的key(a)的信息: b是分区列如上b为大表B的一个分区列,则可以提前收集A.a列的所有值,然后利用A.a的值对B表的b列进行分区裁剪 b不是分区列不能做分区裁剪,只能在实际数据扫描的过程中进行过滤。可以提前收集A.a列的min/max/Bloomfilter的统计信息,然后利用这些统计信息对B表进行数据过滤,这个过滤又可以分成两种粒度: 可下推到存储层,减少数据扫描如底层文件格式是Parquet/ORC, 可以将相关过滤谓词(min/max等)下推到存储层面,从而减少实际扫描的数据。扫描后数据过滤不能下推到存储层的,可以在数据被扫描后做条件过滤,减少后续参与计算的数据量(如shuffle/join等) Runtime Filter实现Runtime Filter的实现主要在Catalyst中,分为4个步骤: 谓词合成在用户SQL生成的逻辑执行计划树(logical plan)中,寻找满足条件的Equi-Join节点,然后根据上面的思路,在Join的大表B侧插入一个新的Filter节点,如Filter(In(b, Seq(DynamicValue(a, A))), B) 谓词下推上面生成的新的Filter会经过PushDownPredicate的Rule,尽量下推靠近DataSource附近 物理执行计划生成该阶段会将上面下推的Filter(In(b, Seq(DynamicValue(a, A))), B)转换成物理节点(FilterExec),根据上面两种场景会生成两种不同的FilterExec b是分区列b是分区列,采集的是a列的所有值,如: case class DynamicPartitionPruneFilterExec( child: SparkPlan, collectors: Seq[(Expression, SparkPlan)]) extends DynamicFilterUnaryExecNode with CodegenSupport with PredicateHelper其中colletors就是用于采集信息的SparkPlan,因为要跑一个SQL来采集a列的所有值(select a from A group by a);因为有可能会有多个分区列,所以这个地方是一个Seq. ...

July 5, 2019 · 1 min · jiezi

MySQL 常用命令

大学学的数据库系统概论工作后几年没有使用都已忘了, 现在项目需要用到数据库, 但来不及细看相关书籍了, 遂将一些常用的记录下来.常用类型MySQL 支持多种类型, 大致可以分为三类: 数值, 日期/时间和字符串(字符)类型, 大致如下:数值类型整数: tinyint, smallint, mediumint, int, bigint浮点数: float, double, decimal日期和时间date, time, datetime, timestamp, year字符串类型字符串: char, varchar文本: tinytext, text, mediumtext, longtext二进制字符串: tinyblob, blob, mediumblob, longblob下面将详细给出每种类型的大小和描述.数值类型类型字节描述tinyint1小整数值smallint2大整数值mediumint3大整数值int4大整数值bigint8极大整数值float4单精度浮点数double8双精度浮点数decimaldecimal(M,D)定点数DECIMAL(M,D) 高精度的数据类型, 常用来存储交易相关的数据M 代表总精度, N 代表小数点右侧的位数. 1 < M < 254, 0 < N < 60, 存储空间变长日期和时间类型字节描述date3精确到年月日, 如: 2016-09-01time3精确到时分秒, 如: 09:10:11datetime8精确到年月日时分秒, 如: 2016-09-01 09:10:11timedtamp8精确到年月日时分秒, 如: 2016-09-01 09:10:11MySQL 5.6.4 之后, datetime 和 timestamp 支持到微秒timestamp 会根据时区进行转换, datetime 则不会timestamp 存储范围: 1970-01-01 00::00:01 ~ 2038-01-19 03:14:07datetime 的存储范围: 1000-01-01 00:00:00 to 9999-12-31 23:59:59一般使用 timestamp 国际化如存时间戳使用数字类型 BIGINT字符串类型类型大小描述char最大为255字符存储定长, 容易造成空间的浪费varchar可以超过255个字符存储变长,节省存储空间text总大小为65535字节,约为64KB长文本数据根据 MySQL 版本的不同, 类型的大小范围可能会有改变.常用命令数据库创建CREATE DATABASE <name>;删除DROP DATABASE <name>;列出数据库show databases;使用数据库use <name>;修改 CHARSETALTER DATABASE <name> DEFAULT CHARACTER SET <char name>;显示创建类型show create database <name>表创建CREATE TABLE <name>(id INT(11) AUTO_INCREMENT),name VARCHAR(64) NOT NULL,…)CHARACTER SET=utf8mb4;删除DROP TABLE <name>;插入INSERT INTO <name>(filed1, filed2,…) VALUES (value1, value2,…),(value3,value4,…),…;可同时插入多条记录查询SELECT filed1,filed2,… FROM <name> WHERE <condition>多表查询: SELECT t1.id,t1.name,t2.name AS desc FROM t1,t2 WHERE t1.t2_id=t2.id, 这将会返回两个表的交集多表查询也可使用联合查询, 联合查询详情见后文.使用 LIKE 可以模糊查询, % 来表示任意字符, 如: SELECT * FROM <name> WHERE name LIKE ‘%oy%’;显示表显示表字段desc <name>显示表创建字段show create table <name>列出表show tables;修改值UPDATE <name> SET <filed1>=value1 WHERE <condition>添加列ALTER TABLE <name> ADD filed1 <type>删除列ALTER TABLE <name> DROP filed1修改列类型ALTER TABLE <name> MODIFY filed1 <type>ALTER TABLE <name> CHANGE filed1 filed1 <type> 可以修改列名修改 CHARSET修改表的默认 CHARSETALTER TABLE <name> DEFAULT CHARACTER SET utf8mb4修改表及所以列的字符ALTER TABLE <name> CONVERT TO CHARACTER SET utf8mb4修改列的字符集ALTER TABLE <name> CHANGE filed1 CHARACTER SET utf8mb4备份/恢复备份mysqldump -h<address> –port <port> -u<username> -p<password> <datebase name> <table name> > bak.sql其中 table name 可选恢复mysql -h<address> –port <port> -u<username> -p<password> <datebase name> < bak.sql导入数据到指定的数据库中联合查询假设存在表: user(id, name, email) 和 user_profile(id,uid,avatar), 几种联合方式如下:左联LEFT JOIN 或 LEFT OUTER JOIN 返回的结果包含左表中的所有行, 若左行在右行中匹配, 则在对应的右表中显示为 NULLSELECT * FROM user LEFT JOIN user_profile ON user.id=user_profile.uid右联RIGHT JOIN 或 RIGHT OUTER JOIN 返回的结果包含右表中的所有行, 若左行在右行中匹配, 则在对应的左表中显示为 NULL全联FULL JOIN 或 FULL OUTER JOIN 返回左右两表中的所有行, 如果右表中某行在左表中没有匹配, 则结果中对应行右表的部分全部为 NULL;如果左表中某行在右表中没有匹配, 则结果中对应行左表的部分全部为空 NULL.内联inner join 是比较运算符, 只返回符合条件的行, 如:SELECT * FROM user INNER JOIN user_profile ON user.id=user_profile.uid等同于 SELECT * FROM user,user_profile WHERE user.id=user_profile.uid ...

April 7, 2019 · 2 min · jiezi

TiDB 源码阅读系列文章(二十一)基于规则的优化 II

在 TiDB 源码阅读系列文章(七)基于规则的优化 一文中,我们介绍了几种 TiDB 中的逻辑优化规则,包括列剪裁,最大最小消除,投影消除,谓词下推和构建节点属性,本篇将继续介绍更多的优化规则:聚合消除、外连接消除和子查询优化。聚合消除聚合消除会检查 SQL 查询中 Group By 语句所使用的列是否具有唯一性属性,如果满足,则会将执行计划中相应的 LogicalAggregation 算子替换为 LogicalProjection 算子。这里的逻辑是当聚合函数按照具有唯一性属性的一列或多列分组时,下层算子输出的每一行都是一个单独的分组,这时就可以将聚合函数展开成具体的参数列或者包含参数列的普通函数表达式,具体的代码实现在 rule_aggregation_elimination.go 文件中。下面举一些具体的例子。例一:下面这个 Query 可以将聚合函数展开成列的查询:select max(a) from t group by t.pk;被等价地改写成:select a from t;例二:下面这个 Query 可以将聚合函数展开为包含参数列的内置函数的查询:select count(a) from t group by t.pk;被等价地改写成:select if(isnull(a), 0, 1) from t;这里其实还可以做进一步的优化:如果列 a 具有 Not Null 的属性,那么可以将 if(isnull(a), 0, 1) 直接替换为常量 1(目前 TiDB 还没做这个优化,感兴趣的同学可以来贡献一个 PR)。另外提一点,对于大部分聚合函数,参数的类型和返回结果的类型一般是不同的,所以在展开聚合函数的时候一般会在参数列上构造 cast 函数做类型转换,展开后的表达式会保存在作为替换 LogicalAggregation 算子的 LogicalProjection 算子中。这个优化过程中,有一点非常关键,就是如何知道 Group By 使用的列是否满足唯一性属性,尤其是当聚合算子的下层节点不是 DataSource 的时候?我们在 (七)基于规则的优化 一文中的“构建节点属性”章节提到过,执行计划中每个算子节点会维护这样一个信息:当前算子的输出会按照哪一列或者哪几列满足唯一性属性。因此,在聚合消除中,我们可以通过查看下层算子保存的这个信息,再结合 Group By 用到的列判断当前聚合算子是否可以被消除。外连接消除不同于 (七)基于规则的优化 一文中“谓词下推”章节提到的将外连接转换为内连接,这里外连接消除指的是将整个连接操作从查询中移除。外连接消除需要满足一定条件:条件 1 : LogicalJoin 的父亲算子只会用到 LogicalJoin 的 outer plan 所输出的列条件 2 :条件 2.1 : LogicalJoin 中的 join key 在 inner plan 的输出结果中满足唯一性属性条件 2.2 : LogicalJoin 的父亲算子会对输入的记录去重条件 1 和条件 2 必须同时满足,但条件 2.1 和条件 2.2 只需满足一条即可。满足条件 1 和 条件 2.1 的一个例子:select t1.a from t1 left join t2 on t1.b = t2.pk;可以被改写成:select t1.a from t1;满足条件 1 和条件 2.2 的一个例子:select distinct(t1.a) from t1 left join t2 on t1.b = t2.b;可以被改写成:select distinct(t1.a) from t1;具体的原理是,对于外连接,outer plan 的每一行记录肯定会在连接的结果集里出现一次或多次,当 outer plan 的行不能找到匹配时,或者只能找到一行匹配时,这行 outer plan 的记录在连接结果中只出现一次;当 outer plan 的行能找到多行匹配时,它会在连接结果中出现多次;那么如果 inner plan 在 join key 上满足唯一性属性,就不可能存在 outer plan 的行能够找到多行匹配,所以这时 outer plan 的每一行都会且仅会在连接结果中出现一次。同时,上层算子只需要 outer plan 的数据,那么外连接可以直接从查询中被去除掉。同理就可以很容易理解当上层算子只需要 outer plan 的去重后结果时,外连接也可以被消除。这部分优化的具体代码实现在 rule_join_elimination.go 文件中。子查询优化 / 去相关子查询分为非相关子查询和相关子查询,例如:– 非相关子查询select * from t1 where t1.a > (select t2.a from t2 limit 1);– 相关子查询select * from t1 where t1.a > (select t2.a from t2 where t2.b > t1.b limit 1);对于非相关子查询, TiDB 会在 expressionRewriter 的逻辑中做两类操作:子查询展开即直接执行子查询获得结果,再利用这个结果改写原本包含子查询的表达式;比如上述的非相关子查询,如果其返回的结果为一行记录 “1” ,那么整个查询会被改写为:select * from t1 where t1.a > 1;详细的代码逻辑可以参考 expression_rewriter.go 中的 handleScalarSubquery 和 handleExistSubquery 函数。子查询转为 Join对于包含 IN (subquery) 的查询,比如:select * from t1 where t1.a in (select t2.a from t2);会被改写成:select t1.* from t1 inner join (select distinct(t2.a) as a from t2) as sub on t1.a = sub.a;如果 t2.a 满足唯一性属性,根据上面介绍的聚合消除规则,查询会被进一步改写成:select t1.* from t1 inner join t2 on t1.a = t2.a;这里选择将子查询转化为 inner join 的 inner plan 而不是执行子查询的原因是:以上述查询为例,子查询的结果集可能会很大,展开子查询需要一次性将 t2 的全部数据从 TiKV 返回到 TiDB 中缓存,并作为 t1 扫描的过滤条件;如果将子查询转化为 inner join 的 inner plan ,我们可以更灵活地对 t2 选择访问方式,比如我们可以对 join 选择 IndexLookUpJoin 实现方式,那么对于拿到的每一条 t1 表数据,我们只需拿 t1.a 作为 range 对 t2 做一次索引扫描,如果 t1 表很小,相比于展开子查询返回 t2 全部数据,我们可能总共只需要从 t2 返回很少的几条数据。注意这个转换的结果不一定会比展开子查询更好,其具体情况会受 t1 表和 t2 表数据的影响,如果在上述查询中, t1 表很大而 t2 表很小,那么展开子查询再对 t1 选择索引扫描可能才是最好的方案,所以现在有参数控制这个转化是否打开,详细的代码可以参考 expression_rewriter.go 中的 handleInSubquery 函数。对于相关子查询,TiDB 会在 expressionRewriter 中将整个包含相关子查询的表达式转化为 LogicalApply 算子。LogicalApply 算子是一类特殊的 LogicalJoin ,特殊之处体现在执行逻辑上:对于 outer plan 返回的每一行记录,取出相关列的具体值传递给子查询,再执行根据子查询生成的 inner plan ,即 LogicalApply 在执行时只能选择类似循环嵌套连接的方式,而普通的 LogicalJoin 则可以在物理优化阶段根据代价模型选择最合适的执行方式,包括 HashJoin,MergeJoin 和 IndexLookUpJoin,理论上后者生成的物理执行计划一定会比前者更优,所以在逻辑优化阶段我们会检查是否可以应用“去相关”这一优化规则,试图将 LogicalApply 转化为等价的 LogicalJoin 。其核心思想是将 LogicalApply 的 inner plan 中包含相关列的那些算子提升到 LogicalApply 之中或之上,在算子提升后如果 inner plan 中不再包含任何的相关列,即不再引用任何 outer plan 中的列,那么 LogicalApply 就会被转换为普通的 LogicalJoin ,这部分代码逻辑实现在 rule_decorrelate.go 文件中。具体的算子提升方式分为以下几种情况:inner plan 的根节点是 LogicalSelection则将其过滤条件添加到 LogicalApply 的 join condition 中,然后将该 LogicalSelection 从 inner plan 中删除,再递归地对 inner plan 提升算子。以如下查询为例:select * from t1 where t1.a in (select t2.a from t2 where t2.b = t1.b);其生成的最初执行计划片段会是:LogicalSelection 提升后会变成如下片段:到此 inner plan 中不再包含相关列,于是 LogicalApply 会被转换为如下 LogicalJoin :inner plan 的根节点是 LogicalMaxOneRow即要求子查询最多输出一行记录,比如这个例子:select *, (select t2.a from t2 where t2.pk = t1.a) from t1;因为子查询出现在整个查询的投影项里,所以 expressionRewriter 在处理子查询时会对其生成的执行计划在根节点上加一个 LogicalMaxOneRow 限制最多产生一行记录,如果在执行时发现下层输出多于一行记录,则会报错。在这个例子中,子查询的过滤条件是 t2 表的主键上的等值条件,所以子查询肯定最多只会输出一行记录,而这个信息在“构建节点属性”这一步时会被发掘出来并记录在算子节点的 MaxOneRow 属性中,所以这里的 LogicalMaxOneRow 节点实际上是冗余的,于是我们可以将其从 inner plan 中移除,然后再递归地对 inner plan 做算子提升。inner plan 的根节点是 LogicalProjection则首先将这个投影算子从 inner plan 中移除,再根据 LogicalApply 的连接类型判断是否需要在 LogicalApply 之上再加上一个 LogicalProjection ,具体来说是:对于非 semi-join 这一类的连接(包括 inner join 和 left join ),inner plan 的输出列会保留在 LogicalApply 的结果中,所以这个投影操作需要保留,反之则不需要。最后,再递归地对删除投影后的 inner plan 提升下层算子。inner plan 的根节点是 LogicalAggregation首先我们会检查这个聚合算子是否可以被提升到 LogicalApply 之上再执行。以如下查询为例:select , (select sum(t2.b) from t2 where t2.a = t1.pk) from t1;其最初生成的执行计划片段会是:将聚合提升到 LogicalApply 后的执行计划片段会是:即先对 t1 和 t2 做连接,再在连接结果上按照 t1.pk 分组后做聚合。这里有两个关键变化:第一是不管提升前 LogicalApply 的连接类型是 inner join 还是 left join ,提升后必须被改为 left join ;第二是提升后的聚合新增了 Group By 的列,即要按照 outer plan 传进 inner plan 中的相关列做分组。这两个变化背后的原因都会在后面进行阐述。因为提升后 inner plan 不再包含相关列,去相关后最终生成的执行计划片段会是:聚合提升有很多限定条件:LogicalApply 的连接类型必须是 inner join 或者 left join 。 LogicalApply 是根据相关子查询生成的,只可能有 3 类连接类型,除了 inner join 和 left join 外,第三类是 semi join (包括 SemiJoin,LeftOuterSemiJoin,AntiSemiJoin,AntiLeftOuterSemiJoin),具体可以参考 expression_rewriter.go 中的代码,限于篇幅在这里就不对此做展开了。对于 semi join 类型的 LogicalApply ,因为 inner plan 的输出列不会出现在连接的结果中,所以很容易理解我们无法将聚合算子提升到 LogicalApply 之上。LogicalApply 本身不能包含 join condition 。以上面给出的查询为例,可以看到聚合提升后会将子查询中包含相关列的过滤条件 (t2.a = t1.pk) 添加到 LogicalApply 的 join condition 中,如果 LogicalApply 本身存在 join condition ,那么聚合提升后聚合算子的输入(连接算子的输出)就会和在子查询中时聚合算子的输入不同,导致聚合算子结果不正确。子查询中用到的相关列在 outer plan 输出里具有唯一性属性。以上面查询为例,如果 t1.pk 不满足唯一性,假设 t1 有两条记录满足 t1.pk = 1,t2 只有一条记录 { (t2.a: 1, t2.b: 2) } ,那么该查询会输出两行结果 { (sum(t2.b): 2), (sum(t2.b): 2) } ;但对于聚合提升后的执行计划,则会生成错误的一行结果 { (sum(t2.b): 4) } 。当 t1.pk 满足唯一性后,每一行 outer plan 的记录都对应连接结果中的一个分组,所以其聚合结果会和在子查询中的聚合结果一致,这也解释了为什么聚合提升后需要按照 t1.pk 做分组。聚合函数必须满足当输入为 null 时输出结果也一定是 null 。这是为了在子查询中没有匹配的特殊情况下保证结果的正确性,以上面查询为例,当 t2 表没有任何记录满足 t2.a = t1.pk 时,子查询中不管是什么聚合函数都会返回 null 结果,为了保留这种特殊情况,在聚合提升的同时, LogicalApply 的连接类型会被强制改为 left join(改之前可能是 inner join ),所以在这种没有匹配的情况下,LogicalApply 输出结果中 inner plan 部分会是 null ,而这个 null 会作为新添加的聚合算子的输入,为了和提升前结果一致,其结果也必须是 null 。对于根据上述条件判定不能提升的聚合算子,我们再检查这个聚合算子的子节点是否为 LogicalSelection ,如果是,则将其从 inner plan 中移除并将过滤条件添加到 LogicalApply 的 join condition 中。这种情况下 LogicalAggregation 依然会被保留在 inner plan 中,但会将 LogicalSelection 过滤条件中涉及的 inner 表的列添加到聚合算子的 Group By 中。比如对于查询:select , (select count() from t2 where t2.a = t1.a) from t1;其生成的最初的执行计划片段会是:因为聚合函数是 count() ,不满足当输入为 null 时输出也为 null 的条件,所以它不能被提升到 LogicalApply 之上,但它可以被改写成:注意 LogicalAggregation 的 Group By 新加了 t2.a ,这一步将原本的先做过滤再做聚合转换为了先按照 t2.a 分组做聚合,再将聚合结果与 t1 做连接。 LogicalSelection 提升后 inner plan 已经不再依赖 outer plan 的结果了,整个查询去相关后将会变为:总结这是基于规则优化的第二篇文章,后续我们还将介绍更多逻辑优化规则:聚合下推,TopN 下推和 Join Reorder 。 ...

December 13, 2018 · 4 min · jiezi