前言
布隆过滤器 [1] 是burton Howard Bloom提出的一种哈希算法的变体,它可以使用少量的内存空间来过滤非常大的数据,通过存储大量数据的信息,可以过滤重复数据,优化内存消耗,提高数据处理效率。布隆过滤器在大数据领域得到广泛应用:
1)重复数据删除:在大数据存储系统中,重复数据的存在导致内存浪费和数据管理效率低下。布隆过滤器可以用于检测重复数据,只保留唯一的副本,从而节省存储空间。
2)大数据存储:在大数据存储中,布隆过滤器可以用于索引和压缩数据。通过快速查询和过滤不感兴趣的记录,可以减少磁盘I/O操作,提高查询速度。
3)闪存:闪存一种电子式可清除程序化只读存储器的形式,具有较高的读写速度。布隆过滤器可以用于优化闪存的写操作,通过缓存重复数据和块,减少写操作次数,提高闪存的耐用性和寿命。
4)云计算:在云计算中,布隆过滤器可以用于数据安全和隐私保护。通过过滤敏感数据,可以保护用户的数据不被非法使用。
本文通过分享一些经典布隆过滤器的变体,来阐述近似成员查找的发展沿革。
哈希工具类实现
Hash.java
package org.example.bloomFilter;
import java.util.Random;
public class Hash {
private static Random random = new Random();
public static void setSeed(long seed) {
random.setSeed(seed);
}
public static long hash64(long x, long seed) {
x += seed;
x = (x ^ (x >>> 33)) * 0xff51afd7ed558ccdL;
x = (x ^ (x >>> 33)) * 0xc4ceb9fe1a85ec53L;
x = x ^ (x >>> 33);
return x;
}
public static long randomSeed() {
return random.nextLong();
}
/**
* 将哈希值缩小到位数组长度范围0..n内,得到位置的索引(这个方法近似取模)
* @param hash 哈希值
* @param n 缩小范围边界
* @return
*/
public static int reduce(int hash, int n) {
return (int) (((hash & 0xffffffffL) * n) >>> 32);
}
public static long multiplyHighUnsigned(long a, long b) {
return Math.multiplyHigh(a, b) + ((a >> 63) & b) + ((b >> 63) & a);
}
}
不实现此工具类,后续程序可能将无法运行。
布隆过滤器 Bloom filter
Bloom filter是一种用于快速判断一个元素是否属于一个集合的数据结构。它通过利用一个位数组和多个哈希函数来实现。
Bloom filter的工作方式如下:
1)首先,将位数组初始化为全0。当要将一个元素加入集合时,使用多个独立的哈希函数对该元素进行哈希运算,得到多个哈希值。
2)然后将位数组中对应的哈希槽位标记为1。
3)当判断一个元素是否在集合中时,同样进行多次哈希运算,检查对应位置的位是否都为1。如果有一位不为1,则可以确定该元素一定不在集合中;如果所有位都为1,则该元素可能在集合中,但也有一定的误判率。
布隆过滤器 适用于那些对时间和空间要求较高,而对误判率有一定容忍度的场景。它可以在常数时间内判断一个元素是否在集合中,且空间占用相对较小。但是由于使用了哈希函数和位数组,它无法删除已经加入的元素,并且存在一定的误判率。因此在某些应用场景下,布隆过滤器可能会产生一些误报。
实现
布隆过滤器的一个简单实现如下(注意此处用到的Hash类,见前言-工具类实现):
BloomFilter.java
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
public class BloomFilter {
/**
* 每个key能够映射到位数组的哈希槽数
*/
public static int k;
/**
* 总bits数
*/
public static long bits;
/**
* hash种子
*/
public static long seed;
/**
* 位数组长度换算成long数组后的长度
*/
public static int arraySize;
/**
* long数组
*/
public static long[] data;
public BloomFilter() {
}
/**
* @param entryCount 能够容纳的key数
* @param bitsPerKey 每个key的bits数
* @param kint 每个key能够映射到的哈希槽数
*/
public BloomFilter(int entryCount, double bitsPerKey, int kint) {
entryCount = Math.max(1, entryCount);
k = kint;
seed = Hash.randomSeed();
bits = (long) (entryCount * bitsPerKey);
arraySize = (int) ((bits + 63) / 64);
data = new long[arraySize];
}
public BloomFilter construct(long[] keys, double bitsPerKey) {
long n = keys.length;
int k = getBestK(bitsPerKey);
BloomFilter f = new BloomFilter((int) n, bitsPerKey, k);
for (long x : keys) {
f.add(x);
}
return f;
}
private int getBestK(double bitsPerKey) {
return Math.max(1, (int) Math.round(bitsPerKey * Math.log(2)));
}
public long getBitCount() {
return data.length * 64L;
}
public boolean supportsAdd() {
return true;
}
/**
* 添加key
*/
public void add(long key) {
// 计算哈希值
long hash = Hash.hash64(key, seed);
// 将哈希值高位和低位进行交换,增加哈希混淆性
long a = (hash >>> 32) | (hash << 32);
long b = hash;
for (int i = 0; i < k; i++) {
int position = Hash.reduce((int) (a >>> 32), arraySize); // 计算键映射到的位数组中的位置
data[position] |= 1L << a; // 使用位运算左移操作,将计算出的位设置为1
a += b; // 更新 a,增加哈希混淆性,保证key映射到不同位置
}
}
/**
* 查询 key
*/
public boolean mayContain(long key) {
long hash = Hash.hash64(key, seed);
long a = (hash >>> 32) | (hash << 32);
long b = hash;
for (int i = 0; i < k; i++) {
int position = Hash.reduce((int) (a >>> 32), arraySize);
if ((data[position] & 1L << a) == 0) {
return false;
}
a += b;
}
return true;
}
// 测试
public static void main(String[] args) {
// 初始化时间,200万长度的数组,和100万的位数组长度,
long time;
int len = 1000000;
long[] list = new long[len * 2];
// 创建200万长度的随机数组,每个元素不重复
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
// 随机数组的前一半作为过滤器的key,后一半不是过滤器的key
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
// 创建布隆过滤器
BloomFilter f = new BloomFilter();
f.construct(keys, 10);
// 查看keys是否存在
if (f.mayContain(keys[0])) {
System.out.println("布隆过滤器传入:" + keys[0]);
System.out.println(keys[0] + " 在过滤器中已存在");
System.out.println("=================");
}
time = System.nanoTime();
// 所有key查出,用于计时
int falseNegatives = 0;
for (int j = 0; j < len; j++) {
if (!f.mayContain(keys[j])) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / 2 / len;
// 计算假阳性率
int falsePositives = 0;
for (int j = 0; j < len; j++) {
if (f.mayContain(nonKeys[j])) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
long bitCount = f.getBitCount();
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + bitCount +
" lookupAllInSet " + lookupAllInSet + "ns"
);
}
}
执行main结果如下:
可以看到100万数据时假阳性率为 0.8%,总bit数1000万,查询耗时23ns。
计数布隆过滤器 Counting Bloom filter
布隆过滤器的问题: 布隆过滤器可以用于快速判断一个元素是否在集合中的数据结构,但是元素的存在判断是基于哈希函数,不是真正存储的元素本身。因此布隆过滤器无法准确地知道集合中某个元素的个数,也不可能在不影响其他元素的情况下删除存储的某个元素。
计数布隆过滤器(Counting Bloom Filter)是布隆过滤器的一种变种,用来标准布隆过滤器存在的无法删除元素的问题。它在标准布隆过滤器的基础上引入了计数器,允许在添加和删除元素时进行计数,从而更加灵活地控制保存信息的状态。
1)哈希函数索引到的数组位对应一个计数器,初始值通常为 0。
2)当要添加一个元素时,同样对元素经过多个哈希函数计算,计算出对应位置的计数器进行累加操作。
3)要查询一个元素是否存在时,会检查所有对应位置的计数器是否都大于 0。
4)当要删除一个元素时,会对元素通过多个哈希函数计算出的位置的计数器进行累减操作。
原理图如下:
优点:计数布隆过滤器能够追踪元素的出现次数,解决了布隆过滤器无法准确统计元素个数和删除元素的问题,适用于需要记录元素出现次数的场景,能允许删除元素。
缺点:计数布隆过滤器相比布隆过滤器占用更多的空间,因为每个哈希位置都需要存储一个计数器,在使用过程中需要考虑空间占用问题,另外随着计数器增加,可能会发生计数器溢出的情况,需要采取相应的措施来兜底。
实现
计数布隆过滤器实现如下:
CountingBloomFilter.java
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* 计数布隆过滤器
*/
public class CountingBloomFilter {
/**
* 每个key能够映射到位数组的哈希槽数
*/
public static int k;
/**
* 总bits数
*/
public static long bits;
/**
* hash种子
*/
public static long seed;
/**
* 位数组长度换算成long数组后的长度
*/
public static int arraySize;
/**
* 计数器long数组
*/
public static long[] counts;
public CountingBloomFilter() {
}
public CountingBloomFilter(int entryCount, double bitsPerKey, int kint) {
entryCount = Math.max(1, entryCount);
k = kint;
seed = Hash.randomSeed();
bits = (long) (4 * entryCount * bitsPerKey) + 64;
arraySize = (int) ((bits + 63) / 64);
counts = new long[arraySize];
}
public CountingBloomFilter construct(long[] keys, double bitsPerKey) {
long n = keys.length;
int k = getBestK(bitsPerKey);
CountingBloomFilter f = new CountingBloomFilter((int) n, bitsPerKey, k);
for(long x : keys) {
f.add(x);
}
return f;
}
private int getBestK(double bitsPerKey) {
return Math.max(1, (int) Math.round(bitsPerKey * Math.log(2)));
}
public long getBitCount() {
return counts.length * 64L;
}
public boolean supportsAdd() {
return true;
}
public boolean supportsRemove() {
return true;
}
private static long getBit(int index) {
return 1L << (index << 2);
}
/**
* 查询 key
*/
public void add(long key) {
long hash = Hash.hash64(key, seed);
int a = (int) (hash >>> 32);
int b = (int) hash;
for (int i = 0; i < k; i++) {
int index = Hash.reduce(a, arraySize << 4);
// 溢出时抛出异常
int oldCount = (int) (counts[index >>> 4] >>> (index << 2)) & 0xf;
if (oldCount >= 15) {
throw new RuntimeException("计数器溢出!");
}
counts[index >>> 4] += getBit(index);
a += b;
}
}
/**
* 删除 key
*/
public void remove(long key) {
long hash = Hash.hash64(key, seed);
int a = (int) (hash >>> 32);
int b = (int) hash;
for (int i = 0; i < k; i++) {
int index = Hash.reduce(a, arraySize << 4);
// 在对应位递减
counts[index >>> 4] -= getBit(index);
a += b;
}
}
/**
* 查询 key
*/
public boolean mayContain(long key) {
long hash = Hash.hash64(key, seed);
int a = (int) (hash >>> 32);
int b = (int) hash;
for (int i = 0; i < k; i++) {
int index = Hash.reduce(a, arraySize << 4);
if (((counts[index >>> 4] >>> (index << 2)) & 0xf) == 0) {
return false;
}
a += b;
}
return true;
}
// 测试代码
public static void main(String[] args) {
// 初始化时间,200万长度的数组,和100万的位数组长度,
long time;
int len = 1000000;
long[] list = new long[len * 2];
// 创建200万长度的随机数组,每个元素不重复
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
// 随机数组的前一半作为过滤器的key,后一半不是过滤器的key
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
// 创建布隆过滤器
CountingBloomFilter f = new CountingBloomFilter();
f.construct(keys, 10);
// 查看keys是否存在
System.out.println("布隆过滤器查询:" + keys[0]);
if (f.mayContain(keys[0])) {
System.out.println(keys[0] + " 在过滤器中已存在");
}
time = System.nanoTime();
// 所有key查出,用于计时
int falseNegatives = 0;
for (int j = 0; j < len; j++) {
if (!f.mayContain(keys[j])) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / 2 / len;
// 计算假阳性率
int falsePositives = 0;
for (int j = 0; j < len; j++) {
if (f.mayContain(nonKeys[j])) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
long bitCount = f.getBitCount();
// 删除key
if (f.supportsRemove()) {
f.remove(keys[0]);
System.out.println(keys[0] + " 在过滤器中已删除");
}
// 查看keys是否存在
System.out.println("布隆过滤器查询:" + keys[0]);
if (f.mayContain(keys[0])) {
System.out.println(keys[0] + " 在过滤器中已存在");
} else {
System.out.println(keys[0] + " 在过滤器中已不存在!");
}
System.out.println("=================");
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + bitCount +
" lookupAllInSet " + lookupAllInSet + "ns"
);
}
}
执行测试结果如下:
可以看到由于基本原理相似,计数布隆过滤器的假阳性率和布隆过滤器是差不多的,假阳性率都是0.8%,而计数布隆过滤器比布隆过滤器多支持了元素删除功能。
并且在这个案例中,前者的空间占用是布隆过滤器的4倍,并且查询耗时也较长,达到了56ns,是布隆过滤器的2倍多。
分块布隆过滤器 blocked bloom filter
分块布隆过滤器 Blocked Bloom Filter(BBF)也是对布隆过滤器 Bloom Filter 进行改进,主要用于解决布隆过滤器在处理缓存和流式数据时可能出现的性能问题。其基本原理是将整个位数组分成多个块(blocks),每个块有自己的位数组。每个块通过不同的哈希函数进行填充,当数据流中的哈希值分布不均匀时,可以减少假阳性的发生。
工作原理:
1)BBF将位数组划分为多个块,每个块使用不同的哈希函数。每个块的哈希函数产生不同的哈希值序列,从而实现对数据流的不同表示。
2)首先,将整个位数组划分为多个块,每个块都有自己的位数组(每个块都是一个小的布隆过滤器)。每个块使用不同的哈希函数,这些哈希函数会产生不同的哈希值,用于将元素映射到多个块中的不同位置。
3)BBF在添加新元素时,它会经过所有块的不同哈希函数,将哈希值映射到各自的位数组中保存。
4)在查询操作中,判断一个元素是否存在,需要在每个块中对应的位数组位置进行查询,只有当所有块中的对应位都为1时,才能判断元素存在。如果有任何一个块中的对应位为0,则确定元素不存在。
优点:
当数据流中的哈希值分布不均匀时,BBF的元素会被分散到多个块中,减少了位被过度压缩的情况。
BBF的性能取决于块的数量、哈希函数的选择以及位数组的大小等因素。在实际使用中,可以根据数据分布和查询需求来优化和调整这些参数,达到更好的性能
缺点
由于每个块需要维护自己的位数组和哈希函数,BBF相对于传统布隆过滤器可能需要更多的内存空间。虽然理论上BBF可以降低假阳性率,但是经过实验测得,BBF的性能提高是以提高了假阳性率为代价的(可以往下看)。
实现
分块布隆过滤器代码实现如下:
BlockedBloomFilter.java
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
public class BlockedBloomFilter {
public static int buckets;
public static long seed;
public static long[] data;
public BlockedBloomFilter() {
}
public BlockedBloomFilter construct(long[] keys, int bitsPerKey) {
int n = keys.length;
BlockedBloomFilter f = new BlockedBloomFilter(n, bitsPerKey);
for (long x : keys) {
f.add(x);
}
return f;
}
public long getBitCount() {
return data.length * 64L;
}
/**
* @param entryCount 能够容纳的key数
* @param bitsPerKey 每个key的bits数
*/
public BlockedBloomFilter(int entryCount, int bitsPerKey) {
// bitsPerKey = 11;
entryCount = Math.max(1, entryCount);
seed = Hash.randomSeed();
long bits = (long) entryCount * bitsPerKey;
buckets = (int) bits / 64;
data = new long[buckets + 16 + 1];
}
public boolean supportsAdd() {
return true;
}
/**
* 添加key
*/
public void add(long key) {
// 计算哈希值
long hash = Hash.hash64(key, seed);
// 根据哈希值计算起始位置(桶索引)
int start = Hash.reduce((int) hash, buckets);
hash = hash ^ Long.rotateLeft(hash, 32);
// 计算两个哈希掩码 m1 和 m2,根据哈希值设置要置为1的位
long m1 = (1L << hash) | (1L << (hash >> 6));
long m2 = (1L << (hash >> 12)) | (1L << (hash >> 18));
// 将对应的数组中的 m1 位和 m2 位设置为1
data[start] |= m1;
data[start + 1 + (int) (hash >>> 60)] |= m2;
}
/**
* 查询 key
*/
public boolean mayContain(long key) {
// 计算哈希值
long hash = Hash.hash64(key, seed);
// 根据哈希值计算起始位置(桶索引)
int start = Hash.reduce((int) hash, buckets);
hash = hash ^ Long.rotateLeft(hash, 32);
// 数组中获取对应的位
long a = data[start];
long b = data[start + 1 + (int) (hash >>> 60)];
long m1 = (1L << hash) | (1L << (hash >> 6));
long m2 = (1L << (hash >> 12)) | (1L << (hash >> 18));
// 检查计算出的位是否与数据数组中的位相匹配
// 如果匹配,则返回 true,表示元素可能存在
return ((m1 & a) == m1) && ((m2 & b) == m2);
}
// 测试代码
public static void main(String[] args) {
// 初始化时间,200万长度的数组,和100万的位数组长度,
long time;
int len = 1000000;
long[] list = new long[len * 2];
// 创建200万长度的随机数组,每个元素不重复
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
// 随机数组的前一半作为过滤器的key,后一半不是过滤器的key
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
// 创建布隆过滤器
BlockedBloomFilter f = new BlockedBloomFilter();
f.construct(keys, 10);
// 查看keys是否存在
if (f.mayContain(keys[0])) {
System.out.println("布隆过滤器传入:" + keys[0]);
System.out.println(keys[0] + " 在过滤器中已存在");
System.out.println("=================");
}
time = System.nanoTime();
// 所有key查出,用于计时
int falseNegatives = 0;
for (int j = 0; j < len; j++) {
if (!f.mayContain(keys[j])) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / 2 / len;
// 计算假阳性率
int falsePositives = 0;
for (int j = 0; j < len; j++) {
if (f.mayContain(nonKeys[j])) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
long bitCount = f.getBitCount();
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + bitCount +
" lookupAllInSet " + lookupAllInSet + "ns"
);
}
}
代码测试执行如下:
相比布隆过滤器的0.8%,可以看到分块布隆过滤器的假阳性率确实提高了,达到1.27%,牺牲假阳性率换来的是性能的提高,查询时间降低到了12ns,耗时降到近乎布隆过滤器的一半。
布谷鸟过滤器 Cuckoo Filter
布谷鸟过滤器(Cuckoo Filter)是一种基于布谷鸟哈希散列的布隆过滤器结构改进,同样用来高效判断一个元素是否存在于集合中。在基于布谷鸟哈希的改进下,当过滤器要插入一个新的元素时,如果索引到所在的位置已经有元素存在,就将现有元素替换到其他位置,从而为新元素腾出空间进行插入。
布谷鸟过滤器 Cuckoo Filter 的主要原理如下:
1)布谷鸟过滤器 Cuckoo Filter 存储结构也是一个数组,数组中包含多个桶(bucket),每个桶有4个位置(可以设置),每个位置存一个元素,因此一个桶可以存储4个元素,这些元素以 16 bit(可以设置) 的指纹(fingerprint)形式被存储。指纹是从元素哈希值中提取的信息片段,是元素哈希值经过二次哈希得到的,提高了混淆性,类似于哈希值的作用,用于判定元素的存在。
2) 当要插入一个新的元素时,布谷鸟过滤器 Cuckoo Filter 首先通过两个不同的哈希函数计算出两个可能的桶位置。如果其中任何一个位置是空的,就直接将元素的指纹存储在这个位置。
3)如果桶内的所有位置都已经有元素占据,就会选择一个位置,将已存在的元素的指纹替换到另一个桶,为新元素腾出空间。如果另一个位置也已经被占用,这就引发连续的“踢出”操作,继续将已存在的元素替换到其它桶。这个过程可能会引发连锁反应,导致多个元素的迁移。
4)查询一个元素是否在 布谷鸟过滤器 Cuckoo Filter 中时,类似于插入操作,通过两个哈希函数计算出两个可能的位置,然后检查这个位置的指纹是否匹配查询元素的指纹。若存在至少一个位置指纹匹配,就认为元素存在于集合中。这里也存在哈希冲突的可能性,所以也有一定的假阳性概率。但是由于桶索引存在哈希混淆,元素的指纹信息也存在哈希混淆,因此假阳性率可以被大大降低。
5) 一般来说布谷鸟过滤器 Cuckoo Filter 不直接支持删除操作,因为删除元素时会导致其他元素迁移,类似于插入操作的反向过程,这时候需要复杂的元素迁移来避免数据丢失。
交互动画:
可以看到,我们在布谷鸟过滤器插入 key="tes1f",而 key="tes1f" 与 key="TEST1" 占用同一个哈希槽位,这里就产生了哈希冲突。选择插入 key="tes1f" 后,key="tes1f" 直接占用了 key="TEST1" 的位置,而 key="TEST1" 则索引到了另外一个数组中。
交互动画可以帮助理解,但不代表真实情况,想理解真实过程建议还是看代码。
优点:
布谷鸟过滤器相比于一些布隆过滤器,拥有不算高的空间消耗,和较高的查询速度,并且由于布谷鸟哈希的引入,也有效降低了假阳性率。
缺点:
相对原生布隆过滤器来说,额外引入了空间损耗。删除比较复杂,不直接支持删除操作,并且可能存在表溢出问题。。
在论文中,作者比对了包括布隆过滤器 Bloom Filter,计数布隆过滤器Counting Bloom Filter ,分块布隆过滤器Blocked Bloom Filter 等方法,并提出布谷鸟过滤器 Cuckoo Filter 不仅支持删除操作,还在空间损耗上比前三者更有优势,每次查询缓存未命中的数量仅为2。
实现
CuckooFilter.java
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* 布谷鸟过滤器(我赌你的哈希槽和指纹不会全都跟我一样)
*
* 布谷鸟过滤器分成多个桶 bucket,每个桶内包含若干个位置(ENTRIES_PER_BUCKET = 4个位置),
* 每个位置存储一个key,因此一个桶4个位置,可以存储4个key。不同于布隆过滤器的一个哈希函数,多次哈希取槽位来表示key,
* 布谷鸟过滤器先让key做一次哈希,索引到某个桶位置,然后根据这个hash值进行二次哈希,生成一个16bit的指纹(fingerprint),
* 使用指纹(fingerprint)来存储key,然后for循环遍历桶的4个位置,如果里面没指纹(为0),则插入指纹,如果有指纹则跳过位置,继续遍历。
* 如果存在指纹跟插入指纹一样,则认为元素已经存在(可能误判,赌的就是哈希槽和指纹不会全都一样)
*
* 如果桶内的所有位置都满了,则尝试插入到第二个桶中。
* 想要到达第二个桶,需要将key的指纹和一个随机值(预设的)进行第三次哈希,来唯一确定第二个桶,然后继续遍历第二个桶的4个位置。
* 如果第二个桶的4个位置也都装满了,则开始踢出:
* 在4个位置中随机取一个位置,取出原来的老指纹,插入新指纹。这个老指纹则和一个随机值进行第三次哈希,来唯一确定它自己的第二个桶,
* 然后继续遍历它的第二个桶的4个位置。。。如此往复,可能会发生连续的元素抢占和踢出,如果重复了1000次(可设置)都没能结束元素交换,则报错,说明桶已经满了。
*
* 查询时,跟插入是一样的,先计算key的指纹,对key计算哈希索引到桶,然后看桶内的4个位置是否有指纹?指纹是否等于key的指纹?如有则返回存在,
* 没有则返回不存在,由于布谷鸟过滤器对key的哈希复杂度和混淆度更高,并且用了更多的空间来存储,所以误判率非常低。
* 注:查询时,会把两个桶都搜索一遍。两个桶都不存在才返回不存在。
*/
public class CuckooFilter {
/**
* 指纹位数,用于存储元素指纹信息
* 这里指纹占 16 bit位;16进制,以Ox开头
*/
public static int FINGERPRINT_BITS = 16;
/**
* 每个桶包含的位置数量
* 每个位置可以存储 1个 key,因此这里每个桶能存4个key
*/
public static int ENTRIES_PER_BUCKET = 4;
/**
* 用于生成指纹信息的掩码
*/
public static long FINGERPRINT_MASK = (1L << FINGERPRINT_BITS) - 1;
/**
* 数据数组
*/
public static long[] data;
/**
* 哈希种子
*/
public static long seed;
public static int bucketCount;
/**
* 随机数生成器
*/
private final Random random = new Random(1);
public CuckooFilter construct(long[] keys) {
int len = keys.length;
while (true) {
try {
CuckooFilter f = new CuckooFilter((int) (len / 0.95));
for (long k : keys) {
f.insert(k);
}
return f;
} catch (IllegalStateException e) {
throw new IllegalStateException("桶满了!");
}
}
}
public CuckooFilter() {
}
public CuckooFilter(int capacity) {
// 计算需要的桶的数量,确保它是偶数
bucketCount = Math.max(1, (int) Math.ceil((double) capacity / ENTRIES_PER_BUCKET) / 2 * 2);
// 初始化数据数组
data = new long[bucketCount];
// 生成哈希种子
seed = Hash.randomSeed();
}
/**
* 插入 key
*/
public void insert(long key) {
// 计算 key 的哈希值
long hash = Hash.hash64(key, seed);
// 生成指纹信息,插入指纹到对应桶
insertFingerprint(getBucket(hash), getFingerprint(hash));
}
public boolean mayContain(long key) {
// 计算 key 的哈希值
long hash = Hash.hash64(key, seed);
// 获取桶索引
int bucket = getBucket(hash);
// 生成key的指纹信息
int fingerprint = getFingerprint(hash);
// 检查桶中是否包含指定的指纹信息
if (bucketContains(bucket, fingerprint)) {
return true;
}
// 获取第二个的桶
int bucket2 = getBucket2(bucket, fingerprint);
// 检查第二个桶中是否包含指定的指纹信息
return bucketContains(bucket2, fingerprint);
}
/**
* 计算第二个桶
*/
private int getBucket2(int bucket, int fingerprint) {
long hash = fingerprint * 0xc4ceb9fe1a85ec53L;
// 保证bucketCount为偶数,且bucket2 = bucketCount - bucket - y
// 可以保证bucket2不会是原来的原bucket
int r = (Hash.reduce((int) hash, bucketCount >> 1) << 1) + 1;
int b2 = bucketCount - bucket - r;
if (b2 < 0) {
b2 += bucketCount;
}
return b2;
}
/**
* 判断指定的桶是否包含指定的指纹
*/
private boolean bucketContains(int bucket, int fingerprint) {
long allFingerprints = data[bucket];
/*
* 判断 allFingerprints 的所有位置中,是否包含fingerprint。fingerprint只占16bit,allFingerprints总共占了16*4=64bit,长度不相等,
* 所以(fingerprint * 0x0001000100010001L)就是把 fingerprint 扩展成了4份相同的16bit的 fingerprint,这样就和 allFingerprints 的长度相等,
* 两者才好做异或运算,如果 allFingerprints 中的4个位置,有一个和 fingerprint 一模一样,异或计算后,则该位置的 16bit 就会全部等于0
*
* demo:
* int fingerprint = 2; // 2的2进制是 0b0000000000000010(16bit表示)
* long output1 = fingerprint * 0x0001000100010001L;
* long output2 = 0b0000000000000010000000000000001000000000000000100000000000000010L;
* System.out.println(output1 == output2); // true
*/
long v = allFingerprints ^ (fingerprint * 0x0001000100010001L);
/*
* 下行代码确定v是否有0字节(16进制),参考 https://graphics.stanford.edu/~seander/bithacks.html#OperationCounting
* 1)首先将字中4个字节的高位清零。2)如果初始设置了任何低位,则会添加一个数字,导致字节的高位溢出。
* 3)原v的高位与结果值进行“或”运算;因此原v设置的对应字节位设置为字节的高位。
* 4)通过与除高位之外的所有位置的1进行“或”运算,最后再反转,来确定这些高位中是否有任何一个为0
*/
long zeroByteExist = ~((((v & 0x7fff7fff7fff7fffL) + 0x7fff7fff7fff7fffL) | v) | 0x7fff7fff7fff7fffL);
return zeroByteExist != 0;
}
/**
* 获取指定桶和位置的指纹
*/
private int getFingerprintAt(int bucket, int entry) {
// 从数据数组中获取指定桶和位置的指纹信息
// (桶有4个位置,我们需要到达第entry个位置,每个位置占 FINGERPRINT_BITS 个bit位
// 因此 data[bucket] 总共需要右移 FINGERPRINT_BITS * entry 个位置)
return (int) ((data[bucket] >>> (FINGERPRINT_BITS * entry)) & FINGERPRINT_MASK);
}
/**
* 将指定桶的指定位置的指纹信息设置为新的值
*/
private void setFingerprintAt(int bucket, int entry, int fingerprint) {
// 将原指纹清零
data[bucket] &= ~(FINGERPRINT_MASK << (FINGERPRINT_BITS * entry));
// 写入新指纹
data[bucket] |= (long) fingerprint << (FINGERPRINT_BITS * entry);
}
/**
* 将指纹插入到桶中
*/
private void insertFingerprint(int bucket, int fingerprint) {
// 尝试将指纹插入指定的桶
if (bucketInsert(bucket, fingerprint)) {
return;
}
// 如果桶已满,尝试插入到另一个桶或进行元素交换
int bucket2 = getBucket2(bucket, fingerprint);
if (bucketInsert(bucket2, fingerprint)) {
return;
}
// 如果仍然无法插入,尝试进行元素交换
swap(bucket2, fingerprint);
}
private boolean bucketInsert(int bucket, int fingerprint) {
// 尝试将指纹插入桶中的一个位置
for (int entry = 0; entry < ENTRIES_PER_BUCKET; entry++) {
int fp = getFingerprintAt(bucket, entry);
if (fp == 0) {
setFingerprintAt(bucket, entry, fingerprint);
return true;
} else if (fp == fingerprint) {
return true;
}
}
return false;
}
private void swap(int bucket, int fingerprint) {
for (int n = 0; n < 1000; n++) {
int entry = random.nextInt() & (ENTRIES_PER_BUCKET - 1);
// 交换元素的指纹信息以处理桶溢出,bucketsSwap后返回被踢出的老指纹信息。
fingerprint = bucketsSwap(bucket, entry, fingerprint);
// 计算当前桶的Bucket2,尝试插入被踢出的老指纹信息
bucket = getBucket2(bucket, fingerprint);
if (bucketInsert(bucket, fingerprint)) {
return;
}
}
// 如果尝试1000次仍然无法插入,抛出异常
throw new IllegalStateException("桶满了!");
}
/**
* 交换桶中的指纹信息
*/
private int bucketsSwap(int bucket, int entry, int fingerprint) {
// 交换指定位置的指纹信息,先获取原来的指纹信息 old
int old = getFingerprintAt(bucket, entry);
// 将新指纹插入要交换的指定位置
setFingerprintAt(bucket, entry, fingerprint);
return old;
}
/**
* 根据哈希值计算桶的编号
* @param hash
* @return
*/
private int getBucket(long hash) {
// 使用哈希函数将哈希值映射到桶的范围
return Hash.reduce((int) hash, bucketCount);
}
/**
* 从哈希值中生成指纹信息
* 相当于二次哈希
* @param hash
* @return
*/
private int getFingerprint(long hash) {
// 对哈希值进行二次哈希以增加混淆,然后取低位作为指纹信息
hash = Hash.hash64(hash, seed);
int fingerprint = (int) (hash & FINGERPRINT_MASK);
// 确保指纹信息不为0
return Math.max(1, fingerprint);
}
public long getBitCount() {
// 计算总的比特数,用于衡量过滤器的容量
return FINGERPRINT_BITS * ENTRIES_PER_BUCKET * bucketCount;
}
public static void main(String[] args) {
// 初始化时间,200万长度的数组,和100万的位数组长度,
long time;
int len = 1000000;
long[] list = new long[len * 2];
// 创建200万长度的随机数组,每个元素不重复
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
// 随机数组的前一半作为过滤器的key,后一半不是过滤器的key
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
// 创建布隆过滤器
CuckooFilter f = new CuckooFilter();
f.construct(keys);
// 查看keys是否存在
if (f.mayContain(keys[0])) {
System.out.println("过滤器传入:" + keys[0]);
System.out.println(keys[0] + " 在过滤器中已存在");
System.out.println("=================");
}
time = System.nanoTime();
// 所有key查出,用于计时
int falseNegatives = 0;
for (int j = 0; j < len; j++) {
if (!f.mayContain(keys[j])) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / 2 / len;
// 计算假阳性率
int falsePositives = 0;
for (int j = 0; j < len; j++) {
if (f.mayContain(nonKeys[j])) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
long bitCount = f.getBitCount();
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + bitCount +
" lookupAllInSet " + lookupAllInSet + "ns"
);
}
}
测试代码运行后结果如下:
可以看到假阳性率降低到了0.01%,这个是前三个布隆过滤器没有做到的,空间上使用了1600万bit,查询时间则要比原生布隆过滤器是要慢的。(可以侧面看出 分块布隆过滤器 Blocked Bloom Filter 速度真的很快,让其他选手望尘莫及)
Xor过滤器 Xor filter
Xor Filter(异或过滤器)是一种新型的数据结构,它可以用来代替布隆过滤器(Bloom Filter)并在某些情况下表现更优。Xor Filter需要相对较少的内存,并且在检查元素是否存在时性能良好。
以下是Xor Filter的详细介绍:
1.原理:
Xor Filter的原理基于异或运算。它使用多个哈希函数对元素进行哈希,并将哈希结果与之前插入的元素的哈希结果进行异或运算,最终存储在数据结构中。在查询元素是否存在时,再次进行哈希并与之前存储的哈希结果进行异或运算。如果结果为零,表示元素存在,否则表示元素不存在。
2.优点:
内存效率:Xor Filter需要的内存比布隆过滤器更少。它需要的存储空间大约为1.23 * log(1/fpp) bits per key,其中fpp是期望的假阳率(false positive probability)。这相对较低的内存占用使得Xor Filter在内存受限的环境中更具吸引力。
低假阳率:Xor Filter可以实现较低的假阳率,即当查询返回元素存在时,几乎可以确定元素确实存在。
高性能:查询元素是否存在的性能很好,通常只需要执行少量的哈希运算和异或运算。
3.数据结构:
Xor Filter的核心数据结构包括:
1)fingerprints 数组:存储元素的指纹信息,用于判断元素是否存在。
2)seed:哈希种子,用于生成哈希值。
3)blockLength:将哈希值映射到不同的块的长度。
4.构建过程:
构建Xor Filter的过程包括:
初始化核心数据结构。
对每个插入的元素,使用多个哈希函数生成哈希值,将哈希结果与已有的哈希值进行异或运算,更新指纹信息。
处理哈希冲突,确保指纹信息能够唯一地表示每个元素。
5.查询过程:
查询元素是否存在的过程包括:
1)对要查询的元素使用相同的哈希函数生成哈希值。
2)根据哈希值计算块的索引,并使用指纹信息判断元素是否存在。
6.性能和应用:
Xor Filter适用于需要高性能、低内存占用和低假阳率的应用场景。它可以用于数据缓存、网络路由表、数据库索引等多种领域。Xor Filter不支持删除元素,因为删除操作会导致哈希冲突的处理变得复杂。其可以在某些情况下取代传统的布隆过滤器,并在内存受限的环境中表现出色。它的高性能和低内存占用使得它在现代应用程序中具有广泛的潜在应用。
实现
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* The xor filter, a new algorithm that can replace a Bloom filter.
*
* It needs 1.23 log(1/fpp) bits per key. It is related to the BDZ algorithm [1]
* (a minimal perfect hash function algorithm).
*
* [1] paper: Simple and Space-Efficient Minimal Perfect Hash Functions -
* http://cmph.sourceforge.net/papers/wads07.pdf
*
* 这段代码实现了Xor Filter,一种可以替代Bloom Filter的新算法。
* 它的主要思想是使用异或运算来计算每个键的指纹信息,并通过哈希函数将指纹信息散列到不同的位置。
* Xor Filter具有较低的空间复杂度,并且在检查键是否存在时非常高效。
*/
public class XorFilter {
/**
* 指纹位数
*/
public static int BITS_PER_FINGERPRINT = 16;
/**
* 使用的哈希函数数量(分块数)
*/
public static int HASHES = 3;
/**
* 用于计算过滤器大小的偏移量
*/
public static int OFFSET = 32;
/**
* 用于计算过滤器大小的常数因子
*/
public static final int FACTOR_TIMES_100 = 123;
/**
* 每个块的长度
* XorFilter把数组分成(HASHES = 3)3个块block,所以每个块长度 = 指纹数组长度 / 哈希函数数量
* 例如:XorFilter分成了3块,指纹数组长度为 1230032,则每个块长度为 410010
*/
public static int blockLength;
/**
* 哈希种子
*/
public static long seed;
/**
* 存储指纹的数组
* XorFilter把数组分成3个块block,key由3个不同的哈希函数(h1(),h2(),h3())计算得到3个哈希值(h1, h2, h3)
* ,并分别索引到3个块当中。最终key的指纹值则为key的三个哈希值之间做异或运算(fingerprint = h1^h2^h3)得到
*/
public static short[] fingerprints;
/**
* 过滤器中的总位数
*/
public static int bitCount;
public XorFilter construct(long[] keys) {
return new XorFilter(keys);
}
public XorFilter() {
}
/**
* 使用给定的键数组构造 XorFilter
*
* @param keys 要插入到过滤器中的键的数组
*/
public XorFilter(long[] keys) {
// key数量
int size = keys.length;
// 获取指纹数组的长度
int arrayLength = getArrayLength(size);
// 总bit数 = 指纹数组长度×每个指纹占用bit数
bitCount = arrayLength * BITS_PER_FINGERPRINT;
// 每个块长度 = 指纹数组长度 / 哈希函数数量
blockLength = arrayLength / HASHES;
// 生成一个逆序的key数组
long[] reverseOrder = new long[size];
byte[] reverseH = new byte[size];
int reverseOrderPos;
long seed;
do {
seed = Hash.randomSeed();
// 用于跟踪数组中每个位置上的key数
byte[] t2count = new byte[arrayLength];
// 用于存储临时指纹信息的数组
long[] t2 = new long[arrayLength];
// 遍历所有key
for (long k : keys) {
// 求key在三个block的哈希索引,执行XOR运算
for (int hi = 0; hi < HASHES; hi++) {
// hi = 0,1,2,根据不同的hi,每次会轮询 3(HASHES) 个不同的block进行哈希索引得到h
int h = getHash(k, seed, hi);
// 索引到指纹数组的第h位置,与key做异或运算
t2[h] = t2[h] ^ k;
if (t2count[h] > 120) {
// 当哈希冲突过多时,放弃构建Xor Filter,将所有指纹置为0xFFFF
for (int i = 0; i < fingerprints.length; i++) {
fingerprints[i] = (short)0xFFFF;
}
// 结束构建
return;
}
// 记录t2[]分别在3个block的哈希索引h处的key数量,>1则说明存在多个哈希冲突,=1说明正好
t2count[h]++;
}
}
// 用于记录在t2count[]计数为1的key,存储在t2[]的哈希索引
// int[] alone = {key1的hash索引,key2的hash索引,key3的hash索引, ...}
int[] alone = new int[arrayLength];
// alone[]遍历的当前位置
int alonePos = 0;
// 反序数组遍历的当前位置
reverseOrderPos = 0;
// 查找t2[]中唯一出现的key
for (int nextAloneCheck = 0; nextAloneCheck < arrayLength; ) {
/*
构建alone[]数组,示例:
seed = -4313613350925777766L,key = 4720714422438504289L
int hash = getHash(4720714422438504289L, -4313613350925777766L, 0); // 2 (nextAloneCheck)
int hash1 = getHash(4720714422438504289L, -4313613350925777766L, 1); // 575340
int hash2 = getHash(4720714422438504289L, -4313613350925777766L, 2); // 876124
因此key有:t2[] = {-6116013316159012190L, 0, 4720714422438504289L, ...}
t2count[] = {1,0,1, ...} // t2count[hash] == 1, 当hash = 2时,t2[hash]=4720714422438504289L
alone[] = {0,2,14, ...} // alone[alonePos] = hash = 2 (0 = alonePos -> arrayLength)
*/
while (nextAloneCheck < arrayLength) {
// 记录在t2count[]计数为1的key,在t2count的位置,按顺序存于alone数组中
if (t2count[nextAloneCheck] == 1) {
alone[alonePos++] = nextAloneCheck;
// break;
}
nextAloneCheck++;
}
// 如果alonePos>0,则alonePos为alone[]中,排名最后一个只出现一次的key的哈希索引
while (alonePos > 0) {
// 倒序遍历alone[],获取只出现一次的key的哈希索引i
int i = alone[--alonePos];
if (t2count[i] == 0) {
// 排除t2count[i] == 0,即key未出现情况
continue;
}
// 根据哈希索引i获取t2[]中的异或key
long k = t2[i];
byte found = -1;
// 遍历三个block,再次求key在三个block的哈希索引
for (int hi = 0; hi < HASHES; hi++) {
// 把异或key求哈希,得到第 hi 个block的哈希索引h
int h = getHash(k, seed, hi);
// 根据h索引到计数数组t2count的t2count[h]位置,并做累减计数得到newCount
int newCount = --t2count[h];
if (newCount == 0) {
// 如果newCount==0说明在t2count[]原计数为1,把桶索引hi记录下来
found = (byte) hi;
} else {
// 如果newCount!=0说明在t2count[]原计数不为1
if (newCount == 1) {
// 如果newCount==1说明在t2count[]原计数为2,alone中记录下哈希索引h
alone[alonePos++] = h;
}
// 根据哈希索引h,在t2[h]位置与自身key再做异或运算,增加哈希混淆
t2[h] = t2[h] ^ k;
}
}
// 逆序记录异或key
reverseOrder[reverseOrderPos] = k;
// 逆序记录t2count[h]原计数为1的异或key的block块索引
reverseH[reverseOrderPos] = found;
reverseOrderPos++;
}
}
} while (reverseOrderPos != size);
this.seed = seed;
// 用于存储中间计算的指纹信息的数组
short[] fp = new short[arrayLength];
// 根据逆序键数组重新计算指纹
for (int i = reverseOrderPos - 1; i >= 0; i--) {
long k = reverseOrder[i];
int found = reverseH[i];
int change = -1;
long hash = Hash.hash64(k, seed);
int xor = fingerprint(hash);
// 遍历所有哈希函数,计算指纹信息并更新fp数组
for (int hi = 0; hi < HASHES; hi++) {
int h = getHash(k, seed, hi);
if (found == hi) {
change = h;
} else {
xor ^= fp[h];
}
}
fp[change] = (short) xor;
}
// 将计算得到的指纹信息存储到fingerprints数组中
fingerprints = new short[arrayLength];
System.arraycopy(fp, 0, fingerprints, 0, fp.length);
}
public boolean mayContain(long key) {
// 计算key的哈希值
long hash = Hash.hash64(key, seed);
// 计算指纹
int f = fingerprint(hash);
// 计算三个不同的哈希值
int r0 = (int) hash;
int r1 = (int) Long.rotateLeft(hash, 21);
int r2 = (int) Long.rotateLeft(hash, 42);
// 将哈希值映射到Xor Filter中的块
int h0 = Hash.reduce(r0, blockLength);
int h1 = Hash.reduce(r1, blockLength) + blockLength;
int h2 = Hash.reduce(r2, blockLength) + 2 * blockLength;
// 利用指纹信息对比计算得到的f值
f ^= fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2];
// 检查f的低16位是否全为0
return (f & 0xffff) == 0;
}
/**
* 根据块索引index进行哈希计算
* 三个哈希函数求哈希,每次哈希将key分别索引到3个block中
*
* rotateLeft——循环左移,如:
* long a = 1L;
* long b = 8L;
* long c = 16L;
* Long.rotateLeft(a, 3) == b
* Long.rotateLeft(a, 4) == c
*/
private int getHash(long key, long seed, int index) {
// index为当前遍历到的块(0,1,2)
// key求哈希值,根据index循环左移确定哈希函数
long r = Long.rotateLeft(Hash.hash64(key, seed), 21 * index);
// 将key的哈希,映射到的一个块长度的数组中
r = Hash.reduce((int) r, blockLength);
// 当前遍历到的块为index,块数组长度为blockLength,因此r的偏移量为 index * blockLength
r = r + index * blockLength;
return (int) r;
}
private int fingerprint(long hash) {
// 取哈希值的低16位作为指纹
return (int) (hash & ((1 << BITS_PER_FINGERPRINT) - 1));
}
/**
* 获取Xor Filter中的总位数
*/
public long getBitCount() {
return bitCount;
}
/**
* 计算Xor Filter数组的长度
*/
private int getArrayLength(int size) {
// 数组长度:OFFSET + 1.23 * size
return (int) (OFFSET + (long) FACTOR_TIMES_100 * size / 100);
}
public static void main(String[] args) {
// 初始化时间,200万长度的数组,和100万的位数组长度,
long time;
int len = 1000000;
long[] list = new long[len * 2];
// 创建200万长度的随机数组,每个元素不重复
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
// 随机数组的前一半作为过滤器的key,后一半不是过滤器的key
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
// 创建布隆过滤器
XorFilter f = new XorFilter();
f.construct(keys);
// 查看keys是否存在
if (f.mayContain(keys[0])) {
System.out.println("过滤器传入:" + keys[0]);
System.out.println(keys[0] + " 在过滤器中已存在");
System.out.println("=================");
}
time = System.nanoTime();
// 所有key查出,用于计时
int falseNegatives = 0;
for (int j = 0; j < len; j++) {
if (!f.mayContain(keys[j])) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / 2 / len;
// 计算假阳性率
int falsePositives = 0;
for (int j = 0; j < len; j++) {
if (f.mayContain(nonKeys[j])) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
long bitCount = f.getBitCount();
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + bitCount +
" lookupAllInSet " + lookupAllInSet + "ns"
);
}
}
测试结果如下:
可以看到假阳性率达到了惊人的0.0014%,容量只占用1900+万bit,而查询速度也快得惊人,比肩blocked bloom filter,100万key查询速度达到12ns。
功能区过滤器 Ribbon filter
Ribbon Filter 是一种用于静态集合(构建完成后不再修改)的近似成员查询结构(Approximate Membership Query, AMQ)。它的核心目标是:在给定假阳性率(FPP)的前提下,用更接近信息论下界的空间占用,替代 Bloom Filter;代价主要体现在构建阶段需要更多 CPU(以及一些临时内存)。
Ribbon Filter 在工程上最出名的落地之一是 RocksDB:从 6.15 版本开始支持 Ribbon filters,用于 SST 文件的过滤器,通常会与 Bloom 形成混合策略(Ribbon+Bloom hybrid)。
原理
Ribbon Filter 可以把 “判断 key 是否存在” 转换成 “从数组里取出若干位置做 XOR,得到 key 的指纹(fingerprint)并对比”:
1)对 key 计算哈希值,得到一个较短的指纹 fingerprint(例如 8/16 bit),同时通过哈希得到一段连续窗口(window)的起点;这段 window 覆盖数组中的若干个位置(这也是“Ribbon/带状”这个名字的来源)。
2)构建阶段,将每个 key 的 “window 内若干位置的 XOR 结果应该等于 fingerprint” 的关系写成一个 GF(2) 线性系统;工程实现会利用带状矩阵结构,让消元/求解更快、内存更可控。
3)求解完成后得到一个存储数组 A;查询时对 key 再算同样的 window,从 A 取出对应位置做 XOR,并与 fingerprint 对比:
- XOR 结果不等于 fingerprint:一定不存在(No False Negative)
- XOR 结果等于 fingerprint:可能存在(False Positive 由 fingerprint 冲突导致)
优点:
1)空间更省:在 LSM 场景中,Ribbon 往往能在同等 FPP 下显著节省 filter memory(RocksDB 官方文章给出的典型量级是:约 30% 的空间节省,对应 3~4 倍左右的构建 CPU)。
2)查询仍是 O(1) 常数复杂度,适合读路径高频触发过滤器的场景。
3)可配置性接近 Bloom:工程实现可像 Bloom 一样按目标 FPP 选择 bits/key(这点对数据库很关键)。
缺点:
1)构建更重:CPU 成本主要发生在构建(常在 compaction/后台任务中),并且构建过程需要更多临时内存。
2)更偏静态:不适合“在线持续插入、集合动态增长”的场景(这类更适合 Bloom / QF / VQF / Cuckoo 等动态结构)。
3)在极低 FPP 下查询 CPU 常数会变大(仍是 O(1),但更吃算力)。
在 RocksDB 中的工程落地:常用 Ribbon + Bloom 混合
LSM-tree 的不同 level 上,SST 文件的生命周期差异非常大:小 level 的文件会被频繁 compaction 重写,而大 level 的文件可能存活很久。Ribbon 的“构建更贵、驻留更省”非常匹配这种分层特性:
1)寿命短(经常被重写)的 level:用 Bloom,构建便宜、立刻受益;
2)寿命长(长期驻留内存)的 level:用 Ribbon,节省的内存能长期摊薄构建开销。
实现参考
package org.example.bloomFilter;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* Ribbon Filter(教学实现)
*
* 目标:展示 Ribbon 的核心形态——把每个 key 映射为一个“连续窗口(window)上的 XOR 约束”,
* 然后通过 peeling(剥皮)方式求解数组,使得插入 key 无假阴性。
*
* - 本实现使用 16-bit fingerprint(short)
* - 每个 key 关联 WINDOW 个连续位置:data[start..start+WINDOW-1]
* - 构建成功后,查询时只需对窗口做 XOR 并比较 fingerprint
*
* 注意:这是教学版实现,不是生产级实现(未做 SIMD、批处理、参数寻优等)。
*/
public class RibbonFilter {
/** 指纹位数(16-bit) */
private static final int FINGERPRINT_BITS = 16;
/** 窗口大小(每个 key 关联 WINDOW 个连续位置) */
private static final int WINDOW = 8;
private final int arrayLength;
private final long seed;
private final short[] data;
private RibbonFilter(int arrayLength, long seed, short[] data) {
this.arrayLength = arrayLength;
this.seed = seed;
this.data = data;
}
public long getBitCount() {
return (long) arrayLength * FINGERPRINT_BITS;
}
public boolean mayContain(long key) {
long hash = Hash.hash64(key, seed);
int start = Hash.reduce((int) hash, arrayLength - WINDOW + 1);
short fp = fingerprint(hash);
short x = 0;
for (int i = 0; i < WINDOW; i++) {
x ^= data[start + i];
}
return x == fp;
}
/**
* 构建 RibbonFilter(多次尝试不同 seed / 扩容直到 peeling 成功)
*/
public static RibbonFilter construct(long[] keys) {
int n = keys.length;
int arrayLength = Math.max(WINDOW + 1, (int) (n * 1.35) + 32);
for (int attempt = 0; attempt < 50; attempt++) {
long seed = Hash.randomSeed();
RibbonFilter f = buildOnce(keys, arrayLength, seed);
if (f != null) {
return f;
}
// 失败就稍微扩一点数组再试(教学版策略:简单粗暴)
arrayLength = (int) (arrayLength * 1.05) + 1;
}
throw new IllegalStateException("Failed to build RibbonFilter after many attempts.");
}
private static RibbonFilter buildOnce(long[] keys, int arrayLength, long seed) {
int n = keys.length;
if (arrayLength - WINDOW + 1 <= 0) {
return null;
}
int[] starts = new int[n];
short[] fps = new short[n];
// 每个 key 形成一个约束:XOR(data[start..start+WINDOW-1]) == fingerprint(key)
for (int i = 0; i < n; i++) {
long hash = Hash.hash64(keys[i], seed);
starts[i] = Hash.reduce((int) hash, arrayLength - WINDOW + 1);
fps[i] = fingerprint(hash);
}
// 构建 vertex->edges 邻接(CSR),用于 peeling
int total = n * WINDOW;
int[] offsets = new int[arrayLength + 1];
for (int e = 0; e < n; e++) {
int s = starts[e];
for (int j = 0; j < WINDOW; j++) {
offsets[s + j + 1]++;
}
}
for (int i = 0; i < arrayLength; i++) {
offsets[i + 1] += offsets[i];
}
int[] cursor = offsets.clone();
int[] edgeIds = new int[total];
for (int e = 0; e < n; e++) {
int s = starts[e];
for (int j = 0; j < WINDOW; j++) {
int v = s + j;
edgeIds[cursor[v]++] = e;
}
}
int[] degree = new int[arrayLength];
for (int v = 0; v < arrayLength; v++) {
degree[v] = offsets[v + 1] - offsets[v];
}
boolean[] removedEdge = new boolean[n];
int[] stack = new int[arrayLength];
int stackSize = 0;
for (int v = 0; v < arrayLength; v++) {
if (degree[v] == 1) {
stack[stackSize++] = v;
}
}
int[] peelEdge = new int[n];
int[] peelVertex = new int[n];
int peelPos = 0;
// peeling:不断移除“度为1”的点,得到可逆序赋值的顺序
while (stackSize > 0) {
int v = stack[--stackSize];
if (degree[v] != 1) {
continue;
}
int e = -1;
for (int idx = offsets[v]; idx < offsets[v + 1]; idx++) {
int cand = edgeIds[idx];
if (!removedEdge[cand]) {
e = cand;
break;
}
}
if (e == -1) {
continue;
}
removedEdge[e] = true;
peelEdge[peelPos] = e;
peelVertex[peelPos] = v;
peelPos++;
int s = starts[e];
for (int j = 0; j < WINDOW; j++) {
int u = s + j;
degree[u]--;
if (degree[u] == 1) {
stack[stackSize++] = u;
}
}
}
if (peelPos != n) {
return null;
}
// 逆序赋值:保证每条约束成立
short[] data = new short[arrayLength];
for (int i = peelPos - 1; i >= 0; i--) {
int e = peelEdge[i];
int v = peelVertex[i];
short x = fps[e];
int s = starts[e];
for (int j = 0; j < WINDOW; j++) {
int u = s + j;
if (u != v) {
x ^= data[u];
}
}
data[v] = x;
}
return new RibbonFilter(arrayLength, seed, data);
}
private static short fingerprint(long hash) {
int fp = (int) (hash & 0xFFFF);
return (short) (fp == 0 ? 1 : fp);
}
// 简单测试(风格与上文保持一致)
public static void main(String[] args) {
int len = 200000;
long[] list = new long[len * 2];
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = Arrays.copyOfRange(list, 0, len);
long[] nonKeys = Arrays.copyOfRange(list, len, len * 2);
long t0 = System.nanoTime();
RibbonFilter f = RibbonFilter.construct(keys);
long buildNs = System.nanoTime() - t0;
// no false negatives
int fn = 0;
t0 = System.nanoTime();
for (long k : keys) {
if (!f.mayContain(k)) {
fn++;
}
}
long lookupNs = System.nanoTime() - t0;
if (fn > 0) {
throw new AssertionError("False negatives: " + fn);
}
int fp = 0;
for (long k : nonKeys) {
if (f.mayContain(k)) {
fp++;
}
}
double fpp = (double) fp / nonKeys.length;
System.out.println(
"RibbonFilter bitCount=" + f.getBitCount() +
" buildNs=" + buildNs +
" lookupAvgNs=" + (lookupNs / (double) keys.length) +
" fpp=" + (fpp * 100) + "%"
);
}
}
商过滤器 quotient filter
Quotient Filter(QF,商过滤器)是一类动态的 AMQ 结构,常被看作 Bloom Filter 的替代方案之一:它同样“可能误判存在、不会误判不存在”,但工程上更容易做到删除、扩容、合并等能力(代价是实现更复杂,且性能与负载因子更相关)。
原理
QF 的核心思想是把哈希值拆成两段:
1)对 key 计算哈希值得到 H。
2)将 H 切分为 quotient(商)q 与 remainder(余数)r。直观地:
q用来定位“桶”(bucket)r作为指纹信息实际存储
3)QF 用一个连续数组承载所有桶。由于插入时可能发生位移,它会为每个槽位配少量元数据位,经典设计用 3 个标志位表达:
- occupied:某个桶是否有元素
- continuation:当前槽位是否是某个桶 run 的延续
- shifted:当前元素是否从其原始桶位置被位移
4)查询时,通过 q 找到对应桶的 run 起点,顺序扫描该 run,比较是否存在某个 r。存在则“可能存在”,扫完仍无则“不存在”。
优点:
1)支持删除:删除某个 r 并维护 run/元数据即可(相比 Counting Bloom,通常能更省空间,但实现复杂度更高)。
2)连续内存、局部性更好:查询往往在一个 cluster/run 中顺序扫描,cache 友好。
3)更工程化:更容易实现扩容、合并(例如用于分层过滤器、分片合并、落盘结构等)。
缺点:
1)实现复杂:需要维护 run/cluster 与元数据位,写路径比 Bloom 更难。
2)对负载因子敏感:占用率高时 run 变长,导致插入/查询延迟上升;这也是后续 VQF 等改进结构试图解决的问题之一。
3)并发更新更难:开放寻址结构在并发写入时需要更精细的同步策略。
适用场景
1)需要“插入 + 查询 + 删除”的近似集合(黑名单、去重窗口、实时风控规则等)。
2)希望比 Counting Bloom 更省空间,同时接受更复杂的实现。
实现
为了避免文章只停留在“概念层”,这里给一个教学版 Quotient Filter 思想实现:核心是把哈希值拆成 q(quotient) 与 r(remainder),用 q 定位桶、在桶附近做线性探测存指纹 r。
说明:
- 严格意义上的 Quotient Filter 会维护 run/cluster 与元数据位(occupied/continuation/shifted),实现更复杂;下面的实现用于讲清楚 q/r 切分 + 指纹过滤,并提供可直接运行的
add / mayContain / remove。 - 如果你要在生产中使用严格 QF,更建议直接用成熟库或论文实现。
QuotientFilterLite.java
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* Quotient Filter Lite
*
* - 把 hash 拆成 quotient(q) 和 remainder(r)
* - 用 q 作为“桶起点”,用线性探测在附近存储 r(指纹)
* - 支持 add / mayContain / remove(通过 tombstone)
*
* 注意:这不是论文中的严格 QF(不维护 occupied/continuation/shifted 的 run/cluster 结构)。
*/
public class QuotientFilterLite {
private static final byte EMPTY = 0;
private static final byte FULL = 1;
private static final byte DELETED = 2;
/** table size(必须是 2 的幂) */
private final int m;
private final int mask;
/** q bits = log2(m) */
private final int qBits;
/** remainder bits(<=16) */
private final int rBits;
private final long seed;
private final short[] rem; // 存 remainder 指纹
private final byte[] state; // EMPTY/FULL/DELETED
private int size;
public QuotientFilterLite(int qBits, int rBits) {
if (qBits <= 0 || qBits >= 31) {
throw new IllegalArgumentException("qBits invalid: " + qBits);
}
if (rBits <= 0 || rBits > 16) {
throw new IllegalArgumentException("rBits invalid: " + rBits);
}
this.qBits = qBits;
this.rBits = rBits;
this.m = 1 << qBits;
this.mask = m - 1;
this.seed = Hash.randomSeed();
this.rem = new short[m];
this.state = new byte[m];
}
public long getBitCount() {
// 只统计 fingerprint 本身;真实工程还要算状态位、装载因子等开销
return (long) m * rBits;
}
public boolean mayContain(long key) {
long h = Hash.hash64(key, seed);
int q = (int) h & mask;
short r = remainder(h);
int pos = q;
while (state[pos] != EMPTY) {
if (state[pos] == FULL && rem[pos] == r) {
return true;
}
pos = (pos + 1) & mask;
}
return false;
}
public boolean add(long key) {
// 负载过高会让线性探测退化,教学版简单限制一下
if (size * 10 >= m * 8) { // load factor >= 0.8
throw new IllegalStateException("Table too full. Increase qBits.");
}
long h = Hash.hash64(key, seed);
int q = (int) h & mask;
short r = remainder(h);
int firstDeleted = -1;
int pos = q;
while (state[pos] != EMPTY) {
if (state[pos] == FULL && rem[pos] == r) {
return true; // already present (or false positive collision)
}
if (firstDeleted < 0 && state[pos] == DELETED) {
firstDeleted = pos;
}
pos = (pos + 1) & mask;
}
int writePos = (firstDeleted >= 0) ? firstDeleted : pos;
rem[writePos] = r;
state[writePos] = FULL;
size++;
return true;
}
public boolean remove(long key) {
long h = Hash.hash64(key, seed);
int q = (int) h & mask;
short r = remainder(h);
int pos = q;
while (state[pos] != EMPTY) {
if (state[pos] == FULL && rem[pos] == r) {
state[pos] = DELETED;
size--;
return true;
}
pos = (pos + 1) & mask;
}
return false;
}
private short remainder(long h) {
int x = (int) ((h >>> qBits) & ((1L << rBits) - 1));
if (x == 0) {
x = 1;
}
return (short) x;
}
// 测试(风格与上文保持一致)
public static void main(String[] args) {
long time;
int len = 200000;
long[] list = new long[len * 2];
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
QuotientFilterLite f = new QuotientFilterLite(20, 12); // m=2^20 slots, 12-bit fingerprint
for (long k : keys) {
f.add(k);
}
time = System.nanoTime();
int falseNegatives = 0;
for (long k : keys) {
if (!f.mayContain(k)) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / (double) len;
int falsePositives = 0;
for (long k : nonKeys) {
if (f.mayContain(k)) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + f.getBitCount() +
" lookupAllInSet " + lookupAllInSet + "ns"
);
// remove demo
f.remove(keys[0]);
System.out.println("remove " + keys[0] + ", mayContain=" + f.mayContain(keys[0]));
}
}
现成实现
- Java(datastax):github.com/datastax/ja…
- Go(facebookincubator):github.com/facebookinc…
向量商过滤器 vector quotient filter
Vector Quotient Filter(VQF)是 SIGMOD 2021 提出的过滤器结构,可以理解为“在 QF 思路上,进一步解决高负载时 run 变长导致吞吐不稳定的问题”。它仍然是动态 AMQ,支持 insert / lookup / remove,并通过更好的落点策略与向量化实现,做到更稳定的高吞吐。
(下述特性来自论文与开源实现 splatlab/vqf 的 README。)
核心思路
1)与 QF 一样:存储指纹而不是原 key,允许假阳性、无假阴性。
2)Robin Hood hashing:插入时通过“位移/抢占”让探测距离更均衡,降低尾延迟。
3)Power-of-two-choices hashing:每个 key 计算两个候选位置/桶,选择更合适的一侧插入,以降低 run 的方差,从而在高负载下保持稳定吞吐,并降低并发更新的冲突概率。
4)Vector(向量化):对桶/指纹布局做 SIMD 友好设计,开源实现提供 AVX512/AVX2 版本,用于加速批量比较与桶内操作。
优点:
1)高吞吐且更稳定:尤其在高负载因子下,相比传统 QF 更不容易出现长 run 导致的性能抖动。
2)支持删除,并提供面向并发更新的实现选项。
缺点:
1)实现复杂,且对 CPU 指令集敏感:最佳性能依赖 AVX512(也提供 AVX2 备选实现,但需要权衡平台覆盖与性能)。
2)仍属于开放寻址家族:需要合理规划负载因子,避免极端高占用导致退化。
实现
VQF 的核心创新点很多(two-choice、Robin Hood、SIMD 向量化、并发友好等)。这里给一个简化版:演示 VQF 里非常关键的 power-of-two-choices hashing 思路——每个 key 有两个候选桶,插入时优先选择更 “好插” 的一侧,从而降低探测长度的方差。
下面代码不是论文 VQF 的 SIMD/桶布局实现,但它能把“two-choice 能显著稳定吞吐”的核心直觉用 Java 跑出来(并且支持 remove)。
TwoChoiceFingerprintFilter.java
package org.example.bloomFilter;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* Two-Choice Fingerprint Filter
*
* - 每个 key 计算两个候选起点(two-choice)
* - 在两条探测路径里选“更短/更容易插入”的那条
* - 存储的是 fingerprint(short),因此会有假阳性
* - 不会有假阴性(只要插入成功)
*
* 代码用于理解 VQF 的 two-choice ,不是 SIMD/向量化版 VQF。
*/
public class TwoChoiceFingerprintFilter {
private static final byte EMPTY = 0;
private static final byte FULL = 1;
private static final byte DELETED = 2;
private final int m;
private final int mask;
private final int fpBits;
private final long seed;
private final short[] fp;
private final byte[] state;
private int size;
public TwoChoiceFingerprintFilter(int logSlots, int fpBits) {
if (logSlots <= 0 || logSlots >= 31) {
throw new IllegalArgumentException("logSlots invalid: " + logSlots);
}
if (fpBits <= 0 || fpBits > 16) {
throw new IllegalArgumentException("fpBits invalid: " + fpBits);
}
this.m = 1 << logSlots;
this.mask = m - 1;
this.fpBits = fpBits;
this.seed = Hash.randomSeed();
this.fp = new short[m];
this.state = new byte[m];
}
public long getBitCount() {
return (long) m * fpBits;
}
public boolean mayContain(long key) {
long h = Hash.hash64(key, seed);
short f = fingerprint(h);
int i1 = (int) h & mask;
int i2 = (int) Long.rotateLeft(h, 32) & mask;
return probeContains(i1, f) || probeContains(i2, f);
}
public boolean add(long key) {
// 教学版:负载过高就直接报错(生产里应扩容/重建)
if (size * 10 >= m * 9) { // load factor >= 0.9
throw new IllegalStateException("Table too full. Increase size.");
}
long h = Hash.hash64(key, seed);
short f = fingerprint(h);
int i1 = (int) h & mask;
int i2 = (int) Long.rotateLeft(h, 32) & mask;
// 选择“更容易插入”的起点:用一次快速预估(探测到第一个 EMPTY 的距离)
int d1 = estimateDistanceToEmpty(i1, 16);
int d2 = estimateDistanceToEmpty(i2, 16);
int start = (d1 <= d2) ? i1 : i2;
return probeInsert(start, f);
}
public boolean remove(long key) {
long h = Hash.hash64(key, seed);
short f = fingerprint(h);
int i1 = (int) h & mask;
int i2 = (int) Long.rotateLeft(h, 32) & mask;
return probeRemove(i1, f) || probeRemove(i2, f);
}
private boolean probeContains(int start, short f) {
int pos = start;
while (state[pos] != EMPTY) {
if (state[pos] == FULL && fp[pos] == f) {
return true;
}
pos = (pos + 1) & mask;
}
return false;
}
private boolean probeInsert(int start, short f) {
int firstDeleted = -1;
int pos = start;
while (state[pos] != EMPTY) {
if (state[pos] == FULL && fp[pos] == f) {
return true;
}
if (firstDeleted < 0 && state[pos] == DELETED) {
firstDeleted = pos;
}
pos = (pos + 1) & mask;
}
int writePos = (firstDeleted >= 0) ? firstDeleted : pos;
fp[writePos] = f;
state[writePos] = FULL;
size++;
return true;
}
private boolean probeRemove(int start, short f) {
int pos = start;
while (state[pos] != EMPTY) {
if (state[pos] == FULL && fp[pos] == f) {
state[pos] = DELETED;
size--;
return true;
}
pos = (pos + 1) & mask;
}
return false;
}
private int estimateDistanceToEmpty(int start, int maxSteps) {
int pos = start;
for (int d = 0; d < maxSteps; d++) {
if (state[pos] == EMPTY) {
return d;
}
pos = (pos + 1) & mask;
}
return maxSteps;
}
private short fingerprint(long h) {
int mask = (1 << fpBits) - 1;
int x = (int) (h & mask);
return (short) (x == 0 ? 1 : x);
}
// 测试(风格与上文保持一致)
public static void main(String[] args) {
long time;
int len = 200000;
long[] list = new long[len * 2];
Random r = new Random(1);
Set<Long> set = new HashSet<>(list.length);
while (set.size() < list.length) {
set.add(r.nextLong());
}
int i = 0;
for (long x : set) {
list[i++] = x;
}
long[] keys = new long[len];
long[] nonKeys = new long[len];
for (int j = 0; j < len; j++) {
keys[j] = list[j];
nonKeys[j] = list[j + len];
}
TwoChoiceFingerprintFilter f = new TwoChoiceFingerprintFilter(20, 12);
for (long k : keys) {
f.add(k);
}
time = System.nanoTime();
int falseNegatives = 0;
for (long k : keys) {
if (!f.mayContain(k)) {
falseNegatives++;
}
}
if (falseNegatives > 0) {
throw new AssertionError("假阴性异常: " + falseNegatives);
}
time = System.nanoTime() - time;
double lookupAllInSet = time / (double) len;
int falsePositives = 0;
for (long k : nonKeys) {
if (f.mayContain(k)) {
falsePositives++;
}
}
double falsePositiveRate = (double) falsePositives / len;
System.out.println(
"falsePositiveRate: " + falsePositiveRate * 100 + "%" +
" size: " + len +
" bitCount: " + f.getBitCount() +
" lookupAllInSet " + lookupAllInSet + "ns"
);
// remove demo
f.remove(keys[0]);
System.out.println("remove " + keys[0] + ", mayContain=" + f.mayContain(keys[0]));
}
}
前缀过滤器 Prefix Filter
原理
Prefix Filter 是一种较新的增量式(incremental) AMQ 结构:它希望在 “支持在线插入” 的同时,在空间效率与吞吐上都比传统 Bloom 更好
从工程角度看,它可以理解为:试图弥合 Bloom(在线增量但空间有常数因子浪费)与 Xor/Ribbon(更省空间但更偏静态构建)之间的鸿沟。
近似成员查询结构在工程里常见的需求组合是:
- 在线插入(集合会增长)
- 高吞吐查询
- 低内存
- (可选)支持删除
Bloom 对“在线插入 + 简单实现” 很友好,但在空间上并不总能贴近最优;Xor/Ribbon 空间更省但构建更偏离线/批处理;QF/VQF/Cuckoo 能删但实现复杂且受负载影响。Prefix Filter 的定位就是在这些维度上给出新的折中。
工程特性
1)增量插入:开源示例中通过 FilterAPI<...>::Add(...) 完成插入,插入过程强调“可以逐步推进”(incremental/step-based),并保持无假阴性。
2)性能取向明显:作者实现要求 AVX512(同时提供插入/查询/构建/FPP 等 benchmark),说明其追求极致吞吐与可测量的“有效空间消耗”。
3)如果你希望落地到业务系统,建议先阅读论文对参数/误判率/空间模型的描述,再结合硬件平台(是否有 AVX512)评估收益。
实现参考
- 开源实现:
TomerEven/Prefix-Filter(见参考链接) - 论文:arXiv:2203.17139(见参考链接)
总结
布隆过滤器自1970年被提出以来,历经50年的岁月,仍经久不衰,在解决大数据缓存未命中等问题上发挥了至关重要的作用,在使用布隆过滤器或它的衍生时哪个效果最好,很多情况时候不能一概而论。不同的过滤器适用于不同的应用场景,具体选择应该根据数据大小、查询频率、误判要求和内存使用等因素进行权衡。
参考
Filter
www.jasondavies.com/bloomfilter
wenku.baidu.com/view/8d1044…wkts=1693276151446
citeseerx.ist.psu.edu/viewdoc/dow…
algo2.iti.kit.edu/documents/c…
www.jasondavies.com/bloomfilter
www.xueshufan.com/reader/1335…
www.xueshufan.com/reader/1341…
bloom filter [https://www.cnblogs.com/javastack/p/16439957.html] [https://blog.csdn.net/shayuchaor/article/details/128188267]
Counting Bloom filter [https://github.com/FastFilter/fastfilter_java] [https://www.cnblogs.com/javastack/p/16439957.html] [https://blog.csdn.net/vipshop_fin_dev/article/details/102647115]
cuckoo filter www.cs.cmu.edu/~binfan/pap… [https://github.com/efficient/cuckoofilter] [https://github.com/FastFilter/fastfilter_java] [http://www.lkozma.net/cuckoo_hashing_visualization] [https://blog.csdn.net/shayuchaor/article/details/128188267] [https://citeseerx.ist.psu.edu/viewdoc/download;jsessionid=3A0E427E1EAE2FCE8D9F55C94D935EFA?doi=10.1.1.682.905&rep=rep1&type=pdf]
blocked bloom filter [https://www.xueshufan.com/reader/134189653?publicationId=2916238833] [https://github.com/peterboncz/bloomfilter-repro] [https://github.com/FastFilter/fastfilter_java] [https://blog.csdn.net/shayuchaor/article/details/128188267] [http://algo2.iti.kit.edu/documents/cacheefficientbloomfilters-jea.pdf]
Xor filter [https://github.com/FastFilter/xorfilter] [https://github.com/FastFilter/fastfilter_java] [https://zhuanlan.zhihu.com/p/543943112] cmph.sourceforge.net/papers/wads… arxiv.org/pdf/1912.08…
Ribbon filter [https://github.com/MnO2/ribbon_filter] [https://github.com/facebook/rocksdb/wiki/RocksDB-Bloom-Filter] [https://rocksdb.org/blog/2021/12/29/ribbon-filter.html] [https://zhuanlan.zhihu.com/p/543943112] [https://developer.aliyun.com/article/980796?spm=a2c6h.14164896.0.0.10ff164crsfjaI] [https://blog.csdn.net/Z_Stand/article/details/119979663] [https://github.com/FastFilter/fastfilter_cpp/tree/master/src/ribbon] arxiv.org/pdf/2103.02…
quotient filter [https://github.com/datastax/java-quotient-filter] [https://github.com/facebookincubator/go-qfext]
vector quotient filter [https://github.com/bhsingla/VQF]
Prefix Filter [https://github.com/TomerEven/Prefix-Filter] [https://www.youtube.com/watch?v=KMVtvACSGo0] [https://github.com/facebook/rocksdb/wiki/RocksDB-Bloom-Filter] arxiv.org/pdf/2203.17…