背景
本文基于 Paimon 0.9.0
之前也是一直想找一些例子以及场景来看 实战中 怎么构造BloomFilter
以及怎么在文件层级进行过滤。刚好在Paimon
中有这么个例子,这次可以全面来分析一下
分析
BloomFilter的构造
直接定位到KeyValueFileStoreWrite.createLookupLevels
方法:
return new LookupLevels<>(
levels,
keyComparatorSupplier.get(),
keyType,
valueProcessor,
readerFactory::createRecordReader,
file ->
ioManager
.createChannel(
localFilePrefix(partitionType, partition, bucket, file))
.getPathFile(),
lookupStoreFactory,
bfGenerator(options),
lookupFileCache);
其中bfGenerator
这里就是BloomFilter
的构造,代码如下:
static Function<Long, BloomFilter.Builder> bfGenerator(Options options) {
Function<Long, BloomFilter.Builder> bfGenerator = rowCount -> null;
if (options.get(CoreOptions.LOOKUP_CACHE_BLOOM_FILTER_ENABLED)) {
double bfFpp = options.get(CoreOptions.LOOKUP_CACHE_BLOOM_FILTER_FPP);
bfGenerator =
rowCount -> {
if (rowCount > 0) {
return BloomFilter.builder(rowCount, bfFpp);
}
return null;
};
}
return bfGenerator;
}
...
public static Builder builder(long expectedRow, double fpp) {
int numBytes = (int) Math.ceil(BloomFilter.optimalNumOfBits(expectedRow, fpp) / 8D);
Preconditions.checkArgument(
numBytes > 0,
"The optimal bits should > 0. expectedRow: %s, fpp: %s",
expectedRow,
fpp);
return new Builder(MemorySegment.wrap(new byte[numBytes]), expectedRow);
}
...
Builder(MemorySegment buffer, long expectedEntries) {
this.buffer = buffer;
this.filter = new BloomFilter(expectedEntries, buffer.size());
filter.setMemorySegment(buffer, 0);
this.expectedEntries = expectedEntries;
}
对于BloomFilter
的来说,会涉及到两个方面,一个是用多少位来存储,一个是用多少个hash函数,这里都是有好多
- 位数组大小的计算
位数组的计算是根据预期的元素数量和允许的最大误判率来的,有一个计算公式:m = - (n * ln(p)) / (ln(2)^2)
其中 n 为 预期的元素个数,
p 为 误判率,也就是布隆过滤器将非集合元素错误地判断为集合元素的概率
m 为位数
n对应到Paimon中为文件的行数,
p对应到到Paimon中为lookup.cache.bloom.filter.fpp
,默认是0.05.
BloomFilter.optimalNumOfBits(expectedRow, fpp)
的计算也是根据这个来的 - hash函数的个数的确认
哈希函数个数的计算需要综合考虑误判率和空间利用率,在给定布隆过滤器大小和预计插入元素个数的情况下,可以使用以下公式来估算:
k = (m / n) * ln2
其中 m 为 位数
n 为 插入的元素的个数
k 为hash函数的个数
m 对应到Paimon中就是 上述位数组大小的计算中m
的指
n 对应到Paimon中为文件的行数
optimalNumOfHashFunctions
这个计算公式也就是根据这个来的
注意到Paimon在构造Builder
的时候,用到了MemorySegment
,也就是(MemorySegment.wrap(new byte[numBytes])
这里说明一下MemorySegment
这个是利用UNSAFE来操作字节数组的工具类
,原理上和java.util.BitSet
一样,都可以来操作对应的管理单元(BitSet对应的是Long类型,这里对应的是Byte类型),具体的可以参考深入理解SPARK SQL 中HashAggregateExec和ObjectHashAggregateExec以及UnsafeRow中
只不过如果用MemorySegment
可以更好的管理(比如申请和释放),且后续的位操作都是基于memorySegment
来的,比如说putByte和getByte
.
BloomFilter的使用
BloomFiltet的数据写入
直接定位到LookupLevels.createLookupFile
方法:
LookupStoreWriter kvWriter =
lookupStoreFactory.createWriter(localFile, bfGenerator.apply(file.rowCount()));
LookupStoreFactory.Context context;
try (RecordReader<KeyValue> reader = fileReaderFactory.apply(file)) {
KeyValue kv;
if (valueProcessor.withPosition()) {
FileRecordIterator<KeyValue> batch;
while ((batch = (FileRecordIterator<KeyValue>) reader.readBatch()) != null) {
while ((kv = batch.next()) != null) {
byte[] keyBytes = keySerializer.serializeToBytes(kv.key());
byte[] valueBytes =
valueProcessor.persistToDisk(kv, batch.returnedPosition());
kvWriter.put(keyBytes, valueBytes);
}
batch.releaseBatch();
}
kvWriter 这里构造了HashLookupStoreWriter
,对于每一个key和value都会调用该put方法(这样就把对应的key值传给了BloomFilter):
...
if (bloomFilter != null) {
bloomFilter.addHash(MurmurHashUtils.hashBytes(key));
}
而而方法最后都会把对应的Key值写入到bloomFilter中,这里调用的方法如下:
BloomFilter.java
public void addHash(int hash1) {
int hash2 = hash1 >>> 16;
for (int i = 1; i <= numHashFunctions; i++) {
int combinedHash = hash1 + (i * hash2);
// hashcode should be positive, flip all the bits if it's negative
if (combinedHash < 0) {
combinedHash = ~combinedHash;
}
int pos = combinedHash % bitSet.bitSize();
bitSet.set(pos);
}
}
...
org.apache.paimon.utils.BitSet.java
public void set(int index) {
checkArgument(index < bitLength && index >= 0);
int byteIndex = index >>> 3;
byte current = memorySegment.get(offset + byteIndex);
current |= (1 << (index & BYTE_INDEX_MASK));
memorySegment.put(offset + byteIndex, current);
}
int byteIndex = index >>> 3;
找到对应的字节数组对应的字节,也就是index/8
current |= (1 << (index & BYTE_INDEX_MASK));
是找到在对应的8位byte字节中的哪一位,也就是index%8
memorySegment.put(offset + byteIndex, current);
底层会调用UNSAFE.putByte
设置对应的值
BloomFiltet的数据读取
直接定位到LookupFile.get
方法,该方法会最终调用HashLookupStoreReader.lookup
:
int keyLength = key.length;
if (keyLength >= slots.length || keyCounts[keyLength] == 0) {
return null;
}
int hashcode = MurmurHashUtils.hashBytes(key);
if (bloomFilter != null && !bloomFilter.testHash(hashcode)) {
return null;
}
这里会调用bloomFilter.testHash
方法(如果不存在则返回null):
BloomFilter.java
public boolean testHash(int hash1) {
int hash2 = hash1 >>> 16;
for (int i = 1; i <= numHashFunctions; i++) {
int combinedHash = hash1 + (i * hash2);
// hashcode should be positive, flip all the bits if it's negative
if (combinedHash < 0) {
combinedHash = ~combinedHash;
}
int pos = combinedHash % bitSet.bitSize();
if (!bitSet.get(pos)) {
return false;
}
}
return true;
}
org.apache.paimon.utils.BitSet.java
public boolean get(int index) {
checkArgument(index < bitLength && index >= 0);
int byteIndex = index >>> 3;
byte current = memorySegment.get(offset + byteIndex);
return (current & (1 << (index & BYTE_INDEX_MASK))) != 0;
}
这里的操作和之前的一样,只不过拿出来对应的Bit位,看是否为0。
这样,对于不存在的key就可以很快的跳过。