Doris Bitmap 索引实战:高基数精确去重与用户行为分析优化
Doris Bitmap 索引实战:高基数精确去重与用户行为分析优化
最近在做一个用户行为分析平台,需要实时统计 UV(独立访客)、留存、漏斗等指标。一开始图省事,直接用 COUNT(DISTINCT user_id),结果 5 亿条日志的查询直接超时。运维建议用 HLL(HyperLogLog)近似去重,但产品经理无法接受 2% 的误差。最后在 Doris 里用 Bitmap 解决了问题——精确去重、性能还快。这篇文章把完整的实战过程记录下来,包括建表、导入、查询和性能对比。
一、问题场景
用户行为日志表结构大致如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| event_time | DATETIME | 事件时间 |
| dt | DATE | 事件日期(分区键) |
| page_id | INT | 页面 ID |
| user_id | BIGINT | 用户 ID |
典型查询需求:
- 实时 UV:统计某天各页面的独立访客数。
- 次日留存:昨天访问过某页面的用户,今天又访问了该页面的数量。
- 7 日留存:一周内连续活跃用户数。
传统方案:
COUNT(DISTINCT user_id):精确但性能差,大数据量下内存消耗巨大。- HLL 近似去重:性能好、内存固定,但有 1-2% 误差,且无法直接做交集运算(留存分析需要交集)。
Doris 提供了 Bitmap 数据类型和 Bitmap 索引,能够同时满足精确、高性能和集合运算三个需求。
二、Bitmap 原理简述
Bitmap(位图)将每个 user_id 映射到一个二进制位,值为 1 表示该用户出现过,0 表示未出现。例如用户集合 {1, 3, 5} 的 Bitmap 可以表示为 101010...。
核心优势:
- 精确:每个用户占一个 bit,不丢失信息。
- 压缩:Doris 使用 Roaring Bitmap 算法,对稀疏和稠密位图都有很好的压缩效果。
- 集合运算:Bitwise AND/OR/XOR 天然支持交集、并集、差集,非常适合留存、漏斗等分析。
Doris 中 Bitmap 有两种用法:
- Bitmap 索引:建在普通列上的位图索引,用于加速等值过滤,不是本文重点。
- Bitmap 数据类型:列类型为
BITMAP,配合聚合模型BITMAP_UNION聚合,可以高效存储和查询精确去重结果。
本文聚焦于第 2 种——Bitmap 数据类型在精确去重和集合运算中的应用。
三、环境准备与表结构设计
验证环境:
- Doris 2.0.3(3 节点 FE + 3 节点 BE,单机 Docker 亦可复现)
- 8 核 16G 内存
- JDK 17
我们设计三张表来对比不同方案:
user_behavior_detail:普通明细表,存储每条日志,用于COUNT(DISTINCT)。user_behavior_hll:聚合模型,存储 HLL 类型列,用于近似去重。user_behavior_bitmap:聚合模型,存储 BITMAP 类型列,用于精确去重和集合运算。
3.1 建表语句
1 | -- 1. 普通明细表(用于 COUNT DISTINCT 基准) |
说明:
- 聚合模型使用
AGGREGATE KEY(dt, page_id),相同dt + page_id的行会自动合并。 - HLL 列类型为
HLL,聚合方式HLL_UNION。 - Bitmap 列类型为
BITMAP,聚合方式BITMAP_UNION。
3.2 导入测试数据
使用 Doris 内置的 numbers 表生成 500 万条模拟行为日志,覆盖 100 万用户,两个日期(2026-08-13 和 2026-08-14),10 个页面。
1 | -- 生成明细数据 |
验证数据量:
1 | SELECT dt, COUNT(*) AS total_rows, COUNT(DISTINCT user_id) AS total_users |
输出示例:
| dt | total_rows | total_users |
|---|---|---|
| 2026-08-13 | 2500000 | 999999 |
| 2026-08-14 | 2500000 | 999999 |
将明细数据分别导入 HLL 表和 Bitmap 表:
1 | -- 导入 HLL 表(自动按 key 聚合 HLL 值) |
导入完成后,每张聚合表中每天每个页面只有一行,共 20 行(2 天 × 10 页面)。
四、场景一:高基数 COUNT DISTINCT 优化
统计 2026-08-14 各页面的 UV。
方案 A:明细表 COUNT(DISTINCT)
1 | SELECT page_id, COUNT(DISTINCT user_id) AS uv |
方案 B:HLL 近似去重
1 | SELECT page_id, HLL_CARDINALITY(user_hll) AS uv_approx |
方案 C:Bitmap 精确去重
1 | SELECT page_id, BITMAP_COUNT(user_bitmap) AS uv_exact |
4.1 精度对比
以 page_id=1 为例,三种方案的结果:
| 方案 | UV 结果 | 误差 |
|---|---|---|
| COUNT(DISTINCT) | 100000 | 0% |
| HLL | 101237 | +1.2% |
| Bitmap | 100000 | 0% |
HLL 存在固定误差,Bitmap 与明细完全一致,精确无误。
4.2 性能对比
在 500 万明细数据规模下(单机 Docker):
| 方案 | 查询耗时 |
|---|---|
| COUNT(DISTINCT) | 820 ms |
| HLL | 12 ms |
| Bitmap | 15 ms |
Bitmap 和 HLL 性能接近,都远快于传统 COUNT(DISTINCT)。随着数据量增长,COUNT(DISTINCT) 的耗时呈线性甚至超线性增长,而聚合模型的 Bitmap 查询几乎不受影响。
五、场景二:用户留存分析
留存分析需要计算两个日期活跃用户集合的交集。传统明细方案需要 JOIN 或 COUNT(DISTINCT CASE WHEN ...),Bitmap 则直接用 BITMAP_AND。
以 page_id=1 为例,计算 2026-08-13 访问过、2026-08-14 又访问的用户数(次日留存)。
5.1 Bitmap 交集计算
1 | SELECT BITMAP_COUNT(BITMAP_AND(a.user_bitmap, b.user_bitmap)) AS retention_users |
输出:
1 | retention_users |
5.2 整体留存(不区分页面)
如果需要计算全平台留存,先按天聚合所有页面的 Bitmap:
1 | WITH day_users AS ( |
同样逻辑可以扩展到 7 日留存、漏斗分析等复杂集合运算。
5.3 对比传统方案
传统明细表计算次日留存:
1 | SELECT COUNT(DISTINCT a.user_id) AS retention_users |
该查询需要扫描两个大表并做 JOIN,在 500 万数据下耗时约 2.8 秒,而 Bitmap 方案仅需 30 ms 左右。数据量越大,差距越明显。
六、生产环境使用建议与注意事项
6.1 适用场景
- 精确去重:UV、活跃用户数等需要精确值的指标。
- 集合运算:留存、漏斗、用户分群等需要交集、并集分析的场景。
- 数据可聚合:用户 ID 可以映射
