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

典型查询需求:

  1. 实时 UV:统计某天各页面的独立访客数。
  2. 次日留存:昨天访问过某页面的用户,今天又访问了该页面的数量。
  3. 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 有两种用法:

  1. Bitmap 索引:建在普通列上的位图索引,用于加速等值过滤,不是本文重点。
  2. Bitmap 数据类型:列类型为 BITMAP,配合聚合模型 BITMAP_UNION 聚合,可以高效存储和查询精确去重结果。

本文聚焦于第 2 种——Bitmap 数据类型在精确去重和集合运算中的应用。

三、环境准备与表结构设计

验证环境

  • Doris 2.0.3(3 节点 FE + 3 节点 BE,单机 Docker 亦可复现)
  • 8 核 16G 内存
  • JDK 17

我们设计三张表来对比不同方案:

  1. user_behavior_detail:普通明细表,存储每条日志,用于 COUNT(DISTINCT)
  2. user_behavior_hll:聚合模型,存储 HLL 类型列,用于近似去重。
  3. user_behavior_bitmap:聚合模型,存储 BITMAP 类型列,用于精确去重和集合运算。

3.1 建表语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- 1. 普通明细表(用于 COUNT DISTINCT 基准)
CREATE TABLE user_behavior_detail (
event_time DATETIME,
dt DATE,
page_id INT,
user_id BIGINT
) ENGINE=OLAP
DUPLICATE KEY(dt, page_id)
DISTRIBUTED BY HASH(page_id) BUCKETS 5
PROPERTIES(
"replication_num" = "1"
);

-- 2. HLL 聚合表(近似去重)
CREATE TABLE user_behavior_hll (
dt DATE,
page_id INT,
user_hll HLL HLL_UNION
) ENGINE=OLAP
AGGREGATE KEY(dt, page_id)
DISTRIBUTED BY HASH(page_id) BUCKETS 5
PROPERTIES(
"replication_num" = "1"
);

-- 3. Bitmap 聚合表(精确去重 + 集合运算)
CREATE TABLE user_behavior_bitmap (
dt DATE,
page_id INT,
user_bitmap BITMAP BITMAP_UNION
) ENGINE=OLAP
AGGREGATE KEY(dt, page_id)
DISTRIBUTED BY HASH(page_id) BUCKETS 5
PROPERTIES(
"replication_num" = "1"
);

说明

  • 聚合模型使用 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
2
3
4
5
6
7
8
-- 生成明细数据
INSERT INTO user_behavior_detail
SELECT
TIMESTAMP('2026-08-13 00:00:00') + INTERVAL (number % 86400) SECOND AS event_time,
DATE('2026-08-13') + INTERVAL (number % 2) DAY AS dt,
(number % 10) + 1 AS page_id,
number % 1000000 AS user_id
FROM numbers("number" = "5000000");

验证数据量

1
2
3
SELECT dt, COUNT(*) AS total_rows, COUNT(DISTINCT user_id) AS total_users
FROM user_behavior_detail
GROUP BY dt;

输出示例:

dt total_rows total_users
2026-08-13 2500000 999999
2026-08-14 2500000 999999

将明细数据分别导入 HLL 表和 Bitmap 表:

1
2
3
4
5
6
7
8
9
-- 导入 HLL 表(自动按 key 聚合 HLL 值)
INSERT INTO user_behavior_hll (dt, page_id, user_hll)
SELECT dt, page_id, HLL_HASH(user_id)
FROM user_behavior_detail;

-- 导入 Bitmap 表(自动按 key 聚合 Bitmap 值)
INSERT INTO user_behavior_bitmap (dt, page_id, user_bitmap)
SELECT dt, page_id, TO_BITMAP(user_id)
FROM user_behavior_detail;

导入完成后,每张聚合表中每天每个页面只有一行,共 20 行(2 天 × 10 页面)。

四、场景一:高基数 COUNT DISTINCT 优化

统计 2026-08-14 各页面的 UV。

方案 A:明细表 COUNT(DISTINCT)

1
2
3
4
5
SELECT page_id, COUNT(DISTINCT user_id) AS uv
FROM user_behavior_detail
WHERE dt = '2026-08-14'
GROUP BY page_id
ORDER BY page_id;

方案 B:HLL 近似去重

1
2
3
4
SELECT page_id, HLL_CARDINALITY(user_hll) AS uv_approx
FROM user_behavior_hll
WHERE dt = '2026-08-14'
ORDER BY page_id;

方案 C:Bitmap 精确去重

1
2
3
4
SELECT page_id, BITMAP_COUNT(user_bitmap) AS uv_exact
FROM user_behavior_bitmap
WHERE dt = '2026-08-14'
ORDER BY page_id;

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
2
3
4
5
SELECT BITMAP_COUNT(BITMAP_AND(a.user_bitmap, b.user_bitmap)) AS retention_users
FROM
(SELECT user_bitmap FROM user_behavior_bitmap WHERE dt='2026-08-13' AND page_id=1) a
CROSS JOIN
(SELECT user_bitmap FROM user_behavior_bitmap WHERE dt='2026-08-14' AND page_id=1) b;

输出:

1
2
3
retention_users
----------------
51230

5.2 整体留存(不区分页面)

如果需要计算全平台留存,先按天聚合所有页面的 Bitmap:

1
2
3
4
5
6
7
8
9
10
11
WITH day_users AS (
SELECT dt, BITMAP_UNION(user_bitmap) AS total_users
FROM user_behavior_bitmap
WHERE dt IN ('2026-08-13', '2026-08-14')
GROUP BY dt
)
SELECT BITMAP_COUNT(BITMAP_AND(a.total_users, b.total_users)) AS retention_users
FROM
(SELECT total_users FROM day_users WHERE dt='2026-08-13') a
CROSS JOIN
(SELECT total_users FROM day_users WHERE dt='2026-08-14') b;

同样逻辑可以扩展到 7 日留存、漏斗分析等复杂集合运算。

5.3 对比传统方案

传统明细表计算次日留存:

1
2
3
4
5
6
7
8
SELECT COUNT(DISTINCT a.user_id) AS retention_users
FROM user_behavior_detail a
JOIN user_behavior_detail b
ON a.user_id = b.user_id
AND a.dt = '2026-08-13'
AND b.dt = '2026-08-14'
AND a.page_id = 1
AND b.page_id = 1;

该查询需要扫描两个大表并做 JOIN,在 500 万数据下耗时约 2.8 秒,而 Bitmap 方案仅需 30 ms 左右。数据量越大,差距越明显。

六、生产环境使用建议与注意事项

6.1 适用场景

  • 精确去重:UV、活跃用户数等需要精确值的指标。
  • 集合运算:留存、漏斗、用户分群等需要交集、并集分析的场景。
  • 数据可聚合:用户 ID 可以映射