Paimon中BloomFilter在key查找中的应用

背景

本文基于 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中,这里调用的方法如下:

org.apache.paimon.utils.BitSet
private static final int BYTE_INDEX_MASK = 0x00000007;

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就可以很快的跳过。

Paimon 中创建增量表(Incremental Table)主要通过其支持的增量数据摄取机制实现。Paimon 是一个基于 Apache Flink 构建的流批一体数据湖仓系统,支持从多种数据源中捕获增量数据,并将其高效地写入湖仓中。 增量表的构建通常依赖于 Flink 的 CDC(Change Data Capture)功能,例如 MySQL CDC、PostgreSQL CDC 等连接器,通过这些连接器捕获源数据库的变更日志(如 binlog),并将变更数据实时同步到 Paimon 表中。以下是创建增量表的基本方法: ### 1. 使用 Flink CDC 连接器创建增量表 以 MySQL CDC 为例,可以通过 Flink SQL 创建一个增量数据同步作业,将 MySQL 表的变更数据同步到 Paimon 表中。首先确保已配置好 Flink 环境,并将 MySQL CDC 连接器和 Paimon 的 Flink 集成模块加入依赖。 #### 示例 Flink SQL 脚本: ```sql -- 创建 MySQL CDC 源表 CREATE TABLE mysql_cdc_source ( id INT PRIMARY KEY, name STRING, age INT ) WITH ( 'connector' = 'mysql-cdc', 'hostname' = 'localhost', 'port' = '3306', 'database-name' = 'test_db', 'table-name' = 'user_table', 'username' = 'root', 'password' = 'password' ); -- 创建 Paimon 目标表 CREATE TABLE paimon_incremental_table ( id INT PRIMARY KEY, name STRING, age INT ) WITH ( 'connector' = 'filesystem', 'path' = 'file:///path/to/paimon_table' ); -- 插入数据,实现增量同步 INSERT INTO paimon_incremental_table SELECT * FROM mysql_cdc_source; ``` 该脚本定义了一个 MySQL CDC 源表和一个 Paimon 表,并通过 `INSERT INTO ... SELECT` 实现了从源到目标的增量数据同步[^1]。 ### 2. 使用 Paimon 的主键表支持 Paimon 支持主键表(Primary Key Table),可以用于实现 UPSERT 语义,即当主键冲突时自动更新记录。这在构建增量表时非常有用,尤其适用于需要合并多个变更事件的场景。 #### 示例: ```sql -- 创建具有主键的 Paimon 表 CREATE TABLE paimon_pk_table ( id INT PRIMARY KEY, name STRING, age INT ) WITH ( 'connector' = 'filesystem', 'path' = 'file:///path/to/paimon_pk_table' ); ``` 该表结构支持基于主键的更新操作,适用于接收来自 CDC 源的变更数据流。 ### 3. 增量快照读取与时间旅行查询 Paimon 支持基于时间点的快照读取(Time Travel),可以查询某一时间点的数据快照或增量变更。这对于构建增量处理流水线、审计和回溯分析非常有帮助。 #### 示例: ```sql -- 查询某个时间点的快照数据 SELECT * FROM paimon_pk_table FOR SYSTEM_TIME AS OF '2024-01-01 00:00:00'; ``` 该功能基于 Paimon 的多版本并发控制(MVCC)机制,能够高效地管理数据版本并支持增量读取[^1]。 ### 4. 使用 Paimon 的增量消费接口 Paimon 提供了增量消费接口(Incremental Source),可以通过 Flink DataStream API 或 Paimon 的 CLI 工具订阅某个时间点之后的增量变更。这对于构建实时数据管道、变更数据捕获和事件溯源系统非常有用。 #### 示例 CLI 命令: ```bash paimon run -m yarn-cluster \ -Djobmanager.memory.process.size=2gb \ -Dtaskmanager.memory.process.size=4gb \ -jarfile /path/to/paimon-flink-action.jar \ incremental-source \ --warehouse file:///path/to/warehouse \ --database default_database \ --table paimon_pk_table \ --starting-checkpoint latest ``` 该命令启动了一个增量数据读取作业,从指定表中读取最新的变更数据并输出到下游系统[^1]。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值