使用Go做一个分布式短链系统

文章目录

短链系统

1、生成短链

  1. 携带long_url查询缓存,如果缓存中存在对应的短链,存在就直接返回;
  2. 查询mysql,如果存在就直接返回短链;
  3. 通过雪花算法生成ID,使用base62将ID转换为短链;
  4. 保存短链到布隆过滤器和缓存中,再保存到数据库中;
  5. 返回短链。

2、查询短链

  1. 利用Lua脚本,同时查询布隆过滤器和Redis缓存;
  2. 如果缓存中存在了对应长链,直接重定向到对应长链;
  3. 如果布隆过滤器中不存在,直接返回404;否则查询DB确认是否存在对应的长链,如果存在重定向到记录对应的长链。

方案调研

1、重定向状态码

永久重定向:301

301状态码代表永久重定向,只要浏览器拿到长链之后就会对其缓存,下次请求的短链就直接从缓存中拿到对应的长链地址。这样就导致了,我们无法对短链进行分析。

临时重定向:302

302状态码代表临时重定向,就比如我有一个活动链接,我想统计这个短链后续的点击情况,使用301状态码就不行,因为是从浏览器缓存中得到的长链地址。

https://tsejx.github.io/javascript-guidebook/computer-networks/http/http-status-code/

301 Moved Permanently:表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。

302 Found:表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。

304 Not Modified:(未修改)自从上次请求后,请求的网页未修改过。 服务器返回此响应时,不会返回网页内容。

为什么选择302?

2、短链生成算法

  • 哈希算法
  • 唯一ID算法
    • MySQL自增ID
    • Redis自增ID
    • Snowflake 雪花算法

3、base62算法

将获取的ID进行62进制编码,使其更加简短。

go 复制代码
// base62Encode 将十进制数字转换为62进制
func base62Encode(decimal int64) string {
    var result strings.Builder
    for decimal > 0 {
        remainder := decimal % 62
        result.WriteByte(characters[remainder])
        decimal /= 62
    }
    return result.String()
}

// shuffleString 打乱字符顺序,避免被猜测到
func shuffleString(input string) string {
    // 洗牌算法,打乱字符串的字符顺序
    chars := strings.Split(input, "")
    for i := len(chars) - 1; i > 0; i-- {
        // 获取一个0-i之间的随机索引
        j := globalRand.Intn(i + 1)
        chars[i], chars[j] = chars[j], chars[i]
    }
    return strings.Join(chars, "")
}

架构优化

1、缓存和布隆过滤器优化接口查询性能

什么是布隆过滤器?

布隆过滤器是用来检索一个元素是否存在于一个集合中,是由很长的二进制向量与一系列随机函数构成。

布隆过滤器

布隆过滤器是用来检索一个元素是否存在于一个集合中。

布隆过滤器是由很长的二进制向量与一系列随机映射函数构成。

使用场景

  • Redis通过布隆过滤器防止缓存穿透
  • RocketMQ通过布隆过滤器防止消息重复消费

短链查询的优化

1. 布隆过滤器检查:

  • 在用户请求一个短链时,首先使用布隆过滤器来判断该短链是否可能存在于缓存中。布隆过滤器是一种空间效率高的数据结构,能够快速检查元素是否在集合中,但可能会产生假阳性(即错误地认为某个元素存在)。
  • 如果布隆过滤器返回"可能存在",则继续查询缓存。
  1. 查询缓存:
  • 如果布隆过滤器认为短链可能存在,接下来在缓存中查询该短链的实际数据。这通常涉及到从内存中快速检索数据,以提高访问速度。
  • 如果在缓存中找到了短链,则提取出其对应的原始 URL,并进行重定向。
  1. 查询数据库:
  • 如果缓存中未找到短链数据,系统将查询数据库。这一步是为了确保获取最新的信息,因为缓存可能会过期或未更新。
  • 如果在数据库中找到了短链,提取出原始 URL 并重定向到该位置。
  1. 返回404:
  • 如果在数据库中也未找到短链,则返回404错误,表示请求的资源不存在。这是一个标准的HTTP响应,告知用户所请求的短链无效。
go 复制代码
// internal/biz/v3.go
func (uc *UrlMapUseCase) GetLongUrlV3(ctx context.Context, shortUrl string) (string, error) {
    // 从布隆过滤器中查询以及缓存中查询
    need, longUrl, err := uc.repo.FindShortUrlFormBloomFilterAndCache(ctx, shortUrl)
    // 发生错误 || longUrl不为空 || 不需要查询DB, 直接return
    if err != nil || longUrl != "" || need == 0 {
        return longUrl, err
    }

    // 从数据库中查询
    return uc.repo.GetLongUrlFormDb(ctx, shortUrl)
}

// internal/data/url_map.go
func (r *urlMapRepo) FindShortUrlFormBloomFilterAndCache(ctx context.Context, shortUrl string) (int64, string, error) {
    // 利用Lua脚本,先从布隆过滤器中查询,如果存在,再从缓存中查询
    res, err := r.data.cache.EvalResults(ctx, findShortUrlFormBloomFilterAndCacheLua, []string{shortUrlBloomFilterKey, shortUrlPrefix + shortUrl}, shortUrl)
    if err != nil {
        return 0, "", err
    }

    resSLice := res.([]interface{})
    need := resSLice[0].(int64)
    longUrl := resSLice[1].(string)
    return need, longUrl, nil
}

// Lua脚本
    // 访问布隆过滤器以及缓存
    findShortUrlFormBloomFilterAndCacheLua = `
          local bloomKey = KEYS[1]
          local cacheKey = KEYS[2]
          local bloomVal = ARGV[1]
        -- 检查val是否存在于布隆过滤器对应的bloomKey中
          local exists = redis.call('BF.EXISTS', bloomKey, bloomVal)
        -- 如果bloomVal不存在于布隆过滤器中,直接返回空字符串, 返回0代表不需要查db了
          if exists == 0 then
              return {0, ''}
          end
        -- 如果bloomVal存在于布隆过滤器中,查询cacheKey
          local value = redis.call('GET', cacheKey)
        -- 如果cacheKey存在,返回对应的值,否则返回空字符串
          if value then
              return {0, value}
          else
              return {1, ''}
          end
        `

2、雪花算法

雪花算法的实现原理:

雪花算法是一种随时间变化的分布式全局唯一ID算法,其生成的ID可以看做是一个64位的正整数,除了最高位,将剩余的63位分别分为41位的时间戳,10位的机器ID以及12位的自增序列号。

我们不采用MySQL的主键自增ID和redsi的incr的自增ID,而是使用本地雪花算法的形式直接生成ID,这样性能更高。

go 复制代码
// internal/biz/v3.go
func (uc *UrlMapUseCase) GenerateShortUrlV3(ctx context.Context, longUrl string) (string, error) {
    // 先查询缓存里面是否对应的短链
    shortUrl, err := uc.repo.GetShortUrlFormCache(ctx, longUrl)
    // 有错误 或者 缓存中有这个短链,直接返回
    if err != nil || shortUrl != "" {
        return shortUrl, err
    }

    // 再查询数据库里面是否有长链 对应的短链
    shortUrl, err = uc.repo.GetShortUrlFormDb(ctx, longUrl)
    // 有错误直接返回
    if err != nil {
        return "", err
    }

    // 如果有,顺便保存到缓存中
    if shortUrl != "" {
        _ = uc.repo.SaveToCache(ctx, longUrl, shortUrl)
        return shortUrl, nil
    }

    // 还是没找到, 那就利用雪花算法生成ID
    id := snowflake.GenID()
    // 利用base62算法,生成短链
    shortUrl = generateShortUrl(id)

    // 将短链保存到布隆过滤器中
    err = uc.repo.SaveToBloomFilter(ctx, shortUrl)
    if err != nil {
        return "", err
    }

    // 保存到缓存中, 以便下次查询
    err = uc.repo.SaveToCache(ctx, longUrl, shortUrl)
    if err != nil {
        return "", err
    }

    // 保存到数据库
    _, err = uc.repo.CreateToDb(ctx, &UrlMap{ShortUrl: shortUrl, LongUrl: longUrl})
    if err != nil {
        return "", err
    }

    // 返回短链
    return shortUrl, nil
}

1.你用了雪花算法,处理时钟回拨问题,你能说一下具体的情况,以及处理方法吗?

造成时钟回拨的原因就是 服务器的系统时间被调整到过去,那么就会生成重复的ID。

在原始的雪花算法中,生成全局唯一 ID 的结构为 64 位,其中第一位为符号位,接下来的 41 位表示时间戳,10 位用于机器 ID,最后 12 位为序列号。为了解决时钟回拨问题,我们可以将时间戳和机器 ID 的位置进行交换,新的结构将为:1 位符号位 + 10 位机器 ID + 41 位时间戳 + 12 位序列号。

在机器启动时,系统将记录一个初始时间戳,并基于此时间戳生成一个初始 ID。后续每次请求下一个 ID 时,系统将通过对当前 ID 进行自增操作来获取新的 ID,并将其返回。

这种设计具有以下几个优点:

  1. 弱依赖于系统时钟:由于时间戳位于低位,ID 的唯一性不再完全依赖于系统时钟的连续性。当时钟回拨发生时,机器 ID 的高位仍然可以确保不同节点生成的 ID 不会冲突。此外,序列号的存在允许在同一毫秒内生成多个唯一 ID。
  2. 提高并发性能:通过自增操作生成下一个 ID,可以在高并发情况下有效降低生成 ID 的延迟,满足大规模分布式系统的需求。
  3. 易于管理:初始 ID 的生成基于启动时的时间戳,后续 ID 的生成只需简单的自增操作,降低了生成过程的复杂性。

2.既然不使用时间戳,那加1怎么去加1的?

  1. 启动时记录初始时间戳,初始化序列号为 0。
  2. 每次请求 ID 时:
  • 如果当前时间戳与上次相同,序列号加 1。
  • 如果不同,更新时间戳并重置序列号为 0。
    这样,序列号确保在同一毫秒内生成唯一 ID。

3.如果必须要求生成的全局ID是有序的呢?

那就使用原来的雪花算法,如果发生了时钟回拨,那就等一段时间,或者直接返回异常(提示时间不对,无法生成新ID)。也可以使用分布式ID生成服务,比如美团leaf(在分布式场景下,会存在网络延迟,不可能完全递增插入,只能趋势是递增的)

4.还了解其他分布式ID生成方案吗?

  1. 使用uuid算法生成唯一ID(ID为36个字符,ID太长存储成本高,ID随机生成,可读性差)。
  2. 利用数据库主键自增来生成唯一ID(数据库宕机问题)。
  3. Redis INCR设置一个全局计数键来生成唯一自增ID,原子操作。(Redis宕机问题)
  4. 美团leaf-segment和leaf-snowflake。
  5. 雪花算法。

5.如何保证全局递增?

  1. 利用数据库主键自增来生成唯一ID(数据库宕机问题)。
  2. Redis INCR设置一个全局计数键来生成唯一自增ID,原子操作。(Redis宕机问题)。
  3. 原本的雪花算法(时钟回拨问题)。

6.并发量怎么上去?

采用leaf-segment方案。

Leaf-Segment 通过预分配一段可用的编号ID,而不是每次请求时都与数据库交互生成单个 ID。

原始的leaf方案,在号段交替期间可能造成阻塞问题,所以采用双buffer方案,当号段消费到某个点时就异步的把下一个号段加载到内存中。(支持高并发,但是强依赖DB)

7.Redis怎么获取ID?

初始化一个ID为0,没接收一个获取ID的请求,就是一共INCR,给ID+1,返回这个ID。

会暴露访问次数,可以使用leaf,预先生成一批ID,并返回一段ID,而不是每次都从计数器中获取,这样会有效隐藏访问量。

精华

相关推荐
Scott9999HH4 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
码智社5 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海5 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖6 小时前
JDK 26 新特性详解
java·开发语言
马优晨7 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
Database_Cool_7 小时前
单机 MySQL 迁移到分布式数据库方便吗?阿里云 PolarDB-X 100% MySQL 协议兼容零改造平滑迁移
数据库·分布式·mysql
人邮异步社区8 小时前
怎么把C语言学到精通?
c语言·开发语言
心平气和量大福大9 小时前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf
ttwuai9 小时前
Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验
开发语言·后端·golang
এ慕ོ冬℘゜9 小时前
前端基础:什么是时间戳?JS获取时间戳三种方法与实战用途
开发语言·前端·javascript