ClickHouse遇见RoaringBitmap

Q&A

Q:如图。

A:当然是自带的。其实RoaringBitmap正是ClickHouse位图的底层实现(笑

RoaringBitmap的预备知识请见这里

在CH中产生位图

使用普通函数bitmapBuild可以由无符号整形数的数组直接产生位图,e.g.

WITH bitmapBuild([32, 65, 127, 1026]) AS bm
SELECT bm,toTypeName(bm);

┌─bm─┬─toTypeName(bm)─────────────────────────┐
│  A� │ AggregateFunction(groupBitmap, UInt16) │
└────┴────────────────────────────────────────┘

可见,位图在CH中的本质是AggregateFunction(groupBitmap, UInt*)类型的(*由位图中的最大值弹性决定),即groupBitmap这个聚合函数产生的中间结果。根据聚合函数的combinator语法,加上-Merge后缀再查询一次:

WITH bitmapBuild([32, 65, 127, 1026]) AS bm
SELECT bm,groupBitmapMerge(bm);

┌─bm─┬─groupBitmapMerge(bm)─┐
│  A� │                    4 │
└────┴──────────────────────┘

也就是说,groupBitmap函数返回的是位图的基数(即1的数量)。显然,与-Merge后缀相对,我们还可以用-State后缀来对某一列生成位图:

SELECT groupBitmapState(toUInt64(user_id)) FROM ods.analytics_access_log_local
WHERE ts_date = today();

-- 这个位图很长,所以显示大量乱码hhh

ClickHouse提供了很多位图操作的函数,参见官方文档,稍后会给出示例。

翻翻源码

既然位图都是从groupBitmap函数构建出来的,就来看看与其相关的实现吧。来到AggregateFunctionGroupBitmapData.h,定义如下:

template <typename T>
struct AggregateFunctionGroupBitmapData
{
    bool doneFirst = false;
    RoaringBitmapWithSmallSet<T, 32> rbs;
    static const char * name() { return "groupBitmap"; }
};

其核心就是RoaringBitmapWithSmallSet这个数据结构,继续看代码:

template <typename T, UInt8 small_set_size>
class RoaringBitmapWithSmallSet : private boost::noncopyable
{
private:
    using Small = SmallSet<T, small_set_size>;
    using ValueBuffer = std::vector<T>;
    Small small;
    roaring_bitmap_t * rb = nullptr;

    void toLarge()
    {
        rb = roaring_bitmap_create();

        for (const auto & x : small)
            roaring_bitmap_add(rb, x.getValue());
    }

public:
    // ......
}

roaring_bitmap_t就是RBM在CRoaring库中的实现,SmallSet又是什么鬼?其实它的定义位于SmallTable.h中,是一个限定大小(无法扩容)的小集合,底层用数组来存储。在前面的结构体里,模板参数small_set_size设为32,说明它最多只能容下32个元素。

继续向下读一段:

public:
    bool isLarge() const { return rb != nullptr; }

    bool isSmall() const { return rb == nullptr; }

    ~RoaringBitmapWithSmallSet()
    {
        if (isLarge())
            roaring_bitmap_free(rb);
    }

    void add(T value)
    {
        if (isSmall())
        {
            if (small.find(value) == small.end())
            {
                if (!small.full())
                    small.insert(value);
                else
                {
                    toLarge();
                    roaring_bitmap_add(rb, value);
                }
            }
        }
        else
            roaring_bitmap_add(rb, value);
    }

    UInt64 size() const
    {
        return isSmall()
            ? small.size()
            : roaring_bitmap_get_cardinality(rb);
    }
    // ....

这下就非常明显了:当位图的基数少于32时,仅使用SmallSet存储;一旦超过此阈值,就调用toLarge()方法转化为RoaringBitmap,此后都在RoaringBitmap上操作。之所以有这种区别,想来是因为SmallSet在小数据量下的性能比RBM更优吧。

当然,在RoaringBitmapWithSmallSet之间进行运算时,就需要分情况讨论了。举个栗子,两个CH位图做按位与运算(对应bitmapAnd函数),对应方法如下:

void rb_and(const RoaringBitmapWithSmallSet & r1)
{
    ValueBuffer buffer;
    if (isSmall() && r1.isSmall())
    {
        // intersect
        for (const auto & x : small)
            if (r1.small.find(x.getValue()) != r1.small.end())
                buffer.push_back(x.getValue());
        // Clear out the original values
        small.clear();
        for (const auto & value : buffer)
            small.insert(value);
        buffer.clear();
    }
    else if (isSmall() && r1.isLarge())
    {
        for (const auto & x : small)
            if (roaring_bitmap_contains(r1.rb, x.getValue()))
                buffer.push_back(x.getValue());
        // Clear out the original values
        small.clear();
        for (const auto & value : buffer)
            small.insert(value);
        buffer.clear();
    }
    else
    {
        roaring_bitmap_t * rb1 = r1.isSmall() ? r1.getNewRbFromSmall() : r1.getRb();
        roaring_bitmap_and_inplace(rb, rb1);
        if (r1.isSmall())
            roaring_bitmap_free(rb1);
    }
}
  • 当this为小位图时,遍历this并逐个检查其中的元素在r1是否存在(只是根据r1的大小不同调用的方法不同而已),将重合的元素放入缓存,再重新插入this中。
  • 当this为大位图时,直接调用CRoaring自带的roaring_bitmap_and_inplace()方法做与运算。注意如果r1为小位图,需要先调用getNewRbFromSmall()方法生成新的RBM。

位图操作函数的定义与实现都位于FunctionsBitmap.h内,例如刚才说过的bitmapAnd函数:

using FunctionBitmapAnd = FunctionBitmap<BitmapAndImpl, NameBitmapAnd>;

template <typename T>
struct BitmapAndImpl
{
    static void apply(AggregateFunctionGroupBitmapData<T> & bitmap_data_1, const AggregateFunctionGroupBitmapData<T> & bitmap_data_2)
    {
        bitmap_data_1.rbs.rb_and(bitmap_data_2.rbs);
    }
};

位图部分的源码在整个ClickHouse体系中算是相当友好的,看官也可自行查看,不再赘述。

CH位图的应用

我们知道,位图在需要精确统计基数及快速求交集、并集的场景非常有用。以用户行为(点击流)数据为例,创建以下的表:

CREATE TABLE tmp.analytics_access_log_bmp (
  ts_date Date,
  event_type LowCardinality(String),
  user_bmp AggregateFunction(groupBitmap, UInt64)
)
ENGINE = AggregatingMergeTree()
PARTITION BY ts_date
ORDER BY event_type
SETTINGS index_granularity = 16;

这里是以日期和事件类型作为key,将符合条件的用户ID以位图存储。注意为了配合AggregateFunction,引擎应使用AggregatingMergeTree,并且此表的行数肯定偏少,所以索引粒度可适当降低。

取一些数据灌入表中:

INSERT INTO tmp.analytics_access_log_bmp
SELECT 
  ts_date,event_type,
  groupBitmapState(toUInt64(user_id))
FROM ods.analytics_access_log_local
WHERE ts_date >= '2020-08-15' AND ts_date <= '2020-08-20'
GROUP BY ts_date,event_type;

这样,我们就可以非常快速地统计每天查看过商品详情页的用户数:

SELECT 
  ts_date,
  bitmapCardinality(user_bmp) AS user_cnt
FROM tmp.analytics_access_log_bmp
WHERE event_type = 'OpenGoodsDetail';

以及统计前一日打开过App,后一日点击过“立即购买”的用户转化率:

SELECT c1 / c0
FROM (
  SELECT bitmapCardinality(
    (SELECT user_bmp FROM tmp.analytics_access_log_bmp
    WHERE ts_date = '2020-08-17' AND event_type = 'AppStart')
  ) AS c0,
  bitmapCardinality(bitmapAnd(
    (SELECT user_bmp FROM tmp.analytics_access_log_bmp
    WHERE ts_date = '2020-08-17' AND event_type = 'AppStart'),
    (SELECT user_bmp FROM tmp.analytics_access_log_bmp
    WHERE ts_date = '2020-08-18' AND event_type = 'BuyNow')
  )) AS c1
);

毫无疑问,由于位图操作消灭了GROUP BY、JOIN等复杂的关系代数操作,效率有极为明显的提升。

The End

Happy Friday night.

民那晚安晚安。

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

推荐阅读更多精彩内容