RoaringBitMap在ClickHouse和Spark之间的实践-解决数据仓库预计算多维分析问题

本文探讨了Spark多维分析中的数据倾斜问题,通过RoaringBitMap进行优化,并介绍了如何将RBM序列化应用于ClickHouse,显著提升查询速度。重点在于解决数据存储过大和计算逻辑复杂带来的任务长尾问题,以及如何在预计算场景中针对性地减少无用数据计算。


前面在Spark多维分析去重计数场景优化案例中说了一下Spark计算在多维分析场景中的弊端,多维度分析会导致数据量指数级膨胀,搭配上去重计算字段越多,膨胀倍数也是线性增长,通过BitMap这个案例也更加让我们明白了,什么是数据倾斜,从根本来讲,并不仅仅是数据量的问题,而是倾斜Task在进行数据IO和数据计算的时候耗费过长时间,我理解为下面三种情况。

  • 数据量过大 很常见
  • 单条数据存储过大 很少有单个字段单条数据超过几百兆或者几个G的
  • 单个Task计算逻辑非常复杂
    上面三种情况任意一个情况较差,那么都有可能造成Task长尾。那在之前的案例中也见到了,我使用了RoaringBitMap导致最终的数据计算长尾,单个跑出来的数据可达几个G。

RoaringBitMap

原理分析

借用一张图片看看它比较直观的存储方式。以下都以32位存储为例。64位可以自己去找资料看看。
简单来说,每次往里面加入一个数据,就会将32位数据分为高16位和低16位,高16位是大桶,低16位是小桶,那一个小桶最多可以存储2^16=65536条数据。
在这里插入图片描述
在这里插入图片描述
其中RoaringBitMap有一个比较聪明的点,就是一个小桶数据小于4096条时,是采用SmallSet的方式存储,理解为一个排序数组就行,超过这个阈值就采用位图。具体参考这篇文章解释
这个图片也能让你明白大于4096后,位图内存利用率更好,另外就是我们一旦使用了位图,就是2^16位=8kb,所以对于小数据量,没必要一上来就分配这么多存储空间,一条数据低16位,就是2个字节,4096条刚好到8kb,后面如果继续使用数组,就会超过8kb,还不如直接使用位图来存储了,只需要8kb。
在这里插入图片描述

存储分析

上面说了RoaringBitMap分为高位存储和低位存储,高位桶对应65536个低位桶,每个低位桶最多8kb,这样一个RoaringBitMap最多有655368kb=512M。经过上面原理的分析,RoaringBitMap很容易理解为一个位图的压缩算法,想象一下,假如直接用Bitmap存储最大值和最小值,相当于需要2^32位,只有最高位和最低位为1,中间就全空了,而且还要分配512M存储,如果采用RoaringBitmap,就只有最高低位桶和最低低位桶,且都采用排序数组存储,也就2byte+2byte=4byte,算上高位也就几个字节。明显压缩了很多存储。
但是,像这种压缩算法,永远也避免不了它的局限性,这个RoaringBitMap存储大小主要受两个东西影响
* 统计基数的量
* 当前数据的散列程度
第一点毋庸置疑,基数越大浪费存储空间越多,但不是绝对,宏观上是这样的,只有当一个桶大于4096之后,那么往里面加元素,空间都不会浪费。
第二点,还是这个图片,可以假设有2000w的基数,那么假设是顺序的,那么2000w/65536桶
8kb=306桶8kb=2.448M。但是如果每个桶恰好只存4096条数据,那么就是2000w/4096桶8kb=38M,所以数据越离散,对压缩算法的影响越大,压缩效果也就没那么好。
而第二点也就非常贴合我们实际应用场景,比如上一次场景的多维分析uv去重,userid本身是一个不固定,加上维度分析,userid更是自由组合,所以生成的二进制对象非常大,即便userid比较少。
在这里插入图片描述
上面讨论了那么多RoaringBitMap存储空间问题,在实际场景中,userid分布是很不均匀的,很随机,那么在实际中每个维度场景下生成的RoaringBitMap对象非常大,尤其是最粗粒度,聚集了最多散列用户,细粒度只聚集了少部分,开销很小。
Spark和Hive计算时,多维度组合必定有一个读取数据量会倾斜,因为他要聚合所有维度组合情况,实际And和or操作非常快,主要时间花在了IO上。

RoringBitMap在ClickHouse和Spark之间作用

上面从RoringBitMap原理和存储方面分析了我们之前案例中数据倾斜的问题,但是有一个待讨论的点,就是我们分析了11个维度组合,经过验证可能有几百万的维度组合情况,而其中某些维度组合是不是我们真正分析需要的。比如是否要看某个用户等级,os的情况,很少有人这么分析,相当于我们的多维分析,是预计算好所有可能组成的情况,沉淀到数仓的ADS层。然后提供接口查询,对数仓来说,只要按时产出就行,而实际数据需求方只是挑选其中某些情况进行分析,不可能枚举这么多种。
针对这种数据仓库预计算多维分析场景,我们花费了几个小时跑出来预计算的结果,只有少部分数据是有价值的,很显然要解决这种问题,要从两个方面入手

  • 只求数据业务方需要的数据,很难预判业务方的需求,毕竟阴晴不定。
  • 一是最大限度缩小无关联维度的组合 还没想到解决办法,记个代办
  • 按需自助取数

这里我们针对第三点,前面已经把数据聚合到了DWS,也就是最细粒度的维度+RoaringBitMap< userid >,那我们不提供ADS,业务方需要的话,自己根据DWS查就行。

维度 RBM
维度2 RBM< user >
维度3 RBM< user >

由于Hive表不能直接存储对象,只能存储二进制,那业务方查询遇到了一个问题,就是必须使用我们的反序列化UDF,先将表数据转成RBM对象,然后在聚合维度做OR操作,也就是userid去重,这个耗时就会更长。
于是我们想到了ClickHouse的groupBitMapOR,底层也是采用RoaringBitMap,但是是用Croaring实现的,那可以将hive的RBM对象序列化成ClickHouse的RoaringBitMap数据存储方式。
下面案例以64位实现方式为准,32位可自己扒代码。

Spark RoaringBitMapUDAF聚合函数实现

/**
 * @description roaring64NavigableMap 采用红黑树的RBM实现方法
 */
public class RoaringBitMapNavigableUDAF extends AbstractGenericUDAFResolver implements Serializable {
   
   

    /**
     * @return 返回去重Buffer
     * @description UDAF初始化 仅仅支持一个参数 传进来的为TextWritable 读取Object可以强制转为Text
     * */
    @Override
    public GenericUDAFEvaluator getEvaluator(TypeInfo[] parameters) throws SemanticException {
   
   
        // TODO Auto-generated method stub
        if (parameters == null || parameters.length != 1) {
   
   
            throw new UDFArgumentTypeException(0, "仅支持一个参数!");
        }
        return new RoaringBitMapNavigableUDAF.BitmapDistinctUDAFBuffer();
    }

    /**
     * BitMap去重 静态内部类
     * 作为 COMPLETE(PARTIAL1) -> PARTIAL2 -> FINAL 几个过程的处理
     * */
    public static class BitmapDistinctUDAFBuffer  extends GenericUDAFEvaluator  {
   
   
        // 输入类型
        PrimitiveObjectInspector inputType;

        /**
         * @return 约定每个过程的输出类型。即告诉下个过程我将传入Byte[]数组过来
         * @description 初始化函数 如果为COMPLETE(PARTIAL1)过程 约定原始输入类型类PrimitiveObjectInspector(Text),用户传入字符串即可 其余模式下为Byte[]
         * */
        @Override
        public ObjectInspector init(GenericUDAFEvaluator.Mode m, ObjectInspector[] parameters)
                throws HiveException {
   
   
            super.init(m, parameters);
            if(Mode.PARTIAL1.equals(m) || Mode.COMPLETE.equals(m)){
   
   
                inputType = (PrimitiveObjectInspector) parameters[0];
            }
            return PrimitiveObjectInspectorFactory.javaByteArrayObjectInspector;
        }

        /**
         * @description 聚合的Buffer类
         * */
        @Override
        public GenericUDAFEvaluator.AggregationBuffer getNewAggregationBuffer() throws HiveException {
   
   
            return new RoaringBitMapNavigableUDAF.BitmapAggrBuffer();
        }

        /**
         * @description 重置清空buffer
         * */
        @Override
        public void reset(GenericUDAFEvaluator.AggregationBuffer aggregationBuffer) throws HiveException {
   
   
            ((RoaringBitMapNavigableUDAF.BitmapAggrBuffer) aggregationBuffer).reset();
        }

        /**
         * @param aggregationBuffer 聚合去重Buffer处理器
         * @param objects 对象列表
         * @description 由聚合Buffer处理器处理每一个object
         * */
        @Override
        public void iterate(GenericUDAFEvaluator.AggregationBuffer aggregationBuffer, Object[] objects) throws HiveException {
   
   
            if(objects == null) {
   
   
                return;
            }
            for(Object object: objects){
   
   
                if(object != null) {
   
   
                    Object input = inputType.getPrimitiveWritableObject(object);
                    RoaringBitMapNavigableUDAF.BitmapAggrBuffer bitmapAggrBufferr = (RoaringBitMapNavigableUDAF.BitmapAggrBuffer) aggregationBuffer;
                    bitmapAggrBufferr.calcResult((Text) input); // 这里强制转为了Text。注意BitMap只能处理数字,所以传进来的必须是数字 字符串 否则会报错
                }
            }
        }


        /**
         * @param aggregationBuffer 聚合去重Buffer处理器
         * @description 将Buffer中的RoaringBitmap序列化成字节数组,输出给下游shuffle。
         * */
        @Override
        public Object terminatePartial(GenericUDAFEvaluator.AggregationBuffer aggregationBuffer) throws HiveException {
   
   
            RoaringBitMapNavigableUDAF.BitmapAggrBuffer bitmapAggrBuffer = (RoaringBitMapNavigableUDAF.BitmapAggrBuffer) aggregationBuffer;
            // 约定字节输出流
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            ObjectOutputStream objectOutputStream = null;
            try {
   
   
                objectOutputStream = new ObjectOutputStream(out);
                bitmapAggrBuffer.writeBytes(objectOutputStream);
                objectOutputStream.flush(); // 记得要flush一下
                return out.toByteArray();
            } catch (IOException e) {
   
   
                e.printStackTrace();
            } finally {
   
   
                // 关闭流
                try {
   
   
                    out.close();
                    if (objectOutputStream != null) {
   
   
                        objectOutputStream.close(); // 关闭外部流即可 内部流会自己关闭
                    }
                } catch (IOException e) {
   
   
                    e.printStackTrace();
                }
            }
            return new byte[0];
        }

        /**
         * @param aggregationBuffer 聚合去重Buffer处理器
         * @param o 另一个需要待合并的 聚合去重Buffer处理器
         * @description 这里在下游聚合的时候,不同上游传了同一个Group key但是不同value,
         *  此时value已经是序列化之后的RoaringBitMap对象object,需要将每一个object反序列化成RoaringBitMap
         *  然后利用aggregationBuffer 将每个RoaringBitMap合并起来
         *  这里的合并采用OR操作,因为涉及到同一个key的value去重,只需要任何一个value的某个bit有值,那么就说明这个bit代表的值是有的,即把bit当做标记而已。
         * */
        
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小满锅lock

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值