短链接生成-基于布隆过滤器和唯一索引

java 复制代码
@Transactional(rollbackFor = Exception.class)
    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
        // 短链接接口的并发量有多少?如何测试?详情查看:https://nageoffer.com/shortlink/question
        verificationWhitelist(requestParam.getOriginUrl());
        String shortLinkSuffix = generateSuffix(requestParam);
        String fullShortUrl = StrBuilder.create(createShortLinkDefaultDomain)
                .append("/")
                .append(shortLinkSuffix)
                .toString();
        ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                .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)
                .delTime(0L)
                .fullShortUrl(fullShortUrl)
                .favicon(getFavicon(requestParam.getOriginUrl()))
                .build();
        ShortLinkGotoDO linkGotoDO = ShortLinkGotoDO.builder()
                .fullShortUrl(fullShortUrl)
                .gid(requestParam.getGid())
                .build();
        try {
            // 短链接项目有多少数据?如何解决海量数据存储?详情查看:https://nageoffer.com/shortlink/question
            baseMapper.insert(shortLinkDO);
            // 短链接数据库分片键是如何考虑的?详情查看:https://nageoffer.com/shortlink/question
            shortLinkGotoMapper.insert(linkGotoDO);
        } catch (DuplicateKeyException ex) {
            // 首先判断是否存在布隆过滤器,如果不存在直接新增
            if (!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
                shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
            }
            throw new ServiceException(String.format("短链接:%s 生成重复", fullShortUrl));
        }
        // 项目中短链接缓存预热是怎么做的?详情查看:https://nageoffer.com/shortlink/question
        stringRedisTemplate.opsForValue().set(
                String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
                requestParam.getOriginUrl(),
                LinkUtil.getLinkCacheValidTime(requestParam.getValidDate()), TimeUnit.MILLISECONDS
        );
        // 删除短链接后,布隆过滤器如何删除?详情查看:https://nageoffer.com/shortlink/question
        shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
        return ShortLinkCreateRespDTO.builder()
                .fullShortUrl("http://" + shortLinkDO.getFullShortUrl())
                .originUrl(requestParam.getOriginUrl())
                .gid(requestParam.getGid())
                .build();
    }
   private String generateSuffix(ShortLinkCreateReqDTO requestParam) {
        int customGenerateCount = 0;
        String shorUri;
        while (true) {
            if (customGenerateCount > 10) {
                throw new ServiceException("短链接频繁生成,请稍后再试");
            }
            String originUrl = requestParam.getOriginUrl();
            originUrl += UUID.randomUUID().toString();
            // 短链接哈希算法生成冲突问题如何解决?详情查看:https://nageoffer.com/shortlink/question
            shorUri = HashUtil.hashToBase62(originUrl);
            // 判断短链接是否存在为什么不使用Set结构?详情查看:https://nageoffer.com/shortlink/question
            // 如果布隆过滤器挂了,里边存的数据全丢失了,怎么恢复呢?详情查看:https://nageoffer.com/shortlink/question
            if (!shortUriCreateCachePenetrationBloomFilter.contains(createShortLinkDefaultDomain + "/" + shorUri)) {
                break;
            }
            customGenerateCount++;
        }
        return shorUri;
    }

业务流程分步详解

1. 发起创建请求
  • 角色: 用户或客户端。
  • 动作 : 调用短链接服务的 createShortLink 接口,并传入一个原始长链接(originUrl)以及其他可选参数(如分组 ID gid、有效期 validDate 等)。
2. 参数校验
  • 角色: 短链接服务。
  • 动作 : 服务首先对接收到的 originUrl 进行合法性校验,例如检查 URL 格式是否正确,或者该 URL 是否在系统的白名单内,以防止生成非法或恶意网站的短链接。
  • 结果 :
    • 校验失败: 直接抛出异常,终止整个创建流程。
    • 校验成功: 流程继续。
3. 生成唯一短链接后缀
  • 角色 : 短链接服务(generateSuffix 方法)。
  • 动作 : 这是核心的 ID 生成环节。
    1. 循环生成: 进入一个循环,不断尝试生成一个唯一的短链接后缀。
    2. 增加随机性 : 为了避免不同请求为同一个 originUrl 生成相同的短链接,服务会在 originUrl 后拼接一个随机的 UUID
    3. 哈希与编码: 对拼接后的字符串进行哈希计算,然后将结果编码成一个简短的、URL 安全的字符串(使用 Base62 编码),得到一个候选的短链接后缀。
    4. 快速判重 : 为了避免频繁查询数据库,服务会先查询布隆过滤器 ,判断这个候选后缀是否可能已经存在。
    5. 判断与重试 :
      • 如果布隆过滤器判断不存在,则认为这个后缀是唯一的,跳出循环。
      • 如果布隆过滤器判断可能存在(可能是误判),则认为发生冲突,继续下一次循环,重新生成。
  • 结果 : 最终得到一个唯一的短链接后缀(如 abc123)。
4. 拼接完整短链接
  • 角色: 短链接服务。
  • 动作 : 将系统默认的域名(如 t.cn)和上一步生成的后缀拼接起来,形成一个完整的短链接(如 t.cn/abc123)。
5. 数据持久化与冲突处理
  • 角色: 短链接服务与数据库。
  • 动作 :
    1. 准备数据 : 创建两个数据对象(ShortLinkDOShortLinkGotoDO),分别包含短链接的完整信息和用于快速跳转的核心映射关系。
    2. 事务内插入: 在一个数据库事务中,尝试将这两个对象的数据插入到对应的两张表中。
    3. 冲突处理 :
      • 成功: 数据成功写入数据库,流程继续。
      • 失败 (DuplicateKeyException): 在极高并发下,可能两个请求同时生成了相同的短链接。第一个请求成功写入,第二个请求在写入时会因唯一键冲突而失败。服务会捕获此异常,向用户抛出 "短链接生成重复" 的业务异常,并终止流程。
6. 缓存预热
  • 角色: 短链接服务与 Redis 缓存。
  • 动作 : 为了让后续的短链接跳转请求能更快地响应,服务会立即将 完整短链接 -> 原始长链接 的映射关系存入 Redis 缓存中,并设置一个与短链接有效期相匹配的过期时间。
7. 更新布隆过滤器
  • 角色: 短链接服务与布隆过滤器。
  • 动作: 将刚刚成功创建的完整短链接添加到布隆过滤器中。这样,未来的创建请求在生成后缀时,就能通过布隆过滤器快速判断出这个短链接已经存在,从而避免了对数据库的无效查询。
8. 构建并返回响应
  • 角色: 短链接服务。
  • 动作 : 将创建成功的短链接信息(包括完整短链接、原始长链接等)封装成一个响应对象(ShortLinkCreateRespDTO),返回给用户。
1. 事务管理 (@Transactional)
  • 问题 : 你为什么在这个方法上加了 @Transactional(rollbackFor = Exception.class)
    • 考察点: 对 Spring 声明式事务的理解。
    • 深度追问 :
      • rollbackFor = Exception.class 的作用是什么?如果不加会怎么样?
        • 回答 : Spring 事务默认只对 RuntimeExceptionError 回滚。加上 rollbackFor = Exception.class 后,任何类型的 Exception(包括受检异常)都会触发回滚。这确保了在创建短链接的任何环节(如校验、数据库插入)失败时,数据库能保持一致性,不会出现只插入了部分数据的脏数据。
      • 这个事务的隔离级别是什么?在高并发场景下,这个隔离级别可能会带来什么问题?(如脏读、不可重复读、幻读)
      • 如果 stringRedisTemplate.opsForValue().set 这行代码执行失败了,数据库事务会回滚吗?为什么?
        • 回答 : 不会 。因为 @Transactional 注解只能管理数据库(如 MySQL)的事务。Redis 的操作是独立的,它不在这个数据库事务的管辖范围内。这会导致数据不一致:数据库里有记录,但缓存里没有。这是一个典型的分布式事务问题。
2. 数据库操作与并发控制
  • 问题 : 你为什么要插入两张表 (ShortLinkDOShortLinkGotoDO)?这样做有什么好处?
    • 考察点: 数据库表结构设计、读写分离思想。
    • 深度追问 :
      • ShortLinkGotoDO 这个 DO 的设计意图是什么?
        • 回答 : 这是一种典型的读写分离查询优化 设计。short_link 表存储了所有详细信息(访问统计、描述等),字段多,数据量大。而 short_link_goto 表只存储用于跳转的核心映射关系(full_short_url -> gidorigin_url)。当用户访问短链接时,查询 short_link_goto 表会更快,IO 开销更小,从而极大地提升了跳转服务的性能。
  • 问题 : 代码中 catch (DuplicateKeyException ex) 是在处理什么问题?为什么会发生这个异常?
    • 考察点: 高并发下的数据一致性问题、乐观锁思想。
    • 深度追问 :
      • 这是一种什么并发控制策略?(乐观锁 vs 悲观锁)
        • 回答 : 这是一种乐观锁 策略。它假设冲突很少发生,所以先尝试执行数据库插入操作。只有当数据库因为唯一键约束(fullShortUrlshortUri)而抛出 DuplicateKeyException 时,才认为发生了冲突。
      • 除了这种 "先操作,后处理" 的方式,还有什么其他方法可以避免或处理这种并发冲突?
        • 回答 :
          • 悲观锁: 在生成短链接前,先对某个资源(如一个计数器或特定行)加锁,确保同一时间只有一个线程能生成,这会严重影响性能。
          • 分布式锁: 使用 Redis 或 Zookeeper 等实现分布式锁,锁定一个生成区间,避免不同节点生成重复 ID。
          • 预先生成 ID: 由一个专门的 ID 生成服务(如雪花算法、号段模式)预先生成一批唯一的短链接后缀,应用服务器从 ID 服务获取 ID 再进行数据库操作,将并发冲突提前解决。

二、 缓存与性能优化

1. 布隆过滤器 (BloomFilter)
  • 问题 : 这里为什么要用布隆过滤器?它解决了什么问题?
    • 考察点: 对缓存穿透问题的理解和解决方案。
    • 深度追问 :
      • 布隆过滤器的工作原理是什么?它有什么优缺点?
        • 回答: 原理是使用多个哈希函数将元素映射到位数组的多个位置并置 1。查询时,如果所有映射位都为 1,则认为 "可能存在";如果有任何一位为 0,则 "一定不存在"。
        • 优点: 空间效率和查询时间都极高。
        • 缺点 : 存在误判率(False Positive),即它可能会把一个不存在的元素误判为存在;且一般不支持删除操作。
      • 布隆过滤器的误判率在你的代码中会导致什么后果?
        • 回答 : 如果布隆过滤器误判一个新生成的短链接已存在,generateSuffix 方法会错误地进入下一次循环,进行重试。这虽然不会导致数据错误,但会降低生成效率
      • 如果布隆过滤器服务挂了,所有数据都丢失了,你该如何恢复?
        • 回答 : 可以通过扫描数据库中所有已存在的短链接(fullShortUrl),重新构建布隆过滤器。在恢复期间,可以暂时降级,直接查询数据库进行判重,但这会对数据库造成巨大压力。
2. Redis 缓存
  • 问题 : 为什么在数据库写入成功后,还要往 Redis 里写一份缓存?
    • 考察点: 缓存的作用、读写策略。
    • 深度追问 :
      • 这属于哪种缓存更新策略?(Cache Aside Pattern)
        • 回答: 这是典型的 "先更新数据库,再更新缓存" 策略。
      • 这种策略可能会带来什么问题?
        • 回答 : 数据一致性问题。如果在更新数据库成功后、更新缓存前,有另一个请求来读取数据,它会读到缓存里的旧数据。更严重的是,如果更新缓存时失败了,就会导致数据库和缓存数据永久不一致。
      • 缓存的过期时间是如何设置的?为什么这么设计?
        • 回答 : LinkUtil.getLinkCacheValidTime(...) 表明缓存的过期时间与短链接的有效期绑定。这是一种非常合理的设计,可以自动清理掉已失效的短链接缓存,避免内存浪费。

三、 核心算法与 ID 生成 (generateSuffix 方法)

1. 哈希冲突与随机性
  • 问题 : 为什么要在 originUrl 后面拼接一个 UUID?直接对 originUrl 进行哈希不行吗?
    • 考察点: 对哈希算法特性和冲突问题的理解。
    • 深度追问 :
      • 如果不加 UUID,会出现什么问题?
        • 回答 : 如果两个不同的用户请求为同一个 originUrl 创建短链接,不加 UUID 会导致两次哈希输入完全相同,从而生成完全相同 的短链接。这可能不是我们想要的结果(我们可能希望每个请求都生成一个独立的短链接)。加了 UUID 后,每次哈希的输入都是唯一的,从根本上避免了这种因输入相同而导致的冲突。
      • 这能完全避免哈希冲突吗?
        • 回答 : 不能。哈希算法本身就存在将不同输入映射到相同输出的可能性(哈希冲突)。但 UUID 极大地降低了这种冲突的概率,使得在实际应用中可以忽略不计。
2. Base62 编码
  • 问题 : 为什么选择 Base62 而不是 Base64 或者其他编码方式?
    • 考察点: 对 URL 安全和编码效率的理解。
    • 深度追问 :
      • 回答 : Base64 包含 +, /, = 等字符,这些字符在 URL 中有特殊含义,需要进行转义(Percent-encoding),会使 URL 变长且不美观。Base62 ([0-9a-zA-Z]) 只包含 URL 安全的字符,因此生成的短链接可以直接使用,无需转义。

四、 海量数据与架构设计

  • 问题 : 如果短链接的数量达到上亿甚至十亿级别,你的数据库设计和查询方式会遇到什么挑战?如何解决?
    • 考察点: 对海量数据存储和数据库分库分表的理解。
    • 深度追问 :
      • 单表数据量过大会导致什么问题?(查询慢、索引失效、DDL 困难)
      • 你会如何进行分库分表?分库键(Sharding Key)应该如何选择?
        • 回答 : 通常会选择 gid (分组 ID) 或 fullShortUrl 作为分库分表键。
          • gid : 同一个用户或同一场景下的短链接会落在同一个分片,便于管理和统计,但可能导致数据热点(某个gid下的链接特别多)。
          • fullShortUrl 哈希分: 数据分布更均匀,能更好地打散热点,但跨分片查询会变复杂。
      • 分库分表后,SELECT * FROM short_link_goto WHERE full_short_url = ? 这样的查询如何高效执行?
        • 回答 : 如果 full_short_url 是分表键,那么可以直接根据哈希路由到对应的分片查询。如果不是,则需要进行广播查询,性能会下降。因此,short_link_goto 表非常适合用 full_short_url 作为分表键。
相关推荐
慕白Lee2 小时前
Java foreach在lambda的foreach遍历中退出操作(lambda foreach break)
java
winfield8212 小时前
Java 中大量闲置 MySQL 连接的解决方案(从根因到落地)
java·mysql
moxiaoran57532 小时前
Java开发中VO的使用
java·开发语言
计算机毕设指导62 小时前
基于微信小程序图像识别的智能垃圾分类系统【源码文末联系】
java·spring boot·mysql·微信小程序·小程序·分类·maven
LJianK12 小时前
前后端接口常见传参
java·spring
独自破碎E2 小时前
消息队列如何保证消息的有效性?
java·开发语言·rocketmq·java-rocketmq
3824278272 小时前
使用 webdriver-manager配置geckodriver
java·开发语言·数据库·爬虫·python
骚戴2 小时前
2025企业级架构演进:重构 Java/Python 的 RAG 与 Agent 系统的六种核心策略
java·人工智能·大模型·llm·api
悟空码字2 小时前
SpringBoot读取Excel文件,一场与“表格怪兽”的搏斗记
java·spring boot·后端