1.写在前面
上一篇博客,我大概的介绍了下Redis的一些常用的API以及Redis的持久化的一些的内容,够大家应付工作是完全没有问题的,今天要讲的就是缓存的三大问题中的缓存穿透的问题,后面我会分成三篇的博客的样子,分别的介绍缓存的三大问题,缓存穿透,缓存击穿,缓存雪崩。在介绍缓存穿透的开始前,我会简单的介绍下Redis的一些从基本类型扩展出来的一些的类型。废话不多说,我们直接开始吧!
2.Redis的其他的类型
2.1GEO
主要是用来计算经度和纬度。
常用的API:
GEOADD locations 116.419217 39.921133 beijing
GEOPOS locations beijing
GEODIST locations tianjin beijing km 计算距离
GEORADIUSBYMEMBER locations beijing 150 km 通过距离计算城市
注意:没有删除命令 它的本质是zset (type locations)
所以可以使用zrem key member 删除元素
zrange key 0 -1 表示所有 返回指定集合中所有value
2.2HyperLogLog
Redis 在 2.8.9 版本添加了 HyperLogLog 结构。
Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的
在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
常用的API:
PFADD 2017_03_06:king 'yes' 'yes' 'yes' 'yes' 'no'
PFCOUNT 2017_03_06:king 统计有多少不同的值
PFADD 2017_09_08:king uuid9 uuid10 uu11
PFMERGE 2016_03_06:king 2017_09_08:king 合并
注意:本质还是字符串 ,有容错率,官方数据是0.81%
2.3Bitmaps
常用的API:
setbit king 500000 0
getbit king 500000
bitcount king
Bitmap本质是string,是一串连续的2进制数字(0或1),每一位所在的位置为偏移(offset)。string(Bitmap)最大长度是512 MB,所以它们可以表示2 ^ 32=4294967296个不同的位。
这儿还是简单的介绍一个应用:朋友圈点赞功能。要实现的功能如下:
- 点赞
- 取消点赞
- 统计这条朋友圈的点赞数
- 查看是否点赞
传统的方式就是将这些数据存到数据库,比如说朋友圈的ID和点赞用户的ID存到数据库中去。但是现在我们用一个简单的方法来实现上面的功能,就是不存数据库的方式。但是局限性比较大,这儿我们只是应付一些简单的需求。
我们接下来说说每个功能如何实现?
首先是点赞的功能,我们可以用setbit 1000 100 1
命令,其中1000是朋友圈的ID,然后100是点赞人的ID,然后1就是100位设置为1,这样就表示了用户ID为100的人点赞了这条ID为1000的朋友圈。
再然后是取消点赞的功能,我们可以用setbit 1000 100 0
命令,其中1000是朋友圈的ID,然后100是点赞人的ID,然后0就是100位设置为0,这样就表示了用户ID为100的人取消点赞了这条ID为1000的朋友圈。
然后就是统计这条朋友圈的点赞数的功能,我们可以使用bitcount 1000
,其中1000是朋友圈的ID,然后bitcount
就是统计朋友圈ID为1000的值中有多少个1,这样就达到了统计这条朋友圈的点赞数了。
最后就是查看是否点赞的功能,我们可以使用gitbit 1000 100
,其中1000是朋友圈的ID,然后100表示要检查用户是否点赞的用户的ID,如果返回的是1表示这个用户ID为100的人点赞了这条朋友圈,如果返回的是0表示这个用户ID为100的人没有点赞这条朋友圈。
于是我们这儿可以写出如下的Java的代码,具体的如下:先定义一个实体类,用来存储朋友圈的ID,用户的ID,状态是点赞 还是取消点赞。
package com.ys.entity;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class Talk {
private String id; //朋友圈id
private Integer likeUserId; //点赞的用户id
private boolean status; //状态 是点赞 还是取消点赞
}
package com.ys.service.impl;
import com.ys.entity.Talk;
import com.ys.service.LikeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
@Service
public class LikeServiceImpl implements LikeService {
@Autowired
RedisTemplate redisTemplate;
/**
* 点赞取消点赞
* @param talk 封装好的点赞的对象
* @return 是否成功
*/
@Override
public boolean likeAndCancelLike(Talk talk) {
boolean execute = true;
try {
redisTemplate.execute(new RedisCallback<Boolean>() {
@Nullable
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean aBoolean = redisConnection.setBit(talk.getId().getBytes(), talk.getLikeUserId(), talk.isStatus());
redisConnection.close();
return aBoolean;
}
});
} catch (Exception e) {
execute = false;
}
return execute;
}
/**
* 获取点赞数
* @param talk 封装好的点赞的对象
* @return 点赞的人数
*/
@Override
public long getLikeCount(Talk talk) {
Object execute = redisTemplate.execute(new RedisCallback<Long>() {
@Nullable
@Override
public Long doInRedis(RedisConnection redisConnection) throws DataAccessException {
Long aLong = redisConnection.bitCount(talk.getId().getBytes());
redisConnection.close();
return aLong;
}
});
return (long) execute;
}
/**
* 是否点赞
* @param talk 封装好的点赞对象
* @return 是否点赞
*/
@Override
public boolean isLike(Talk talk) {
return (boolean) redisTemplate.execute(new RedisCallback<Boolean>() {
@Nullable
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
Boolean bit = connection.getBit(talk.getId().getBytes(), talk.getLikeUserId());
connection.close();
return bit;
}
});
}
}
这段的代码就是对着我前面的介绍的命令写出来的,相信大家看着这串的代码再对着前面的描述的功能,大家应该都懂了。
拓展:Redis中字符串的底层的存的是什么?存的是二进制,那么我们又怎么证明呢?我们可以用bitmaps来证明,首先我们往Redis中存储一个键为king,值为abc的值,然后我们用Python去查看这个abc的二进制的值是多少?具体的如下:
也就是说a和b的二进制,只要将第6位变成1,第7位变成0,那么a就变成了b,我们可以用bitmaps执行下面的操作,具体的如下:
上面的例子可以发现字符串底层最终存的是二进制的数据。
3.缓存
3.1缓存粒度控制
通俗来讲,缓存粒度问题就是我们在使用缓存时,是将所有数据缓存还是缓存部分数据?
数据类型 | 通用性 | 空间占用(内存空间+网络码率) | 代码维护 |
---|---|---|---|
全部数据 | 高 | 大 | 简单 |
部分数据 | 低 | 小 | 较为复杂 |
缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,可能会造成网络带宽的浪费,可能会造成代码通用性较差等情况,必须学会综合数据通用性、空间占用比、代码维护性 三点评估取舍因素权衡使用。
3.2缓存穿透问题
缓存穿透是指查询一个一定不存在的数据,由于缓存不命中,并且出于容错考虑, 如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
可能造成原因:
1.业务代码自身问题
2.恶意攻击。爬虫等等
危害
对底层数据源压力过大,有些底层数据源不具备高并发性。 例如mysql一般来说单台能够扛1000-QPS就已经很不错了。
现象
我们先来简单的演示下,对应的缓存的穿透的问题。具体的代码如下:
public R redisFindCache(String key, long expire, TimeUnit unit, CacheLoadble<T> cacheLoadble, boolean b) {
//查询缓存
Object redisObj = valueOperations.get(String.valueOf(key));
//命中缓存
if (redisObj != null) {
//正常返回数据
return new R().setCode(200).setData(redisObj).setMsg("OK");
}
T load = cacheLoadble.load();//查询数据库
if (load != null) {
valueOperations.set(key, load, expire, unit); //加入缓存
return new R().setCode(200).setData(load).setMsg("OK");
}
return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查询无果");
}
这是一个正常的逻辑就是先查询缓存,然后缓存查到的话,就正常返回数据,如果查询不到数据就直接去查数据库,运行的结果如下:
这是第一个访问,缓存中是没有的,所以这儿是查数据库,这个时候我们第二次访问看看,
发现没有走缓存,直接走了Redis,一切似乎很完美,但是往往事在人为,这个时候有人一直访问数据中不存在的内容,你会发现,打印下面的东西
你会发现一直查询数据库,如果有人一直恶意破坏,这个时候你的数据就直接会崩了,那么有没有什么解决办法,
解决方案
于是我们想到了可以缓存空的对象,具体的代码如下:
public R redisFindCache(String key, long expire, TimeUnit unit, CacheLoadble<T> cacheLoadble, boolean b) {
//查询缓存
Object redisObj = valueOperations.get(String.valueOf(key));
//命中缓存
if (redisObj != null) {
//正常返回数据
return new R().setCode(200).setData(redisObj).setMsg("OK");
}
T load = cacheLoadble.load();//查询数据库
if (load != null) {
valueOperations.set(key, load, expire, unit); //加入缓存
return new R().setCode(200).setData(load).setMsg("OK");
}else {
valueOperations.set(key, new NullValueResultDO(), expire, unit); //加入缓存
return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查询无果");
}
}
似乎我们解决了问题,但是,如果我们一直访问的都是我们没有存在缓存和数据库中的东西,那么是不是意味着我们的解决的方案又不可行。似乎是有点问题。
于是有了第二种解决办法,就是布隆过滤器,那么布隆过滤器原理是什么呢?就是将一个数进行对应次数的hash的算法,然后得到对应值,然后将这些对应的值在布隆过滤器的数组中的对应的位置改成1,下次如果要查一个数据是否存在于数据库的时候,直接对这个数进行相同次数的hash,然后找到对应的位置看看是否都为1,如果都会1,就表示有可能存在,如果有一个值为0的话,这个数一定不会存在于数据库,从而降低对数据库的访问压力。布隆过滤器主要分为以下两种:
-
Google布隆过滤器的缺点
基于JVM内存的一种布隆过滤器
重启即失效
本地内存无法用在分布式场景
不支持大数据量存储
如何使用如下:
package com.ys.test; import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.util.ArrayList; import java.util.List; public class TestBloom { //容量 static int insertions = 10000000; //误差 static double fpp = 0.0001; static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), insertions, fpp); static List<Integer> list = new ArrayList<>(); public static void main(String[] args) { for (int i = 0; i < insertions; i++) { bloomFilter.put(i); } for (int i = insertions; i < insertions + insertions; i++) { if (bloomFilter.mightContain(i)) { list.add(i); } } System.out.println(list.size()); } }
运行结果如下:
可以看到这个误差差不多是我设置的,切记这儿不能设置成0,不然会直接报错。
但是我们现在都是分布式的环境,所以这个Google的布隆过滤器似乎不太适用大部分的场景。于是我们打算手写一个布隆过滤器,用Redis的bitmaps来实现,具体的代码如下:
package com.ys.filter; import com.google.common.hash.Funnels; import com.google.common.hash.Hashing; import lombok.Data; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Scope; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.Pipeline; import javax.annotation.PostConstruct; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.List; @ConfigurationProperties("bloom.filter") @Component public class RedisBloomFilter { //预计插入量 private long expectedInsertions; //可接受的错误率 private double fpp; @Autowired private RedisTemplate redisTemplate; //bit数组长度 private long numBits; //hash函数数量 private int numHashFunctions; public long getExpectedInsertions() { return expectedInsertions; } public void setExpectedInsertions(long expectedInsertions) { this.expectedInsertions = expectedInsertions; } public void setFpp(double fpp) { this.fpp = fpp; } public double getFpp() { return fpp; } //初始化中的两个值的这儿的算法是从Google的不拢过来器哪儿拷贝过来 @PostConstruct public void init() { this.numBits = optimalNumOfBits(expectedInsertions, fpp); this.numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits); } //计算hash函数个数 private int optimalNumOfHashFunctions(long n, long m) { return Math.max(1, (int) Math.round((double) m / n * Math.log(2))); } //计算bit数组长度 private 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))); } /** * 判断key 是否存在于集合 * * @param key 传入的key * @return 存在就返回true,不存在就返回false */ public boolean isExist(String key) { long[] indexs = getIndexs(key); List list = redisTemplate.executePipelined(new RedisCallback<Object>() { @Nullable @Override public Object doInRedis(RedisConnection redisConnection) throws DataAccessException { redisConnection.openPipeline(); for (long index : indexs) { redisConnection.getBit("bf:taibai".getBytes(), index); } redisConnection.close(); return null; } }); return !list.contains(false); } /** * 将key存入Redis bitmap * * @param key 传入的key */ public void put(String key) { long[] indexs = getIndexs(key); redisTemplate.executePipelined(new RedisCallback<Object>() { @Nullable @Override public Object doInRedis(RedisConnection redisConnection) throws DataAccessException { redisConnection.openPipeline(); for (long index : indexs) { redisConnection.setBit("bf:taibai".getBytes(), index, true); } redisConnection.close(); return null; } }); } /** * 根据key获取bitmap的下标 * * @param key 传入的key * @return 一个hash函数对 */ private long[] getIndexs(String key) { long hash1 = hash(key); long hash2 = hash1 >>> 16; long[] result = new long[numHashFunctions]; for (int i = 0; i < numHashFunctions; i++) { //numHashFunctions hash函数的数量 long combinedHash = hash1 + i * hash2; if (combinedHash < 0) { combinedHash = ~combinedHash; } result[i] = combinedHash % numBits; } return result; } /** * 根据key获取对应的hash值 * * @param key 传入的key * @return 对应的hash值 */ private long hash(String key) { Charset charset = StandardCharsets.UTF_8; return Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asLong(); } }
这就是自己手动的实现的简单的Redis的布隆过滤器,因为Redis在分布式中只有一份,所以这儿存到Redis中,所有的服务都是可以用的,同时重启了还可以用。所以在项目中是如何使用的,走来我们需要将数据库中常用的数据存入到数据库中去,具体的如下:
package com.ys.datainit; import com.ys.entity.Order; import com.ys.filter.RedisBloomFilter; import com.ys.service.OrderService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.List; @Component public class RedisDataInit { @Autowired OrderService orderService; @Autowired RedisBloomFilter redisBloomFilter; @PostConstruct public void init() { List<Order> orders = orderService.selectOrderyAll(); for (Order order : orders) { redisBloomFilter.put(String.valueOf(order.getId())); } } }
然后修改刚刚缓存的代码,具体的如下:
public R redisFindCache(String key, long expire, TimeUnit unit, CacheLoadble<T> cacheLoadble, boolean b) { if (!bloomFilter.isExist(key)) { return new R().setCode(600).setData(new NullValueResultDO()).setMsg("非法访问"); } //查询缓存 Object redisObj = valueOperations.get(String.valueOf(key)); //命中缓存 if (redisObj != null) { //正常返回数据 return new R().setCode(200).setData(redisObj).setMsg("OK"); } T load = cacheLoadble.load();//查询数据库 if (load != null) { valueOperations.set(key, load, expire, unit); //加入缓存 return new R().setCode(200).setData(load).setMsg("OK"); } return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查询无果"); }
这个时候我们走来就来访问布隆过滤器,如果不存在,就直接返回非法访问,这个时候我们启动一下项目看看,具体的如下:
可以发现我们的项目一启动的时候,这个时候就往缓存中存入几条数据,这儿就是我数据库中的所有的记录。然后我们访问对应的网站两次,这个时候访问的数据存在数据库中,然后看控制台打印出来什么,具体的如下:
可以发现我们这儿控制台就答应出来一条的记录,那么我们访问一条数据库中不存在的数据看看,具体的如下:
直接返回非法访问,证明我没有访问数据库。从控制层面降低了无效的很多的请求。从而解决缓存穿透的问题。
4.写在最后
本篇博客大概的介绍了缓存的三大问题中的缓存穿透的问题,后面还有其他的问题,后面的博客继续讲。这篇博客就介绍到这儿了。