导读:Bitmap 是计算机科学中最精巧的数据结构之一——用最少的内存做最多的事。本文不满足于"每个元素用一个 bit 表示"这样的概述,而是从机器指令级别的位运算讲起,逐步深入到 JDK BitSet 的源码实现、RoaringBitmap 的分段压缩策略,再到 Redis Bitmap 和 ClickHouse Bitmap 在生产环境中的真实落地方案。读完本文,你将掌握 Bitmap 从理论到实践的完整知识链路,并能在自己的项目中做出合理的技术选型。
一、从一个真实问题说起
假设你面前有一个需求:统计某个 App 过去 30 天每天的独立用户登录数(DAU),并且能快速回答"用户 X 在第 N 天是否登录过"这个问题。
用户 ID 是自增整数,范围在 0 到 1 亿之间。
最朴素的方案是用 HashSet<Integer> 来存储每天的登录用户集合。粗略算一下内存开销:Java 中一个 Integer 对象占 16 字节(12 字节对象头 + 4 字节 int 值),HashSet 底层是 HashMap,每个 Entry 还有 32 字节的额外开销。1 亿个用户,单日就需要大约 4.5 GB 内存。30 天就是 135 GB。
换成 Bitmap 呢?1 亿个用户只需要 1 亿个 bit,也就是 12.5 MB。30 天不过 375 MB。
内存占用从 135 GB 降到 375 MB,差了 360 倍。这就是 Bitmap 的魅力所在。
二、位运算:Bitmap 的基石
在深入 Bitmap 数据结构之前,需要先把位运算的地基打牢。Bitmap 的所有操作——设置、清除、查询、统计——都依赖于位运算,理解这些运算的语义和性能特征,是后面分析源码的前提。
2.1 六大基础位运算
| 运算 | 符号 | 语义 | 示例(8-bit) |
|---|---|---|---|
| 按位与 | & | 两位都为 1 则为 1 | 1010 & 1100 = 1000 |
| 按位或 | | | 至少一位为 1 则为 1 | 1010 | 1100 = 1110 |
| 按位异或 | ^ | 两位不同则为 1 | 1010 ^ 1100 = 0110 |
| 按位取反 | ~ | 0 变 1,1 变 0 | ~1010 = 0101 |
| 左移 | << | 向高位移动,低位补 0 | 0001 << 2 = 0100 |
| 右移 | >> / >>> | 向低位移动 | 1000 >> 2 = 0010 |
2.2 Bitmap 中的关键位运算技巧
定位 bit 的位置:给定一个整数 n,需要确定它在 Bitmap 中的"字(word)索引"和"位偏移"。
// 假设使用 long[] 作为底层存储,每个 long 有 64 bit
int wordIndex = n >> 6; // 等价于 n / 64,定位到哪个 long
int bitOffset = n & 0x3F; // 等价于 n % 64,定位到 long 中的哪一位
这里用 >> 6 代替 / 64,用 & 0x3F(即 & 63)代替 % 64,并不是什么奇技淫巧,而是因为 64 恰好是 2 的 6 次方。对于 2 的幂次除法和取模,位运算的性能严格优于算术运算——在大多数 CPU 架构上,整数除法需要 20-40 个时钟周期,而移位和按位与只需要 1 个周期。
设置某一位为 1:
words[wordIndex] |= (1L << bitOffset);
1L << bitOffset 生成一个只有目标位置为 1 的掩码,再通过 OR 运算将目标位设为 1,同时保持其他位不变。
清除某一位(设为 0):
words[wordIndex] &= ~(1L << bitOffset);
先取反得到目标位为 0、其他位为 1 的掩码,再通过 AND 运算清除目标位。
查询某一位的值:
boolean isSet = (words[wordIndex] & (1L << bitOffset)) != 0;
统计 1 的个数(popcount):
int count = Long.bitCount(words[i]);
Long.bitCount() 的实现值得展开看看,它使用了经典的"分治法"统计 bit 数:
// JDK 源码 Long.bitCount()
public static int bitCount(long i) {
i = i - ((i >>> 1) & 0x5555555555555555L); // 每 2 位一组求和
i = (i & 0x3333333333333333L) + ((i >>> 2) & 0x3333333333333333L); // 每 4 位一组
i = (i + (i >>> 4)) & 0x0f0f0f0f0f0f0f0fL; // 每 8 位一组
i = i + (i >>> 8); // 每 16 位一组
i = i + (i >>> 16); // 每 32 位一组
i = i + (i >>> 32); // 全部 64 位求和
return (int)i & 0x7f;
}
这段代码的精妙之处在于:它在 O(log₂(64)) = O(6) 步内完成了 64 位的 popcount,没有任何分支和循环。而且现代 CPU(x86 的 POPCNT 指令,ARM 的 CNT 指令)已经在硬件层面实现了这个操作,JIT 编译器会直接将 Long.bitCount() 替换为单条机器指令。
三、Bitmap 的内存布局与空间分析
3.1 内存布局
Bitmap 最朴素的实现就是一个连续的 bit 数组。在 Java 中通常用 long[] 来承载,C/C++ 中用 uint64_t[]。
以存储 0~255 的整数集合为例,需要 256 个 bit,也就是 4 个 long:
words[0]: bit 0 ~ bit 63
words[1]: bit 64 ~ bit 127
words[2]: bit 128 ~ bit 191
words[3]: bit 192 ~ bit 255
每个 long 内部,bit 0 在最低位(LSB),bit 63 在最高位(MSB)。这是 JDK BitSet 的布局方式,也是业界最常见的 little-endian bit order。
3.2 空间复杂度分析
朴素 Bitmap 的空间复杂度是 O(N),其中 N 是数据的值域范围(而非元素个数)。这个特性决定了 Bitmap 的适用场景和局限性:
| 场景 | 值域范围 | Bitmap 大小 | 是否适用 |
|---|---|---|---|
| 用户 ID(自增,1 亿) | 10⁸ | 12.5 MB | 适用 |
| IP 地址(IPv4) | 2³² ≈ 43 亿 | 512 MB | 勉强 |
| 手机号(11 位数字) | 10¹¹ | ~12.5 GB | 不适用 |
| UUID(128 bit) | 2¹²⁸ | 天文数字 | 不适用 |
关键结论:朴素 Bitmap 只适用于值域连续且范围可控的整数集合。对于稀疏分布或超大值域的场景,需要引入压缩 Bitmap(如 RoaringBitmap),这部分在第五节详细展开。
3.3 与其他数据结构的空间对比
以存储 100 万个 int(值域 0~1000 万)为例:
| 数据结构 | 单元素开销 | 总内存 | 说明 |
|---|---|---|---|
int[] | 4 B | 4 MB | 无去重能力 |
HashSet<Integer> | ~48 B | 48 MB | 装箱 + Entry 开销 |
BitSet | 1 bit | 1.25 MB | 按值域分配 |
RoaringBitmap | ≤1 bit | ≤1.25 MB | 压缩后可能更小 |
四、JDK BitSet 源码剖析
java.util.BitSet 是 Java 标准库中唯一的 Bitmap 实现,虽然功能简单,但它的源码清晰地展示了 Bitmap 的核心设计思想。
4.1 核心数据结构
public class BitSet implements Cloneable, java.io.Serializable {
private long[] words; // 底层存储
private transient int wordsInUse; // 实际使用的 word 数量(不含末尾全0的word)
private transient boolean sizeIsSticky; // 是否固定大小
}
wordsInUse 是一个关键字段——它追踪了"逻辑大小",即最后一个非零 word 的索引 +1。这意味着 BitSet 不会浪费内存在末尾的全零 word 上。但需要注意,前面的全零 word 仍然会被保留。换句话说,如果你只设置了 bit 999999,前面 0~999998 对应的 word 也会被分配。
4.2 set 操作详解
public void set(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex); // bitIndex >> 6
expandTo(wordIndex); // 必要时扩容
words[wordIndex] |= (1L << bitIndex); // 注意:这里直接用 bitIndex,不需要 & 0x3F
checkInvariants();
}
这里有个细节容易被忽略:1L << bitIndex 而不是 1L << (bitIndex & 0x3F)。这是因为 Java 语言规范规定,long 类型的移位操作会自动对移位量取模 64(即只取低 6 位)。所以 1L << 65 等价于 1L << 1。这个隐式行为让代码更简洁,但也容易造成困惑。
扩容机制(expandTo 方法):
private void expandTo(int wordIndex) {
int wordsRequired = wordIndex + 1;
if (wordsInUse < wordsRequired) {
ensureCapacity(wordsRequired);
wordsInUse = wordsRequired;
}
}
private void ensureCapacity(int wordsRequired) {
if (words.length < wordsRequired) {
// 扩容策略:取 2 倍当前大小 和 所需大小 中的较大值
int request = Math.max(2 * words.length, wordsRequired);
words = Arrays.copyOf(words, request);
}
}
扩容策略和 ArrayList 类似,采用 倍增法 来平摊扩容成本。但与 ArrayList 不同的是,BitSet 没有提供缩容机制——即使你清除了所有 bit,底层 long[] 的大小也不会减小。在长生命周期场景中,这可能导致内存泄漏。
4.3 集合运算
BitSet 支持交集、并集、差集、异或等集合运算,且这些运算的性能极好——每个 word(64 bit)一条指令:
// 并集:OR 运算
public void or(BitSet set) {
int wordsInCommon = Math.min(wordsInUse, set.wordsInUse);
for (int i = 0; i < wordsInCommon; i++)
words[i] |= set.words[i]; // 一条指令处理 64 个元素
if (wordsInCommon < set.wordsInUse) {
// 拷贝对方多出来的部分
System.arraycopy(set.words, wordsInCommon,
words, wordsInCommon,
wordsInUse - wordsInCommon);
}
}
// 交集:AND 运算
public void and(BitSet set) {
for (int i = wordsInUse - 1; i >= set.wordsInUse; i--)
words[i] = 0; // 超出对方范围的部分清零
for (int i = Math.min(wordsInUse, set.wordsInUse) - 1; i >= 0; i--)
words[i] &= set.words[i];
}
批量操作的性能优势:对于值域 0~N 的 Bitmap,集合运算的时间复杂度是 O(N/64)(在 64 位机器上)。相比之下,两个 HashSet 做交集的时间复杂度是 O(min(|A|, |B|)),虽然渐进复杂度可能更优,但 Bitmap 的 cache 友好性(连续内存访问 + 无指针追踪)让它在实际运行中往往更快。
4.4 BitSet 的局限性
- 值域必须为非负整数:不支持负数下标
- 非线程安全:没有内置同步机制
- 不支持压缩:对稀疏数据浪费严重
- 无缩容:内存只增不减
- 序列化效率低:默认序列化包含大量零字节
这些局限性催生了更高级的 Bitmap 实现,其中最有代表性的就是 RoaringBitmap。
五、RoaringBitmap:压缩 Bitmap 的工业级实现
RoaringBitmap 由 Daniel Lemire 等人在 2016 年的论文中提出,目前已成为大数据领域最主流的 Bitmap 实现。Apache Spark、Apache Druid、Apache Kylin、ClickHouse、Elasticsearch 等系统都在使用它。
5.1 设计动机
朴素 Bitmap 的问题很明显:如果值域很大但数据很稀疏,会浪费大量内存。比如只存储 {0, 1000000} 两个元素,朴素 Bitmap 需要分配 1000001 个 bit(约 122 KB),而有效信息只有 2 个 bit。
早期的压缩方案(如 WAH、EWAH、Concise)使用游程编码(Run-Length Encoding)来压缩连续的 0 或 1。但游程编码有个根本问题:随机访问性能差。要查询某个 bit 是否被设置,最坏情况下需要从头扫描到尾。
RoaringBitmap 的核心思想是:分而治之,对不同密度的数据区间使用不同的存储策略。
5.2 整体架构
RoaringBitmap 将 32 位整数空间按高 16 位分组,每组最多包含 2¹⁶ = 65536 个元素。高 16 位作为 key,存储在一个有序数组中;每个 key 对应一个 Container,用来存储该分组内的低 16 位数据。
32-bit integer: [高16位 | 低16位]
↓ ↓
chunk key Container 中的值
5.3 三种 Container
根据数据密度的不同,RoaringBitmap 使用三种 Container:
1. ArrayContainer
当一个分组中的元素数量 ≤ 4096 时,使用 short[] 有序数组直接存储低 16 位的值。
final short[] content; // 有序数组,存储低 16 位
int cardinality; // 元素个数
为什么阈值是 4096?因为 4096 个 short 占 4096 × 2 = 8192 字节 = 8 KB,而一个完整的 BitmapContainer 固定占 2¹⁶ / 8 = 8192 字节 = 8 KB。当元素超过 4096 个时,ArrayContainer 不比 BitmapContainer 省空间了。
查找操作使用二分搜索,时间复杂度 O(log n)。
2. BitmapContainer
当一个分组中的元素数量 > 4096 时,使用朴素的 Bitmap(长度固定为 1024 的 long[])。
final long[] bitmap; // 固定 1024 个 long = 65536 bit = 8 KB
int cardinality; // 缓存的元素个数
查找操作是 O(1) 的纯位运算,与 JDK BitSet 相同。
3. RunContainer
当数据呈现出连续区间的特征时(例如 {1, 2, 3, 4, 5, 100, 101, 102}),使用游程编码存储,每个连续区间只需要存储 (start, length) 两个 short。
short[] valueslength; // [start1, length1, start2, length2, ...]
int nbrruns; // 区间数量
RunContainer 在数据具有明显的局部连续性时特别高效。例如,存储 {0, 1, 2, ..., 65535} 这 65536 个元素,RunContainer 只需要 4 字节(一个 run),而 ArrayContainer 需要 128 KB,BitmapContainer 需要 8 KB。
5.4 Container 的自动转换
RoaringBitmap 会根据元素数量和数据特征自动选择最优的 Container 类型,这个过程对使用者完全透明:
插入元素 → ArrayContainer
↓ 元素数 > 4096
BitmapContainer
↓ 调用 runOptimize()
RunContainer(如果游程编码更省空间)
源码中的关键判断逻辑:
// ArrayContainer.add() 中的转换逻辑
@Override
public Container add(short x) {
int loc = Util.unsignedBinarySearch(content, 0, cardinality, x);
if (loc < 0) {
if (cardinality >= DEFAULT_MAX_SIZE) { // DEFAULT_MAX_SIZE = 4096
// 升级为 BitmapContainer
BitmapContainer bc = this.toBitmapContainer();
bc.add(x);
return bc;
}
// 在有序数组中插入
System.arraycopy(content, -loc - 1, content, -loc, cardinality + loc + 1);
content[-loc - 1] = x;
cardinality++;
}
return this;
}
5.5 集合运算的优化
两个 Container 做集合运算时,RoaringBitmap 会根据双方的类型选择最优算法。以 AND(交集)为例:
| Container A | Container B | 算法 |
|---|---|---|
| Array | Array | 双指针归并(类似 merge sort 的 merge) |
| Array | Bitmap | 遍历 Array,逐个在 Bitmap 中查找 |
| Bitmap | Bitmap | 逐 word AND 运算 |
| Run | Run | 区间求交 |
一个有趣的优化:Array ∩ Bitmap 时,如果 Array 很短,直接遍历 Array 逐个查找比将 Array 转为 Bitmap 再做位运算更快,因为避免了 8 KB BitmapContainer 的内存分配和初始化。
5.6 实际性能数据
在实际的基准测试中(数据来自 RoaringBitmap 官方 benchmark),与其他 Bitmap 实现相比:
| 操作 | RoaringBitmap | EWAH | Concise | 朴素 BitSet |
|---|---|---|---|---|
| 交集 | 1x(基准) | 3-5x | 5-10x | 0.5-2x |
| 并集 | 1x | 2-4x | 3-8x | 0.5-1.5x |
| 序列化大小 | 1x | 1.5-3x | 1.2-2x | 2-10x |
| 随机访问 | O(log n) | O(n) | O(n) | O(1) |
朴素 BitSet 在位运算操作上可能更快(因为没有 Container 分发的开销),但综合考虑内存占用和操作性能,RoaringBitmap 在绝大多数场景下是最优选择。
六、Bitmap 的典型应用场景
6.1 布隆过滤器(Bloom Filter)
布隆过滤器本质上就是一个 Bitmap 加上多个哈希函数。它能快速判断一个元素可能存在或一定不存在于集合中。
工作原理:
public class BloomFilter {
private BitSet bitSet;
private int size; // Bitmap 大小
private int hashCount; // 哈希函数个数
public void add(String element) {
for (int i = 0; i < hashCount; i++) {
int hash = hash(element, i) % size;
bitSet.set(Math.abs(hash));
}
}
public boolean mightContain(String element) {
for (int i = 0; i < hashCount; i++) {
int hash = hash(element, i) % size;
if (!bitSet.get(Math.abs(hash))) {
return false; // 一定不存在
}
}
return true; // 可能存在(有误判率)
}
}
误判率公式:
假设 Bitmap 大小为 m bit,插入了 n 个元素,使用 k 个哈希函数:
误判率 ≈ (1 - e^(-kn/m))^k
工程经验:在实践中,给定可接受的误判率 p 和预期元素数量 n,最优参数为:
- m = -n × ln(p) / (ln2)²
- k = (m/n) × ln2
例如,1000 万元素、1% 误判率,需要约 11.5 MB 的 Bitmap 和 7 个哈希函数。
生产应用:
- 缓存穿透防护:在 Redis 前面加一层布隆过滤器,拦截不存在的 key 查询
- 爬虫 URL 去重:判断某个 URL 是否已经被爬取过
- HBase/Cassandra 的行键过滤:避免无效的磁盘 IO
6.2 用户画像与标签系统
在用户画像系统中,每个标签可以用一个 Bitmap 来表示"拥有该标签的用户集合"。
假设有以下标签:
- 标签A "90后":用户 {1, 5, 8, 12, 15, ...}
- 标签B "男性":用户 {1, 3, 5, 9, 12, ...}
- 标签C "一线城市":用户 {2, 5, 8, 12, 20, ...}
要查询"90后 AND 男性 AND 一线城市"的用户群体,只需要三个 Bitmap 做 AND 运算:
RoaringBitmap tag90s = getTagBitmap("90后");
RoaringBitmap tagMale = getTagBitmap("男性");
RoaringBitmap tagCity = getTagBitmap("一线城市");
// 三次 AND 运算,得到同时满足三个条件的用户集合
RoaringBitmap result = RoaringBitmap.and(tag90s, tagMale);
result.and(tagCity);
// 获取匹配用户数
long matchCount = result.getLongCardinality();
// 遍历匹配的用户 ID
result.forEach((int userId) -> {
// 处理每个匹配的用户
});
这种方案的优势在于:
- 查询极快:千万级用户的多标签组合查询,毫秒级返回
- 灵活组合:任意标签的 AND/OR/NOT 组合,不需要预计算
- 增量更新:新增或移除用户标签,只需修改对应 Bitmap 的一个 bit
6.3 权限控制
Linux 文件系统的权限模型是 Bitmap 最经典的应用之一。rwxr-xr-x 这个权限字符串,本质上就是一个 9 位的 Bitmap:
所有者权限 组权限 其他人权限
r w x r - x r - x
1 1 1 1 0 1 1 0 1 = 0755(八进制)
在业务系统中,同样可以用 Bitmap 来做细粒度的权限控制:
public class PermissionBitmap {
// 每个权限占一个 bit
public static final long READ = 1L << 0; // 0001
public static final long WRITE = 1L << 1; // 0010
public static final long DELETE = 1L << 2; // 0100
public static final long ADMIN = 1L << 3; // 1000
private long permissions;
// 授权
public void grant(long permission) {
permissions |= permission;
}
// 撤销权限
public void revoke(long permission) {
permissions &= ~permission;
}
// 检查权限(必须同时拥有所有指定权限)
public boolean hasAll(long required) {
return (permissions & required) == required;
}
// 检查权限(拥有任一指定权限即可)
public boolean hasAny(long required) {
return (permissions & required) != 0;
}
}
一个 long 就能表示 64 种权限,权限检查只需要一次 AND 运算。如果权限超过 64 种,可以扩展为 long[],本质上就是 Bitmap。
6.4 大数据去重
在数据处理管道中,"对海量 ID 去重"是一个高频需求。以日志分析为例:统计一天内访问过某页面的独立用户数(UV)。
传统方案是 SELECT COUNT(DISTINCT user_id) FROM page_view WHERE ...,在数据量大时非常慢。
Bitmap 方案:
// 每个页面维护一个 RoaringBitmap
Map<String, RoaringBitmap> pageUVBitmaps = new ConcurrentHashMap<>();
// 处理每条日志
public void processLog(String pageId, int userId) {
pageUVBitmaps
.computeIfAbsent(pageId, k -> new RoaringBitmap())
.add(userId);
}
// 查询某页面 UV
public long getPageUV(String pageId) {
RoaringBitmap bitmap = pageUVBitmaps.get(pageId);
return bitmap == null ? 0 : bitmap.getLongCardinality();
}
// 查询多个页面的联合 UV(去重后的总 UV)
public long getCombinedUV(List<String> pageIds) {
RoaringBitmap combined = new RoaringBitmap();
for (String pageId : pageIds) {
RoaringBitmap bitmap = pageUVBitmaps.get(pageId);
if (bitmap != null) {
combined.or(bitmap);
}
}
return combined.getLongCardinality();
}
七、Redis Bitmap 生产实践
Redis 原生支持 Bitmap 操作,底层使用 SDS(Simple Dynamic String)来存储 bit 数组。虽然 Redis 的 Bitmap 本质上就是 String 类型,但提供了一组专用命令来操作单个 bit。
7.1 核心命令
# 设置某个 bit
SETBIT key offset value # SETBIT login:2024-01-15 12345 1
# 获取某个 bit
GETBIT key offset # GETBIT login:2024-01-15 12345
# 统计值为 1 的 bit 数量
BITCOUNT key [start end] # BITCOUNT login:2024-01-15
# 位运算
BITOP AND destkey key1 key2 # 交集
BITOP OR destkey key1 key2 # 并集
BITOP XOR destkey key1 key2 # 异或
BITOP NOT destkey key # 取反
# 查找第一个值为 0 或 1 的 bit 位置
BITPOS key bit [start end]
7.2 DAU 统计实战
这是 Redis Bitmap 最经典的应用场景:
import redis
r = redis.Redis()
def user_login(user_id: int, date: str):
"""记录用户登录"""
r.setbit(f"login:{date}", user_id, 1)
def get_dau(date: str) -> int:
"""获取某天的 DAU"""
return r.bitcount(f"login:{date}")
def get_retention(date1: str, date2: str) -> int:
"""计算两天的留存用户数"""
dest_key = f"retention:{date1}:{date2}"
r.bitop("AND", dest_key, f"login:{date1}", f"login:{date2}")
count = r.bitcount(dest_key)
r.delete(dest_key) # 清理临时 key
return count
def get_weekly_active(start_date: str, dates: list) -> int:
"""计算周活跃用户数(7天内至少登录一次)"""
dest_key = f"wau:{start_date}"
keys = [f"login:{d}" for d in dates]
r.bitop("OR", dest_key, *keys)
count = r.bitcount(dest_key)
r.delete(dest_key)
return count
7.3 Redis Bitmap 的内存特性
Redis Bitmap 有几个需要注意的内存行为:
1. 按需分配但不缩容
SETBIT login 999999999 1 会立刻分配约 125 MB 的内存(10 亿 bit / 8),即使只设置了一个 bit。并且之后 SETBIT login 999999999 0 清除这个 bit 后,内存不会释放。
2. 大 offset 的陷阱
在生产中遇到过这样的事故:某系统用 Bitmap 存储用户签到状态,key 设计为 sign:{userId},offset 为当年的第几天(0~365)。后来某个 bug 导致 userId 被错误地用作 offset,一个值为 20 亿的用户 ID 直接分配了 250 MB 内存,Redis 差点被打爆。
教训:使用 Redis Bitmap 前,必须对 offset 做上界校验。
public void setUserLogin(long userId, String date) {
if (userId < 0 || userId > MAX_USER_ID) {
throw new IllegalArgumentException("userId out of bitmap range: " + userId);
}
redisTemplate.opsForValue().setBit("login:" + date, userId, true);
}
3. BITCOUNT 的范围参数是字节偏移
BITCOUNT key start end 中的 start 和 end 是字节偏移,不是 bit 偏移。这个设计经常让人困惑:
# 统计 bit 0~63 中值为 1 的数量
BITCOUNT key 0 7 # 0~7 字节 = 0~63 bit ✓
# 想统计 bit 0~9?抱歉,做不到精确控制
# start/end 只能以字节(8 bit)为粒度
Redis 7.0 引入了 BITCOUNT key start end BIT 语法,支持 bit 级别的范围统计。如果你的 Redis 版本低于 7.0,需要在客户端做额外处理。
7.4 Redis Bitmap vs Redis HyperLogLog
在 UV 统计场景中,Redis Bitmap 和 HyperLogLog 都能用,但特性差异明显:
| 特性 | Bitmap | HyperLogLog |
|---|---|---|
| 内存占用 | O(最大用户ID) | 固定 12 KB |
| 精确度 | 100% 精确 | 0.81% 标准误差 |
| 支持操作 | 交/并/差/单元素查询 | 只支持并集和计数 |
| 能否反查 | 可以查某用户是否登录 | 不能 |
| 适用场景 | 需要精确统计 + 单用户查询 | 只需要近似计数 |
选型建议:如果业务只需要"大概有多少人",用 HyperLogLog(省内存);如果需要"某个用户有没有"或需要做交集/差集运算,用 Bitmap。
八、ClickHouse Bitmap 函数
ClickHouse 内置了对 RoaringBitmap 的支持,提供了 AggregateFunction(groupBitmap, UInt32) 类型和一系列 Bitmap 函数,可以在 OLAP 查询中直接使用 Bitmap 做去重和集合运算。
8.1 基本用法
-- 创建包含 Bitmap 列的表
CREATE TABLE user_tags (
tag_name String,
user_bitmap AggregateFunction(groupBitmap, UInt32)
) ENGINE = AggregatingMergeTree()
ORDER BY tag_name;
-- 插入数据(将用户 ID 聚合为 Bitmap)
INSERT INTO user_tags
SELECT
tag_name,
groupBitmapState(toUInt32(user_id)) as user_bitmap
FROM user_tag_raw
GROUP BY tag_name;
-- 查询某个标签的用户数
SELECT tag_name, groupBitmapMerge(user_bitmap) as user_count
FROM user_tags
WHERE tag_name = '90后'
GROUP BY tag_name;
8.2 多标签组合查询
-- 查询同时拥有"90后"和"一线城市"标签的用户数
SELECT bitmapCardinality(
bitmapAnd(
(SELECT groupBitmapMergeState(user_bitmap) FROM user_tags WHERE tag_name = '90后'),
(SELECT groupBitmapMergeState(user_bitmap) FROM user_tags WHERE tag_name = '一线城市')
)
) as matched_users;
-- 查询"90后"但不是"一线城市"的用户数
SELECT bitmapCardinality(
bitmapAndnot(
(SELECT groupBitmapMergeState(user_bitmap) FROM user_tags WHERE tag_name = '90后'),
(SELECT groupBitmapMergeState(user_bitmap) FROM user_tags WHERE tag_name = '一线城市')
)
) as matched_users;
8.3 ClickHouse Bitmap 在用户分析中的实践
一个典型的用户留存分析场景:
-- 用户行为表,按天分区,Bitmap 存储每天活跃的用户集合
CREATE TABLE daily_active_users (
dt Date,
user_bitmap AggregateFunction(groupBitmap, UInt32)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(dt)
ORDER BY dt;
-- 计算次日留存率
SELECT
d1.dt as cohort_date,
bitmapCardinality(bitmapAnd(d1.user_bitmap, d2.user_bitmap))
/ bitmapCardinality(d1.user_bitmap) as retention_rate
FROM daily_active_users d1
JOIN daily_active_users d2 ON d2.dt = d1.dt + 1
WHERE d1.dt >= '2024-01-01' AND d1.dt <= '2024-01-31';
这种方案相比传统的
COUNT(DISTINCT) 方案,性能提升通常在 10 倍到 100 倍之间,尤其在多维交叉分析时优势更明显。
8.4 ClickHouse Bitmap 的注意事项
- 只支持 UInt32 / UInt64:用户 ID 必须是整数类型,字符串类型的 ID 需要先做映射
- Bitmap 列不支持直接 WHERE 过滤:需要通过
bitmapContains()或bitmapHasAny()函数 - 物化视图的 Bitmap 聚合:使用
AggregatingMergeTree引擎配合groupBitmapState/groupBitmapMerge函数 - 跨 shard 的 Bitmap 运算:在分布式表上做 Bitmap 交集时,需要确保数据分布策略一致,否则结果不正确
九、性能调优与生产踩坑
9.1 稀疏 vs 稠密:选对数据结构
这是使用 Bitmap 最容易犯的错误——在不适合的场景硬用 Bitmap。
| 数据特征 | 推荐方案 | 原因 |
|---|---|---|
| 值域小、密度高(>1%) | 朴素 Bitmap / BitSet | 简单高效 |
| 值域大、密度中等 | RoaringBitmap | 自适应压缩 |
| 值域大、极度稀疏(<0.01%) | HashSet / 排序数组 | Bitmap 浪费空间 |
| 值域非整数 | 先做 ID 映射,再用 Bitmap | 或直接放弃 Bitmap |
9.2 序列化与网络传输
RoaringBitmap 支持两种序列化格式:
RoaringBitmap bitmap = new RoaringBitmap();
// ... 填充数据 ...
// 方式1:标准序列化
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
bitmap.serialize(dos); // 标准格式
byte[] bytes = baos.toByteArray();
// 方式2:便携式序列化(跨语言兼容)
ByteBuffer buffer = ByteBuffer.allocate(bitmap.serializedSizeInBytes());
bitmap.serialize(buffer);
// 反序列化
RoaringBitmap deserialized = new RoaringBitmap();
deserialized.deserialize(new DataInputStream(new ByteArrayInputStream(bytes)));
踩坑经验:在微服务间传输 Bitmap 时,序列化前调用 bitmap.runOptimize() 可以将适合游程编码的 Container 转为 RunContainer,通常能减少 20%~60% 的序列化体积。但 runOptimize() 本身有计算开销,不适合在热路径上频繁调用。
9.3 并发安全
JDK BitSet 和 RoaringBitmap 都不是线程安全的。在多线程场景中:
// 错误示范:多线程直接操作同一个 Bitmap
ExecutorService executor = Executors.newFixedThreadPool(8);
RoaringBitmap bitmap = new RoaringBitmap(); // 非线程安全!
// 正确方案1:外部加锁
synchronized (bitmap) {
bitmap.add(userId);
}
// 正确方案2:每个线程一个局部 Bitmap,最后合并
List<RoaringBitmap> localBitmaps = Collections.synchronizedList(new ArrayList<>());
// 每个线程:
RoaringBitmap local = new RoaringBitmap();
local.add(userId);
localBitmaps.add(local);
// 合并:
RoaringBitmap merged = RoaringBitmap.or(localBitmaps.toArray(new RoaringBitmap[0]));
// 正确方案3:使用 ConcurrentBitmap(需要第三方库或自行实现分段锁)
方案 2(线程本地 Bitmap + 最终合并)在实际生产中最常用,因为它消除了锁竞争,并行度最高。
9.4 内存泄漏防范
// Redis Bitmap 的 key 过期设置
public void recordLogin(int userId, String date) {
String key = "login:" + date;
redisTemplate.opsForValue().setBit(key, userId, true);
redisTemplate.expire(key, 90, TimeUnit.DAYS); // 90 天后自动清理
}
在 JVM 内存中使用 Bitmap 时,注意 BitSet 的"不缩容"特性。如果一个 BitSet 在生命周期中可能先膨胀后收缩,考虑定期用 new BitSet() 替换旧的,让 GC 回收多余的内存。
9.5 BITCOUNT 性能问题
在 Redis 中,如果 Bitmap 很大(例如 1 亿 bit = 12.5 MB),BITCOUNT 命令会扫描整个 Bitmap,在单线程的 Redis 中这会阻塞其他请求。
解决方案:将大 Bitmap 分片存储。
# 将用户 ID 分片到多个 key 中
SHARD_SIZE = 1_000_000 # 每个分片 100 万用户
def get_shard_key(user_id: int, date: str) -> str:
shard = user_id // SHARD_SIZE
return f"login:{date}:shard:{shard}"
def set_login(user_id: int, date: str):
key = get_shard_key(user_id, date)
offset = user_id % SHARD_SIZE
r.setbit(key, offset, 1)
def get_total_dau(date: str) -> int:
total = 0
for shard in range(MAX_USER_ID // SHARD_SIZE + 1):
key = f"login:{date}:shard:{shard}"
total += r.bitcount(key)
return total
分片后每个 BITCOUNT 只需要处理约 122 KB(100 万 bit),耗时从毫秒级降到微秒级。
9.6 生产环境中的监控指标
在生产环境中使用 Bitmap,建议监控以下指标:
- Bitmap 大小:
bitmap.getSizeInBytes()或 Redis 的MEMORY USAGE key - 基数(cardinality):实际包含的元素数量
- 密度:cardinality / 值域范围,判断是否适合继续用 Bitmap
- 序列化体积:在网络传输场景中关注
- 操作延迟:特别是大 Bitmap 的
BITCOUNT和集合运算
十、特殊场景
10.1 64 位 Bitmap 的挑战与应对
当用户 ID 超过 32 位(例如雪花算法生成的 64 位 ID),标准的 RoaringBitmap(只支持 32 位整数)就不够用了。官方提供的 Roaring64NavigableMap 本质上是用 TreeMap<Integer, RoaringBitmap> 做了一层分片——取 64 位 ID 的高 32 位做 key,低 32 位存入对应的 RoaringBitmap。这个方案能用,但有几个实际痛点:
- TreeMap 的红黑树开销:每个 Entry 都是一个对象节点,指针追踪破坏了 cache 局部性
- 序列化体积膨胀:需要序列化 TreeMap 的结构信息,比纯 RoaringBitmap 大不少
- 集合运算变慢:两个
Roaring64NavigableMap做 AND/OR 时,需要先对齐高 32 位 key,再逐个做 Container 级别的运算
实践中更常见的做法是绕开 64 位问题而不是正面硬刚:
方案一:ID 映射(最推荐)
维护一个 雪花ID → 自增序号 的映射表,Bitmap 里存的是紧凑的自增序号。映射表可以用 Redis Hash 或者数据库来做。
// 映射层:雪花ID → 紧凑序号
public class IdMapper {
private final AtomicInteger counter = new AtomicInteger(0);
private final ConcurrentHashMap<Long, Integer> snowflakeToSeq = new ConcurrentHashMap<>();
public int toCompactId(long snowflakeId) {
return snowflakeToSeq.computeIfAbsent(snowflakeId, k -> counter.getAndIncrement());
}
}
// 使用紧凑序号操作 Bitmap
RoaringBitmap bitmap = new RoaringBitmap();
bitmap.add(idMapper.toCompactId(snowflakeUserId));
这样 Bitmap 内部始终在 32 位空间里运作,性能和标准 RoaringBitmap 完全一致。代价是多了一次映射查询,但这个查询通常是 O(1) 的哈希操作。
方案二:利用雪花 ID 的结构特征
雪花 ID 的 64 位中,实际有效的信息并不均匀——高位是时间戳,中间是机器 ID,低位是序列号。如果业务场景是"统计某段时间内的用户",可以按时间戳前缀分桶,每个桶内的 ID 差值通常在 32 位范围内。
// 雪花 ID 结构:1位符号 + 41位时间戳 + 10位机器ID + 12位序列号
// 按天分桶后,桶内 ID 差值 ≈ 2^22 (约400万),32位绰绰有余
long baseId = getMinSnowflakeIdOfDay(date); // 当天最小雪花ID
int offset = (int)(snowflakeId - baseId); // 差值一定在32位以内
bitmap.add(offset);
方案三:Roaring64Bitmap(新实现)
RoaringBitmap 库在较新版本中提供了 Roaring64Bitmap(注意不是 Roaring64NavigableMap),它用 ART (Adaptive Radix Tree) 替代了 TreeMap,在高 32 位的查找上更高效,cache 友好性也更好。如果必须直接处理 64 位 ID,这个实现是目前的最优选择。
10.2 SIMD 与 GPU 加速
Bitmap 的按位运算有一个天然优势:数据无依赖、可大规模并行。两个 Bitmap 的 AND 运算,每个 word 之间是完全独立的,理论上可以同时计算所有 word。
SIMD 加速(已在生产中广泛使用)
现代 CPU 的 SIMD 指令集(SSE4.2、AVX2、AVX-512)可以在单条指令中处理 128/256/512 位数据。RoaringBitmap 的 C/C++ 实现 CRoaring 已经深度使用了 SIMD 优化:
// CRoaring 中使用 AVX2 的 Bitmap AND 运算
// 一条 AVX2 指令处理 256 bit = 4 个 uint64_t
__m256i A = _mm256_loadu_si256((__m256i*)(bitmap1 + i));
__m256i B = _mm256_loadu_si256((__m256i*)(bitmap2 + i));
__m256i result = _mm256_and_si256(A, B);
_mm256_storeu_si256((__m256i*)(out + i), result);
在 BitmapContainer(固定 1024 个 long)的交集运算中,使用 AVX-512 可以将循环次数从 1024 降到 128(每次处理 8 个 long = 512 bit),吞吐量提升约 4~8 倍。
Java 侧虽然不能直接写 SIMD intrinsics,但 JIT 编译器(C2 编译器和 Graal)会对简单的数组循环做自动向量化。RoaringBitmap 的 Java 实现刻意将关键循环写成简单的 for 形式,就是为了让 JIT 更容易做向量化优化。通过 -XX:+PrintCompilation 和 -XX:+TraceLoopOpts 可以验证是否触发了 SIMD。
GPU 加速(实验性阶段)
GPU 拥有数千个计算核心,理论上非常适合 Bitmap 运算。但实际落地有几个瓶颈:
- PCIe 传输开销:Bitmap 数据需要从主存拷贝到显存,PCIe 4.0 的带宽约 32 GB/s。如果 Bitmap 本身只有几十 MB,传输时间可能超过计算时间,得不偿失。
- 访存模式不友好:ArrayContainer 的二分查找涉及大量随机访问,GPU 的 DRAM 延迟远高于 CPU 的 L1/L2 cache。
- 编程复杂度:CUDA/OpenCL 的开发和调试成本较高。
目前真正在生产中使用 GPU 做 Bitmap 运算的场景还很少。NVIDIA 的 RAPIDS 项目中的 cuDF 库提供了一些 GPU 加速的 Bitmap 操作,但主要用在数据科学的 DataFrame 过滤场景,而不是通用的 Bitmap 集合运算。
结论:短期内 SIMD 是更实际的优化方向,ROI 远高于 GPU。如果你使用 CRoaring(C/C++ 版 RoaringBitmap),SIMD 加速是开箱即用的;如果你用 Java 版,可以通过 JNI 调用 CRoaring 来获得 SIMD 红利,Apache Druid 就是这么干的。
10.3 持久化与 LSM-Tree 的结合
在 OLAP 系统中,Bitmap 通常不是临时的内存数据结构,而是需要持久化到磁盘并参与存储引擎的合并流程。这里面有几个关键的工程问题。
ClickHouse 的 AggregatingMergeTree 方案
ClickHouse 的做法比较直接:将 Bitmap 序列化为二进制 blob,作为 AggregateFunction 类型的列值存储。在 MergeTree 的 compact 过程中,相同 key 的多行会通过 groupBitmapMerge(本质是 Bitmap OR 运算)合并为一行。
Merge 过程:
Part 1: tag="90后", bitmap={1,3,5}
Part 2: tag="90后", bitmap={2,5,7}
↓ merge
Result: tag="90后", bitmap={1,2,3,5,7} // OR 合并
这个方案的优雅之处在于它复用了 LSM-Tree 本身的 compaction 机制,不需要额外的合并逻辑。但也有局限:
- 只支持 OR 合并:如果业务需要 AND 语义的增量合并(比如"保留历史所有天都活跃的用户"),需要在查询层处理
- compaction 时的 CPU 开销:大 Bitmap 的反序列化→合并→序列化链路比简单的数值累加重得多
Apache Druid 的 Bitmap 索引方案
Druid 对每个维度列的每个值维护一个 Bitmap 索引(倒排索引)。比如 city 列有值 {北京, 上海, 广州},就会有三个 Bitmap:
city=北京: bitmap{0, 3, 7, 12, ...} // 这些行的 city 值是"北京"
city=上海: bitmap{1, 4, 8, ...}
city=广州: bitmap{2, 5, 9, ...}
查询 WHERE city='北京' AND age='90后' 时,直接把两个 Bitmap 做 AND,得到的就是满足条件的行号集合,然后回表读取数据。
Druid 的 Bitmap 索引在 Segment 文件中以 RoaringBitmap 的序列化格式存储,可以 mmap 直接映射到内存,不需要反序列化就能做 AND/OR 运算(RoaringBitmap 支持 ImmutableRoaringBitmap 接口,直接在 ByteBuffer 上操作)。这种零拷贝的方式极大减少了 IO 开销。
RocksDB 中的 Bitmap 合并
RocksDB 提供了 MergeOperator 接口,允许用户自定义 compaction 时的合并逻辑。可以实现一个 BitmapMergeOperator:
class BitmapMergeOperator : public MergeOperator {
bool FullMerge(const Slice& key,
const Slice* existing_value,
const std::deque<std::string>& operand_list,
std::string* new_value) override {
Roaring result;
if (existing_value) {
result = Roaring::readSafe(existing_value->data(), existing_value->size());
}
for (const auto& operand : operand_list) {
Roaring delta = Roaring::readSafe(operand.data(), operand.size());
result |= delta; // OR 合并
}
result.runOptimize();
// 序列化回 new_value
size_t size = result.getSizeInBytes();
new_value->resize(size);
result.write(new_value->data());
return true;
}
};
这样每次写入只需要 append 一个增量 Bitmap(delta),读取时 RocksDB 会在 read path 上自动合并所有层的 delta。写放大问题通过 compaction 来控制。
10.4 精确 vs 概率:数据结构选型决策框架
Bitmap、HyperLogLog、Count-Min Sketch、Bloom Filter、Cuckoo Filter——这些数据结构都在"用空间换时间"这个大方向上做文章,但各自的取舍点不同。一个实用的选型框架如下:
问自己三个问题:
- 需要精确结果还是近似结果?
- 需要哪些操作?(计数 / 存在性判断 / 集合运算 / 单元素反查)
- 数据规模和可用内存是多少?
决策矩阵:
| 需求 | 推荐方案 | 空间开销 | 精确度 |
|---|---|---|---|
| 精确计数 + 集合运算 + 元素反查 | RoaringBitmap | O(N) 压缩 | 100% |
| 精确计数 + 集合运算(无反查) | RoaringBitmap | O(N) 压缩 | 100% |
| 近似计数(不需要集合运算) | HyperLogLog | 固定 12 KB | ~0.81% 误差 |
| 近似计数 + 并集 | HyperLogLog | 固定 12 KB | ~0.81% 误差 |
| 存在性判断(允许假阳性) | Bloom Filter | ~10 bit/元素 | 可调误判率 |
| 存在性判断 + 删除支持 | Cuckoo Filter | ~12 bit/元素 | 可调误判率 |
| 频率估计(某元素出现几次) | Count-Min Sketch | 固定大小 | 过估计 |
| Top-K 高频元素 | Count-Min Sketch + 最小堆 | 固定大小 | 近似 |
几个关键的选型原则:
原则一:精确度需求是硬约束。如果业务场景涉及计费、合规审计、数据对账,精确度不可妥协,必须用 Bitmap 或传统方案。概率数据结构只适用于"差一点也行"的场景——监控大盘、趋势分析、推荐系统的粗筛等。
原则二:操作丰富度决定上限。HyperLogLog 虽然省内存,但它只能做并集和计数,做不了交集和差集。如果业务需要"A 且 B 且非 C"这种组合查询,HyperLogLog 直接出局,必须用 Bitmap。
原则三:别忽略 Bitmap 的压缩能力。很多团队在看到"1 亿用户需要 12.5 MB"时就觉得太大了,转而用 HyperLogLog。但实际上 RoaringBitmap 在很多真实数据分布下,压缩后的体积只有理论上限的 10%~30%。先实测再做判断,不要被理论值吓退。
一个真实的选型案例:
某电商平台需要统计"过去 7 天访问过商品详情页的 UV"。
- 方案 A(HyperLogLog):7 个 HyperLogLog 做 PFMERGE,12 KB × 7 = 84 KB 内存,结果有 0.81% 误差。如果日活 1000 万,误差范围约 ±8.1 万。
- 方案 B(RoaringBitmap):7 个 Bitmap 做 OR,实测总内存约 15 MB(用户 ID 分布较密)。结果 100% 精确。
如果这个数据只用来在运营大盘上展示一个趋势数字,8 万的误差完全不影响决策,用 HyperLogLog 省 99.4% 的内存,是合理的。但如果要根据这个数据给每个符合条件的用户发优惠券(需要具体的用户 ID 列表),HyperLogLog 做不到——只有 Bitmap 可以。
混合方案也很常见:用 HyperLogLog 做实时的粗略统计(响应监控大盘的秒级查询),同时用 Bitmap 离线计算精确结果(T+1 出报表)。两者互补而不是互斥。
参考资料
- Daniel Lemire et al. "Roaring Bitmaps: Implementation of an Optimized Data Structure" (2016)
- Redis 官方文档 - Bitmaps:redis.io/docs/data-t…
- ClickHouse 官方文档 - Bitmap Functions:clickhouse.com/docs/en/sql…
- JDK BitSet 源码(OpenJDK 17)
- RoaringBitmap GitHub:github.com/RoaringBitm…
- Burton H. Bloom. "Space/Time Trade-offs in Hash Coding with Allowable Errors" (1970)