前面我们说了关于高并发下的数据一致性的处理,但是那只是其中一方面的问题,在高并发情况下,还会遇到其它的问题,今天这片文章,我们来一起看一下。
在Redis 存储的所有数据中,有一部分是被频繁访问的。有两种情况可能会导致热点问题的产生,一个是用户集中访问的数据,比如抢购的商品,明星结婚和明星出轨的微博。
还有一种就是在数据进行分片的情况下,负载不均衡,超过了单个服务器的承受能力。
热点问题可能引起缓存服务的不可用,最终造成压力堆积到数据库。
出于存储和流量优化的角度,我们必须要找到这些热点数据。
除了自动的缓存淘汰机制之外,怎么找出那些访问频率高的 key 呢?或者说,我们可以在哪里记录 key 被访问的情况呢?
第一个当然是在客户端了,比如我们可不可以在所有调用了 get、set 方法的地方,
加上 key 的计数。但是这样的话,每一个地方都要修改,重复的代码也多。如果我们用的是 Jedis 的客户端,我们可以在 Jedis 的 Connection 类的 sendCommand()里面,用一个 HashMap 进行 key 的计数。
但是这种方式有几个问题:
第二种方式就是在代理端实现,比如 TwemProxy 或者 Codis,但是不是所有的项目都使用了代理的架构。
第三种就是在服务端统计,Redis 有一个 monitor 的命令,可以监控到所有 Redis 执行的命令。
代码:
jedis.monitor(new JedisMonitor() {
@Override
public void onCommand(String command) {
System.out.println("#monitor: " + command);
}
});
Facebook 的开源项 目redis-faina
(https://github.com/facebookarchive/redis-faina.git)就是基于这个原理实现的。
它是一个 python 脚本,可以分析 monitor 的数据。
redis-cli -p 6379 monitor | head -n 100000 | ./redis-faina.py
这种方法也会有两个问题:
1)monitor 命令在高并发的场景下,会影响性能,所以不适合长时间使用。
2)只能统计一个 Redis 节点的热点 key。
还有一种方法就是机器层面的,通过对 TCP 协议进行抓包,也有一些开源的方案,比如 ELK 的 packetbeat 插件。
当我们发现了热点 key 之后,我们来看下热点数据在高并发的场景下可能会出现的问题,以及怎么去解决。
缓存雪崩就是 Redis 的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候 Redis 请求的并发量又很大,就会导致所有的请求落到数据库。
1)加互斥锁或者使用队列,针对同一个 key 只允许一个线程到数据库查询
2)缓存定时预先更新,避免同时失效
3)通过加随机数,使 key 在不同的时间过期
4)缓存永不过期
我们已经知道 Redis 使用的场景了。在缓存存在和缓存不存在的情况下的什么情况我们都了解了。
还有一种情况,数据在数据库和 Redis 里面都不存在,可能是一次条件错误的查询。在这种情况下,因为数据库值不存在,所以肯定不会写入 Redis,那么下一次查询相同的 key 的时候,肯定还是会再到数据库查一次。那么这种循环查询数据库中不存在的值,并且每次使用的是相同的 key 的情况,我们有没有什么办法避免应用到数据库查询呢?
(1)缓存空数据
(2)缓存特殊字符串,比如&&
我们可以在数据库缓存一个空字符串,或者缓存一个特殊的字符串,那么在应用里面拿到这个特殊字符串的时候,就知道数据库没有值了,也没有必要再到数据库查询了。
但是这里需要设置一个过期时间,不然的话数据库已经新增了这一条记录,应用也还是拿不到值。
这个是应用重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?即使你每次都缓存特殊字符串也没用,因为它的值不一样,比如我们的用户系统登录的场景,如果是恶意的请求,它每次都生成了一个符合 ID 规则的账号,但是这个账号在我们的数据库是不存在的,那 Redis 就完全失去了作用。
这种因为每次查询的值都不存在导致的 Redis 失效的情况,我们就把它叫做缓存穿透。这个问题我们应该怎么去解决呢?
其实它也是一个通用的问题,关键就在于我们怎么知道请求的 key 在我们的数据库里面是否存在,如果数据量特别大的话,我们怎么去快速判断。
这也是一个非常经典的面试题:
如何在海量元素中(例如 10 亿无序、不定长、不重复)快速判断一个元素是否存在?
如果是缓存穿透的这个问题,我们要避免到数据库查询不存的数据,肯定要把这 10 亿放在别的地方。这些数据在 Redis 里面也是没有的,为了加快检索速度,我们要把数据放到内存里面来判断,问题来了:
如果我们直接把这些元素的值放到基本的数据结构(List、Map、Tree)里面,比如一个元素 1 字节的字段,10 亿的数据大概需要 900G 的内存空间,这个对于普通的服务器来说是承受不了的。
所以,我们存储这几十亿个元素,不能直接存值,我们应该找到一种最简单的最节省空间的数据结构,用来标记这个元素有没有出现。
这个东西我们就把它叫做位图,他是一个有序的数组,只有两个值,0 和 1。0 代表不存在,1 代表存在。
那我们怎么用这个数组里面的有序的位置来标记这 10 亿个元素是否存在呢?我们是不是必须要有一个映射方法,把元素映射到一个下标位置上?
对于这个映射方法,我们有几个基本的要求:
1)因为我们的值长度是不固定的,我希望不同长度的输入,可以得到固定长度的输出。
2)转换成下标的时候,我希望他在我的这个有序数组里面是分布均匀的,不然的话全部挤到一对去了,我也没法判断到底哪个元素存了,哪个元素没存。
这个就是哈希函数,比如 MD5、SHA-1 等等这些都是常见的哈希算法。
比如,这 6 个元素,我们经过哈希函数和位运算,得到了相应的下标。
这个时候,Tom 和 Mike 经过计算得到的哈希值是一样的,那么再经过位运算得到的下标肯定是一样的,我们把这种情况叫做哈希冲突或者哈希碰撞。
如果发生了哈希碰撞,这个时候对于我们的容器存值肯定是有影响的,我们可以通过哪些方式去降低哈希碰撞的概率呢?
第一种就是扩大维数组的长度或者说位图容量。因为我们的函数是分布均匀的,所以,位图容量越大,在同一个位置发生哈希碰撞的概率就越小。
是不是位图容量越大越好呢?不管存多少个元素,都创建一个几万亿大小的位图,可以吗?当然不行,因为越大的位图容量,意味着越多的内存消耗,所以我们要创建一个合适大小的位图容量。
除了扩大位图容量,我们还有什么降低哈希碰撞概率的方法呢?
如果两个元素经过一次哈希计算,得到的相同下标的概率比较高,我可以不可以计算多次呢? 原来我只用一个哈希函数,现在我对于每一个要存储的元素都用多个哈希函数计算,这样每次计算出来的下标都相同的概率就小得多了。
同样的,我们能不能引入很多个哈希函数呢?比如都计算 100 次,都可以吗?当然也会有问题,第一个就是它会填满位图的更多空间,第二个是计算是需要消耗时间的。
所以总的来说,我们既要节省空间,又要很高的计算效率,就必须在位图容量和函数个数之间找到一个最佳的平衡。
比如说:我们存放 100 万个元素,到底需要多大的位图容量,需要多少个哈希函数呢?
当然,这个事情早就有人研究过了,在 1970 年的时候,有一个叫做布隆的前辈对于判断海量元素中元素是否存在的问题进行了研究,也就是到底需要多大的位图容量和多少个哈希函数,它发表了一篇论文,提出的这个容器就叫做布隆过滤器。
我们来看一下布隆过滤器的工作原理。
首先,布隆过滤器的本质就是我们刚才分析的,一个位数组,和若干个哈希函数。
集合里面有 3 个元素,要把它存到布隆过滤器里面去,应该怎么做?首先是 a 元素,这里我们用 3 次计算。b、c 元素也一样。
元素已经存进去之后,现在我要来判断一个元素在这个容器里面是否存在,就要使用同样的三个函数进行计算。
比如 d 元素,我用第一个函数 f1 计算,发现这个位置上是 1,没问题。第二个位置也是 1,第三个位置也是 1 。
如果经过三次计算得到的下标位置值都是 1,这种情况下,能不能确定 d 元素一定在这个容器里面呢? 实际上是不能的。比如这张图里面,这三个位置分别是把 a,b,c 存进去的时候置成 1 的,所以即使 d 元素之前没有存进去,也会得到三个 1,判断返回 true。
所以,这个是布隆过滤器的一个很重要的特性,因为哈希碰撞不可避免,所以它会存在一定的误判率。这种把本来不存在布隆过滤器中的元素误判为存在的情况,我们把它叫做假阳性(False Positive Probability,FPP)。
我们再来看另一个元素,e 元素。我们要判断它在容器里面是否存在,一样地要用这三个函数去计算。第一个位置是 1,第二个位置是 1,第三个位置是 0。
e 元素是不是一定不在这个容器里面呢? 可以确定一定不存在。如果说当时已经把 e 元素存到布隆过滤器里面去了,那么这三个位置肯定都是 1,不可能出现 0。
布隆过滤器的特点:
从容器的角度来说:
- 如果布隆过滤器判断元素在集合中存在,不一定存在
- 如果布隆过滤器判断不存在,一定不存在
从元素的角度来说:
- 如果元素实际存在,布隆过滤器一定判断存在
- 如果元素实际不存在,布隆过滤器可能判断存在
利用,第二个特性,我们是不是就能解决持续从数据库查询不存在的值的问题?
谷歌的 Guava 里面就提供了一个现成的布隆过滤器。
com.google.guava
guava
21.0
创建布隆过滤器:
BloomFilter<String> bf = BloomFilter.create(
Funnels.stringFunnel(Charsets.UTF_8), insertions);
布隆过滤器提供的存放元素的方法是 put()。
布隆过滤器提供的判断元素是否存在的方法是 mightContain()。
if (bf.mightContain(data)) {
if (sets.contains(data)) {
//判断存在实际存在的时候,命中
right++;
continue;
}
//判断存在却不存在的时候,错误
wrong++;
}
布隆过滤器把误判率默认设置为 0.03,也可以在创建的时候指定。
public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {
return create(funnel, expectedInsertions, 0.03D);
}
位图的容量是基于元素个数和误判率计算出来的。
long numBits = optimalNumOfBits(expectedInsertions, fpp);
根据位数组的大小,我们进一步计算出了哈希函数的个数。
int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
存储 100 万个元素只占用了 0.87M 的内存,生成了 5 个哈希函数。
https://hur.st/bloomfilter/?n=1000000&p=0.03&m=&k=
布隆过滤器的工作位置:
因为要判断数据库的值是否存在,所以第一步是加载数据库所有的数据。在去 Redis 查询之前,先在布隆过滤器查询,如果 bf 说没有,那数据库肯定没有,也不用去查了。如果 bf 说有,才走之前的流程。
如何在海量元素中快速判断一个元素是否存在。
所以除了解决缓存穿透的问题之外,我们还有很多其他的用途。
有问题?可以给我留言或私聊
有收获?那就顺手点个赞呗~
想找工作机会也可以联系我噢~
当然,也可以到我的公众号下「6曦轩」,
回复“学习”,即可领取一份
【Java工程师进阶架构师的视频教程】~
回复“面试”,可以获得:
【本人呕心沥血整理的 Java 面试题】
回复“MySQL脑图”,可以获得
【MySQL 知识点梳理高清脑图】
还有【阿里云】【腾讯云】的购买优惠噢~具体请联系我
曦轩我是科班出身的程序员,php,Android以及硬件方面都做过,不过最后还是选择专注于做 Java,所以有啥问题可以到公众号提问讨论(技术情感倾诉都可以哈哈哈),看到的话会尽快回复,希望可以跟大家共同学习进步,关于服务端架构,Java 核心知识解析,职业生涯,面试总结等文章会不定期坚持推送输出,欢迎大家关注~~~