针对以上问题常规做法是:查询数据库,数据库硬扛,如果压力并不大可以使用此方法,保持简单即可。
改进做法:用 list/set/tree 维护一个元素集合,判断元素是否在集合内,时间复杂度或空间复杂度会比较高。如果是微服务的话可以用 redis 中的 list/set 数据结构, 数据规模非常大此方案的内存容量要求可能会非常高。
这些场景有个共同点,可以将问题抽象为:如何高效判断一个元素不在集合中? 那么有没有一种更好方案能达到时间复杂度和空间复杂双优呢?
有!布隆过滤器。 什么是布隆过滤器
布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中,它的优点是空间效率和查询时间都远远超过一般的算法。
> 工作原理
布隆过滤器的原理是,当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点(offset),把它们置为 1。检索时,我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了:如果这些点有任何一个 0,则被检元素一定不在;如果都是 1,则被检元素很可能在。这就是布隆过滤器的基本思想。
简单来说就是准备一个长度为 m 的位数组并初始化所有元素为 0,用 k 个散列函数对元素进行 k 次散列运算跟 len(m)取余得到 k 个位置并将 m 中对应位置设置为 1。
布隆过滤器优缺点 优点:
空间占用极小,因为本身不存储数据而是用比特位表示数据是否存在,某种程度有保密的效果。
插入与查询时间复杂度均为 O(k),常数级别,k 表示散列函数执行次数。
散列函数之间可以相互独立,可以在硬件指令层加速计算。
缺点:
误差(假阳性率)。
无法删除。
> 误差(假阳性率)
布隆过滤器可以 100% 判断元素不在集合中,但是当元素在集合中时可能存在误判,因为当元素非常多时散列函数产生的 k 位点可能会重复。 维基百科有关于假阳性率的数学推导(见文末链接)这里我们直接给结论(实际上是我没看懂...),假设:
位数组长度 m
散列函数个数 k
预期元素数量 n
期望误差_ε_
在创建布隆过滤器时我们为了找到合适的 m 和 k ,可以根据预期元素数量 n 与 ε 来推导出最合适的 m 与 k 。
java 中 Guava, Redisson 实现布隆过滤器估算最优 m 和 k 采用的就是此算法:
// 计算哈希次数
@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)));
}
// 计算位数组长度
@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)));
}
// ARGV:偏移量offset数组
// KYES[1]: setbit操作的key
// 全部设置为1
setScript = `
for _, offset in ipairs(ARGV) do
redis.call("setbit", KEYS[1], offset, 1)
end
`
// ARGV:偏移量offset数组
// KYES[1]: setbit操作的key
// 检查是否全部为1
testScript = `
for _, offset in ipairs(ARGV) do
if tonumber(redis.call("getbit", KEYS[1], offset)) == 0 then
return false
end
end
return true
`
改进建议
整体实现非常简洁高效,那么有没有改进的空间呢?
个人认为还是有的,上面提到过自动计算最优 m 与 k 的数学公式,如果创建参数改为:
预期总数量expectedInsertions
期望误差falseProbability
就更好了,虽然作者注释里特别提到了误差说明,但是实际上作为很多开发者对位数组长度并不敏感,无法直观知道 bits 传多少预期误差会是多少。
// New create a Filter, store is the backed redis, key is the key for the bloom filter,
// bits is how many bits will be used, maps is how many hashes for each addition.
// best practices:
// elements - means how many actual elements
// when maps = 14, formula: 0.7*(bits/maps), bits = 20*elements, the error rate is 0.000067 < 1e-4
// for detailed error rate table, see http://pages.cs.wisc.edu/~cao/papers/summary-cache/node8.html
func New(store *redis.Redis, key string, bits uint) *Filter {
return &Filter{
bits: bits,
bitSet: newRedisBitSet(store, key, bits),
}
}
// expectedInsertions - 预期总数量
// falseProbability - 预期误差
// 这里也可以改为option模式不会破坏原有的兼容性
func NewFilter(store *redis.Redis, key string, expectedInsertions uint, falseProbability float64) *Filter {
bits := optimalNumOfBits(expectedInsertions, falseProbability)
k := optimalNumOfHashFunctions(bits, expectedInsertions)
return &Filter{
bits: bits,
bitSet: newRedisBitSet(store, key, bits),
k: k,
}
}
// 计算最优哈希次数
func optimalNumOfHashFunctions(m, n uint) uint {
return uint(math.Round(float64(m) / float64(n) * math.Log(2)))
}
// 计算最优数组长度
func optimalNumOfBits(n uint, p float64) uint {
return uint(float64(-n) * math.Log(p) / (math.Log(2) * math.Log(2)))
}
回到问题
> 如何预防非法 id 导致缓存穿透?
由于 id 不存在导致请求无法命中缓存流量直接打到数据库,同时数据库也不存在该记录导致无法写入缓存,高并发场景这无疑会极大增加数据库压力。 解决方案有两种: