0.前言
这篇文章主要实现短链接跳转的功能,包括基础功能的实现以及缓存穿透、缓存击穿问题的解决。
1.短链接跳转原理
大多短链接系统短链接跳转逻辑应该是这样的:用户通过浏览器输入短链接访问,通过短链接获取到原始链接并进行跳转。短短一句话就把短链接跳转的逻辑说完了,但事实上里面要考虑的东西非常多,下面我们一层一层分析。
2.数据库准备
前期我们通过分库分表创建了16张t_link表,用于存放短链接,但其中的分片键是gid,也就是分组标识,但我们在跳转的时候,肯定是只输入一个短链接的 ,如果我们只通过短链接去查找link表,其效率可想而知,因此我们还需要创建一张路由表(同样用分库分表完成)。这张t_link_goto表只有三个字段:id,gid,fullShortUri 。因此我们的跳转流程是:先根据传入的参数shortUri来查路由表t_link_goto,查到对应的gid后再去查主表t_link,获取对应的原始链接。


3.基础代码
- ShortLinkServiceImpl.java
java
/**
* 短链接跳转
* @param shortLink
* @param request
* @param response
*/
@SneakyThrows
@Override
public void redirect(String shortLink, HttpServletRequest request, HttpServletResponse response) {
String serverName=request.getServerName();
String fullShortUrl=serverName+"/"+shortLink;
LambdaQueryWrapper<ShortLinkGotoDO> gotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(gotoQueryWrapper);
if(shortLinkGotoDO==null){
return;
}
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getEnableStatus, 0)
.eq(ShortLinkDO::getDelFlag, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
if(shortLinkDO!=null){
response.sendRedirect(shortLinkDO.getOriginUrl());
}
}
在基础的代码中,我们不考虑任何功能,只实现基础功能,首先由request获取域名,拼接上短链接,然后根据拼接后的fullShortUrl,去查找路由表,如果为空的话直接返回。不为空的话就根据fullShortUrl去查找主表,如果查找到的不为空,就实现跳转。(在这之前大家需要先配置下本地host,可以去搜一下教程,这边不做介绍,就是充当Nginx的作用)。
4.缓存击穿
缓存击穿 指在高并发的系统中 ,一个热点数据缓存过期或者在缓存中不存在 ,导致大量并发请求直接访问数据库,从而给数据库造成巨大压力,甚至可能引起宕机。
具体来说,当某个热点数据在缓存中过期时,如果此时有大量并发请求同时访问这个数据,由于缓存中不存在,所有请求都会直接访问数据库,导致数据库负载急剧增加。
这里使用基于Redisson的分布式锁来实现。
java
/**
* 短链接跳转
* @param shortLink
* @param request
* @param response
*/
@SneakyThrows
@Override
public void redirect(String shortLink, HttpServletRequest request, HttpServletResponse response) {
String serverName=request.getServerName();
String fullShortUrl=serverName+"/"+shortLink;
//缓存为空,加锁
RLock lock=redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY,fullShortUrl));
try {
lock.lock();
originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if(StrUtil.isNotBlank(originalLink)){
//缓存不为空,直接跳转
response.sendRedirect(originalLink);
return;
}
LambdaQueryWrapper<ShortLinkGotoDO> gotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(gotoQueryWrapper);
if(shortLinkGotoDO==null){
return;
}
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getEnableStatus, 0)
.eq(ShortLinkDO::getDelFlag, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
if(shortLinkDO!=null){
response.sendRedirect(shortLinkDO.getOriginUrl());
}
}finally {
lock.unlock();
}
}
5.缓存穿透
缓存穿透 是指在缓存中查询一个一定不存在的数据 ,由于缓存不命中,导致请求直接访问数据库,这将导致大量的请求打到数据库上,可能会导致数据库压力过大。
通常情况下,缓存是为了提高数据访问速度,避免频繁查询数据库。但如果攻击者故意请求缓存中不存在的数据,就会导致缓存不命中,请求直接访问数据库。
5.1 空对象值缓存
当查询结果为空时,也将结果进行缓存,但是设置一个较短的过期时间。这样在接下来的一段时间内,如果再次请求相同的数据,就可以直接从缓存中获取,而不是再次访问数据库,可以一定程度上解决缓存穿透问题。
这种方式是比较简单的一种实现方案,会存在一些弊端。那就是当短时间内存在大量恶意请求,缓存系统会存在大量的内存占用。如果要解决这种海量恶意请求带来的内存占用问题,需要搭配一套风控系统,对用户请求缓存不存在数据进行统计,进而封禁用户。整体设计就较为复杂,不推荐使用。
5.2 使用锁
当请求发现缓存不存在时,可以使用锁机制来避免多个相同的请求同时访问数据库,只让一个请求去加载数据,其他请求等待。
这种方式可以解决数据库压力过大问题,如果会出现"误杀"现象,那就是如果缓存中不存在但是数据库存在这种情况,也会等待获取锁,用户等待时间过长,不推荐使用。
5.3 布隆过滤器
布隆过滤器是一种数据结构,可以用于判断一个元素是否存在于一个集合中。它可以在很大程度上减轻缓存穿透问题,因为它可以快速判断一个数据是否可能存在于缓存中。
这种方式较为推荐,可以将所有存量数据全部放入布隆过滤器,然后如果缓存中不存在数据,紧接着判断布隆过滤器是否存在,如果存在访问数据库请求数据,如果不存在直接返回错误响应即可。
但是这种问题还是会有一些小概率问题,那就是如果使用一种小概率误判的缓存进行攻击,依然会对数据库造成比较大的压力。
6.组合方案
上面的这些方案或多或少都会有些问题,应该用三者进行组合用来解决缓存穿透问题。
如果说缓存不存在,那么就通过布隆过滤器进行初步筛选,然后判断是否存在缓存空值,如果存在直接返回失败。如果不存在缓存空值,使用锁机制避免多个相同请求同时访问数据库。最后,如果请求数据库为空,那么将为空的 Key 进行空对象值缓存。

6.1 常规缓存查询
- 用户发起请求时,首先访问Redis缓存,如果命中了,直接返回进行跳转,流程结束。
- 缓存未命中,可能是数据不存在,也可能是热点Key刚过期,继续执行下面的流程。
6.2 布隆过滤器(防穿透)
判断请求Key是否存在于布隆过滤器中。如果布隆过滤器说不存在,那就一定不存在!直接返回404,并退出。如果布隆过滤器说存在,那有一定的误判几率,继续向下执行。
6.3 缓存空值判断
判断RedisKey是否存在空值,如果存的是null,直接返回空,不再往下走。如果不为空,说明这可能是个真正的热点 Key 失效,准备去查库。
6.4 分布式锁
此时,可能已经有了1w+个请求通过了前面的流程,要去查数据库来了。这里利用Redisson的分布式锁,只允许一个线程拿到锁,剩下的在门外等待。
6.5 查数据库
拿到锁的线程去查MySQL数据库。查到了就将真实数据写入Redis,释放锁;没查到说明布隆过滤器产生了误判,为了防止下一次请求来到数据库,必须往Redis写入一个空值,并设置较短的过期时间。
java
/**
* 短链接跳转
* @param shortLink
* @param request
* @param response
*/
@SneakyThrows
@Override
public void redirect(String shortLink, HttpServletRequest request, HttpServletResponse response) {
String serverName=request.getServerName();
String fullShortUrl=serverName+"/"+shortLink;
//解决缓存穿透和缓存击穿
/**
* 阶段1:查询缓存
*/
//从redis获取原始链接
String originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if(StrUtil.isNotBlank(originalLink)){
//判断请求key是否为空值
if(GOTO_IS_NULL_SHORT_LINK_KEY.equals(originalLink)){
//Redis里存储的是空值,说明以前查过数据库不存在,返回404
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
//查到缓存,直接跳转
response.sendRedirect(originalLink);
return;
}
//缓存为空,去布隆过滤器中查询
/**
* 阶段2:布隆过滤器(防穿透)
*/
if(!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)){
//布隆过滤器说不存在,一定不存在,返回404
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
//布隆过滤器说存在,代表可能存在,需要进一步查询
/**
* 阶段3:分布式锁&数据库查询(防击穿)
*/
//缓存为空,加锁
RLock lock=redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY,fullShortUrl));
lock.lock();
try {
originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
//-----双重检查-----
if(StrUtil.isNotBlank(originalLink)){
if (GOTO_IS_NULL_SHORT_LINK_KEY.equals(originalLink)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
//缓存不为空,直接跳转
response.sendRedirect(originalLink);
return;
}
//查询数据库
//1.先查路由表
LambdaQueryWrapper<ShortLinkGotoDO> gotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(gotoQueryWrapper);
if(shortLinkGotoDO==null){
//数据不存在,需要缓存空值到redis
stringRedisTemplate.opsForValue().set(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl), GOTO_IS_NULL_SHORT_LINK_KEY,30, TimeUnit.SECONDS);
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
//2.查主表
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getEnableStatus, 0)
.eq(ShortLinkDO::getDelFlag, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
if(shortLinkDO!=null){
//将数据新增进缓存
String targetUrl=shortLinkDO.getOriginUrl();
if(!targetUrl.startsWith("http://")){
targetUrl="http://"+targetUrl;
}
stringRedisTemplate.opsForValue().set(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl), targetUrl,1, TimeUnit.DAYS);
response.sendRedirect(targetUrl);
}
else {
stringRedisTemplate.opsForValue().set(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl), GOTO_IS_NULL_SHORT_LINK_KEY,30, TimeUnit.SECONDS);
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}finally {
// 释放锁
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}