ClickHouse源码笔记6:探究列式存储系统的排序

分析完成了聚合以及向量化过滤,向量化的函数计算之后。本篇,笔者将分析数据库的一个重要算子:排序。让我们从源码的角度来剖析ClickHouse作为列式存储系统是如何实现排序的。

本系列文章的源码分析基于ClickHouse v19.16.2.2的版本。

1.执行计划

老规矩,咱们还是先从一个简单的查询出发,通过一步步的通过执行计划按图索骥ClickHouse的执行逻辑。

select * from test order by k1;

咱们先尝试打开ClickHouse的Debug日志看一下具体的执行的pipeline

image.png

这里分为了5个流,而咱们所需要关注的流已经呼之欲出了MergeSortingPartialSorting,ClickHouse先从存储引擎的数据读取数据,并且执行函数运算,并对数据先进行部分的排序,然后对于已经有序的数据在进行MergeSort,得出最终有序的数据。

2. 实现流程的梳理

那咱们接下来要梳理的代码也很明确了,就是PartialSortingBlockInputStreamMergingSortedBlockInputStream

  • PartialSortingBlockInputStream的实现
    PartialSortingBlockInputStream的实现很简单,咱们直接看代码吧:
Block PartialSortingBlockInputStream::readImpl()
{
    Block res = children.back()->read();
    sortBlock(res, description, limit);
    return res;
}

它从底层的流读取数据Block,Block可以理解为Doris之中的Batch,相当一批行的数据,然后根据自身的成员变量SortDescription来对单个Block进行排序,并根据limit进行长度截断。

SortDescription是一个vector,每个成员描述了单个排序列的排序规则。比如
: null值的排序规则,是否进行逆序排序等。

/// Description of the sorting rule for several columns.
using SortDescription = std::vector<SortColumnDescription>;
  • sortBlock的函数实现

接下来,我们来看看sortBlock函数的实现,看看列式的执行系统是如何利用上述信息进行数据排序的。

void sortBlock(Block & block, const SortDescription & description, UInt64 limit)
{
    /// If only one column to sort by
    if (description.size() == 1)
    {
        bool reverse = description[0].direction == -1;

        const IColumn * column = !description[0].column_name.empty()
            ? block.getByName(description[0].column_name).column.get()
            : block.safeGetByPosition(description[0].column_number).column.get();

        IColumn::Permutation perm;
        if (needCollation(column, description[0]))
        {
            const ColumnString & column_string = typeid_cast<const ColumnString &>(*column);
            column_string.getPermutationWithCollation(*description[0].collator, reverse, limit, perm);
        }
        else
            column->getPermutation(reverse, limit, description[0].nulls_direction, perm);

        size_t columns = block.columns();
        for (size_t i = 0; i < columns; ++i)
            block.getByPosition(i).column = block.getByPosition(i).column->permute(perm, limit);
    }

这里需要分为两种情况讨论:1. 单列排序。2.多列排序。多列排序与单列的实现大同小异,所以我们先从单列排序的代码开始庖丁解牛。它的核心代码就是下面的这四行:

    column->getPermutation(reverse, limit, description[0].nulls_direction, perm);
    size_t columns = block.columns();
    for (size_t i = 0; i < columns; ++i)
           block.getByPosition(i).column = block.getByPosition(i).column->permute(perm, limit);

先通过单列排序,拿到每一列在排序之后的IColumn::Permutation perm;。然后Block之中的每一列都利用这个perm, 生成一个新的排序列,替换旧的列之后,就完成Block的排序了。

生成Perm

如上图所示,Permutation是一个长度为limitPodArray, 它标识了根据排序列排序之后的排序位置。后续就按照这个perm规则利用函数permute生成新的列,就是排序已经完成的列了。

ColumnPtr ColumnVector<T>::permute(const IColumn::Permutation & perm, size_t limit) const
{
    typename Self::Container & res_data = res->getData();
    for (size_t i = 0; i < limit; ++i)
        res_data[i] = data[perm[i]];

    return res;
}

这里细心的朋友会发现,String列在sortBlock函数之中做了一些额外的判断

  if (needCollation(column, description[0])) {
            const ColumnString & column_string = typeid_cast<const ColumnString &>(*column);
            column_string.getPermutationWithCollation(*description[0].collator, reverse, limit, perm);
 }

这部分是一个特殊的字符串生成perm的逻辑,ClickHouse支持用不同的编码进行字符串列的排序。比如通过GBK编码进行排序的话,那么中文的排序顺序将是基于拼音顺序的。

  • getPermutation的实现
    所以,在ClickHouse的排序过程之中。getPermutation是整个排序算子实现的重中之重, 它是Column类的一个虚函数,也就是说每一个不同的数据类型的列都可以实现自己的排序逻辑。我们通过ColumnVector的实现,来管中规豹一把。
template <typename T>
void ColumnVector<T>::getPermutation(bool reverse, size_t limit, int nan_direction_hint, IColumn::Permutation & res) const
{
        if (reverse)
            std::partial_sort(res.begin(), res.begin() + limit, res.end(), greater(*this, nan_direction_hint));
        else
            std::partial_sort(res.begin(), res.begin() + limit, res.end(), less(*this, nan_direction_hint));
    }
    else
    {
        /// A case for radix sort
        if constexpr (std::is_arithmetic_v<T> && !std::is_same_v<T, UInt128>)
        {
                return;
            }
        }

        /// Default sorting algorithm.
        for (size_t i = 0; i < s; ++i)
            res[i] = i;

       pdqsort(res.begin(), res.end(), less(*this, nan_direction_hint));
    }
}

这部分代码较多,笔者简化了一下这部分的逻辑。

  • 如果存在limit条件,并且列的长度大于limit,采用std::partial_sort进行perm的排序。
  • 如果为数字类型,并且不为UInt128类型时,则采用Radix Sort计数排序来对perm进行排序。
  • 如不满足前二者的条件,则使用快速排序作为最终的默认实现。

好的,看到这里。已经完整的梳理了PartialSortingBlockInputStream,得到了每一个输出的Block已经按照我们的排序规则进行排序了。接下来就要请出MergeSortingBlockInputStream来进行最终的排序工作。

  • MergeSortingBlockInputStream的实现
    从名字上也能看出来,这里需要完成一次归并排序,来得到最终有序的排序结果。至于排序的对象,自然上面通过PartialSortingBlockInputStream输出的Block了。

直接定位到readImpl()的实现,ClickHouse这里实现了Spill to disk的外部排序逻辑,这里为了简化,笔者先暂时拿掉这部分外部排序的逻辑。

Block MergeSortingBlockInputStream::readImpl()
{
    /** Algorithm:
      * - read to memory blocks from source stream;
      */

    /// If has not read source blocks.
    if (!impl)
    {
        while (Block block = children.back()->read())
        {
            blocks.push_back(block);
            sum_rows_in_blocks += block.rows();
            sum_bytes_in_blocks += block.allocatedBytes();

            /** If significant amount of data was accumulated, perform preliminary merging step.
              */
            if (blocks.size() > 1
                && limit
                && limit * 2 < sum_rows_in_blocks   /// 2 is just a guess.
                && remerge_is_useful
                && max_bytes_before_remerge
                && sum_bytes_in_blocks > max_bytes_before_remerge)
            {
                remerge();
            }

        if ((blocks.empty() && temporary_files.empty()) || isCancelledOrThrowIfKilled())
            return Block();

        if (temporary_files.empty())
        {
            impl = std::make_unique<MergeSortingBlocksBlockInputStream>(blocks, description, max_merged_block_size, limit);
        }
       
    Block res = impl->read();
    return res;
}

�由上面代码可以看到,MergeSortingBlockInputStream这部分就是不断从底层的PartialSortingBlockInputStream读取出来,并存储全部存储下来。最终读取完成之后,利用MergeSortingBlocksBlockInputStream类,完成所有Blocks的归并排序工作。而MergeSortingBlocksBlockInputStream类就是简单完成利用堆进行多路归并排序的过程代码,笔者在这里就不再展开了,感兴趣的同学可以自行参考MergeSortingBlockInputStream.cpp部分的实现。

3.要点梳理

第二小节梳理完ClickHouse的排序算子的实现流程,这里进行一些简单的要点小结:

  1. ClickHouse的排序实现需要利用排序列生成对应的perm,最终利用perm完成每一个Block的排序。

  2. 所以每一个不同数据类型的列,都需要实现getPermutationpermute来实现排序。并且可以根据数据类型,选择不同的排序实现。比如radix sort的时间复杂度为O(n),相对快速排序的时间复杂度就存在了明显的优势。

  3. 排序算法存在大量的数据依赖,所以是很难发挥SIMD的优势的。只有在radix sort下才些微有些部分可以向量化,所以相对于非向量化的实现,不存在太多性能上的优势。

4. 小结

OK,到此为止,咱们可以从Clickhouse的源码实现之中梳理完成列式的存储系统是如何实现排序的。
当然,这部分跳过了一部分重要的实现:Spill to disk。这个是确保在一定的内存限制之下,对海量数据进行排序时,可以利用磁盘来缓存排序的中间结果。这部分的实现也很有意思,感兴趣的朋友,可以进一步展开来看这部分的实现。
笔者是一个ClickHouse的初学者,对ClickHouse有兴趣的同学,欢迎多多指教,交流。

5. 参考资料

官方文档
ClickHouse源代码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容