分布式缓存
缓存数据如何分片
单一的缓存节点受到机器内存、网卡带宽和单节点请求量的限制,不能承担比较高的并发,因此我们考虑将数据分片,依照分片算法将数据打散到多个不同的节点上,每个节点上存储部分数据。
一般来讲,分片算法常见的就是 Hash 分片算法和一致性 Hash 分片算法两种。
Hash 分片的算法就是对缓存的 Key 做哈希计算,然后对总的缓存节点个数取余。你可以这么理解:
比如说,我们部署了三个缓存节点组成一个缓存的集群,当有新的数据要写入时,我们先对这个缓存的 Key 做比如 crc32 等 Hash 算法生成 Hash 值,然后对 Hash 值模 3,得出的结果就是要存入缓存节点的序号。
这个算法最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。如果采用这种方法,最好建立在你对于这组缓存命中率下降不敏感,比如下面还有另外一层缓存来兜底的情况下。
当然了,用一致性 Hash 算法可以很好地解决增加和删减节点时,命中率下降的问题。 在这个算法中,我们将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上“行走”,遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面,Key 1 和 Key 2 会落入到 Node 1 中,Key 3、Key 4 会落入到 Node 2 中,Key 5 落入到 Node 3 中,Key 6 落入到 Node 4 中。
这时如果在 Node 1 和 Node 2 之间增加一个 Node 5,你可以看到原本命中 Node 2 的 Key 3 现在命中到 Node 5,而其它的 Key 都没有变化;同样的道理,如果我们把 Node 3 从集群中移除,那么只会影响到 Key 5 。所以你看,在增加和删除节点时,只有少量的 Key 会“漂移”到其它节点上, 而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。
不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:
- 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
- 一致性 Hash 算法的脏数据问题。
极端情况下,比如一个有三个节点 A、B、C 承担整体的访问,每个节点的访问量平均,A 故障后,B 将承担双倍的压力(A 和 B 的全部请求),当 B 承担不了流量 Crash 后,C 也将因为要承担原先三倍的流量而 Crash,这就造成了整体缓存系统的雪崩。
**在一致性 Hash 算法中引入虚拟节点的概念。**它将一个缓存节点计算多个 Hash 值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key 将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。
其次,就是一致性 Hash 算法的脏数据问题。为什么会产生脏数据呢? 比方说,在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。
所以,在使用一致性 Hash 算法时一定要设置缓存的过期时间, 这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。
缓存穿透
先来考虑这样一种场景:在你的电商系统的用户表中,我们需要通过用户 ID 查询用户的信息,缓存的读写策略采用 Cache Aside 策略。
那么,如果要读取一个用户表中未注册的用户,会发生什么情况呢?按照这个策略,我们会先读缓存,再穿透读数据库。由于用户并不存在,所以缓存和数据库中都没有查询到数据,因此也就不会向缓存中回种数据(也就是向缓存中设置值的意思),这样当再次请求这个用户数据的时候还是会再次穿透到数据库。在这种场景下,缓存并不能有效地阻挡请求穿透到数据库上,它的作用就微乎其微了。
那如何解决缓存穿透呢?一般来说我们会有两种解决方案:回种空值以及使用布隆过滤器。
回种空值
回顾上面提到的场景,你会发现最大的问题在于数据库中并不存在用户的数据,这就造成无论查询多少次,数据库中永远都不会存在这个用户的数据,穿透永远都会发生。
类似的场景还有一些: 比如由于代码的 bug 导致查询数据库的时候抛出了异常,这样可以认为从数据库查询出来的数据为空,同样不会回种缓存。
那么,当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。
回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。
布隆过滤器
1970 年布隆提出了一种布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。它的基本思路如下:
我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。
还是以存储用户信息的表为例进行讲解。首先,我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。
新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数组中,相应位置的值。那么当我们需要查询某一个用户的信息时,我们首先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。
布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1),是常量值。在空间上,相对于其他数据结构它也有很大的优势,比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。
不过,任何事物都有两面性,布隆过滤器也不例外,它主要有两个缺陷:
-
它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中;
-
不支持删除元素。
关于第一个缺陷,主要是 Hash 算法的问题。 因为布隆过滤器是由一个二进制数组和一个 Hash 算法组成的,Hash 算法存在着一定的碰撞几率。Hash 碰撞的含义是不同的输入值经过 Hash 运算后得到了相同的 Hash 结果。
本来,Hash 的含义是不同的输入,依据不同的算法映射成独一无二的固定长度的值,也就是我输入字符串“1”,根据 CRC32 算法,值是 2212294583。但是现实中 Hash 算法的输入值是无限的,输出值的值空间却是固定的,比如 16 位的 Hash 值的值空间是 65535,那么它的碰撞几率就是 1/65535,即如果输入值的个数超过 65535 就一定会发生碰撞。
那么你可能会问为什么不映射成更长的 Hash 值呢?
因为更长的 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 位,而是存储数值,也就会增加空间的消耗。所以,你要依据业务场景来选择是否能够使用布隆过滤器, 比如像是注册用户的场景下,因为用户删除的情况基本不存在,所以还是可以使用布隆过滤器来解决缓存穿透的问题的。
讲了这么多,关于布隆过滤器的使用上,我也给你几个建议:
-
选择多个 Hash 函数计算多个 Hash 值,这样可以减少误判的几率;
-
布隆过滤器会消耗一定的内存空间,所以在使用时需要评估你的业务场景下需要多大的内存,存储的成本是否可以接受。