堆外KV存储

有大量数据要存储,堆内放不下,只能选择放堆外。那高性能、线程安全的堆外kv存储怎么做?以下是我实际做的过程。

最原始、最容易的,搞一个ConcurrentHashMap,每次写数据时先计算好value的大小,然后申请相应大小的DirectBuffer,写入数据后,插入Map中,value就是这个DirectBuffer。

可想而知这性能会有多差,申请DirectBuffer是一个非常耗时的操作,而且大量的DirectBuffer会产生大量的虚引用,在一定程度上影响GC。

那么先做池化处理,预先申请一大块连续的DirectBuffer,每次需要写入数据时,从DirectBuffer中取出相应大小即可。当删除这个key时,把对应的DirectBuffer归还到池子中。这就有一个问题,取的时候如何知道哪些没有被占用?也就是如何管理DirectBuffer。

我把这一大块DirectBuffer从逻辑上分为若干个大小相同的block,维护一个双端队列,用来保存所有未被使用的block序号,当需要写入数据时,根据数据大小从双端队列头取出指定数量的block,当删除数据时,把占用的block插入双端队列尾。这样我们存储kv的map中,value只需要保存block的序号即可。

看下伪代码:

put(K key,V value){
    byte[] bytes = value.toByte();
    int size = bytes.length/blockSize+1;
    int [] blockIndexs = new int[size];
    ByteBuffer[] byteBuffers = new DirectByteBuffer[size];
    for(int i = 0; i < size ; i++){
        int index = blockIndexDequeue.pollFirst();
        blockIndexs[i] = index;
        ByteBuffer byteffer = bufferPoll.slice();
        bytebuffer.position(index*blockSize).limit(index*blockSize+blockSize);
        byteBuffers[i] = byteBuffer;
    }
    writeValueToBuffers(bytes,byteuffers);
    kVmap.put(key,blockIndexs);
}


V get(K key){
    int[] blockIndexs = KVmap.get(key);
    ByteBuffer[] byteBuffers = new DirectByteBuffer[size];
    for(int i = 0; i < blockIndexs.length ; i++){
        ByteBuffer byteffer = bufferPoll.slice();
        bytebuffer.position(index*blockSize).limit(index*blockSize+blockSize);
        byteBuffers[i] = byteBuffer;
    }
    byte[] bytes = readValueFromBuffers(byteBuffers);
    return byteToV(bytes);
}

remove(K key){
    int[] blockIndexs = KVmap.get(key);
    for(int i = 0; i < blockIndexs.length ; i++){
        blockIndexDequeue.putLast(blockIndexs[i]);
    }
    KVmap.remove(key);
}

第一个问题,双端队列不是线程安全的,所以要在操作双端队列时进行同步处理,一种方式是加锁,一种是CAS自旋。这里针对双端队列操作简单,所以采用CAS自旋即可,防止线程阻塞等待。

针对双端队列操作改后伪代码:

private AtomicBoolean lock = new AtomicBoolean(false);


get(){
    for(;;){
        if(lock.compareAndSet(false,true)){
            try{
                dequeue.pollFirst();
                break;    
            }finally{
                lock.set(false);
            }
        }
    }
}


remove(){
    for(;;){
        if(lock.compareAndSet(false,true)){
            try{
                dequeue.putLast(index);
                break;    
            }finally{
                lock.set(false);
            }
        }
    }
}

第二个问题,读写并发问题,我们假设有三个线程并发操作,线程1读取key1、线程2写入key2、线程3删除数据key1。

我们看这种执行顺序,线程1读取key1时,数据存在,从Buffer中slice出DirectBuffer。线程3删除数据key1,把key1对应的block索引归还到双端队列中。线程2写入数据,正好从双端队列中取出key1刚归还的block索引,写入key2的value。此时线程1从slice出来的DirectBuffer中读取数据时,读取出来的就变成了key2的value,导致数据混乱。

总而言之,如果当前blockIndex正在被读取时,即使该key已经被删除,也不能让别的线程获取到该blockIndex。这里我把blockIndex的借用、回收当成是垃圾回收过程,采用引用计数法进行回收。

初始取出blockIndex时,引用计数初始化为1。读取该blockIndex时,先判断当前引用计数是否大于0,如果小于等于0,则说明这个blockIndex已经被删除,则归还该blockIndex;否则引用计数加1。当读取完毕后,引用计数减1,如果引用计数减1后小于等于0,则说明该blockIndex已经被删除,则归还该blockIndex。

删除数据时,对blockIndex的引用计数减1,如果减1后小于等于0,则说明该blockIndex没有被读取,则直接删除。否则说明有读线程正在读取该blockIndex,则把该blockIndex放入异步任务队列,等待引用计数小于等于0后归还该block。

以下为引用计数相关伪代码:

put(){

    try{
        if(block.ref.getAndIncr()>=1){
            getValue();
        }
    }finally{
        if(block.ref.dcreAndGet<=0){
            return(block);
        }
    }
}

remove(){
    if(block.ref.dcreAndGet()<=0){
        return(block);
    }else{
        futurehand(block);
    }
}

这样做还有问题,假设有三个线程操作相同的key,一个读取,两个删除,第一个线程读取时,此时引用计数加1,变为2。两个删除线程,都对该引用计数进行减1,导致引用计数变为0,则把该blockIndex归还。如果此时恰好有一个写数据的线程,获取到了刚归还的这个blockIndex,写入了新数据,会导致第一个读取线程又读到了错误数据,导致数据混乱。

那么问题的原因是多次删除,引用计数多次减1而导致的,那我们就需要对删除操作进行标记,每个blockIndex只能删除一次。删除时,如果发现该blockIndex已经被删除过后,则不再对引用计数减1。

并发问题已经解决,再返回来看一遍我的实现方案。

首先有一个KVMap,来保存key和该key对应的block索引、引用计数、删除标识。

然后预申请一大块连续DirectBuffer。从逻辑上划分为若干相同大小的block,每个block大小由实际使用情况来定。给每个block进行编号0到n,由一个双端队列保存所有未使用的block编号,CAS自旋来保证线程安全。

当需要使用block时,从双端队列中取出block编号,根据编号和block大小计算出实际DirectBuffer的pos和limit。

pos = blockIndex * blockSize ; limit = pos + blockSize ;

并初始化该组block的引用计数为1,向DirectBuffer写入数据。

当读取数据时,首先对该组block进行有效判断,即该组block的引用计数是否大于等于1。如果无效则归还该组block索引;否则,引用计数加1,采用相同算法,slice出DirectBuffer,读取数据。然后引用计数减1,再次进行有效性判断,决定是否归还block索引。

当删除数据时,对该组block删除标识判断,如果未删除过,则对引用计数减1,并标记为已删除过。然后进行有效性判断,即引用计数是否小于等于0,是的话,归还block索引;否则,加入异步任务,等待引用计数小于等于0后,再归还该索引。

如此,我做到了线程安全的堆外KV存储,看一下现在还存在的问题。

1.堆内数据占用过多

2.堆外存储浪费严重

先解决第一个问题,堆内数据有两部分,KVMap和双端队列。

首先双端队列保存的都是block的序号,这些序号都是0到n,如果我可以做到每次取出来都是最小的序号,那我就可以从很大程度上保证双端队列中序号的连续性。

连续的序号存储就可以进行很大程度的存储优化。比如从0到n连续的n个正整数,可以保存n个数据,也可以只保存0和n,表示最小值为0,连续n个数据。 这样存储又正好可以满足每次取出来的序号都是当前最小的。

但是如果极端情况下数据都是间断不连续的,实际这种存储方式又回浪费空间。比如1,3,5,7,9,本来只存储5个正整数即可,但这种方式会存储1,1,3,1,5,1,7,1,9,1,导致内存占用膨胀一倍。

所以在不连续时,可以采用bitmap的方式来存储,每个bit位就表示一个数是否存在。两种方式结合使用,不同场景切换不同存储,具体使用了RoaringBitmap开源框架。

但是RoaringBitmap不是线程安全的,即更新RoaringBitmap的值,多线程间不可见,所以没办法,只能针对RoaringBitmap操作时进行加锁处理,保证cpu缓存数据刷新内存,多线程间数据可见。

然后是KVMap,map的value占用的空间会比实际膨胀很多,而我这里value存储了int数据、引用计数、删除标识。如果可以减少这个value内容,就可以成比例减少大量内存占用。

第一个优化,数组会比原始变量多头信息,所以如果只使用了一个block,则不再用数组保存,而是改用int来保存。

第二个优化,既然RoaringBitmap很节省内存,那我们可以用这个来做key的打标。即RoaringBitmap来保存归还的key编号,再有一个原子变量size保存当前key数量。每来一个key,从RoaringBitmap取编号,如果没有剩余编号,则设置该key编号为size,size+1;如果有剩余编号,则设置key的编号为取出来的编号。当删除一个key时,把key对应的编号归还到RoaringBitmap中。

这样KVMap中value就变为了int,再用一个数组来存储原来的value,对应关系为key对应的编号为数组下标。

这样又节省了大量内存。

 

第二个问题,堆外浪费严重,因为我把每个block都设置成了固定大小,如果可以分级别把block分为不同的大小,可以从一定程度上节约了存储空间的浪费。并且如果保证绝大部分value都可以存放到一个block内,那结合上面的内存优化处理,内存占用也会相应少很多。

这个有比较成熟的解决方案,伙伴算法,伙伴算法有很多表现形式,比如netty中的二叉树来组织所有层级的block,当使用一块空间时,找到最小满足的block层级,取出第一个未分配的block,并更新该block的所有父节点,标明有小层级的block被分配。当归还block时,如果其兄弟节点没有被分配,则递归更新父节点标明可以分配自身大小的空间,直到最高层或兄弟节点被分配。

这种方式每次取空间和归还空间时,都要遍历更新二叉树状态,对于我这种叶子节点非常多的情况,树高较高,不太适用。

还有一种表现形式,不提前划分好每个level,而是只预先划分为最小层级的block,当使用的层级没有block时,从低一级的进行合并升级为高级。当低级的都不存在时,再从高级的进行分裂降级为低级。

这样,对于我这种存储kv的场景,v的大小可以预估在一定范围,比较适用第二种方案。

 

以上,本来我只是想简单做一个堆外KV存储,没想到最终涉及到了这么多的内容,一套框架写下来,收益颇多。

总结,所有技术设计都是站在巨人肩膀上,比如池化处理随处可见、CAS自旋模仿jdk并发包、数据压缩采用了RoaringBitmap框架、堆外管理用了变种的伙伴算法、引用计数就是经典垃圾回收算法。原创公众号:

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值