基于RoaringBitmap通用消息去重组件设计

1,156 阅读5分钟

1 背景

消息中间件是分布式系统常用的组件,通常认为,消息中间件是一个可靠的组件。 可靠意味着消息消息不会丢失,但是消息可能会重复投递,或者是重复消费。 通常保证消息只消费一次的任务就会转移到消费端实现。
本文基于RoaringBitmap,设计一个通用的消息去重组件。配合消息系统对已消费消息的快照机制,可以实现消费者对消息消费顺序性,唯一性的要求。

2 RoaringBitmap简介

BitMap的数据稀疏问题

BitMap的问题在于,不管业务中实际的元素基数有多少,它占用的内存空间都恒定不变。
如果BitMap中的位的取值范围是1到100亿之间,那么BitMap就会开辟出100亿Bit的存储空间。
但是如果实际上值只有100个的话,100亿Bit的存储空间只有100Bit为1,其余全部为0,数据存储空间浪费严重,数据越稀疏,空间浪费越严重。

为了解决位图稀疏存储浪费空间的问题,出现了很多稀疏位图的压缩算法,RoaringBitmap就是其中的优秀代表。 RoaringBitmap 是高效压缩位图,简称RBM。

2.1 基本原理

RoaringBitmap采用分桶机制来实现节省空间。具体策略:

  • 将 32bit int类型数据 划分为 2^16 个桶,即最多可能有2^16 = 65536个桶。每个桶有一个container来存放一个数值的低16位
  • 在存储和查询数值时,将数值 k 划分为高16位和低16位,取高16位值找到对应的桶,然后将低16位值存位置相应的 Container 中。

以roaringBitMap存储31为例,其16进制为:0000001F,前16位为0000,后16为001F。 image.png

2.2 container(小桶)的类型

在RoaringBitmap中共有4种小桶:

2.2.1 bitmapcontainer(位图容器)

位图,只不过这里位图的位数为2^16(65536)个,也就是2^16个bit,计算下来起所占内存就是8kb。然后每一位用0,1表示这个数不存在或者存在。如下图:

image.png

2.2.2 arraycontainer(数组容器)

container默认使用arraycontainer(元素都是按从大到小的顺序排列的),由于container只有16位bit位,最大65535,则用short int类型即可。 当ArrayContainer的容量超过4096(这里是指4096个short int即8k Byte)后,切换为bitmapcontainer(这个所占空间始终都是8k Byte,也就是16位bit)。 即ArrayContainer存储稀疏数据,BitmapContainer存储稠密数据,可以最大限度地避免内存浪费。

image.png

2.2.3 runcontainer(行程步长容器)

一种利用步长来压缩空间的方法。

比如连续的整数序列 11, 12, 13, 14, 15, 27, 28, 29 会被 压缩为两个二元组 11, 4, 27, 2 表示:11后面紧跟着4个连续递增的值,27后面跟着2个连续递增的值,那么原先16个字节的空间,现在只需要8个字节,是不是节省了很多空间呢。不过这种容器不常用。

2.2.4 sharedcontainer(共享容器)

它本身是不存储数据的,只是用它来指向arraycontainer,bitmapcontainer或runcontainer,就好比指针的作用一样。这个指针(sharedcontainer)可以被多个对象拥有,但是指针所指针的实质东西是被这多个对象所共享的。

image.png

3 消息去重组件

3.1 设计思路

我们用roaringbitmap来缓存消息并判重。
借鉴JVM的新生代内存中两个survivor区的设计,发生MinorGC时,Eden区存活的对象被移动到S0;再次触发MinorGC时,S0和Eden存活对象被复制到S1。S0与S1会交换角色,循环往复。
我们用两个roaringbitmap(长度为2的数组)来存消息,如下:

LongBitmapDataProvider[] pair = new LongBitmapDataProvider[2]

其中pair[0] 用来处理消息的写入,当pair[0]满了的时候,pair[0]和pair[1]交换角色。 需要判断重复时,同时判断pair[0]和pair[1]。

3.2 系统设计要求

消费系统对消息的消费要求保证顺序性和不重不丢时, 可以在消费端引入消息去重组件,消息去重组件可以保证对一段时间内的消息进行去重。同时消费系统应当引入快照机制,对已经消费的消息进行快照持久化。
假设消息的唯一标识messageId是Long型,快照持久化到10000,应当保证消息去重组件里面最小消息ID <= 1000。
当系统重启时,将已经持久化的最近的消息用来重建缓存。

3.3 代码实现

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import javax.annotation.concurrent.NotThreadSafe;

import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.roaringbitmap.longlong.LongBitmapDataProvider;
import org.roaringbitmap.longlong.Roaring64NavigableMap;

@NotThreadSafe
@Slf4j
public class Deduplicator {

    private Map<String, LongBitmapDataProvider[]> longBitmapProviders = new HashMap<>();

    private static final long DEFAULT_MAX_CAPACITY = 100000000;

    private static final int BIT_OFFSET = 31;

    private long maximumCapacity;

    private long parMaximumSize;

    private long counter;

    private Set<String> keys;

    public Deduplicator(final @NonNull Set<String> keys) {
        this.maximumCapacity = DEFAULT_MAX_CAPACITY;
        this.keys = keys;
        init(keys);
    }

    public Deduplicator(final @NonNull Set<String> sources, final long maximumSize) {
        checkArgument(maximumSize > 0 && (maximumSize & (1 << BIT_OFFSET + 1)) == 0,
                "maximum size must be positive and even");
        this.maximumCapacity = maximumSize;
        this.keys = sources;
        init(sources);
    }

    private void init(final Set<String> keys) {
        this.parMaximumSize = maximumCapacity >> 1;
        for (String key : keys) {
            LongBitmapDataProvider[] pair = new LongBitmapDataProvider[2];
            pair[0] = new Roaring64NavigableMap();
            pair[1] = new Roaring64NavigableMap();
            longBitmapProviders.put(key, pair);
        }
    }

    /**
     * remove duplicated entries.
     */
    public void deduplicateAndSave(final @NonNull Collection<? extends DeduplicateAware> entries) {
        checkArgument(entries.size() <= maximumCapacity,
                "too many entries, allowed maximum is " + maximumCapacity);
        entries.removeIf(this::duplicatedAndSave);
    }

    /**
     * evaluate whether given entry is duplicated.
     */
    public boolean duplicatedAndSave(final @NonNull DeduplicateAware entry) {
        boolean duplicated = duplicated(entry);
        if (!duplicated) {
            add(entry.getId(), entry.getKey());
        } else {
            log.info("duplicated entry {}", entry);
        }
        return duplicated;
    }

    /**
     * restore longBitmapProviders with given entries.
     */
    public void reConstruct(final @NonNull Collection<Long> ids, final String key) {
        checkArgument(keys.contains(key), "invalid massage key: " + key);
        checkArgument(ids.size() <= maximumCapacity,
                "too many entries, allowed maximum is " + maximumCapacity);
        ids.forEach(v -> add(v, key));
    }

    private boolean duplicated(final DeduplicateAware entry) {
        final String key = entry.getKey();
        checkArgument(keys.contains(key), "invalid massage key: " + key);
        long id = entry.getId();
        LongBitmapDataProvider[] pair = longBitmapProviders.get(key);
        return pair[0].contains(id) || pair[1].contains(id);
    }

    private void add(final long id, final String key) {
        if (counter++ == parMaximumSize) {
            flip(longBitmapProviders.get(key));
        }
        LongBitmapDataProvider provider = getWriteBitmap(key);
        provider.addLong(id);
    }

    private void flip(final LongBitmapDataProvider[] pair) {
        this.counter = 1;
        pair[1] = pair[0];
        pair[0] = new Roaring64NavigableMap();
    }

    private LongBitmapDataProvider getWriteBitmap(final String key) {
        LongBitmapDataProvider[] pair = longBitmapProviders.get(key);
        return pair[0];
    }

    private void checkArgument(final boolean expression, final String errorMessage) {
        if (!expression) {
            throw new IllegalArgumentException(errorMessage);
        }
    }
}