Know Your Wisdom

短链系统设计

2025-04-29

功能说明

功能免费版1 元试用个人版企业版
短链
活码
访问统计
过滤爬虫
微信访问10 天内有效
数据看板
访问密码
实时修改
导出数据
自定义域名
A / B 测试
批量创建
收集手机号
填写表单后访问
地域限制
引导浏览器打开
定制开发

短链算法

使用数据库主键(pk)一一映射到短链码(code),这个映射的过程使用的是FF3-1 保形加密算法

Golang 加密算法开源https://github.com/dusty-cjh/golang-fpe

  • 为什么不使用雪花算法?
  1. 我们的短链长度只有 6 位,如果使用雪花算法,8 字节的数据 base62 编码后会达到 11 位,不符合要求。
  2. 去年全年的营销短信数量在 100 亿左右,其中用到短链的有 60 亿,而短信是以字数(每70字)计费的。
  3. 6 位短链对应的 int 类型大小为 5 字节,对于 5 字节的雪花算法来说,不如直接用 sequenceID,即数据库 PK。
  • 为什么不直接用 PK 加密成 code?
    • PK 是 int64 类型,加密成 code 以后会变成 11 位的字符串,带来额外成本。

系统设计

创建短链的流程是这样的:

发号器

在创建短链时,发号器用来给 “创建短链” API 提供唯一的、连续的数据库 pk。

发号器的 3 层 fallback
发号器的 3 层 fallback

如上图所示:

  • 调用 getPk() 函数时,会先从本地缓存中获取 pk,
  • 如果 pk 的范围超出 maxPk,则会从 redis 中 incrBy xxx 'concurrency' 批量获取 pk,更新到本地缓存中。
  • 如果 redis 中的 pk 过期,则会从数据库中 SELECT MAX(pk)

上图流程中的不足:

  • 当本地 pk 耗尽时,需要等待从 Redis 获取 pk 的结果
  • 当 Redis Key 过期时,需要等待从 DB 获取 pk 的结果

优化建议:

  • 剩余本地缓存Pk量 < 当前并发量 时,主动并发拉取新的 Redis Pk
  • Redis 使用自动续期:Redis Pk = Max(maxRedisPk, maxDBPk)

为什么当前不优化?

  • 根据 pprof 分析,最大的耗时在DB 插入RabbitMQ 统计数据 上,而 Redis 获取 pk 这部分的占比已经可以忽略不计了。
  • 在下一节实现 “DB 批量插入” 后,最大的性能损失就只在 RabbitMQ 统计数据 上了。

DB 批量插入

在创建短链时,”DB批量插入“ 用来把单个创建批量化,减少数据库压力,同时能同步返回插入结果。思路就是缓存正在进行的插入操作,每间隔 50ms 启动用于批量插入的 goroutine 一次。

批量化数据库插入操作
批量化数据库插入操作

如图所示,每个队列中的 obj 都有一个 mutex 锁和 ch 对象。左边的 goroutine 会在批量插入操作开始前获取锁,批量插入操作结束后释放锁, 并将结果通过 ch 发送到右边的 goroutine。

可以使用 atomic.CompareAndSwap 函数保证同一时间只有一个批量插入的 goroutine 在运行。

func singleInsert() {
  ...

  //  仅当没有预备中的 goroutine 时,预备新的 gouroutine
  if atomic.CompareAndSwap(&BatchInsertPreparingFlag, 0, 1) {
    go time.AfterFunc(50ms, batchInsert)
  }

  ...
}

func batchInsert() {
    //  允许预备下一个 BatchInsert 操作
    isPrepared := atomic.CompareAndSwapInt32(&BatchInsertPreparingFlag, 1, 0)

    //  确保同一时刻只有一个 BatchInsert 正在运行
    if !atomic.CompareAndSwapInt32(&BatchInsertRunningFlag, 0, 1) {
        if isPrepared {
            isPrepared = atomic.CompareAndSwapInt32(&slBatchInsertPreparingFlag, 0, 1)
            if isPrepared {
                go time.AfterFunc(slTimeout, this.doCreate)
            }
        }
        return
    }
    defer func() {
        if !atomic.CompareAndSwapInt32(&BatchInsertRunningFlag, 1, 0) {
            panic(fmt.Errorf("short link manager unexpected 0"))  //  不可能触达
        }
    }()

  ...
}

通过添加 Prepare goroutine 的方法,可以避免因为竞态问题导致部分 SingleInsert 没被执行到。可以用反证法来证明这一点。

竞态问题 1:

当 A 插入并且 go BatchInsert 时,B 未 go BatchInsert,且 BatchInsertABatchInsert_A 并未获取到 B 的数据。导致 B 永远不会被插入。

发生条件:

BatchInsertABatchInsert_A 已启动未运行,B 走到了 atomic.CompareAndSwap(&BatchInsertPreparingFlag, 0, 1),发现已有在预备的 goroutine,遂跳过。

BatchInsertABatchInsert_A 开始正常运行,获取到 A,但未获取到 B 的数据。

但由于B 入队列操作在 atomic.CompareAndSwap(&BatchInsertPreparingFlag, 0, 1) 检查之前,所以此时 B 数据必然已入队列,可被成功发现。

证明完毕

竞态问题 2:

当 A 插入并且 go BatchInsert 时,B 也 go BatchInsert,但直到 BatchInsertBBatchInsert_B 协程超时,BatchInsertABatchInsert_A 依然卡在插入DB这一步上,导致 BatchInsertBBatchInsert_B 取锁失败直接退出,B永远不会被插入。

解决方案:BatchInsertBBatchInsert_B 运行时,探测是否有正在运行的 BatchInsertBatchInsert 操作,如果有,则重新 Prepare goroutine。

最终的效果就是,当高负载时,总有一个 Prepare goroutine 在等待执行。当低负载时,不会有任何额外的 BatchInsertBatchInsert 执行。

审核

以下情况会触发审核:

  • 创建
  • log2(访问量) 为整数
  • 更新后有新访问量

审核机制:

  1. 使用 playwright 模拟浏览器访问,截屏
  2. LLM 输入 截屏 / 页面标题 / 页面描述,输出 0-1 之间的 spam 分值,以及判分理由
  3. 超出阈值则封禁链接

系统设计:

远端使用 Redis ZSet 存储需要审核的链接,根据时间戳排序。本地使用 CronJob 定时从 ZSet 拉取数据审核。

弊端:

源链接通过判断 User-Agent / 是否移动设备 / 是否微信环境 / 访问者所处IP城市 来决定自己展示什么内容,防不胜防。 AI 审核机制只能过滤基础的垃圾链接,一旦链接通过技术手段伪装自己,将很难被识别。

数据统计

效果:

  • 系统面板展示今日、过去 2 周创建、访问量
  • 用户面板展示今日、过去 2 周创建、访问量
  • 单个短链可展示小时级的访问曲线

实现:

当用户访问短链时会触发 RabbitMQ 消息,数据统计操作在消息队列中发生。

消息体结构:

// MsgAppPv 表示短链访问统计的消息体结构。
type MsgAppPv struct {
    Date           string // 访问日期(如 "2024-06-25")
    Url            string // 访问的短链 URL
    ClientUid      string // 客户端唯一标识(回溯)
    PK             int    // 短链主键
    UserAgent      string // 客户端 User-Agent
    IP             string // 访问者 IP 地址
    Referrer       string // 来源页面 URL
    RequestedWith  string // 请求来源(判断源站,提供完整访问上下文信息)
    ShortLinkOwner int64  // 短链所有者用户ID
}

当日访问数据保存在 Redis,次日通过异步任务存入 DB。

主要保存的数据:

  • 系统
    • 每日访问量
  • 短链
    • 链接ID + 用户ID 的日访问量
    • 链接ID 的小时访问量
    • 全量的访问详情

压力测试

  • 机器:Mac Book Pro M1 (2020款)
  • 数据库:MongoDB

支持每秒 2500+ 同时创建。

Create API 压测结果
Create API 压测结果

支持每秒 4000+ 同时访问。

Visit API 压测结果
Visit API 压测结果

例如,点击 hdcjh.xyz/mgex8 访问短链。

技术支持