先分享一个我自己的经历。之前做用户行为风控系统,每天要处理上亿条日志,里面有个需求很朴素:判断某个用户ID今天是不是第一次出现。我一开始直接上了HashSet<String>,结果线上实例内存肉眼可见地往上涨,没撑过两轮压测就OOM了。后来把存储结构换成位图(Bitmap),内存一下降了两个数量级;再遇到URL去重这种没法直接用整数索引的场景,又引入了布隆过滤器(Bloom Filter)。这两个工具放到一起,其实就是海量数据下做“存在性判断”的标配思路,也是面试里高频出现的考点。
这篇文章我会把位图和布隆过滤器的原理、选型逻辑、代码实现、生产参数算法一次讲透,最后送上几个我自己反复踩坑后总结出来的工程经验。想省心的,直接照着用问题不大。另外提醒一句,如果你搜“位图”搜出来一堆PCB点位图、电路板点位图之类的,说明你搜到另一个领域了——这里说的是数据结构里的Bitmap,是用“一个比特位”表示“一个状态”的存储结构,和图片处理中的位图完全两码事。
1. 先搞清楚你在找哪一种“位图”
1.1 一个让内存从GB降到MB的面试题
很多后端同学对“海量数据存在性判断”这个概念,都是从一道经典面试题开始的:给你10亿个整数,范围在0到20亿之间,内存限制1GB,如何判断某个数是否出现过?
这题第一反应基本都是哈希表。但你可以算一笔账:10亿个int,就算去掉对象头,光裸数据就是40亿字节,约4GB,这还没算HashMap的Node节点、扩容冗余和指针开销,真要用HashSet<Integer>存,内存奔着几十GB去了,明显不行。
那用位图是什么结果呢?20亿个可能的取值,用20亿个bit去标记,也就是20亿 / 8 = 2.5亿字节,约250MB。就算再留一倍余量,500MB也放进1GB限制了。查询的时候算一次下标取一个bit,时间复杂度O(1),快得离谱。
这就是位图的核心逻辑:用一块连续的内存空间,把“值”映射成“位置”,位置上的0/1代表“不存在/存在”。
1.2 存在性判断的本质:允许误差吗
不过现实业务不像面试题那么干净,数据常常不是整数,而是URL、手机号、订单ID这种字符串。位图没有办法直接索引一个字符串,这时候就需要把字符串通过哈希函数“折叠”成整数位置,用一个位数组去标记它。
这里会出现一个关键分岔:哈希折叠是有概率冲突的。两个不同的字符串可能落进同一个bit位,于是“判断A存在,但其实是B的标记”——这就是布隆过滤器里著名的误判(false positive)。换句话说,布隆过滤器告诉你的答案是“可能存在”和“一定不存在”,不是100%的“存在”。
所以选型的一开始,先想清楚业务允不允许误判。允许,就用布隆过滤器,省内存省到极致;不允许,老老实实上精确结构,比如位图(如果数据是整数)或者精确的哈希表。这个决策点,比后面所有细节都重要。
2. 位图:整数集合判断的“一张点名表”
2.1 一个bit位回答一个“是否存在”
位图的实现思路简单到有点朴素。想象你有一张巨大的点名表,表上有N个格子,每个格子只可能被涂成“已到”或“未到”两种状态。数据里的数字是什么,你就去第几个格子上涂一笔。查询时看对应格子有没有被涂过。
在计算机里,“两种状态”天然对应一个bit,也就是二进制的0和1。因为一个字节有8个bit,所以如果取值范围是0到7,一个字节就够了;0到100万,125KB就够。这里要注意的是,位图占用的内存不取决于你往里面塞了多少个元素,而是取决于值域范围有多大。即使你只存3个数,但它们的取值范围是0到20亿,位图仍然要占用250MB。
理解了这一点,位图的优缺点其实已经写在脸上了:
- 省内存,查询、写入都是O(1),速度极快
- 只适合“非负整数”或者能转成非负整数的场景
- 值域很稀疏时反而浪费内存
2.2 内存账本:为什么能省这么多
我用一个表格,直接对比一下不同存储方式存1000万个整数(假设值域在0到1亿之间)的内存占用:
| 存储方式 | 理论内存 | 实际估算 | 备注 |
|---|---|---|---|
HashSet<Integer> | 约40MB裸数据 | 300MB以上 | 算上对象头、指针、扩容冗余 |
int[] | 40MB | 40MB | 但判断“是否存在”得遍历 |
| 二分查找+排序数组 | 40MB | 40MB | 排序后可以二分,但插入成本高 |
| 位图 | 12.5MB | 12.5MB | 1亿bit除以8 |
你可以看到,位图在空间上的优势是数量级的。尤其是“值域密集、数据量大”的场景,位图几乎是无脑最优解。它唯一的短板是没法处理负数和字符串,但这可以通过“加偏移量”和“哈希二次映射”来补救,后面会讲。
2.3 极简Java实现与Redis实操
自己实现一个位图并不难,核心就是用一个long[]数组,每个long是64位,然后通过位运算定位到具体某一个bit。
public class BitMap { private final long[] words; private final int bitCount; public BitMap(int bitCount) { this.bitCount = bitCount; this.words = new long[(bitCount + 63) / 64]; } public void set(int index) { checkRange(index); words[index / 64] |= (1L << (index % 64)); } public boolean get(int index) { checkRange(index); return (words[index / 64] & (1L << (index % 64))) != 0; } public void clear(int index) { checkRange(index); words[index / 64] &= ~(1L << (index % 64)); } private void checkRange(int index) { if (index < 0 || index >= bitCount) { throw new IndexOutOfBoundsException("index: " + index); } } }这里有个小细节:index / 64定位到第几个long,index % 64定位到long里的第几位。位运算里的1L << (index % 64)注意一定要用1L而不是1,否则当移位超过31位时,int会被自动包装,结果完全不对。
更省心的是用现成的类。Java里有java.util.BitSet,已经封装好了set、get、clear、nextSetBit这些方法,内部就是long数组,改改直接用非常方便。但BitSet的set方法是线程不安全的,分布式场景别忘加锁或者用AtomicLongArray。
如果不想写Java代码,Redis也内置了位图操作,日常开发做个日活、签到简直不要太顺手:
# 记录用户100086在2024-01-01登录过 SETBIT login:2024-01-01 100086 1 # 查询用户100086那天是否登录 GETBIT login:2024-01-01 100086 # 统计当天登录人数 BITCOUNT login:2024-01-01 # 连续3天都登录过的用户,把3天的位图做AND运算 BITOP AND login:3days login:2024-01-01 login:2024-01-02 login:2024-01-03注意Redis的SETBIT的value只能是0或1,offset就是你的业务ID。如果用户ID很大,比如9位数的手机号,也没关系,offset直接用它就行。但offset太大会导致单个key的底层字符串变长,记得评估一下内存,一台实例别塞太多大offset的key。
2.4 位图的三个边界条件
第一个坑是负数。位图的下标天然是非负整数,遇到负数怎么办?常见的做法是加一个偏移量,比如区间是[-1000, 1000],存的时候用index = value + 1000。如果范围很大又不想提前知道边界,那就把它转换成long再处理,或者干脆走布隆过滤器路线。
第二个坑是值域太稀疏。比如你有1000万个随机整数,但它们的取值范围是0到100亿,那用位图意味着你要申请100亿bit = 1.25GB的内存,为了存1000万个元素花1.25GB,这就很不值了。这种场景反而应该用哈希表或者布隆过滤器。
第三个坑是你必须提前知道值域上限。位图在初始化时就要分配好空间,后面没法轻松扩容。如果业务上线后数值不断变大,位图很容易“撑爆”。稳妥的做法是预留足够的余量,或者在业务上做好分段,比如按年拆分位图,每个key对应一年的数据,跨年滚动使用。
3. 布隆过滤器:把“哈希表”折叠成一张位图
3.1 从哈希函数组合说起
布隆过滤器的思想可以这样理解:字符串没法直接当位图下标,那我就用哈希函数把它算成一个整数,再把这个整数取模到位数组的长度范围内,把对应bit置1。
但单个哈希函数冲突概率太高,两个不同字符串可能哈希到同一个位置。布隆过滤器的设计巧妙之处在于:它用了K个相互独立的哈希函数。插入时,用K个哈希函数算出K个位置,全部置1。查询时,也用K个哈希函数算出K个位置,检查这K个位置是否全部为1。
类比一下特别好懂:你开始在K个不同窗口都登记过名字,后来有人来核对,只要有一个窗口说“没见过”,那基本可以肯定你确实没登记过;但如果所有窗口都说“见过”,那也可能是几个窗口一起记岔了。所以布隆过滤器的判定结果是:
- 某个位置为0,一定不存在
- 所有位置都为1,可能存在
这个“可能存在”就是误判的根源。好消息是,误判率是可以通过参数控制的,而且布隆过滤器永远不出现“漏判”,也就是如果数据真的在集合里,查询结果一定为true。这个特性在缓存穿透、URL去重里非常有价值。
3.2 误判率公式与参数估算
布隆过滤器有三个核心参数:
n:预期插入的元素个数m:位数组长度,单位是bitk:哈希函数个数
误判率的理论公式是:
- 最优哈希函数个数:
k = (m / n) * ln2 - 位数组长度估算:
m = - (n * ln(p)) / (ln2)^2
其中p是你期望的误判率,ln2约等于0.693。这俩公式是工程上最常用的。
举一个具体例子。假设我们要对1000万个URL去重,希望误判率控制在1%,也就是p=0.01:
m = - (10^7 * ln(0.01)) / (0.693)^2 ≈ 9.58 * 10^7 bit,换算成字节约11.4MBk = (m / n) * ln2 ≈ 9.58 * 0.693 ≈ 6.64,取整为7
也就是说,一个11.4MB的位数组,配合7个独立的哈希函数,就能支撑1000万数据、1%误判率的需求。这个体量放在生产环境里完全不算事。
实际开发中留心一点:公式算出来只是理想值,因为真实数据分布、哈希函数质量都会影响最终表现。我一般会在算出来的m上再乘1.5到2的冗余系数,宁可多占一点内存,也要给以后数据增长留空间,毕竟布隆过滤器满了再扩容是非常痛苦的。
3.3 代码落地:Guava一行搞定,手写版理解原理
生产环境我强烈建议直接用经过充分测试的库,Java生态里最常用的是Guava的BloomFilter:
import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.nio.charset.StandardCharsets; BloomFilter<String> filter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 10_000_000L, 0.01 ); filter.put("news:12345"); boolean exists = filter.mightContain("news:12345"); // true boolean notExists = filter.mightContain("news:99999"); // 可能为true,也可能为falsecreate的第三个参数就是预期的误判率,Guava内部会自动计算最佳位数组长度和哈希函数个数。注意BloomFilter不是线程安全的,多线程写入时需要外部加锁,或者用ConcurrentHashMap加布隆过滤器的组合方案。
如果想搞清楚原理,我写了一个简化版,核心逻辑也就几十行:
import java.util.BitSet; public class SimpleBloomFilter { private final BitSet bits; private final int bitSize; private final int hashCount; public SimpleBloomFilter(int bitSize, int hashCount) { this.bits = new BitSet(bitSize); this.bitSize = bitSize; this.hashCount = hashCount; } public void add(String value) { for (int i = 0; i < hashCount; i++) { bits.set(hash(value, i)); } } public boolean mightContain(String value) { for (int i = 0; i < hashCount; i++) { if (!bits.get(hash(value, i))) { return false; } } return true; } private int hash(String value, int i) { int h1 = value.hashCode(); int h2 = (h1 >>> 16) * 31 + 0x9E3779B9 + i * 0x9E3779B9; return ((h1 + i * h2) & 0x7FFFFFFF) % bitSize; } }注意这里为了演示简单,我用了String.hashCode()和一个衍生hash来模拟K个独立哈希,实际工程要追求分布均匀,通常会用MurmurHash等高质量哈希函数。Guava内部就是这么干的。
3.4 为什么标准布隆过滤器不能删除
这是新手最容易踩的坑:想从布隆过滤器里删除一个元素,把它对应的K个bit置0?不行,因为这K个bit可能还承载了其他元素的信息,一旦你清零,其他元素的查询结果就被破坏了。
也因此,标准布隆过滤器只支持插入和查询,不支持删除。如果业务上有“删除”需求,比如推荐系统里用户把某条内容划走了不想再看到,直接把它的bit置0会污染整个过滤器,正确做法是用“计数布隆过滤器”(Counting Bloom Filter),也就是每个bit位换成计数器,删除时对应位置的计数减1,减到0才真正释放。但代价是内存暴涨几倍,工程上用得不算多。
更朴素也更常见的做法是“定期重建”:数据量超过预估后,新建一个过滤器,把老数据重新灌进去,然后切换读和写。Redis里甚至直接有BF.RESERVE、BF.ADD、BF.EXISTS这类命令,底层就是计数布隆过滤器,功能上对应用层友好很多。
4. 哈希表、位图、布隆过滤器:三张表做选型
4.1 三个维度横向对比
很多同学面试时被问到“哈希表和布隆过滤器怎么选”,其实把三者放在一张表里看就清楚了:
| 对比项 | 哈希表 | 位图 | 布隆过滤器 |
|---|---|---|---|
| 查询结果 | 精确 | 精确 | 有误判(不会漏判) |
| 内存占用 | 高,和元素个数成正比 | 极低,和值域大小成正比 | 低,和预期元素个数相关 |
| 查询耗时 | O(1),但哈希碰撞可能退化 | O(1),纯位运算 | O(k),k通常很小,接近O(1) |
| 支持的数据类型 | 任意对象 | 非负整数 | 任意对象(经哈希映射) |
| 删除操作 | 支持 | 支持 | 标准版不支持 |
| 典型场景 | 数据库索引、Map结构 | 日活统计、签到、在线状态 | URL去重、缓存穿透防护 |
从这张表能清晰看到,位图和布隆过滤器并非竞争关系,而是互补的。位图是“精确但限整数”,布隆过滤器是“省内存但有概率误判”,哈希表则是“功能全面但费内存”。
4.2 工业选型经验:什么时候无脑上位图
我自己的选型习惯是这样的,你可以直接抄:
- 数据是整数或可以转成整数,值域已知且相对密集,业务要求精确判断,优先位图。比如用户ID、手机号、活动ID去重。
- 数据是字符串、值域未知、业务对“偶尔误判”不敏感,优先布隆过滤器。比如URL爬虫去重、缓存穿透保护。
- 数据量小,几千到几十万级别,别折腾这些结构,直接用哈希表省心。位图和布隆过滤器的优势在大数据量下才体现得出来。
- 如果既要精确又要省内存,位图+哈希表的混合方案也常见,外层位图过滤掉大部分明显不存在的数据,命中后再用哈希表兜底确认,既能挡住无效请求,又能保证最终结果准确。
还有一点别忽视:不要为了炫技硬上。规模没到,百万元素级别你用HashSet照样跑得很欢,没必要引入额外的复杂度和维护成本。
4.3 Redis里的现成答案
生产环境不是所有场景都能自己写库,很多时候你只需要在Redis层把方案落下去。Redis本身支持位图命令,也提供了RedisBloom模块支持布隆过滤器。
位图已经提过,这里说下RedisBloom的用法。安装模块后,你可以这样初始化一个布隆过滤器:
# 预分配:期望插入100万个元素,误判率1% BF.RESERVE my_bf 0.01 1000000 # 插入 BF.ADD my_bf "order:12345" # 查询 BF.EXISTS my_bf "order:12345"用Redis做布隆过滤器有个好处:它就是天然的分布式共享状态,多个服务实例同时读写不需要自己做同步。坏处是Redis本身可能成为瓶颈,插入量和查询量很大的时候,网络开销不可忽略,所以有些场景会用本地Guava布隆过滤器做一级过滤,Redis布隆过滤器做二级过滤,把流量分层扛住。
5. 三个生产案例还原
5.1 用户签到与连续性统计:位图最顺手的场景
做签到功能时,如果每个用户都存一条签到记录,运营一年下来光签到表数据量就很吓人。用位图的话,一个用户可以对应一个key,key的每一位代表某一天是否签到。
比如用户ID为100086,2024年1月1日签到:
SETBIT sign:2024:01 100086 0这里把offset设为100086,bit位为0就表示那天签到。如果要算某天签到人数,直接BITCOUNT。如果要算连续7天签到用户,用BITOP AND把7天的位图求交,结果位图里为1的位置就是连续签到的用户ID。
这个方案最舒服的点在于空间:一个用户一年的签到记录只要365个bit,约46字节。1亿用户一年也不过4.6GB左右,分散到不同月份的key里,单key内存完全可控。
注意Redis单key的字符串上限是512MB,所以别把全量用户塞进同一个key,建议按天或者按月拆key,这样无论是统计还是运维清理都比较方便。
5.2 新闻/商品推荐去重:布隆过滤器的标准用法
推荐系统里经常要判断“这条新闻用户看过了没”,如果用户已经看过的item放在Set里精确存储,内存压力很大。我之前处理过一个千万级用户、亿级内容量的推荐项目,最后就是给每个用户建了一个布隆过滤器。
写入时,用户每看一条内容就filter.put(contentId);推荐时先mightContain,返回false就直接过滤掉,返回true再做一次精确比较,避免误删。
这里有个很实用的优化:布隆过滤器的误判率只在“已经完全放满”时才明显失控,所以我在设计时给每个用户的过滤器预估容量是实际内容的1.5倍。用户一天最多看200条内容,就给过滤器预留300的容量,这样就能保证过滤器不会很快就满,误判率也一直保持在低位。
有个缺点也得承认:用户量大的时候,每个用户一个过滤器,内存也不小。1000万用户,每个过滤器按1000bit算,就是10Gbit约1.25GB,加上对象开销,单个实例可能扛不住。这种情况一般会做两层:本机用Guava缓存热点用户的过滤器,冷数据落到Redis里,查不到时再回源。
5.3 缓存穿透保护:先问布隆,再问Redis
缓存穿透是后端老生常谈的问题:大量请求查询一个不存在的key,Redis里没有,请求全部打到数据库,数据库直接被压垮。常规做法是缓存空值,但空值缓存时间短、效果有限,而且会被恶意key刷穿。
用布隆过滤器做前置拦截是更优雅的方案。核心思路是:数据库里每条存在的记录,写入时都把它存在主键ID放进布隆过滤器;查询时先过布隆过滤器,如果返回false,说明这个ID大概率不存在,直接返回空,根本不用进Redis和数据库。
public Article getArticle(Long id) { String key = "article:" + id; // 第一层:布隆过滤器判断是否存在 if (!articleBloom.mightContain(key)) { return null; } // 第二层:查Redis缓存 Article article = redis.get(key); if (article != null) { return article; } // 第三层:查数据库,并把结果写回缓存 article = dao.findById(id); if (article != null) { redis.set(key, article); } return article; }注意一个细节:布隆过滤器的数据是在“写入DB”时同步灌进去的,不是插入Redis时。而且如果系统有删除数据的操作,被删掉的数据ID还会留在过滤器里,查询时依然会放行到Redis和DB,不过因为是“可能存在”,最后还是会落库查一次。所以这类方案更适合“数据只增不删”或者“删除不频繁”的场景,如果删除操作太频繁,过滤器里的残留数据会让防护效果大打折扣。
6. 工程避坑速查:这些问题我踩过
6.1 误判带来的脏数据如何兜底
布隆过滤器最大的隐患就是误判,哪怕误判率只有1%,在海量请求下也会累积成大量无效查询,所以“完全信任BF结果”是不能接受的。我在生产环境的标准做法永远是“BF是过滤器,不是最终结论”:它放行后,后续流程一定要有精确存储做兜底。比如上面的缓存穿透例子,BF放行后还是会走Redis和DB,Redis和DB会给最终答案。这样就保证了系统性能被BF优化,但数据正确性不受误判影响。
6.2 容量估算错了,怎么调整
预估n只估了1000万,结果业务爆发增长到5000万,布隆过滤器会出现什么情况?位数组里1的比例快速上升,误判率急剧升高,最坏情况整个数组全1,用什么key都返回true,等于过滤器失效了。
这类问题没有灵丹妙药,最直接的方案是“换更大的过滤器,全量重建”。数据不大时重建成本可接受;数据量大时,可以用双缓冲:先新建一个容量更大的过滤器,把老数据异步灌进去,数据迁移完成后再切换读写流量。整个过程对业务方透明,但代码实现得多一个开关控制。
更稳妥的办法,是上线前就按业务峰值的3到5倍来做容量规划,宁可多占内存,也别年中做一次全量重建,那个运维成本真是谁做谁知道。
6.3 负数、大整数、字符串怎么办
位图处理负数,用偏移量;处理超大整数,要么分段,要么转字符串后走布隆过滤器。这里说一个常见误区:不要把整数直接toString()后丢进HashSet交给位图处理,字符串哈希之后冲突概率会变大,而且位图的意义就没了。字符串场景应该直接用布隆过滤器。
还有要注意哈希函数必须返回非负整数。Java里String.hashCode()可能返回负数,所以手写布隆过滤器时一定要做& 0x7FFFFFFF这类处理,保证下标落在合法范围。这个问题看似小,但排查起来很隐蔽,经常表现为布隆过滤器“间歇性失灵”。
6.4 短期窗口判断:滚动布隆过滤器
最后分享一个性价比极高的实战技巧:滚动布隆过滤器。适用于“最近N分钟内是否出现过”“5分钟是否发过验证码”这类短时间窗口的判断需求。
实现思路是用一个固定大小的数组保存多个布隆过滤器,每个过滤器负责一个时间窗口,比如1分钟一个。新数据写入当前分钟的过滤器;查询时,遍历最近N个过滤器,只要有一个返回true就命中了。窗口过期后,直接丢弃对应过滤器,或者复用对象清空内部bit数组。
public class SlidingBloomFilter { private final BloomFilter<String>[] windows; private final int windowCount; private int currentIndex; public SlidingBloomFilter(int windowCount, long expectedInsertions, double fpp) { this.windowCount = windowCount; this.windows = new BloomFilter[windowCount]; for (int i = 0; i < windowCount; i++) { windows[i] = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedInsertions, fpp ); } } public void add(String value) { windows[currentIndex].put(value); } public boolean mightContain(String value) { for (BloomFilter<String> window : windows) { if (window.mightContain(value)) { return true; } } return false; } public void rollWindow() { currentIndex = (currentIndex + 1) % windowCount; windows[currentIndex] = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 10_000_000L, 0.01 ); } }这样设计的好处是旧数据会随着窗口滚动自然失效,不用手动清数据,非常适合“5分钟内不能重复发送验证码”“1小时内防重复提交”这类场景。我个人做短时窗口需求时已经离不了这个套路了,你直接把上面的代码拿过去改改参数就能用。