短链接跳转原始链接功能
- 穿透 :大量请求了缓存和数据库中都没有的数据,每次都查询数据库,导致数据库压力过大
- 击穿 : 大量key在同一时间过期,导致所有请求都达到数据库,导致数据库压力过大
缓存穿透:
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案:
接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短一些,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
使用布隆过滤器,需要安装redis组件
使用布谷鸟滤器,布谷鸟过滤器是布隆过滤器的升级版,需要安装redis组件
在客户端自行实现布隆过滤算法;
缓存击穿:
缓存击穿指的是大量的key在同一时间过期,但是又有大量的请求需要用到这些已经过期的key,那么程序在redis找不到数据,就会去数据库里查询,数据库处理大量的请求的同时导致压力瞬间增大,造成压力过大,甚至导致崩溃;
解决方案
- 设置key值永不过期
- 将key的过期时间设为随机
- 使用布隆过滤器或者布谷鸟过滤器
- 使用分布式锁,当多个key过期时,同一时间只有一个查询请求下发到数据库,其他的key等待一个个地轮流查,就可以避免数据库压力过大的问题;
参考:谈谈redis缓存击穿透和缓存击穿的区别,以及它们所引起的雪崩效应-CSDN博客
缓存击穿:
String originallink=stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY,fullShortUrl));
StringRedisTemplate
:这是Spring Data Redis中用于操作Redis数据库的一个模板类,专门用于处理字符串类型的数据。它提供了很多便利的方法来操作Redis中的字符串数据。opsForValue()
:这是StringRedisTemplate
中的一个方法,它返回了一个ValueOperations<K, V>
类型的对象,这个对象提供了对Redis中字符串(String)类型数据的操作方法,如get
、set
等。get(String key)
:这是ValueOperations<K, V>
接口中的一个方法,用于根据给定的键(key)来获取对应的值(value)。如果键存在,则返回对应的值;如果键不存在,则通常返回null
(但这也取决于具体的配置和Redis的版本)。String.format(GOTO_SHORT_LINK_KEY, fullShortUrl)
:这部分代码是动态生成键的过程。GOTO_SHORT_LINK_KEY
是一个格式化字符串,它定义了键的结构,而fullShortUrl
是一个变量,它包含了短链接的完整信息(可能是ID或者某种标识符)。String.format
方法将fullShortUrl
的值按照GOTO_SHORT_LINK_KEY
中定义的格式插入到相应的位置,从而生成最终的键。
这行代码的作用是:根据给定的短链接标识符(fullShortUrl
),通过特定的格式(GOTO_SHORT_LINK_KEY
)生成一个键,然后从Redis数据库中检索出这个键所对应的值,这个值预期是一个原始链接(originallink
)。这种机制常用于短链接服务中,用于将简短的URL映射回原始的、较长的URL。
RLock lock = redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));
lock.lock();
RedissonClient:
redissonClient
是Redisson库的客户端实例,它提供了对Redis的丰富操作,包括但不限于数据结构操作、发布/订阅、Lua脚本执行、事务和分布式锁等。getLock(String name): 这个方法是
RedissonClient
接口中的一个方法,用于根据提供的名称(或键)获取一个RLock
实例。RLock
是Redisson对Javajava.util.concurrent.locks.Lock
接口的一个分布式实现,它允许在分布式环境中以线程安全的方式加锁和解锁资源。lock.lock(): 调用
lock
实例的lock()
方法尝试获取锁。如果锁当前未被其他客户端持有,则当前客户端将成功获取锁并继续执行后续操作。如果锁已被其他客户端持有,则当前客户端将等待(可配置等待时间,如果未指定则为无限等待),直到锁被释放并变得可用。
@Override
public void restoreUrl(String shortUrl, ServletRequest request, ServletResponse response) throws IOException {
//查短链接路由
String servername=request.getServerName();
String fullShortUrl=servername+"/"+shortUrl;
String originallink=stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY,fullShortUrl));
if(StrUtil.isNotBlank(originallink)){
((HttpServletResponse) response).sendRedirect(originallink);
return;
}
//分布式锁
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)){
((HttpServletResponse) response).sendRedirect(originallink);
return;
}
LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
if(shortLinkGotoDO==null){
//从严格意义上此处需要封控
return;
}
//根据路由的gid查短链接,从而定向到原始链接
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO shortLinkDO=baseMapper.selectOne(queryWrapper);
if(shortLinkDO!=null){
stringRedisTemplate.opsForValue().set(String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl),shortLinkDO.getOriginUrl());
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
}
}finally {
lock.unlock();
}
}
双重判定锁:首先加分布式锁,防止缓存过期之后的大量请求过来的缓存穿透问题;同时再加上一个双重判定锁,可以让只有第一个拿到锁的请求进行缓存重构,之后拿到锁的请求直接查询缓存即可,提高了程序运行效率。
对于缓存击穿 进行了双重锁的判定 极端情况下 当我们的缓存没有原始链接(originalLink)大量的请求就回去访问数据库 去重构缓存,这时候我们要设置第一层锁 防止多个请求同时重建缓存 当然为了节省数据库资源 在加一层锁 实现双重锁 第二层锁再次判定是否缓存 中包含原始链接 如果有 则直接去缓存 如果没有就去数据库里面查询在把数据库中的 数据传入给缓存
缓存穿透:
布隆过滤器返回存在结果,真实情况可能存在也可能不存在;返回不存在,则一定不存在。
使用布隆过滤器检查,然后检查缓存里面是否是空值。
public void restoreUrl(String shortUrl, ServletRequest request, ServletResponse response) throws IOException {
//查短链接路由
String servername=request.getServerName();
String fullShortUrl=servername+"/"+shortUrl;
String originallink=stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY,fullShortUrl));
if(StrUtil.isNotBlank(originallink)){
((HttpServletResponse) response).sendRedirect(originallink);
return;
}
boolean contains=shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
if(!contains){
return;
}
String gotoIsNUllShortLink=stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY,fullShortUrl));
if(StrUtil.isNotBlank(gotoIsNUllShortLink)){
return;
}
//分布式锁
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)){
((HttpServletResponse) response).sendRedirect(originallink);
return;
}
LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
if(shortLinkGotoDO==null){
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY,fullShortUrl),"-",30, TimeUnit.MINUTES);
//从严格意义上此处需要风控
return;
}
//根据路由的gid查短链接,从而定向到原始链接
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO shortLinkDO=baseMapper.selectOne(queryWrapper);
if(shortLinkDO!=null){
stringRedisTemplate.opsForValue().set(String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl),shortLinkDO.getOriginUrl());
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
}
}finally {
lock.unlock();
}
缓存预热:
在创建短链接以及重定向短链接时进行预热(因为可能某个短链接很多访问量,这个时候就需要预热,提高性能)
public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
String shortLinkSuffix=generateSuffix(requestParam);
String fullShortUrl=requestParam.getDomain()+"/"+shortLinkSuffix;
ShortLinkDO shortLinkDO=ShortLinkDO.builder()
.domain(requestParam.getDomain())
.originUrl(requestParam.getOriginUrl())
.gid(requestParam.getGid())
.createdType(requestParam.getCreatedType())
.validDateType(requestParam.getValidDateType())
.validDate(requestParam.getValidDate())
.describe(requestParam.getDescribe())
.shortUri(shortLinkSuffix)
.enableStatus(0)
.fullShortUrl(fullShortUrl)
.build();
ShortLinkGotoDO shortLinkGotoDO = ShortLinkGotoDO.builder()
.fullShortUrl(fullShortUrl)
.gid(requestParam.getGid())
.build();
shortLinkGotoMapper.insert(shortLinkGotoDO);
try{
//数据库如果存在,则会报错,进入catch
baseMapper.insert(shortLinkDO);
}catch (DuplicateKeyException exp){
//检查是否存在于数据库中,如果没存在,则说明布隆过滤器误判了。
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl);
ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
if(hasShortLinkDO!=null){
log.warn("短链接:{} 重复入库",fullShortUrl);
throw new ServiceException("短链接生成重复");
}
}
stringRedisTemplate.opsForValue()
.set(
String.format(GOTO_SHORT_LINK_KEY,fullShortUrl),
requestParam.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(requestParam.getValidDate()),TimeUnit.MILLISECONDS);
shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
return ShortLinkCreateRespDTO.builder()
.fullShortUrl("http://"+shortLinkDO.getFullShortUrl())
.originUrl(requestParam.getOriginUrl())
.gid(requestParam.getGid())
.build();
}
public void restoreUrl(String shortUrl, ServletRequest request, ServletResponse response) throws IOException {
//查短链接路由
String servername=request.getServerName();
String fullShortUrl=servername+"/"+shortUrl;
String originallink=stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY,fullShortUrl));
if(StrUtil.isNotBlank(originallink)){
((HttpServletResponse) response).sendRedirect(originallink);
return;
}
boolean contains=shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
if(!contains){
return;
}
String gotoIsNUllShortLink=stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY,fullShortUrl));
if(StrUtil.isNotBlank(gotoIsNUllShortLink)){
return;
}
//分布式锁
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)){
((HttpServletResponse) response).sendRedirect(originallink);
return;
}
LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
if(shortLinkGotoDO==null){
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY,fullShortUrl),"-",30, TimeUnit.MINUTES);
//从严格意义上此处需要风控
return;
}
//根据路由的gid查短链接,从而定向到原始链接
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO shortLinkDO=baseMapper.selectOne(queryWrapper);
if(shortLinkDO!=null){
if(shortLinkDO.getValidDate()!=null&&shortLinkDO.getValidDate().before(new Date())){
//如果缓存有效期已经失效了,就当作没有DO一样处理
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY,fullShortUrl),"-",30, TimeUnit.MINUTES);
return;
}
stringRedisTemplate.opsForValue()
.set(
String.format(GOTO_SHORT_LINK_KEY,fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()),TimeUnit.MILLISECONDS);
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
}
}finally {
lock.unlock();
}
}
短链接不存在跳转指定页面功能
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
spring:
mvc:
view:
prefix: /templates/
suffix: .html
templates/notfound.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,
maximum-scale=1.0, user-scalable=no, shrink-to-fit=no, viewport-fit=cover"
/>
<link rel="shortcut icon" href="" />
<meta name="theme-color" content="#000000" />
<meta property="og:title" lang="zh-CN" content="" />
<meta name="theme-color" content="#000000" />
<meta property="og:type" content="video" />
<meta property="og:title" content="" />
<meta property="og:description" content="" />
<meta property="og:image" content="" />
<meta property="og:image:width" content="750" />
<meta property="og:image:height" content="1334" />
<title></title>
<style>
.container,
.pc-container {
margin-top: 32vh;
background: white;
display: flex;
align-items: center;
flex-direction: column;
}
.text {
color: #333333;
line-height: 28px;
}
.container .text {
margin-top: 16px;
font-size: 3vw;
}
.pc-container .text {
/* margin-top: 100px; */
font-size: 18px;
}
.pc-container .img {
height: 200px;
}
.container .img {
width: 50vw;
}
textarea {
width: 90vw;
}
</style>
</head>
<body>
<div class="pc-container">
<div>
<img
class="img"
src="//p3-live.byteimg.com/tos-cn-i-gjr78lqtd0/c03071dcdc52c24e0aab256518e51557.png~tplv-gjr78lqtd0-image.image"
/>
</div>
<div class="text">您访问的页面不存在,请确认链接是否正确</div>
</div>
</body>
</html>
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* 短链接不存在跳转控制器
*/
@Controller
public class ShortLinkNotFoundController {
/**
* 短链接不存在跳转页面
*/
@RequestMapping("/page/notfound")
public String notfound() {
return "notfound";
}
}
获取目标网站标题功能
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
public class UrlTitleServiceImpl implements UrlTitleService {
/**
* 根据URL获取标题
* @param url
* @return
*/
@SneakyThrows
@Override
public String getTitleByUrl(String url) {
Document doc = Jsoup.connect(url).get();
return doc.title();
// URL tagetUrl = new URL(url);
// HttpURLConnection connection = (HttpURLConnection) tagetUrl.openConnection();
// connection.setRequestMethod("GET");
// connection.connect();
//
// int responseCode = connection.getResponseCode();
// if (responseCode == HttpURLConnection.HTTP_OK) {
// Document document = Jsoup.connect(url).get();
// return document.title();
// }
//
// return "Erro while fetching title";
}
}
获取目标完整图标功能
这里的匹配规则一定记得别写错了,一开始我就将rel写成了ref。
/**
* 获取目标网站图标
* @param url
* @return
* @throws IOException
*/
private String getFavicon(String url) throws IOException {
//创建URL对象
URL targetUrl = new URL(url);
//打开连接
HttpURLConnection connection = (HttpURLConnection) targetUrl.openConnection();
// 禁止自动处理重定向
connection.setInstanceFollowRedirects(false);
// 设置请求方法为GET
connection.setRequestMethod("GET");
//连接
connection.connect();
//获取响应码
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_MOVED_PERM || responseCode == HttpURLConnection.HTTP_MOVED_TEMP) {
//获取重定向的URL
String redirectUrl = connection.getHeaderField("Location");
//如果重定向URL不为空
if (redirectUrl != null) {
// 创建新的URL对象
URL newUrl = new URL(redirectUrl);//打开新的连接
connection = (HttpURLConnection) newUrl.openConnection();//设置请求方法为GET
connection.setRequestMethod("GET");//连接
connection.connect();//获取新的响应码
responseCode = connection.getResponseCode();
}
}
if(HttpURLConnection.HTTP_OK==responseCode){
Document document = Jsoup.connect(url).get();
Element faviconLink = document.select("link[rel~=(?i)^(shortcut )?icon]").first();
if(faviconLink!=null){
return faviconLink.attr("abs:href");
}
}
return null;
}
回收站管理
短链接移至回收站功能
修改enableStatus 0:启用 1:未启用,从0变更为1。
还需要删除该短链接相关的缓存。
回收站分页查询功能
可以复用短链接分页查询,但是需要注意,回收站分页查询不需要传gid,因为这样不利用检索。由于shortlink的分片键是gid,所以不传分组的话,默认会查全表。所以就通过查询当前用户的所有分组,然后传递给真正调用接口的地方,通过gid in gidList可以查。