缓存穿透解决方案之布隆过滤器(Bloom Filter)原理及Guava中的实现
一、什么是緩存穿透
當(dāng)用戶想要查詢一個(gè)數(shù)據(jù),發(fā)現(xiàn)redis內(nèi)存數(shù)據(jù)庫(kù)沒有,出現(xiàn)緩存未命中,于是轉(zhuǎn)向持久層數(shù)據(jù)庫(kù)查詢。發(fā)現(xiàn)也沒有,于是本次查詢失敗。當(dāng)用戶很多的時(shí)候,緩存都沒有命中,于是都去請(qǐng)求了持久層數(shù)據(jù)庫(kù),給持久層數(shù)據(jù)庫(kù)造成很大的壓力,這就是緩存穿透。
于是我們就需要有一個(gè)能實(shí)現(xiàn)“快速判斷是否存在”的方案,在確定不存在時(shí)就不在去后臺(tái)查詢數(shù)據(jù)庫(kù)了,避免了緩存穿透,布隆過濾器應(yīng)運(yùn)而生。
二、什么是布隆過濾器
Bloom Filter是一種空間效率很高的概率型數(shù)據(jù)結(jié)構(gòu),它利用位數(shù)組很簡(jiǎn)潔地表示一個(gè)集合,并能判斷一個(gè)元素是否屬于這個(gè)集合。Bloom Filter的這種高效是有一定代價(jià)的:在判斷一個(gè)元素是否屬于某個(gè)集合時(shí),有可能會(huì)把不屬于這個(gè)集合的元素誤認(rèn)為屬于這個(gè)集合(false positive)。因此,Bloom Filter不適合那些“零錯(cuò)誤”的應(yīng)用場(chǎng)合。而在能容忍低錯(cuò)誤率的應(yīng)用場(chǎng)合下,Bloom Filter通過極少的錯(cuò)誤換取了存儲(chǔ)空間的極大節(jié)省。
那么它的誕生契機(jī)是什么呢?我們平常在檢測(cè)集合中是否存在某元素時(shí),都會(huì)采用比較的方法。考慮以下情況:
如果集合用線性表存儲(chǔ),查找的時(shí)間復(fù)雜度為O(n)。
如果用平衡BST(如AVL樹、紅黑樹)存儲(chǔ),時(shí)間復(fù)雜度為O(logn)。
如果用哈希表存儲(chǔ),并用鏈地址法與平衡BST解決哈希沖突(參考JDK8的HashMap實(shí)現(xiàn)方法),時(shí)間復(fù)雜度也要有O[log(n/m)],m為哈希分桶數(shù)。
總而言之,當(dāng)集合中元素的數(shù)量極多(百/千萬級(jí)甚至更多)時(shí),不僅查找會(huì)變得很慢,而且占用的空間也會(huì)大到無法想象。而布隆(BF)過濾器就是解決這個(gè)矛盾的利器。
三、布隆過濾器原理
BF是由一個(gè)長(zhǎng)度為m比特的位數(shù)組(bit array)與k個(gè)哈希函數(shù)(hash function)組成的數(shù)據(jù)結(jié)構(gòu)。位數(shù)組均初始化為0,所有哈希函數(shù)都可以分別把輸入數(shù)據(jù)盡量均勻地散列。
當(dāng)要插入一個(gè)元素時(shí),將其數(shù)據(jù)分別輸入k個(gè)哈希函數(shù),產(chǎn)生k個(gè)哈希值。以哈希值作為位數(shù)組中的下標(biāo),將所有k個(gè)對(duì)應(yīng)的比特置為1。
當(dāng)要查詢(即判斷是否存在)一個(gè)元素時(shí),同樣將其數(shù)據(jù)輸入哈希函數(shù),然后檢查對(duì)應(yīng)的k個(gè)比特。如果有任意一個(gè)比特為0,表明該元素一定不在集合中。如果所有比特均為1,表明該集合有(較大的)可能性在集合中。為什么不是一定在集合中呢?因?yàn)橐粋€(gè)比特被置為1有可能會(huì)受到其他元素的影響,這就是所謂“假陽(yáng)性”(false positive)。相對(duì)地,“假陰性”(false negative)在BF中是絕不會(huì)出現(xiàn)的。
下圖示出一個(gè)m=18, k=3的BF示例。集合中的x、y、z三個(gè)元素通過3個(gè)不同的哈希函數(shù)散列到位數(shù)組中。當(dāng)查詢?cè)豾時(shí),因?yàn)橛幸粋€(gè)比特為0,因此w不在該集合中。
BF的優(yōu)點(diǎn)是顯而易見的:
不需要存儲(chǔ)數(shù)據(jù)本身,只用比特表示,因此空間占用相對(duì)于傳統(tǒng)方式有巨大的優(yōu)勢(shì),并且能夠保密數(shù)據(jù);
時(shí)間效率也較高,插入和查詢的時(shí)間復(fù)雜度均為O(k);
哈希函數(shù)之間相互獨(dú)立,可以在硬件指令層面并行計(jì)算。
但是,它的缺點(diǎn)也同樣明顯:
存在假陽(yáng)性的概率,不適用于任何要求100%準(zhǔn)確率的情境;
只能插入和查詢?cè)兀荒軇h除元素,這與產(chǎn)生假陽(yáng)性的原因是相同的。我們可以簡(jiǎn)單地想到通過計(jì)數(shù)(即將一個(gè)比特?cái)U(kuò)展為計(jì)數(shù)值)來記錄元素?cái)?shù),但仍然無法保證刪除的元素一定在集合中。
布隆過濾器有這么些特點(diǎn):
哈希函數(shù)個(gè)數(shù)k越多,假陽(yáng)性概率越低;
位數(shù)組長(zhǎng)度m越大,假陽(yáng)性概率越低;
已插入元素的個(gè)數(shù)n越大,假陽(yáng)性概率越高。
四、Guava中的布隆過濾器實(shí)現(xiàn)
1、Bloom Filter成員變量
Guava中,布隆過濾器的實(shí)現(xiàn)主要涉及到2個(gè)類,BloomFilter和BloomFilterStrategies,首先來看一下BloomFilter的成員變量。需要注意的是不同Guava版本的BloomFilter實(shí)現(xiàn)不同。
/** guava實(shí)現(xiàn)的以CAS方式設(shè)置每個(gè)bit位的bit數(shù)組 */ private final LockFreeBitArray bits; /** hash函數(shù)的個(gè)數(shù) */ private final int numHashFunctions; /** guava中將對(duì)象轉(zhuǎn)換為byte的通道 */ private final Funnel<? super T> funnel; /** * 將byte轉(zhuǎn)換為n個(gè)bit的策略,也是bloomfilter hash映射的具體實(shí)現(xiàn) */ private final Strategy strategy;
這是它的4個(gè)成員變量:
LockFreeBitArray是定義在BloomFilterStrategies中的內(nèi)部類,封裝了布隆過濾器底層bit數(shù)組的操作。
numHashFunctions表示哈希函數(shù)的個(gè)數(shù)。
Funnel,它和PrimitiveSink配套使用,能將任意類型的對(duì)象轉(zhuǎn)化成Java基本數(shù)據(jù)類型,默認(rèn)用java.nio.ByteBuffer實(shí)現(xiàn),最終均轉(zhuǎn)化為byte數(shù)組。
Strategy是布隆過濾器的哈希策略,即數(shù)據(jù)如何映射到位數(shù)組,其具體方法在BloomFilterStrategies枚舉中,主要有2個(gè):put和mightContain。
2、Bloom Filter構(gòu)造
創(chuàng)建布隆過濾器,BloomFilter并沒有公有的構(gòu)造函數(shù),只有一個(gè)私有構(gòu)造函數(shù),而對(duì)外它提供了5個(gè)重載的create方法,在缺省情況下誤判率設(shè)定為3%,采用BloomFilterStrategies.MURMUR128_MITZ_64的實(shí)現(xiàn)。
@VisibleForTesting
static <T> BloomFilter<T> create(
Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) {
checkNotNull(funnel);
checkArgument(
expectedInsertions >= 0, "Expected insertions (%s) must be >= 0", expectedInsertions);
checkArgument(fpp > 0.0, "False positive probability (%s) must be > 0.0", fpp);
checkArgument(fpp < 1.0, "False positive probability (%s) must be < 1.0", fpp);
checkNotNull(strategy);
if (expectedInsertions == 0) {
expectedInsertions = 1;
}
/*
* TODO(user): Put a warning in the javadoc about tiny fpp values, since the resulting size
* is proportional to -log(p), but there is not much of a point after all, e.g.
* optimalM(1000, 0.0000000000000001) = 76680 which is less than 10kb. Who cares!
*/
long numBits = optimalNumOfBits(expectedInsertions, fpp);
int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
try {
return new BloomFilter<T>(new LockFreeBitArray(numBits), numHashFunctions, funnel, strategy);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Could not create BloomFilter of " + numBits + " bits", e);
}
}
該方法接受4個(gè)參數(shù):funnel是插入數(shù)據(jù)的Funnel,expectedInsertions是期望插入的元素總個(gè)數(shù)n,fpp即期望假陽(yáng)性率p,strategy即哈希策略。由上可知,位數(shù)組的長(zhǎng)度m和哈希函數(shù)的個(gè)數(shù)k分別通過optimalNumOfBits()方法和optimalNumOfHashFunctions()方法來估計(jì)。
3、估計(jì)最優(yōu)m值和k值
@VisibleForTesting
static long optimalNumOfBits(long n, double p) {
if (p == 0) {
p = Double.MIN_VALUE;
}
return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
@VisibleForTesting
static int optimalNumOfHashFunctions(long n, long m) {
// (m / n) * log(2), but avoid truncation due to division!
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
以上計(jì)算方式是基于推演得出的,此處不再詳述。
4、哈希函數(shù)
在BloomFilterStrategies枚舉中定義了兩種哈希策略,都基于著名的MurmurHash算法,分別是MURMUR128_MITZ_32和MURMUR128_MITZ_64。前者是一個(gè)簡(jiǎn)化版,所以我們來看看后者的實(shí)現(xiàn)方法。
enum BloomFilterStrategies implements BloomFilter.Strategy {
MURMUR128_MITZ_32() {//....}
MURMUR128_MITZ_64() {//....}
}
MURMUR128_MITZ_64() {
@Override
public <T> boolean put(
T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
long bitSize = bits.bitSize();
// 先利用murmur3 hash對(duì)輸入的funnel計(jì)算得到128位的哈希值,funnel現(xiàn)將object轉(zhuǎn)換為byte數(shù)組,
// 然后在使用哈希函數(shù)轉(zhuǎn)換為long
byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
// 根據(jù)hash值的高低位算出hash1和hash2
long hash1 = lowerEight(bytes);
long hash2 = upperEight(bytes);
boolean bitsChanged = false;
// 循環(huán)體內(nèi)采用了2個(gè)函數(shù)模擬其他函數(shù)的思想,相當(dāng)于每次累加hash2
long combinedHash = hash1;
for (int i = 0; i < numHashFunctions; i++) {
// Make the combined hash positive and indexable
// 通過基于bitSize取模的方式獲取bit數(shù)組中的索引,然后調(diào)用set函數(shù)設(shè)置。
bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);
combinedHash += hash2;
}
return bitsChanged;
}
@Override
public <T> boolean mightContain(
T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
long bitSize = bits.bitSize();
byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
long hash1 = lowerEight(bytes);
long hash2 = upperEight(bytes);
long combinedHash = hash1;
for (int i = 0; i < numHashFunctions; i++) {
// Make the combined hash positive and indexable
// 和put的區(qū)別就在這里,從set轉(zhuǎn)換為get,來判斷是否存在
if (!bits.get((combinedHash & Long.MAX_VALUE) % bitSize)) {
return false;
}
combinedHash += hash2;
}
return true;
}
private /* static */ long lowerEight(byte[] bytes) {
return Longs.fromBytes(
bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2], bytes[1], bytes[0]);
}
private /* static */ long upperEight(byte[] bytes) {
return Longs.fromBytes(
bytes[15], bytes[14], bytes[13], bytes[12], bytes[11], bytes[10], bytes[9], bytes[8]);
}
};
其中put()方法負(fù)責(zé)向布隆過濾器中插入元素,mightContain()方法負(fù)責(zé)判斷元素是否存在。以put()方法為例講解一下流程吧。
使用MurmurHash算法對(duì)funnel的輸入數(shù)據(jù)進(jìn)行散列,得到128bit(16B)的字節(jié)數(shù)組。
取低8字節(jié)作為第一個(gè)哈希值hash1,取高8字節(jié)作為第二個(gè)哈希值hash2。
進(jìn)行k次循環(huán),每次循環(huán)都用hash1與hash2的復(fù)合哈希做散列,然后對(duì)m取模,將位數(shù)組中的對(duì)應(yīng)比特設(shè)為1。
這里需要注意兩點(diǎn):
在循環(huán)中實(shí)際上應(yīng)用了雙重哈希(double hashing)的思想,即可以用兩個(gè)哈希函數(shù)來模擬k個(gè),其中i為步長(zhǎng):
這種方法在開放定址的哈希表中,也經(jīng)常用來減少?zèng)_突。
哈希值有可能為負(fù)數(shù),而負(fù)數(shù)是不能在位數(shù)組中定位的。所以哈希值需要與Long.MAX_VALUE做bitwise AND,直接將其最高位(符號(hào)位)置為0,就變成正數(shù)了。
因此在put方法中,先是將索引位置上的二進(jìn)制置為1,然后用bitsChanged記錄插入結(jié)果,如果返回true表明沒有重復(fù)插入成功,而mightContain方法則是將索引位置上的數(shù)值取出,并判斷是否為0,只要其中出現(xiàn)一個(gè)0,那么立即判斷為不存在。
5、位數(shù)組具體實(shí)現(xiàn)
Guava為了提供效率,自己實(shí)現(xiàn)了LockFreeBitArray來提供bit數(shù)組的無鎖設(shè)置和讀取,我們來看看LockFreeBitArray類的部分代碼:
static final class LockFreeBitArray {
private static final int LONG_ADDRESSABLE_BITS = 6;
final AtomicLongArray data;
private final LongAddable bitCount;
LockFreeBitArray(long bits) {
this(new long[Ints.checkedCast(LongMath.divide(bits, 64, RoundingMode.CEILING))]);
}
// Used by serialization
LockFreeBitArray(long[] data) {
checkArgument(data.length > 0, "data length is zero!");
this.data = new AtomicLongArray(data);
this.bitCount = LongAddables.create();
long bitCount = 0;
for (long value : data) {
bitCount += Long.bitCount(value);
}
this.bitCount.add(bitCount);
}
/** Returns true if the bit changed value. */
boolean set(long bitIndex) {
if (get(bitIndex)) {
return false;
}
int longIndex = (int) (bitIndex >>> LONG_ADDRESSABLE_BITS);
long mask = 1L << bitIndex; // only cares about low 6 bits of bitIndex
long oldValue;
long newValue;
// 經(jīng)典的CAS自旋重試機(jī)制
do {
oldValue = data.get(longIndex);
newValue = oldValue | mask;
if (oldValue == newValue) {
return false;
}
} while (!data.compareAndSet(longIndex, oldValue, newValue));
// We turned the bit on, so increment bitCount.
bitCount.increment();
return true;
}
boolean get(long bitIndex) {
return (data.get((int) (bitIndex >>> 6)) & (1L << bitIndex)) != 0;
}
// ....
}
它是采用原子類型AtomicLongArray作為位數(shù)組的存儲(chǔ)的,確實(shí)不需要加鎖。另外還有一個(gè)Guava中特有的LongAddable類型的計(jì)數(shù)器,用來統(tǒng)計(jì)置為1的比特?cái)?shù)。
采用AtomicLongArray除了有并發(fā)上的優(yōu)勢(shì)之外,更主要的是它可以表示非常長(zhǎng)的位數(shù)組。一個(gè)長(zhǎng)整型數(shù)占用64bit,因此data[0]可以代表第0~63bit,data[1]代表64~127bit,data[2]代表128~191bit……依次類推。這樣設(shè)計(jì)的話,將下標(biāo)i無符號(hào)右移6位就可以獲得data數(shù)組中對(duì)應(yīng)的位置,再在其基礎(chǔ)上左移i位就可以取得對(duì)應(yīng)的比特了。
上面的代碼中用到了Long.bitCount()方法計(jì)算long型二進(jìn)制表示中1的數(shù)量:
public static int bitCount(long i) {
// HD, Figure 5-14
i = i - ((i >>> 1) & 0x5555555555555555L);
i = (i & 0x3333333333333333L) + ((i >>> 2) & 0x3333333333333333L);
i = (i + (i >>> 4)) & 0x0f0f0f0f0f0f0f0fL;
i = i + (i >>> 8);
i = i + (i >>> 16);
i = i + (i >>> 32);
return (int)i & 0x7f;
}
五、Redis實(shí)現(xiàn)布隆過濾器
上面使用guava實(shí)現(xiàn)布隆過濾器是把數(shù)據(jù)放在本地內(nèi)存中,無法實(shí)現(xiàn)布隆過濾器的共享,我們還可以把數(shù)據(jù)放在redis中,用 redis來實(shí)現(xiàn)布隆過濾器,我們要使用的數(shù)據(jù)結(jié)構(gòu)是bitmap,你可能會(huì)有疑問,redis支持五種數(shù)據(jù)結(jié)構(gòu):String,List,Hash,Set,ZSet,沒有bitmap呀。沒錯(cuò),實(shí)際上bitmap的本質(zhì)還是String。
要用redis來實(shí)現(xiàn)布隆過濾器,我們需要自己設(shè)計(jì)映射函數(shù),自己度量二進(jìn)制向量的長(zhǎng)度。
public class RedisMain {
static final int expectedInsertions = 100;//要插入多少數(shù)據(jù)
static final double fpp = 0.01;//期望的誤判率
//bit數(shù)組長(zhǎng)度
private static long numBits;
//hash函數(shù)數(shù)量
private static int numHashFunctions;
static {
numBits = optimalNumOfBits(expectedInsertions, fpp);
numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
}
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
for (int i = 0; i < 100; i++) {
long[] indexs = getIndexs(String.valueOf(i));
for (long index : indexs) {
jedis.setbit("codebear:bloom", index, true);
}
}
for (int i = 0; i < 100; i++) {
long[] indexs = getIndexs(String.valueOf(i));
for (long index : indexs) {
Boolean isContain = jedis.getbit("codebear:bloom", index);
if (!isContain) {
System.out.println(i + "肯定沒有重復(fù)");
}
}
System.out.println(i + "可能重復(fù)");
}
}
/**
* 根據(jù)key獲取bitmap下標(biāo)
*/
private static long[] getIndexs(String key) {
long hash1 = hash(key);
long hash2 = hash1 >>> 16;
long[] result = new long[numHashFunctions];
for (int i = 0; i < numHashFunctions; i++) {
long combinedHash = hash1 + i * hash2;
if (combinedHash < 0) {
combinedHash = ~combinedHash;
}
result[i] = combinedHash % numBits;
}
return result;
}
private static long hash(String key) {
Charset charset = Charset.forName("UTF-8");
return Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asLong();
}
//計(jì)算hash函數(shù)個(gè)數(shù)
private static int optimalNumOfHashFunctions(long n, long m) {
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
//計(jì)算bit數(shù)組長(zhǎng)度
private static long optimalNumOfBits(long n, double p) {
if (p == 0) {
p = Double.MIN_VALUE;
}
return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
}
總結(jié)
以上是生活随笔為你收集整理的缓存穿透解决方案之布隆过滤器(Bloom Filter)原理及Guava中的实现的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 安兔兔评测手机芯片性能评测怎么查 安兔兔
- 下一篇: 京东咚咚的使用教程 京东怎么聊天