ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ClickHouse Bitmap实战:从RoaringBitmap原理到UV统计与标签圈选优化

ClickHouse Bitmap实战:从RoaringBitmap原理到UV统计与标签圈选优化 做数据分析这一行尤其是流量分析、用户画像、广告投放的人群圈选基本都逃不开ClickHouse的bitmap。我第一次接触它是在做留存分析的时候用户数量千万级每天要算多维度的交集和差集用传统COUNT(DISTINCT)跑一次要连续吃CPU好几秒钟换了一批RoaringBitmap之后同样的查询变成了几十毫秒级而且内存占用还低了一个数量级。这篇文章我结合自己实际项目里的用法把 ClickHouse 里 bitmap 的建表、写入、常用函数、性能优化和踩坑经验一次性说清楚。不管你是刚上手还是已经用过一段时间但对其中的一些细节还比较模糊里面记录的 SQL 和排查思路都可以直接拿去做参考。项目里的真实情况往往比文档更啰嗦我尽量把背后的原理也讲明白这样你就知道为什么这么写以及遇到性能瓶颈时该从哪里下手。1. ClickHouse bitmap能做什么场景与底层实现1.1 什么场景真正需要bitmap先泼一盆冷水如果一张表就几万行或者你只需要统计一个总数完全没必要用 bitmap普通COUNT(DISTINCT)就够了。bitmap 的价值体现在“海量 ID 的多集合运算”上。举三个我实际做过的例子第一个是 UV 分析。每天有上亿次访问日志需要统计某时间段内的独立访客数而且要按渠道、地区、设备维度交叉过滤。传统先SELECT DISTINCT user_id再计数对内存和 CPU 的消耗都很大用 bitmap 可以先把每个用户放到一个位里然后直接按条件对多天的 bitmap 做ORbitmapCardinality一下就出来了。第二个是用户行为路径分析。比如“看过A页面但在B页面下单的用户”本质上是两个收藏集或行为集合的交集。bitmap 天然支持AND、OR、AND NOT这些操作计算集合关系比 SQL 里的IN嵌套快几个量级。第三个是标签圈选。用户画像系统里每个标签对应一个 bitmap圈人时把多个标签做AND或OR然后bitmapToArray输出用户列表或者直接传给下游推送系统。这类场景下 bitmap 几乎是标准答案。概括一下当你的集合元素是数值型 ID一般用 UInt32 或 UInt64集合规模在百万到十亿级并且频繁进行交、并、差运算时就是在 ClickHouse 里使用 bitmap 的合适信号。1.2 底层为什么选用RoaringBitmapClickHouse 里的 bitmap 并不是自研的它整合了经典的RoaringBitmap算法。传统上如果要表示一个包含 0 到 40 亿的整数集合最朴素的做法是每个整数占用一个 bit那需要 512MB 空间。这个空间对于很多单机服务来说还可以接受但一旦涉及压缩、序列化和网络传输就会非常笨重。RoaringBitmap 的高明之处在于它把 32 位整数拆成高 16 位和低 16 位两部分然后根据桶内的数据密度自动选择不同的容器当低16位的数量很少我印象中是 4096 个以下时用有序的ArrayContainer数组存储数量超过阈值时转成BitmapContainer按位图存储此外还有RunContainer专门处理连续整数较多的情况比如 1、2、3、4…10000一个 Run 就能表示。这种自适应策略让它在稀疏和稠密数据下都保持很好的性能这也是 ClickHouse 选它的最主要原因。另外RoaringBitmap 还做了内存对齐和 SSE 指令优化在真实查询里大部分集合的交并运算都跑在 CPU 缓存内速度自然快。1.3 ClickHouse 里的bitmap类型在 ClickHouse 建表时bitmap 并不是像UInt32那样的基础字段而是以聚合函数类型出现的。最常用的是AggregateFunction(groupBitmap, UInt32)底层对应 32 位的 RoaringBitmap。如果业务里的用户 ID 已经超过 40 亿或者你想存任意 UInt64 的整数就要用AggregateFunction(groupBitmap, UInt64)底层是 64 位版本RoaringBitmap 的实现叫 Roaring64Map。注意这里的groupBitmap是聚合函数名不是一张表名。你也可以用groupBitmapAnd、groupBitmapOr做聚合但建表字段类型前缀必须是AggregateFunction(groupBitmap, 类型)。用CREATE TABLE定义字段的时候它看起来如下CREATE TABLE user_tag_bitmap ( tag_id UInt32, user_bitmap AggregateFunction(groupBitmap, UInt64) ) ENGINE MergeTree ORDER BY tag_id;我在项目里见有些人会建一张明细表然后在查询时临时通过groupBitmapState聚合出 bitmap这其实没有发挥出 bitmap 的最大优势。真正高效的用法是在写入侧就维护这种物化好的聚合字段后面会专门讲。2. 从零搭建bitmap建表与数据写入2.1 标准的bitmap建表方式有两种常见的建表思路第一种是“行为明细 物化字段”的方式适合日志表第二种是“聚合结果表和明细表分离”的方式适合标签库、画像系统。如果直接在明细表上维护 bitmap可以这样建CREATE TABLE daily_user_bitmap ( event_date Date, channel String, users AggregateFunction(groupBitmap, UInt64) ) ENGINE MergeTree PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, channel);这里用event_date作为分区键完全没有问题但一定要清楚bitmap 字段不能作为分区键或排序键。聚合函数类型字段只是作为列存储不参与索引排序。第二种方式是先建一个包含所有明细的表再用普通 UInt64 存储 user_id然后通过CREATE MATERIALIZED VIEW或者INSERT INTO ... SELECT ... GROUP BY把数据聚合到目标表。实际操作中物化视图比较“涨姿势”我建议先掌握手动写入再上自动物化。2.2 普通数组转bitmap并写入把已知的用户 ID 数组变成 bitmap 字段最简单的是arrayToBitmap函数。假设你有一张用户标签表结构如下CREATE TABLE user_tags_source ( tag_id UInt32, user_id UInt64 ) ENGINE MergeTree ORDER BY (tag_id, user_id);要把某个 tag 下的所有 user_id 聚合成 bitmap 插入到聚合表可以使用groupBitmapState聚合函数INSERT INTO user_tag_bitmap SELECT tag_id, groupBitmapState(user_id) AS user_bitmap FROM user_tags_source GROUP BY tag_id;这里有个重要概念groupBitmapState返回的是聚合函数中间状态写入字段时必须用State后缀而不是直接用groupBitmap。groupBitmap会直接返回基数groupBitmapState才会返回一个 bitmap 串。很多新手在这一步就卡住了报错说类型不匹配。如果你有一个已经去重的数组也可以直接用INSERT INTO user_tag_bitmap SELECT 123 AS tag_id, arrayToBitmap([1001, 1002, 1003, 2000]) AS user_bitmap;实测中arrayToBitmap对几百万长度的数组会占用很大内存尤其是要确保数组里的元素本身有序时性能会比无序稍好一点。但实际上无论有序无序它都能处理只不过元素要能转成你指定的整数类型。2.3 用groupBitmap做增量更新很多场景不是一次性全量写入而是每天有新用户进来。ClickHouse 的 MergeTree 引擎本身不支持对单行 bitmap 字段做原地更新所以常用方式是把新的聚合结果再次插入然后在查询时对同一个 key 的多行 bitmap 做OR。举个例子如果user_tag_bitmap里同一tag_id已有昨天的 bitmap今天又插入了一行今天的 bitmapINSERT INTO user_tag_bitmap SELECT tag_id, groupBitmapState(uid) FROM today_users GROUP BY tag_id;两个不同分区的数据会存储为两行。查询时要用groupBitmapMerge或者bitmapOr对他们进行合并SELECT tag_id, bitmapCardinality(groupBitmapMerge(user_bitmap)) AS uv FROM user_tag_bitmap GROUP BY tag_id;groupBitmapMerge会读取该列所有行本组内的 bitmap 状态并合并成一个新的 bitmap注意它只能在聚合查询里使用。如果你只是希望定时把历史 新数据合并到一行可以再写一个轻量任务将合并结果写到一个新分区。这种方法在技术圈里有个常用的昵称bitmap旋转。意思是你不能原地更新只能把新旧 bitmap 进行“轮换叠加”。我在生产环境就是这么做的按天分区同时保留一个全量的 bitmap 表每天凌晨用INSERT SELECT把前一天的 UV 和数据写入累积表。3. 常用操作函数与组合玩法3.1 基础信息函数基数、是否为空、转换数组ClickHouse 提供了非常完整的 bitmap 函数绝大多数文档都能查到但真正组合起来是有讲究的。SELECT bitmapCardinality(users) AS uv, -- bitmap 中元素个数 bitmapMin(users) AS min_id, -- 最小元素 bitmapMax(users) AS max_id, -- 最大元素 bitmapAndCardinality(users, users) AS duplicate_count, -- 两个 bitmap 的交集个数 bitmapToArray(users) AS user_list -- 转换为数组 FROM user_tag_bitmap;bitmapCardinality返回去重后的数量等价于 COUNT(DISTINCT) 的轻量版速度更快。bitmapEmpty判断 bitmap 是否为空常用于数据完整性校验。bitmapToArray把 bitmap 转回数组。注意当基数极大时这个操作会产生大量内存一般在圈选出几千人做下游触达时才用。bitmapRangeCardinality统计某个区间 [range_start, range_end) 内的数量适合做年龄/等级分层不需要把所有数字都展开。用个生活类比bitmap 就像一个超大的座位图每个座位有一个编号有人坐就把座位号位图置1。bitmapToArray就是扫一遍把所有有人的座位号列出来。平时查人数只需要数位图上有多少位是1根本不用去扫每个名字。3.2 集合运算AND、OR、XOR、AND NOT这是 bitmap 的核心战斗力也是很多 SQL 大任务从“跑不动”变“秒出”的关键。常用函数如下bitmapAnd(a, b) -- 交集 bitmapOr(a, b) -- 并集 bitmapXor(a, b) -- 异或属于a或b但不同时属于两者 bitmapAndnot(a, b) -- 差集属于a但不属于b bitmapAndCardinality(a, b) -- 交集数量 bitmapOrCardinality(a, b) -- 并集数量 bitmapXorCardinality(a, b) -- 异或数量 bitmapAndnotCardinality(a, b) -- 差集数量直接上例子。假设有两张标签 bitmap 表-- 看过首页的用户 SELECT tag_id, user_bitmap FROM user_tag_bitmap WHERE tag_id pv_home; -- 成功下单的用户 SELECT tag_id, user_bitmap FROM user_tag_bitmap WHERE tag_id order_done;要计算“看过首页但没有下单的用户”SELECT bitmapAndnotCardinality( (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id pv_home), (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id order_done) ) AS browse_not_order;由于 ClickHouse 里子查询标量必须是单行单值所以这里子查询返回一个聚合类型字段是允许的。你还可以把它放到 JOIN 中处理。更常见的做法是先把两张表 JOIN 到一行SELECT bitmapAndCardinality(a.user_bitmap, b.user_bitmap) AS common_uv FROM (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id pv_home) AS a, (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id order_done) AS b;实测需要注意两个 bitmap 的底层类型必须完全一致比如都是groupBitmap(UInt64)否则某些版本会报类型不匹配。我一般建表时强制所有 bitmap 字段都用 UInt64省得后面来回转换。3.3 判断与包含关系contains、hasAny与子集除了整体集合运算有时需要判断一个单独 ID 是否在 bitmap 中比如实时风控里判断“这个用户是否是VIP”。可以用bitmapContainsSELECT bitmapContains(user_bitmap, 123456) AS is_vip FROM user_tag_bitmap WHERE tag_id vip_users;要判断两个 set 是否有交集用bitmapHasAny判断一个 bitmap 是否完全包含另一个也就是子集关系用bitmapHasAll。这两个函数在标签圈选里很常用比如“包含手机品牌A标签并且包含付费意愿标签的人”可以简化为SELECT bitmapHasAll(v.brand, v.pay_intent) AS matched FROM ...注意bitmapHasAll(a, b)是判断 b 是否全部出现在 a 中别搞反了。另外bitmapSubsetInRange可以提取某个范围内的元素这个在统计年龄段分布时很好用。比如要拿到用户 ID 在 1000 到 9999 之间的所有用户可以SELECT bitmapSubsetInRange(user_bitmap, 1000, 10000) FROM user_tag_bitmap WHERE tag_id all_users;返回结果还是 bitmap需要后再接bitmapCardinality计数。3.4 位偏移与位图“旋转”的歧义处理有些朋友在搜索热词里看到“bitmap旋转”会困惑其实在 ClickHouse 的世界里并没有“旋转”这种内置函数。如果你遇到这个概念多半有两种来源一是前面提到的“bitmap更新轮换”玩法由于不可变行而被迫用“新旧分区叠加合并”的方式我习惯叫它“位图轮转”。另一种是开发人员从普通位图库那里带过来的概念比如把某个 bitmap 进行左右位移或环形旋转。但 ClickHouse 的 RoaringBitmap 面向的是集合语义不是普通 bit 位运算库不支持对整个 bitmap 做位移。如果你需要对整数集合进行“偏移映射”例如把用户 ID 统一加一个偏移量只能先bitmapToArray再逐元素操作但这种方式极其浪费。因此实际生产里不要试图对 bitmap 做位旋转应把它变成整型数组转换或者在建 bitmap 时提前规划好编码值。4. 典型实战UV统计、留存与标签圈选4.1 每日UV统计的bitmap方案以访问日志表为例传统 SQLSELECT event_date, COUNT(DISTINCT user_id) FROM access_log WHERE event_date BETWEEN 2024-01-01 AND 2024-01-07 GROUP BY event_date;如果每天 UV 千万级别这个查询会占用大量内存而且当天只有一个 distinct 计算并发一多容易占满内存。用 bitmap 之后我们可以建一张“日聚合 bitmap 表”每行存一天的完整活跃用户集合CREATE TABLE daily_active_bitmap ( event_date Date, users AggregateFunction(groupBitmap, UInt64) ) ENGINE MergeTree PARTITION BY toYYYYMM(event_date) ORDER BY event_date; INSERT INTO daily_active_bitmap SELECT event_date, groupBitmapState(user_id) FROM access_log GROUP BY event_date;写入完成后查询就变成了SELECT bitmapCardinality(users) AS uv FROM daily_active_bitmap WHERE event_date 2024-01-01;这时候 ClickHouse 不再逐行去重只需要加载该分区的一列反序列化出一个 bitmap统计里面置 1 的 bit 数性能非常高。如果想统计 7 天累计 UV直接对七天的 bitmap 做 ORSELECT bitmapCardinality( groupBitmapMerge(users) ) AS weekly_uv FROM daily_active_bitmap WHERE event_date BETWEEN 2024-01-01 AND 2024-01-07;用groupBitmapMerge是对多行 bitmap 收拢合并的关键它的效果等于把多个 bitmap 按 OR 逻辑合成一个。4.2 留存用户与用户回流的差集计算留存率计算的本质是“第一天活跃且第N天活跃的用户数”。借助 daily_active_bitmap 表可以非常轻量地算出来SELECT bitmapAndCardinality( (SELECT users FROM daily_active_bitmap WHERE event_date 2024-01-01), (SELECT users FROM daily_active_bitmap WHERE event_date 2024-01-02) ) AS day1_remain一次性看多天留存可以提前把两张表 JOIN 成笛卡尔积但数据量不大时可以这么写SELECT a.event_date AS start_date, b.event_date AS retain_date, bitmapAndCardinality(a.users, b.users) AS retain_cnt FROM daily_active_bitmap AS a CROSS JOIN daily_active_bitmap AS b WHERE a.event_date BETWEEN 2024-01-01 AND 2024-01-07 AND b.event_date a.event_date INTERVAL 1 DAY这里 CROSS JOIN 之后每组只有两行不会有膨胀问题。你还可以继续使用bitmapAndnotCardinality找出“昨日活跃但今日流失”的用户这属于流失分析的范畴。这类查询如果要每天实时跑仍然建议建物化视图实时维护不要把整个明细表在查询时临时聚合。我已经踩过这种坑刚上线时觉得“物化视图太麻烦反正查询时只要 groupBitmapState 就行”结果凌晨大量报表同时跑直接拖垮了集群。后来改成提前聚合的 bitmap 表凌晨调度压力下降非常明显。4.3 标签圈选与交集人群输出标签系统场景下我会建一张“标签IDtag_id - bitmap 用户”的表类似前面user_tag_bitmap。圈选时先取对应标签的 bitmap再做集合运算。例如圈出“最近30天有登录记录且品类偏好为‘电子数码’但是否收货地为上海”的人群。假设三个条件分别保存为三个 tag_id那么 SQL 长这样SELECT bitmapToArray( bitmapAnd( (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id login_30d), bitmapAnd( (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id pref_digital), (SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id addr_shanghai) ) ) ) AS target_users;输出数组给下游使用。如果只是要数量就用bitmapAndCardinality。如果标签维度太多可以先把若干标签的数据合并成一个大 bitmap避免嵌套太深。嵌套层的基数大了以后对 SQL 的解析和优化也不友好建议将多个标签“预合并”或放入临时表CREATE TEMPORARY TABLE tmp_cond AS SELECT user_bitmap FROM user_tag_bitmap WHERE tag_id IN (login_30d, pref_digital)然后对这临时表做groupBitmapMerge形成一个大集合再和其他条件做运算。注意 TEMPORARY TABLE 默认内存表字段也支持聚合类型不过临时表只在当前会话有效。5. 性能优化与踩坑实录5.1 控制bitmap构建时的内存消耗groupBitmapState本质上是在聚合过程中构建 RoaringBitmap它的底层容器会随数据分布自适应。内存峰值其实是不可控的但可以通过两种方式降低第一保证进入 groupBitmap 的 ID 尽可能有序。因为 RoaringBitmap 的高16位分桶特性如果同一分区内的 ID 连续那么同一个桶内的数据会集中在同一个容器里如果随机分散每个桶都要初始化容器内存碎片多且压缩率差。最实用的方法是在明细表插入前按照 user_id 做一次排序。INSERT INTO daily_active_bitmap SELECT event_date, groupBitmapState(user_id) FROM ( SELECT event_date, user_id FROM access_log WHERE event_date 2024-01-01 ORDER BY user_id ) GROUP BY event_date;这个排序看似增加成本但对内存收益非常明显。我遇到过极端情况随机 ID 构建千万级 bitmap 需要大约 1.2GB 内存排序后再构建降到 600MB 左右而且速度更快。第二避免在明细表上反复做大规模 groupBitmapState。如果一个查询里既有明细过滤又要 groupBitmapState 聚合几亿行内存必然吃紧。正确姿势是把 “按天/按行为类型” 预先聚合成 bitmap 结果表后续分析在结果表上操作。5.2 物化视图让bitmap自动保持最新手动批量INSERT ... GROUP BY的问题是如果日志不断实时写入视图就不够实时。现在推荐用物化视图来维护 bitmap。先建一张明细表CREATE TABLE access_log ( event_time DateTime, user_id UInt64, event_type String ) ENGINE MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY (event_type, event_time);再建物化视图目标表为daily_active_bitmap_mvCREATE MATERIALIZED VIEW access_log_daily_bitmap_mv TO daily_active_bitmap AS SELECT toDate(event_time) AS event_date, groupBitmapState(user_id) AS users FROM access_log GROUP BY toDate(event_time);之后每当 access_log 插入数据时ClickHouse 会自动把增量数据按天聚合成 bitmap 状态写入目标表。由于是增量聚合并存储物化视图会维护多行同一天的数据查询时仍需要用groupBitmapMerge合并或定期做OPTIMIZE TABLE ... FINAL合并数据部分。物化视图有一个很容易忽略的坑它只能在插入时看到分区块无法感知更新或删除所以不适合频繁“修正”的历史数据场景。如果数据会重算我建议手动建一个替代方案用“先删除分区再重插”的方式。5.3 不能分区怎么处理很多人遇到“bitmap中有标记为已使用的未用簇”这种标题会把问题归到 ClickHouse 上但实际上这是文件系统层面对无效簇的报错和 ClickHouse bitmap 没有直接关系。更常见的 ClickHouse 问题是“bucket 无法分区”或者“bitmap 字段不能做 partition key”。如果你试图用AggregateFunction类型的字段作为 PARTITION BY 或 ORDER BY 字段建表会直接报错。解决办法并不难分区键必须用普通字段比如日期、渠道 ID。如果业务上确实想按“用户群编号”来分区建议将用户群编号单独存为一个普通 UInt32 字段并作为分区键CREATE TABLE user_group_bitmap ( group_id UInt32, day Date, users AggregateFunction(groupBitmap, UInt64) ) ENGINE MergeTree PARTITION BY (group_id, toYYYYMM(day)) ORDER BY (group_id, day);注意聚合类型字段只参与计算不参与索引排序但你仍可以通过普通字段 group_id、day 快速定位分区。另外如果你建表时用了bitmap类型某些客户端工具导出 SQL 时可能会识别成异常字段这是工具不兼容和服务器无关。建议直接写 SQL 建表不要依赖可视化建表。5.4 类型不一致、基数溢出与其他常见坑类型不一致两张表的 bitmap 字段一个groupBitmap(UInt32)一个groupBitmap(UInt64)直接bitmapAnd会报错。统一使用UInt64是成本最低的方案。基数溢出普通 RoaringBitmap 只支持 32 位整数超过2^32 - 1会丢失或报错。如果业务主键是自增字段短期内可能不超但一旦超过就必须改用groupBitmap(UInt64)。bitmapToArray超大结果千万不要对全量用户 bitmap 直接执行bitmapToArray。之前我在测试全量几千万用户时这么干过直接 OOM。可以先bitmapSubsetInRange截断范围或者用bitmapToArray输出到 Null 引擎也不行必须限制范围。bitmapAnd与arrayIntersect的选择如果数据量小arrayIntersect也能算交集但大集合下性能会指数下降。判断标准是集合规模超过万级就直接用 bitmap。运算后结果无法继续读写bitmapAnd返回的是临时 bitmap可以再交给其他 bitmap 函数但不能直接从客户端导出为原始文件因为它是二进制状态。需要导出时先用bitmapToArray转成文本数组或者用toTypeName确认类型。5.5 判断物化视图与查询之间的一致性问题由于 ClickHouse 的物化视图是异步批量处理准确说是插入触发不会阻塞主表写入你可能会看到目标表数据略微滞后。尤其是高频实时写入时读取 bitmap 聚合表看到的基数可能比明细表小一点。解决办法是在业务允许范围内接受秒级延迟如果必须强一致就暂时不要用物化视图而是通过实时流或计划任务定期INSERT SELECT。另外物化视图GROUP BY toDate(event_time)在跨天时可能会将同一分钟的数据拆到不同分区这没问题但如果你按小时做物化而查询按天合并记得用groupBitmapMerge合并多个小时份的 bitmap。6. 一些写代码之外的体会我自己在实际维护 ClickHouse bitmap 任务的过程中最大的体会是bitmap 让人上瘾但不能滥用。它在海量 ID 集合运算上很能打可一旦你把不该转 bitmap 的字段也转了比如用字符串做 ID那就很别扭。因为 bitmap 只支持整数类型字符串 ID 必须映射成整数映射过程本身就是个额外状态。如果业务 ID 是固定的 UUID最佳实践是维护一张“UUID-自增 ID”的字典表再把自增 ID 放入 bitmap。圈选完拿到 ID 列表后再反查原始 UUID 给下游。这套逻辑我落地上线后很多报表的查询性能都提升了一个数量级。但我也清楚它并不是唯一的方案如果集合数据的基数非常小或者你需要频繁查询集合内元素的原始业务含义那么用Set或普通数组反而更好。工具选型永远要从实际问题出发而不是从“我会用什么”出发。最后说一个小技巧在排查 bitmap 相关查询性能时先用EXPLAIN看一下有没有走错索引再确认是否对聚合类型列做了不必要的函数包裹。绝大多数慢查询是因为你忘了提前聚合或者把多个 bitmap 在查询时现场拼接。把聚合前置到写入侧查询侧只做轻量集合运算这才是 ClickHouse bitmap 正确打开方式。
返回列表