ClickHouse Hash Join 分析

 

Join (Inner Join)

Join 算法
https://clickhouse.com/docs/en/operations/settings/settings/#settings-join_algorithm
Specifies JOIN algorithm.
Possible values:
hashHash join algorithm is used.
partial_mergeSort-merge algorithm is used.
prefer_partial_merge — ClickHouse always tries to use merge join if possible.
auto — ClickHouse tries to change hash join to merge join on the fly to avoid out of memory.
Default value: hash.

QueryPlan

 
MacBook.local :) explain select * from t1 join t2 on t1.x = t2.x1

EXPLAIN
SELECT *
FROM t1
INNER JOIN t2 ON t1.x = t2.x1

Query id: b7068d63-dc61-4389-a12c-ea8016e32bc0

┌─explain──────────────────────────────────────────────────────────────────────────────────────┐
│ Expression ((Projection + Before ORDER BY))                                                  │
│   Join (JOIN)                                                                                │
│     Expression (Before JOIN)                                                                 │
│       SettingQuotaAndLimits (Set limits and quota after reading from storage)                │
│         ReadFromMergeTree                                                                    │
│     Expression ((Joined actions + (Rename joined columns + (Projection + Before ORDER BY)))) │
│       SettingQuotaAndLimits (Set limits and quota after reading from storage)                │
│         ReadFromMergeTree                                                                    │
└──────────────────────────────────────────────────────────────────────────────────────────────┘

8 rows in set. Elapsed: 0.003 sec. 

  

 

查看 Pipeline

MacBook.local :) explain pipeline select * from t1 join t2 on t1.x = t2.x1

EXPLAIN PIPELINE
SELECT *
FROM t1
INNER JOIN t2 ON t1.x = t2.x1

Query id: c054b9e0-d82c-4bc4-a22d-64dd9f556cf0

┌─explain──────────────────────────┐
│ (Expression)                     │
│ ExpressionTransform              │
│   (Join)                         │
│   JoiningTransform 2 → 1        │
│     FillingRightJoinSide         │
│       (Expression)               │
│       ExpressionTransform        │
│         (SettingQuotaAndLimits)  │
│           (ReadFromMergeTree)    │
│           MergeTreeInOrder 0 → 1 │
│       (Expression)               │
│       ExpressionTransform        │
│         (SettingQuotaAndLimits)  │
│           (ReadFromMergeTree)    │
│           MergeTreeInOrder 0 → 1 │
└──────────────────────────────────┘

15 rows in set. Elapsed: 0.005 sec. 

MacBook.local :) 

    

HashJoin 本质

Table1 INNER JOIN Table2
  1. Table2 被转换成一个HashTable1.
  2. Table1 去HashTable1中查找,如果发现key值匹配, 就将相关列的行的值copy到结果级。如果右表有多个行匹配,那么就copy多个对应行的值到对应列。
举例
Table1                Table2 
x     y               x1    y1
--------             ----------
1    'a'              1    'c'
2    'b'              1    'd'

-------------------
Exeute:
    select * from Table1 Inner Join Table2 on Table1.x = Table2.x1
Result:
x    y     x1     y1
--------------------
1    'a'    1    'c'
1    'a'    1    'd'
--------------------

  

HashJoin 的关键

遍历左表key column的同时,遇到多个匹配需要duplicate,或者filter。

ClickHouse 中 HashJoin的实现

│   JoiningTransform 2 → 1         │
│     FillingRightJoinSide  

  

FillingRightJoinSideTransform

本质:使用右表构建HashTable.
HashTable结构示意图
 
     HashTable 可扩容.
  
[14789223245, [1,3,4,[Column1Ptr, Column2Ptr]]   ]    --HashCell
    ...
[79137398732, [2,[Column3Ptr]]    --HashCell
    ...
[76349271791, [5,[Column5Ptr]]    --HashCell
    ...

[hash值, [row_number, [指向相关列的指针]]] // 

  

HashTable 在ClickHouse的实现

HashJoin使用的HashTable与LowCardinality结构中使用的一样。HashJoin结构体中创建HashTable,HashCell中存储StringRef与RowsRef,并且保存当前key的hash值。

(细节)Join 构建HashMap debug 一览.

(细节)构建map_

'b' 是右表第一个元素.
为'b'在hashTable中找位置。 b被放在HashTable中13的位置.
 
构建一个holder.key.data
构建一个在buf对应的内存位置构建一个HashCell.
更新buf中的value(*this)的saved_hash值.
接着更新 'a'

(细节)RowRef 数据结构

HashTable debug细腻.

通过右表key的hash值,我们可以从RowRef中的Block快速通过Column的随机访问快速拿到任意列的值,可以拿到所有列的相关索引的值。
 

 

 

struct RowRef
{
    using SizeT = uint32_t; /// Do not use size_t cause of memory economy

    const Block * block = nullptr;
    SizeT row_num = 0;

    RowRef() {}
    RowRef(const Block * block_, size_t row_num_) : block(block_), row_num(row_num_) {}
};

  

第二个'a'写入,会在对应的RowsRef中增加row_number。
 
  if (emplace_result.isInserted())
                new (&emplace_result.getMapped()) typename Map::mapped_type(stored_block, i);
            else
            {
                /// The first element of the list is stored in the value of the hash table, the rest in the pool.
                emplace_result.getMapped().insert({stored_block, i}, pool);
            }

  

可以看到创建HashTable,包含了其他右表的列的信息。也就是说在后续查找某列中i row的值,可以直接随机查找。

JoiningTransform::work().

此时需要FillingRightJoinSide构建的Join object。
读取参与Join的左侧的Chunk (columns)。
构建的row_filter是HashJoin的关键。row_filter是 filter 左侧的表还是右侧的表?
开始HashJoin时,需要使用Join结构中的maps_ ,
Block使用的是左侧列组成的Block.
右边表需要被加到最终结果block中的column。 block_with_columns_to_add 变量
根据block_with_columns_to_add 构建added_columns。
 
 

SwitchJoinRightColumns 最重要的函数 filter 与replicate

参与计算这两个关键的要素,只需要参与Join 左表的key column 就可以。其他的filter和replicated都是和左表的key column保持一致。 这里的列都是新建并填充的,这样保证在匹配/不匹配的情况下填充时的灵活性。
 
 
size_t rows = added_columns.rows_to_add; // rows_to_add 是 左表 column的行. 此时右表的可以被map表示.

filter = IColumn::Filter(rows, 0);// 以左表行数 设置filter. 比较合理
added_columns.offsets_to_replicate = std::make_unique<IColumn::Offsets>(rows); // 以左表行数设置 需要复制的行. 合理. 如果是左表[a,a] -> 右表[a] => 那么左表行最终为[a,a]合理。
for (auto &row: rows){
    // 第一层的过滤,目前不知道哪个算子提供这个join_mask_columns。测试时为Nullptr,即所有行都不进行过滤。
    bool row_acceptable = !added_columns.isRowFiltered(i);
    // 如果这行row需要被保留,我们需要查看这个left_key_column的第i行在map中的位置.在hashTable中查找。 获取 类型为String 类型的key 
    // StringRef key(chars + offsets[row - 1], offsets[row] - offsets[row - 1] - 1);
    auto find_result = row_acceptable ? key_getter.findKey(map, i, pool) : FindResult();
    if (find_result.isFound()){
        setUsed<need_filter>(filter, i); 设置相应filter[i]为1.
        // 将当前所有能和当前左表key join上的row添加到所有左边列。
        // columns[i] 开启复制模式,因为columns这里一开始都是empty.
        addFoundRowAll<Map, add_missing>(mapped, added_columns, current_offset);     // 通过对各个列调用此方法来完成 左表 [a] -> [a,a] 右表,让 所有的列都完成对应的复制 [a,a]。
        //void insertFrom(const IColumn & src, size_t n) override {
        //        data.push_back(assert_cast<const Self &>(src).getData()[n]);
        // }
        //
        
    }
    if constexpr need_replicated){
        // 当前row i 更新对offsets进行更新.这里主要是为了后面replicate时,可以采用批量的方式. memcpyxxx 一种优化方式.
        (*added_columns.offsets_to_replicate)[i] = current_offset;
    }

}

  

执行完Join算法以后。将结果存放到block
  
 for (size_t i = 0; i < added_columns.size(); ++i)
        block.insert(added_columns.moveColumn(i));

  

返回前的replicate 操作
  • ?最后返回之前需要创建一个新的column 新建 需要replicate的列。目前不知道最后一步实现的replicate的含义是什么。
 
   
for (size_t i = 0; i < existing_columns; ++i)
    block.safeGetByPosition(i).column = block.safeGetByPosition(i).column->replicate(*offsets_to_replicate);

  

ColumnPtr ColumnString::replicate(const Offsets & replicate_offsets) const
{
    size_t col_size = size();
    if (col_size != replicate_offsets.size())
        throw Exception("Size of offsets doesn't match size of column.", ErrorCodes::SIZES_OF_COLUMNS_DOESNT_MATCH);

    auto res = ColumnString::create();

    if (0 == col_size)
        return res;

    Chars & res_chars = res->chars;
    Offsets & res_offsets = res->offsets;
    res_chars.reserve(chars.size() / col_size * replicate_offsets.back());
    res_offsets.reserve(replicate_offsets.back());

    Offset prev_replicate_offset = 0;
    Offset prev_string_offset = 0;
    Offset current_new_offset = 0;

    for (size_t i = 0; i < col_size; ++i)
    {
        size_t size_to_replicate = replicate_offsets[i] - prev_replicate_offset;
        size_t string_size = offsets[i] - prev_string_offset;

        for (size_t j = 0; j < size_to_replicate; ++j)
        {
            current_new_offset += string_size;
            res_offsets.push_back(current_new_offset);

            res_chars.resize(res_chars.size() + string_size);
            // 这里就是利用了 前面记录的offset进行memcpy copy的优化.
            memcpySmallAllowReadWriteOverflow15(
                &res_chars[res_chars.size() - string_size], &chars[prev_string_offset], string_size);
        }

        prev_replicate_offset = replicate_offsets[i];
        prev_string_offset = offsets[i];
    }

    return res;
}

  

 

总结

  1. 计算右表key的列hashTable,并将相关联的其他右表列信息也更新在map中。
  2. 根据map计算左表的最终结果放到block.
    1. 所有的计算重点都在 SwitchJoinRightColumns<KIND, STRICTNESS>(maps_, added_columns, data->type, null_map, used_flags) 函数中。
  3. 将右表key的列加入到block
  4. 返回。

收获

  1. RightTable到->Join,然后不同的JoiningTransform可以自由Join,将结果写入到local变量added_columns,这样可以使join并行化。
  2. HashTable 作用起到了关键性的作用。
  • 问题:

  • 在BuildQueryPlan 执行FillingRightJoinSide::transformHeader()和JoiningTransform::transformHeader()的意义是什么?
  • 在JoiningTransform:: joinRightColumns()执行结束之后的对 block中已有的column进行replicate的作用是什么?
  • Interpreter 执行优化?
  • BlockStreaming.
  • QueryPipeline = pipeline.

InBlockInputStream

 
Block RemoteQueryExecutor::read()
{
    if (!sent_query)
    {
        sendQuery();

        if (context->getSettingsRef().skip_unavailable_shards && (0 == connections->size()))
            return {};
    }

    while (true)
    {
        if (was_cancelled)
            return Block();

        Packet packet = connections->receivePacket();

        if (auto block = processPacket(std::move(packet)))
            return *block;
        else if (got_duplicated_part_uuids)
            return std::get<Block>(restartQueryWithoutDuplicatedUUIDs());
    }
}

  

 
IBlockInputStream可以 输出 Block(data) 给调用者。以RemoteBlockInputStream为例
 
posted @ 2021-12-19 20:29  博客记  阅读(1205)  评论(0编辑  收藏  举报