9:短链接的跳转功能
跳转原理
通过短链接获取到对应的长链接,对长链接进行 302 重定向,最终访问原始网址。
跳转流程
- 缓存怎么办?访问缓存和数据库不存在短网址,数据库压力骤增。
- 短时间内大量请求访问一个缓存过期短链接,那么就会造成缓存击穿。
controller
/**
* 短链接跳转原始链接
*/
@GetMapping("/{short-uri}")
public void restoreUrl(@PathVariable("short-uri") String shortUri, ServletRequest request, ServletResponse response) {
shortLinkService.restoreUrl(shortUri, request, response);
}
service
@SneakyThrows
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
// 短链接接口的并发量有多少?如何测试?详情查看:https://nageoffer.com/shortlink/question
// 面试中如何回答短链接是如何跳转长链接?详情查看:https://nageoffer.com/shortlink/question
String serverName = request.getServerName(); //获取请求的IP地址
String serverPort = Optional.of(request.getServerPort())
.filter(each -> !Objects.equals(each, 80))
.map(String::valueOf)
.map(each -> ":" + each)
.orElse("");
//如果请求的端口号是80(标准HTTP端口),则不需要在URL中包含端口号,因此返回空字符串。
//如果请求的端口号不是80(例如8080或其他非标准端口),则返回:端口号的字符串形式(例如:8080),用于构建完整的URL。
String fullShortUrl = serverName + serverPort + "/" + shortUri;//拼接完整的短链接URL,包括服务器名称、端口(如果有)和短链接标识(shortUri)。
//1:先查缓存中是否有当前的信息,如果有直接跳转。
String originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(originalLink)) {
shortLinkStats(buildLinkStatsRecordAndSetUser(fullShortUrl, request, response));
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}
//2:如果缓存中没有当前的信息,再查布隆过滤器,如果布隆过滤器没有,直接返回,说明这是一个恶意请求。
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
if (!contains) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
//3:如果缓存中没有,布隆过滤其中有 -> 这是一个正常的请求,此时恰好缓存过期 / 这是一个恶意的请求,但是布隆过滤器假阳性 误判当前的短链接没有
// 再查缓存中是否是空值,如果是空值, 直接返回,说明这是一个恶意请求,但是空值表一上来是什么都没有的,所以恶意请求第一次访问的时候,空值表中是没有数据的,会突破这一关。
String gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
//4:加分布式锁,只允许一个线程进行操作
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)) {
shortLinkStats(buildLinkStatsRecordAndSetUser(fullShortUrl, request, response));
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}
//进行双重判定 - 对空值缓存进行双重判定 - 可能短时间内大量恶意请求来请求空值缓存 - 此时大量恶意缓存在阻塞队列中 - 只允许一个线程读取数据库然后更新缓存 - 释放锁之后 - 阻塞队列中的线程直接从缓存判断是否是空值,直接返回,防止数据库挂了。
gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
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);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
//如果路由表数据库中有这个数据 -> 正常请求 -> 通过路由表拿到当前的短链接 -> 判断短链接缓存是否是过期 -> 过期 -> 相当于没有当前的短链接,直接放入空值表中,并设置一个较短的过期时间
// -> 没有过期 -> 再放入正常缓存中,并设置一个较长的过期时间,然后进行跳转
//正常的请求没有过期,那是为什么从缓存中消失的 ? -> 在某些缓存系统中,如Redis,当缓存空间不足时,可能会根据设定的策略(如LRU,Least Recently Used)逐出一些不常用的数据。这会导致缓存中的某些数据消失,即使这些数据并未过期。
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 || (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date()))) {
stringRedisTemplate.opsForValue().set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()), TimeUnit.MILLISECONDS
);
shortLinkStats(buildLinkStatsRecordAndSetUser(fullShortUrl, request, response));
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
} finally {
lock.unlock();
}
}
逻辑步骤:
1:先检查缓存中是否有当前的信息,如果有,则直接跳转
2:如果缓存中没有当前的信息,再检查布隆过滤器,如果布隆过滤器中没有,则直接返回,因为这可能是一个恶意请求,由于布隆过滤器存在假阳性问题(误判当前没有的元素存在于布隆过滤器中)/ 这是一个正常的请求,此时恰好缓存过期
3:再查空值缓存,如果空值缓存有,说明这是一个恶意请求,直接返回,如果空值缓存没有 -> 空值表一上来是什么都没有的,所以恶意请求第一次访问的时候,空值表中是没有数据的,会突破这一关。/ 正常请求当然肯定不会存在于空值缓存中
4:加入分布式锁,此时只允许一个线程访问,保证数据库安全,防止大量请求涌入。
- 对正常缓存进行双重判定 - 可能短时间内大量请求来请求正常的缓存 - 此时大量正常缓存在阻塞队列中 - 只允许一个线程读取数据库然后更新缓存 - 释放锁之后 - 阻塞队列中的线程直接从缓存中取即可。
- 对空值缓存进行双重判定 - 可能短时间内大量恶意请求来请求空值缓存 - 此时大量恶意缓存在阻塞队列中 - 只允许一个线程读取数据库然后更新缓存 - 释放锁之后 - 阻塞队列中的线程直接从缓存判断是否是空值,直接返回,防止数据库挂了。
5:查数据库
- 查路由表数据库 路由表数据库中没有这个数据 -> 恶意请求,直接在缓存中存放空值,直接返回
- 如果路由表数据库中有这个数据 -> 正常请求 -> 通过路由表拿到当前的短链接 -> 判断短链接缓存是否是过期 -> 过期 -> 相当于没有当前的短链接,直接放入空值表中,并设置一个较短的过期时间
- -> 没有过期 -> 再放入正常缓存中,并设置一个较长的过期时间,然后进行跳转
6:疑问 -> 正常的请求没有过期,那是为什么从缓存中消失的 ?
- 在某些缓存系统中,如Redis,当缓存空间不足时,可能会根据设定的策略(如LRU,Least Recently Used)逐出一些不常用的数据。这会导致缓存中的某些数据消失,即使这些数据并未过期。