Paimon数据湖中RoaringBitmap与聚合引擎深度解析

9 次阅读

Apache Paimon中的数据并不只是简单地“存进去再读出来”。对于实时数仓、用户行为分析、标签画像等场景,同一个主键可能不断收到新的数据贡献,最终需要把这些数据合并成一个逻辑结果。Paimon的聚合合并引擎正是为这类场景设计的,而RoaringBitmap则进一步解决了大规模整数集合高效存储与集合运算的问题。

两者结合后,可以构建出一种非常适合实时数据湖的Bitmap聚合方案:上游将用户ID、设备ID、页面ID等整数集合编码为RoaringBitmap,下游通过Paimon aggregation merge engine按照主键持续完成Bitmap Union,从而避免反复扫描明细数据。

Paimon聚合引擎到底解决什么问题

Paimon的Merge Engine决定相同主键的数据如何形成最终的逻辑行。除了默认的deduplicate模式之外,还提供partial-update、aggregation、first-row等模式。其中,aggregation模式的核心特点是:同一个主键下,各个非主键字段可以按照配置的聚合函数持续合并。

例如有一张用户统计表:

CREATE TABLE user_stats (
    user_id BIGINT,
    total_orders BIGINT,
    max_amount DOUBLE,
    PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
    'merge-engine' = 'aggregation',
    'fields.total_orders.aggregate-function' = 'sum',
    'fields.max_amount.aggregate-function' = 'max'
);

假设用户1001先后产生两条数据:

1001, 3, 128.5
1001, 5, 256.0

聚合后的逻辑结果就是:

1001, 8, 256.0

也就是说,Paimon并不是简单选择其中一条记录,而是根据字段配置决定如何合并。官方文档同时说明,未单独配置聚合函数的普通非主键字段通常使用last_non_null_value。

这种机制对于计数、求和、最大值、最小值等指标非常直观,但面对“用户集合”“访问页面集合”“标签集合”等数据时,传统的数值聚合就不够用了。

为什么需要RoaringBitmap

Bitmap的核心思想是用位集合表达整数集合。例如:

{1, 3, 5, 8}

可以抽象成对应位置上的0和1。

普通Bitmap虽然计算集合并集、交集非常方便,但当整数空间很大而实际数据比较稀疏时,直接分配连续位空间会产生大量浪费。

RoaringBitmap采用分块和压缩容器等方式,对不同密度的数据选择更合适的存储形式,因此特别适合表示大规模整数集合。对于用户ID、商品ID、设备ID、文章ID等非连续整数集合,它通常比简单的稠密Bitmap更加实用。

更重要的是,RoaringBitmap天然支持集合运算。两个集合:

A = {100, 101, 102}
B = {102, 103, 104}

执行Union后得到:

A ∪ B = {100, 101, 102, 103, 104}

如果业务目标本身就是“持续收集一个主键对应的所有ID”,那么Bitmap Union与Paimon的聚合模型具有非常自然的匹配关系。

Paimon中的rbm32与rbm64

Paimon聚合引擎已经提供了专门用于RoaringBitmap合并的rbm32和rbm64聚合函数。

rbm32用于聚合序列化后的32位RoaringBitmap,字段类型为VARBINARY;rbm64则针对64位整数集合,适合更大的ID空间。两者的基本处理流程都是读取序列化Bitmap、执行集合OR合并,再将结果序列化回VARBINARY。

可以简单理解成:

Bitmap A
   +
Bitmap B
   +
Bitmap C
   ↓
RoaringBitmap Union
   ↓
一个聚合后的Bitmap

因此,Paimon表中的Bitmap字段并不是普通二进制字符串,而是具有明确语义的序列化集合。

一个典型的rbm32表设计

例如需要统计每个用户访问过哪些页面,可以设计如下表:

CREATE TABLE user_visits (
    user_id INT,
    visit_bitmap VARBINARY,
    PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
    'merge-engine' = 'aggregation',
    'fields.visit_bitmap.aggregate-function' = 'rbm32'
);

假设用户1第一次访问:

{100, 101, 102}

第二次访问:

{102, 103, 105}

进入Paimon后,两个Bitmap会按照主键user_id=1进行聚合,最终得到:

{100, 101, 102, 103, 105}

官方示例也是基于这种模式实现用户访问页面集合的Bitmap聚合。

rbm32和rbm64应该如何选择

两者最大的区别并不是压缩算法,而是能够表达的整数范围。

rbm32适合ID可以安全表示为32位整数的业务,例如:

  • 页面ID

  • 商品ID

  • 较小范围的用户ID

  • 业务标签ID

  • 普通事件ID

rbm64适合使用64位整数的业务,例如:

  • 大规模用户ID

  • Long类型业务主键

  • 全局唯一ID映射值

  • 可能超过32位范围的事件标识

如果原始业务数据本身就是Java的long或数据库中的BIGINT,通常应该优先考虑64位Bitmap,避免在数据转换过程中发生截断。

Bitmap聚合和普通SUM聚合的本质区别

很多人第一次接触Paimon的Bitmap聚合时,容易把它理解成另一种SUM。实际上,两者解决的是完全不同的问题。

SUM处理的是数值:

3 + 5 + 7 = 15

而Bitmap聚合处理的是集合:

{1, 2, 3}
∪
{3, 4, 5}
=
{1, 2, 3, 4, 5}

因此,如果业务指标是“累计订单金额”,适合使用sum;如果业务指标是“所有访问过的商品集合”,则Bitmap更合适。

还需要特别注意,Paimon的聚合引擎并不会自动根据事件ID对贡献进行去重。官方文档明确指出,例如使用sum重复重放一条贡献数据会再次累加;Bitmap同样应该从业务语义上考虑事件重复问题。

这意味着:

事件A → Bitmap{100,101}
事件A重放 → Bitmap{100,101}

最终Bitmap的集合结果不会因为重复添加100、101而产生重复元素,因为集合本身具有去重语义,但这并不意味着整个数据链路已经实现了事件级幂等。对于其他伴随字段,或者依赖事件次数的指标,仍然需要考虑重复消费与重放问题。

Paimon并不会自动帮你创建Bitmap

这是实际落地时非常容易踩到的一个点。

Paimon提供的是rbm32和rbm64聚合能力,但Bitmap输入本身需要是正确序列化的RoaringBitmap二进制数据。官方文档特别说明,Paimon当前不会自动提供用于把原始整数转换为Bitmap的内置Flink UDF。可以在应用代码中创建序列化Bitmap,也可以自行编写Flink UDF完成转换。

例如Java侧可以使用RoaringBitmap类库生成集合:

RoaringBitmap bitmap = RoaringBitmap.bitmapOf(
    100, 101, 102, 103
);

ByteArrayOutputStream output = new ByteArrayOutputStream();
DataOutputStream dataOutput = new DataOutputStream(output);

bitmap.serialize(dataOutput);

byte[] bytes = output.toByteArray();

然后将序列化后的字节数组作为VARBINARY写入Paimon。

具体序列化方式必须保证生产端和消费端使用兼容的数据格式,否则即使字段类型都是VARBINARY,也不能简单认为它们可以互相解析。

Flink实时链路中的典型架构

一个较完整的Bitmap聚合链路可以设计成:

业务事件
   ↓
Flink CDC / Kafka / 消息队列
   ↓
Flink实时计算
   ↓
用户ID、商品ID等整数集合
   ↓
Bitmap UDF
   ↓
RoaringBitmap序列化
   ↓
Paimon VARBINARY
   ↓
aggregation merge engine
   ↓
rbm32 / rbm64
   ↓
聚合后的Bitmap

这种设计最大的价值,是把“集合状态”从大量明细事件中提取出来。

假设一天产生10亿条用户行为记录,如果每次查询都从明细表中执行:

SELECT DISTINCT user_id
FROM events
WHERE ...

计算成本可能随着历史数据规模持续增加。

如果Paimon已经维护了对应业务维度的Bitmap,那么后续查询可以直接围绕Bitmap状态进行处理,从而减少重复扫描原始明细数据的压力。

Bitmap聚合适合哪些业务

Bitmap尤其适合“集合型指标”。

例如用户标签:

用户1001 → {男性, 会员, 高价值, 华东}

商品访问:

用户1001 → {商品10, 商品20, 商品35}

内容阅读:

用户1001 → {文章100, 文章120, 文章135}

广告触达:

广告A → {用户1, 用户2, 用户5, 用户8}

设备行为:

设备ID → {事件10, 事件20, 事件30}

如果这些集合中的元素能够映射成整数ID,就可以考虑使用RoaringBitmap。

尤其是在用户画像和实时标签场景中,Bitmap能够很好地表达“某个主体关联了哪些对象”。

Bitmap与去重统计之间的关系

Bitmap最有价值的能力之一就是天然表达集合,因此它特别适合精确去重。

例如:

第一批用户:{1, 2, 3, 4}
第二批用户:{3, 4, 5, 6}

Bitmap Union之后:

{1, 2, 3, 4, 5, 6}

集合大小为6。

但这里需要区分两个概念:

Bitmap保存的是集合本身,而不是最终的COUNT结果。

如果业务只关心“有多少个不同用户”,Bitmap可以作为中间状态:

Bitmap
   ↓
基数统计
   ↓
Distinct Count

如果只需要近似UV而不需要保存每个具体ID,则HLL等基数Sketch可能更加节省空间。Paimon同样提供HLL和Theta等基数Sketch聚合方式。

因此可以按照业务目标选择:

需求更适合的方案
保存精确ID集合RoaringBitmap
精确集合UnionRoaringBitmap
精确判断某个ID是否存在RoaringBitmap
只关心近似UVHLL
需要更复杂集合统计Theta Sketch
普通数值累计SUM
最大值/最小值MAX/MIN

聚合引擎与Bitmap索引不要混淆

Paimon中还存在Bitmap Index,这与rbm32、rbm64并不是同一个概念。

Bitmap聚合解决的是:

多个输入记录
    ↓
多个Bitmap状态
    ↓
合并成一个Bitmap状态

而Bitmap Index解决的是:

某个字段的值
    ↓
对应数据行的压缩Bitmap
    ↓
用于查询过滤和数据裁剪

Paimon的Bitmap Index主要面向枚举型、标签型等字段,可以针对相等、IN、部分字符串匹配以及NULL判断等条件建立索引,并通过Bitmap集合运算辅助查询。

因此:

rbm32 / rbm64
= 聚合数据

Bitmap Index
= 加速查询

二者虽然都使用Bitmap思想,但处于完全不同的数据处理层次。

实际设计时需要关注主键

Paimon aggregation的核心前提之一,是确定哪些数据应该被合并到同一条逻辑记录中。

例如:

PRIMARY KEY (user_id) NOT ENFORCED

意味着同一个user_id对应的Bitmap贡献会不断聚合。

如果改成:

PRIMARY KEY (user_id, dt) NOT ENFORCED

那么每天的数据就会形成不同的聚合状态:

user_id + 2026-10-01
user_id + 2026-10-02
user_id + 2026-10-03

这种设计适合按天保存集合状态。

如果希望维护用户的长期累计集合,则日期字段通常不应该直接成为聚合主键的一部分,而应该结合分区、查询模式以及数据生命周期进行设计。

聚合引擎中的数据一致性问题

Bitmap虽然具备集合去重特性,但不能因此忽略数据一致性。

首先需要明确输入事件是不是可重复消费。

如果业务要求:

一条事件只能计算一次

那么仅仅依赖Bitmap Union并不能解决所有问题。

其次,需要考虑删除和撤回。

普通SUM可以通过正负贡献设计某些可逆聚合,但Bitmap Union本质上是集合并集:

A ∪ B

并不存在简单的:

(A ∪ B) - B

就一定能够安全恢复原始状态的通用方案,因为B中的元素可能同时来自A。

因此,如果业务存在频繁删除、撤回或者严格的反向操作需求,Bitmap聚合模型需要重新评估,不能简单套用普通数值聚合的思路。

Paimon官方文档也提醒,选择aggregation merge engine时需要根据输入贡献的语义选择聚合函数,并特别注意UPDATE_BEFORE、DELETE以及数据重放等情况。

流式读取时的一个关键区别

聚合数据写入之后,读取方式同样值得关注。

Paimon文档指出,如果希望获得完整的聚合结果,可以使用lookup或full-compaction changelog producer;input producer则可能直接输出输入贡献,而不是最终聚合结果。

这意味着排查“为什么下游看到的Bitmap不是最终结果”时,不能只看表定义,还要检查Changelog Producer以及消费链路。

例如:

输入:
Bitmap{1,2}

输入:
Bitmap{2,3}

最终状态应该是:

Bitmap{1,2,3}

但某些读取方式看到的可能是两次贡献:

Bitmap{1,2}
Bitmap{2,3}

这并不一定意味着聚合失败,而可能只是读取语义不同。

性能优化建议

Bitmap聚合的性能通常受到几个因素影响。

第一是Bitmap大小。

如果集合非常稀疏或者ID空间特别大,需要关注RoaringBitmap最终序列化后的大小,而不能只看元素数量。

第二是主键分布。

如果所有数据集中到极少数主键,单个Bitmap可能持续膨胀,并成为热点状态。应该根据业务访问模式重新设计聚合粒度。

第三是Bitmap创建方式。

不要在SQL层反复进行复杂字符串解析后再生成Bitmap。对于高吞吐实时链路,更合理的方式通常是在Flink UDF或上游应用中直接完成整数到Bitmap的转换。

第四是读取模式。

如果下游只需要统计基数,却不需要知道具体ID集合,可以考虑直接使用HLL等Sketch,避免保存大量精确Bitmap。

第五是压缩与存储。

Bitmap本身已经具有压缩特性,但最终的VARBINARY大小仍然应该通过真实业务数据进行测试。不要单纯根据理论上的元素数量估算存储成本。

常见问题排查

Bitmap聚合结果为空

首先检查Bitmap字段是否确实使用了正确的序列化格式,其次确认聚合字段配置是否正确:

'fields.visit_bitmap.aggregate-function' = 'rbm32'

同时检查实际写入的数据是否为NULL。

rbm32和rbm64类型不匹配

如果输入使用64位ID,却配置了rbm32,就可能出现数据范围或类型层面的不兼容。应根据业务ID的数据类型选择对应聚合函数。

为什么重复事件没有导致Bitmap元素重复

这是Bitmap集合语义的正常表现。相同整数加入同一个集合不会产生重复元素,但这不代表整个事件流已经完成幂等处理。

为什么查询结果不是最终Bitmap

检查Changelog Producer以及读取方式。输入贡献和最终聚合结果属于不同语义,不能混为一谈。

为什么Bitmap越来越大

需要判断业务是否真的需要维护长期全集。如果Bitmap生命周期过长,可能不断吸收新的ID。可以考虑按照日期、业务域或其他维度拆分聚合状态。

一套更合理的技术选型思路

实际项目中,可以按照下面的判断路径设计:

是否需要保存整数集合?
        │
       是
        ↓
是否要求精确集合?
   ┌────┴────┐
  是         否
  ↓           ↓
RoaringBitmap  Sketch
  ↓
集合规模是否可能超过32位?
   ┌────┴────┐
  否         是
  ↓           ↓
rbm32       rbm64

如果需求只是:

“这个用户是否属于某个标签?”

则不一定需要完整Bitmap聚合,具体方案还要结合查询频率、数据规模和索引能力判断。

如果需求是:

“某个用户累计关联了多少商品?”
“多个事件集合需要持续Union”
“需要保留精确的ID集合”

RoaringBitmap与Paimon aggregation的组合就具有较高的实用价值。

总结

Paimon的聚合引擎负责定义“同一个主键下的数据应该如何合并”,而RoaringBitmap负责高效表达和操作整数集合。rbm32和rbm64把这两个能力连接起来,使Paimon能够直接维护可持续累积的Bitmap状态。

真正理解这套方案的关键,不是简单记住rbm32或rbm64的配置项,而是理解三层关系:

业务事件
   ↓
RoaringBitmap集合状态
   ↓
Paimon aggregation
   ↓