当前位置: 欣欣网 > 资讯

StarRocks消除聚合操作规则(EliminateAggRule)解析

2024-05-24资讯

引言

在数据库查询优化中,消除不必要的聚合操作是提高查询性能的重要手段。本文将详细介绍StarRocks优化器中的EliminateAggRule类,该类通过一系列规则检查和转换,优化聚合查询。我们将深入解析其实现细节,并解释每个步骤的目的和原理。

PR链接:https://github.com/StarRocks/starrocks/pull/45432

背景介绍

当使用唯一键(Unique Key,UK)执行聚合查询时,在满足某些条件下可以将聚合操作消除。例如,查询「SELECT MAX(t1) FROM demo GROUP BY uk」可以改写为「SELECT t1 FROM demo」。这种改写的原因在于唯一键的特性,UK保证了表中的每一行数据都是唯一的,因此每个UK值都不会重复。
更详细的原因如下:

  1. 主键的唯一性和非空约束:

  • 唯一键(Unique Key,UK)的定义要求每个值都是唯一的,并且唯一键不允许为空。这意味着表中的每一行都可以通过UK进行唯一标识。

  • 2. 分组和聚合操作的逻辑:

  • 使用「GROUP BY」子句时,数据库会按照一个或多个列进行分组。当使用「GROUP BY uk」时,由于UK是唯一键,每个UK值在表中都是唯一的,因此每个分组实际上都包含该唯一键的行。

  • 当对一个数据组使用「MAX(t1)」或其他聚合函数时,如果分组中只有一条记录(因为每个UK值唯一,故每组只有一行),那么聚合函数的结果就是那条记录中相应的值。因此,「MAX(t1)」的结果就是该行的「t1」字段值。

  • 3. 查询改写的合理性:

  • 原查询「SELECT MAX(t1) FROM demo GROUP BY uk」实际上对于每个唯一键都返回「t1」的最大值。但由于每个唯一键对应只有一条记录,「t1」列的最大值就是「t1」列的实际值。因此,这个查询可以简化为「SELECT t1 FROM demo」,直接返回每行的「t1」字段值。这样做不仅更简单,而且效率更高,避免了不必要的分组和聚合计算过程。

  • 通过以上分析可以看出,当UK为表的唯一键时,使用「GROUP BY uk」对数据进行分组实际是多余的,因为每个唯一键都唯一标识一条记录。在这种情况下,任何对单个记录的聚合操作都可以通过选择该记录的相应字段来实现,从而简化查询并提高查询性能。

    实现步骤

    在StarRocsk中添加新的优化规则一般涉及到查询优化器的修改,一些明显正收益的优化规则一般添加在FE层的logicalRuleRewrite方法中。logicalRuleRewrite的主要作用是通过一系列逻辑规则(Logical Rules)来优化查询计划。这个步骤在查询优化过程中起到关键作用,通过应用预定义的优化规则来改写查询计划,使其更加高效。
    logicalRuleRewrite的具体作用包括:

    1. 简化查询计划:通过应用优化规则来简化查询计划,消除冗余操作。例如,去掉不必要的投影、过滤等操作

    2. 重写查询结构:根据优化规则重写查询的逻辑结构,使得查询更加高效。例如,将嵌套查询转换为连接操作,或者将复杂的表达式简化为等价的更高效的表达式。

    3. 优化操作顺序:调整查询计划中操作的顺序,使得数据处理的效率更高。例如,将选择操作(过滤条件)尽可能提前到数据处理的早期阶段,以减少后续处理的数据量。

    4. 应用特定的优化策略:根据查询的特征和数据库的内部实现,应用特定的优化策略。例如,使用索引、分区等技术来优化查询性能。

    5. 准备物理执行计划:logicalRuleRewrite是生成最终物理计划的重要一步。逻辑优化后的查询计划将作为物理计划生成的基础,在物理计划生成阶段进一步优化和具体化。

    具体实现上,logicalRuleRewrite会通过遍历查询树,应用一系列定义的逻辑规则对查询计划进行改写和优化。每个逻辑规则通常会针对特定的查询模式进行优化,例如子查询消除、连接条件下推、常量折叠等。
    logicalRuleRewrite中的一条新的优化规则通常由以下部分组成:

    1. 规则类型和模式匹配:每条规则都需要定义其类型和匹配模式。规则类型通常是一个枚举值,用于标识规则的类别。模式匹配适用于指定规则的查询计划模式。

    2. 规则的实现类:定义一个继承TransformationRule的实现类,里面包含规则的具体实现逻辑,通过重写check和transform来实现具体的优化规则。

    在StarRocks中的logicalRuleRewrite执行过程中,规则的应用顺序是优化器设计的一个重要方面。确定规则的应用顺序通常依据以下几个逻辑和原则:

    1. 规则的优先性:不同规则可能有不同的优先级。通常,高优先级的规则先应用,因为它们对查询计划的优化效果更明显。例如,消除冗余操作或简化查询结构的规则通常优先应用。

    2. 规则的依赖关系:有些规则可能依赖于其他规则的结果。例如,某些规则在执行前需要其他规则先行优化查询计划。因此,必须确保依赖的规则先应用。

    3. 规则的作用和范围:一些规则的作用范围较广,可能在更大范围内进行优化,而其他规则可能只针对特定类型的查询操作。一般来说,范围较广的规则优先应用,以便为后续更具体的优化规则创造条件。

    4. 规则的变换性质:规则的变换性质也影响应用顺序。某些变化规则可能引入新的优化机会,这些机会通常需要通过后续规则进一步优化。例如,将一个子查询改写为连接操作后,可能会有新的连接下推的机会。

    5. 规则的代价和收益:优化器可能会根据规则的应用代价和潜在收益来确定顺序。高收益且低代价的规则会优先应用,以尽可能减少查询优化的总体开销。

    在StarRocks中添加新优化规则具体实践中,确定规则的放置位置有两种具体方法:

    1. 查看规则的注释,有些规则的注释会明确指出其特定的放置要求。例如,某些规则在执行后不允许生成新的project操作,那么生成新project的规则就需要放在这些规则之前。你还可以搜索类似的优化关键字,尽量将同类优化规则靠近放置。

    2. 与StarRocks的研发团队同学沟通。StarRocks的研发团队拥有丰富的经验,他们可以快速判断出新优化规则的最佳放置位置。与他们沟通可以确保你的规则在整个优化流程中发挥最大效用。

    下面将介绍如何实现具体的消除规则实现过程

    1. 模式规则的定义

    在StarRocks的logicalRuleRewrite添加新的规则时,通常的做法是用新的节点替换匹配到的节点。这种转换规则的目的是通过查询计划中的某些节点进行替换和重构,以实现查询优化。
    定义一个新的模式匹配规则涉及到创建一个继承TransformationRule的类,并在其构造函数中指定规则类型和匹配模式。具体的实现步骤如下

    1. 创建新的规则类:首先,在package com.starrocks.sql.optimizer.rule.transformation下创建一个新的EliminateAggRule类

    2. 定义规则类型和匹配模式:在类的构造函数中,定义规则类型和匹配模式。

    super(RuleType.TF_ELIMINATE_AGG, Pattern.create(OperatorType.LOGICAL_AGGR)
    .addChildren(Pattern.create(OperatorType.PATTERN_LEAF)));

    下面对实现代码进行详细的解释说明:
    super(RuleType.TF_ELIMINATE_AGG, ...)
    这个部分调用了父类的构造函数,传递了两个参数:规则类型和匹配模式。RuleType.TF_ELIMINATE_AGG:这是一个枚举值,表示规则的类型。TF代表某种类型转换或传递函数。ELIMINATE_AGG表示这是一个用于消除聚合操作的规则。

    Pattern.create(OperatorType.LOGICAL_AGGR)
    这个部分定义了匹配模式的根节点,即这个模式规则的查询计划节点类型。
    Pattern.create(...):创建一个新的匹配模式。
    OperatorType.LOGICAL_AGGR:指定匹配的节点类型为逻辑聚合操作(LOGICAL_AGGR)。这意味着这个规则适用于查询计划中包含逻辑聚合操作的节点。
    addChildren(Pattern.create(OperatorType.PATTERN_LEAF))
    这个部分定义了根节点的子节点模式
    .addChildren(...):添加子节点模式,表示根节点必须满足的子节点结构。
    Pattern.create(OperatorType.PATTERN_LEAF):指定子节点的模式为单节点(PATTERN_LEAF)。StarRocks中节点类型包括单节点(PATTERN_LEAF)、多节点(PATTERN_MULTI_LEAF)、扫描节点(PATTERN_SCAN)等。

    2. 规则检查

    TransformationRule中,check方法用于验证输入的查询计划节点是否符合规则的应用条件。该方法的主要作用是确保只有特定条件的节点才会应用该转换规则。
    check方法主要有2个作用:

    1. 验证规则的应用条件:check方法通过检查输入的查询节点的属性、结构和其他条件,决定是否可以应用该规则,例如:对于EliminateAggrule,需要检查聚合操作中的函数能否允许被消除,分组键是否唯一等。

    2. 提高优化效率:通过预先检查条件,可以避免不必要的转换操作,提高查询优化的效率。只有满足条件的节点才会被传递给transform方法进行进一步处理。

    TransformationRule的具体逻辑和步骤详细说明如下:

    1. 检查聚合函数的合法性

    遍历聚合操作中的每一个聚合函数,检查其是否合法。如果聚合函数是Distinct的或者不是指定的聚合函数之一,则返回「false」

    for(Map.Entry<ColumnRefOperator, CallOperator> entry : aggOp.getAggregations().entrySet()){
    if(entry.getValue().isDistinct()){
    returnfalse;
    }
    String fnName = entry.getValue().getFnName();
    if(!(fnName.equals(FunctionSet.SUM)|| fnName.equals(FunctionSet.COUNT)||
    fnName.equals(FunctionSet.AVG)||
    fnName.equals(FunctionSet.FIRST_VALUE)||
    fnName.equals(FunctionSet.MAX)|| fnName.equals(FunctionSet.MIN)||
    fnName.equals(FunctionSet.GROUP_CONCAT))){
    returnfalse;
    }
    }

    Q1: 为什么要过滤掉Distinct函数

    对于Distinct聚合函数,消除聚合操作可能会导致查询结果不正确,因为Distinct意味着需要对结果集进行去重处理,直接消除会忽略这一要求。此外,从单一职责原则来看,一条优化规则应该是清晰和简单的,因此不应包含复杂的Distinct逻辑。

    Q2:为什么聚合消除只处理sum、count等函数

    主要原因如下:

  • 常用性和适用性:这些函数是最常见和广泛使用的聚合函数,处理这些函数可以覆盖大部分实际应用中的场景。这些函数具有明确的数学和逻辑定义,容易进行优化和处理。

  • 计算逻辑计算:这些函数的计算逻辑相对简单,聚合消除操作在这些函数上更容易实现。例如,SUM、COUNT、AVG、MAX、MIN、FIRST_VALUE都是基于单一列进行简单的数学运算。

  • 结果确定性:这些函数的结果是确定的,不依赖于数据的排列或其他复杂因素。因此,聚合消除操作不会影响查询的正确性。例如,sum和count只需要累加数据或计数,agv可以通过sum和count计算得出,max和min只需要比较值,first_value是第一个出现的值,而group_concat只需要将字符串连接起来。

  • 复杂函数的处理难度:其他聚合函数(如复杂的自定义聚合函数)可能具有复杂的逻辑和计算过程,聚合消除操作难以确保正确性和效率。处理这些复杂函数需要更多的逻辑判断和条件处理,增加了优化规则的复杂性,不利于规则的简单和清晰。

  • 输入参数简单:这些函数的输入参数通常只有一个,这使得消除的逻辑更加简单和清晰。处理单一输入参数的聚合函数,能够更容易地应用优化规则。例如,sum(t1)、max(t1)等函数值需要关注t1列,而不需要处理复杂的多参数输入。

  • 因此,聚合消除只处理SUM、COUNT、AVG、MAX、MIN、FIRST_VALUE、GROUP_CONCAT等函数,以确保优化规则的有效性、简单性和高效性。

    2. 验证唯一键的聚合分组键是否一致

    这个过程主要完成这几项工作:

  • 收集子节点的唯一键约束

  • UKFKConstraintsCollector collector =new UKFKConstraintsCollector();
    input.getOp().accept(collector, input,null);
    OptExpression childOptExpression = input.inputAt(0);
    Map<Integer, UKFKConstraints.UniqueConstraintWrapper> uniqueKeys =
    childOptExpression.getConstraints().getUniqueKeys();
    Set<Integer> uniqueColumnRefIds =
    uniqueKeys.keySet().stream().collect(Collectors.toSet());

    收集子节点的唯一键约束在check方法中非常重要,因为这些唯一键约束是决定能否进行聚合消除的关键。聚合消除的前提是保证每个分组(由唯一键确定的分组)中只有一条记录,因此连接子节点的唯一键约束是确保查询正确正确性的基础。
    收集子节点通常通过树遍历和约束传播算法来实现,实现这一过程的步骤如下:

    1. 树遍历:查询计划被表示为一棵树,每个节点代表一个操作符。为了收集子节点的唯一键约束,需要遍历这棵树。自下而上的遍历(bottom-up traversal)是一种常用的方法,从叶子节点开始,逐层向上收集和传播约束信息。

    2. 约束传播:每个节点根据自身的操作类型和子节点的约束信息来计算和更新自己的唯一键约束。例如,一个投影(projection)会根据子节点的唯一约束和投影列来更新自己的约束。

  • 验证唯一键的存在 :如果没有唯一键约束,分组后的每个分组可能包含多条记录,这样聚合函数就不能被安全消除。

  • 对比唯一键的数量和聚合分组键的列是否数量和ID是否一致 :唯一键(Unique Key)是在数据库表中由一列或多列组成的,这些列上的值在每一行都是唯一的,即没有重复值。如果分组键与唯一键的列完全一致(包括数量和列ID),那么按照分组键进行分组时,每个分组必定只有一条记录,因为唯一键确保了这些列的组合是唯一的。相反,如果分组键和唯一键的列数量和列ID不一致,则在分组聚合时无法保证每个分组只有一条记录。

  • 3. 执行节点转换

    在transform方法中,最重要的原则是 保持输入和输出列的一致性 。保证输出列完全一样的原因主要与查询计划的正确性和一致性有关。以下是一些关键点:

  • 保持查询结果的一致性:当优化应用一个转换规则来改变查询计划的一部分时,重要的是确保这种转换不会改变查询的预期输出。输出列必须保持不变,以确保查询前后的结果集是一致的。这意味着无论优化规则如何转换或重组查询的某些部分,最终提供给用户的数据应该与未优化的查询相同。

  • 避免对上层操作产生影响:查询计划通常由多层操作组成,如选择、投影、连接等。一个层级输出往往作为另一个层级的输入。如果一个转换规则改变了其输出列,那么依赖于这些列的上层操作可能无法正确执行,因为它们可能期待特定的列存在。

  • 计划优化过程:维持输出列的不变可以简化优化逻辑。优化器不需要担心后续操作可能因为缺少预期的列而失败。这使得编写和测试优化规则更加直接和可预测。

  • 允许独立的优化规则应用:如果每个规则都保证了输出列的一致性,那么这些规则可以独立应用,而无需担心其顺序或组合的复杂性。这种灵活性允许优化器更加自由地探索不同的优化策略组合,以找到最有效的执行计划。

  • 确保逻辑的正确性:查询计划的逻辑正确性是数据库管理核心的一部分。确保每次优化转换后的输出列不变,是维护整个查询处理过程中数据完整性和正确性的重要措施。

  • 因此,在StarRocks中,维护在应用一条新的TransformationRule规则后输出列的一致性是确保查询优化不会引起意外结果和错误的关键。这种方法确保了优化过程中的稳定性和可靠性,同时减少了优化器设计的复杂性。
    例如,在转换过程中生成新的投影(Project)操作,要确保输入列的一致性。在Project操作中,输出列的映射存储于 Map<ColumnRefOperator, ScalarOperator> 结构中,其中 ColumnRefOperator 用于表示列引用,而 ScalarOperator 用于描述相应的列表达式。ColumnRefOperator 通常包含列的唯一标识(如ID)和类型信息,这些都需要保持不变。如果 ColumnRefOperator 相同,则意味着其引用的列在数据库表中的位置和类型都未发生变化。确保每个 ColumnRefOperator 在原始和转换后的查询计划中保持相同,是保证输出列一致性的关键。
    Map<ColumnRefOperator, ScalarOperator>结构中。ColumnRefOperator和ScalarOperator分别有着特定的角色和含义,它们共同构成了在处理数据库查询时所使用的一个关键的数据映射结构。这种映射主要用于定义和管理查询计划中列的映射和转换逻辑。

    ColumnRefOperator: 是一个用于表示数据库查询中列引用的操作符。它封装了特定列的引用信息,比如列标识符,数据类型,列名,可空性。
    ScalarOperator :是一个更广泛的概念,它表示任何可能产生标量值(单个值)的操作或表达式。这可以包括简单的列引用,也可以包括复杂的表达式或函数调用。例如:SELECT MAX(t1) AS tt1 FROM demo GROUP BY uk;MAX函数的ScalarOperator通常是一个CallOperator。CallOperator是一个表示函数调用的操作符,用于处理聚合函数、标量函数等。而通过聚合消除之后,CallOperator转换为适当的ScalarOperator。转换后的ScalarOperator需要保证返回类型一致。

    Map<ColumnRefOperator, ScalarOperator>结构中,每个ColumnRefOperator作为键,表示输出列的一个标识,而与之关联的ScalarOperator作为值,定义了如何计算或派生这个列的值。这个映射允许复杂的数据转换和计算逻辑被明确地表示在查询计划中的某个阶段,例如在逻辑投影(LogicalProject)操作中。

    这种映射的设计不仅清晰地分离了列的标识(哪一列)和列的计算逻辑(如何处理这一列),还提供了足够的灵活性来支持广泛的查询优化和执行策略,这使得数据库系统能够有效地处理和优化复杂的查询请求。

    综上所述,在转换过程中输出的 ColumnRefOperator 通常保持不变,可以直接沿用输入时的列引用。而ScalarOperator 通常会发生变化,因为优化过程涉及调整相关列的计算逻辑。

    聚合优化规则中的transform方法的目的是将符合特定条件的聚合操作转换为有效的投影操作,以优化查询计划。下面是transform方法的实现逻辑。

    1. 处理聚合函数

    for(Map.Entry<ColumnRefOperator, CallOperator> entry : aggOp.getAggregations().entrySet()){
    ColumnRefOperator aggColumnRef = entry.getKey();
    CallOperator callOperator = entry.getValue();
    ScalarOperator newOperator = handleAggregationFunction(callOperator.getFnName(), callOperator);
    newProjectMap.put(aggColumnRef, newOperator);
    }

    遍历聚合操作符中的每个聚合函数,将其转换为新的操作符,然后将这些新的操作符放入一个新的投影映射 (newProjectMap)中。

    根据聚合函数的名称调用不同的处理函数

    private ScalarOperator handleAggregationFunction(String fnName, CallOperator callOperator){
    if(fnName.equals(FunctionSet.COUNT)){
    return rewriteCountFunction(callOperator);
    }elseif(fnName.equals(FunctionSet.SUM)|| fnName.equals(FunctionSet.AVG)||
    fnName.equals(FunctionSet.FIRST_VALUE)|| fnName.equals(FunctionSet.MAX)||
    fnName.equals(FunctionSet.MIN)|| fnName.equals(FunctionSet.GROUP_CONCAT)){
    return rewriteCastFunction(callOperator);
    }
    return callOperator;
    }

    这里为什么只有count单独处理,而其他函数直接处理。原因如下:

  • count函数的特殊性:参数为空的时候,count(*)计算的是非空的数量,与具体的列值无关。参数不为空的时候,需要检查列值是否为Null。

  • 其他聚合函数的通用性:SUM、AVG、FIRST_VALUE、MAX、MIN、GROUP_CONCAT等函数的计算逻辑相对简单,可以通过类型转换保持一致。这些函数直接作用于列值,不需要额外的逻辑判断。另外,这些函数只需确保返回类型与参数类型一致,通过类型转换通过类型转换(CastOperator)可以轻松实现这一点。

  • private ScalarOperator rewriteCountFunction(CallOperator callOperator){
    if(callOperator.getArguments().isEmpty()){
    return ConstantOperator.createInt(1);
    }
    IsNullPredicateOperator isNullPredicateOperator =
    new IsNullPredicateOperator(callOperator.getArguments().get(0));
    ArrayList<ScalarOperator> ifArgs = Lists.newArrayList();
    ScalarOperator thenExpr = ConstantOperator.createInt(0);
    ScalarOperator elseExpr = ConstantOperator.createInt(1);
    ifArgs.add(isNullPredicateOperator);
    ifArgs.add(thenExpr);
    ifArgs.add(elseExpr);
    Type[] argumentTypes = ifArgs.stream().map(ScalarOperator::getType).toArray(Type[]::new);
    Function fn =
    Expr.getBuiltinFunction(FunctionSet.IF, argumentTypes, Function.CompareMode.IS_NONSTRICT_SUPERTYPE_OF);
    returnnew CallOperator(FunctionSet.IF, ScalarType.createType(PrimitiveType.TINYINT), ifArgs, fn);
    }

    rewriteCountFunction函数的目的是处理count聚合函数的重写逻辑,使其能够在不需要聚合的情况下正确执行。实现判断是否有参数比如(count(*)),如果没有参数则直接返回常量1。如果有参数则创建IsNullPredicateOperator用于判断参数是否为Null。
    第一个参数:isNullPredicateOperator,判断列值是否为NULL。
    第二个参数:thenExpr,如果列值为NULL,返回0。
    第三个参数:elseExpr,如果列值不为NULL,返回1。
    最终使用IF函数创建CallOperator,模拟Count(column)的行为。

    private ScalarOperator rewriteCastFunction(CallOperator callOperator){
    ScalarOperator argument = callOperator.getArguments().get(0);
    if(callOperator.getType().equals(argument.getType())){
    return argument;
    }
    ScalarOperator scalarOperator =new CastOperator(callOperator.getType(), argument);
    return scalarOperator;
    }

    rewriteCastFunction函数的是处理(如SUM、AVG、FIRST_VALUE、MAX、MIN、GROUP_CONCAT)的重写逻辑,确保它们在聚合操作消除后,参数类型与聚合函数的返回类型一致,从而保证查询结果的正确性。这些函数可能涉及类型转换,因此需要生成适当的类型转换操作符(CastOperator)。代码的整体逻辑:

  • 从聚合操作符中获取第一个参数。检查聚合函数返回类型与参数类型是否一致,如果一致,直接返回参数。

  • 如果不一致,生成一个类型转换符,将参数转换为聚合函数的返回类型。

  • 2. 保留分组列

    aggOp.getGroupingKeys()
    .forEach(columnRefOperator -> newProjectMap.put(columnRefOperator, columnRefOperator))

    这段代码的目的是将聚合操作符中的分组列直接添加到新的投影中,以确保在聚合消除后,这些列仍然保留在查询计划中。比如执行SELECT uk, MAX(t1) FROM demo GROUP BY uk,uk就是分组列,我们在转换时同样需要保留。这个逻辑的具体步骤如下:

  • 获取分组列:aggOp.getGroupingKeys()。返回一个包含聚合操作符中所有分组键(grouping keys)的列表。

  • 遍历分组列:.forEach(columnRefOperator -> ...)。遍历分组键列表中的每个 ColumnRefOperator 对象。

  • 添加到新的投影映射:newProjectMap.put(columnRefOperator, columnRefOperator)。 将每个分组键列引用操作符 columnRefOperator 作为键和值放入 newProjectMap 中。

  • 通过这个操作,可以保证在消除聚合操作符并转换为投影操作符后,这些分组列依然存在于查询计划中,并且可以正确地参与后续的查询处理和优化。

    3. 创建新的投影映射

    LogicalProjectOperator newProjectOp =
    LogicalProjectOperator.builder().setColumnRefMap(newProjectMap).build();

    LogicalProjectOperator.builder().setColumnRefMap(newProjectMap).build(),使用构建器模式创建一个新的LogicalProjectOperator实例,设置列引用映射为newProjectMap,这确保了新的投影操作符包含了所有需要的列和它们对应的操作。

    4. 返回新的查询计划节点

    return List.of(OptExpression.create(newProjectOp, input.inputAt(0)));

    使用OptExpression.create方法创建一个新的查询计划节点,该节点包含新的投影操作符,并将原始查询计划Agg节点的子节点作为输入。返回一个包含该新查询计划节点的不可变单元素列表。新的计划节点可以直接使用 input.inputAt(0) 子节点的输入,原因如下:
    保持查询计划结构的连贯性:

  • 节点替换原则:在查询计划转换过程中,通常是将某个节点替换为另一个节点,但该节点的子节点结构不变。这样可以确保在转换后,新的节点仍然与原有的子节点相连接,从而保持查询计划结构的连贯性。

  • 保持数据流的正确性:原始的子节点输入包含了查询计划中已经存在的数据流和操作步骤。这些子节点在转换后的新节点中仍然需要被保留,以确保数据处理的正确顺序和逻辑不变。

  • 保持输入输出一致:列引用的一致性,子节点输入的列引用在转换前后需要保持一致。通过直接使用原来的子节点输入,可以确保列引用的一致性,避免列引用丢失或不匹配。查询逻辑的一致性,转换后的查询逻辑应该与转换前的逻辑一致。直接使用原有子节点输入,可以确保查询逻辑的连贯性和正确性。

  • 单元测试

    在StarRcosk这样的系统只能够,单元测试的重要性不容忽略。它不仅保证了系统的稳定性和可靠性,还提高了代码质量,支持快速迭代和持续集成,防止回归问题,并提供了对重构和新功能开发的支持,通过编写全面的单元测试,开发者可以确保系统在各种情况下都能稳定运行,从而为用户提供高质量的数据库服务。

    而如何保证数据库进行完整的测试覆盖,主要有三种方法:

    1. 从数据库本身支持的语法文件、函数、类型进行正交

    2. 尝试各种随机性测试

    3. 人工正交梳理,持续记录相关场景的测试case。

    不断积累的单元测试case对于数据库而言是一笔宝贵的资产。

    在进行测试之前,我们需要构建数据表,插入测试数据集和生成覆盖全部场景的测试SQL。

    1. 创建测试数据表

    ---------unqiue key测试表-----------------
    CREATE TABLE `test_agg_group_single_unique_key` (
    id INTNOTNULL,
    big_value BIGINT,
    double_value DOUBLE,
    decimal_value DECIMAL(10, 5),
    varchar_value VARCHAR(255)
    ) ENGINE=OLAP
    UNIQUEKEY(id)
    DISTRIBUTED BYHASH(id) BUCKETS 10
    PROPERTIES (
    "replication_num"="1"
    );
    ---------unqiue key测试表-----------------
    CREATE TABLE `test_agg_group_multi_unique_key` (
    id INTNOTNULL,
    big_value BIGINT,
    double_value DOUBLE,
    decimal_value DECIMAL(10, 5),
    varchar_value VARCHAR(255)
    ) ENGINE=OLAP
    UNIQUEKEY(id,big_value)
    DISTRIBUTED BYHASH(id) BUCKETS 10
    PROPERTIES (
    "replication_num"="1"
    );

    SUM、AVG、FIRST_VALUE、MAX、MIN、GROUP_CONCAT的输入参数一般为整形与字符型,所以在创建测试表的时候也主要使用这些字段类型。

    2. 创建测试数据集

    -- 插入测试数据
    INSERTINTO`test_agg_group_single_unique_key` (id, big_value, double_value, decimal_value, varchar_value) VALUES
    (1, 100000, 1.23, 123.45678, 'Test1'),
    (2, 200000, 2.34, 234.56789, 'Test2'),
    (3, 300000, 3.45, 345.67890, 'Test3'),
    (4, 400000, 4.56, 456.78901, 'Test4'),
    (5, 500000, 5.67, 567.89012, NULL),
    (6, 600000, 6.78, 678.90123, 'Test6'),
    (7, 700000, 7.89, 789.01234, NULL),
    (8, 800000, 8.90, 890.12345, 'Test8'),
    (9, 900000, 9.01, 901.23456, NULL),
    (10, 1000000, 10.12, 1012.34567, 'Test10'),
    (11, 1100000, 11.23, 1123.45678, 'Test11'),
    (12, 1200000, 12.34, 1234.56789, 'Test12'),
    (13, 1300000, 13.45, 1345.67890, NULL),
    (14, 1400000, 14.56, 1456.78901, 'Test14'),
    (15, 1500000, 15.67, 1567.89012, 'Test15'),
    (16, 1600000, 16.78, 1678.90123, NULL),
    (17, 1700000, 17.89, 1789.01234, 'Test17'),
    (18, 1800000, 18.90, 1890.12345, 'Test18'),
    (19, 1900000, 19.01, 1901.23456, NULL),
    (20, 2000000, 20.12, 2012.34567, 'Test20');
    -- 插入测试数据
    INSERTINTO`test_agg_group_multi_unique_key` (id, big_value, double_value, decimal_value, varchar_value) VALUES
    (1, 100000, 1.23, 123.45678, 'Test1'),
    (2, 200000, 2.34, 234.56789, 'Test2'),
    (3, 300000, 3.45, 345.67890, 'Test3'),
    (4, 400000, 4.56, 456.78901, 'Test4'),
    (5, 500000, 5.67, 567.89012, NULL),
    (6, 600000, 6.78, 678.90123, 'Test6'),
    (7, 700000, 7.89, 789.01234, NULL),
    (8, 800000, 8.90, 890.12345, 'Test8'),
    (9, 900000, 9.01, 901.23456, NULL),
    (10, 1000000, 10.12, 1012.34567, 'Test10'),
    (11, 1100000, 11.23, 1123.45678, 'Test11'),
    (12, 1200000, 12.34, 1234.56789, 'Test12'),
    (13, 1300000, 13.45, 1345.67890, NULL),
    (14, 1400000, 14.56, 1456.78901, 'Test14'),
    (15, 1500000, 15.67, 1567.89012, 'Test15'),
    (16, 1600000, 16.78, 1678.90123, NULL),
    (17, 1700000, 17.89, 1789.01234, 'Test17'),
    (18, 1800000, 18.90, 1890.12345, 'Test18'),
    (19, 1900000, 19.01, 1901.23456, NULL),
    (20, 2000000, 20.12, 2012.34567, 'Test20');

    3. 生成测试SQL

    生成单个Unique Key测试SQL

    -- SUM 聚合函数测试
    SELECT
    id,
    SUM(big_value) AS sum_big_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- SUM 聚合函数测试 (double_value 列)
    SELECT
    id,
    SUM(double_value) AS sum_double_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- AVG 聚合函数测试
    SELECT
    id,
    AVG(big_value) AS avg_big_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- AVG 聚合函数测试 (double_value 列)
    SELECT
    id,
    AVG(double_value) AS avg_double_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- COUNT 聚合函数测试
    SELECT
    id,
    COUNT(big_value) AS count_big_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- COUNT 聚合函数测试 (varchar_value 列)
    SELECT
    id,
    COUNT(varchar_value) AS count_varchar_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- MAX 聚合函数测试
    SELECT
    id,
    MAX(decimal_value) AS max_decimal_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- MAX 聚合函数测试 (double_value 列)
    SELECT
    id,
    MAX(double_value) AS max_double_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- MIN 聚合函数测试
    SELECT
    id,
    MIN(double_value) AS min_double_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- MIN 聚合函数测试 (big_value 列)
    SELECT
    id,
    MIN(big_value) AS min_big_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- GROUP_CONCAT 聚合函数测试
    SELECT
    id,
    GROUP_CONCAT(varchar_value ORDERBY varchar_value) AS group_concat_varchar_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;
    -- GROUP_CONCAT 聚合函数测试 (double_value 列)
    SELECT
    id,
    GROUP_CONCAT(double_value ORDERBY double_value) AS group_concat_double_value
    FROM
    test_agg_group_single_unique_key
    GROUPBY
    id
    ORDERBY
    id;










    生成多个Unique Key测试SQL

    -- SUM 聚合函数测试 (double_value 列)
    SELECT
    id,
    big_value,
    SUM(double_value) AS sum_double_value
    FROM
    test_agg_group_multi_unique_key
    GROUPBY
    id, big_value
    ORDERBY
    id;
    -- AVG 聚合函数测试 (decimal_value 列)
    SELECT
    id,
    big_value,
    AVG(decimal_value) AS avg_decimal_value
    FROM
    test_agg_group_multi_unique_key
    GROUPBY
    id, big_value
    ORDERBY
    id;
    -- COUNT 聚合函数测试 (varchar_value 列)
    SELECT
    id,
    big_value,
    COUNT(varchar_value) AS count_varchar_value
    FROM
    test_agg_group_multi_unique_key
    GROUPBY
    id, big_value
    ORDERBY
    id;
    -- MAX 聚合函数测试 (double_value 列)
    SELECT
    id,
    big_value,
    MAX(double_value) AS max_double_value
    FROM
    test_agg_group_multi_unique_key
    GROUPBY
    id, big_value
    ORDERBY
    id;
    -- MIN 聚合函数测试 (big_value 列)
    SELECT
    id,
    big_value,
    MIN(big_value) AS min_big_value
    FROM
    test_agg_group_multi_unique_key
    GROUPBY
    id, big_value
    ORDERBY
    id;
    -- GROUP_CONCAT 聚合函数测试 (varchar_value 列)
    SELECT
    id,
    big_value,
    GROUP_CONCAT(varchar_value ORDERBY varchar_value) AS group_concat_varchar_value
    FROM
    test_agg_group_multi_unique_key
    GROUPBY
    id, big_value
    ORDERBY
    id;




    具体到聚合消除单测设计中,应用二个原则来进行测试:

    1. 聚合规则应用前后,确保查询输出结果的一致性。

    2. 聚合规则应用后,确保生成的执行计划符合预期

    第一部分,聚合规则应用查询结果前后一致性验证,这里使用了Starrocks自带的SQL-tester。

    SQL-tester 是一个用于 StarRocks 的 SQL 测试框架,旨在降低测试用例构建的成本。它使用 SQL 语言编写测试用例,并具有验证测试结果的能力。

    SQL-tester 中有两种类型的测试文件:

  • R 文件:存储测试用例的 SQL 语句和需要验证的执行结果。

  • T 文件:存储测试用例的 SQL 语句。

  • 本次测试中,创建了二个新的测试文件
    starrocks/test/sql/test_agg/R/test_eliminate_agg (实际执行SQL,可以理解为EXE可执行)
    starrocks/test/sql/test_agg/T/test_eliminate_agg(记录测试SQL,可以理解为TXT可读)

    这2个文件因为查询SQL和测试结果内容多,手工输入成本非常高。所以使用了自动化Python程序来生成R/test_eliminate_agg和T/test_eliminate_agg文件。

    在自动化生成test_eliminate_agg内容时,有一点特别需要注意:运行自动化程序生成test_eliminate_agg测试文件之前,需要注释关闭此次优化。正式测试时再取消注释打开此次优化,这样就可以验证优化规则前后的输出是否一致。

    运行测试命令,完成聚合规则应用前后查询结果一致性验证。

    python3 run.py -d sql/test_agg/R -c 1 -v --file_filter=test_eliminate_agg

    第二部分,聚合规则应用后,验证生成的执行计划是否符合预期

    执行计划的测试验证代码放置在 fe/fe-core/src/test/java/com/starrocks/sql/plan 目录下

    1. 创建执行计划测试数据表

    fe/fe-core/src/test/java/com/starrocks/sql/plan/PlanTestBase.java
    starRocksAssert.withTable("CREATE TABLE `test_agg_group_single_unique_key` (\n"+
    " `id` int(11) NOT NULL COMMENT \"\",\n"+
    " `big_value` bigint(20) NULL COMMENT \"\",\n"+
    " `double_value` double NULL COMMENT \"\",\n"+
    " `decimal_value` decimal(10, 5) NULL COMMENT \"\",\n"+
    " `varchar_value` varchar(255) NULL COMMENT \"\"\n"+
    ") ENGINE=OLAP\n"+
    "UNIQUE KEY(`id`)\n"+
    "DISTRIBUTED BY HASH(`id`) BUCKETS 10\n"+
    "PROPERTIES (\n"+
    "\"compression\" = \"LZ4\",\n"+
    "\"fast_schema_evolution\" = \"true\",\n"+
    "\"replicated_storage\" = \"true\",\n"+
    "\"replication_num\" = \"1\"\n"+
    ");");
    starRocksAssert.withTable("CREATE TABLE `test_agg_group_multi_unique_key` (\n"+
    " `id` int(11) NOT NULL COMMENT \"\",\n"+
    " `big_value` bigint(20) NULL COMMENT \"\",\n"+
    " `double_value` double NULL COMMENT \"\",\n"+
    " `decimal_value` decimal(10, 5) NULL COMMENT \"\",\n"+
    " `varchar_value` varchar(255) NULL COMMENT \"\"\n"+
    ") ENGINE=OLAP\n"+
    "UNIQUE KEY(`id`, `big_value`)\n"+
    "DISTRIBUTED BY HASH(`id`) BUCKETS 10\n"+
    "PROPERTIES (\n"+
    "\"compression\" = \"LZ4\",\n"+
    "\"fast_schema_evolution\" = \"true\",\n"+
    "\"replicated_storage\" = \"true\",\n"+
    "\"replication_num\" = \"1\"\n"+
    ");");

    2. 验证生成的执行计划是否符合预期

    fe/fe-core/src/test/java/com/starrocks/sql/plan/SetTest.java
    @Test
    publicvoidtestEliminateAgg()throws Exception {
    String sql ="SELECT \n"+
    " id, \n"+
    " SUM(big_value) AS sum_big_value\n"+
    "FROM \n"+
    " test_agg_group_single_unique_key\n"+
    "GROUP BY \n"+
    " id\n"+
    "ORDER BY \n"+
    " id;";
    String plan = getVerboseExplain(sql);
    assertContains(plan," 1:Project\n"+
    " | output columns:\n"+
    " | 1 <-> [1: id, INT, false]\n"+
    " | 6 <-> [2: big_value, BIGINT, true]\n"+
    " | cardinality: 1\n"+
    " | \n"+
    " 0:OlapScanNode\n"+
    " table: test_agg_group_single_unique_key, rollup: test_agg_group_single_unique_key\n"+
    " preAggregation: off. Reason: None aggregate function\n");
    sql ="SELECT\n"+
    " id,\n"+
    " big_value,\n"+
    " COUNT(varchar_value) AS count_varchar_value\n"+
    "FROM\n"+
    " test_agg_group_multi_unique_key\n"+
    "GROUP BY\n"+
    " id, big_value\n"+
    "ORDER BY\n"+
    " id;";
    plan = getVerboseExplain(sql);
    assertContains(plan," 1:Project\n"+
    " | output columns:\n"+
    " | 1 <-> [1: id, INT, false]\n"+
    " | 2 <-> [2: big_value, BIGINT, true]\n"+
    " | 6 <-> if[(5: varchar_value IS NULL, 0, 1); args: BOOLEAN,INT,INT; "+
    "result: TINYINT; args nullable: false; result nullable: true]\n"+
    " | cardinality: 1\n"+
    " | \n"+
    " 0:OlapScanNode\n"+
    " table: test_agg_group_multi_unique_key, rollup: test_agg_group_multi_unique_key\n"+
    " preAggregation: off. Reason: None aggregate function\n");
    sql ="SELECT\n"+
    " id,\n"+
    " big_value,\n"+
    " AVG(decimal_value) AS avg_decimal_value\n"+
    "FROM\n"+
    " test_agg_group_multi_unique_key\n"+
    "GROUP BY\n"+
    " id, big_value\n"+
    "ORDER BY\n"+
    " id;";
    plan = getVerboseExplain(sql);
    assertContains(plan," 1:Project\n"+
    " | output columns:\n"+
    " | 1 <-> [1: id, INT, false]\n"+
    " | 2 <-> [2: big_value, BIGINT, true]\n"+
    " | 6 <-> cast([4: decimal_value, DECIMAL64(10,5), true] as DECIMAL128(38,11))\n"+
    " | cardinality: 1\n"+
    " | \n"+
    " 0:OlapScanNode\n"+
    " table: test_agg_group_multi_unique_key, rollup: test_agg_group_multi_unique_key\n"+
    " preAggregation: off. Reason: None aggregate function\n");
    sql ="SELECT\n"+
    " id,\n"+
    " big_value,\n"+
    " COUNT(varchar_value) AS count_varchar_value\n"+
    "FROM\n"+
    " test_agg_group_multi_unique_key\n"+
    "GROUP BY\n"+
    " id, big_value\n"+
    "ORDER BY\n"+
    " id;";
    plan = getVerboseExplain(sql);
    assertContains(plan," 1:Project\n"+
    " | output columns:\n"+
    " | 1 <-> [1: id, INT, false]\n"+
    " | 2 <-> [2: big_value, BIGINT, true]\n"+
    " | 6 <-> if[(5: varchar_value IS NULL, 0, 1); args: BOOLEAN,INT,INT;"+
    " result: TINYINT; args nullable: false; result nullable: true]\n"+
    " | cardinality: 1\n"+
    " | \n"+
    " 0:OlapScanNode\n"+
    " table: test_agg_group_multi_unique_key, rollup: test_agg_group_multi_unique_key\n"+
    " preAggregation: off. Reason: None aggregate function\n");
    }



    通过设计完善、清晰的单元测试,不仅验证了EliminateAggRule这个规则在不同输入情况下的行为,还确保了其与整个系统的兼容性。这些单元测试为系统的维护和优化提供了坚实的基础。

    总结

    EliminateAggRule类为新的优化规则提供了一个规范模板,从规则定义、验证到转换和测试,涵盖了完整的实现过程,确保优化规则的有效性和可靠性。