SoruxGPT
发布于 2024-09-10 / 9 阅读
0

短链接管理

短链接管理

数据库创建

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) 是一种常见的错误处理和重试策略,尤其在网络请求、分布式系统或高并发场景中。它的核心思想是,在出现错误时,不是立即进行重试,而是按照指数增长的间隔时间进行重试,以避免系统过载或请求频率过高导致的更多问题。

工作原理:

  1. 初始重试:在第一次出现错误时,立即进行第一次重试。

  2. 延迟增加:如果重试仍然失败,等待一段时间再进行第二次重试。之后每次重试的等待时间(延迟)会按指数倍数增加,通常是上一次等待时间的两倍。

  3. 重试上限:有一个最大重试次数的上限或者总的等待时间上限,超过这个上限后就停止重试,防止系统进入无限重试循环。

公式:

指数退避的基本延迟时间通常以 2 的幂次方增长,可以表达为:

延迟时间=初始延迟×2n\text{延迟时间} = \text{初始延迟} \times 2^n延迟时间=初始延迟×2n

其中,n 是重试的次数。例如:

  • 第一次失败后,延迟 100ms。

  • 第二次失败后,延迟 200ms(100ms × 2^1)。

  • 第三次失败后,延迟 400ms(100ms × 2^2)。

  • 第四次失败后,延迟 800ms(100ms × 2^3)。

优点:

  1. 减少系统压力:避免在短时间内进行高频重试,导致系统资源耗尽或网络过载。

  2. 提高成功率:随着时间的推移,系统或服务有更多时间恢复,从而增加重试成功的可能性。

  3. 适用于分布式系统:当多个客户端同时请求服务时,指数退避可以有效避免“雪崩效应”。

典型场景:

  1. 网络请求的重试:当请求的服务暂时不可用时(如超时、连接中断),可以使用指数退避进行重试。

  2. API 限流处理:当 API 服务提供方有速率限制时,如果请求被拒绝,使用指数退避可以减少再次拒绝的风险。

  3. 数据库操作:当数据库出现短暂的高并发或锁等待问题时,重试操作可以使用指数退避。

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中缓存不存在, 就有可能是如下两种情况:

  1. 缓存中不存在,数据库中存在(缓存击穿)

  2. 缓存中不存在,数据库中也不存在(缓存穿透)

所以,接下来就可以去布隆过滤器中判断一下,如果布隆过滤器中不存在,那么就是真的不存在了,直接返回空,如果布隆过滤器中存在,那么是有一定的误判率的,接下来就是要去查数据库了。

但是不可能让所有连接都打进数据库,所以需要加锁,如果数据库中存在数据,那么继续写进缓存,如果不存在,缓存一个空值,并且设置一个比较短的有效期。所以,在加锁之前需要对空缓存进行判断,如果是空也直接返回了。

为什么要先查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";
    }
}