有两种解决方案:回种空值以及使用布隆过滤器。

一.回种空值

由于代码的 bug 导致查询数据库的时候抛出了异常,这样可以 认为从数据库查询出来的数据为空,同样不会回种缓存。最大的问题在于数据库中并不存在用户的数据,这就造成无 论查询多少次,数据库中永远都不会存在这个用户的数据,穿透永远都会发生。

那么,当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但 是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个 比较短的过期时间,让空值在短时间之内能够快速过期淘汰。

 

 回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内 就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除 掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。所以在使用的时候应该评估一下缓存容量是否能够支撑。如果需要大量 的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时可以考虑使用布隆 过滤器。

二.布隆过滤器

我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数 组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断 一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如 果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。,我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计 算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。 新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数 组中,相应位置的值。那么当我们需要查询某一个用户的信息时,我们首先查询这个 ID 在 布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这 样就可以极大地减少异常查询带来的缓存穿透。

它主要有两个缺陷: 1. 它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断 为处在集合中; 2. 不支持删除元素。

关于第一个缺陷,主要是 Hash 算法的问题。因为布隆过滤器是由一个二进制数组和一个 Hash 算法组成的,Hash 算法存在着一定的碰撞几率。Hash 碰撞的含义是不同的输入值 经过 Hash 运算后得到了相同的 Hash 结果。 本来,Hash 的含义是不同的输入,依据不同的算法映射成独一无二的固定长度的值,也就 是我输入字符串“1”,根据 CRC32 算法,值是 2212294583。但是现实中 Hash 算法的 输入值是无限的,输出值的值空间却是固定的,比如 16 位的 Hash 值的值空间是 65535, 那么它的碰撞几率就是 1/65535,即如果输入值的个数超过 65535 就一定会发生碰撞。

因为更长的 Hash 值会带来更高的存储成本和计算成本。即使使用 32 位的 Hash 算法,它 的值空间长度是 2 的 32 次幂减一,约等于 42 亿,用来映射 20 亿的用户数据,碰撞几率 依然有接近 50%。 Hash 的碰撞就造成了两个用户 ID ,A 和 B 会计算出相同的 Hash 值,那么如果 A 是注册 的用户,它的 Hash 值对应的数组中的值是 1,那么 B 即使不是注册用户,它在数组中的 位置和 A 是相同的,对应的值也是 1,这就产生了误判。 布隆过滤器的误判有一个特点,就是它只会出现“false positive”的情况。这是什么意思 呢?当布隆过滤器判断元素在集合中时,这个元素可能不在集合中。但是一旦布隆过滤器判 断这个元素不在集合中时,它一定不在集合中。这一点非常适合解决缓存穿透的问题。

一个解决方案是: 使用多个 Hash 算法为元素计算出多个 Hash 值,只有所有 Hash 值对应的数组中的值都为 1 时,才会认为这个元素在集合中。 布隆过滤器不支持删除元素的缺陷也和 Hash 碰撞有关。给你举一个例子,假如两个元素 A 和 B 都是集合中的元素,它们有相同的 Hash 值,它们就会映射到数组的同一个位置。 这时我们删除了 A,数组中对应位置的值也从 1 变成 0,那么在判断 B 的时候发现值是 0,也会判断 B 是不在集合中的元素,就会得到错误的结论。

我会让数组中不再只有 0 和 1 两个值,而是存储一个计 数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删 除了就把这个值从 2 改为 1。这个方案中的数组不再存储 bit 位,而是存储数值,也就会增 加空间的消耗。

回种空值和布隆过滤器是解决缓存穿透问题的两种最主要的解决方案,但是它们 也有各自的适用场景,并不能解决所有问题。比方说当有一个极热点的缓存项,它一旦失效 会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫 做“dog-pile effect”(狗桩效应), 这是典型的缓存并发穿透的问题,那么,我们如何来解决这个问题呢?解决狗桩效应的思路 是尽量地减少缓存穿透后的并发,方案也比较简单: 1. 在代码中,控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数 据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。 2. 通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数 据库。

比方说 ID 为 1 的用户是一个热点用户,当他的用户信息缓存 失效后,我们需要从数据库中重新加载数据时,先向 Memcached 中写入一个 Key 为"lock.1"的缓存项,然后去数据库里面加载数据,当数据加载完成后再把这个 Key 删掉。 这时,如果另外一个线程也要请求这个用户的数据,它发现缓存中有 Key 为“lock.1”的 缓存,就认为目前已经有线程在加载数据库中的值到缓存中了,它就可以重新去缓存中查询 数据,不再穿透数据库了。

 

posted on 2020-06-03 01:39  Moonshoterr  阅读(842)  评论(0编辑  收藏  举报