ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Apache Doris BitMap去重实战:高基数场景下替代COUNT(DISTINCT)的性能优化方案

2026/8/12 20:25:22 拓冰建站 浏览量
Apache Doris BitMap去重实战:高基数场景下替代COUNT(DISTINCT)的性能优化方案

1. 从“计数不准”到“精准去重”的实战需求

在数据仓库和实时分析领域,我们经常遇到一个看似简单却暗藏玄机的问题:如何精确地统计一个集合中不重复元素的数量?尤其是在处理海量用户ID、设备ID、订单号等场景时,传统的COUNT(DISTINCT)方法在高基数(即唯一值数量巨大)和大数据量下,往往会成为性能瓶颈,甚至因为内存限制导致查询失败或结果不准确。很多从业者都曾踩过这个坑,明明数据就在那里,但一个简单的去重计数查询却跑得异常缓慢,或者在某些分布式计算框架下,因为数据倾斜或近似算法的误差,得到一个“差不多”但“不精确”的数字。对于需要精确对账、财务计算或关键业务指标的场景,这种“差不多”是绝对无法接受的。

Apache Doris,作为一个高性能的实时分析型数据库,其MPP架构和向量化执行引擎在处理大规模数据分析上有着天然优势。面对精准去重的挑战,Doris提供了一套基于BitMap数据类型的强大函数集。这不仅仅是提供了一个新函数那么简单,它代表了一种从“近似估算”到“精确计算”的思维转变,以及一种利用内存高效数据结构来换取极致查询性能的工程实践。BitMap去重的核心价值在于,它能在常数级内存增长下,实现对海量高基数数据的精确去重计数,将原本可能需要数分钟甚至更久的COUNT(DISTINCT)查询,优化到秒级甚至毫秒级响应。

本文将深入拆解Apache Doris中BitMap函数用于精准去重的原理、适用场景、详细操作步骤以及那些官方文档可能不会提及的实战避坑指南。无论你是正在为COUNT(DISTINCT)的性能问题而头疼,还是希望寻找一种更优雅、更高效的数据去重解决方案,这篇从一线实战中总结出的经验,都将为你提供清晰的路径和可靠的参考。

2. BitMap去重:为什么是它,而不是COUNT(DISTINCT)?

要理解BitMap的优势,我们必须先看清传统COUNT(DISTINCT)的局限性。在分布式数据库如Doris中,一个COUNT(DISTINCT column)的查询,其执行过程通常涉及数据重分布(Shuffle)和全局聚合。当去重列的唯一值数量(基数)非常高时,这个聚合节点需要维护一个巨大的哈希表来记录所有已经出现过的唯一值,以判断后续值是否重复。这个过程会消耗大量的内存,一旦超出节点内存限制,就会触发磁盘溢出(Spill to Disk)甚至直接导致查询失败(OOM)。此外,数据在节点间网络传输(Shuffle)本身也是一项昂贵的开销。

BitMap则采用了一种完全不同的思路。它本质上是一个基于位(bit)的集合表示法。假设我们需要对用户ID进行去重,我们可以预先定义一个足够大的位图空间(例如,使用BIGINT类型的用户ID,理论范围是0到2^63-1)。BitMap并不直接存储每一个用户ID的原始值,而是将每个用户ID映射到位图中的一个特定位置(位)。如果该用户ID存在,则将其对应的位设置为1;否则为0。最后,要得到不重复用户的数量,只需要统计这个位图中值为1的位的个数即可。

2.1 核心优势对比

为了更直观地展示差异,我们通过一个表格来对比两种方式:

特性维度COUNT(DISTINCT)BitMap去重
计算精度精确精确
内存消耗与去重列的基数成正比。基数越高,内存消耗越大,易OOM。与去重列的值域范围成正比,与数据量和基数无关。通过压缩技术(如Roaring Bitmap),实际内存消耗远小于理论值。
计算速度较慢。涉及哈希计算、哈希表维护和可能的数据Shuffle。极快。位操作(OR, AND, COUNT)是CPU指令级优化,效率极高,且常可避免全局Shuffle。
适用场景低基数去重(例如,性别、省份等枚举值)。高基数去重(用户ID、设备ID、手机号等)。特别是当ID是连续或分布相对集中的整数时,优势巨大。
预处理需求无需预处理,可直接在原始数据上查询。通常需要在数据导入或通过物化视图预计算阶段,将原始数据转化为BitMap。属于“空间换时间”和“计算前置”。
存储开销无额外存储。需要存储BitMap对象,但通过压缩,存储增量通常可接受。

注意BitMap并非银弹。它的一个关键前提是,去重对象必须是整型(TINYINT, SMALLINT, INT, BIGINT),或者可以稳定地映射为整型(如将字符串通过哈希函数转为整型,但需注意哈希冲突风险)。对于非整型数据,需要额外的ETL处理。

2.2 BitMap在Doris中的实现精髓

Apache Doris内置了BITMAP数据类型和一系列函数(如bitmap_union(),bitmap_union_count(),bitmap_hash()等)。其高效性得益于两点:

  1. 计算下推与预聚合:在Doris的聚合模型中,可以在数据导入时或通过物化视图,使用BITMAP类型列和bitmap_union()函数进行预聚合。查询时,直接对已经聚合好的BitMap进行bitmap_union_count()计算,避免了扫描原始明细数据和高成本的运行时去重。
  2. Roaring Bitmap压缩:Doris底层使用的BitMap库是经过高度优化的Roaring Bitmap。它并非简单开辟一个巨大的位数组,而是根据数据分布,智能地将值域分成多个块(Container),对于稀疏块使用数组存储,对于稠密块使用位图存储,从而在绝大多数实际场景下,实现了内存和计算效率的最佳平衡。

理解了“为什么”之后,接下来的问题就是“怎么做”。我们将从一个具体的业务场景出发,手把手完成从表设计到高效查询的全过程。

3. 实战演练:设计一个基于BitMap的用户活跃日统计表

假设我们有一个经典的业务需求:统计每天活跃的用户数(DAU),以及任意时间范围内的去重活跃用户数(如WAU, MAU)。用户ID是BIGINT类型,每天产生数十亿的访问记录,用户基数在数亿级别。

3.1 表结构设计与建表语句

在Doris中,我们需要设计一张聚合表(Aggregate Table),这是使用BitMap进行高效预聚合的基础。

CREATE TABLE `dau_bitmap` ( `dt` date NOT NULL COMMENT "日期", `user_id_bitmap` BITMAP BITMAP_UNION NOT NULL COMMENT "活跃用户Bitmap" ) ENGINE=OLAP AGGREGATE KEY(`dt`) COMMENT "使用Bitmap存储每日活跃用户" DISTRIBUTED BY HASH(`dt`) BUCKETS 10 PROPERTIES ( "replication_num" = "3", "storage_format" = "V2" );

关键设计解析:

  1. 聚合键(AGGREGATE KEY):这里只包含了dt(日期)字段。这意味着表会按照dt进行聚合。所有相同dt的数据行,在导入或Compaction时,其user_id_bitmap列会按照BITMAP_UNION的聚合方式合并。
  2. BITMAP_UNION聚合方式:这是核心。它指定了user_id_bitmap这个BITMAP类型列的聚合逻辑是“位图并集合并”。当多行数据具有相同的dt时,它们的user_id_bitmap会被合并(求并集)成一个更大的BitMap,从而自动实现去重。
  3. 数据分布:使用dt字段进行HASH分桶,将不同日期的数据分布到不同节点,便于并行查询和存储。

这个表结构非常精简,它不存储原始的用户ID列表,只存储每天聚合后的BitMap对象。存储压力从存储大量重复的user_id字符串或数字,转变为存储高度压缩的BitMap

3.2 数据导入:将原始数据转化为Bitmap

原始日志或业务表(user_behavior)可能长这样:

user_idevent_time...
100012023-10-27 10:00:00...
100022023-10-27 10:01:00...
100012023-10-27 11:00:00...
100032023-10-28 09:00:00...

我们需要将这样的数据导入到dau_bitmap表中。这里使用INSERT INTO SELECT配合bitmap_agg()函数来实现。bitmap_agg()是一个聚合函数,它将一组整数值聚合成一个BitMap

-- 假设原始表为 user_behavior, 有 user_id(BIGINT) 和 event_time(DATETIME) 字段 INSERT INTO dau_bitmap (dt, user_id_bitmap) SELECT DATE(event_time) as dt, BITMAP_AGG(user_id) as user_id_bitmap FROM user_behavior WHERE event_time >= '2023-10-27' GROUP BY DATE(event_time);

执行这个语句后,Doris会:

  1. DATE(event_time)分组。
  2. 在每个分组内,对所有的user_id调用BITMAP_AGG(),生成一个包含该日内所有活跃用户ID的BitMap(自动去重)。
  3. 将结果(日期, BitMap)插入到dau_bitmap表。如果同一天的数据分多次导入,Doris会在底层自动使用BITMAP_UNION合并这些BitMap

实操心得:数据分批次导入的优化对于历史数据初始化或大规模数据回溯,建议按时间范围分批执行INSERT INTO SELECT,例如每次处理一周或一天的数据。这可以避免单个导入作业过大,导致FE(Frontend)生成执行计划过慢或BE(Backend)内存压力剧增。可以在脚本中循环执行,并添加适当的间隔。

3.3 高效查询:秒级获取DAU、WAU、MAU

当数据以BitMap形式预聚合好后,查询变得异常简单和快速。

查询单日活跃用户数(DAU):

SELECT dt, BITMAP_UNION_COUNT(user_id_bitmap) as dau FROM dau_bitmap WHERE dt = '2023-10-27';

BITMAP_UNION_COUNT()函数是查询的关键。它接收一个BitMap列,并返回该位图中置为1的位的总数,即精确的去重用户数。由于数据已经按天聚合好,这个查询几乎不需要进行任何昂贵的计算,直接读取并计算预聚合好的BitMap即可,速度极快。

查询过去7天的去重活跃用户数(WAU):

SELECT BITMAP_UNION_COUNT(user_id_bitmap) as wau FROM dau_bitmap WHERE dt >= '2023-10-21' AND dt <= '2023-10-27';

这个查询会将7天内每天的BitMap通过BITMAP_UNION_COUNT()函数在计算过程中进行合并(union),然后统计总数。虽然涉及多个BitMap的合并操作,但由于BitMap合并是高度优化的位运算,且数据已经按天压缩,其性能依然远优于对7天原始数据做COUNT(DISTINCT user_id)

4. 进阶应用与复杂场景剖析

基础的日粒度统计只是开始。BitMap的真正威力体现在更复杂的多维分析和用户分群场景中。

4.1 多维交叉分析:哪些用户既活跃又完成了购买?

假设我们还有另一张表purchase_bitmap,以同样的方式存储了每日完成购买的用户BitMap。现在想分析在2023-10-27当天,既活跃又购买的用户数(即活跃购买用户交集)。

SELECT BITMAP_UNION_COUNT( BITMAP_INTERSECT(dau.user_id_bitmap, pur.user_id_bitmap) ) as active_purchase_users FROM dau_bitmap dau JOIN purchase_bitmap pur ON dau.dt = pur.dt WHERE dau.dt = '2023-10-27';

这里引入了BITMAP_INTERSECT()函数,用于计算两个BitMap的交集,然后再对交集的BitMap计数。这种基于集合的运算(并集、交集、差集)是BitMap的天然优势,可以轻松实现复杂的用户行为交集、并集分析,而无需复杂的子查询或JOIN去重。

4.2 用户留存率计算

计算次日留存率是常见的需求。例如,计算2023-10-26的新增用户,在2023-10-27的留存情况。

  1. 首先,我们需要一张表first_day_bitmap来记录用户的首日活跃BitMap(这可以通过对dau_bitmap进行首次出现聚合得到,或由业务直接记录)。
  2. 然后,使用BITMAP_INTERSECT计算留存用户。
-- 假设 first_day_bitmap 表结构为 (first_dt date, user_id_bitmap BITMAP) SELECT first.first_dt, BITMAP_UNION_COUNT(first.user_id_bitmap) as new_users, BITMAP_UNION_COUNT( BITMAP_INTERSECT(first.user_id_bitmap, dau.user_id_bitmap) ) as retained_users, BITMAP_UNION_COUNT( BITMAP_INTERSECT(first.user_id_bitmap, dau.user_id_bitmap) ) / BITMAP_UNION_COUNT(first.user_id_bitmap) as retention_rate FROM first_day_bitmap first LEFT JOIN dau_bitmap dau ON dau.dt = DATE_ADD(first.first_dt, INTERVAL 1 DAY) WHERE first.first_dt = '2023-10-26' GROUP BY first.first_dt;

这个查询清晰地展示了如何利用BitMap的集合运算,高效且精确地完成留存率这种需要多重去重和关联的分析。

4.3 处理非整型数据:字符串ID的映射

如果用户ID是字符串(如UUID、手机号),直接使用BitMap是不行的。常见的做法是使用一个稳定的哈希函数(如murmur_hash3_32,crc32)将其转化为整型。但这里有一个至关重要的坑:哈希冲突

-- 在数据导入时进行转换 INSERT INTO dau_bitmap (dt, user_id_bitmap) SELECT DATE(event_time), BITMAP_AGG(CAST(MURMUR_HASH3_32(user_id_string) AS INT)) -- 使用32位哈希,转为INT FROM user_behavior_string GROUP BY DATE(event_time);

重要警告与避坑指南:哈希冲突风险哈希函数将无限可能的字符串映射到有限的整数空间(如32位整型是42亿),必然存在不同字符串哈希到同一个整数的可能,这就是哈希冲突。冲突会导致本应不同的用户被误判为同一用户,使得去重计数结果偏少,造成数据不准。

缓解方案

  1. 评估冲突概率:对于数亿级别的用户基数,使用32位哈希(约42亿空间)冲突概率已不可忽视。建议使用64位哈希(murmur_hash3_64)并存储为BIGINT,冲突概率极低。
  2. 业务层保证:如果可能,最好在业务系统设计时,就为需要分析的用户生成一个全局唯一的数字ID(如自增ID或Snowflake算法ID),从源头上避免映射问题。
  3. 使用Bitmap字典:维护一个用户字符串到唯一整数ID的映射字典表。虽然更精确,但增加了ETL复杂度。需要权衡精确性和工程成本。

实测建议:在决定方案前,可以对存量数据抽样,计算哈希冲突率,评估对业务指标的潜在影响。

5. 性能调优与生产环境注意事项

BitMap应用于生产环境,除了正确的使用姿势,还需要关注一些影响稳定性和性能的细节。

5.1 Bitmap列的数据分布与压缩

BitMap对象的体积与其表示的最大整数值有关。如果用户ID范围非常稀疏(例如,ID从10亿开始),直接使用会导致BitMap内部开辟大量无效空间。虽然Roaring Bitmap已经做了优化,但初始ID偏移量过大仍可能影响效率。

优化建议:如果ID不是从0或1开始的小数字,可以考虑在导入时进行偏移归一化。例如,如果最小用户ID是1000000000,可以在BITMAP_AGG之前先减去这个最小值(user_id - 1000000000),以缩小值域范围。查询时,如果需要还原原始ID进行关联,则需要额外记录这个偏移量。这属于一种“数据预处理”的优化手段,适用于ID范围已知且相对固定的场景。

5.2 物化视图加速更复杂的聚合

上述例子是按天聚合。但如果经常需要查询“每小时的DAU”或“每周的MAU”,每次都从日粒度BitMap上计算,虽然比查原始数据快,但仍有计算开销。此时,可以利用Doris的异步物化视图功能,预先计算好更细粒度或更粗粒度的BitMap聚合。

例如,创建小时粒度的物化视图:

CREATE MATERIALIZED VIEW dau_hourly_mv AS SELECT DATE_TRUNC('HOUR', event_time) as dt_hour, BITMAP_AGG(user_id) as user_id_bitmap FROM user_behavior GROUP BY DATE_TRUNC('HOUR', event_time);

创建后,查询WHERE dt_hour = ...时,Doris查询优化器会自动路由到这个物化视图,直接读取小时粒度的聚合结果,速度更快。

5.3 监控与问题排查

  • 内存监控:虽然BitMap压缩率高,但在执行涉及多个大BitMap合并的查询时(如计算一个月的MAU),中间结果BitMap可能会占用较多内存。需要关注BE节点的内存监控指标,如query_peak_memory
  • 慢查询分析:如果BitMap查询变慢,可以使用EXPLAIN命令查看执行计划。重点检查是否没有命中预聚合的BitMap数据,而是回退到了扫描原始明细数据。这通常是因为WHERE条件中的过滤字段不是聚合键的一部分,或者物化视图未正确创建。
  • 存储膨胀:定期检查表的数据量。虽然BitMap压缩,但无止境的历史数据存储仍会带来成本。需要根据业务需求设计数据生命周期管理(TTL),将过期的冷数据转移到对象存储(如S3)或直接删除。

5.4 一个真实的踩坑案例:Bitmap与COUNT DISTINCT的混合使用误区

有一次在优化一个宽表查询时,我需要同时计算一个高基数字段A的去重数和几个低基数字段B、C的去重数。我自作聪明地将高基数字段A改用了BitMap预聚合,而低基数字段B、C保留了COUNT(DISTINCT)。查询语句类似:

SELECT bitmap_union_count(bitmap_A) as uv_A, COUNT(DISTINCT B) as uv_B, COUNT(DISTINCT C) as uv_C FROM my_table;

结果发现查询性能并没有达到预期提升,有时甚至更慢。通过EXPLAIN分析发现,由于查询中同时存在BitMap聚合和普通的COUNT(DISTINCT)聚合,Doris的查询引擎无法完全利用BitMap表的预聚合优势,执行计划变得复杂,部分计算仍需扫描原始数据。

解决方案与心得: 对于这种混合场景,更优的做法是彻底拥抱BitMap,或者进行查询拆分

  1. 彻底拥抱BitMap:即使对于低基数字段B和C,也为其创建BITMAP类型的聚合列。因为BitMap对于低基数数据压缩率极高,计算也很快。将表彻底改造为全BitMap聚合表。
  2. 查询拆分:如果改造表结构成本高,可以将一个复杂查询拆分成多个简单查询。先通过BitMap表快速查询出uv_A,再通过其他方式查询uv_B和uv_C,在应用层进行结果组装。这违背了“一次查询搞定所有”的直觉,但在分布式系统中,有时简单的查询并行执行,总耗时反而少于一个复杂的单一查询。

这个坑让我明白,性能优化不是简单地替换一个函数,而是需要从表设计、数据存储到查询方式的全链路通盘考虑。引入BitMap这类高效但特化的数据结构后,整个数据模型和查询模式最好都能围绕其特性进行适配,才能最大化其收益。