Roaring Bitmap实战:亿级用户标签系统的高效集合运算与内存优化 这次我们来看一个在互联网大厂和数据分析领域被高频使用的底层数据结构Roaring Bitmap。它不是某个新发布的软件而是一种高效压缩位图算法核心要解决的问题是如何在海量用户标签、实时推荐、广告定向、数据仓库等场景下对亿级甚至十亿级的用户ID集合进行快速、低内存的集合运算如交集、并集、差集。如果你正在处理用户画像、人群圈选、AB测试分组这类需要频繁进行集合操作的任务并且被传统BitMap的内存消耗或HashSet的查询速度所困扰那么Roaring Bitmap就是你必须要了解的技术。它的设计目标非常直接在保持接近位图BitMap的快速位运算性能的同时将内存占用压缩到极致从而让单机处理超大整数集合成为可能。本文不会停留在概念层面而是聚焦于实战。我们将拆解Roaring Bitmap的核心原理对比其与传统方案的性能差异并通过具体的代码示例展示如何在Java等环境中使用它来管理亿级用户标签完成人群的快速圈选和计算。你会看到它的硬件门槛极低主要依赖内存启动即用通常是引入一个Jar包并且能无缝集成到现有的数据管道和API服务中支持高并发的批量查询任务。1. 核心能力速览Roaring Bitmap 不是一个独立运行的服务而是一个嵌入式的数据结构库。因此它的“规格”主要体现在性能、容量和集成方式上。能力项说明项目类型高效压缩位图算法 / 内存数据结构库核心功能超大整数集合的存储、压缩与高速集合运算交、并、差、非内存占用远低于传统 BitMap。对于稀疏分布的大整数集合如用户ID内存占用可压缩至原始位图的百分之几甚至千分之一。查询性能接近传统 BitMap。支持快速的成员检查contains和基数计算cardinality。集合运算速度极快是HashSet的数十倍甚至上百倍。启动/集成方式作为库引入项目。Java中通常通过Maven/Gradle添加依赖直接调用API。是否支持API本身无网络API但其封装的数据结构可轻松集成到Spring Boot等服务的REST API中对外提供人群计算服务。是否支持批量任务原生支持。是其核心设计目标非常适合批处理人群包计算、离线用户画像分析等场景。适合场景用户标签系统、实时推荐引擎、广告定向投放、数据分析OLAP、数据库倒排索引。不适合场景非整数类型数据、需要频繁单条增删的场景批量操作更优、对延迟有纳秒级要求的极端场景。2. 适用场景与使用边界适合谁用后端开发工程师需要构建高性能用户分群、权限系统的开发者。数据平台工程师负责用户画像、标签系统、AB测试平台搭建的工程师。算法工程师在推荐、广告场景中需要快速计算用户交集的人群。大数据工程师在Spark、Flink等计算引擎中处理大规模集合运算。能解决什么问题人群圈选“找出同时拥有标签A和标签B但没有标签C的所有用户”。实时推荐用户上线时快速计算其所属的所有标签人群进行实时匹配。广告定向根据广告主设定的复杂人群条件地域、兴趣、行为在毫秒级内筛选出目标用户。数据统计快速计算某个标签下的用户总数基数或多个标签间的重叠用户数。倒排索引替代传统的倒排列表用压缩位图存储文档ID列表加速查询。使用边界与注意事项数据类型仅适用于整数通常是用户ID、商品ID等集合。字符串等类型需要先映射为整数。更新频率虽然支持单点增删但其最大优势在于批量构建和批量查询。对于超高频率的单点更新可能需要结合其他数据结构。内存与磁盘Roaring Bitmap是内存数据结构。虽然序列化后可以持久化到磁盘但核心操作都在内存中进行需要保证足够的内存容量。合规与隐私当用于存储用户标签时需严格遵守数据安全与隐私保护法规。确保用户数据脱敏、授权使用并在传输、存储过程中进行加密。3. 环境准备与前置条件Roaring Bitmap 本身是算法和数据结构不依赖特定的操作系统或GPU。其运行环境主要取决于你使用的编程语言和项目架构。通用环境清单开发语言Roaring Bitmap 有多个语言实现最成熟的是Java版本。此外还有 C/C、Go、Python 等版本。本文以 Java 为例。Java 环境JDK 8 或更高版本。建议使用 JDK 11 或 17 等 LTS 版本以获得更好的性能。构建工具Maven 或 Gradle用于管理项目依赖。内存这是最关键的资源。你需要根据预估的用户基数用户ID最大值和标签稀疏度来评估内存需求。一个粗略的估计是存储数千万到亿级用户ID的稀疏集合内存占用可能在几十MB到几百MB之间。务必在实际数据上进行测试。集成环境计划将 Roaring Bitmap 集成到的系统如 Spring Boot 微服务、Spark 作业、Flink 任务等。4. 安装部署与启动方式在 Java 项目中使用 Roaring Bitmap 非常简单本质上就是添加一个依赖库。Maven 项目在pom.xml中添加依赖dependency groupIdorg.roaringbitmap/groupId artifactIdRoaringBitmap/artifactId version0.9.47/version !-- 请检查并使用最新版本 -- /dependencyGradle 项目在build.gradle中添加依赖dependencies { implementation org.roaringbitmap:RoaringBitmap:0.9.47 }添加依赖后无需任何“启动”操作。直接在代码中导入类即可开始使用import org.roaringbitmap.RoaringBitmap; public class RoaringBitmapDemo { public static void main(String[] args) { // 1. 创建 RoaringBitmap 对象 RoaringBitmap rb1 new RoaringBitmap(); // 2. 添加数据用户ID rb1.add(1, 3, 5, 100000, 2000000); // 添加多个ID rb1.add(5000000); // 添加单个ID // 3. 此时 rb1 就已经在内存中运行起来了 System.out.println(rb1 的基数元素个数: rb1.getCardinality()); } }这就是全部的“部署”过程。它的“服务”就是内存中的对象通过 API 调用来完成所有功能。5. 功能测试与效果验证我们来模拟一个真实的用户标签管理场景验证 Roaring Bitmap 的核心功能。5.1 场景构建初始化标签位图假设我们有三个用户标签tag_tech_lover: 科技爱好者用户ID: 1, 3, 5, 100001, 2000001tag_movie_fan: 电影迷用户ID: 3, 5, 7, 2000001, 3000000tag_shopper: 购物达人用户ID: 5, 100001, 3000000, 4000000import org.roaringbitmap.RoaringBitmap; import java.io.*; public class UserTagDemo { public static void main(String[] args) { // 初始化三个标签对应的用户集合 RoaringBitmap techLovers new RoaringBitmap(); techLovers.add(1, 3, 5, 100001, 2000001); RoaringBitmap movieFans new RoaringBitmap(); movieFans.add(3, 5, 7, 2000001, 3000000); RoaringBitmap shoppers new RoaringBitmap(); shoppers.add(5, 100001, 3000000, 4000000); System.out.println( 初始标签人群 ); System.out.println(科技爱好者人数: techLovers.getCardinality()); System.out.println(电影迷人数: movieFans.getCardinality()); System.out.println(购物达人人人数: shoppers.getCardinality()); } }5.2 功能测试1集合运算人群圈选这是最核心的功能。我们通过集合运算来回答业务问题。// 接上面的代码 System.out.println(\n 复杂人群圈选 ); // 问题1既是科技爱好者又是电影迷的用户交集 RoaringBitmap techAndMovie RoaringBitmap.and(techLovers, movieFans); System.out.println(科技爱好者 电影迷: techAndMovie.toArray()); // 输出 [3, 5, 2000001] // 问题2是科技爱好者但不是购物达人的用户差集 RoaringBitmap techNotShopper RoaringBitmap.andNot(techLovers, shoppers); System.out.println(科技爱好者 !购物达人: techNotShopper.toArray()); // 输出 [1, 3, 2000001] // 问题3所有至少有一个标签的用户并集 RoaringBitmap anyTag RoaringBitmap.or(techLovers, movieFans, shoppers); System.out.println(至少有一个标签的总人数: anyTag.getCardinality()); // 输出 9 // 问题4是电影迷或购物达人但不是科技爱好者的用户 // (movieFans ∪ shoppers) - techLovers RoaringBitmap movieOrShopper RoaringBitmap.or(movieFans, shoppers); RoaringBitmap result RoaringBitmap.andNot(movieOrShopper, techLovers); System.out.println((电影迷|购物达人) !科技爱好者: result.toArray()); // 输出 [7, 3000000, 4000000]预期结果与判断代码运行后应能正确输出符合各逻辑条件的用户ID数组。这验证了 Roaring Bitmap 在复杂逻辑组合下的计算正确性。5.3 功能测试2序列化与持久化标签数据需要持久化存储如Redis、数据库、HDFS。Roaring Bitmap 提供了高效的序列化方法。// 接上面的代码 System.out.println(\n 序列化与反序列化 ); try { // 将 techLovers 位图序列化到字节数组 ByteArrayOutputStream bos new ByteArrayOutputStream(); DataOutputStream dos new DataOutputStream(bos); techLovers.serialize(dos); byte[] serializedBytes bos.toByteArray(); System.out.println(序列化后字节大小: serializedBytes.length bytes); // 从字节数组反序列化 ByteArrayInputStream bis new ByteArrayInputStream(serializedBytes); DataInputStream dis new DataInputStream(bis); RoaringBitmap deserializedRb new RoaringBitmap(); deserializedRb.deserialize(dis); // 验证反序列化后的数据是否一致 System.out.println(反序列化后基数是否一致: deserializedRb.equals(techLovers)); // 应为 true } catch (IOException e) { e.printStackTrace(); }判断成功标准序列化与反序列化过程不报错且反序列化后的位图与原位图通过equals方法比较为true。序列化后的字节数组大小远小于存储原始整数列表的大小。5.4 功能测试3性能对比与HashSet我们通过一个简单的测试感受性能差距。注意此测试仅为演示严谨性能测试需用JMH等工具。import java.util.HashSet; import java.util.Random; public class PerformanceDemo { public static void main(String[] args) { int dataSize 1_000_000; // 100万用户ID int maxUserId 50_000_000; // 用户ID范围到5000万 Random rand new Random(42); // 固定种子保证可重复 RoaringBitmap rb new RoaringBitmap(); HashSetInteger hashSet new HashSet(); // 1. 构建数据 long start System.currentTimeMillis(); for (int i 0; i dataSize; i) { int id rand.nextInt(maxUserId); rb.add(id); } long timeRbBuild System.currentTimeMillis() - start; start System.currentTimeMillis(); for (int i 0; i dataSize; i) { int id rand.nextInt(maxUserId); // 注意重新生成相同序列的ID rand.setSeed(42 i); // 重置随机种子确保两组数据完全一致 id rand.nextInt(maxUserId); hashSet.add(id); } long timeHsBuild System.currentTimeMillis() - start; // 2. 计算交集模拟人群圈选 // 创建另一个有部分重叠的集合 RoaringBitmap rb2 new RoaringBitmap(); HashSetInteger hashSet2 new HashSet(); rand.setSeed(12345); for (int i 0; i dataSize; i) { int id rand.nextInt(maxUserId); rb2.add(id); hashSet2.add(id); } start System.currentTimeMillis(); RoaringBitmap.and(rb, rb2); long timeRbIntersect System.currentTimeMillis() - start; start System.currentTimeMillis(); HashSetInteger intersectSet new HashSet(hashSet); intersectSet.retainAll(hashSet2); long timeHsIntersect System.currentTimeMillis() - start; System.out.println( 性能对比 (数据量: dataSize ) ); System.out.println(构建时间 - RoaringBitmap: timeRbBuild ms, HashSet: timeHsBuild ms); System.out.println(交集计算 - RoaringBitmap: timeRbIntersect ms, HashSet: timeHsIntersect ms); // 3. 内存占用对比近似 // RoaringBitmap 序列化后大小可作为内存占用的近似参考 // HashSet 的内存占用通常远大于其元素本身的大小 System.out.println(\n提示RoaringBitmap 在稀疏大数据集上的内存优势远超 HashSet交集计算速度通常快一个数量级以上。); } }运行这段代码你会直观看到在百万级数据量上RoaringBitmap 在集合运算速度上的巨大优势。内存优势则需要通过更专业的工具如JProfiler或观察序列化大小来间接评估。6. 接口 API 与批量任务Roaring Bitmap 本身没有网络接口但我们可以轻松地将其封装成服务。以下是一个简单的 Spring Boot 控制器示例提供人群圈选的 HTTP API。1. 服务层封装核心逻辑import org.roaringbitmap.RoaringBitmap; import org.springframework.stereotype.Service; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Service public class UserTagService { // 模拟存储标签名 - 对应的用户RoaringBitmap private MapString, RoaringBitmap tagBitmapStore new ConcurrentHashMap(); /** * 根据标签名获取位图 */ public RoaringBitmap getBitmapByTag(String tagName) { return tagBitmapStore.getOrDefault(tagName, new RoaringBitmap()); } /** * 核心人群计算AND, OR, ANDNOT * param operation AND, OR, ANDNOT * param tagNames 操作的标签数组 * return 计算结果的用户ID数组 */ public int[] computeUserGroup(String operation, String[] tagNames) { if (tagNames null || tagNames.length 0) { return new int[0]; } RoaringBitmap result getBitmapByTag(tagNames[0]); for (int i 1; i tagNames.length; i) { RoaringBitmap current getBitmapByTag(tagNames[i]); switch (operation.toUpperCase()) { case AND: result RoaringBitmap.and(result, current); break; case OR: result RoaringBitmap.or(result, current); break; case ANDNOT: // ANDNOT 通常只作用于两个位图这里简化处理为 result ANDNOT current result RoaringBitmap.andNot(result, current); break; default: throw new IllegalArgumentException(Unsupported operation: operation); } } return result.toArray(); } // ... 其他方法如添加标签数据等 }2. 提供 REST APIimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.Arrays; RestController RequestMapping(/api/user-tags) public class UserTagController { Autowired private UserTagService userTagService; /** * 人群圈选接口 * GET /api/user-tags/segment?operationANDtagstech,movie */ GetMapping(/segment) public SegmentResponse segmentUsers( RequestParam String operation, RequestParam String tags) { String[] tagArray tags.split(,); int[] userIds userTagService.computeUserGroup(operation, tagArray); SegmentResponse response new SegmentResponse(); response.setOperation(operation); response.setTags(Arrays.asList(tagArray)); response.setUserCount(userIds.length); response.setUserIds(userIds); // 注意实际生产环境可能只返回数量或分页ID return response; } // 响应对象 static class SegmentResponse { private String operation; private ListString tags; private int userCount; private int[] userIds; // getters and setters ... } }3. 批量任务集成在数据平台中批量更新标签是常态。我们可以用 Spark 来演示如何批量生成 Roaring Bitmap。// Spark Scala 示例从用户行为日志批量计算每个标签的用户位图 import org.roaringbitmap.RoaringBitmap import org.apache.spark.sql.{SparkSession, functions F} val spark SparkSession.builder().appName(TagBitmapBuilder).getOrCreate() import spark.implicits._ // 1. 读取用户打标日志 (userId, tag) val logDF spark.read.parquet(hdfs://path/to/user_tag_logs/*.parquet) .select($userId.cast(int), $tag) // 2. 按tag分组聚合userId构建RoaringBitmap val tagBitmapRDD logDF.rdd .map(row (row.getAs[String](tag), row.getAs[Int](userId))) .aggregateByKey(new RoaringBitmap())( // 分区内聚合将userId添加到bitmap (bitmap: RoaringBitmap, userId: Int) { bitmap.add(userId); bitmap }, // 分区间合并合并两个bitmap (bitmap1: RoaringBitmap, bitmap2: RoaringBitmap) RoaringBitmap.or(bitmap1, bitmap2) ) // 3. 将结果tag - 序列化的bitmap字节数组保存到存储系统 tagBitmapRDD.map { case (tag, bitmap) val bos new java.io.ByteArrayOutputStream() val dos new java.io.DataOutputStream(bos) bitmap.serialize(dos) (tag, bos.toByteArray) }.toDF(tag, bitmapBytes) .write .mode(overwrite) .parquet(hdfs://path/to/tag_bitmap_store/)这个 Spark 作业会高效地处理海量日志为每个标签生成一个压缩的 Roaring Bitmap 文件供后续的实时查询服务加载使用。7. 资源占用与性能观察如何观察内存占用直接观察序列化大小如前面测试所示serialize()后得到的字节数组长度是衡量其在内存和磁盘中占用空间的良好指标。使用 JVM 工具在启动命令中添加-XX:PrintCompressionOops等参数观察对象指针压缩情况对位图这类大量小对象的数据结构有益。使用jmap -histo:live pid查看 RoaringBitmap 对象的实例数量和总大小。使用 VisualVM、JProfiler 或 async-profiler 进行堆内存分析查看org.roaringbitmap包下的对象内存。经验公式估算内存占用主要取决于基数Cardinality集合中实际有多少个整数。数据分布整数是密集连续如1-100万还是稀疏分散如随机分布在0-10亿。Roaring Bitmap 会将32位整数空间0~2^32-1分成若干桶Container。对于稀疏数据它使用紧凑的数组存储内存效率极高。性能影响因素操作类型contains检查是否存在是O(1)级别极快。集合运算and,or,andNot,xor的速度与涉及的位图大小和数据分布有关但通常远快于基于HashSet的同类操作。数据分布对完全随机的稀疏数据压缩效果最好。对完全连续的数据它会退化成高效的位集Bitset内存占用稍大但运算依然很快。JVM 优化确保为JVM分配足够堆内存-Xmx并考虑使用G1等垃圾回收器以减少大内存工作集下的GC停顿。降低资源占用的建议使用runOptimize()在批量添加完数据后调用bitmap.runOptimize()方法可以尝试进一步压缩内存以轻微增加后续修改操作为代价。选择合适的整数范围如果用户ID范围可以控制尽量使用连续的、较小的整数能提升性能并减少内存。离线构建在线查询在Spark/Flink中离线计算好标签位图序列化存储。在线服务只加载和查询避免在线服务进行大量的单点更新操作。8. 常见问题与排查方法问题现象可能原因排查方式解决方案添加大量数据后内存占用依然很高数据可能过于密集导致Roaring Bitmap内部使用了未压缩的BitmapContainer。1. 检查数据的基数getCardinality()和最大值。2. 调用bitmap.getSizeInBytes()估算内存。1. 如果数据确实密集高内存是正常的但运算速度依然快。2. 考虑是否能用更小的整数范围。序列化/反序列化时报错或数据不一致1. 序列化与反序列化的API使用不正确。2. 字节流在传输或存储过程中损坏。1. 检查代码确保使用DataOutput/DataInput流。2. 对比序列化前后位图的equals()结果。1. 严格遵循官方示例代码。2. 对于网络传输确保使用可靠的协议并校验数据完整性。集合运算结果不符合预期1. 参与运算的位图数据有误。2. 运算逻辑AND, OR, ANDNOT理解有误。1. 打印出参与运算的各个位图的toArray()或getCardinality()进行验证。2. 用少量测试数据手动验算。1. 检查数据源确保位图被正确构建。2. 复习集合运算的数学定义ANDNOT(A,B)表示在A中但不在B中的元素。在Spark等分布式环境中使用时报序列化错误RoaringBitmap 未实现java.io.Serializable接口但实现了更高效的Externalizable。检查在RDD操作中是否错误地尝试了Java原生序列化。在Spark中应使用mapPartitions或aggregateByKey配合本地操作避免将RoaringBitmap作为需要网络传输的复杂对象。或者使用Kryo序列化并注册RoaringBitmap的序列化器。GC垃圾回收压力大频繁创建和丢弃大量的、较大的RoaringBitmap对象。使用JVM监控工具观察GC频率和Old Gen使用情况。1. 对象复用考虑使用对象池。2. 减少中间对象使用RoaringBitmap.and/lor的原地操作版本如rb1.and(rb2)来避免创建新对象。3. 调整JVM堆大小和GC策略。与数据库查询性能对比不明显数据量太小或者数据库索引已经非常高效。1. 增大测试数据量到百万、千万级。2. 确保对比的是相同的复杂集合运算逻辑。Roaring Bitmap 的优势在于内存中的大规模集合运算。对于小数据量或简单的单点查询数据库可能更有优势。正确场景下才能发挥其威力。9. 最佳实践与使用建议预热与缓存在线服务中将常用的标签位图反序列化后缓存在内存如Guava Cache或Caffeine中避免每次请求都进行IO读取。分片策略如果用户ID总量巨大如百亿单个位图可能过大。可以考虑按用户ID范围如按亿分片或业务维度如按渠道分片进行水平分片每个分片使用独立的RoaringBitmap。组合查询优化对于固定的、频繁使用的复杂人群组合如“(标签A AND 标签B) OR (标签C ANDNOT 标签D)”可以预先计算好结果位图并缓存实现O(1)复杂度的查询。监控与告警监控标签位图服务的内存使用量、QPS、计算延迟。为关键人群圈选接口设置慢查询告警。数据更新策略增量更新维护一个“增量位图”记录新增和移除的用户。定期如每小时将增量合并到全量位图中。查询时需要同时查询全量和增量位图并进行逻辑合并。全量重建在离线数仓中每天用最新的全量数据重建所有标签位图然后整体切换上线。策略更简单数据一致性高。合规与安全脱敏存储和计算的是用户ID而非直接的个人身份信息PII。权限对人群圈选API进行严格的权限控制确保只有授权业务方可以查询特定标签。审计记录所有的人群查询日志用于溯源和合规审计。10. 总结与下一步Roaring Bitmap 是处理海量整数集合运算的“利器”。它的价值不在于概念新颖而在于工程上的极致优化用巧妙的分桶和压缩算法在速度与空间之间取得了近乎完美的平衡。最值得尝试的点如果你的系统中存在“从亿级用户中快速找出符合多个条件的用户”这类需求并且当前方案如数据库联表、HashSet内存计算遇到性能或资源瓶颈那么引入 Roaring Bitmap 很可能带来数量级的提升。最先应该验证的功能不要一上来就处理全量数据。先用一个小的、代表性的数据集例如10万个用户ID测试核心流程位图构建、序列化持久化、反序列化加载、交集/并集计算。验证整个数据链路是通的。最容易踩的坑数据一致性确保离线构建的位图与在线查询服务加载的位图版本一致。内存评估不足虽然压缩率高但处理十亿级用户、数万标签的全量数据总内存需求仍需仔细评估和测试。API误用注意and和or等静态方法返回的是新对象而rb1.and(rb2)是原地修改rb1。根据场景选择正确的方法。后续扩展方向探索 Roaring64NavigableMap如果你的用户ID是64位的如Long类型可以使用Roaring64NavigableMap。与现有生态集成Doris/Pinot等OLAP数据库它们内部已集成Roaring Bitmap用于加速查询了解其使用方式。Redis可以将序列化后的位图字节数组存入Redis作为分布式缓存。Apache Druid其在数据索引中大量使用Roaring Bitmap。阅读论文与源码要真正理解其强大之处推荐阅读原始的论文《Better bitmap performance with Roaring bitmaps》以及其Java实现的源码理解其三种ContainerArrayContainer, BitmapContainer, RunContainer的设计与转换策略。将 Roaring Bitmap 纳入你的技术工具箱下次面对海量数据集合运算时你会多一个高效而优雅的选择。建议收藏本文在需要时参照步骤进行集成和验证。