短链接管理
数据库创建
CREATE TABLE `t_link` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`gid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '分组ID',
`domain` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '域名',
`short_uri` varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
`full_short_url` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '完整的短链接',
`origin_url` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '原始链接',
`click_number` int unsigned DEFAULT '0' COMMENT '点击量',
`enable_status` tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用',
`create_type` tinyint(1) DEFAULT NULL COMMENT '创建类型 0:接口创建 1:控制台创建',
`valid_date_type` tinyint(1) DEFAULT NULL COMMENT '有效期类型 0:永久有效 1:自定义',
`valid_date` datetime DEFAULT NULL COMMENT '有效期',
`describe` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '短链接信息描述',
`favicon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '图标',
`total_pv` int DEFAULT NULL COMMENT '历史PV',
`total_uv` int DEFAULT NULL COMMENT '历史UV',
`total_uip` int DEFAULT NULL COMMENT '历史UIP',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_time` bigint DEFAULT NULL COMMENT '删除时间戳',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `URI_UNIQUE` (`full_short_url`) USING BTREE COMMENT '短链接域名下唯一'
) ENGINE=InnoDB AUTO_INCREMENT=1789990777544290307 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC;
短链接实体类
package com.hayaizo.shortlink.project.dao.entity;/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 短链接实体
* */
@Data
@Builder
@TableName("t_link")
@NoArgsConstructor
@AllArgsConstructor
public class ShortLinkDO extends BaseDO {
/**
* id
*/
private Long id;
/**
* 域名
*/
private String domain;
/**
* 短链接
*/
private String shortUri;
/**
* 完整短链接
*/
private String fullShortUrl;
/**
* 原始链接
*/
private String originUrl;
/**
* 点击量
*/
private Integer clickNum;
/**
* 分组标识
*/
private String gid;
/**
* 启用标识 0:启用 1:未启用
*/
private Integer enableStatus;
/**
* 创建类型 0:接口创建 1:控制台创建
*/
private Integer createdType;
/**
* 有效期类型 0:永久有效 1:自定义
*/
private Integer validDateType;
/**
* 有效期
*/
private Date validDate;
/**
* 描述
*/
@TableField("`describe`")
private String describe;
/**
* 网站标识
*/
private String favicon;
/**
* 历史PV
*/
private Integer totalPv;
/**
* 历史UV
*/
private Integer totalUv;
/**
* 历史UIP
*/
private Integer totalUip;
/**
* 今日PV
*/
@TableField(exist = false)
private Integer todayPv;
/**
* 今日UV
*/
@TableField(exist = false)
private Integer todayUv;
/**
* 今日UIP
*/
@TableField(exist = false)
private Integer todayUip;
/**
* 删除时间
*/
private Long delTime;
}
创建短链接
首先确定前端需要传递什么参数过来:
分组ID、跳转的链接、描述信息、创建类型(永久/自定义)
请求类:
package com.hayaizo.shortlink.project.dto.req;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 创建短链接请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ShortLinkCreateReqDTO {
/**
* 域名
*/
private String domain;
/**
* 原始链接
*/
private String originUrl;
/**
* 分组标识
*/
private String gid;
/**
* 创建类型 0:接口创建 1:控制台创建
*/
private Integer createdType;
/**
* 有效期类型 0:永久有效 1:自定义
*/
private Integer validDateType;
/**
* 有效期
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date validDate;
/**
* 描述
*/
private String describe;
}
返回类
package com.hayaizo.shortlink.project.dto.resp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 短链接创建响应对象
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShortLinkCreateRespDTO {
/**
* 分组信息
*/
private String gid;
/**
* 原始链接
*/
private String originUrl;
/**
* 短链接
*/
private String fullShortUrl;
}
实现类
package com.hayaizo.shortlink.project.service.Impl;
import cn.hutool.core.text.StrBuilder;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hayaizo.shortlink.project.common.convention.exception.ServiceException;
import com.hayaizo.shortlink.project.dao.entity.ShortLinkDO;
import com.hayaizo.shortlink.project.dao.mapper.ShortLinkMapper;
import com.hayaizo.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.hayaizo.shortlink.project.dto.resp.ShortLinkCreateRespDTO;
import com.hayaizo.shortlink.project.service.ShortLinkService;
import com.hayaizo.shortlink.project.tooltik.HashUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* 短链接接口实现层
*/
@Service
@Slf4j
@AllArgsConstructor
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {
private final RBloomFilter<String> shortUriCreateCachePenetrationBloomFilter;
private final RedissonClient redissonClient;
@Override
public ShortLinkCreateRespDTO create(ShortLinkCreateReqDTO requestParam) {
String shortLinkSuffix = generateSuffix(requestParam);
if(shortLinkSuffix == null){
throw new ServiceException("生成URI失败");
}
String fullShortUrl = StrBuilder.create(requestParam.getDomain())
.append("/")
.append(shortLinkSuffix)
.toString();
// 创建短链接
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)
.totalPv(0)
.totalUv(0)
.totalUip(0)
.delTime(0L)
.fullShortUrl(fullShortUrl)
.build();
shortLinkDO.setFullShortUrl(fullShortUrl);
shortLinkDO.setShortUri(shortLinkSuffix);
shortLinkDO.setEnableStatus(0);
try{
baseMapper.insert(shortLinkDO);
}catch (DuplicateKeyException e){
if(!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)){
shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
}
log.warn("短链接:{} 重复入库",fullShortUrl);
throw new ServiceException("原始链接重复");
}
// 把URI添加到布隆过滤器
shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
return new ShortLinkCreateRespDTO().builder()
.gid(shortLinkDO.getGid())
.originUrl(shortLinkDO.getOriginUrl())
.fullShortUrl(shortLinkDO.getFullShortUrl())
.build();
}
public String generateSuffix(ShortLinkCreateReqDTO requestParam) {
int maxRetries = 10; // 允许的最大重试次数,避免硬编码
int count = 0;
String shortUri;
// 原始链接
String originUrl = requestParam.getOriginUrl();
while (count <= maxRetries) {
// 通过原始链接生成URI
String hashInput = originUrl + UUID.randomUUID().toString(); // 引入时间戳作为随机性
shortUri = HashUtil.hashToBase62(hashInput);
String fullShortUrl = StrBuilder.create(requestParam.getDomain())
.append("/")
.append(shortUri)
.toString();
if (!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
// 如果布隆过滤器中不存在该短链接,则生成成功
return shortUri;
}
count++;
if (count > maxRetries) {
// 日志记录失败信息
log.error("短链接生成失败,原始链接: {}, 重试次数: {}", originUrl, count);
throw new ServiceException("短链接生成频繁,请稍后再试");
}
}
return null;
}
}
指数退避优化
指数退避(Exponential Backoff) 是一种常见的错误处理和重试策略,尤其在网络请求、分布式系统或高并发场景中。它的核心思想是,在出现错误时,不是立即进行重试,而是按照指数增长的间隔时间进行重试,以避免系统过载或请求频率过高导致的更多问题。
工作原理:
初始重试:在第一次出现错误时,立即进行第一次重试。
延迟增加:如果重试仍然失败,等待一段时间再进行第二次重试。之后每次重试的等待时间(延迟)会按指数倍数增加,通常是上一次等待时间的两倍。
重试上限:有一个最大重试次数的上限或者总的等待时间上限,超过这个上限后就停止重试,防止系统进入无限重试循环。
公式:
指数退避的基本延迟时间通常以 2 的幂次方增长,可以表达为:
延迟时间=初始延迟×2n\text{延迟时间} = \text{初始延迟} \times 2^n延迟时间=初始延迟×2n
其中,n
是重试的次数。例如:
第一次失败后,延迟 100ms。
第二次失败后,延迟 200ms(100ms × 2^1)。
第三次失败后,延迟 400ms(100ms × 2^2)。
第四次失败后,延迟 800ms(100ms × 2^3)。
优点:
减少系统压力:避免在短时间内进行高频重试,导致系统资源耗尽或网络过载。
提高成功率:随着时间的推移,系统或服务有更多时间恢复,从而增加重试成功的可能性。
适用于分布式系统:当多个客户端同时请求服务时,指数退避可以有效避免“雪崩效应”。
典型场景:
网络请求的重试:当请求的服务暂时不可用时(如超时、连接中断),可以使用指数退避进行重试。
API 限流处理:当 API 服务提供方有速率限制时,如果请求被拒绝,使用指数退避可以减少再次拒绝的风险。
数据库操作:当数据库出现短暂的高并发或锁等待问题时,重试操作可以使用指数退避。
public String generateSuffix(ShortLinkCreateReqDTO requestParam) {
int maxRetries = 10; // 可将此值设置为可配置项
int count = 0;
String shortUri;
String originUrl = requestParam.getOriginUrl();
while (count <= maxRetries) {
String hashInput = originUrl + UUID.randomUUID().toString(); // 引入随机性
shortUri = HashUtil.hashToBase62(hashInput);
String fullShortUrl = requestParam.getDomain() + "/" + shortUri;
if (!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
return shortUri;
}
count++;
try {
// 添加指数退避
Thread.sleep((long) Math.pow(2, count) * 100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("线程被中断", e);
}
if (count > maxRetries) {
log.error("短链接生成失败,原始链接: {}, 重试次数: {}", originUrl, count);
throw new ServiceException("短链接生成频繁,请稍后再试");
}
}
return null;
}
t_link分表
配置文件
# 数据源集合
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://localhost:3306/shortlink?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
- !SHARDING
tables:
t_link:
# 真实数据节点,定义分表的物理节点
actualDataNodes: ds_0.t_link_${0..15}
# 分表策略
tableStrategy:
# 标准分片策略,基于单个分片键
standard:
# 分片键,使用用户名作为分片字段
shardingColumn: gid
# 分片算法的名称,指向下方定义的分片算法
shardingAlgorithmName: link_table_hash_mod
# 分片算法配置
shardingAlgorithms:
link_table_hash_mod:
# 分片算法类型,使用 HASH_MOD 算法
type: HASH_MOD
# 定义分片的数量,16个分片
props:
sharding-count: 16
# 通用属性配置
props:
sql-show: true
拦截器封装用户上下文
package com.hayaizo.shortlink.admin.common.biz.user;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.google.common.collect.Lists;
import com.hayaizo.shortlink.admin.common.convention.Results;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Objects;
import static com.hayaizo.shortlink.admin.common.enums.UserErrorCodeEnum.USER_TOKEN_FAIL;
/**
* 用户信息传输过滤器
*
*/
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {
private final StringRedisTemplate stringRedisTemplate;
// 定义白名单 URL 列表
private static final List<String> IGNORE_URLS = Lists.newArrayList(
"/api/short-link/admin/v1/user/login",
"/api/shortlink/v1/user/hash-username"
);
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String requestURI = httpServletRequest.getRequestURI();
// 如果当前URI不在白名单里面,就需要进行拦截,封装上下文
if(!IGNORE_URLS.contains(requestURI)) {
// 还有一个注册需要特殊判断,因为是restful格式,URI相同,需要判断请求方式
String method = httpServletRequest.getMethod();
if(!(Objects.equals(requestURI, "/api/short-link/admin/v1/user")&&Objects.equals(method,"POST"))){
// 如果不是注册接口,那么就需要拦截了
String username = httpServletRequest.getHeader("username");
String token = httpServletRequest.getHeader("token");
// 如果token和username有为空的
if(!StrUtil.isAllNotBlank(username,token)){
returnJson((HttpServletResponse) servletResponse, JSON.toJSONString(Results.failure(USER_TOKEN_FAIL)));
return;
}
Object userInfoJsonStr;
try{
// 根据用户Token以及用户名从redis中获取用户信息
userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_"+username, token);
if(userInfoJsonStr == null){
returnJson((HttpServletResponse) servletResponse, JSON.toJSONString(Results.failure(USER_TOKEN_FAIL)));
return;
}
}catch (Exception e){
returnJson((HttpServletResponse) servletResponse, JSON.toJSONString(Results.failure(USER_TOKEN_FAIL)));
return;
}
// 解析JSON
UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
UserContext.setUser(userInfoDTO);
}
}
try{
filterChain.doFilter(servletRequest, servletResponse);
} finally {
UserContext.removeUser();
}
}
private void returnJson(HttpServletResponse response,String json){
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try{
writer = response.getWriter();
writer.write(json);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if(writer != null){
writer.close();
}
}
}
}
分页查询短链接列表
MyBatis-Plus配置
package com.hayaizo.shortlink.project.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DatabaseConfiguration {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
public IPage<ShortLinkPageRespDTO> pageShortLink(ShortLinkPageReqDTO requestParam) {
// 创建查询条件
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, requestParam.getGid())
.eq(ShortLinkDO::getEnableStatus,0)
.eq(ShortLinkDO::getDelFlag, 0);
IPage<ShortLinkDO> shortLinkPage = baseMapper.selectPage(requestParam,queryWrapper);
return shortLinkPage.convert(each-> BeanUtil.copyProperties(each,ShortLinkPageRespDTO.class));
}
后管联调中台短链接接口
中台调用Admin服务
package com.hayaizo.shortlink.admin.remote.dto;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.TypeReference;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hayaizo.shortlink.admin.common.convention.Result;
import java.util.HashMap;
import java.util.Map;
public interface ShortLinkRemoteService {
default Result<IPage<ShortLinkPageRespDTO>> page(ShortLinkPageReqDTO requestParam){
Map<String,Object> param = new HashMap<>();
param.put("gid",requestParam.getGid());
param.put("current", requestParam.getCurrent());
param.put("size", requestParam.getSize());
String resultPageStr = HttpUtil.get("localhost:8001/api/short-link/v1/page", param);
// JSON序列化
return JSON.parseObject(resultPageStr,new TypeReference<Result<IPage<ShortLinkPageRespDTO>>>(){});
}
}
创建短链接
default Result<ShortLinkCreateRespDTO> create(ShortLinkCreateReqDTO requestParam){
String resultPageStr = HttpUtil.post("localhost:8001/api/short-link/v1/create", JSON.toJSONString(requestParam));
return JSON.parseObject(resultPageStr, new TypeReference<Result<ShortLinkCreateRespDTO>>() {});
}
配置文件
url: jdbc:shardingsphere:classpath:shardingsphere-config-${database.env:dev}.yaml
分组数量查询
@Override
public List<ShortLinkGroupCountQueryRespDTO> listGroupShortLinkCount(List<String> requestParam) {
QueryWrapper<ShortLinkDO> queryWrapper = Wrappers.query(new ShortLinkDO())
.select("gid as gid,count(*) as shortLinkCount")
.in("gid", requestParam)
.eq("enable_status", 0)
.groupBy("gid");
List<Map<String,Object>> shortLinkDOList = null;
try{
shortLinkDOList = baseMapper.selectMaps(queryWrapper);
}catch (Exception e){
throw new ClientException("没有查询到");
}
return BeanUtil.copyToList(shortLinkDOList,ShortLinkGroupCountQueryRespDTO.class);
// List<Map<String,Object>> shortLinkDOList = shortLinkMapper.selectGroupCount(requestParam);
// return BeanUtil.copyToList(shortLinkDOList,ShortLinkGroupCountQueryRespDTO.class);
}
也可以写XML来表示:
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hayaizo.shortlink.project.dao.mapper.ShortLinkMapper">
<select id="selectGroupCount" resultType="java.util.Map" parameterType="java.util.List">
select gid as gid, count(*) as shortLinkCount
from t_link
where gid in
<foreach collection="requestParam" item="item" open="(" close=")" separator=",">
#{item}
</foreach>
and enable_status = 0
group by gid <!-- 添加 GROUP BY 子句 -->
order by gid
</select>
</mapper>
写入数量,使用Stream流来操作
@Override
public List<ShortLinkGroupRespDTO> listGroup() {
// TODO 从当前的请求中获取用户名
LambdaQueryWrapper<GroupDO> groupDOLambdaQueryWrapper = Wrappers.lambdaQuery(GroupDO.class)
.eq(GroupDO::getUsername, UserContext.getUsername())
.eq(GroupDO::getDelFlag,0)
.orderByDesc(GroupDO::getSortOrder)
.orderByDesc(GroupDO::getUpdateTime);
List<GroupDO> groupDOList = baseMapper.selectList(groupDOLambdaQueryWrapper);
// 获取数量
Result<List<ShortLinkGroupCountQueryRespDTO>> listResult =
shortLinkRemoteService.listGroupShortLinkCount(groupDOList.stream().map(GroupDO::getGid).toList());
// 写入数量
List<ShortLinkGroupRespDTO> shortLinkGroupRespDTOList = BeanUtil.copyToList(groupDOList, ShortLinkGroupRespDTO.class);
shortLinkGroupRespDTOList.forEach(each -> {
Integer shortLinkCount = listResult.getData().stream()
.filter(item -> each.getGid().equals(item.getGid()))
.map(ShortLinkGroupCountQueryRespDTO::getShortLinkCount)
.findFirst()
.orElse(0);
each.setShortLinkCount(shortLinkCount);
});
return shortLinkGroupRespDTOList;
}
注册用户创建默认分组
@Override
public void saveGroup(String name) {
saveGroup(UserContext.getUsername(),name);
}
@Override
public void saveGroup(String username,String name){
String gid;
int maxRetries = 10; // 最大重试次数
int retries = 0;
do {
if (retries++ >= maxRetries) {
throw new RuntimeException("Failed to generate a unique GID after " + maxRetries + " retries");
}
gid = RandomGenerator.generateRandomString();
} while (!hasGid(gid));
// 创建分组对象
GroupDO groupDO = new GroupDO().builder()
.gid(gid)
.name(name)
.username(username)
.sortOrder(0)
.build();
baseMapper.insert(groupDO);
}
短链接的跳转
对于某个短链接需要考虑一下缓存穿透和缓存击穿问题
缓存击穿
某些数据是热点数据,访问频率非常高。假设这个热点数据的缓存突然失效(例如缓存超时过期),此时有大量请求同时到来。这些请求会发现缓存中没有这条数据,全部涌向数据库,导致数据库瞬时压力增大,可能会导致数据库过载。
解决方案:加锁、缓存预热、设置不同的缓存过期时间、缓存永不过期
缓存穿透
例如,客户端请求的数据 Key 根本不存在于数据库中,比如恶意用户不断请求随机生成的、从未存储过的数据。这种请求直接穿过缓存系统,每次都打到数据库,形成了“缓存穿透”。
大量请求不存在的数据,可能是恶意攻击(如DDoS攻击的一部分),或者客户端错误地请求了无效的数据。
解决方案:布隆过滤器、缓存空值、参数校验。
缓存空值是解决缓存穿透问题的一种有效策略。它的核心思想是,当我们从数据库查询数据时,如果发现数据不存在(即数据库中该记录为空),我们将这个空结果也存入缓存中,避免后续对同一 Key 的重复请求再次穿透缓存、打到数据库上。
思路:首先去Redis中查询,如果Redis中存在缓存,那么之后重定向original_url
就可以了,如果Redis中缓存不存在, 就有可能是如下两种情况:
缓存中不存在,数据库中存在(缓存击穿)
缓存中不存在,数据库中也不存在(缓存穿透)
所以,接下来就可以去布隆过滤器中判断一下,如果布隆过滤器中不存在,那么就是真的不存在了,直接返回空,如果布隆过滤器中存在,那么是有一定的误判率的,接下来就是要去查数据库了。
但是不可能让所有连接都打进数据库,所以需要加锁,如果数据库中存在数据,那么继续写进缓存,如果不存在,缓存一个空值,并且设置一个比较短的有效期。所以,在加锁之前需要对空缓存进行判断,如果是空也直接返回了。
为什么要先查Redis再查询布隆过滤器
Redis 缓存查询速度极快,通常情况下命中率也较高,直接查询缓存是最优选择。
布隆过滤器的主要作用是防止缓存穿透,它是一种备用机制,在缓存未命中的情况下才需要使用。
优先查询 Redis 可以减少不必要的布隆过滤器查询,节省计算资源。
Redis 缓存可以处理缓存击穿,而布隆过滤器主要应对缓存穿透,两者针对的场景不同。
因此,先查询 Redis 缓存,再查布隆过滤器是更合理的设计,能够在大多数情况下减少查询开销并提高系统性能。
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
String serverName = request.getServerName();
String fullShortUrl = serverName+"/"+shortUri;
// 从Redis缓存中根据短链接获取到原始链接
String originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
// 如果存在,直接返回了
if(StrUtil.isNotBlank(originalLink)){
try {
((HttpServletResponse) response).sendRedirect(originalLink); // 重定向到原始链接\
return;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 如果缓存中不存在数据,去布隆过滤器中查询一下
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
// 布隆中不存在就是真不存在了,直接return
if(!contains){
return;
}
// 如果布隆中存在,就得去数据库中找,还得加一下锁,不过先要看一下是否存在空缓存
String gotoIsNUllShortLInk = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
// 如果是空值(其实写入的是 "-" ),直接就return了,说明没有这个数据
if(StrUtil.isNotBlank(gotoIsNUllShortLInk)){
return;
}
// 获取分布式锁
RLock lock = redissonClient.getLock(LOCK_GOTO_SHORT_LINK_KEY + fullShortUrl);
lock.lock();
try{
// 再去redis中查询一次看是不是空,因为存在并发问题导致多查几次数据库。
originalLink = stringRedisTemplate.opsForValue().get(GOTO_IS_NULL_SHORT_LINK_KEY+fullShortUrl);
if(StrUtil.isNotBlank(originalLink)){
return;
}
// 去数据库中查询
LambdaQueryWrapper<ShortLinkGotoDO> shortLinkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO linkGotoDO = shortLinkGotoMapper.selectOne(shortLinkGotoQueryWrapper);
if (linkGotoDO == null) {
// TODO 还要进行风控
return;
}
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, linkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
// 判断shirtLinkDO是否为空或者已经过期了,并且要设置上空缓存
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);
}
// 将原始链接缓存到Redis中,并且设置缓存的有效期
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()), TimeUnit.MILLISECONDS
);
}finally {
lock.unlock();
}
}
一旦出现缓存失效还有几千个访问,用户还在那里阻塞然后再发一次redis网络请求。大量线程阻塞CPU负载过高导致整个服务崩溃怎么办?
我这里用的是guava来进行限流
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
String serverName = request.getServerName();
String fullShortUrl = serverName+"/"+shortUri;
// 从Redis缓存中根据短链接获取到原始链接
String originalLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
// 如果存在,直接返回了
if(StrUtil.isNotBlank(originalLink)){
try {
((HttpServletResponse) response).sendRedirect(originalLink); // 重定向到原始链接\
return;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 如果缓存中不存在数据,去布隆过滤器中查询一下
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
// 布隆中不存在就是真不存在了,直接return
if(!contains){
return;
}
// 如果布隆中存在,就得去数据库中找,还得加一下锁,不过先要看一下是否存在空缓存
String gotoIsNUllShortLInk = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
// 如果是空值(其实写入的是 "-" ),直接就return了,说明没有这个数据
if(StrUtil.isNotBlank(gotoIsNUllShortLInk)){
return;
}
if(!rateLimiter.tryAcquire()){
sendErrorResponse(response, "Too many requests, please try again later.");
return;
}
// 获取分布式锁
RLock lock = redissonClient.getLock(LOCK_GOTO_SHORT_LINK_KEY + fullShortUrl);
lock.lock();
try{
// 再去redis中查询一次看是不是空,因为存在并发问题导致多查几次数据库。
originalLink = stringRedisTemplate.opsForValue().get(GOTO_IS_NULL_SHORT_LINK_KEY+fullShortUrl);
if(StrUtil.isNotBlank(originalLink)){
return;
}
// 去数据库中查询
LambdaQueryWrapper<ShortLinkGotoDO> shortLinkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO linkGotoDO = shortLinkGotoMapper.selectOne(shortLinkGotoQueryWrapper);
if (linkGotoDO == null) {
// TODO 还要进行风控
return;
}
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, linkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
// 判断shirtLinkDO是否为空或者已经过期了,并且要设置上空缓存
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);
}
// 将原始链接缓存到Redis中,并且设置缓存的有效期
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()), TimeUnit.MILLISECONDS
);
}finally {
lock.unlock();
}
}
短链接不存在跳转指定页面功能
引入 thymeleaf
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
配置文件
spring:
mvc:
view:
prefix: /templates/
suffix: .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";
}
}