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。
2.2 container(小桶)的类型
在RoaringBitmap中共有4种小桶:
2.2.1 bitmapcontainer(位图容器)
位图,只不过这里位图的位数为2^16(65536)个,也就是2^16个bit,计算下来起所占内存就是8kb。然后每一位用0,1表示这个数不存在或者存在。如下图:
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存储稠密数据,可以最大限度地避免内存浪费。
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)可以被多个对象拥有,但是指针所指针的实质东西是被这多个对象所共享的。
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);
}
}
}