位运算、缓存行与分段压缩:Bitmap 的高性能进化之路

0 阅读32分钟

导读: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 则为 11010 & 1100 = 1000
按位或|至少一位为 1 则为 11010 | 1100 = 1110
按位异或^两位不同则为 11010 ^ 1100 = 0110
按位取反~0 变 1,1 变 0~1010 = 0101
左移<<向高位移动,低位补 00001 << 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() 替换为单条机器指令。

图1:Bitmap位运算操作示意图

三、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。

图2:Bitmap内存布局图

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 B4 MB无去重能力
HashSet<Integer>~48 B48 MB装箱 + Entry 开销
BitSet1 bit1.25 MB按值域分配
RoaringBitmap≤1 bit≤1.25 MB压缩后可能更小

图3:Bitmap与其他数据结构的空间对比

四、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:JDK BitSet内部结构

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:RoaringBitmap整体架构

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。

图6:RoaringBitmap三种Container对比

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 AContainer B算法
ArrayArray双指针归并(类似 merge sort 的 merge)
ArrayBitmap遍历 Array,逐个在 Bitmap 中查找
BitmapBitmap逐 word AND 运算
RunRun区间求交

一个有趣的优化:Array ∩ Bitmap 时,如果 Array 很短,直接遍历 Array 逐个查找比将 Array 转为 Bitmap 再做位运算更快,因为避免了 8 KB BitmapContainer 的内存分配和初始化。

图7:RoaringBitmap集合运算策略

5.6 实际性能数据

在实际的基准测试中(数据来自 RoaringBitmap 官方 benchmark),与其他 Bitmap 实现相比:

操作RoaringBitmapEWAHConcise朴素 BitSet
交集1x(基准)3-5x5-10x0.5-2x
并集1x2-4x3-8x0.5-1.5x
序列化大小1x1.5-3x1.2-2x2-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 个哈希函数。

图8:布隆过滤器原理图 生产应用

  • 缓存穿透防护:在 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

图9:用户画像Bitmap应用架构

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。

图10:权限控制位图模型

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,需要在客户端做额外处理。

图11:Redis Bitmap架构

7.4 Redis Bitmap vs Redis HyperLogLog

在 UV 统计场景中,Redis Bitmap 和 HyperLogLog 都能用,但特性差异明显:

特性BitmapHyperLogLog
内存占用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';

图12:ClickHouse Bitmap处理流程 这种方案相比传统的 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

图13:稀疏与稠密数据的策略选择

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 运算。但实际落地有几个瓶颈:

  1. PCIe 传输开销:Bitmap 数据需要从主存拷贝到显存,PCIe 4.0 的带宽约 32 GB/s。如果 Bitmap 本身只有几十 MB,传输时间可能超过计算时间,得不偿失。
  2. 访存模式不友好:ArrayContainer 的二分查找涉及大量随机访问,GPU 的 DRAM 延迟远高于 CPU 的 L1/L2 cache。
  3. 编程复杂度: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——这些数据结构都在"用空间换时间"这个大方向上做文章,但各自的取舍点不同。一个实用的选型框架如下:

问自己三个问题

  1. 需要精确结果还是近似结果?
  2. 需要哪些操作?(计数 / 存在性判断 / 集合运算 / 单元素反查)
  3. 数据规模和可用内存是多少?

决策矩阵

需求推荐方案空间开销精确度
精确计数 + 集合运算 + 元素反查RoaringBitmapO(N) 压缩100%
精确计数 + 集合运算(无反查)RoaringBitmapO(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)