1. 背景说明
在实习过程中要过滤黑名单用户,首先第一个想到的是 set,但是内存不足了,然后想到布隆过滤器,但是布隆过滤器会有误判率,因为它也是基于 hash 函数的,不同用户 id 可能会生成同一个一个 hash 值,假如这个时候要拦截黑名单用户,放行正常用户,这个时候正常用户就可能被拦截。
然后就想到了 bitmap,位图。
2. bitmap 简介
位图(Bitmap)是一种通过位来表示数据的结构,适合用于大量数据的状态表示和快速判断。它以数组的形式存储数据,每一个元素是一个 bit(即 0 或 1)。通过位图,可以节省大量的存储空间,并能以极高的效率对某些特定的数据进行操作,比如判断某个元素是否存在、设置某个元素的状态等。
- SETBIT: 设置某个偏移量的 bit 值,语法:
SETBIT key offset value
- GETBIT: 获取某个偏移量的 bit 值,语法:
GETBIT key offset
- BITCOUNT: 计算位图中 bit 为 1 的数量,语法:
BITCOUNT key
- BITOP: 对多个位图进行按位与、或、异或等操 做
2.1. 我的理解
举个例子: 一个 bit 位可以表示两个状态:0,1,8 个 bit 能表示多少 状态,2^8 = 256,所以 java 里面的 byte,可以表示的数字范围是 -2^7-2^7-1 的。
所以假设我要表示 n 个状态,同时每个状态又有多个可能, 要多少个 bit 位来表示呢?
2.1.1. 2. 每个状态有多个可能(多值状态)
如果每个状态有 m 个可能的值,那么每个状态需要 log₂(m) 个 bit 来表示。例如:
- m = 4(4 个可能值):需要 2 位(因为 2² = 4)
- m = 8(8 个可能值):需要 3 位(因为 2³ = 8)
总所需 bit 位数:
这里注意一下: Bitmap 是基于 bit 进行操作的,但无论是在操作系统层面还是在数据存储层面,最终内存分配的最小单位仍然是字节。
所以这里涉及到内存对齐的问题,比如: 例如,表示 10 位需要 2 字节,而实际只用到了其中的 10 位,剩余的 6 位未使用
3. bitmap 应用
前面说了可以快速判断 某个值存不存在,有一个典型的场景题:
3.1. 40亿个QQ号,如何判断一个QQ号是否存在?
要实现 O(1)时间复杂度的话,如果对内存没有限制,直接用 set。
但是如果对内存有限制的话,数组也可以实现O(1)复杂度的查询,如果只是判断是否存在的话用一个位数组就好了,QQ号是10位(有 100 亿个数字),所以需要 100 亿 bit=12.5 亿字节=1.64 GB;
注意,bitmap 是用偏移量来表示数字的大小,然后用这个 bit 位 0 和 1,来表示这个数字存不存在,如果需要判断这个数字吹出现次数是多次呢?只需要用两个 bit 位来表示每个数字的状就可以了。
3.2. 过滤黑名单的场景
实习的时候用 redis 的位图来判是不是封禁用户,问了社区那边现在的用户数不超过 1.1 亿, 使用位图表示 1.1 亿个用户 ID 是否被封禁,需要约 13MB 的内存。
但后觉得这个内存占用是有点没法接受的,所以就想了一个馊主意,把 9位数的用户id分成三个三位数分别去校验,然后呢这个时候只需要 3*1000bit,375 字节就可以了。但是这样其实会造成误判的,一个非黑名单用户 id,分成三个三位数顺序校验也可能都校验成功。
位图适用于能够将元素映射到唯一索引的场景,每个元素对应一个唯一的 bit 位。
拆分用户 ID 并使用位图的方法无法保证用户 ID 的唯一性,违背了位图使用的基本原则。
但是我觉得内存还是太大了,在网上了解到了压缩位图这种数据结构(Java 有个 RoaringBitmap 的库)。 相比于普通位图,压缩位图在保持高查询性能的同时,显著降低了内存占用 。大致原理是通过 压缩稀疏数据,大幅降低内存占用,然后只会占用 3MB 的内存。
4. 压缩位图
- 压缩位图(如 Roaring Bitmap) 通过 压缩稀疏数据,大幅降低内存占用,
- 原理是分块存储: 将整个数字范围按 块(block) 划分,每个块包含 65,536 个数字
-
- 块索引: 用数字的高 16 位确定属于哪个块。
- 块内偏移: 用数字的低 16 位确定在块内的位置。
- 针对块内数据的密度,选择不同的存储方式:
-
- 稀疏块(少量数字): 使用 数组(Array Container) ,存储存在的数字列表。
- 密集块(大量数字): 使用 位图(Bitmap Container) ,类似传统位图,但只针对该块。
- 连续数字块: 使用 运行长度编码(Run-Length Encoding, RLE) ,记录连续数字的起始和结束位置。