1. 项目概述:为什么我们需要更精准的计数?
在数据仓库和实时分析领域,精确统计用户数、设备数这类“唯一值”是一个高频且核心的需求。你可能经常听到“日活跃用户(DAU)”、“月活跃用户(MAU)”这样的指标,它们的本质就是基于用户ID的去重计数。在Apache Doris这类MPP分析型数据库中,我们通常会用COUNT(DISTINCT user_id)来实现。
这个方法简单直观,但在面对海量数据(比如数亿甚至数十亿级别的用户ID)时,它的性能瓶颈会非常明显。COUNT(DISTINCT)在分布式计算中,需要将数据打散(Shuffle)到一个节点上进行全局去重和聚合,这个过程会产生巨大的网络传输开销和单点计算压力,查询耗时可能从几秒飙升到几分钟,严重影响了实时分析的体验。
于是,Bitmap(位图)技术就成为了解决这一痛点的利器。它不是Doris的独创,但在Doris中通过高效的函数和聚合模型得到了极佳的实现。简单来说,Bitmap将一个巨大的整数集合(比如用户ID),压缩成一个比特位数组。每个比特位代表一个整数,如果该整数存在,则对应位为1,否则为0。最终,我们只需要统计这个位图中“1”的个数,就能得到去重后的总数。这种方法的空间利用率和计算效率,在处理稀疏的大整数集时,远超传统的哈希去重。
最近在处理一个用户行为日志分析项目时,我就深刻体会到了这一点。我们的用户ID是十亿级别的BIGINT类型,按天分区存储。最初使用COUNT(DISTINCT)查询每日去重用户数,在数据量大的分区需要近一分钟。在引入Bitmap方案后,同样的查询被稳定优化到了5秒以内,并且支持了更复杂的多日去重合并计算(如7日去重用户数)。这不仅仅是性能的提升,更是为业务提供了更灵活、更强大的数据分析能力。接下来,我就结合这次实战,拆解如何在Apache Doris中玩转Bitmap函数,实现精准且高效的去重。
2. Bitmap去重的核心原理与Doris实现
要用好Bitmap,不能只停留在“调用函数”的层面,理解其底层原理和Doris的实现方式,能帮助我们在模型设计、资源调优时做出更正确的决策。
2.1 Bitmap的数学与存储本质
Bitmap的核心思想是用位(Bit)来标记存在性。假设我们有一个取值范围在[0, 7]的用户ID集合 {1, 3, 5}。我们可以用一个长度为8比特的位图来表示它:
- 比特位下标:0 1 2 3 4 5 6 7
- 对应值: 0 1 0 1 0 1 0 0
这样,统计“1”的个数(即bit_count)为3,就是去重后的用户数。其优势在于:
- 空间效率:存储一个整数(如8字节的BIGINT)需要64位,而Bitmap中标记它只需要1位。对于稀疏但范围集中的整数集,压缩比极高。
- 计算效率:集合的交(AND)、并(OR)、差(ANDNOT)操作,可以转换为比特位的逻辑运算,CPU利用SIMD指令集能极快地并行处理。
- 精确性:与HyperLogLog等概率性数据结构不同,Bitmap提供的是100%精确的基数统计。
在Doris中,Bitmap对象并不是直接存储为原始的比特位数组。Doris使用了Roaring Bitmap这种优化的数据结构。Roaring Bitmap将32位整数范围(Doris的Bitmap函数主要针对INT和BIGINT)划分为多个桶(Container),每个桶根据内部数据的稀疏程度,选择用位图(Bitmap Container)还是数组(Array Container)来存储。这种混合结构,使得它在各种数据分布下都能保持高性能和低内存占用,避免了传统位图在数据极度稀疏时(如只存储一个很大的数)的空间浪费。
2.2 Doris中的Bitmap类型与聚合模型
Doris为Bitmap提供了原生支持,主要体现在两种方式:
BITMAP类型:这是一种实际的列数据类型。你可以在建表时定义一列的类型为
BITMAP,并使用BITMAP_UNION()聚合函数。这通常用于明细模型或聚合模型的表,是进行预聚合的最佳实践。-- 在聚合模型中,使用BITMAP列预存用户ID集合 CREATE TABLE user_visit_bitmap ( dt DATE, page VARCHAR(50), user_id_bitmap BITMAP BITMAP_UNION ) AGGREGATE KEY(dt, page) DISTRIBUTED BY HASH(dt, page) BUCKETS 10;向这张表插入数据时,需要先将整型的user_id转化为Bitmap:
INSERT INTO user_visit_bitmap VALUES ('2023-10-27', 'homepage', BITMAP_FROM_STRING('1,3,5')); -- 或者从查询构建 INSERT INTO user_visit_bitmap SELECT '2023-10-27', 'homepage', BITMAP_FROM_ARRAY(ARRAY[1,3,5]);查询时,直接使用
BITMAP_UNION_COUNT(user_id_bitmap)即可获得全局去重数。Doris会在后台自动进行Bitmap的合并计算。Bitmap聚合函数:针对存储在
INT或BIGINT列中的原始ID数据,Doris提供了一系列聚合函数,如BITMAP_UNION(TO_BITMAP(user_id))。这更适用于对已有表的即席查询,无需改变表结构。-- 在原始明细表上直接计算 SELECT COUNT(DISTINCT user_id) as cnt_distinct, BITMAP_UNION_COUNT(TO_BITMAP(user_id)) as cnt_bitmap FROM user_visit_detail WHERE dt = '2023-10-27';两者结果一致,但
BITMAP_UNION_COUNT在分布式计算中,会先在每个节点本地构建并聚合Bitmap,然后只传输压缩后的Bitmap中间结果到FE进行最终合并,大幅减少了网络传输量。
实操心得:模型选择策略如果你的去重查询模式固定且频繁(如每日DAU报表),强烈建议使用聚合模型+BITMAP列进行预聚合。数据导入时即完成去重计算,查询速度极快,消耗资源极少。 如果你的分析需求灵活多变,需要基于各种维度组合进行即席去重查询,那么使用明细模型+Bitmap函数更为合适,虽然每次查询需要计算,但灵活性最高。在实际项目中,我们常常会采用“分层建设”的思路:明细层存放原始数据,轻度汇总层使用Bitmap预聚合常用维度,应用层直接查询汇总层,兼顾灵活与性能。
3. 从入门到精通:Bitmap函数实战解析
了解了原理,我们进入实战环节。Doris提供了丰富的Bitmap函数,掌握它们的关键在于理解其输入输出和适用场景。
3.1 基础构造与聚合函数
这是最常用的函数簇,用于构建Bitmap并计算基数。
TO_BITMAP(INT_COL)/BITMAP_FROM_ARRAY(ARRAY[INT...]):将整数或整数数组转化为一个Bitmap对象。这是所有Bitmap操作的起点。-- 将单个值转为Bitmap (通常用于INSERT或测试) SELECT TO_BITMAP(10086); -- 将数组转为Bitmap,非常实用 SELECT BITMAP_FROM_ARRAY([1,2,3,2,1]); -- 结果Bitmap包含 {1,2,3},自动去重BITMAP_UNION(BITMAP_COL):聚合函数,用于将多行数据的Bitmap合并为一个全局Bitmap。通常与BITMAP_UNION_COUNT联用,但也可以单独使用以获取中间结果。BITMAP_UNION_COUNT(BITMAP_COL):最核心的去重函数。它先执行BITMAP_UNION,然后计算合并后Bitmap的基数(即去重总数)。等价于BITMAP_COUNT(BITMAP_UNION(BITMAP_COL))。-- 计算每日去重用户数 SELECT dt, BITMAP_UNION_COUNT(TO_BITMAP(user_id)) as daily_uv FROM user_visits GROUP BY dt ORDER BY dt;BITMAP_COUNT(BITMAP):标量函数,计算单个Bitmap的基数。常用于对已经聚合或构建好的Bitmap进行统计。-- 假设我们已经有一个存储了某页面用户集的Bitmap列 `user_set` SELECT page, BITMAP_COUNT(user_set) as uv FROM page_agg_table;
3.2 集合运算函数
Bitmap的强大之处在于支持高效的集合运算,用于计算交集、并集、差集等复杂场景,例如计算“留存用户”、“新增用户”。
BITMAP_AND(BITMAP, BITMAP):返回两个Bitmap的交集。-- 计算昨天和今天都访问过的用户(次日留存) SELECT BITMAP_UNION_COUNT( BITMAP_AND( TO_BITMAP(today.user_id), TO_BITMAP(yesterday.user_id) ) ) as retained_users FROM user_visits today JOIN user_visits yesterday ON today.user_id = yesterday.user_id WHERE today.dt = '2023-10-27' AND yesterday.dt = '2023-10-26';但上述写法效率不高,更优的做法是先按天聚合出Bitmap:
WITH daily_bitmap AS ( SELECT dt, BITMAP_UNION(TO_BITMAP(user_id)) as uid_bitmap FROM user_visits WHERE dt IN ('2023-10-26', '2023-10-27') GROUP BY dt ) SELECT BITMAP_COUNT( BITMAP_AND( MAX(CASE WHEN dt='2023-10-27' THEN uid_bitmap END), MAX(CASE WHEN dt='2023-10-26' THEN uid_bitmap END) ) ) as retained_users FROM daily_bitmap;BITMAP_OR(BITMAP, BITMAP):返回两个Bitmap的并集。BITMAP_UNION本质上是多行的BITMAP_OR聚合。-- 计算最近7天的去重用户数(7日活跃) SELECT BITMAP_UNION_COUNT(uid_bitmap) as wau FROM ( SELECT BITMAP_UNION(TO_BITMAP(user_id)) as uid_bitmap FROM user_visits WHERE dt >= '2023-10-21' AND dt <= '2023-10-27' ) t; -- 更简洁的写法,Doris的聚合函数会自动处理 SELECT BITMAP_UNION_COUNT(TO_BITMAP(user_id)) as wau FROM user_visits WHERE dt >= '2023-10-21' AND dt <= '2023-10-27';BITMAP_ANDNOT(BITMAP, BITMAP):返回第一个Bitmap有而第二个Bitmap没有的元素(差集)。-- 计算今天新增的用户(今天有,昨天没有) WITH bitmap_today AS ( SELECT BITMAP_UNION(TO_BITMAP(user_id)) as bm FROM user_visits WHERE dt = '2023-10-27' ), bitmap_yesterday AS ( SELECT BITMAP_UNION(TO_BITMAP(user_id)) as bm FROM user_visits WHERE dt = '2023-10-26' ) SELECT BITMAP_COUNT(BITMAP_ANDNOT(t.bm, y.bm)) as new_users FROM bitmap_today t, bitmap_yesterday y;
3.3 包含性判断与输出函数
BITMAP_CONTAINS(BITMAP, INT):判断Bitmap中是否包含某个整数。适用于用户分群后的精准筛选。-- 找出高价值用户(UV>1000的页面)的访问记录 WITH high_value_users AS ( SELECT BITMAP_UNION(TO_BITMAP(user_id)) as bm FROM user_visits GROUP BY page HAVING BITMAP_UNION_COUNT(TO_BITMAP(user_id)) > 1000 ) SELECT v.* FROM user_visits v, high_value_users h WHERE BITMAP_CONTAINS(h.bm, v.user_id);BITMAP_TO_STRING(BITMAP):将Bitmap转换为逗号分隔的字符串。慎用,仅适用于调试或极小数据集,因为转换大Bitmap会爆内存。SELECT BITMAP_TO_STRING(BITMAP_FROM_ARRAY([1,5,10])); -- 输出 '1,5,10'
注意事项:性能与内存
BITMAP_TO_STRING是“性能杀手”:绝对不要在大数据量的Bitmap上使用这个函数。它的作用是将所有ID展开,完全违背了Bitmap压缩存储的初衷,极易导致内存溢出(OOM)。仅在调试时对极小结果使用。BITMAP_CONTAINS在Join中的使用:如上例所示,BITMAP_CONTAINS常用于过滤。需要关注执行计划,确保大Bitmap(如high_value_users)能够被广播(Broadcast)到存储原始数据的节点,避免Shuffle。
4. 高级应用:多维去重与状态组合分析
掌握了基础函数,我们可以解决更复杂的业务场景。Bitmap的真正威力在于其可聚合、可计算性,能够轻松处理多维度组合下的去重问题。
4.1 多维度去重(UV per Property)
业务中常需要计算“每个品类下的独立访客数”或“每个城市的不同设备数”。传统COUNT(DISTINCT)在多个维度组合时,计算复杂度成倍增长。而Bitmap可以优雅解决。
假设有表user_actions(item_id, city_id, user_id),我们需要计算每个商品在每个城市下的独立访问用户数。
SELECT item_id, city_id, BITMAP_UNION_COUNT(TO_BITMAP(user_id)) as unique_visitors FROM user_actions GROUP BY item_id, city_id;这个查询会在每个(item_id, city_id)分组内,本地构建user_id的Bitmap并聚合,效率远高于在每个分组内做哈希去重。
更进一步,如果我们已经有了按天聚合的Bitmap表daily_user_bitmap(dt, item_id, city_id, user_bitmap BITMAP),那么查询历史任意时间段的维度组合UV将变得极其高效:
-- 查询过去7天,商品A在北京市的独立访客数 SELECT BITMAP_UNION_COUNT(user_bitmap) as uv_7d FROM daily_user_bitmap WHERE dt >= '2023-10-21' AND dt <= '2023-10-27' AND item_id = 'A' AND city_id = 'Beijing';4.2 用户行为序列与状态留存分析
这是Bitmap集合运算的经典场景。例如,我们定义“连续活跃用户”为最近7天每天都访问的用户。
首先,我们需要一张表,记录每天活跃用户的Bitmap:
CREATE TABLE dau_bitmap ( dt DATE, active_user_bm BITMAP BITMAP_UNION ) AGGREGATE KEY(dt) DISTRIBUTED BY HASH(dt) BUCKETS 10; -- 每日从明细表更新 INSERT INTO dau_bitmap SELECT dt, BITMAP_UNION(TO_BITMAP(user_id)) FROM user_visit_detail GROUP BY dt;然后,计算连续7天活跃的用户:
WITH recent_days AS ( SELECT dt, active_user_bm FROM dau_bitmap WHERE dt >= '2023-10-21' AND dt <= '2023-10-27' ORDER BY dt ) -- 使用BITMAP_AND逐次求交集 SELECT BITMAP_COUNT( active_user_bm_21 & active_user_bm_22 & active_user_bm_23 & active_user_bm_24 & active_user_bm_25 & active_user_bm_26 & active_user_bm_27 ) as continuous_active_users_7d FROM ( SELECT MAX(CASE WHEN dt='2023-10-21' THEN active_user_bm END) as active_user_bm_21, MAX(CASE WHEN dt='2023-10-22' THEN active_user_bm END) as active_user_bm_22, ... -- 以此类推 MAX(CASE WHEN dt='2023-10-27' THEN active_user_bm END) as active_user_bm_27 FROM recent_days ) t;这个查询通过行转列(Pivot)将7天的Bitmap变成7列,然后使用&(BITMAP_AND的运算符形式)进行连续求交。虽然SQL写起来有点冗长,但实际计算发生在Bitmap的压缩数据上,速度非常快。
4.3 与其他聚合函数的协同
Bitmap可以和其他聚合结果组合,进行更深入的分析。例如,计算人均访问次数(PV/UV)。
SELECT dt, COUNT(*) as total_pv, -- 总访问次数 BITMAP_UNION_COUNT(TO_BITMAP(user_id)) as uv, -- 去重用户数 COUNT(*) / BITMAP_UNION_COUNT(TO_BITMAP(user_id)) as avg_pv_per_user -- 人均PV FROM user_visit_detail GROUP BY dt;这里,COUNT(*)和BITMAP_UNION_COUNT在同一个聚合过程中并行计算,效率很高。
5. 性能调优、问题排查与最佳实践
再好的工具,用不好也会事倍功半。以下是我在项目中积累的关于Bitmap性能调优和问题排查的经验。
5.1 常见性能瓶颈与优化策略
数据分布倾斜:如果用户ID本身分布不均(例如,某些超级用户ID产生海量记录),在构建Bitmap的
TO_BITMAP阶段可能产生数据倾斜。可以通过观察BE节点内存监控,如果某个节点内存远高于其他节点,可能存在倾斜。- 优化:检查
user_id的分布。如果ID是连续的或可分段,可以考虑在DISTRIBUTED BY语句中增加一个随机桶列,或者使用user_id本身进行分桶,但需确保桶数足够多(如>=10)。
- 优化:检查
Bitmap聚合内存压力:在最终聚合阶段(
BITMAP_UNION),如果全局去重基数极大(例如数亿),合并Bitmap会消耗较多内存。- 优化:调整BE的
bitmap_memory_limit参数(在be.conf中),增加Bitmap操作可用的内存上限。同时,确保查询有合理的LIMIT或有效的过滤条件,避免全表扫描构建超大Bitmap。
- 优化:调整BE的
即席查询响应慢:对没有Bitmap预聚合的明细表进行多日去重查询,即使使用Bitmap函数,也需要扫描大量数据并现场构建Bitmap。
- 优化:这是架构问题。必须建立预聚合层。根据业务查询模式,建立按小时、天、周等不同粒度,并包含关键维度(如产品、渠道)的Bitmap聚合表。让95%的查询命中聚合表。
5.2 典型错误与问题排查
错误:
Invalid type或Cannot cast- 场景:
TO_BITMAP函数报错。 - 原因:
TO_BITMAP只接受INT或BIGINT类型的列。如果你的用户ID是字符串(如UUID),直接使用会报错。 - 排查:
-- 1. 确认字段类型 DESCRIBE table_name; -- 2. 如果是字符串,需要先哈希为整数(有碰撞风险,需评估) SELECT BITMAP_UNION_COUNT(TO_BITMAP(CRC32(user_id_string))) FROM table_name; -- 或者,如果字符串本身是数字,可以转换 SELECT BITMAP_UNION_COUNT(TO_BITMAP(CAST(user_id_string AS BIGINT))) FROM table_name; - 根本解决:在设计表时,尽量让需要去重的标识字段使用
INT/BIGINT类型。如果源数据是字符串,应在ETL过程中将其映射为整数。
- 场景:
错误:查询内存超限(OOM)
- 场景:查询复杂,涉及多个大Bitmap的集合运算。
- 排查:查看FE/BE的日志,确认错误是否发生在
BITMAP_UNION或BITMAP_TO_STRING阶段。 - 解决:
- 避免使用
BITMAP_TO_STRING。 - 尝试分而治之:将复杂的多日交集/并集查询,拆分成多个子查询,逐步缩小Bitmap大小。
- 增加BE内存,并调整
query_mem_limit和exec_mem_limit参数。 - 检查是否存在数据倾斜,优化数据分布。
- 避免使用
问题:Bitmap精度与哈希碰撞
- 场景:使用哈希函数(如
CRC32)将字符串转为整数后使用Bitmap,结果可能与COUNT(DISTINCT)有细微差异。 - 原因:
CRC32是32位哈希,存在理论上的碰撞概率(两个不同的字符串哈希到同一个整数)。 - 评估:对于亿级以下的基数统计,CRC32碰撞概率极低,通常可以接受。如果要求绝对精确,可以考虑使用
BIGINT类型的哈希函数(如HASH64),或使用BITMAP类型存储原始字符串的哈希值(但Doris Bitmap原生支持整数)。
- 场景:使用哈希函数(如
5.3 最佳实践清单
根据我的项目经验,总结出以下 checklist,能帮你避开大多数坑:
设计阶段:
- [ ]标识字段整形化:将需要去重的业务ID(用户、设备、订单)设计为
BIGINT类型。 - [ ]优先使用聚合模型:对于固定维度的去重指标,建表时直接使用
AGGREGATE KEY和BITMAP列。 - [ ]合理分桶:根据数据量和查询模式,选择高基数列(如
user_id)或常用过滤列进行分桶,避免数据倾斜。
- [ ]标识字段整形化:将需要去重的业务ID(用户、设备、订单)设计为
开发阶段:
- [ ]避免
BITMAP_TO_STRING:除非调试,否则不要在生产查询中使用。 - [ ]善用
BITMAP_FROM_ARRAY:在数据补录或手动插入时,这是构建测试数据的好工具。 - [ ]
BITMAP_UNION_COUNT是首选:绝大多数去重场景,直接使用这个聚合函数。 - [ ]复杂集合运算先聚合:先按最小粒度聚合出Bitmap,再进行多Bitmap的AND/OR运算,性能远优于在明细数据上直接JOIN。
- [ ]避免
运维阶段:
- [ ]监控Bitmap内存:关注BE节点的
bitmap_memory_usage指标。 - [ ]建立预聚合链路:将Bitmap预聚合作为数仓DWD层到DWS层(轻度汇总层)的标准加工步骤。
- [ ]查询引导:通过物化视图或查询路由,将业务查询引导至Bitmap聚合表。
- [ ]监控Bitmap内存:关注BE节点的
从我自己的经验来看,引入Bitmap最大的收益不是单个查询变快,而是它为整个数据分析体系提供了一种可累加、可计算的“用户资产”表达方式。一旦将每日的用户集合沉淀为Bitmap,后续计算留存、新增、交叉活跃等复杂指标就变成了简单的集合运算,成本极低且实时性极高。这背后是从“统计结果”到“存储集合”的思维转变,也是构建高效实时分析系统的关键一步。