SaaS短链接系统-新手从零学习 10. 功能扩展

eve2333 发布于 2025-07-18 121 次阅读


 这里我运行vue文件,搞得我好麻烦,我选在idea一次性把

node-sass编译失败替换为sass

然后install和run dev(不是serve)就都成功了 

- 第01节:短链接创建时指定默认域名

 这节课来跟大家说一下,短链接创建的时候,它怎么帮助我们有效的去提升我们的访问效率。这个不是代码性能上的提交,是我们一个日常使用的提交,这是我们的软件页面,应该也是第一次和大家见面就如果说,你是跟着从0~1下来的话,反弹的话我们可以这样去创建它,然后我们这边选择末日分组,然后点击创建,创建的话大家会发现一个事情,我们创建里面对吧,是前端给我们传的功能,然后这个是最早之前跟前端约定的,然后但是在后面来看的话会有一些问题,就是我们自己在配后的时候对吧?你不可能每次都要改前端对吧?那样会很方便,这样的话我们是不是不是可请前端就是前端不传默认的刀门,然后这个刀门然后在我们后端里面去配置然后这样的话是不是会更合理一些,对不对?

然后我们首先如果说。如果说我们要加一个默认配置的话,肯定是加到配置文件里面的,所以说我们这边从设立和跟配置下加一个盗版MV N,然后下面再加一个默认这个单词,我默认,然后我们去输入我们哪里了,是输我们house的名额对不对?N URL点nk对吧?这样的话就可以了,我们把给它解析过来。然后在这里面都没有。然后可以的shot。Shot立刻default。这就是我们的一个默认的域名,然后在这里面我们去给他重建出来。 

Ok这样就可以了,然后我们重启一下看看效果,让我们刷新一下。我们还是拿着我们换一换,具备 ok,然后房间然后这边创建的话,我们可以看到它现在在创建的我们配置的厚的里面的域名了,但是这个时候还有一个问题就是给大家然后直接复制他跳过来的对吧?是跳过不过来的话,你走HTTP它默认是80端口,但是我们真正短链接中台它的域名的对吧? 

是801, 这样的话才可以可能细心的小伙伴就说了一件事情,怎么才能够更加高效的去节省,我们每次去加的步骤很简单,在这里加上801之前应该早做的,有可能大家看我之前录了那么多期监控的视频对吧?每次都要加端口,可能大家都要比我更着急。 

Ok我们再来创建一个 ok这样直接点是不是就跳过来了,对不对?配置让它放着。我想想他这个配置怎么可能放呢?他8001是打到这里来的。我想想。然后8001,这里也是8001没问题。我们跳转这里还需要加一个步骤,我们这边获取了思路,我这个东西,然后我们这边也要去获取它的part。什么好的?201. 不行,第二这肯定是围攻了。点一个。有的。然后这个标准一直应该是都不能盖章,我就死掉了,如果他080. 那么就过怎么样?如果等于80,他如果等于80的话,我们就直接返回空,如果如果说不等于80,这样可以X。这样的看法是什么?那就是801。如果是8001我想一下,做的太复杂了,我想想这里是该怎么去给它去加进去。 

如果说它返回的是80,如果是80的话,那么就返回一个空。算了,我们这边反正协议有点不清晰,我们直接判10万如果等于808。如果不等于80,如果等于80的话,如果说它等于80应该设置一个空才对。我想想行。所以我靠想想 

紧急的国家没有用,实施效果。如果说等于它肯定是不等于80的。没关系,我们先试一试效果。大概就把加进去。Ok这样是满足我们效果的,取个板就可以了。它怎么是这玩意?没关系,我们再创建一个一个点,看到没有,这个就达到我们的效果了,然后我们自己这里把这个网址给他带了上来。然后如果说我们这里面创建的不是8001对,我们如果没有的话,刚才的条件这个条件应该就是返回的,就是空字母块还是拼不上去的,这个是符合我们预期。我们把代码提交一下。应该叫优化。短链接创建以和跳转功能,什么短链接创建和跳转功能?短链接创建,创建时域名通过后端。 Ok这节课就讲这些,基本上把我们后续的话,如果大家要在本地测试,他的就不用再每次再去输对应的域名了,以及他就不用还有端口,这样的话会加大一些大家本地开发 

application.yaml 

short-link:
  domain:
    default: nurl.ink:8001

ShortLinkServicelmpl.java  

@Value("${short-link.stats.locale.amap-key}")
private String statsLocaleAmapKey;
@Value("${short-link.domain.default}")
private String createShortLinkDefaultDomain;

@Override
public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {

    String shortLinkSuffix = generateSuffix(requestParam);
    //ShortLinkDO shortLinkDO = BeanUtil.toBean(requestParam, ShortLinkDO.class);
    //shortLinkDO.setShortUri(shortLinkSuffix);
    //String fullShortUrl = requestParam.getDomain() + "/" + shortLinkSuffix;

    // String fullShortUrl = StrBuilder.create(requestParam.getDomain())
    String fullShortUrl = StrBuilder.create(createShortLinkDefaultDomain)

            .append("/")
            .append(shortLinkSuffix)
            .toString();

    //shortLinkDO.setEnableStatus(0);
    //shortLinkDO.setFullShortUrl(requestParam.getDomain() + "/" + shortLinkSuffix);
    ShortLinkDO shortLinkDO = ShortLinkDO.builder()
            //.domain(requestParam.getDomain())
            .domain(createShortLinkDefaultDomain)

            .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)
            .fullShortUrl(fullShortUrl)
            .build();

另外一段添加和补充

@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
    String serverName = request.getServerName();

    String serverPort = Optional.of(request.getServerPort())
            .filter(each -> !Objects.equals(each, 80))
            .map(String::valueOf)
            .map(each -> ":" + each)
            .orElse("");
    String fullShortUrl = serverName + serverPort + "/" + shortUri;

我们初始化短链接前端项目, 64个文件以及后续的ico修改都自行导入即可;

optimzie:短链接创建时域名通过后端控制 

 那如果后续需要改端口号,之前的短链接不就失效了吗?
存在一个问题,咱们是开发环境,生产环境默认不加端口号的

 在利用布隆过滤器判断短链接是否存在的时候是不是应该也要把requestParam.getDomain()换成createShortLinkDefaultDomain 

答:是要换的 不换会找不到页面 布隆过滤器中与要找的不匹配 但是换了之后以后换端口的话 布隆过滤器中的数据就失效了 在判断还是会找不到页面 这不太懂

 为什么需要过滤掉80端口的访问? 因为HTTP协议的默认端口就是80,即Example Domain其实是Example Domain。如果配置了nginx进行方向代理,nginx会监听80端口然后转发到8001端口。

- 第02节:如何通过接口批量创建短链接

大家说一下批量创建短链接,首先的话批量空间的链接因为涉及到和前端交互写起来会比较麻烦一点,所以说我就没有带大家一点点的挑。然后这个代码我也没有提交,我会通过一次提交把它上传到这个仓库,这样的话后面大家去看视频的时候,就能够拿到我们本次提交记录去比对自己相应的一个开发好吧?

首先说一下创建短链接和批量创建短链接它有什么区别 首先我们单个创建短链接,它只能有一个原始链接以及对应的标题只能有一个对吧?这两个都是一对一的。我们批量创建的话就相当于这些domain这些东西都不变,甚至我们现在domain其实已经不在了(默认写好了),我们如果把这个参数留着,然后我们和这个都已经其实变成了部分,相当于也是个他们也相当于是一对一的关系。 然后其他没有变化,然后我们提交到后台之后

{
  "domain": "nurl.ink",
  "originUrls": [
    "https://nageoffer.com/",
    "https://nageoffer.com/"
  ],
  "describes": [
    "consequat quis nisi nulla",
    "consequat quis nisi nulla"
  ],
  "gid": "uyCClp",
  "createdType": 1,
  "validDateType": 0
}

这些东西是一个对应的 Excel文件, Excel文件里面有创建成功的短链接的一个数据,我们把它保存一下,我们用这个Apifox的话要点下载文件

然后在这个里面我们可以给它点一个打开,打开之后它有三点,首先第一列是标题,第二是短链接,第三列是原始链接,这是一一对应的关系,如果说我们点了这边各自点10条,假如失败了3条,那么这里面只会展示7条记录,要跟大家提前说一下,可能有同学会问,既然我们已经有单个创建短链接了,我们为什么还要用批量创建?有些情况是这样的,单个创建短链接在客户或者用户在已知要创建某些短链接的情况,已知要批量创建短链接的情况下,他们用单个去创建的话,会有一些网络io消耗对吧,其实不是特别的友好,我们可以用批量的形式帮他们把给他把网络消耗的给去掉,然后其次的话你看我们这边返回的都是一些Excel数据对吧?但是这个Excel数据它只是在你前端,就是在我们这里去批量创建的时候,他会返回一个流,其实我是把一个流打到前端的,然后它会下载出来Excel文件,在这里面展示的话,就相当于是详情里面有这么多参数,不过如果说完全如果去访问的话,就这样我们点确认它会弹给我们一个成功,其次会把刚才的Excel文件从谷歌上给下载下来,这是一个正常的交互。

这个时候问题来了,有些情况下我们中台的接口形式对接给一些想要调用我们短链接API的一些系统对吧?你API系统去返回这样一个理由是肯定不正确的,所以说我们批量创建短链接,它返回的是正常的一个成功的数量以及成功的一个明细,然后我们在后管shortlinkcontroller里面我们可以在这里看到,我们在货款里面拿到对应的数据,然后判断是否成功,如果成功的话,ok我们拿到对应的数据,通过EasyExcelWebUtil的形式给它写进达到控制台里面一个流,然后这样的话谷歌浏览器就会自动下载这样的一个交互流程。

业务逻辑

创建单个短链接参数:

{
    "domain": "nurl.ink",
    "originUrl": "https://nageoffer.com/",
    "gid": "siCwZo",
    "createdType": 1,
    "validDateType": 0,
    "describe": "consequat quis nisi nulla"
}

批量创建短链接参数,将原始链接和标题描述拆分为一个数组。

{
    "domain": "nurl.ink",
    "originUrls": [
        "https://nageoffer.com/",
        "https://nageoffer.com/"
    ],
    "describes": [
        "consequat quis nisi nulla",
        "consequat quis nisi nulla"
    ],
    "gid": "uyCClP",
    "createdType": 1,
    "validDateType": 0
}

并且,在创建完短链接之后,会返回前端一个 Excel 文件,方便用户查看哪些原始链接生成了短链接,以及对应短链接是什么。

整体逻辑如下:

为什么短链接中台返回标准数据,后管返回Excel?

因为短链接中台是提供通用能力的中台,供N多客户端系统使用,不只是短链接后管,应该返回公共的返回参数,而不是直接返回 Excel 流。

因为可能有些某些其他客户端系统不做 Excel 流处理,如果客户端系统希望这种交互,让他们根据返回参数自己做处理就好。

首先的话RemoteService这是后管admin对吧?然后他去我们的batchCreateShortLink然后在这里面返回对应的信息,然后通过信息来判断是否成功isSuccess,如果成功的话,那么我们拿到它对应的里面的一个明细数据getBaseLinkInfos,然后将该明细数据通过Excel的形式给写到对应的response里面去,让前端的控制台在浏览器里面直接下载这是一个最完整的流程;remoteService如下

/**
 * 批量创建短链接
 *
 * @param requestParam 批量创建短链接请求参数
 * @return 短链接批量创建响应
 */
default Result<ShortLinkBatchCreateRespDTO> batchCreateShortLink(ShortLinkBatchCreateReqDTO requestParam) {
    String resultBodyStr = HttpUtil.post("http://127.0.0.1:8001/api/short-link/v1/create/batch", JSON.toJSONString(requestParam));
    return JSON.parseObject(resultBodyStr, new TypeReference<>() {
    });
}

 admin的shortlinkController

/**
 * 批量创建短链接
 */
@SneakyThrows
@PostMapping("/api/short-link/admin/v1/create/batch")
public void batchCreateShortLink(@RequestBody ShortLinkBatchCreateReqDTO requestParam, HttpServletResponse response) {
    Result<ShortLinkBatchCreateRespDTO> shortLinkBatchCreateRespDTOResult = shortLinkRemoteService.batchCreateShortLink(requestParam);
    if (shortLinkBatchCreateRespDTOResult.isSuccess()) {
        List<ShortLinkBaseInfoRespDTO> baseLinkInfos = shortLinkBatchCreateRespDTOResult.getData().getBaseLinkInfos();
        EasyExcelWebUtil.write(response, "批量创建短链接-SaaS短链接系统", ShortLinkBaseInfoRespDTO.class, baseLinkInfos);
    }
}

我们再去看一下我们的shortlink,里面他在干什么,还是老样子。 批量单独把有筷子地球,然后创建一个地方的地球,然后我们再带进去,然后这边的话我们相当于以原始URL为判断,可能有多个原始码,然后这里面的话我们给他最终其实调动的市场是createShortlink,也就是我们的创建单个的方法,这样的话我们把一些信息说白都是可以复用的,所以说我们通过BeanUtil.toBean形式将这些信息创建成shortLinkCreateReqDTO就是单个创建的一个dto;然后把我们独立去判断的比如说原始链接和标题我们给它下载进去,然后经过create之后把一些信息拿到当做返回到原始参数返回出去,然后返回的如果说创建失败,ok我们就打个日志什么都不用管,就在日志里面留痕就可以了,然后这是它的一个整体的代码逻辑就比较清晰,其实它本质上依赖的就是我们单个桌面,然后我们这里的话是依赖了一下阿里巴巴开源的easy Excel,然后其他的逻辑都还好,我们把代码提交一下。shortlinkImpl如下添加


    @Override
    public ShortLinkBatchCreateRespDTO batchCreateShortLink(ShortLinkBatchCreateReqDTO requestParam) {
        List<String> originUrls = requestParam.getOriginUrls();
        List<String> describes = requestParam.getDescribes();
        List<ShortLinkBaseInfoRespDTO> result = new ArrayList<>();
        for (int i = 0; i < originUrls.size(); i++) {
            ShortLinkCreateReqDTO shortLinkCreateReqDTO = BeanUtil.toBean(requestParam, ShortLinkCreateReqDTO.class);
            shortLinkCreateReqDTO.setOriginUrl(originUrls.get(i));
            shortLinkCreateReqDTO.setDescribe(describes.get(i));
            try {
                ShortLinkCreateRespDTO shortLink = createShortLink(shortLinkCreateReqDTO);
                ShortLinkBaseInfoRespDTO linkBaseInfoRespDTO = ShortLinkBaseInfoRespDTO.builder()
                        .fullShortUrl(shortLink.getFullShortUrl())
                        .originUrl(shortLink.getOriginUrl())
                        .describe(describes.get(i))
                        .build();
                result.add(linkBaseInfoRespDTO);
            } catch (Throwable ex) {
                log.error("批量创建短链接失败,原始参数:{}", originUrls.get(i));
            }
        }
        return ShortLinkBatchCreateRespDTO.builder()
                .total(result.size())
                .baseLinkInfos(result)
                .build();
    }

在pom中分别导入easyExcel 

  <easyexcel.version>3.1.3</easyexcel.version>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version> $ {easyexcel.version}</version>
 </dependency>

 接下来是 是各种dto了,还有一个方法类放到admin的toolkit好了

package com.nageoffer.shortlink.admin.toolkit;

import com.alibaba.excel.EasyExcel;
import jakarta.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * 封装 EasyExcel 操作 Web 工具方法
 */
public class EasyExcelWebUtil {

    /**
     * 向浏览器写入 Excel 响应,直接返回用户下载数据
     *
     * @param response 响应
     * @param fileName 文件名
     * @param clazz    指定写入类
     * @param data     写入数据
     */
    @SneakyThrows
    public static void write(HttpServletResponse response, String fileName, Class<?> clazz, List<?> data) {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
        EasyExcel.write(response.getOutputStream(), clazz).sheet("Sheet").doWrite(data);
    }
}

 resq\ShortLinkBaselnfoRespDTO.java

package com.nageoffer.shortlink.admin.dto.resq;


import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 短链接基础信息响应参数
 * 公众号:马丁玩编程,回复:加群,添加马哥微信(备注:link)获取项目资料
 */
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ShortLinkBaseInfoRespDTO {

    /**
     * 描述信息
     */
    @ExcelProperty("标题")
    @ColumnWidth(40)
    private String describe;

    /**
     * 短链接
     */
    @ExcelProperty("短链接")
    @ColumnWidth(40)
    private String fullShortUrl;

    /**
     * 原始链接
     */
    @ExcelProperty("原始链接")
    @ColumnWidth(80)
    private String originUrl;
}

resq\ShortLinkBatchCreateRespDTO.java

package com.nageoffer.shortlink.admin.dto.resq;


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

/**
 * 短链接批量创建响应对象
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShortLinkBatchCreateRespDTO {

    /**
     * 成功数量
     */
    private Integer total;

    /**
     * 批量创建返回参数
     */
    private List<ShortLinkBaseInfoRespDTO> baseLinkInfos;
}

create的

package com.nageoffer.shortlink.project.dto.req;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.apache.shardingsphere.sharding.exception.syntax.UnsupportedUpdatingShardingValueException;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;

import java.util.Date;

/**
 * 短链接创建请求对象
 */
@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 MM:mm:ss",timezone = "GMT+8")
    private Date validDate;
    /**
     * 描述
     */
    private String describe;
}

shortLinkSercice 

/**
 * 批量创建短链接
 *
 * @param requestParam 批量创建短链接请求参数
 * @return 批量创建短链接返回参数
 */
ShortLinkBatchCreateRespDTO batchCreateShortLink(ShortLinkBatchCreateReqDTO requestParam);

control如下

/**
 * 批量创建短链接
 */
@PostMapping("/api/short-link/v1/create/batch")
public Result<ShortLinkBatchCreateRespDTO> batchCreateShortLink(@RequestBody ShortLinkBatchCreateReqDTO requestParam) {
    return Results.success(shortLinkService.batchCreateShortLink(requestParam));
}

 resp\ShortLinkBatchCreateRespDTO.java

package com.nageoffer.shortlink.project.dto.resp;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

/**
 * 短链接批量创建响应对象
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShortLinkBatchCreateRespDTO {

    /**
     * 成功数量
     */
    private Integer total;

    /**
     * 批量创建返回参数
     */
    private List<ShortLinkBaseInfoRespDTO> baseLinkInfos;
}

project\..\ShortLinkBatchCreateReqDTO.java

package com.nageoffer.shortlink.project.dto.req;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.util.Date;
import java.util.List;

/**
 * 短链接批量创建请求对象
 */
@Data
public class ShortLinkBatchCreateReqDTO {

    /**
     * 原始链接集合
     */
    private List<String> originUrls;

    /**
     * 描述集合
     */
    private List<String> describes;

    /**
     * 分组标识
     */
    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;
}

admin\..IShortLinkBatchCreateReqDTO.java

package com.nageoffer.shortlink.admin.dto.req;


import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.util.Date;
import java.util.List;

/**
 * 短链接批量创建请求对象
 * */
@Data
public class ShortLinkBatchCreateReqDTO {

    /**
     * 原始链接集合
     */
    private List<String> originUrls;

    /**
     * 描述集合
     */
    private List<String> describes;

    /**
     * 分组标识
     */
    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;
}

业务逻辑走的还是单条insert语句, 这个批量短链接没有性能提升,还是多次IO;

提升的网络IO;

是不是可以在一次for循环插入多条数据并在Mapper中使用foreach的方式来,避免频繁的访问数据库;直接操作mapper的话 添加缓存怎么办;不影响 这个 uu 的意思是用 SQL 中的批量插入而已

 URLEncoder.encode() 方法在编码时将空格转换为 +,是因为这种方式符合 application/x-www-form-urlencoded 编码格式的规范。在这种编码规范中,空格用 + 表示,而不是 %20。这是 URL 编码的一种历史做法,常用于表单数据的编码和传递。

- 第03节:修复短链接跳转空指针问题

在我们短链接系统里有一个bug,这个是之前有很多同学在知识星球以及在其他渠道给我反馈的。首先我们创建一个短链接,我们把某个分组用来创建一下试试,然后我们选择永久有效期的情况才会出问题。然后我们点确认,现在把这个短链接创建出来了,现在我们访问一下,ok是可以正常跳转对吧?这个时候我们如果说把它给放入回收站,然后我们再从回收站给它恢复过来,这个时候我们再访问,就会出现一个系统执行出错。

我们看一下报错信息,根据报错结果可以看到说的是shortLinkDO.getValidDate()过期时间是null对吧?这个问题很容易理解,因为我们这边是永久有效期的情况下validDate字段是空的,所以它没有过期时间。在跳转方法这里,当我们去判断如果shortLinkDO不等于空,或者它的过期时间已经过期的情况下,需要给它跳转到404页面。这种情况下我们这边还要加一个判断,那就是要判断这个validDate参数不等于空,然后再判断它的过期时间是否小于当前时间。

 这个已经改过来了,就是那个三次注释后的数据
// 原来错误的写法(当validDate为null时会触发空指针)
// if (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date())) {
// 修复后的写法:先判断shortLinkDO是否为null,再处理validDate的可能null情况

        //stringRedisTemplate.opsForValue().set(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl), shortLinkDO.getOriginUrl());
        //if (shortLinkDO != null) {
        //if (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date())) {
        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(fullShortUrl, shortLinkDO.getGid(), request, response);
        ((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());

    } finally {
        lock.unlock();
    }
}

然后我们重启服务,这个时候重新创建短链接,访问测试可以正常跳转。接着我们把它移入回收站,再从回收站恢复。按照刚才的情况,现在应该报错的,但修复后就没有报错了,访问次数也正确更新为2次。这个问题就解决了,其实就是加了一个null值判断的防护逻辑。这是欠了大家很长时间的一个问题,face修复了短链接跳转时空指针的问题,现在彻底解决了。

- 第04节:修复短链接修改有限期后无法跳转问题

说一下咱们之前做修改短链接的时候留下来的一个小问题。之前做修改链接的时候只是操作数据库的值,其实对于缓存我们是没有过多的操作的,这样的话会带来一个问题。我给大家演示一下。现在先看当前的短链接是可以被访问的,然后如果说我们把它设置成一个已经过去的有效期(已经失效了),这个时候其实它还是可以访问的,这明显就有点问题对吧?这种情况下我们应该怎么办?我们应该在这上面去给他做一个判断。

如果说它有效期的类型或者说有效期和之前的记录不一样了,我们需要对他把他的跳转的缓存给删掉,这样的话应该是一个解决方案。具体来说就是当type不一致或者validDate不一致时,触发缓存删除逻辑。可以了。他俩如果说有任何一个不一致,这个时候我们应该怎么办?我们应该去操作删除GOTO_SHORT_LINK_KEY的记录删掉,也就是它通过短链接跳转原始链接的存储,然后把它给删掉再重新生成。我们这样的话应该就可以避免刚才的问题,ok我们重启一下服务。然后我们把它给恢复过来,也是ok的。可以跳转,然后我们再改成自定义的时间,比如设成直接就失效了。现在访问的页面就是404,为什么?是因为我们把它的跳转缓存立刻给删除之后,相当于它没有短链接对应的原始链接缓存了,这样的话它就得去数据库里面加载,但是从数据库加载的时候它已经是一个过期的状态了,对吧?它的有效期已经过了,这种情况下对吧?只有把它加载到不存在的情况里去了,所以说它会直接返回404。

其实他还有一个问题,当他去做变更的时候,我给大家演示一下。等一下,现在他已经是一个404的状态对不对?已经不存在了,假如说我们现在把它恢复了对吧?他恢复了之后是不是就可以跳转了?现在看一下是不是还是不能跳转,为什么?是不是?因为我们这里面虽然给它恢复了,但是它在这里跳转的GOTO_IS_NULL_SHORT_LINK_KEY缓存还存在,这里是不是还存在着对不对?所以我们如果把他从一个已经失效的状态变成有效状态时,我们也要把它的这条无效记录给它删掉,对不对?好,我们这边还得再加一层判断逻辑。具体来说:

ShortLinkImpl如下 

@Transactional(rollbackFor = Exception.class)
@Override
public void updateShortLink(ShortLinkUpdateReqDTO requestParam) {
    LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
            .eq(ShortLinkDO::getGid, requestParam.getGid())
            .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
            .eq(ShortLinkDO::getDelFlag, 0)
            .eq(ShortLinkDO::getEnableStatus, 0);
    ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
    if (hasShortLinkDO == null) {
        throw new ClientException("短链接记录不存在");
    }
    ShortLinkDO shortLinkDO = ShortLinkDO.builder()
            .domain(hasShortLinkDO.getDomain())
            .shortUri(hasShortLinkDO.getShortUri())
            .clickNum(hasShortLinkDO.getClickNum())
            .favicon(hasShortLinkDO.getFavicon())
            .createdType(hasShortLinkDO.getCreatedType())
            .gid(requestParam.getGid())
            .originUrl(requestParam.getOriginUrl())
            .describe(requestParam.getDescribe())
            .validDateType(requestParam.getValidDateType())
            .validDate(requestParam.getValidDate())
            .favicon(getFavicon(requestParam.getOriginUrl()))
            .build();
    if (Objects.equals(hasShortLinkDO.getGid(), requestParam.getGid())) {
        LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
                .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                .eq(ShortLinkDO::getGid, requestParam.getGid())
                .eq(ShortLinkDO::getDelFlag, 0)
                .eq(ShortLinkDO::getEnableStatus, 0)
                .set(Objects.equals(requestParam.getValidDateType(), VailDateTypeEnum.PERMANENT.getType()), ShortLinkDO::getValidDate, null);
        baseMapper.update(shortLinkDO, updateWrapper);
    } else {
        LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
                .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                .eq(ShortLinkDO::getGid, hasShortLinkDO.getGid())
                .eq(ShortLinkDO::getDelFlag, 0)
                .eq(ShortLinkDO::getEnableStatus, 0);
        baseMapper.delete(updateWrapper);
        baseMapper.insert(shortLinkDO);
    }
//下面是新加入的函数
    if (!Objects.equals(hasShortLinkDO.getValidDateType(), requestParam.getValidDateType())
            || !Objects.equals(hasShortLinkDO.getValidDate(), requestParam.getValidDate())) {
        stringRedisTemplate.delete(String.format(GOTO_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
        if (hasShortLinkDO.getValidDate() != null && hasShortLinkDO.getValidDate().before(new Date())) {
            if (Objects.equals(requestParam.getValidDateType(), VailDateTypeEnum.PERMANENT.getType()) || requestParam.getValidDate().after(new Date())) {
                stringRedisTemplate.delete(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
            }
        }
    }
}

这段代码的意思是:如果说它的过期时间不等于空,证明它是个带有效期的短链接,而且这个短链接是在当前时间之前的,就证明它已经过期了的话,我们要判断:如果新的参数是永久效期,或者新的有效期时间在当前时间之后,就说明这条记录现在是有效的,要把之前存的GOTO_IS_NULL_SHORT_LINK_KEY无效缓存给删掉。这样的话相当于:如果说它的有效期类型是永久有效,或者说它的有效期时间在当前时间之后,就证明这条记录现在不是失效状态,如果有small的K的缓存记录,麻烦你给我删除掉,让系统重新走数据库加载最新状态。

我们重启一下服务。应该就能看到效果了。然后我们再变更一下有效期,比如变更到30号。第一次比较重要,没事,今天可以说是什么感觉?咱刚才重启8:30重启了,不应该有问题了。我看一下如果说,它不为空,并且之前的记录它的消息时间大于当前时间的。稍等我们就直接再创建一个测试用例吧。

首先我们创建了一条短链接,然后这个时候我们给它改成已经是一个失效状态的记录,正常情况下这个失效状态应该是跳不过去对吧?应该404了。按照刚才我们没有变更前的逻辑,即使我们给它修改成永久存储,它也一样跳不过去,但是现在修改后可以跳转过去对不对?然后再比如说我们给它变更成一个此刻已经过期的有效期,这个时候应该是跳不过去的。我们给它变更成一个历史的有效期,变更成一个不在之前的记录,后期会失效的一个记录,这样的话他还是能跳过去,这个问题就妥善解决了。

可以看到看着比较简单的一些功能,但是其实背后的这些逻辑还是有一些的,不是很复杂,但是你需要静下心去思考。因为当时我们在做修改的时候,是以最快完成和联调的方式写的,所以说会造成一些小坑是可以理解的,给自己找补一下。我们提交了"修复短链接修改后无法访问问题"的代码变更,好好完成这个功能。

 创建的时候就失效的话好像还能访问-----加一个前置检验,过期时间不能小于当前时间

 感觉这个判断:hasShortLinkDO.getValidDate() != null && hasShortLinkDO.getValidDate().before(new Date())有点多余了,大不了缓存中没有GOTO_IS_NULL_SHORT_LINK_KEY,不删就是了;

如果已经过期的数据,缓存中存在空值。然后修改为不过期数据,如果不删除空值,就无法访问了

这样还是有问题呀,如果修改的是原始链接,而缓存还不变的话,跳转的链接还是原来的而不是新的 ;你说的是对的,后面的代码已修复该问题

 L:看完感觉逻辑很奇怪,requestParam是里面我们想要修改的数据,然后hasShortLink就是我们找到了想要的数据后将修改的数据添加进去(也就是修改完的实体对象),那你后面的判断中 if(xxxxx&&hasShortLinkDO.getValidDate().before(new Date())){ if(requestParam.getValidDate.after(new Date())} 不是矛盾了吗,已经判断出它有效期非永久,并且有效期已经是失效,直接删除缓存不就行了吗

TheTurtle 回复 L:如果有条短链接早就过期了,然后为了防止缓存击穿(在restoreUrl里)会在redis里设置gotoShortLinkIsNullKey。此时通过修改期限使链接恢复有效性,如果不删除gotoShortLinkIsNullKey,还是无法访问。

if (!Objects.equals(hasShortLinkDO.getValidDateType(), requestParam.getValidDateType()) || !Objects.equals(hasShortLinkDO.getValidDate(), requestParam.getValidDate())) { if (Objects.equals(requestParam.getValidDateType(), 0) || (requestParam.getValidDate() != null && requestParam.getValidDate().after(new Date()))) stringRedisTemplate.delete(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, requestParam.getFullShortUrl())); else stringRedisTemplate.delete(String.format(GOTO_SHORT_LINK_KEY, requestParam.getFullShortUrl())); }类型为永久或者时间大于当前时间则说明是现在是可用的,就把GOTO_IS_NULL_SHORT_LINK_KEY缓存删除了,其他情况是不可用的,就把GOTO_SHORT_LINK_KEY删除。 

 总结一下: 存在问题1:如果把短链接设置成失效,短链接还是可以访问 原因:原因是短链接设置成失效后,没有把缓存删除。 解决办法:我们可以在判断到有效期类型更改或者有效期更改后把缓存删除。 存在问题 2:当把短链接设置成失效后,访问短链接,显示 404(这是正常的),但是当我们再恢复短链接后,恢复后的短链接依然不可跳转 原因: 原因是当我们把短链接设置成失效后,再去访问短链接,这时候查询的短链接已经过期,按照以前的判断逻辑,会把过期的短链接缓存空值。因此,当我们重新把短链接恢复后,空值依然存在,访问的时候直接打到空值去了,因此这时候短链接依然不可跳转。 解决办法:如果原来的有效期是已过期的,现在要把它改成有效,就要去删除缓存的空值

 个人认为这里的操作应该是只要进行了修改操作(日期,url),,就可以删除该短链在redis里面的所有缓存了。哪怕哪个key不存在,redis删除一个不存在的key是几乎不存在性能开销的。

//对于修改之后redis的处理 if (!Objects.equals(pendingShortLinkDO.getValidDateType(), requestParam.getValidDateType()) || !Objects.equals(pendingShortLinkDO.getValidDate(), requestParam.getValidDate()) || !Objects.equals(pendingShortLinkDO.getFullShortUrl() ,requestParam.getFullShortUrl()) || !Objects.equals(pendingShortLinkDO.getOriginUrl(),requestParam.getOriginUrl()) ){ stringRedisTemplate.delete(String.format(GOTO_SHORT_LINK_KEY, requestParam.getFullShortUrl())); stringRedisTemplate.delete(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, requestParam.getFullShortUrl())); }

其实还有个BUG就是修改短连接后就是我不修改有效期什么的,我把原始链接进行修改后,你会发现进行跳转的时候就是跳转的还是未修改之前的短连接,而不是修改后的原始链接,是因为缓存中还是有的,你把缓存中的删掉就好了 

第一:创建的时候就创建过期这样也能访问呢,,,第二跟老师写的不一样,我之前就修改了,直接添加一个try finall,

.build();
try {
    if (Objects.equals(hashShortLinkDO.getGid(), requestParam.getGid())) { //说明传递来的分组没有发生变化
        LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
            .eq(ShortLinkDO::getGid, requestParam.getGid())
            .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
            .eq(ShortLinkDO::getDelFlag, val: 0)
            .eq(ShortLinkDO::getEnableStatus, val: 0)
            .set(Objects.equals(requestParam.getValidDate(), validDateTypeEnum.PERMANENT.getType()), ShortLinkDO::getValidDate, requestParam.getValidDate());
        baseMapper.update(build, updateWrapper);
    } else { //说明传递来的分组也发生了变化
        LambdaQueryWrapper<ShortLinkDO> wrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
            .eq(ShortLinkDO::getGid, hashShortLinkDO.getGid())
            .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
            .eq(ShortLinkDO::getDelFlag, val: 0)
            .eq(ShortLinkDO::getEnableStatus, val: 0);
        baseMapper.delete(wrapper);
        baseMapper.insert(build);
    }
} finally {
    //同时将这个原先的空白的redis缓存也给删掉...这里就不进行预热了,一般来说从回收站移出的也没有多大的热度流量
    stringRedisTemplate.delete(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
    stringRedisTemplate.delete(String.format(RedisKeyConstant.GOTO_IS_NULL_LINK_KEY, requestParam.getFullShortUrl()));
}
@Override
public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
    if ((requestParam.getValidDateType() != 0 && Objects.equals(requestParam.getValidDate(), null)) //如果选择自定义时间,并且传入的时间为空-报错
        || (!Objects.equals(requestParam.getValidDate(), null) && requestParam.getValidDate().before(new Date()))) { //如果传入的时间不等于空,并且传入的时间小于当前时间-报错
        throw new ClientException("自定义过期时间不能为空且不能小于当前时间");
    }
}

pv和uv的问题在写的时候已修改 

这里有一大前端细微处的修改,可直接git看下跳过即可,我可以在根目录下面新一个resource/database,来加上描述link.sql

fix:修复知链接访问记录中的访客类型字       ShortLinkStatsServicelmpl.java统统变成这个             .map(item -> item.get("uvType"))

在这里有2项后端上的的修改,就是合并到主线上;b76f44b - Merge branch 'main' ...(17 files)  还有  ac44d71 - Merge branch 'main' .. (3 files)

- 第05节:短链接变更分组记录功能

之前漏洞

之前的代码逻辑,如果存在短链接记录修改分组,那么就会变成短链接记录不存在问题。

LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
        .eq(ShortLinkDO::getGid, requestParam.getGid())
        .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
        .eq(ShortLinkDO::getDelFlag, 0)
        .eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
if (hasShortLinkDO == null) {
    throw new ClientException("短链接记录不存在");
}

 方法执行逻辑

1. 删除原始短链接记录

如果说我们的这个GID变更了之后,因为根据GID进行分表,可能不存在原始表中。

我们为ShortLinkUpdateReqDTO.java 添加一个新的gid

/**
 * 原始分组标识
 */
private String originGid;

然后我们的短链接逻辑非常的复杂;代码里面操作一下这个逻辑就可以了。然后接下来就是我们建立一个变更上面这个锁大家可以先忽略;首先第一步对吧?我们将之前的表记录给他置为大家flag对吧?修改一下,然后我们再给他进行新增一下,这样的话它就相当于在两个表里面会操作两个表记录可能。

2. 修改唯一索引

第二点的话那就是修改为索引,我们之前唯一索引是建的包括说和,URL对吧?这种情况下我们为什么要修改这个微缩引?那是有一种可能性,就是我们的 gid对吧?假如你变更了,他可能还是会录入到当前的表,这样的话假如说我之前已经有这样的方式推完了,你再录一个他不就萎缩冲突对吧?所以说是不建议的,然后大家可能会说我能够我直接在他判断的时候,我根据希的分片的相关的东西,我确定他在哪个表是不是在当前表里不就行了,对不对?

是可以但是我想告诉大家的是这种逻辑上的判断是对代码的优雅以及后续的扩展会有一定的轻松性,你想之前我们分配的逻辑只在沙迪斯标的底层的分析算法里面,有用到你将来你在这里面就是在业务里面去用的话,那如果说你的代码换个人接手,人家不知道这个逻辑,那就凉了,所以说这种侵入式的这种变更我们是肯定推荐的

然后大家说马哥我用这个del_flag对吧?del_flag不也行对不对?因为我们的短信一旦删除是不允许被重复的再去创建了对吧?这样我加个del_flag对吧?它只有一个0就是带回来正常情况下,他是否说要加这个del_flagg等于0对吧?那是可以的,然后他就变成一了之后,然后他再进来个0也是可以的没问题,这种情况下我为什么不用del_flag对吧?我为什么要加一个Del time呢?有没有同学知道的想一想,或者你可以把视频暂停一下,大家看就是删除时间戳,就如果说你删除了ok,把当前时间戳传过来,有没有没想到对吧?这个是大家TM的标识,是因为我们可能一个短链接会被重复的去使用,这个相当是一个扩展功能,可能我们不会在这个代码里面去写,但是大家要考虑到比如说我们现在创建出来的这种短链接对吧?

后面短链接都是我们通过代码自动标示出来的,对吧?有没有这样一种业务需要你去将把链接给他自定义,比如说看AAA ABA对吧?这样的话他也想复用这种情况,你就得为这个东西去做一些适配对吧?我只告诉大家用Dell diagram这种形式,假如说我有一个后缀是a,我可以重复删除,因为他每次的 Dell time都是不一样的,所以不会触发唯一标识,因为我们默认只会有一条有效的记录它就是零好吧? 

索引按照目前创建的 full_short_url 字段可能存在冲突。

需要创建一个新的字段存储删除时间戳 del_time,防止唯一索引冲突。

del_flag 也能完成上述需求,为什么要单独创建个 del_time?方便后序功能扩展:如果想要复用某个short-uri

因为分表时修改短链接分组(gid)要先删除再新增,可能导致新增的短链接路由到相同的表导致唯一索引冲突。新增的del_time和full_short_url组成唯一索引可以有效避免这种冲突。
新的唯一索引变成:full_short_url, del_time。

用url+flag如果修改后还在同一个表中,最多你就只能修改这一次,多一次都改不了,用了时间戳,想改多少次都可以;link.sql重新导入一下语句好吧

CREATE TABLE `t_group_0`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_1`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_10`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_11`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_12`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_13`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_14`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_15`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_2`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_3`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_4`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_5`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_6`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_7`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_8`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_group_9`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`         varchar(32)  DEFAULT NULL COMMENT '分组标识',
    `name`        varchar(64)  DEFAULT NULL COMMENT '分组名称',
    `username`    varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
    `sort_order`  int(3) DEFAULT NULL COMMENT '分组排序',
    `create_time` datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`    tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_0`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_1`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_10`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_11`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_12`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_13`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_14`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_15`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_2`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_3`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_4`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_5`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_6`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_7`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_8`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_9`
(
    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `domain`          varchar(128)                                   DEFAULT NULL COMMENT '域名',
    `short_uri`       varchar(8) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '短链接',
    `full_short_url`  varchar(128)                                   DEFAULT NULL COMMENT '完整短链接',
    `origin_url`      varchar(1024)                                  DEFAULT NULL COMMENT '原始链接',
    `click_num`       int(11) DEFAULT '0' COMMENT '点击量',
    `gid`             varchar(32)                                    DEFAULT 'default' COMMENT '分组标识',
    `favicon`         varchar(256)                                   DEFAULT NULL COMMENT '网站图标',
    `enable_status`   tinyint(1) DEFAULT NULL COMMENT '启用标识 0:启用 1:未启用',
    `created_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)                                  DEFAULT NULL COMMENT '描述',
    `total_pv`        int(11) DEFAULT NULL COMMENT '历史PV',
    `total_uv`        int(11) DEFAULT NULL COMMENT '历史UV',
    `total_uip`       int(11) DEFAULT NULL COMMENT '历史UIP',
    `create_time`     datetime                                       DEFAULT NULL COMMENT '创建时间',
    `update_time`     datetime                                       DEFAULT NULL COMMENT '修改时间',
    `del_time`        bigint(20) DEFAULT '0' COMMENT '删除时间戳',
    `del_flag`        tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_full-short-url` (`full_short_url`,`del_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_access_logs`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `user`           varchar(64)  DEFAULT NULL COMMENT '用户信息',
    `ip`             varchar(64)  DEFAULT NULL COMMENT 'IP',
    `browser`        varchar(64)  DEFAULT NULL COMMENT '浏览器',
    `os`             varchar(64)  DEFAULT NULL COMMENT '操作系统',
    `network`        varchar(64)  DEFAULT NULL COMMENT '访问网络',
    `device`         varchar(64)  DEFAULT NULL COMMENT '访问设备',
    `locale`         varchar(256) DEFAULT NULL COMMENT '地区',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_access_stats`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `pv`             int(11) DEFAULT NULL COMMENT '访问量',
    `uv`             int(11) DEFAULT NULL COMMENT '独立访客数',
    `uip`            int(11) DEFAULT NULL COMMENT '独立IP数',
    `hour`           int(3) DEFAULT NULL COMMENT '小时',
    `weekday`        int(3) DEFAULT NULL COMMENT '星期',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_access_stats` (`full_short_url`,`gid`,`weekday`,`hour`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_browser_stats`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `cnt`            int(11) DEFAULT NULL COMMENT '访问量',
    `browser`        varchar(64)  DEFAULT NULL COMMENT '浏览器',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_browser_stats` (`full_short_url`,`gid`,`date`,`browser`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_device_stats`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `cnt`            int(11) DEFAULT NULL COMMENT '访问量',
    `device`         varchar(64)  DEFAULT NULL COMMENT '访问设备',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_browser_stats` (`full_short_url`,`gid`,`date`,`device`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_0`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_1`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_10`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_11`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_12`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_13`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_14`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_15`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_2`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_3`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_4`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_5`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_6`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_7`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_8`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_goto_9`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_locale_stats`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `cnt`            int(11) DEFAULT NULL COMMENT '访问量',
    `province`       varchar(64)  DEFAULT NULL COMMENT '省份名称',
    `city`           varchar(64)  DEFAULT NULL COMMENT '市名称',
    `adcode`         varchar(64)  DEFAULT NULL COMMENT '城市编码',
    `country`        varchar(64)  DEFAULT NULL COMMENT '国家标识',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_locale_stats` (`full_short_url`,`gid`,`date`,`adcode`,`province`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_network_stats`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `cnt`            int(11) DEFAULT NULL COMMENT '访问量',
    `network`        varchar(64)  DEFAULT NULL COMMENT '访问网络',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_browser_stats` (`full_short_url`,`gid`,`date`,`network`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_os_stats`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `cnt`            int(11) DEFAULT NULL COMMENT '访问量',
    `os`             varchar(64)  DEFAULT NULL COMMENT '操作系统',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_os_stats` (`full_short_url`,`gid`,`date`,`os`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_0`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_1`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_10`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_11`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_12`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_13`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_14`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_15`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_2`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_3`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_4`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_5`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_6`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_7`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_8`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_link_stats_today_9`
(
    `id`             bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `gid`            varchar(32)  DEFAULT 'default' COMMENT '分组标识',
    `full_short_url` varchar(128) DEFAULT NULL COMMENT '短链接',
    `date`           date         DEFAULT NULL COMMENT '日期',
    `today_pv`       int(11) DEFAULT '0' COMMENT '今日PV',
    `today_uv`       int(11) DEFAULT '0' COMMENT '今日UV',
    `today_uip`      int(11) DEFAULT '0' COMMENT '今日IP数',
    `create_time`    datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`    datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`       tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_today_stats` (`full_short_url`,`gid`,`date`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_0`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716344307570487299 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_1`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1726253659068588035 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_10`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1726262175087058946 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_11`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716835884998893571 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_12`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716356833762906114 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_13`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716777589441347586 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_14`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716835562859589634 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_15`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1725312189079834626 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_2`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1726260205890691074 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_3`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716826815625977859 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_4`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716777824704053251 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_5`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716835362095034371 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_6`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716991700406161411 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_7`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1716834641844936706 DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_8`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

CREATE TABLE `t_user_9`
(
    `id`            bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `username`      varchar(256) DEFAULT NULL COMMENT '用户名',
    `password`      varchar(512) DEFAULT NULL COMMENT '密码',
    `real_name`     varchar(256) DEFAULT NULL COMMENT '真实姓名',
    `phone`         varchar(128) DEFAULT NULL COMMENT '手机号',
    `mail`          varchar(512) DEFAULT NULL COMMENT '邮箱',
    `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
    `create_time`   datetime     DEFAULT NULL COMMENT '创建时间',
    `update_time`   datetime     DEFAULT NULL COMMENT '修改时间',
    `del_flag`      tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_unique_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1726852231086505986 DEFAULT CHARSET=utf8mb4;;

3. 迁移相关业务表数据

加了个字段,然后迁变更了一个这个缩影对吧,

将短链接相关的表进行数据修改,如果涉及到分片行为,先删除原有数据再新增。如果不涉及分片行为,只需要修改对应的数据库表记录即可。

我们gi d和我们的一个就是full_short_url是贯穿整个短链接业务的,这种情况下怎么办?没有分表的记录,我们直接去给他改gid的记录,分表记录按照刚才短链接的标识的那种方式先删再加,然后我们可以看这个代码会非常的长,对吧?Today当前今天的访问记录开始删对吧?然后在新增,因为它这个数据可能非常多,所以我们用了一个三个位置。然后接下来对各种异型增生,完事儿大概就是这么个逻辑,然后这个的话是没有办法的,因为确实把GDP放进去,因为JD对我们一个主要的就是分配的键,你必须要这么去做,然后它的事务会比较长一点,这个是没办法的。因为如果我们一旦进行并行操作的话,可能会遇到一个问题,就是那个事物会串,这样的话就没有办没有办法达到那种回稳的效果。shortlinkImpl

4. 引入读写锁

之后如果说有用户要去访问短链接,我们统计相关的分组里面的数据,对吧?我刚才说的这些部队这些统计表里面还有之前的接地,假如说它定型了对吧?我们把这些表的数据给它全部修改成最新的接地了,那是不是我们就没有办法再进行统计,往里面放的时候就没办法给他统计成最新的记录了,对不对?这个怎么办?你们分不是走吗?大家想想是不是要用什么手册,比如说我在我再去修改链接之前,我引入一个分布式的站锁,然后我访问短链接的时候也引入独占锁,这样的话两者互不干扰对吧?因为他修改的时候统计是拿不到锁的,所以说没有问题,那统计的时候我把锁给占住,你修改不了这种是不是也没问题?有几个很坑的地方,首先第一那就是这里拿告诉我没问题,拿分布锁没问题,但是我在统计的时候,我在这里我在统计的时候,难道说我要去拿分布式解锁吗对不对?我拿个分布式锁的话,那是不是意味着我一条短链接只能允许一个用户去访问,同一时间是不允许一个用户去访问,这肯定是不现实的。这种情况下我们就得运用到一些扎尔并发现的,东西,读写锁,什么是读写锁对吧?

思考一个问题,如果短链接正在修改分组,这时有用户正在访问短链接,统计监控相关的分组还是之前的数据,是否就涉及到无法正确统计监控数据问题?

引入分布式锁?

引入读写锁?

读写锁是一种用于管理对共享资源的访问的同步机制,允许多个线程同时读取共享资源,但在写入时保证独占访问,以确保数据的一致性和完整性。

读写锁分为两类:

  1. 读锁(共享锁): 多个线程可以同时获取读锁,用于并发读取共享资源。读锁在没有写锁的情况下可以被多个线程持有。
  2. 写锁(排它锁): 写锁是独占的,一旦一个线程获取了写锁,其他线程无法同时获取读锁或写锁。写锁用于修改共享资源,确保在写入时没有其他线程能够访问。

读写锁的优势在于它允许多个线程同时读取共享资源,提高了读取的并发性,从而提升了性能。但是,在写入时必须独占资源,以确保数据的一致性。这种锁的使用场景适用于读操作频繁,写操作较少的情况。

5. 引入延迟队列

如果用户正在修改短链接分组,因为涉及到表操作很多,我们假设可能会操作 300ms。

这 300ms 内难道就不允许用户访问?

我给大家看一下,首先我们再去做这个短链接统计的时候,我们首先要拿写锁,我们不是用读锁对吧?拿到读锁之后其他人访问也能拿到读锁,这样的话他就能正常访问,同时我们拿到读锁的时候,修改逻辑就无法执行,你看到没有?他去获取失败,他会告诉"短链接资源被占用,请稍后再试",这种情况是比较合理的。这样就完美避免那个问题了吗?不是。之前我们获取锁是有系统损耗的,要知道普通情况下访问都是读锁不需要竞争,现在需要加锁相当于可能带来几毫秒到几十毫秒的额外开销,大家是要知道的。但为了适配修改逻辑没有办法,后续可能会思考架构设计升级方案,比如是否能用其他方式避免这种情况,到时候可能会分享相关方案。

还有一个问题,如果用户正在修改分组,操作量大的话可能超过300毫秒。当他获取到写锁后,读锁全部被阻塞,此时修改耗时300毫秒都不允许访问吗?这显然不合理。如果因为异常情况导致阻塞时间扩大到几秒,采用死等读锁的方式会怎样?我们的应用就可能挂掉,因为很多线程被占用无法处理新请求。那怎么办?解决方案是延迟队列。延迟队列分为两种:内存级别的(如JDK自带的DelayQueue)和MQ级别的(如RocketMQ/RabbitMQ)。虽然内存队列可用但不建议,比如堆积几百上千请求时,应用重启会导致统计丢失。

最终我们选择Redisson封装的延迟队列实现:

public class DelayShortLinkStatsProducer {
    public void send(ShortLinkStatsRecordDTO statsRecord) {
        RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque = redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
        RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
        delayedQueue.offer(statsRecord, 5, TimeUnit.SECONDS); // 5秒延迟投递
    }
}

 通过delayedQueue.offer()将统计实体异步投递,5秒后被消费。这个机制大家应该比较熟悉。ShortLinkStatsRecordDTO通过新抽象的buildLinkStatsRecordAndSetUser()方法构建,避免流水账代码重复。MQ的Consumer端(即消费端)会处理这些延迟消息,实现解耦统计与业务逻辑。

6. 回收站删除

因为加了 del_time,所以回收站删除功能也要改造。

7. 修改短链接分组查询数量接口

添加 del_flag 条件。


请问? 当修改短链接修改分组时,分组ID会修改,然后修改所有日志表的分组的分组ID。若在这中间穿插访问短链接,要修改统计信息,因为统计信息是分组ID查找的,此时统计信息表还是旧的分组ID,就找不到对应的信息。这就要引入分布式锁,则同一时间仅允许一个用户访问,显然不现实。所以读写锁,但是当写日志的时候不允许短链接访问,不科学,因为访问更改日志信息不重要,所以放入延迟队列

引入分布锁?如果在修改短链接的时候用到分布式锁,那么在访问短链接的时候就需要引入分布式锁进行解锁,会造成同一时间只允许一个用户访问的后果

引入读写锁?可同时获得读锁,但是写锁是独占的,但是会造成获得写锁的时候,读锁获取不到,那么会影响用户访问短链接

这 300ms 内难道就不允许用户访问?
1.这里没人解释一下吗?有没有大佬,我疑惑的地方就是这里的解决方案是引入MQ,但是问题是MQ在完成这个任务的时候不也得获取写锁吗?,那如果在这个时候由用户进行访问,那不还得是要进行等待吗?
2.在修改短链接的代码那里没有看见延迟队列,这个延迟队列和监控那里的延迟队列有关系吗?

短链接记录变更分组功能 

 Echo:为什么要单独创建del_time?这里没有听懂

QtdS 回复 Echo:第一次变更,如果路由到同一张表,会出现一个delFlag为0的记录,如果第二次变更,如果还是路由到同一张表,就会出现两个delFalag为0的记录,不符合唯一索引的设计

Echo 回复 QtdS:感谢您的回复!但是为什么不直接把变更前的记录删掉呢

QtdS 回复 Echo:直接删可以解决,但一般都是用的逻辑删,不建议真删

Echo 回复 QtdS:谢谢!

Goat 回复 Echo:我的理解是 第一次他变更之后,如果路由到同一张表的话,此时这张表中有fullShortUrl相同但是delFlag不同的表,如果fullShortUrl相同的话就会出现唯一索引碰撞,所以需要加del_time用来唯一索引

皮蛋瘦肉粥。 回复 Echo:就是怕数据找不到,那么操作就是把之前的表delFlag设置为1,然后再插入一条。这时候插入数据的分表标识HashCode取余之后和之前插入数据的分表标识取余的HashCode相同。那么就换一个唯一索引。但是我觉得直接把他删除了会不会好一点。因为直接不用换了,这里不一定说一定要把delFlag设置为1的吧

那为什么不能禁止修改GID呢,就像用户不能改username一样 

禁止修改gid这个业务难避免,比如我要恢复回收站的短链接,而原来的分组已经删除了,那么此时不得不移动到默认分组中。有gid,在改gid时修改DB次数多;没有gid,在查询分组统计信息时查询DB次数多,只能做个权衡,无法完全避免这个问题。

 王小明:其实我觉得,那么多统计表里面,最好还是不要加上分组gid,有一个fullShortUrl就够了,已经全局唯一了,否则修改一下分组要改的数据库数据太多了

一切随缘 回复 王小明:对,统计数据跟短链接绑定就好了

一切随缘 回复 王小明:下面评论咋删了,刚想回复你。

王小明 回复 一切随缘:那个评论想错了 哈哈 当时看了遍视频没有细看代码

一切随缘 回复 王小明:大佬能不能加你微信,感觉你好强

王小明 回复 一切随缘:好的,你加我吧,wx:wanganwen11,非大佬,一起学习交流

夏天 回复 王小明:我也感觉,尤其是那个Log数据量非常大估计几千万上亿,去修改所有的日志信息太吓人了

心态要好昂 回复 王小明:这些统计表好像本来也是要分表的,去掉gid的话吧fullShortUrl当成分表键应该也行

茂盛 回复 王小明:感觉频繁改监控日志有点怪怪的,但不要gid分组监控怎么做呢

独来独往 回复 王小明:是不是为了分组统计啊?

PzF 回复 王小明:我的想法是这样的:在有路由表这个中间表的情况下,可以不给短链接相关的统计表加上gid,对于单个短链接的统计数据获取,可以直接通过fullShortUrl来进行统计数据查询(fullShortUrl是全局唯一的);而对于短链接组的统计,可以先根据需要统计的gid,去t_link表中找到gid下的所有fullShortUrl,由于gid是t_link的分片键,同一个分组下的fullShortUrl在同一张分表中,再利用fullShortUrl去进行统计汇总,但是对于整组的统计,可能这会比加上gid进行统计耗时更长一些,因为涉及到t_link表。所以考虑统计功能和切换分组功能使用的频率,统计功能使用次数可能更多,所以还是加上了gid?

王小明 回复 PzF:我的想法是短链接访问次数非常多的时候,统计数据非常大,那么修改一下分组可能是几千万条数据的修改,这个延迟太大了....相比来说,用gid先查询一下所有fullshorturl,这点时间可能根本算不了什么,而且对于查询的数据,还可以通过做缓存等方式减小延迟,所以我删除了统计表的gid。当然,两种各有利弊

PzF 回复 王小明:嗯嗯确实你说的也有道理,感谢你提供不一样的想法

马丁 回复 王小明:监控表不存 gid 思路是对的。不过,正常来说,如果做分组监控,按照你的这个说法行不通的,假设一个分组下十万的短链接,难道要 in 10 万个么?后面我重构了分组查询,也是把 gid 从监控表删除,采用内关联形式,避免了提前查询分组下所有短链接。

为什么要加一个del_time字段?个人理解 1. 修改了分组,逻辑删除 delFlag 置为1,然后创建新的分组的短链接插入,有可能在同一张表中,那么此时就违背了唯一索引,所以需要优化。 2. 为什么不使用 delFlag 当做唯一索引,而需要添加一个del_time呢, 因为一个短链接不止会被修改一次,如果修改了多次,都出现在同一张表中,delFlag只有0和1,也会唯一索引冲突,所以需要添加一个 delTime 字段,来唯一索引。 

那为什么不直接把之前那个数据物理删除呢?其实没必要说一定要保留之前的数据吧?控制台监控的数据也不会监控delFlag=1的呀。还是以后要出一个统计数量关于用户删除了多少条短链接这种么?感觉如果没这方面业务的话,最好就是物理删除了。

 为什么要在一个线程池里运行Consumer啊,这跟但开一个线程有什么区别吗?可以看看线程池的作用,用到多线程直接创建线程肯定是不行的

 DelayShortLinkStatsConsumer 中 for循环里LockSupport.parkUntil(500)是不是有问题?parkUntil传入的参数应该是一个绝对时间戳,应该写成LockSupport.parkUntil(System.currentTimeMillis() + 500)吧

可能,这个我研究下。不过咱们后续的延时已经没了

引入删除时间戳的原因:如果用户想要修改gid,因为这里gid作为分片键,新gid不一定能hash到原来表,所以这里的思路是先逻辑删除原来表的逻辑,再经过新gid的值hash到新表新增一条记录,但是可能会有这样一个场景,就是原来短链接经逻辑删除后,新的gid可能还会hash到原来的表, 因为唯一索引是full_short_url,所以就出错了,那可能会想再将del_flag也加入索引, 但是如果还想修改gid就必须将del_flag置为1,如果这样做就会有两条记录full_short_url相同,del_flag又相同。 那有的人可能会想,为什么不把full_short_url和gid作为复合索引,这个也不行,因为一条记录从一个分组移到另一个分组,此时会逻辑删除,原本的记录会留下。那这记录又移回到原来的分组了呢?就会因为逻辑删除留下的记录,而产生冲突 

 求助大佬们,这一节不是很理解我们要用到消息队列,当更改链接的分组的时候使用写锁,这个时候用户是无法访问这个短链接吗?然后更改完毕后,用户可以正常访问,为什么统计短链接的一些数据需要用到消息队列啊?

写锁锁住之后,用户确实无法访问短链接,但这个访问需求没有变,访问如果不加消息队列应该会一直等着这个锁,这样会占用资源吧,加上消息队列就可以直接释放这个进程,然后让用户直接访问originUrl,之后在消息队列里消费统计短链接数据的这个请求,应该是这个逻辑

 后面测试的时候发现RecycleBinServiceImpl这里有问题

1.首先,我们执行saveRecycleBin删除一个短链接的时候是先移动至回收站,在回收站中的短链接此时del_time还是为0,只是修改列enable_status置为1,这个功能没问题。 2.其次,当执行removeRecycleBin彻底回收站中的短链接之后,是让del_time设置为当前系统时间然后让del_flag设置为1,这个功能也没问题,因为del_time为0才表示短连接能用。 3.但是,当再次查询回收站中的短链接后,还是会查询到我们从回收站删除的短链接,因为我们执行pageShortLink的时候并没有限制del_time这个查询条件,因此不管短链接是否在回收站中删除都会查询到,我们需要加上.eq(ShortLinkDO::getDelTime, 0L)才表示没被彻底删除。

这块是没加del_time的代码,按你的逻辑,第二步让del_flag设置为1了,那在查询的时候,判断.eq(ShortLinkDO::getDelFlag, 0),只能查的出来del_flag为0的,删除的查不到的

public IPage<ShortLinkPageRespDTO> pageShortLink(ShortLinkRecycleBinPageReqDTO requestParam) {
    LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
        .in(ShortLinkDO::getGid, requestParam.getGidList())
        .eq(ShortLinkDO::getEnableStatus, val: 1)
        .eq(ShortLinkDO::getDelFlag, val: 0)
        .orderByDesc(ShortLinkDO::getUpdateTime);
    IPage<ShortLinkDO> resultPage = baseMapper.selectPage(requestParam, queryWrapper);
    return resultPage.convert(ShortLinkDO each -> {
        ShortLinkPageRespDTO result = BeanUtil.toBean(each, ShortLinkPageRespDTO.class);
        result.setDomain("http://" + result.getDomain());
        return result;
    });
}

 个人的理解,这一集的干货满满,但从理解的角度,优化等以后再说哈哈哈: 1.如果单单使用full_short_url来作为唯一索引,那么无法对该url进行复用,t_link的分片键是gid,如果修改gid的话,该记录可能路由到其它表中,所以需要在该表中删除该记录,将该记录的del_flag置为1,如果另外的gid也被分到这一组,想创建同一个full_short_url,就不行,达不到full_short_url复用的功能; 2.所以加上一个del_time时间戳,和short_link_url一同作为联合索引,不同的gid可以对在该表中被删除的full_short_url进行复用; 3.修改gid需要进行的两步操作,一删除在原来分片的记录(将del_flag置为1),在新分片中添加记录; 4.在修改gid的过程中,用户访问该分组的short_link,因为MySQL默认支持的是可重复读,那么监控数据就不准确->需要加锁,加分布式锁?那就不能并发,一个用户访问该short_link,别的用户访问不了。如果加读写锁,相当于大家都可以读,但是获得写锁的用户只有一个。达到1.监控数据准确2.并发! 5.难道你修改的时候我就不能访问了?用户体验感太差了吧?所以需要在修改的时候也能进行访问,但是访问的数据得记录好,记录的是我修改后gid的访问数据(监控数据延迟加载),所以需要使用延迟队列。但是保证了用户体验感,因为用户不会在第一时间去看监控数据,可以接受稍许延迟

我想问一下,如果是改了gid,那么直接执行物理删除的操作,不就可以继续使用full_short_url作为唯一索引了吗

package com.nageoffer.shortlink.project.mq.consumer;

import com.nageoffer.shortlink.project.dto.biz.ShortLinkStatsRecordDTO;
import com.nageoffer.shortlink.project.service.ShortLinkService;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RBlockingDeque;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

import java.util.concurrent.Executors;
import java.util.concurrent.locks.LockSupport;

import static com.nageoffer.shortlink.project.common.constant.RedisKeyConstant.DELAY_QUEUE_STATS_KEY;

/**
 * 延迟记录短链接统计组件
 */
@Component
@RequiredArgsConstructor
public class DelayShortLinkStatsConsumer implements InitializingBean {

    private final RedissonClient redissonClient;
    private final ShortLinkService shortLinkService;

    public void onMessage() {
        Executors.newSingleThreadExecutor(
                        runnable -> {
                            Thread thread = new Thread(runnable);
                            thread.setName("delay_short-link_stats_consumer");
                            thread.setDaemon(Boolean.TRUE);
                            return thread;
                        })
                .execute(() -> {
                    RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque = redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
                    RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
                    for (; ; ) {
                        try {
                            ShortLinkStatsRecordDTO statsRecord = delayedQueue.poll();
                            if (statsRecord != null) {
                                shortLinkService.shortLinkStats(null, null, statsRecord);
                                continue;
                            }
                            LockSupport.parkUntil(500);
                        } catch (Throwable ignored) {
                        }
                    }
                });
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        onMessage();
    }
}
/**
 * 短链接修改分组 ID 锁前缀 Key
 */
public static final String LOCK_GID_UPDATE_KEY = "short-link_lock_update-gid_%s";

/**
 * 短链接延迟队列消费统计 Key
 */
public static final String DELAY_QUEUE_STATS_KEY = "short-link_delay-queue:stats";

 还有ShortLinkUpdateReqDTO.java都要加入

/**
 * 分组标识
 */
private String gid;
/**
 * 原始分组标识
 */
private String originGid;

shortlinkDO里面加入 等其他的dto文件也要修改

/**
 * 删除时间
 */
private Long delTime;

 LinkStatsTodayService.java

package com.nageoffer.shortlink.project.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.project.dao.entiry.LinkStatsTodayDO;

/**
 * 短链接今日统计接口层
 */
public interface LinkStatsTodayService extends IService<LinkStatsTodayDO> {
}

ShortLinkService.java 添加

/**
 * 短链接统计
 *
 * @param fullShortUrl         完整短链接
 * @param gid                  分组标识
 * @param shortLinkStatsRecord 短链接统计实体参数
 */
void shortLinkStats(String fullShortUrl, String gid, ShortLinkStatsRecordDTO shortLinkStatsRecord);
package com.nageoffer.shortlink.project.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.project.dao.entiry.LinkStatsTodayDO;
import com.nageoffer.shortlink.project.dao.mapper.LinkStatsTodayMapper;
import com.nageoffer.shortlink.project.service.LinkStatsTodayService;
import org.springframework.stereotype.Service;

/**
 * 短链接今日统计接口实现层
 */
@Service
public class LinkStatsTodayServiceImpl extends ServiceImpl<LinkStatsTodayMapper, LinkStatsTodayDO> implements LinkStatsTodayService {
}

 DelayShortLinkStatsProducer.java

package com.nageoffer.shortlink.project.mq.producer;


import com.nageoffer.shortlink.project.dto.biz.ShortLinkStatsRecordDTO;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RBlockingDeque;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

import static com.nageoffer.shortlink.project.common.constant.RedisKeyConstant.DELAY_QUEUE_STATS_KEY;

/**
 * 延迟消费短链接统计发送者
 */
@Component
@RequiredArgsConstructor
public class DelayShortLinkStatsProducer {

    private final RedissonClient redissonClient;

    /**
     * 发送延迟消费短链接统计
     *
     * @param statsRecord 短链接统计实体参数
     */
    public void send(ShortLinkStatsRecordDTO statsRecord) {
        RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque = redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
        RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
        delayedQueue.offer(statsRecord, 5, TimeUnit.SECONDS);
    }
}

RecycleBinServicelmpl.java 

shortLinkImpl修修补补的,不写了自行在git中寻找

- 第06节:短链接创建和修改验证跳转链接白名单

前年有搞了一个AI项目,因为没有做语言模型的风控,导致出现敏感问题,域名被风控了——这就是典型的躺枪案例,我们要引以为戒。毕竟域名很重要,如果涉及工信部备案之类的会很麻烦。

所以我们要做一个跳转链接的白名单功能。这是必须的,如果是做SaaS官网更是必须的。另一种场景是部署在公司内网的情况,这种就不是必须的了,因为谁创建谁心里有数,如果创建违法链接,责任人明确。还有一种情况是无限制跳转原始网站,但创建时要分析内容是否违法,但这种流程太复杂,所以我们直接用域名白名单。

代码实现不复杂,已经写好。我们在配置里加了goto-domain.white-list参数:

application.yaml添加如下

goto-domain:
  white-list:
    enable: true
    names: '拿个offer,知乎,掘金,博客园'
    details:
      - nageoffer.com
      - zhihu.com
      - juejin.cn
      - cnblogs.com
  1. 创建GotoDomainWhiteListConfiguration配置类加载参数
  2. LinkUtil.extractDomain()方法提取原始链接域名(自动过滤www前缀)
  3. createShortLinkupdateShortLink方法前添加verificationWhitelist()校验

在project/config中新建GotoDomainWhiteListConfiguration如下

package com.nageoffer.shortlink.project.config;


import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * 跳转域名白名单配置文件
 */
@Data
@Component
@ConfigurationProperties(prefix = "short-link.goto-domain.white-list")
public class GotoDomainWhiteListConfiguration {

    /**
     * 是否开启跳转原始链接域名白名单验证
     */
    private Boolean enable;

    /**
     * 跳转原始域名白名单网站名称集合
     */
    private String names;

    /**
     * 可跳转的原始链接域名
     */
    private List<String> details;
}

LinkUtil里面新写函数 

/**
 * 获取原始链接中的域名
 * 如果原始链接包含 www 开头的话需要去掉
 *
 * @param url 创建或者修改短链接的原始链接
 * @return 原始链接中的域名
 */
public static String extractDomain(String url) {
    String domain = null;
    try {
        String host = uri.getHost();
        if (StrUtil.isNotBlank(host)) {
            domain = host;
            if (domain.startsWith("www.")) {
                domain = host.substring(4);
            }
        }
    } catch (Exception ignored) {
    }
    return domain;
}
shortLinkImpl里面添加    private final GotoDomainWhiteListConfiguration gotoDomainWhiteListConfiguration;


接下来createShortLink还有updateShortLink第一句前填上        verificationWhitelist(requestParam.getOriginUrl());



    private void verificationWhitelist(String originUrl) {
        Boolean enable = gotoDomainWhiteListConfiguration.getEnable();
        if (enable == null || !enable) {
            return;
        }
        String domain = LinkUtil.extractDomain(originUrl);
        if (StrUtil.isBlank(domain)) {
            throw new ClientException("跳转链接填写错误");
        }
        List<String> details = gotoDomainWhiteListConfiguration.getDetails();
        if (!details.contains(domain)) {
            throw new ClientException("演示环境为避免恶意攻击,请生成以下网站跳转链接:" + gotoDomainWhiteListConfiguration.getNames());
        }
    }

- 第07节:变更用户已登录状态下异常行为

我们的用户他只能在一个地方登录对吧?但是后来发现惨遭打脸——就是咱们的短链接平台,我会部署到公网上面一个服务对吧?这样的话我是不开放用户注册接口的,这种情况的话我们就会只能用一个用户去登录这种情况对吧?我们现有的体系就不怎么支持了,支持不怎么好,所以说我们要进行改造。怎么改造?首先它原来不是判断当前如果有登录信息就直接抛异常,我们现在需要把它改成:如果说存在了,ok我们把它的 token给它返回回去就可以了。

我们这边改逻辑的时候用了Redis的Hash结构:

admin里面的UserServiceImpl修改如下 :用户已登录状态如果重复登录返回Token;还有用户登录状态有效期修改为30分钟

@Override
public UserLoginRespDTO login(UserLoginReqDTO requestParam) {
    LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
            .eq(UserDO::getUsername, requestParam.getUsername())
            .eq(UserDO::getPassword, requestParam.getPassword())
            .eq(UserDO::getDelFlag, 0);
    UserDO userDO = baseMapper.selectOne(queryWrapper);
    if (userDO == null) {
        throw new ClientException("用户不存在");
    }
    /* 原来未修改的多端登录
    Boolean hasLogin = stringRedisTemplate.hasKey("login_" + requestParam.getUsername());
    if (hasLogin != null && hasLogin) {
        throw new ClientException("用户已登录");
    }
    */
    Map<Object ,Object> hasLoginMap = stringRedisTemplate.opsForHash().entries("login_" + requestParam.getUsername());
    if (CollUtil.isNotEmpty(hasLoginMap)) {
        String token = hasLoginMap.keySet().stream()
                .findFirst()
                .map(Object::toString)
                .orElseThrow(() -> new ClientException("用户登录错误"));
        return new UserLoginRespDTO(token);
    }
    /**
     * Hash
     * Key: login_用户名
     * Value:
     * Key: token标识
     * Val: JSON 字符串(用户信息)
     */
    // HashOperations中没有发现办法设置过期时间
    String uuid = UUID.randomUUID().toString();
    stringRedisTemplate.opsForHash().put("login_" + requestParam.getUsername(), uuid, JSON.toJSONString(userDO));
    stringRedisTemplate.expire("login_" + requestParam.getUsername(), 30L, TimeUnit.MINUTES);//不是DAYS了
    return new UserLoginRespDTO(uuid);
}

这部分代码的意思是:当用户已经存在登录记录时(即Redis Hash中有数据),我们直接取第一个token返回,而不是报错。这样就实现了允许多端同时登录的效果。

用了Redis Hash存储用户的多个token(key是login_用户名,value是token->用户信息的映射),并且把过期时间从原来的30天改成了30分钟。我们测试下来因为是在Redis的Hash结构里命名,所以直接重启服务就能生效。 

 1.谁能讲讲这是咋实现的多端登录,想不明白

我的理解是这样的,仅供参考:多端登录指的是可以在不同设备上同时登录使用,那么这里修改的目的就很清楚了。假设这么一个场景,你已经在手机上通过账号和密码登录了,如果按照之前的代码,那么这时你在电脑上紧接着登录的话显然会被抛异常说用户已经登陆,但是代码修改之后,不再抛出用户已登录的异常而是返回用户token,使用户登陆成功,这样就

  • 代码中每次登录只会生成一个Token ,并非"多端登录时返回已有Token"。所有设备共享同一个Token(因为每次登录都复用已有Token),这本质上是单Token共享登录 ,而非真正的多Token多端登录(每个设备独立Token)。真正的多端登录应为每个设备生成独立Token,但此处设计选择了简化方案。

2.实现了多端登录,不知道我有没有解释清楚。

如果有多个用户的话 那么这里返回的是哈希表中第一个用户的token么 ?

  • 代码中使用 hash.keySet().stream().findFirst() 取出的是唯一存在的Token(因为每次登录只存一个键值对),并非"多个Token中取第一个"。
  • 结论 :用户2的表述存在逻辑矛盾:若"map中的token只有一个",则不存在"取第一个"的概念,其本质是复用已有Token

3.原代码还有一个弊端,如果一个用户已经登录,如果他直接关闭网页,那么他还是登录状态,下次登录如果token没过期的话,他就登录不上去了;每次操作都刷新缓存过期时间是不是比较好?(login方法里面只是进行登录操作,不进行检查登录的操作,检查登录的操作在checkLogin方法中执行。目前看来好像是允许重复登录的->允许多端登录?)

  • 应在每次请求校验登录状态时(如checkLogin方法)通过 stringRedisTemplate.expire(...) 刷新Token过期时间,实现"滑动过期"机制。
  • 当前代码仅在登录时设置一次过期时间,无法动态延长有效期,用户体验较差。

- 第08节:用户创建分组限制最大数量

 这节课来跟大家说一下关于创建短链接分组的一个限制问题,大家都知道我们这边给他去做,稍等我登录一下。我们这边去创建这些短链接分组的话,它不可能是可以无限创建对吧?这个肯定是要加一些限制的,我们这边在刚开始限制的话,我们可以给他限制,就是一个用户下面可以有20个分组,这种情况下,因为我们都知道我们是作为这种分布式应用去部署的,对吧?你这样普通的single 奈斯或者远程lock就肯定不行了,然后基于这种情况我们应该分布式锁去做全局管控并且通过count的形式判断它有多少个这种分组,然后如果说小于20个才可以创建如果说,等于20个就不允许创建了,ok这边的话我们就可以给他把RedissonClient先引进来。三楼然后一个就第一次看,就看到了。然后 get到的话我们就以就在这边搞这里开始。 user应该是go用户注册分布式锁,分组创建,short-link:lock_group-create:%s。然后这边还要给它加一个什么?加一个加上%S,因为它是要去跟用户是挂钩的,有的内容。Ok在这里的话我们首先要基于什么?要基于我们要基于一个wrappers.lambdaquery。EQ一下当前用户,

.eq(GroupDO::getUsername, username)

                    .eq(GroupDO::getDelFlag, 0);

,然后我们这边就直接稍等一下。我们这边我想想直接就来了一个 List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);,if第二开始,并且屌塞子。等于20;不能叫大于20.如果说不能大于如果说等20。20肯定是不能够在这边去直接去指定的这种的,话一般的话我们应该在就配文件里面就给他确定,

  1. 持续创建分组到18/19/20个
  2. 第21次创建时抛出"已超出最大分组数:20"异常
  3. 截图验证功能生效

这个功能属于基础风控措施,建议所有需要限制用户资源数量的场景都采用类似方案。通过配置中心管理max-num参数可灵活调整限制数量,无需修改代码。

package com.nageoffer.shortlink.admin.common.constant;

/**
 * redisson 的常量命名
 */
public class RedisCacheConstant {
    /**
     * 用户注册分布式锁
     */
    public static final String LOCK_USER_REGISTER_KEY = "short-link:lock-user-register:";

    /**
     * 分组创建分布式锁
     */
    public static final String LOCK_GROUP_CREATE_KEY = "short-link:lock_group-create:%s";

}

admin的GroupServicelmpl.java如下 还有application.yaml 里面添加上这些

short-link:
  group:
    max-num: 20
package com.nageoffer.shortlink.admin.service.impl;


import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.admin.common.biz.user.UserContext;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import com.nageoffer.shortlink.admin.dao.entiry.GroupDO;
import com.nageoffer.shortlink.admin.dao.mapper.GroupMapper;
import com.nageoffer.shortlink.admin.dto.req.ShortLinkGroupSortReqDTO;
import com.nageoffer.shortlink.admin.dto.req.ShortLinkGroupUpdateReqDTO;
import com.nageoffer.shortlink.admin.dto.resq.ShortLinkGroupRespDTO;
import com.nageoffer.shortlink.admin.remote.ShortLinkRemoteService;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkGroupCountQueryRespDTO;
import com.nageoffer.shortlink.admin.service.GroupService;
import com.nageoffer.shortlink.admin.toolkit.RandomGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import com.nageoffer.shortlink.admin.common.convention.exception.ClientException;
import cn.hutool.core.collection.CollUtil;

import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static com.nageoffer.shortlink.admin.common.constant.RedisCacheConstant.LOCK_GROUP_CREATE_KEY;

/**
 * 短链接分组接口实现层
 */
@Slf4j
@Service
@RequiredArgsConstructor

public class GroupServiceImpl extends ServiceImpl<GroupMapper, GroupDO> implements GroupService {


    private final RedissonClient redissonClient;

    @Value("${short-link.group.max-num}")
    private Integer groupMaxNum;

    ShortLinkRemoteService shortLinkRemoteService = new ShortLinkRemoteService() {
    };

    @Override
    public void saveGroup(String groupName) {
        saveGroup(UserContext.getUsername(), groupName);
    }
    @Override
    public void saveGroup(String username, String groupName) {
        RLock lock = redissonClient.getLock(String.format(LOCK_GROUP_CREATE_KEY, username));
        lock.lock();
        try {
            LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                    .eq(GroupDO::getUsername, username)
                    .eq(GroupDO::getDelFlag, 0);
            List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);
            if (CollUtil.isNotEmpty(groupDOList) && groupDOList.size() == groupMaxNum) {
                throw new ClientException(String.format("已超出最大分组数:%d", groupMaxNum));
            }
        String gid;
        do {
            gid = RandomGenerator.generateRandom();
        } while (!hasGid(username,gid));
        GroupDO groupDO = GroupDO.builder()
                .gid(gid)
                .sortOrder(0)
                .username(username)
                .name(groupName)
                .build();
        baseMapper.insert(groupDO);
        } finally {
            lock.unlock();
        }
    }

    private boolean hasGid(String username,String gid) {
        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getGid, gid)
                .eq(GroupDO::getUsername, Optional.ofNullable(username).orElse(UserContext.getUsername()));
        GroupDO hasGroupFlag = baseMapper.selectOne(queryWrapper);
        return hasGroupFlag == null;
    }

    @Override
    public List<ShortLinkGroupRespDTO> listGroup() {
        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getDelFlag, 0)
                .eq(GroupDO::getUsername, UserContext.getUsername())
                .orderByDesc(GroupDO::getSortOrder, GroupDO::getUpdateTime);
        List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);
        Result<List<ShortLinkGroupCountQueryRespDTO>> listResult = shortLinkRemoteService
                .listGroupShortLinkCount(groupDOList.stream().map(GroupDO::getGid).toList());
        List<ShortLinkGroupRespDTO> shortLinkGroupRespDTOList = BeanUtil.copyToList(groupDOList, ShortLinkGroupRespDTO.class);
        shortLinkGroupRespDTOList.forEach(each -> {
            Optional<ShortLinkGroupCountQueryRespDTO> first = listResult.getData().stream()
                    .filter(item -> Objects.equals(item.getGid(), each.getGid()))
                    .findFirst();
            first.ifPresent(item -> each.setShortLinkCount(first.get().getShortLinkCount()));
        });
        return shortLinkGroupRespDTOList;
    }

    @Override
    public void updateGroup(ShortLinkGroupUpdateReqDTO requestParam) {
        LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class)
                .eq(GroupDO::getUsername, UserContext.getUsername())
                .eq(GroupDO::getGid, requestParam.getGid())
                .eq(GroupDO::getDelFlag, 0);
        GroupDO groupDO = new GroupDO();
        groupDO.setName(requestParam.getName());
        baseMapper.update(groupDO, updateWrapper);
    }

    @Override
    public void deleteGroup(String gid) {
        LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class)
                .eq(GroupDO::getUsername, UserContext.getUsername())
                .eq(GroupDO::getGid, gid)
                .eq(GroupDO::getDelFlag, 0);
        GroupDO groupDO = new GroupDO();
        groupDO.setDelFlag(1);
        baseMapper.update(groupDO, updateWrapper);
    }

    @Override
    public void sortGroup(List<ShortLinkGroupSortReqDTO> requestParam) {
        requestParam.forEach(each -> {
            GroupDO groupDO = GroupDO.builder()
                    .sortOrder(each.getSortOrder())
                    .build();
            LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class)
                    .eq(GroupDO::getUsername, UserContext.getUsername())
                    .eq(GroupDO::getGid, each.getGid())
                    .eq(GroupDO::getDelFlag, 0);
            baseMapper.update(groupDO, updateWrapper);
        });
    }
}

 你从树林深处走出雾:这里为什么需要加锁哇,没太理解。虽然是分布式但当前用户不是只能操作自己的分组么,而且添加分组操作不是只在admin项目中有嘛,是哪里会引起冲突么?

  • 并发场景未考虑 :即使用户只能操作自己的分组,同一用户可能通过多端登录(如手机、电脑)并发 创建分组。例如,两个请求同时检查到当前分组数为19(小于20),均认为可以创建新分组,导致最终创建21个分组,突破限制
  • 分布式锁的必要性 :代码中使用 RedissonClient 的分布式锁(RLock)是为了确保 “检查分组数 + 创建分组” 的操作具有原子性,防止并发导致的超限问题。
  • 单节点锁无法解决分布式问题 :如果仅使用本地锁(如 synchronized),在分布式部署下,不同节点的请求仍可能同时执行检查和创建操作,导致超限。

勿进草 回复 你从树林深处走出雾:这是为了防止同一用户在不同线程(可能同一用户在多个设备)同时新增分组,而超出最大分组数吧。

马丁 回复 勿进草:正解

    • 并发场景下的超限风险 (勿进草、马丁):
      同一用户在不同设备(多端登录)或同一设备的多个请求(如浏览器多标签页)同时触发分组创建时,未加锁会导致并发超限。
    • 多端登录的背景 (TheTurtle):
      前文提到用户允许多端登录(共享同一Token),因此同一账号可能被多个设备同时操作,进一步加剧并发风险。
  • 补充说明
    • 分布式锁的作用 :确保整个操作(查询当前分组数 + 判断是否超限 + 插入新分组)在分布式环境下串行化执行 ,避免多个请求同时通过检查条件。
    • 锁的粒度 :代码中锁的 Key 是按用户绑定的(LOCK_GROUP_CREATE_KEY = "short-link:lock_group-create:%s"),即用户级别锁 ,仅阻塞同一用户的并发请求,不影响其他用户的操作。

TheTurtle 回复 你从树林深处走出雾:而且 Saas 应用没有开发注册接口,大家登的都是同一个号,之前https://t.zsxq.com/19ZbImhrA中还特地允许了多端登录,因此不同使用者(但他们是同一个号,同一个用户)很可能在同时添加分组。

小凡同学 回复 TheTurtle:多端用户应该是同一个用户不同设备吧 不同用户咋会用一个账号TheTurtle:agree,我错了

  1. 并发超限的模拟
    假设当前用户已有19个分组,两个并发请求同时执行以下步骤:
    • 请求A :查询到 groupDOList.size()=19 → 允许创建。
    • 请求B :查询到 groupDOList.size()=19 → 允许创建。
    • 结果 :两个请求均插入新分组,总数变为21,突破限制。
      加锁后,两个请求会串行执行,第二个请求在检查时会发现分组数已变为20,从而抛出异常。
  2. 锁的粒度设计
    • 用户级别锁 LOCK_GROUP_CREATE_KEY = "short-link:lock_group-create:%s")合理:
      • 避免全局锁(如 short-link:lock_group-create)导致所有用户的分组操作串行化,影响性能。
      • 仅针对同一用户的并发请求加锁,其他用户的操作不受影响。
  3. 替代方案的局限性
    • 数据库唯一约束 :无法直接限制分组数量(需结合触发器或额外表,复杂度高)。
    • CAS(Compare and Set) :需在插入分组时检查数量,但实现复杂且可能引发多次重试。

- 第09节:短链接验证布隆过滤器域名冲突

  • 原代码通过布隆过滤器判断短链接是否存在时,使用了requestParam.getDomain()获取域名
  • 但当前前端已不再传递domain参数,导致生成短链接时可能出现重复或异常

现在将布隆过滤器校验的域名从requestParam.getDomain()改为createShortLinkDefaultDomain(配置文件指定的默认域名);保证即使前端不传domain参数,仍能通过默认域名正确判断短链接是否存在;保留原有的时间戳拼接逻辑,防止相同URL瞬间重复生成

 ShortLinkServicelmpl.java修改

private String generateSuffix(ShortLinkCreateReqDTO requestParam) {

    /*去重试,先给它get一个原始的一个链接,然后我们要定义一个自定义生成次数*/
    int customGenerateCount = 0;
    String shortUri;
    while (true) {
        if (customGenerateCount > 10) {
            throw new ServiceException("短链接频繁生成,请稍后再试");
        }
        String originUrl = requestParam.getOriginUrl();
        originUrl += System.currentTimeMillis();
        shortUri = HashUtil.hashToBase62(originUrl);
        //原为if (!shortUriCreateCachePenetrationBloomFilter.contains(requestParam.getDomain() + "/" + shortUri)) {
        if (!shortUriCreateCachePenetrationBloomFilter.contains(createShortLinkDefaultDomain + "/" + shortUri)) {

            break;
        }
        customGenerateCount++;

    }
    return shortUri;
}

- 第10节:公网环境部署系统如何做流量风控(上)

针对短链接后台系统的访问,根据登录用户做频率限制。例如:每秒内单个用户的请求次数不超过指定阈值。实现原理如下使用Lua脚本保证INCREXPIRE的原子性

用户上下文获取依赖前置的UserContextFilter将用户名存入上下文;默认值设为"other"处理匿名访问场景

使用setOrder(10)预留扩展空间(后续可插入5/15等中间值)过滤器顺序保证在业务逻辑前执行

确保在用户信息过滤器之后执行,能正确获取用户上下文;未登录用户统一归为"other"标识,防止空指针异常

动态配置管理 :通过@ConditionalOnProperty控制功能开关;后续可通过动态配置中心实现运行时参数调整

Redis异常捕获后返回统一错误码A000300

1秒内连续发送20次请求 → 成功;第21次请求 → 返回"当前系统繁忙,请稍后再试";Redis服务异常 → 记录错误日志并拒绝请求;配置关闭(enable: false) → 不触发限流逻辑

在(admin\common\biz\user新建UserFlowRiskControlFilter

package com.nageoffer.shortlink.admin.common.biz.user;

import com.alibaba.fastjson2.JSON;
import com.google.common.collect.Lists;
import com.nageoffer.shortlink.admin.common.convention.exception.ClientException;
import com.nageoffer.shortlink.admin.common.convention.result.Results;
import com.nageoffer.shortlink.admin.config.UserFlowRiskControlConfiguration;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Optional;

import static com.nageoffer.shortlink.admin.common.convention.errorCode.BaseErrorCode.FLOW_LIMIT_ERROR;

/**
 * 用户操作流量风控过滤器
 */
@Slf4j
@RequiredArgsConstructor
public class UserFlowRiskControlFilter implements Filter {

    private final StringRedisTemplate stringRedisTemplate;
    private final UserFlowRiskControlConfiguration userFlowRiskControlConfiguration;

    private static final String USER_FLOW_RISK_CONTROL_LUA_SCRIPT_PATH = "lua/user_flow_risk_control.lua";

    @SneakyThrows
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(USER_FLOW_RISK_CONTROL_LUA_SCRIPT_PATH)));
        redisScript.setResultType(Long.class);
        String username = Optional.ofNullable(UserContext.getUsername()).orElse("other");
        Long result = null;
        try {
            result = stringRedisTemplate.execute(redisScript, Lists.newArrayList(username), userFlowRiskControlConfiguration.getTimeWindow());
        } catch (Throwable ex) {
            log.error("执行用户请求流量限制LUA脚本出错", ex);
            returnJson((HttpServletResponse) response, JSON.toJSONString(Results.failure(new ClientException(FLOW_LIMIT_ERROR))));
        }
        if (result == null || result > userFlowRiskControlConfiguration.getMaxAccessCount()) {
            returnJson((HttpServletResponse) response, JSON.toJSONString(Results.failure(new ClientException(FLOW_LIMIT_ERROR))));
        }
        filterChain.doFilter(request, response);
    }

    private void returnJson(HttpServletResponse response, String json) throws Exception {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try (PrintWriter writer = response.getWriter()) {
            writer.print(json);
        }
    }
}

ladmin\common\convention\errorcode里面BaseErrorCode.java添加错误码

// ========== 二级宏观错误码 系统请求操作频繁 ==========
FLOW_LIMIT_ERROR("A000300", "当前系统繁忙,请稍后再试"),

UserConfiguration.java

/**
 * 用户操作流量风控过滤器
 */
@Bean
@ConditionalOnProperty(name = "short-link.flow-limit.enable", havingValue = "true")
public FilterRegistrationBean<UserFlowRiskControlFilter> globalUserFlowRiskControlFilter(
        StringRedisTemplate stringRedisTemplate,
        UserFlowRiskControlConfiguration userFlowRiskControlConfiguration) {
    FilterRegistrationBean<UserFlowRiskControlFilter> registration = new FilterRegistrationBean<>();
    registration.setFilter(new UserFlowRiskControlFilter(stringRedisTemplate, userFlowRiskControlConfiguration));
    registration.addUrlPatterns("/*");
    registration.setOrder(10);
    return registration;
}

admin\config里面新建 UserFlowRiskControlConfiguration.java

package com.nageoffer.shortlink.admin.config;


import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 用户操作流量风控配置文件
 */
@Data
@Component
@ConfigurationProperties(prefix = "short-link.flow-limit")
public class UserFlowRiskControlConfiguration {

    /**
     * 是否开启用户流量风控验证
     */
    private Boolean enable;

    /**
     * 流量风控时间窗口,单位:秒
     */
    private String timeWindow;

    /**
     * 流量风控时间窗口内可访问次数
     */
    private Long maxAccessCount;
}

application.yaml补充

flow-limit:
  enable: true
  time-window: 1
  max-access-count: 20

短链接后管

根据登录用户做出控制,比如 x 秒请求后管系统的频率最多 x 次。

实现原理也比较简单,通过 Redis increment 命令对一个数据进行递增,如果超过 x 次就会返回失败。这里有个细节就是我们的这个周期是 x 秒,需要对 Redis 的 Key 设置 x 秒有效期。

但是 Redis 中对于 increment 命令是没有提供过期命令的,这就需要两步操作,进而出现原子性问题。

为此,我们需要通过 LUA 脚本来保证原子性。

-- 设置用户访问频率限制的参数
local username = KEYS[1]
local timeWindow = tonumber(ARGV[1]) -- 时间窗口,单位:秒

-- 构造 Redis 中存储用户访问次数的键名
local accessKey = "short-link:user-flow-risk-control:" .. username

-- 原子递增访问次数,并获取递增后的值
local currentAccessCount = redis.call("INCR", accessKey)

-- 设置键的过期时间
redis.call("EXPIRE", accessKey, timeWindow)

-- 返回当前访问次数
return currentAccessCount

1. 返回错误数据后忘记加 return 了,我的锅。这个代码里提交了两个返回关键字:return登录 - Gitee.com

在 Servlet 中,一旦调用 response.getWriter() 并写入响应体,响应即被提交(committed)。后续若调用 filterChain.doFilter,会抛出 IllegalStateException(因为响应已提交)。
虽然框架会阻止后续执行,但显式 return 更清晰,避免潜在问题(如日志记录、调试混乱)。补充 return 提升了可维护性。

2. 王小明:这里拿不到UserContext中的username就用others,岂不是多个用户注册的时候就被限流了?

一切随缘 回复 王小明:赞同

给趣多多巧克力豆 回复 王小明:感觉没办法避免这个问题,如果对这个接口开放的话,可能坏人也会逮着这个接口不停请求

B1n_ 回复 王小明:我们不是屏蔽了用户注册接口了吗

早睡早起身体好 回复 王小明:屏蔽用户接口了

🎃 🎃 🎃 回复 B1n_:在哪屏蔽的呀 配置注册器时也没看到屏蔽注册路径啊

神经蛙 回复 B1n_:没有只是第一个过滤器进行了放行,楼主说的第二个过滤器并没有进行放行,你可以进行调试一下

神经蛙 回复 王小明:或许other后面拼接一个uuid?

  • 若系统允许匿名访问(如未登录用户可创建短链接),所有匿名用户的请求会被视为同一用户("other"),导致限流阈值被共享占用。
  • 例如:20个匿名用户并发请求,每个用户请求1次,"other" 键的计数器达到20,后续所有匿名用户均被限流。若需区分匿名用户,可用 IP 地址或设备指纹生成唯一标识(如 other_${ip})。但若业务已禁止匿名访问(如强制登录),此问题不存在。
  • 结论 :王小明指出潜在设计缺陷,回复中 B1n_早睡早起身体好 的“屏蔽注册接口”说法不成立(匿名访问≠注册接口),需明确业务场景

3. :这个lua脚本里面每次访问都更新过期时间,如果每次都在即将过期的时候访问了刷新了过期时间,一直递增会报错吧。改成如果存在key不刷新时间,不存在key新增并且设置过期时间是不是更合理

--设置用户访问频率限制的参数
local username = KEYS[1]
local timeWindow =tonumber(ARGV[1])--时间窗口,单位:秒

--构造Redis中存储用户访问次数的键名
local accessKey = "short-link:user-flow-risk-control:" .. Username

--原子递增访问次数,并获取递增后的值
local currentAccessCount = redis.call("INcR", accessKey)

--设置键的过期时间
redis.call("EXPIRE", accessKey, timeWindow)

--返回当前访问次数
return currentAccessCount

很对,后面会重构;有问题的。【一种边界情况】假设过期时间是1s,限制1s内只能访问20次。如果在即将过期的时候刷新了过期时间,即又获得了1s,那么会继续在之前计数值的基础上递增,假设之前递增到19了,那么之后的1s内的请求全部都会被过滤掉,因为这个key被续时长了,但其实新的1s应该重新计数的。

完全正确 

4. 

改过的lua脚本  好!

local username = KEYS[1]
local timeWindow = tonumber(ARGV[1]) -- 时间窗口,单位:秒
-- 构造 Redis 中存储用户访问次数的键名
local accessKey = "short-link:user-flow-risk-control:" .. username
-- 检查键是否存在
local keyExists = redis.call("EXISTS", accessKey)
-- 若键已存在,原子递增访问次数并返回递增后的值
if keyExists == 1 then
    local currentAccessCount = redis.call("INCR", accessKey)
return currentAccessCount
else    -- 若键不存在,创建键并设置过期时间,然后原子递增访问次数并返回递增后的值
    redis.call("SET", accessKey, 1, "EX", timeWindow)
    local currentAccessCount = redis.call("INCR", accessKey)
    return currentAccessCount
end

根据我的最新重构逻辑,要比这个简单一些。重点在于,如果返回自增为 1就证明是新的数据,如下所示,供参考: -- 原子递增访问次数,并获取递增后的值 local currentAccessCount = redis.call("INCR", accessKey) -- 设置键的过期时间 if currentAccessCount == 1 then redis.call("EXPIRE", accessKey, timeWindow) end

redis官方也提供了几种方案:INCR | Docs

实际上方案二的EXISTS + INCR + SET 组合逻辑复杂,且 SETINCR 之间存在竞争条件(可能导致计数错误)还是开始那个“ 改过的lua脚本” 好

5..有点像限流,可以用zset滑动窗口的方法进行限流吗

  • ZSet 可存储每个请求的时间戳,通过移除过期时间戳实现精确限流(如“每秒最多20次”严格按时间窗口计算)。适用于对限流精度要求高的场景,但需评估性能开销

- 第11节:公网环境部署系统如何做流量风控(下)

根据接口进行流控,比如同一接口最大接受 20 QPS。

Sentinel 官网地址,其中的微服务生态图还不错。home | Sentinel

Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量控制、流量路由、熔断降级、系统自适应保护等多个维度来帮助用户保障微服务的稳定性。
简单的一句话就是当你的服务或者你的某个接口它们统一叫做资源;一个上限值,如果超过上限它可以通过干什么?给你把要么可以直接把它掐断掉,直接快速返回失败要么可以给你降级比如说你之前对吧?是需要调用多个服务去组装你的数据,但是如果说kps超了之后,你的系统的负载量对吧?会比较拉高,这个情况下我们就可以干什么?给他直接返回一个静态的数据,或者说一个没那么准确的数据直接反馈出去,这样的话其实你的系统负载就会降到很低

看一下它这里画一张图我觉得挺考究的,就是从我们的IOT PC MOBILE对吧?也就是 lt好像是和哪个概念有关的,我有点忘了,但是pc就是我们的网页端还有手机,然后通过访问,然后访问的第一关先过网关,这个网关可以是springcloud,也可以是nginx,也可以是任何的网关产品,但是这里面是阿里自己开展的higress然后我们继续然后到这边的话是到springcloud或者说dubbo一个集群,然后这是一个ab集群对吧?可能比如说是商品服务,可能是订单服务等等,然后异步服务的应rabbitMQ,然后这边数据可能这个东西然后是mysql,PG等东西,然后分布式服务是Seata,然后这边的话是sentinel系统也就是起到比较重要的作用,混沌工程chaosblade放在这里边的一个过程大家可以去了解一下,相当于比如说你有10个服务在正常的运行,然后框对吧?启动这样的服务或者什么的模拟直接把两个服务给干掉或者说模拟的网速限制等等都可以通过去做,相当于自己给自己找事情来模拟正常情况,然后避免你再遇到这种情况的时候手足无措,或者说通过这种异常的情况来说,你们系统稳定性对吧?能不能快速的回滚,或者说快速的把问题修复掉等等,然后是什么我看一下。我还没太了解过大家有兴趣可以自己;然后这边的话就是一个交互软件kubevela,可以相当于是类似于什么 csd的 k8S,然后这边的话可观测skywalking,然后拉克斯就是我们的注册中心和配套中心,然后 opensergo这个东西怎么说听一直在听说过它,但是好像用处不是很大,说实话我个人没用过,你看他的star也不高,应该是推广力度不足,或者说只是一种的就没有太多落。然后当我们看完这个之后重点想说的是什么?就是我们的根据阿里的这样一套、平台以及这些中间件来组建了我们一整套微服务这种生态架构图,然后我们自己的我们的项目,比如说13606稍微课,我们这个系统都在沿用这样的设计,然后省领导在这里面是起到至关重要的。

我们简单说一下,在你系统没有问题,没有遇到挑战的时候,他默默无闻,但是一旦遇到问题,他就像一个勇士一样哐对吧?把敌人要攻进大门的什么把那个桥给砍断对吧?让敌人来不了当然也出不去对不对?

引入 Sentinel定义接口规则定义需要风控接口的规则。

1. 引入 Sentinel

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-annotation-aspectj</artifactId>
</dependency>

2. 定义接口规则

定义需要风控接口的规则

package com.nageoffer.shortlink.project.config;

import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
 * 初始化限流配置
 * 公众号:马丁玩编程,回复:加群,添加马哥微信(备注:link)获取项目资料
 */
@Component
public class SentinelRuleConfig implements InitializingBean {

    @Override
    public void afterPropertiesSet() throws Exception {
        List<FlowRule> rules = new ArrayList<>();
        FlowRule createOrderRule = new FlowRule();
        createOrderRule.setResource("create_short-link");
        createOrderRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        createOrderRule.setCount(1);
        rules.add(createOrderRule);
        FlowRuleManager.loadRules(rules);
    }
}

如果触发风控,设置降级策略。

通过Spring Boot自动装配机制加载Sentinel组件;在Spring启动完成后(InitializingBean)加载流控规则:

@SentinelResource标注需要保护的资源;value对应规则定义的resource名称;blockHandler指定降级方法

当QPS超过设定值(如1次/秒)Sentinel自动触发降级,调用CustomBlockHandler方法;返回友好提示而非直接报错

package com.nageoffer.shortlink.project.handler;

import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.nageoffer.shortlink.project.common.convention.result.Result;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;

/**
 * 自定义流控策略
 * 公众号:马丁玩编程,回复:加群,添加马哥微信(备注:link)获取项目资料
 */
public class CustomBlockHandler {

    public static Result<ShortLinkCreateRespDTO> createShortLinkBlockHandlerMethod(ShortLinkCreateReqDTO requestParam, BlockException exception) {
        return new Result<ShortLinkCreateRespDTO>().setCode("B100000").setMessage("当前访问网站人数过多,请稍后再试...");
    }
}

 在代码中引入 Sentinel 注解控制流控规则。

/**
 * 创建短链接
 */
@PostMapping("/api/short-link/v1/create")
@SentinelResource(
        value = "create_short-link",
        blockHandler = "createShortLinkBlockHandlerMethod",
        blockHandlerClass = CustomBlockHandler.class
)
public Result<ShortLinkCreateRespDTO> createShortLink(@RequestBody ShortLinkCreateReqDTO requestParam) {
    return Results.success(shortLinkService.createShortLink(requestParam));
}

接下来压测,用jmx文件压测,由于文件太长了(5KB,就不写出来;记得在HTTP请求里面把gid换掉;

 "code":"B000001","message":"系统执行出错","data":null,"requestId":null,"success":false

 也有那么两个成功的nurl.ink:8001/ks1aD ;nurl.ink:8001/ElDFO;然后就爆了

 我看到idea里面是100请求一共六个成功的,前五个都是nurl.ink:8001/ks1aD;第六是ElDFO,;为什么呢?

3. 微服务版本 Sentinel 如何接入?

Sentinel 控制台说明。dashboard | Sentinel

启动 Sentinel 控制台,删除 Sentinel 定义的相关规则代码,加入以下配置即可。

删除的规则配置,在 Sentinel 中进行配置。

spring:
    sentinel:
      transport:
        dashboard: localhost:8686
        port: 8719

4. 压测脚本 

“other” 作为兜底的用户名去进行访问统计,是不是会对原本用户名叫“other”的用户造成影响呢?这个是我临时起的,我们可以在用户名创建的时候,默认初始化一个 other 系统用户,或者就不允许创建 other 就好

数据库记得 加事务

 看到过一种解决方案,就是将用户访问次数用 redis int64的最大值减去用户可以使用的次数。接着用户每次访问进行自增,当超过访问次数,redis中存储的value也会发生数值溢出,抛错。接着捕获错误,抛出“访问过快,系统繁忙”的错误就可以了

- 第12节:消息队列重构短链接监控功能

我们之前使用读写锁,是为了防止在改变短链接分组的时候用户访问短链接。如果短链接正在修改分组,这时有用户正在访问短链接,统计监控相关的分组还是之前的数据,就涉及到无法正确统计监控数据问题,比如在统计监控代码执行之前,另一个用户已经修改了gid,此时统计数据如果拿着之前的gid来查询的话,就查不到,那就没办法统计最新的记录了。 我们之前引入延迟队列的原因是因为,如果用户正在修改短链接,那其他用户执行访问短链接restoreUrl的时候会调用shortLinkStats获取读锁,其他用户不能访问肯定是不合理的,如果我们在shortLinkStats中获取锁的时候是死等直到获取读锁,那如果很多的用户来访问就会开启很多的线程,如果这些用户都在死等,那么我们的应用就无法再接收新的请求。那如果获取不到读锁就返回就不处理了,那就没办法统计了,此时就引入延迟队列。 而现在代码改造之后,当用户访问短链接的话,执行的是改造之后的ShortLinkServiceImpl中的shortLinkStats方法,该方法是往RedisStream中发送消息的。而此时消费者消费的时候,是先获取到消息,然后执行actualSaveShortLinkStats方法,该方法还是先获取读写锁。如果此时有用户正在修改短链接,那么消息就会被发送到延迟队列,而延迟队列中的消息会通过执行ShortLinkServiceImpl中的shortLinkStats方法把消息发送到RedisStream中,进而被异步修改。如果此时没有用户在修改短链接,那么访问短链接都是获取读锁,也就没有读写锁竞争,因此消息都会被直接发送到RedisStream中,进而被异步修改。因此,最终消息都会被成功消费,后续再来处理幂等性问题。 

 我们之前的短链接监控那里是直接通过访问数据库的形式,在他就是在用户去访问短链接,我们将这个短链接的监控信息存储sql里面;我们看一下这个流程
 

海量访问短链接,直接访问数据库,会导致数据库负载变高,甚至数据库宕机。为此,需要引入消息队列削峰。

有一些问题,它有一些比如说我们在短链接访问过程当中,由于用户的请求量较大,然后导致流量的暴增,这个时候短链接中台这边访问,短链接请求的转这种大量的访问流量的时候对吧?mysql可能他无法承受这种海量的调用量,这种情况下它就会导致最坏的情况会导致mysql崩溃对吧?我们为了解决这个问题,我们要在mysql和应用中间加一个消息队列,消息队列我相信大家都已经比较熟悉了,对吧?比如说一些常见的场景异步对吧?解耦以及削峰对不对?基于这些的话,我们这里就不再过多的赘述,然后我给大家如果想了解消息队列,使用我们这个网站里面

消息队列使用场景:从零到一学习中间件之RocketMQ | 拿个offer-开源&项目实战

1. 为什么使用 Redis 充当消息队列?

轻量级

解决这个问题之前要跟大家说的是我们短链接主打的就是轻量级对不对?我不希望就是在部署大家在部署短信的过程当中对吧?由于要部署rabbitmq,rocketmq等一系列的这种就是占用资源较大的组件的时候造成一些部署问题,本着能不多引用组件就不多引用组件的原则我们直接使用来意思 ready斯,当消息队列在网上的一些八股文比较多,对吧?相信大家在网上或多或少也都能看到一些有三种方式在原地层面实现消息队列

2. Redis 实现消息队列的几种方式?

List

PubSub

Stream

使用 Redis 充当消息队列参考文章:Redis消息队列发展历程 

想有一个深入的了解,这里面阿里巴巴这边就写了篇文章,我个人觉得写的还蛮好的,大家可以去看一下。 

3. 使用 Redis 消息队列后逻辑

然后在reids里面我们首先要执行 xadd的,相当于是去创建一个对吧? 我们的可以叫什么李克斯特,然后spring然后因为它默认是要创建一些key value ,所以说成了一个new key 和new value,应该是无意义的,然后这个房子是个消息,然后我就消费者组。 Ok现在创建完了之后,我们开始去代码里面去创建对应的一系列的流程

创建 Redis Stream Key 相关配置

1. 创建 Stream Key

XADD "short_link:stats-stream" * "New key" "New value"

2. 创建消费者组

xgroup create short_link:stats-stream short_link:stats-stream:only-group 0

首先我们肯定是要创建出来对应的redis stream这么一个config类,然后这里面有两个配置,首先第一个就是redis开始三个月相当于意思连接工厂,然后直接点就行了,然后这个的话是我们的原则,消费者需要去绑定,ok先把它引进来,然后我们在刚才不是创建了一个topic了,其实就是使用的K,我们可以理解它是一个topic给它引进来,然后还有这两个写在哪里,我给大家看一下,在我们的application.yaml配置文件里面,相当于我在类似的目录下面又加了一个channel-topic,然后这个是它的topic,这个是它的一个group,现在我们在这里写好了。

data:
  redis:
    host: 192.168.111.130
    password: 123321
    port: 6379
    channel-topic:
      short-link-stats: 'short_link:stats-stream'
      short-link-stats-group: 'short_link:stats-stream:only-group'
package com.nageoffer.shortlink.project.config;

import com.nageoffer.shortlink.project.mq.consumer.ShortLinkStatsSaveConsumer;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.Consumer;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.ReadOffset;
import org.springframework.data.redis.connection.stream.StreamOffset;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;

import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Redis Stream 消息队列配置
 */
@Configuration
@RequiredArgsConstructor
public class RedisStreamConfiguration {

    private final RedisConnectionFactory redisConnectionFactory;
    private final ShortLinkStatsSaveConsumer shortLinkStatsSaveConsumer;

    @Value("${spring.data.redis.channel-topic.short-link-stats}")
    private String topic;
    @Value("${spring.data.redis.channel-topic.short-link-stats-group}")
    private String group;

    @Bean
    public ExecutorService asyncStreamConsumer() {
        AtomicInteger index = new AtomicInteger();
        int processors = Runtime.getRuntime().availableProcessors();
        return new ThreadPoolExecutor(processors,
                processors + processors >> 1,
                60,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(),
                runnable -> {
                    Thread thread = new Thread(runnable);
                    thread.setName("stream_consumer_short-link_stats_" + index.incrementAndGet());
                    thread.setDaemon(true);
                    return thread;
                }
        );
    }

    @Bean(initMethod = "start", destroyMethod = "stop")
    public StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamMessageListenerContainer(ExecutorService asyncStreamConsumer) {
        StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options =
                StreamMessageListenerContainer.StreamMessageListenerContainerOptions
                        .builder()
                        // 一次最多获取多少条消息
                        .batchSize(10)
                        // 执行从 Stream 拉取到消息的任务流程
                        .executor(asyncStreamConsumer)
                        // 如果没有拉取到消息,需要阻塞的时间。不能大于 ${spring.data.redis.timeout},否则会超时
                        .pollTimeout(Duration.ofSeconds(3))
                        .build();
        StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamMessageListenerContainer =
                StreamMessageListenerContainer.create(redisConnectionFactory, options);
        streamMessageListenerContainer.receiveAutoAck(Consumer.from(group, "stats-consumer"),
                StreamOffset.create(topic, ReadOffset.lastConsumed()), shortLinkStatsSaveConsumer);
        return streamMessageListenerContainer;
    }
}

然后这边创建了线程池,这个线程池是用来做什么的,是因为我们消息的消费者也是通过一个线程池去执行的,如果说你不在这里指定它就会用默认的与其默认的对吧?不如由我们自己来管控对吧?出现问题什么的还好分析一点,我们这里面正常的就是这个是指的是获取当前机器的CPU和数,然后最大线程的话我们用了一个位移相当于比如现在是向右移位,相当于是除以2对吧? 然后我们假如说我们是一个四核的机器,相当于4+2=6最大线程数,然后下面参数就不一一解析了,就是:较常见。

然后这边是一个比较重要的点,它的监听的绑定首先我们创建监听的时候一定要先给他指定一些配置,首选第一我们一次性里面去获取消息的时候要一次拿多少条,我们这里面设的是10对吧? 批量1个消息对吧?然后第二我们这里面就是执行这个消息拉到我们的内存之后,由哪个执行者去执行如果说,我们不绑定我们的对吧?那么它就是默认的,为此我们肯定是本着绑定的原则对吧?把所有的流程控制在自己能够把控的地方,对不对? 然后第三个就是如果说,我们使用里面没有消息了,这个时候我们的客户端不能一直去找他去拿去对不对?所以说我们要给他让他类似sleep一段时间。是不能大于它下面就是redis有一个全局的开放的时间的,否则的话会超时。再接下来的话就是去给它创建对应的绑定监听,然后相当于是把我们的通过连接工厂以及我们对配置创建出来,然后通过配置去创建对应的去绑定对应的监听就是监听者、也是,我们消消费者,也就是这是我们自己写的认可对吧? 就是监控保存卡数码消费者。Ok配置这一块的话大概就这些

shortlinkIpml里面要修改的那个类: 

   @Override
    public void shortLinkStats(String fullShortUrl, String gid, ShortLinkStatsRecordDTO statsRecord) {
        Map<String, String> producerMap = new HashMap<>();
        producerMap.put("fullShortUrl", fullShortUrl);
        producerMap.put("gid", gid);
        producerMap.put("statsRecord", JSON.toJSONString(statsRecord));
        shortLinkStatsSaveProducer.send(producerMap);
    }

shortlinkimpl的 现在变成相当于它就是把之前的那些逻辑全部封装到我们的哪里去了,封装到我们的消息消费者里面去了,现在只有一个发送流程。

package com.nageoffer.shortlink.project.mq.producer;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Map;

/**
 * 短链接监控状态保存消息队列生产者
 */
@Component
@RequiredArgsConstructor
public class ShortLinkStatsSaveProducer {

    private final StringRedisTemplate stringRedisTemplate;

    @Value("${spring.data.redis.channel-topic.short-link-stats}")
    private String topic;

    /**
     * 发送延迟消费短链接统计
     */
    public void send(Map<String, String> producerMap) {
        stringRedisTemplate.opsForStream().add(topic, producerMap);
    }
}

然后这个类也是我们新建的去做一个新的操作,非常简单,我们直接是stream,然后有一个opsForStream直接add就可以了,我们往哪个topic里面去发,然后然后我们发了之后他是不是就到哪了,他是不是就到消费者能监听到的对不对?Ok看到没有?把这很大一坨给他引进来了。然后消息消费者大家如果说用过,你像recketmq那种比较熟悉的话对吧?

package com.nageoffer.shortlink.project.mq.consumer;


import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.Week;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.nageoffer.shortlink.project.dao.entiry.LinkAccessLogsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkAccessStatsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkBrowserStatsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkDeviceStatsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkLocaleStatsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkNetworkStatsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkOsStatsDO;
import com.nageoffer.shortlink.project.dao.entiry.LinkStatsTodayDO;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkGotoDO;
import com.nageoffer.shortlink.project.dao.mapper.LinkAccessLogsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkAccessStatsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkBrowserStatsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkDeviceStatsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkLocaleStatsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkNetworkStatsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkOsStatsMapper;
import com.nageoffer.shortlink.project.dao.mapper.LinkStatsTodayMapper;
import com.nageoffer.shortlink.project.dao.mapper.ShortLinkGotoMapper;
import com.nageoffer.shortlink.project.dao.mapper.ShortLinkMapper;
import com.nageoffer.shortlink.project.dto.biz.ShortLinkStatsRecordDTO;
import com.nageoffer.shortlink.project.mq.producer.DelayShortLinkStatsProducer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.stream.StreamListener;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

import static com.nageoffer.shortlink.project.common.constant.RedisKeyConstant.LOCK_GID_UPDATE_KEY;
import static com.nageoffer.shortlink.project.common.constant.ShortLinkConstant.AMAP_REMOTE_URL;

/**
 * 短链接监控状态保存消息队列消费者
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ShortLinkStatsSaveConsumer implements StreamListener<String, MapRecord<String, String, String>> {

    private final ShortLinkMapper shortLinkMapper;
    private final ShortLinkGotoMapper shortLinkGotoMapper;
    private final RedissonClient redissonClient;
    private final LinkAccessStatsMapper linkAccessStatsMapper;
    private final LinkLocaleStatsMapper linkLocaleStatsMapper;
    private final LinkOsStatsMapper linkOsStatsMapper;
    private final LinkBrowserStatsMapper linkBrowserStatsMapper;
    private final LinkAccessLogsMapper linkAccessLogsMapper;
    private final LinkDeviceStatsMapper linkDeviceStatsMapper;
    private final LinkNetworkStatsMapper linkNetworkStatsMapper;
    private final LinkStatsTodayMapper linkStatsTodayMapper;
    private final DelayShortLinkStatsProducer delayShortLinkStatsProducer;
    private final StringRedisTemplate stringRedisTemplate;

    @Value("${short-link.stats.locale.amap-key}")
    private String statsLocaleAmapKey;

    @Override
    public void onMessage(MapRecord<String, String, String> message) {
        String stream = message.getStream();
        RecordId id = message.getId();
        Map<String, String> producerMap = message.getValue();
        String fullShortUrl = producerMap.get("fullShortUrl");
        if (StrUtil.isNotBlank(fullShortUrl)) {
            String gid = producerMap.get("gid");
            ShortLinkStatsRecordDTO statsRecord = JSON.parseObject(producerMap.get("statsRecord"), ShortLinkStatsRecordDTO.class);
            actualSaveShortLinkStats(fullShortUrl, gid, statsRecord);
        }
        stringRedisTemplate.opsForStream().delete(Objects.requireNonNull(stream), id.getValue());
    }

    public void actualSaveShortLinkStats(String fullShortUrl, String gid, ShortLinkStatsRecordDTO statsRecord) {
        fullShortUrl = Optional.ofNullable(fullShortUrl).orElse(statsRecord.getFullShortUrl());
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(String.format(LOCK_GID_UPDATE_KEY, fullShortUrl));
        RLock rLock = readWriteLock.readLock();
        if (!rLock.tryLock()) {
            delayShortLinkStatsProducer.send(statsRecord);
            return;
        }
        try {
            if (StrUtil.isBlank(gid)) {
                LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
                        .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
                ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
                gid = shortLinkGotoDO.getGid();
            }
            int hour = DateUtil.hour(new Date(), true);
            Week week = DateUtil.dayOfWeekEnum(new Date());
            int weekValue = week.getIso8601Value();
            LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
                    .pv(1)
                    .uv(statsRecord.getUvFirstFlag() ? 1 : 0)
                    .uip(statsRecord.getUipFirstFlag() ? 1 : 0)
                    .hour(hour)
                    .weekday(weekValue)
                    .fullShortUrl(fullShortUrl)
                    .gid(gid)
                    .date(new Date())
                    .build();
            linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);
            Map<String, Object> localeParamMap = new HashMap<>();
            localeParamMap.put("key", statsLocaleAmapKey);
            localeParamMap.put("ip", statsRecord.getRemoteAddr());
            String localeResultStr = HttpUtil.get(AMAP_REMOTE_URL, localeParamMap);
            JSONObject localeResultObj = JSON.parseObject(localeResultStr);
            String infoCode = localeResultObj.getString("infocode");
            String actualProvince = "未知";
            String actualCity = "未知";
            if (StrUtil.isNotBlank(infoCode) && StrUtil.equals(infoCode, "10000")) {
                String province = localeResultObj.getString("province");
                boolean unknownFlag = StrUtil.equals(province, "[]");
                LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
                        .province(actualProvince = unknownFlag ? actualProvince : province)
                        .city(actualCity = unknownFlag ? actualCity : localeResultObj.getString("city"))
                        .adcode(unknownFlag ? "未知" : localeResultObj.getString("adcode"))
                        .cnt(1)
                        .fullShortUrl(fullShortUrl)
                        .country("中国")
                        .gid(gid)
                        .date(new Date())
                        .build();
                linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO);
            }
            LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
                    .os(statsRecord.getOs())
                    .cnt(1)
                    .gid(gid)
                    .fullShortUrl(fullShortUrl)
                    .date(new Date())
                    .build();
            linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);
            LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
                    .browser(statsRecord.getBrowser())
                    .cnt(1)
                    .gid(gid)
                    .fullShortUrl(fullShortUrl)
                    .date(new Date())
                    .build();
            linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);
            LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
                    .device(statsRecord.getDevice())
                    .cnt(1)
                    .gid(gid)
                    .fullShortUrl(fullShortUrl)
                    .date(new Date())
                    .build();
            linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO);
            LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder()
                    .network(statsRecord.getNetwork())
                    .cnt(1)
                    .gid(gid)
                    .fullShortUrl(fullShortUrl)
                    .date(new Date())
                    .build();
            linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO);
            LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
                    .user(statsRecord.getUv())
                    .ip(statsRecord.getRemoteAddr())
                    .browser(statsRecord.getBrowser())
                    .os(statsRecord.getOs())
                    .network(statsRecord.getNetwork())
                    .device(statsRecord.getDevice())
                    .locale(StrUtil.join("-", "中国", actualProvince, actualCity))
                    .gid(gid)
                    .fullShortUrl(fullShortUrl)
                    .build();
            linkAccessLogsMapper.insert(linkAccessLogsDO);
            shortLinkMapper.incrementStats(gid, fullShortUrl, 1, statsRecord.getUvFirstFlag() ? 1 : 0, statsRecord.getUipFirstFlag() ? 1 : 0);
            LinkStatsTodayDO linkStatsTodayDO = LinkStatsTodayDO.builder()
                    .todayPv(1)
                    .todayUv(statsRecord.getUvFirstFlag() ? 1 : 0)
                    .todayUip(statsRecord.getUipFirstFlag() ? 1 : 0)
                    .gid(gid)
                    .fullShortUrl(fullShortUrl)
                    .date(new Date())
                    .build();
            linkStatsTodayMapper.shortLinkTodayState(linkStatsTodayDO);
        } catch (Throwable ex) {
            log.error("短链接访问量统计异常", ex);
        } finally {
            rLock.unlock();
        }
    }
}

我们来看一下短链接监控统计的异步化重构过程。原来的实现是同步写入数据库,这样在高并发场景下容易成为性能瓶颈。我们通过引入Redis Stream消息队列实现了异步处理,具体流程如下:

在shortlinkImpl类中,原本的shortLinkStats方法会直接执行数据库操作,现在改为调用消息队列生产者发送消息:

@Override

public void shortLinkStats(String fullShortUrl, String gid, ShortLinkStatsRecordDTO statsRecord) {

Map<String, String> producerMap = new HashMap<>();

producerMap.put("fullShortUrl", fullShortUrl);

producerMap.put("gid", gid);

producerMap.put("statsRecord", JSON.toJSONString(statsRecord));

shortLinkStatsSaveProducer.send(producerMap);

}

这里创建了一个Map容器来封装需要传递的三个核心参数:完整短链接地址、分组ID和统计记录对象。通过JSON序列化将statsRecord转换为字符串存储,确保参数传输的完整性。

消息队列生产者ShortLinkStatsSaveProducer的实现非常简单:

public class ShortLinkStatsSaveProducer {
    private final StringRedisTemplate stringRedisTemplate;
    @Value("${spring.data.redis.channel-topic.short-link-stats}")
    private String topic;
    
    public void send(Map<String, String> producerMap) {
        stringRedisTemplate.opsForStream().add(topic, producerMap);
    }
}

这里利用了Redis 5.0+的Stream数据结构作为消息队列,相比List结构具有更好的消息追踪能力。通过配置中心注入的topic名称,将封装好的参数以流式数据形式发送到指定通道。

消息消费者ShortLinkStatsSaveConsumer的核心逻辑在onMessage方法中:

@Override
public void onMessage(MapRecord<String, String, String> message) {
    String stream = message.getStream();
    RecordId id = message.getId();
    Map<String, String> producerMap = message.getValue();
    String fullShortUrl = producerMap.get("fullShortUrl");
    if (StrUtil.isNotBlank(fullShortUrl)) {
        String gid = producerMap.get("gid");
        ShortLinkStatsRecordDTO statsRecord = JSON.parseObject(producerMap.get("statsRecord"), ShortLinkStatsRecordDTO.class);
        actualSaveShortLinkStats(fullShortUrl, gid, statsRecord);
    }
    stringRedisTemplate.opsForStream().delete(Objects.requireNonNull(stream), id.getValue());
}

当接收到消息时,首先解析出三个核心参数,验证完整性后调用实际处理方法。处理完成后立即删除已消费的消息,防止内存堆积。这里有个关键设计:通过Stream的del指令确保消息只被处理一次,避免重复消费问题。

实际的数据持久化逻辑集中在actualSaveShortLinkStats方法中。这个方法需要处理多个维度的统计:

  1. 基础访问统计 :LinkAccessStatsDO记录每小时/每周的PV/UV/UIP
  2. 地理位置统计 :调用AMap API解析IP归属地
  3. 客户端环境统计 :操作系统、浏览器、设备、网络类型
  4. 访问日志记录 :LinkAccessLogsDO存储原始访问日志
  5. 实时指标更新 :通过shortLinkMapper.incrementStats更新实时计数器
  6. 当日统计 :LinkStatsTodayDO记录当天的累计数据

这里用了Redisson的读写锁来保证同个短链接的并发安全:通过读写锁避免同一短链接的并发处理冲突。如果获取锁失败,会通过延迟队列重新入队处理。这个设计考虑到了分布式场景下的并发控制需求。

地理位置统计部分有个巧妙的处理:这里通过高德地图API将IP转换为地理信息,但做了容错处理:当API调用失败或返回无效数据时,默认标记为"未知"地区。这种设计既保证了核心统计的可用性,又避免了第三方服务不稳定带来的影响。

在处理客户端环境统计时,每个维度都有独立的DAO操作:

linkOsStatsMapper.shortLinkOsState(linkOsStatsDO); // 操作系统

linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO); // 浏览器

linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO); // 设备类型

linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO); // 网络类型

这种设计保持了各维度统计的正交性,便于后续扩展和维护。每个统计维度都有对应的表结构支持,符合单一职责原则。

日志记录和实时指标更新采用了不同的策略:

linkAccessLogsMapper.insert(linkAccessLogsDO); // 原始访问日志直接插入

shortLinkMapper.incrementStats(...); // 使用MySQL的原子操作更新实时计数

linkStatsTodayMapper.shortLinkTodayState(linkStatsTodayDO); // 当日统计通过upsert操作

原始日志使用简单的insert操作保证数据完整性,而实时指标采用MySQL的原子更新(如UPDATE ... ON DUPLICATE KEY UPDATE),既保证性能又避免锁竞争。

在异常处理方面,整个方法包裹在try-catch中:确保任何情况下都会释放分布式锁,防止死锁。同时通过延迟队列的二次投递机制,保证数据最终一致性。

这个方案有三个显著优势:

  1. 解耦统计与业务 :将耗时的统计操作异步化,提升主流程响应速度
  2. 批量处理能力 :通过消息队列缓冲,可以支持批量入库优化
  3. 系统稳定性 :隔离统计模块对核心链路的影响,增强系统整体可靠性

但还存在两个待优化点:

  1. 数据延迟问题 :由于异步处理,实时数据可能存在秒级延迟
  2. 重复消费风险 :虽然消息处理是幂等的,但需要更完善的去重机制

后续计划:

  • 在consume阶段增加基于Redis的布隆过滤器做去重
  • 使用延迟队列做补偿机制,应对消息丢失场景
  • 增加配置开关,支持RocketMQ/Kafka等专业消息中间件的无缝切换
  • 添加监控埋点,实时观测消息堆积情况

这种改造将原本需要100ms的同步操作降到了10ms内完成,虽然增加了系统复杂度,但显著提升了吞吐量。通过Redis Stream的自动ack机制和消息删除策略,确保了消息的可靠消费。这种设计特别适合写多读少的统计场景,后续可以根据实际压测数据调整线程池参数,进一步优化处理性能。

使用消息队列后的一些问题?

数据延迟

幂等

课后作业

使用 RabbitMQ、RocketMQ 或 Kafka 任意一种 MQ 进行改造。

- 第13节:消息队列重复消费问题如何解决(上)

需求背景

当消息队列出现重复消费问题情况下,应该如何保障数据的准确性?网络问题;生产重试

如何解决?幂等。就是你一个请求,两次一模一样的请求过来,最终执行结果和来一次的是一样的
 

在mq下封装idempotent的MessageQueueIdempotentHandler文件

package com.nageoffer.shortlink.project.mq.idempotent;


import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 消息队列幂等处理器
 */
@Component
@RequiredArgsConstructor
public class MessageQueueIdempotentHandler {

    private final StringRedisTemplate stringRedisTemplate;

    private static final String IDEMPOTENT_KEY_PREFIX = "short-link:idempotent:";

    /**
     * 判断当前消息是否消费过
     *
     * @param messageId 消息唯一标识
     * @return 消息是否消费过
     */
    public boolean isMessageProcessed(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "0", 2, TimeUnit.MINUTES));
    }

    /**
     * 判断消息消费流程是否执行完成
     *
     * @param messageId 消息唯一标识
     * @return 消息是否执行完成
     */
    public boolean isAccomplish(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        return Objects.equals(stringRedisTemplate.opsForValue().get(key), "1");
    }

    /**
     * 设置消息流程执行完成
     *
     * @param messageId 消息唯一标识
     */
    public void setAccomplish(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        stringRedisTemplate.opsForValue().set(key, "1", 2, TimeUnit.MINUTES);
    }

    /**
     * 如果消息处理遇到异常情况,删除幂等标识
     *
     * @param messageId 消息唯一标识
     */
    public void delMessageProcessed(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        stringRedisTemplate.delete(key);
    }
}

对应是 ShortLinkStatsSaveConsumer

    @Override
    public void onMessage(MapRecord<String, String, String> message) {
        String stream = message.getStream();
        RecordId id = message.getId();
        if (!messageQueueIdempotentHandler.isMessageProcessed(id.toString())) {
            // 判断当前的这个消息流程是否执行完成
            if (messageQueueIdempotentHandler.isAccomplish(id.toString())) {
                return;
            }
            throw new ServiceException("消息未完成流程,需要消息队列重试");
        }
        try {
            Map<String, String> producerMap = message.getValue();
            String fullShortUrl = producerMap.get("fullShortUrl");
            if (StrUtil.isNotBlank(fullShortUrl)) {
                String gid = producerMap.get("gid");
                ShortLinkStatsRecordDTO statsRecord = JSON.parseObject(producerMap.get("statsRecord"), ShortLinkStatsRecordDTO.class);
                actualSaveShortLinkStats(fullShortUrl, gid, statsRecord);
            }
            stringRedisTemplate.opsForStream().delete(Objects.requireNonNull(stream), id.getValue());
        } catch (Throwable ex) {
            // 某某某情况宕机了
            messageQueueIdempotentHandler.delMessageProcessed(id.toString());
            log.error("记录短链接监控消费异常", ex);
        }
        messageQueueIdempotentHandler.setAccomplish(id.toString());
    }

我们这边已经有一个Key,它的过期时间是600秒(10分钟),对吧?这里已经ok了。那如果说再有一个一模一样的消息,ID再出现的话,这里直接是不是因为它已经有了插不进去?就会返回false。这个时候一旦Q的话它就等于false,boss这边就列入了,是不是就直接保证了我们的幂等性?对吧?如果大家看到这里觉得没有问题的,我们再往下看。

继续看常见问题:消息消费失败怎么办

举个例子,假设消息第一次消费时插入了幂等标识(Redis key),但在后续处理中发生了异常。这时候MQ一般会触发重试机制。由于Redis中还存在这个标识,第二次消费时会被认为已经处理过,直接返回成功,但实际上业务逻辑并没有正确执行。当处理失败时,主动删除Redis中的标识,让MQ可以重新投递这条消息。这样即使第一次处理失败,也能保证后续能成功处理。设置两个状态:"0" 表示已插入但未完成;"1" 表示已完全处理成功

关键改进点:在catch中删除标识,确保MQ可以重新投递;无论成功与否都在finally中删除消息,避免消息堆积

测试验证:

  1. 正常场景 :第一次处理成功 → Redis值变为"1" → 重复消息直接跳过
  2. 失败重试 :第一次处理失败 → 删除标识 → MQ重试 → 成功处理 → 标记为"1"
  3. MQ自动重试 :消息未确认 → MQ自动重投 → 发现状态是"0" → 抛异常触发重试

通过Redis+状态机实现了可靠的幂等性保障,解决了以下问题:

  • 消息重复 :通过唯一ID和Redis锁保证
  • 消息丢失 :MQ自动ack机制保障
  • 状态混乱 :状态机设计防止中间状态导致的误判
  • 内存泄漏 :设置TTL自动清理

常见问题如果消费者消费失败了但没有执行到删除标识,该怎么办?为什么仅设置 10 分钟的过期时间?如何应对海量幂等 Key 所消耗的内存?MySQL 或其它大数据量存储。想办法改造数据。

正常情况:获取消息 -> 设置预占标识(无,设置正在消费中) -> 消费 -> 标识设置(已完成)

异常情况:

1.重复消息:重复消息 -> 判断预占标识(正在消费中) -> 判断是否消费完成(已完成)-> 返回

2.重试消息(正常流程):重试消息 -> 判断预占标识(无,设置正在消费中)->消费 ->标识设置(已完成)

3.重试消息(之前设置已完成标识失败):重试消息 -> 判断预占标识(正在消费中)-> 判断是否消费完成(未完成)-> 抛出异常 消息消费中 但是未完成 -> 重试 ->redis过期 -> 进入正常流程

4.正常消息(消费异常):获取消息 -> 判断预占标识(无,设置正在消费中)-> 消费异常 ->删除预占标识 -> 抛出异常 消息消费失败 ->重试

5.重试消息(之前消费异常,并且删除预占标识失败“宕机”):参考第3条


乐忧忘忧:想问一下redis的Stream有ack机制来保证消息被消费者获取到,为什么这里还需要单独重写判断

Edgar 回复 乐忧忘忧:ACK只是保证”AT LEAST ONCE“,不是”Exactly Once“。ACK机制可能出现问题,当生产者没有收到ACK时,可能继续向队列中重复发送消息,此时就需要额外在代码层面干预(个人理解)

cmqqq 回复 Edgar:ACK机制只是说去保证消息至少被消费一次,并没有保证重复消费的幂等性问题,所以我们需要在业务里判断是否被消费过了。

TheTurtle 回复 乐忧忘忧:大佬说的是xack命令吗?我查了下这个命令是将消息标记为【已处理】,不是用来保证消息被消费者【已获取】。

TheTurtle 回复 Edgar:你说的意思我懂,但是在 redis stream 中的生产者没有重发机制。 

  • ACK机制的局限性
    Redis Stream 的 XACK 仅保证消费者确认消息后,MQ 才删除消息(AT LEAST ONCE),但无法避免以下场景:
    • 生产者重复发送 :生产者未收到 ACK,触发重试,导致 MQ 中存在重复消息。
    • 消费者重复处理 :消费者业务逻辑执行失败(如网络异常),MQ 重试时重复消费。
  • 幂等性的必要性
    即使 MQ 保证消息不丢失,业务层仍需通过唯一 ID(如 messageId)和 Redis 标识确保重复消息的处理结果一致。

木小里:在视频最后马哥演示的那个异常是不是会有这样的一个问题:由于异常是在消息被消费之后抛出的,发生在 stringRedisTemplate.opsForStream().delete(...) 之前;也就是消息被成功消费了,但是这时这条消息的 key 会从 Redis 中删掉,那下一次另一个消费者拿到同样的消息之后,由于 Redis 没有该条消息的 key,那岂不是说明该条消息没有被消费过,又要被消费一次,这不还是没解决幂等性问题吗?

李温候 回复 木小里:我看了一下try里面的代码,可能会导致异常的应该就只有actualSaveShortLinkStats() 和 stringRedisTemplate.opsForStream().delete(),前者是和消费有关的(所以它放在try里面没毛病),后者只是释放内存,要是后者这里一定不会报错,那我们就可以不用动,要是可能报错,我们可以把它移到try外面(个人看法)

地信哥 回复 木小里:消息被成功消费之后,最后那个消息不是被移除了?就不存在下一次另一个消费者拿到同样的消息的这种情况了

  • 正常流程 :消息处理成功 → setAccomplish 将 Redis 值设为 "1" → 删除 Stream 消息。
  • 异常流程 :若 actualSaveShortLinkStats(...) 抛出异常 → 进入 catch 块 → 删除 Redis 标识(允许重试)。
  • 潜在风险
    • 若异常发生在 actualSaveShortLinkStats(...) 成功后,但 delete(...) 失败前,此时 setAccomplish(...) 未执行,Redis 标识仍为 "0",MQ 重试时会重新消费,但业务逻辑可能已部分执行(如数据库写入),导致数据不一致
    • 正常流程 :消息处理成功 → setAccomplish 将 Redis 值设为 "1" → 删除 Stream 消息。
    • 异常流程 :若 actualSaveShortLinkStats(...) 抛出异常 → 进入 catch 块 → 删除 Redis 标识(允许重试)。
  • 潜在风险
    • 若异常发生在 actualSaveShortLinkStats(...) 成功后,但 delete(...) 失败前,此时 setAccomplish(...) 未执行,Redis 标识仍为 "0",MQ 重试时会重新消费,但业务逻辑可能已部分执行(如数据库写入),导致数据不一致。

马丁哥,我觉得抛异常的语句应该写在stringRedisTemplate.opsForStream().delete(Objects.requireNonNull(stream), id.getValue()); 这条语句之前更加合理,因为我觉得消息删除就已经代表消费成功,之后消费者也不会再获取到消息队列里的那条消息了(因为已经被删除了),之后再抛异常然后删除幂等标识感觉没有多大意义

马丁 回复 地信哥:这个后面有重构的,可以先往后看,如果有问题可以在重构章节反馈。 视频修复章节 9-04:修复幂等&Redis-Stream消息队列线上消费停止问题

  • 当前代码逻辑 setAccomplish(...)try 块末尾调用,确保仅当业务逻辑和消息删除均成功时,才标记为完成。潜在缺陷 :若 delete(...) 抛出异常(如 Redis 连接失败),会进入 catch 块删除标识,导致 MQ 重试,但实际消息未删除,可能重复消费。
  • 改进方案 幂等标识生命周期 :设置 Redis Key 的 TTL(如 2 分钟),避免长期占用内存。分离状态更新与消息删除 :先更新状态,再删除消息(参考上一点)。
  • 结论 :马丁哥的建议部分正确,需重构代码确保状态更新与消息删除的原子性。

监控统计不是有好多张表吗,请问,如果监控数据插入了几张表,抛异常了或者程序宕机了(宕机加事务管用吗?),剩下几张没查监控数据,那不是不满足原子性吗,如果消息队列重试让消费者消费这个消息,前几张表不是又消费了一次,所以整个插入表的逻辑(actualSaveShortLinkStats方法)要不要加事务 

  • 无事务时,部分数据写入成功 → MQ 重试 → 再次写入,导致数据重复或不一致。
    • 解决方案
      • 添加事务 :在 actualSaveShortLinkStats(...) 方法上添加 @Transactional,确保原子性。
      • 补偿机制 :若事务不可行,需记录中间状态并异步补偿。
    • 结论 :讨论者正确指出事务缺失的风险,需在业务逻辑中补充事务管理。

- 第14节:消息队列重复消费问题如何解决(下)

1. 如果消费者消费失败了但没有执行到删除标识,该怎么办?

比如生产环境中 -9 直接杀掉redis,机器断电什么的,这时候就提到为什么10min过期了,

2. 为什么仅设置 10 分钟的过期时间?

幂标识所占redis存储的问题,你一旦所设置的时间过高,你所要存储的key的数量越多,因此异常重要

3. 如何应对海量幂等 Key 所消耗的内存?

  • MySQL 或其它大数据量存储。
  • 想办法改造数据。

DelayShortLinkStatsConsumer添加判断当前的这个消息流程是否执行完成,完成了true就执行,没有完成就throw new ServiceException("消息未完成流程,需要消息队列重试");意味着如果说没完成,那需要那个消息队列重复给这个消费者进行下放,里面执行异常了,就把东西给删除掉,没有执行异常,就执行为true;

如果说这个消息消费失败了,rocketmq在消费者消费失败的情况下,block是重复会让消费者拉到某个消息,有一个阶梯,先1min,2min,半小时什么的,一点一点的。那如果说是因为我们没有消费完,这里这个时候,

public void onMessage() {
    Executors.newSingleThreadExecutor(
                    runnable -> {
                        Thread thread = new Thread(runnable);
                        thread.setName("delay_short-link_stats_consumer");
                        thread.setDaemon(Boolean.TRUE);
                        return thread;
                    })
            .execute(() -> {
                RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque = redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
                RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
                for (; ; ) {
                    try {
                        ShortLinkStatsRecordDTO statsRecord = delayedQueue.poll();
                        if (statsRecord != null) {
                            if (!messageQueueIdempotentHandler.isMessageProcessed(statsRecord.getKeys())) {
                                // 判断当前的这个消息流程是否执行完成
                                if (messageQueueIdempotentHandler.isAccomplish(statsRecord.getKeys())) {
                                    return;
                                }
                                throw new ServiceException("消息未完成流程,需要消息队列重试");
                            }
                            try {
                                shortLinkService.shortLinkStats(null, null, statsRecord);
                            } catch (Throwable ex) {
                                messageQueueIdempotentHandler.delMessageProcessed(statsRecord.getKeys());
                                log.error("延迟记录短链接监控消费异常", ex);
                            }
                            messageQueueIdempotentHandler.setAccomplish(statsRecord.getKeys());
                            continue;
                        }
                        LockSupport.parkUntil(500);
                    } catch (Throwable ignored) {
                    }
                }
            });
}

 那么MessageQueueIdempotentHandler里面的添加如下函数;

private static final String IDEMPOTENT_KEY_PREFIX = "short-link:idempotent:";

/**
 * 判断当前消息是否消费过
 *
 * @param messageId 消息唯一标识
 * @return 消息是否消费过
 */
public boolean isMessageProcessed(String messageId) {
    String key = IDEMPOTENT_KEY_PREFIX + messageId;
    return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "0", 2, TimeUnit.MINUTES));
}

/**
 * 判断消息消费流程是否执行完成
 *
 * @param messageId 消息唯一标识
 * @return 消息是否执行完成
 */
public boolean isAccomplish(String messageId) {
    String key = IDEMPOTENT_KEY_PREFIX + messageId;
    return Objects.equals(stringRedisTemplate.opsForValue().get(key), "1");
}

/**
 * 设置消息流程执行完成
 *
 * @param messageId 消息唯一标识
 */
public void setAccomplish(String messageId) {
    String key = IDEMPOTENT_KEY_PREFIX + messageId;
    stringRedisTemplate.opsForValue().set(key, "1", 2, TimeUnit.MINUTES);
}

/**
 * 如果消息处理遇到异常情况,删除幂等标识
 *
 * @param messageId 消息唯一标识
 */
public void delMessageProcessed(String messageId) {
    String key = IDEMPOTENT_KEY_PREFIX + messageId;
    stringRedisTemplate.delete(key);
}

return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "0", 2, TimeUnit.MINUTES));里面0代表执行中,1代表已完成,进一步补充此文件;

是0的话就会不断的抛异常;10分钟实践我们这里他在拉来消息的时候,Redis里面就没有这个标识了,没有标识后就能够正常消费

除了我们用Redis去实现mysql也能实现,

package com.nageoffer.shortlink.project.mq.idempotent;


import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 消息队列幂等处理器
 */
@Component
@RequiredArgsConstructor
public class MessageQueueIdempotentHandler {

    private final StringRedisTemplate stringRedisTemplate;

    private static final String IDEMPOTENT_KEY_PREFIX = "short-link:idempotent:";

    /**
     * 判断当前消息是否消费过
     *
     * @param messageId 消息唯一标识
     * @return 消息是否消费过
     */
    public boolean isMessageProcessed(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "0", 2, TimeUnit.MINUTES));
    }

    /**
     * 判断消息消费流程是否执行完成
     *
     * @param messageId 消息唯一标识
     * @return 消息是否执行完成
     */
    public boolean isAccomplish(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        return Objects.equals(stringRedisTemplate.opsForValue().get(key), "1");
    }

    /**
     * 设置消息流程执行完成
     *
     * @param messageId 消息唯一标识
     */
    public void setAccomplish(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        stringRedisTemplate.opsForValue().set(key, "1", 2, TimeUnit.MINUTES);
    }

    /**
     * 如果消息处理遇到异常情况,删除幂等标识
     *
     * @param messageId 消息唯一标识
     */
    public void delMessageProcessed(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        stringRedisTemplate.delete(key);
    }
}

 delreq没有消息id啊,因此在shortLinkStatsRecordDTO里面添加一个消息队列keys


 鲸:这里短连接统计时出现异常catch后,只打印错误日志好像并不会重新去消费,我尝试的抛出异常才会进行重试

  • 代码逻辑 :当前 catch 块中仅删除 Redis 标识,未抛出异常,导致 MQ 认为消费成功(自动 ACK),消息不会重试。需在 catch 中抛出异常(如 ServiceException),触发 MQ 重试机制。

TheTurtle 回复 鲸:这里使用 redis stream 是收到即焚的哇?uu 怎么测出重试的?

  • 自动 ACK 的风险 :消息拉取后立即被确认,若消费失败,MQ 不会重试(消息已删除)。使用手动 ACK,在消费完成后显式确认,避免消息丢失。

王小明:我怎么觉得,在消费成功后,直接设置一个成果标识就可以了,不需要处理的这么复杂。先判断成功标识是否存在 即hasKey,不存在则进行消费,消费完setAccomplish

Apricity🌤 回复 王小明:我也这么觉得,应该在消费成功后设置标识,而不是消费开始的时候设置标识

少·羽 回复 Apricity🌤:但是就会出现重复消费啊,你是消费成功了才设置,那么就会有好多个同时进入了消费程序,那么就相当于将判断压力给到了数据库这边了

自由如风 回复 少·羽:重复消费?应该不会,因为拉取消息的时候都是单线程,两个连接拿到的是不同得消息

ymf 回复 王小明:个人理解矛盾点主要在于如何是否catch消费异常,如果没有catch异常,那么这个异常会不断被抛出,妨碍执行;但实际上这个消费异常可以在该方法内部catch到来进行解决,如有不对请指正。

INSIST 回复 ymf:有没有这个可能,刚好消费完成,还没来得及设置消费成功标识,此时宕机,那么再次投放消息时就等于消费了两次,这该咋办。

rebcaaaaaa* 回复 INSIST:马哥的代码也不能处理这种情况吧,总的来说我感觉这种思路简化了很多,效果也差不多。我觉得马哥的代码可以把未完成直接抛异常重试那段删了,未完成就继续往下进行消费,直接完成为止,这样就不用一直重试了,反正最终都还是要消费完成的。这样整体上就跟楼主的思路差不多了

白日梦想家 回复 rebcaaaaaa*:赞同。看了一下这层下面的回答,感觉设置预占标识确实有点多此一举。直接消费完成之后设置一个完成标识就行

Best HL 回复 白日梦想家:如果一个消息正在消费,已经把读写锁释放但还没有设置完成标识的时候,然后此消息又重新投递,如果没有预占标识,这个消息判断没有完成标识就会再次被消费,引起幂等问题。但是重复的消息什么时候会投递,消费时间过长会不会自动再次投递一次,如果会的话感觉是需要设置一个预占标识的。

  • 方案 setAccomplish 方法中检查 Redis 值是否为 "0",再设置为 "1",避免覆盖异常状态。
  • 正确性 :✅ 部分正确
    • 优势 :防止在异常处理中错误标记为完成(如标识已被删除)。
    • 缺陷 :未解决宕机发生在 setAccomplish 之前的场景(如标识已写入数据库但未更新 Redis)。
  • Drano 🌝 的建议
    • 方案 :将 setAccomplish 移出 finally,在 catch 中抛出异常。

 一开始redis中创建key, 值为0,然后抛出异常这里catch之后,会将当前key删除,最后还是会执行一个setAccomplish,也就是将key设置成1,因为我们已经删除了,所以就算key不存在也会将key设置成1,所以下次重试的时候,就会判断到值为1,所以就不会重试了 这是我改进之后的代码,在设置完成之前判断值是不是为0

public void setAccomplish(String id) {
    String key = IDEMPOTENT_REDIS_KEY + id;
    if (Objects.equals(stringRedisTemplate.opsForValue().get(key), "0")){ 
        stringRedisTemplate.opsForValue() .set(key, "1", 2, TimeUnit.MINUTES);
    }
}

champagnep* 回复 栗子ing:为啥还会执行一个setAccomplish

一只鱼 回复 栗子ing:栗子哥,你有自己记短链接的笔记嘛?

马丁 回复 栗子ing:正常来说应该在 catch 中将异常 throw 出去

Drano 🌝 回复 栗子ing:把setAccomplish方法移出finally,然后在catch里抛出异常也可以吧

栗子ing 回复 Drano 🌝:直接抛异常就可以,不需要移到finally里

Everglow 回复 栗子ing:已经抛出异常了肯定不会往下面执行了吧

TheTurtle 回复 champagnep*:catch 捕获异常后,程序会继续往下执行

肚圆圆 回复 栗子ing:那能不能直接在try里面执行setAccomplish就好

  • 方案 setAccomplish 方法中检查 Redis 值是否为 "0",再设置为 "1",避免覆盖异常状态。
  • 正确性 :✅ 部分正确
    • 优势 :防止在异常处理中错误标记为完成(如标识已被删除)。
    • 缺陷 :未解决宕机发生在 setAccomplish 之前的场景(如标识已写入数据库但未更新 Redis)。
  • Drano 🌝 的建议
    • 方案 :将 setAccomplish 移出 finally,在 catch 中抛出异常。

 如果消费成功setAccomplish后,等到过期时间过了,又进来一个之前消费过的数据怎么办?有这种可能么?消息队列中,如果一个消息已经被消费成功了,理论上不会重新发送这条消息。

  • 讨论焦点
    • 消息已标记为完成("1")且 Redis Key 过期后,相同消息是否会被重新消费?
  • 正确性 :✅ 完全正确
    • 逻辑分析
      • 过期后 Redis Key 不存在,消息 ID 可再次通过 isMessageProcessed 预占标识("0")进入消费流程。
      • 风险 :若消息 ID 全局唯一且仅单次有效,此设计会导致重复消费;若消息 ID 可重复(如业务逻辑关联唯一键),需额外校验。
    • 改进建议
      • 消息 ID 设计 :确保全局唯一性,或关联业务唯一键(如订单号)。
      • 状态持久化 :将幂等标识落库,避免 Redis 过期后丢失状态。

RQTN:这一节给我看懵逼了,我感觉真要讲消息队列消费幂等的话,应该要结合 ack 来讲吧。 像这一节末尾的延时队列,其实完全没必要加幂等,因为延时队列 poll 出来,消息在redis中其实就没有了,阅后即焚,不存在重复投递的可能性,不必再加幂等了。 再比如这一节提到的 Redis Stream,其实 Redis Stream 是有 ack 机制的,但是马哥用的是 auto ack,相当于消息也是阅后即焚了,也不存在重复投递的可能性了,自然也不必再加幂等了。 最后这里讲幂等,因为没有 ack 讲得也比较奇怪,比如为什么幂等标识存在以后,还需要额外判断一下是否消费成功,一个直接return,一个抛异常,不都是提前返回吗?看起来额外判断是否消费成功是多余的,但这个问题我是看了 12306 幂等那块才大致明白,相当于是整个 onMessage 方法外面还包了一层,MQ抛异常就不会返回 ack 从而会让 MQ 重新去投递消息,正常return会返回 ack,MQ 就不会重新投递了。

吃个月饼 回复 RQTN:这个auto ack是不是拿到消息就发送了ACK啊,没有等到onMessage执行完,那感觉漏洞还挺多的,如果消费过程中出了问题,那这个消息不就不会加入到Pending列表中了

RQTN 回复 吃个月饼:是的,相当于消息阅后即焚,以 auto ack 方式去读取消息的话,消息不会额外在 PEL 中额外存一份。

TheTurtle 回复 吃个月饼:对 就是阅后即焚 如果用的时候出问题了 那就直接丢数据了

TheTurtle 回复 RQTN:最好不要 auto ack 吧!阅后即焚的话如果出异常消息直接就丢失了,还是得完成消费后手动 acknowledge。感觉使用 redis stream 防重复消费,主要需要考虑 redis 宕机、云服务器宕机就够了,redis stream 机制下不需要过多担心【重复消费】问题,反而需要关注【消息丢失】问题。

TheTurtle 回复 RQTN:redis stream 消费者在 onMessage 中抛出异常会导致重新投递消息吗?为什么我这里模拟不出来,一旦抛出异常就像挂了一样。

kidult 回复 TheTurtle:马哥这里对redis 做消息队列的原理的理解我个人感觉是有问题的,这里马哥用的是auto ack一旦收到了消息会自动确认消息,然后这个消息就再也收不到了 如果是手动ack的话,未ack的消息是会放到一个pending_list里,除非指定从这个队列里取,redis是不会主动对这个队列里的消息进行投递的。 除开网络抖动出现把一个消息几乎同时投送两遍这种情况,redis是不会把一个未ack的消息进行二次投递

晟 回复 RQTN:自动ack也是需要加幂等的啊,虽然阅后即焚,但是你能保证消费者组里面多个实例不会拉取到同一条消息吗?但是好像这里没有解决生产者重试机制:redis stream中可能有两条相同的消息(内容相同但id不同)

  • RQTN 的质疑
    • 观点 :Redis Stream 自动 ACK(阅后即焚)下无需幂等,消息不会重复投递。
    • 正确性 :❌ 错误
      • 问题分析
        • 生产者重试 :若生产者未收到 ACK,可能重复发送相同消息(不同 ID),导致 MQ 中存在重复消息。
        • 网络抖动 :消息可能被多次投递(即使 ID 不同)。
      • 结论 :幂等处理仍必要,需结合消息 ID 和业务逻辑保障。
    • kidult 的补充 :✅ 完全正确
      • 自动 ACK 的局限性 :消息一旦拉取即确认,异常无法触发重试,需依赖手动 ACK。
  • Best HL 的建议
    • 问题 :若消费中宕机(已写入数据库但未标记完成),重复消息会因无预占标识而再次消费。
    • 正确性 :✅ 完全正确
      • 解决方案
        • 分离状态更新与业务逻辑 :先更新 Redis 为 "1",再提交数据库事务(需两阶段提交)。
        • 补偿机制 :定时任务扫描未完成标识,触发重试。
    • Everglow 的质疑
      • 问题 setAccomplishfinally 中执行是否合理?
      • 正确性 :✅ 部分正确
        • 风险 :若消费失败(如数据库异常),标记为完成("1")会导致消息被误判为成功。
        • 建议 :仅在业务逻辑成功后调用 setAccomplish

 正常情况:获取消息 -> 设置预占标识(无,设置正在消费中) -> 消费 -> 标识设置(已完成) 异常情况: 1.重复消息:重复消息 -> 判断预占标识(正在消费中) -> 判断是否消费完成(已完成)-> 返回 2.重试消息(正常流程):重试消息 -> 判断预占标识(无,设置正在消费中)->消费 ->标识设置(已完成) 3.重试消息(之前设置已完成标识失败):重试消息 -> 判断预占标识(正在消费中)-> 判断是否消费完成(未完成)-> 抛出异常 消息消费中 但是未完成 -> 重试 ->redis过期 -> 进入正常流程 4.正常消息(消费异常):获取消息 -> 判断预占标识(无,设置正在消费中)-> 消费异常 ->删除预占标识 -> 抛出异常 消息消费失败 ->重试 5.重试消息(之前消费异常,并且删除预占标识失败“宕机”):参考第3条

- 第15节:短链接Redis缓存命名重构

 在userserviceimpl中,我们发现 这么一句话       Map<Object ,Object> hasLoginMap = stringRedisTemplate.opsForHash().entries("login_" + requestParam.getUsername());修改为

Map<Object, Object> hasLoginMap = stringRedisTemplate.opsForHash().entries(USER_LOGIN_KEY + requestParam.getUsername());

 同样的下面的 所有“login_”统统修改为USER_LOGIN_KEY,一共5个;

相应的,RedisCacheConstant里面弄一个USER_LOGIN_KEY,还有UserTransmitFilter里面也有一个

package com.nageoffer.shortlink.admin.common.constant;

/**
 * redisson 的常量命名
 */
public class RedisCacheConstant {
    /**
     * 用户注册分布式锁
     */
    public static final String LOCK_USER_REGISTER_KEY = "short-link:lock-user-register:";

    /**
     * 分组创建分布式锁
     */
    public static final String LOCK_GROUP_CREATE_KEY = "short-link:lock_group-create:%s";
    /**
     * 用户登录缓存标识
     */
    public static final String USER_LOGIN_KEY = "short-link:login:";
}

还有就是RedisKeyConstant.java

package com.nageoffer.shortlink.project.common.constant;

/**
 * Redis Key 常量类
 */
public class RedisKeyConstant {

    /**
     * 短链接跳转前缀 Key
     */
    public static final String GOTO_SHORT_LINK_KEY = "short-link:goto:%s";

    /**
     * 短链接空值跳转前缀 Key
     */
    public static final String GOTO_IS_NULL_SHORT_LINK_KEY = "short-link:is-null:goto_%s";

    /**
     * 短链接跳转锁前缀 Key
     */
    public static final String LOCK_GOTO_SHORT_LINK_KEY = "short-link:lock:goto:%s";

    /**
     * 短链接修改分组 ID 锁前缀 Key
     */
    public static final String LOCK_GID_UPDATE_KEY = "short-link:lock:update-gid:%s";

    /**
     * 短链接延迟队列消费统计 Key
     */
    public static final String DELAY_QUEUE_STATS_KEY = "short-link:delay-queue:stats";

    /**
     * 短链接统计判断是否新用户缓存标识
     */
    public static final String SHORT_LINK_STATS_UV_KEY = "short-link:stats:uv:";

    /**
     * 短链接统计判断是否新 IP 缓存标识
     */
    public static final String SHORT_LINK_STATS_UIP_KEY = "short-link:stats:uip:";
}

 project 的shortLinkServiceimpl是这样的:所有的 "short-link:stats:uv:"  统统替换成
SHORT_LINK_STATS_UV_KEY

他这边加进去之后再处理,可能没地方用了。我想想他在里面提到了哪里?他在哪个环节卡住了?我们把业务关联关系不能这样处理,这里只是新增了但没用到是不可能的。我问一下这个逻辑是什么?第二a的remote,没用到吗?他到底在做什么?用的是这个东西。VIP。VIP。You are talking。然后再加一个127.0.0.1,这个不可能用不到。我们执行的时候就开始发送请求了,我们去里面看一下,有点离谱。我想想当时我拿它用来做什么,把当前的uip address不等于...我想起来确实没用到,或者说不能说完全没用到,是因为本身把它加进去,我们用了Excel的结果,如果能加进去就证明是首次访问,加不进去其实就不是对吧?能大概讲清楚这个临时逻辑,ok现在我们的rise应该就没问题了。咱们这边我看一下。Ok有没有问题,我们启动前端试一下。我取消。用户名我还真有点忘了,找一个用户好像是我新加的。还真是。我之前没创建过吗?后来怎么了?难道是用户这边改错了?功能这块修改要比较谨慎,一招错就会步步错,一开始登录报错就知道是过滤器的问题。Ok怎么了?来重启一下服务,这就对了。我记得当时我弄了一个链接的ok,验证成功。这家因为之前有过类似问题,这边是1999怎么没有?这个逻辑是没问题的,相当于这次旧改是正确的,我们加一个优化。

我们讲优化短链接相关缓存key把代码提交一下就可以了,然后以这个视频也给大家一些建议,每次在写完代码之后,尽量隔一段时间在不影响线上业务运行的情况下就去留心一下代码,相信我肯定会有一些帮助的。

 《重构既有代码设计》,《代码整洁之道》

optimize:优化短链接后管用户返

public UserLoginRespDTO login(UserLoginReqDTO requestParam) {
    LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
            .eq(UserDO::getUsername, requestParam.getUsername())
            .eq(UserDO::getPassword, requestParam.getPassword())
            .eq(UserDO::getDelFlag, 0);
    UserDO userDO = baseMapper.selectOne(queryWrapper);
    if (userDO == null) {
        throw new ServiceException("用户不存在");
    }

optimize:优化用户不存在异常

private void returnJson(HttpServletResponse response, String json) throws Exception {
   /*
    PrintWriter writer = null;
    response.setCharacterEncoding("UTF-8");
    response.setContentType("text/html; charset=utf-8");
    try {
        writer = response.getWriter();
        writer.print(json);
    } catch (IOException e) {
    } finally {
        if (writer != null)
            writer.close();
    }
    */
    response.setCharacterEncoding("UTF-8");
    response.setContentType("text/html; charset=utf-8");
    try (PrintWriter writer = response.getWriter()) {
        writer.print(json);
    }
}

- 第16节:短链接生成重复为什么要再查询数据库?

这个来跟大家聊一个困扰大家已久的一个问题。为什么困扰已久?因为之前一直听大家跟我提起,但是当时因为视频在录制进度过程当中不太可能回到前面,所以说当大部分内容录完之后,我们终于可以在一起聊聊这个问题。那短链接catch里面的 if判断有没有必要,对吧?在聊这个问题之前,我们先把上节课的小尾巴给收一下。我在review代码的过程当中发现了我们的你可以优化一下,我们统一的把这里面抽象到长江里面去,在他们然后是这种这个叫10个认可。是距离中心。实际我们这边给他改个特别了,区别我们下面的区别我们下面的啥?区别我们下面这个歌谱,短链接监控。消息保存,然后我们就可以看一下这里面它在哪里可以用到了两个地方应该首先这里我们直接把它改成然后然后格鲁普删掉。Ok,没有了。确保万一我们把这两个看到没有,包括波浪线了,这边确实没有了。然后把这个删掉之后,你先提交代码。将缓存T一致考虑一起提。然后把这个搞定之后还有一个问题,就是我们在上一节课里面说了,需要给他做一个就是手动的去添加group的一个逻辑,这是在我的印象当中肯定忍不了,所以说我们干什么添加一个初始化的任务,打错了。

然后加一个什么?加一个对。使劲,没什么问题。初始化。初始化什么初始化,短链接监控消息队列,什么?消费者,然后我们要在这里面把那个是不适应原理,他没有疑难。第二实训,第二a的不对group。觉得不是然后把 topic引进来,答不出来。答不出来那就复制。然后还有一点就是如果说这个短信,如果说 stream这个已经存在它会报错,所以说我们这里面要先加一个判断。第二还是可以。第四课的话我们进入脱贫课题。第四题,领导,或者正好就万无一失了,我们可以把什么我们可以把这玩意给删了。说不定下面就这两个,然后我们把这个项目启动一下,只要我们启动两次以上没有问题,他这个就算是ok的。然后这样的话大家启动的时候就少了一个步骤,对不对看到没有我们

 Ok是没报错的。然后我们访问一下我们的前端项目,又有点忘了用户名叫什么了。搜一下。想起来8889 3 45678. 好的逻辑是没啥问题。看看有没有报错。没报错,然后数据也正常都进来了,就证明我们改的没问题。把这个改一下,这样的话大家对吧就少了个逻辑,少了一个步骤对不对?就不需要你手动的去做了,因为手动做的话,说实话这里我是比较反感这种行为的,说实话。Raise stream. 高品格和消费者出来。然后把这个问题解决了之后,我们就可以真正的去看一下我们今天要讲的主题了,那就是我们的异常到底有没有必要在我们service里面,这个逻辑比较简单,就是如果说新增出现了 K重复以上,因为我们已经把短链接表里面的完整短链接当做一个唯一索引了,如果说抛出异常的话,它应该会在这里边拦截出来,然后我们之前这里查询了一步对吧?然后查询真正不等于空的话再跑一场。这个逻辑就是为什么?然后我们先分析一下流程,我们是靠什么来判断这个数据短链接是否存在的?不能过滤器对不对?那不能过滤器有什么特点?查询是否存在。如果返回存在,可能数据存可能数据是不存在的,如果返回不存在,数据一定不存在。大家去熟悉一下我们不同过滤器的一些原理,大家应该都知道,之前应该是给大家讲过的。基于这种的话,首先这里面我是按照上面这个逻辑去判断的,他可能对吧?我可能预想到会有这种情况,但是我后面的代码其实重构了,我把这里忘改了,就是这个样子。然后我们可以看一下判断它生成链接的时候,那就是在这里我们用的什么?不是这里。在这里我们用的是不存在如果说不存在,生成短信页之后判断不能过滤器里面不存在,那么它一定是对吧?不存在的,这样的话就返回博博可不认可对吧?然后就告诉我们说这个当然也可以用,这种情况下他有什么问题吗?大家想想有没有什么问题,给大家半分钟的时间思考。他的问题就在于他的一些假如说我们生成了123456,假如生成了这个由于转链接的误判,他可能没办法使用,但是这个就像我们在12366里面的用户名一样,这种有影响吗?

对吧?短链接的组合是随意的,然后我们只需要保证它是唯一可用的就可以,它假如说有一些不可用有什么影响吗?没有影响对不对?既然没有影响的话,我们就可以让他再去重复生成,比如说把9改成7,对吧?这种情况下我们要的结果就是它不存在于不能过滤器,这种的话没问题的。所以说我们用的这种模型判断它不存在就一定不存在的这种逻辑,如果说这个逻辑是成立的话,这里其实其实就没有必要了。对,这里其实就没必要了。我们把它优化一下。4月份我们把这个异常正常抛出来,其实这里查询就没有必要了,对吧?大家理解一下这个比较不能过滤器里面有点绕,大家只需要知道我们是不是按照它是否存在去判断,我们是按照它返回不存在则数据一定不存在的逻辑去判断的。有些同学可能会问不能过滤器里面对吧?这里面已经在哪?这里面已经判断不存在了,它还会发生组件冲突吗?还会发生唯一索引冲突吗?会的,因为它并发场景下对吧?假如说我们有两个一模一样的数据同时请求了,这个时候还没走到数据库,在这里面它肯定是同时返回存在的,它一起去请求数据库肯定只有一个能成功,大家懂我意思吧?所以说我们那里还是要去做唯一索引判断

 这里面的话相信已经给大家解惑了对吧?我们也把判断给删除了,这个是因为我之前做架构设计的时候调整了,然后这个里面的忘记调整,然后导致大家一直心里面的一些疑问,当然大家就是提问的点是非常好的,勇于质疑对吧?我可以质疑的对,但是我觉得不对的时候我应该质疑这个是挺好的。然后第二点那天我在压测短链接的时候,它并发场景下会出现一些短链接生成重复,也就是我们前两天在做压测的过程当中,压那个时候会出现一些短时间生成重复,然后我就在想这个问题可能是什么。然后我想了一下,可能是在大量用户同一毫秒下去请求对吧?假如说我们的原始的URL是一样的,对吧?我们的前置条件是原始URL是一样的,原始URL的情况一致的情况下,如果有大量的请求去访问我们的数据,所以说用这个当然毫秒数是有点问题的。因为它如果在短时间内涌上来大量的应用,大量的请求,它的毫秒数是一致的,所以说这里有一点不太好,然后之前有位同学跟我说了这一点,然后之前还是没有去优化,结合到前两天我们的报错提醒,然后我想到了这一点,我们应该把它改成什么?把它改成UV it,用随机的U ID就可以了。然后这样的话就能够将它的将它的重复率降到最低,好吧?我们把这个也先提一下,别人说马哥他冲突无所谓,我们不是唯一索引,假如说有人想恶意攻击你对吧?他就用这种比如说用海量的这种数据请求去访问你,他就是要做的,在短时间内全部把数据放到你的数据库里面去,那这样的话其实它的目的已经拿到了,好吧?所以说我们要尽量的让让数据库避免这种行为。所以说我们要尽可能的把一切能考虑到的点都考虑到, UU ID就是其中的一个点。然后这个问题也给大家搞定了,就是同一时间下。短时间。怎么描述时间?要同一毫米。大量请求相同的原始链接会生成重复短链接,判断不存在,通过该通过该方式访问数据库。为此我们使用UI ID替换了当前时间说来一定程度减少重复的短链接生成。刚才有一点跟大家说错了,不是为了防止大量国际数据库,是为了防止我们的短链接返回生成重复的报错好吧?虽然说它可能这种东西是不太可能别人会对吧?一直是请求相同的这种断链接,但是我们要相对应的就有一些措施对吧?避免这种无意义的报错。然后还有一点我们这里面是用到了什么?用到了就解决了两两两个数据库,如果说其中一个报错款我们肯定是要回款的,我们目前没有回款的行为,所以说我们要在这里面加一个加个transaction。Try second one. Look back for exception. 可以了,现在的话相当于我们很多的业务逻辑都已经完善了,我们接下来的课时就要开始去做什么?做我们的微服务架构的一个转变了,