目录
缓存穿透原理:
缓存穿透一般有几种解决方案:
1.缓存空值
2.使用锁
3.布隆过滤器
优缺点
布隆过滤器误判理解
布隆过滤器的简单使用流程
4.组合方案
那么当我们高并发的访问短链接或者人为的去穿透的时候呢?
最近做项目遇到了缓存的一些问题,总结一下解决方法
缓存穿透原理:
缓存穿透是指在缓存中查询一个一定不存在的数据,由于缓存不命中,导致请求直接访问数据库,这将导致大量的请求打到数据库上,可能会导致数据库压力过大。
具体原理:
缓存穿透一般有几种解决方案:
1.缓存空值
当查询结果为空时,也将结果进行缓存,但是设置一个较短的过期时间。这样在接下来的一段时间内,如果再次请求相同的数据,就可以直接从缓存中获取,而不是再次访问数据库。
这种方式是比较简单的一种实现方案,可以很好解决缓存穿透问题,但是会存在一些弊端。那就是当短时间内存在大量恶意请求,缓存系统会存在大量的内存占用。如果要解决这种海量恶意请求带来的内存占用问题,需要搭配一套风控系统,对用户请求缓存不存在数据进行统计,进而封禁用户。整体设计就较为复杂,不推荐使用。
2.使用锁
当请求发现缓存不存在时,可以使用锁机制来避免多个相同的请求同时访问数据库,只让一个请求去加载数据,其他请求等待。
这种方式可以解决数据库压力过大问题,如果会出现“误杀”现象,那就是如果缓存中不存在但是数据库存在这种情况,也会等待获取锁,用户等待时间过长,不推荐使用。
3.布隆过滤器
布隆过滤器是一种数据结构,用于快速判断一个元素是否存在于一个集合中。具体来说,布隆过滤器包含一个位数组和一组哈希函数。位数组的初始值全部置为 0。在插入一个元素时,将该元素经过多个哈希函数映射到位数组上的多个位置,并将这些位置的值置为 1。
在查询一个元素是否存在时,会将该元素经过多个哈希函数映射到位数组上的多个位置,如果所有位置的值都为 1,则认为元素存在;如果存在任一位置的值为 0,则认为元素不存在。
优缺点
优点:
- 高效地判断一个元素是否属于一个大规模集合。
- 节省内存。
缺点:
- 可能存在一定的误判。
布隆过滤器误判理解
- 布隆过滤器要设置初始容量。容量设置越大,冲突几率越低。
- 布隆过滤器会设置预期的误判值。
布隆过滤器的特点
- 查询是否存在,如果返回存在,可能数据是不存在的。
- 查询是否存在,如果返回不存在,数据一定不存在。
布隆过滤器的简单使用流程
1.导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
2.配置Redis参数
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
3.配置布隆过滤器:
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 布隆过滤器配置
*/
@Configuration
public class RBloomFilterConfiguration {
/**
* 防止用户注册查询数据库的布隆过滤器
*/
@Bean
public RBloomFilter<String> userRegisterCachePenetrationBloomFilter(RedissonClient redissonClient) {
RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("userRegisterCachePenetrationBloomFilter");
cachePenetrationBloomFilter.tryInit(10000000L, 0.001);
return cachePenetrationBloomFilter;
}
}
注意:
tryInit 有两个核心参数:
- expectedInsertions:预估布隆过滤器存储的元素长度。
- falseProbability:运行的误判率。
错误率越低,位数组越长,布隆过滤器的内存占用越大。
错误率越低,散列 Hash 函数越多,计算耗时较长。
4.使用:
4.1先注入:private final RBloomFilter<String> userRegisterCachePenetrationBloomFilter;
4.2
userRegisterCachePenetrationBloomFilter.contains(xxx) 这个判断元素是否存在布隆过滤器
userRegisterCachePenetrationBloomFilter.add(xxx) 把元素加入布隆过滤器
4.组合方案
上面的这些方案或多或少都会有些问题,应该用三者进行组合用来解决缓存穿透问题。如果说缓存不存在,那么就通过布隆过滤器进行初步筛选,然后判断是否存在缓存空值,如果存在直接返回失败。如果不存在缓存空值,使用锁机制避免多个相同请求同时访问数据库。最后,如果请求数据库为空,那么将为空的 Key 进行空对象值缓存。
1.当我们生成短链接的时候,因为完整短链接是唯一的,我们用布隆过滤器判断,不存在才生成。
private String generateSuffix(ShortLinkCreateReqDTO shortLinkCreateReqDTO) {
int count = 0;
String shortUri;
while (true) {
if (count > 10) {
throw new ServiceException("短链接创造频繁,请稍后再试!");
}
//加上当前毫秒数,减少重复可能
shortUri = HashUtil.hashToBase62(shortLinkCreateReqDTO.getOriginUrl() + System.currentTimeMillis());
if (!shortUriCreateCachePenetrationBloomFilter.contains(shortLinkCreateReqDTO.getDomain() + "/" + shortUri)) {
break;
}
count++;
}
return shortUri;
}
2.当上一步的布隆过滤器误判了,明明存在但判断不存在。当我们插入短链接的时候,去查一次数据库。如果存在数据,证明布隆过滤器误判。
try {
baseMapper.insert(shortLinkDO);
shortLinkGotoMapper.insert(shortLinkGotoDO);
// basemapper的插入: 这个异常是 插入mysql 是 key重复了 因为布隆过滤器误判才会如此
// 存在的 判断为 不存在
} catch (DuplicateKeyException ex) {
// TODO 布隆过滤器误判咋办
// 那就去数据库查在判断一次
ShortLinkDO ifExit = this.baseMapper.selectOne(Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getFullShortUrl, full_short_link));
if (ifExit != null) {
log.warn("短链接:{} 重复入库", full_short_link);
throw new ServiceException("短链接存在了!!");
}
}
3.成功插入之后,把完整短链接加入布隆过滤器。同时缓存预热,key为原始连接
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY,full_short_link),
shortLinkCreateReqDTO.getOriginUrl(),
LinkUtil.getLinCacheValidDate(shortLinkCreateReqDTO.getValidDate()),
TimeUnit.MILLISECONDS
);
shortUriCreateCachePenetrationBloomFilter.add(full_short_link);
以上是插入一条短链接的判断大致流程
那么当我们高并发的访问短链接或者人为的去穿透的时候呢?
比如下面有人恶意请求毫秒级触发大量请求去一个插入的短链接
1.先从缓存中拿原始链接(这个访问当时是之前我们已经通过插入mysql时候,预热到缓存中的),拿到就跳转,(这里跳转不了)
2.布隆过滤器判断是否包含完整的短链接(明显没有,如果误判的话,逻辑下走)
3.这个要调回头再看,第一次明显不走
4.分布式锁,双检加锁策略。
5.因为数据本就不存在,所以shortLinkGotoDO == null,存入信息到,IS_NO_SHORK_LINK,对应了第3步
以上步骤,我们判断了布隆过滤器,查看了是否为空值,加了分布式锁。
6.一直向向下乃至释放锁是正常访问的流程。
@SneakyThrows
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
String serverName = request.getServerName();
String full_short_url = serverName + "/" + shortUri;
//1.先从缓存中那 跳转的原始链接 拿到的话直接跳转
String origin_url = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, full_short_url));
if (StrUtil.isNotBlank(origin_url)) {
HttpServletResponse response1 = (HttpServletResponse) response;
response1.sendRedirect(origin_url);
return;
}
// 判断布隆过滤器是否存在 完整短连接, 这个full_short_url 在添加短连接的时候就添加到布隆过滤器里面了
// 这个避免了 穿透 乱输入的链接地址 PS : 误判的话,逻辑向下走 通过redis的路由表 在判断一次
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(full_short_url);
if(!contains) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
//缓存的路由信息 存在
String isNoShortGotoLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NOLL_SHORT_LINK_KEY, full_short_url));
if(StrUtil.isNotBlank(isNoShortGotoLink)) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
//分布式锁
RLock lock = redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY, full_short_url));
lock.lock();
try {
origin_url = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, full_short_url));
if(StrUtil.isNotBlank(origin_url)) {
HttpServletResponse response1 = (HttpServletResponse) response;
response1.sendRedirect(origin_url);
return;
}
//根据 full_short_url 查找路由表
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, full_short_url));
if (shortLinkGotoDO == null) {
// 这个旨在解决布隆过滤器误判
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NOLL_SHORT_LINK_KEY, full_short_url),"-",30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
//根据路由表的Gid 和 full_short_url 查找 shortLinkDO
ShortLinkDO shortLinkDO = baseMapper.selectOne(Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getEnableStatus, 0)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getFullShortUrl, shortLinkGotoDO.getFullShortUrl()));
if (shortLinkDO != null) {
//这是解决短链接 已经过期的问题
if(shortLinkDO.getValidDate() !=null && shortLinkDO.getValidDate().before(new Date())) {
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NOLL_SHORT_LINK_KEY, full_short_url),"-",30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY, full_short_url),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinCacheValidDate(shortLinkDO.getValidDate()),
TimeUnit.MILLISECONDS
);
HttpServletResponse response1 = (HttpServletResponse) response;
response1.sendRedirect(shortLinkDO.getOriginUrl());
}
} finally {
lock.unlock();
}
}