← 返回文章列表

GoLink

一個短連結專案的設計與實現:生成策略、資料庫、快取和重定向。

daily

一個簡單實用的短連結專案。專案地址

短連結簡介

是什麼

短連結通俗來講是將一個比較長的 URL 透過程式計算的方式轉換成簡單的簡短的網址連結。

我平時收到最多的短連結就是京東發的,下面是一個例子

【京東】北京國補狂歡限時猛料,26年國補正式上線!上京東領補貼立省15%,限時搶,手慢無!搶 3.cn/v/1-FT59nd 拒收請回復R

我們點選之後得到的 URL 是:

https://pro.m.jd.com/mall/active/4YSZ3d72rtEfxiWiDVcWRBEjCbSd/index.html?babelChannel=ttt58

正是透過短連結的方式將原本長長的地址換成了一個簡短的,並且讓人讀不懂的連結。

很多公司也有提供短連結服務:

  • 百度:dwz.cn

  • 微博:t.cn

  • 。。。

為什麼

一般公司內部會有很多需要傳送連結的場景,並且業務側的非常細緻,導致連結非常長,在傳送簡訊,IM 工具傳送訊息以及 push 等場景會會有很多劣勢,比如:

  • 簡訊內容可能超長,截斷會花更多的錢,資訊也不完整

  • 社交平臺的字數限制

  • 還有的平臺監管機制比較嚴格,能識別關鍵字,判定為違規,比如小某書、某紅書和小紅某

  • URL 是可以轉換成二維碼的,如果太長,轉換成的二維碼就會非常複雜,短連結轉換成的二維碼更加清晰

這個時候將長連結轉換成短連結就顯得非常重要。

怎麼做

daily

瀏覽器並不能將短連結轉換成長連結,這裡涉及到一個重新定向的概念。

瀏覽器收到短連結的地址,向公司的短連結伺服器傳送請求,公司返回資料和 302 重定向的狀態碼,之後瀏覽器進行重定向,向真正的業務伺服器傳送請求。

daily

專案設計

需求分析

背景

公司內部業務需要大量傳送營銷簡訊、通知類的簡訊,在客服電話推銷之後要傳送到簡訊配套營銷。

需要一個短連結服務滿足各個業務線的使用,現階段是提供轉連結的介面,後續可以提供點選連結統計的資料包表分析介面

描述

  1. 輸入一個長網址得到一個唯一的短網址

  2. 使用者點選短連結能夠跳轉到對應的網址

  3. 為了保證業務的延續性,短網址長期有效

定位

  1. 只是公司內部業務使用的短連結服務,只接收公司內部的長連結轉短連結需求,不提供對外的服務

  2. 基本在國內使用

  3. 後續可能會根據需求提供短連結的訪問資料包表介面

規模

  1. 服務於公司內部多少條業務線

  2. 使用者的規模是什麼量級

  3. QPS 是多少

    1. 注:QPS是指每秒查詢率(Queries Per Second),是衡量系統效能的一個重要指標,表示系統每秒能夠處理的查詢請求數量。在資料庫、網路、伺服器等領域中,QPS是一個常用的效能指標,通常越高表示系統效能越好。

技術指標

  • 請求延時,多少 ms之內要完成整個過程

  • 可靠性,保證 99.99% 能正常跳轉

  • 安全性,不能資料洩露,被攻擊

功能模組分析

根據需求分析,可以將需求拆分成轉鏈模組、儲存模組和訪問連結模組,以及可以拓展的資料統計模組

  1. 轉鏈模組

    1. 相同的長連結轉成同一個短連結(也可以根據個人資訊轉成不同的,比如推廣機制,精確到人)

    2. 生成的短連結為儘可能短的字元

    3. 需要避免出現一些不合適的詞語和有些具有特殊含義的詞語

    4. 需要避免迴圈轉鏈(把已經是短鏈的再拿出來轉短鏈)

  2. 儲存模組

    1. 儲存原始長連結和短連結之間的對應關係

    2. 能夠根據短連結查到原始的長連結

  3. 檢視連結模組

    1. 根據連結查詢到長鏈後返回重定向響應

    2. 後續資料包表需求可能需要採集並統計請求頭資料

系統設計

根據實際的業務場景,我們能夠發現存入的連結是少的,訪問的連結是多的,這是一個典型的讀多寫少的系統。

並且還有個特點就是資料寫入之後基本不需要改變,這樣資料一致性問題其實可以忽略,能夠使用快取系統來提高讀的效率。

整體框架

daily

  • 長連結轉短連結

    • 單獨部署為一個微服務(轉鏈服務)

    • 對其他服務提供轉鏈服務,需要鑑權

      • 透過RESTful API呼叫我們的轉連結口

      • 透過RPC方式呼叫我們的轉鏈方法(自己實現一個RPC版本的轉鏈)

  • 檢視短連結

    • 單獨部署為一個服務(檢視短鏈服務)

    • 透過 nginx 轉發檢視請求,/[0-9+a-z+A-Z]* —> 轉發到我們的查鏈服務

    • 透過 access.log 收集(EFK)並統計訪問資料

技術方案

短連結生成方式

  • Hash

    • 使用hash 函式對長連結進行 hash,得到的 hash 值作為短連結識別符號

    • 這樣做的優勢比較簡單,但是如果資料量大會出現雜湊衝突

    • MurmurHash 是一種非加密型雜湊函式,和其他流行的雜湊函式相比,對規律性比較強的 key隨機分佈特性表現更加優良,這種演算法隨機分佈特性表現好,演算法速度快。

  • 發號器/自增序列

    • 每收到一個長連結轉短連結請求之後,就使用發號器生成遞增,然後將該序列號轉成 62 (10+26*2)進位制,最後拼接到短域名之後就得到最後的最終短鏈。

    • 發號器能生成 id的自增,理論上容量足夠滿足實現需求。但是高併發的情況下發號器的設計比比較難,因為要實現合理的自增。

    • 發號器的實現方式

      • 基於 uuid 實現

        • 優勢:不會重複、效能好

        • 劣勢:數字太大,有 32 位的 16 進位制數

      • 基於 redis 實現發號器

        • 優勢:高效能

        • 劣勢:需要搭建高可用架構並考慮持久化

      • 基於雪花演算法的分散式 ID生成器

        • 優勢:高效能、高可用

        • 劣勢:實現複雜,依賴時鐘

      • 基於 MySQL 自增主鍵的發號器

        • 優勢:簡單可靠

        • 劣勢:依賴 MySQL,效能會成為瓶頸,但是可以透過分片拓展可用性

  • 基於 MySQL 自增主鍵實現發號器

    • 我們的專案採用基於 MySQL 資料庫主鍵作為發號器的方案

    • 構建一個資料表,這個表的結構比較簡單,不能包含其他多餘的資料欄位

    • 每有一次轉鏈請求,我們都會在資料表表中插入一個記錄,我們可以使用該表自增 ID 作為生成的號碼

    • 使用 MySQL replace,如果表中的舊行和新行在 primary key 或 unique 索引具有相同的值,就在新行之前刪除舊行。

    • 為了避免單點故障,我們將ID 生成器分成奇數和偶數兩個部分,分別部署在兩個 MySQL 伺服器。

    server1:
    auto-increment-increment = 2
    auto-increment-offset = 1
    
    server2:
    auto-increment-increment = 2
    auto-increment-offset = 2
    • 下面是資料表建立和相關 SQL 語句
    /* 序号表的设计 */
    
    CREATE TABLE `sequence` (
        `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        `stub` varchar(1) NOT NULL,
        `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        PRIMARY KEY (`id`),
        UNIQUE KEY `idx_uniq_stub` (`stub`)
    
    ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
    
    REPLACE INTO sequence (stub) VALUES ('a');
    SELECT LAST_INSERT_ID();

簡單測試

daily

資料表

  • 資料表設計

    • lur1和 sur1都唯一,長鏈和短鏈都不允許重複

    • 長連結的資料量大不適合建索引,所以這裡使用md5值來做索引

    • is_del是軟刪除標識

    • 可以採用讀寫分離的模式,寫主庫、讀從庫

    CREATE TABLE `short_url_map` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
        `create_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
        `create_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
        `is_del` tinyint UNSIGNED NOT NULL DEFAULT '0' COMMENT '是否删除:0正常 1删除',
        
        `lurl` varchar(2048) DEFAULT NULL COMMENT '长链接',
        `md5` char(32) DEFAULT NULL COMMENT '长链接MD5',
        `surl` varchar(11) DEFAULT NULL COMMENT '短链接',
        PRIMARY KEY(`id`),
        INDEX(`is_del`),
        UNIQUE(`md5`),
        UNIQUE(`surl`)
    ) ENGINE=INNODB DEFAULT CHARSET=utf8mb4;
  • 資料量

    • URL 網址的最大長度不同瀏覽器下不同

    • 1 條長鏈+短鏈需要的儲存空間假設為 200bytes/條

    • 假設每個業務線每秒寫入 100 條,10 條線就是 1000 條/秒

    • 一主兩備份共三份資料,索引等冗餘系統 1.5

    • 這樣 200*1000*3600*24*365*3*1.5大概等於 20T

  • 資料刪除

    • 本專案不涉及連結配置過期時間,所有刪除均為軟刪除,透過手動標記實現刪除

預防攻擊

  • 對外服務

    • IP 請求限制

    • 使用者限制轉鏈額度

    • 記錄已轉連結的 URL 快取,防止刷光 ID,適用於相同的 URL 可以轉成不同短連結的場景

      • LRU 快取 URL

      • 布隆過濾器

    • 校驗連結是否有效

  • 對內服務

    • 許可權認證

    • 賬戶每日限額

    • 檢驗連結是否有效

訪問短鏈方式

  • 使用 Redis 作為快取+MySQL 進行持久化儲存

  • 短連結請求進來之後先根據識別符號查詢快取中的,有就返回,沒有就查詢,MySQL 資料庫,查到就返回重定位響應,也才寫入快取中

  • 關於快取設定

    • 採用 LRU 方式

    • Redis 叢集部署

    • 根據資料量設計記憶體的大小,使用 LRU 淘汰策略,移除最近最少使用的 key

  • 環保節快取擊穿的問題

    • 過期時間加大

    • 加鎖

    • 使用 singleflight 合併請求

  • 解決快取穿透的問題

    • 透過布隆過濾器,不儲存原始資料,如果過濾器找不到一定不在,找到了就查詢資料庫,可能不存在

    • 布穀鳥過濾器,支援刪除本專案比較適合使用布穀鳥過濾器

部署

我們的短鏈服務部署在 Nginx 後。透過這種方式,可以透過 Nginx 的訪問日誌(access.log)來統計訪問資料。(例如透過EFK採集日誌,統計報表)

專案管理

使用 git

技術棧總結

Golang Go-Zero MySQL Redis Git Nginx EFK

專案實現

1、搭建專案骨架

1.1、建庫建表

使用 docker建立的 MySQL 容器,在其中進行建庫建表,具體過程如下圖:

daily

1.2、搭建 go-zero框架的骨架

我們使用 goctl進行框架程式碼的生成

daily

api 檔案

syntax = "v1"

// 进行项目的简单说明
info (
    title:   "短链接项目"
    author:  "aorangehc"
    date:    "2025年 8 月 5 日"
    version: "v1"
)

type ConvertReq {
    LongUrl string `json:"longUrl"`
}

type ConvertResp {
    ShortUrl string `json:"shortUrl"`
}

type ShowReq {
    ShortUrl string `json:"shortUrl"`
}

type ShowResp {
    LongUrl string `json:"longUrl"`
}

@server (
    prefix: "/api/shortener"
)
service shortener_api {
    @handler ConvertHandler
    post /convert (ConvertReq) returns (ConvertResp)

    @handler ShowHandler
    get /:shortUrl (ShowReq) returns (ShowResp)
}

model檔案

CREATE TABLE `sequence` (
    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    `stub` varchar(1) NOT NULL,
    `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_uniq_stub` (`stub`)

) ENGINE=MyISAM DEFAULT CHARSET=utf8;

CREATE TABLE `short_url_map` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
    `create_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `create_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
    `is_del` tinyint UNSIGNED NOT NULL DEFAULT '0' COMMENT '是否删除:0正常 1删除',
    
    `lurl` varchar(2048) DEFAULT NULL COMMENT '长链接',
    `md5` char(32) DEFAULT NULL COMMENT '长链接MD5',
    `surl` varchar(11) DEFAULT NULL COMMENT '短链接',
    PRIMARY KEY(`id`),
    INDEX(`is_del`),
    UNIQUE(`md5`),
    UNIQUE(`surl`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4;

1.3、下載專案依賴

# 在根目录下使用
go mod tidy

1.4、執行專案

執行正常

daily

1.5、修改配置檔案和配置結構體

咋yaml 檔案中增加資料庫配置並在 config 檔案中進行相應引數的新增。

ShortUrlDB:
  DataSource: "user:password@tcp(127.0.0.1:3307)/golink?charset=utf8mb4&parseTime=True&loc=Local"

SequenceDB:
  DataSource: "user:password@tcp(127.0.0.1:3307)/golink?charset=utf8mb4&parseTime=True&loc=Local"
  
  type Config struct {
    rest.RestConf

    ShortUrlDB struct {
        DataSource string
    }

    SequenceDB struct {
        DataSource string
    }
}

2、轉鏈

長連結 -> 短連結

2.1、長連結校驗

資料不能為空

使用 validator 庫進行引數校驗

go get github.com/go-playground/validator/v10

之後在 api檔案中引數後面新增 validate 引數和設定

type ConvertReq {
    LongUrl string `json:"longUrl" validate:"required"`
}

type ConvertResp {
    ShortUrl string `json:"shortUrl"`
}

type ShowReq {
    ShortUrl string `json:"shortUrl" validate:"required"`
}

type ShowResp {
    LongUrl string `json:"longUrl"`
}

重新生成程式碼後修改 context 檔案並測試,資料校驗設定成功,對空資料會直接報錯。

// 参数校验
if err := validator.New().StructCtx(r.Context(), &req); err != nil {
    httpx.ErrorCtx(r.Context(), w, err)
    return
}

daily

長連結能夠進行請求

透過 http 標準庫建立一個 http 客戶端進行探活

daily

daily

判斷是否已經轉鏈

資料庫中使用 md5作為主鍵

先生成md5

之後查資料庫中的 md5是否存在

md5Str := md5.Sum([]byte(req.LongUrl))
u, err := l.svcCtx.ShortUrlModel.FindOneByMd5(l.ctx, sql.NullString{String: md5Str, Valid: true})
if err != sqlx.ErrNotFound {
    if err == nil {
        return nil, errors.New("该长链接已转链,短链接为:" + u.Surl.String)
    }
    logx.Errorw("ShortUrlModel.FindOneByMd5 error", logx.Field("err", err), logx.Field("md5", md5Str))
    return nil, err
}

// Sum 计算数据的 MD5 值,返回十六进制字符串表示
func Sum(data []byte) string {
    h := md5.New()
    h.Write(data)
    return hex.EncodeToString(h.Sum(nil))
}
    

避免迴圈轉連結

解析出連結的 basepath,然後查詢資料庫中是否是已經轉過的短鏈

basePath, err := urltools.GetBasePath(req.LongUrl)
if err != nil {
    logx.Errorw("GetBasePath error", logx.Field("err", err))
    return nil, err
}
u, err = l.svcCtx.ShortUrlModel.FindOneBySurl(l.ctx, sql.NullString{String: basePath, Valid: true})
if err != sqlx.ErrNotFound {
    if err == nil {
        return nil, errors.New("该链接已转链,短链接为:" + u.Surl.String)
    }
    logx.Errorw("ShortUrlModel.FindOneBySurl error", logx.Field("err", err), logx.Field("basePath", basePath))
    return nil, err
}

// GetBasePath 获取 url 的 base path
func GetBasePath(baseUrl string) (string, error) {
    myUrl, err := url.Parse(baseUrl)

    if err != nil {
        return "", err
    }

    basePath := path.Base(myUrl.Path)
    return basePath, nil
}

2.2、取號器實現

每來一個轉鏈請求,使用 replace into 語句往 sequence 表中插入一條資料,並取出 id 作為號碼。

我們在 model中增加新的方法,時間 replace 方法並返回最新的 id

// Replace 实现 repalce into语句,返回最后插入数据的 id
func (m *defaultSequenceModel) Replace(ctx context.Context, newData *Sequence) (int64, error) {
    query := fmt.Sprintf("replace into %s (%s) values (?, ?)", m.table, sequenceRowsExpectAutoSet)
    ret, err := m.conn.ExecCtx(ctx, query, newData.Stub, newData.Timestamp)
    if err != nil {
        return 0, err
    }
    id, err := ret.LastInsertId()
    if err != nil {
        return 0, err
    }
    return id, nil
}

測試結果正常。

daily

daily

2.3、62 進位制轉鏈

package base62

import (
    "math"
    "strings"
)

// 62进制转换
// 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
// 0-9:0-9
// a-z:10-35
// A-Z:36-61

// 为了避免安全性,打乱顺序
var (
    baseStr    string
    baseStrLen uint64
)

// BaseInit 初始化
func BaseInit(bs string) {
    if len(bs) == 0 {
        panic("baseStr is empty")
    }
    baseStr = bs
    baseStrLen = uint64(len(bs))
}

// Base62Encode 将数字转换为62进制
func Base62Encode(num uint64) string {
    if num == 0 {
        return string(baseStr[0])
    }
    var res []byte
    for num > 0 {
        res = append(res, baseStr[num%baseStrLen])
        num /= baseStrLen
    }

    return string(reverse(res))
}

// Base62Decode 将62进制转换为数字
func Base62Decode(str string) (seq uint64) {
    bl := []byte(str)
    bl = reverse(bl)

    for idx, b := range bl {
        base := math.Pow(float64(baseStrLen), float64(idx))
        seq += uint64(strings.Index(baseStr, string(b))) * uint64(base)
    }

    return seq
}

func reverse(s []byte) []byte {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
    return s
}

透過打亂全域性 62 進位制字串簡單保證安全性

透過設定全域性的短連結黑名單實現某些詞彙的出現

2.4、儲存和測試

結果儲存到資料庫中

// 4.存储长链接和短链接的映射关系
    if _, err := l.svcCtx.ShortUrlModel.Insert(l.ctx, &model.ShortUrlMap{
        Lurl: sql.NullString{String: req.LongUrl, Valid: true},
        Surl: sql.NullString{String: short, Valid: true},
        Md5:  sql.NullString{String: md5Str, Valid: true},
    }); err != nil {
        logx.Errorw("ShortUrlModel.Insert error", logx.Field("err", err))
        return nil, err
    }

    shortUrl := l.svcCtx.Config.ShortDoamin + "/" + short // shortUrl = 'http://localhost:8080/' short
    resp = &types.ConvertResp{
        ShortUrl: shortUrl,
    }

    return resp, nil

daily

daily

3、檢視短連結

3.1、實現短連結重定向介面

接收到短連結請求之後,重定向到真實的連結,同時進行測試

func (l *ShowLogic) Show(req *types.ShowReq) (resp *types.ShowResp, err error) {
    // 根据短链接查询长链接
    u, err := l.svcCtx.ShortUrlModel.FindOneBySurl(l.ctx, sql.NullString{String: req.ShortUrl, Valid: true})
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, errors.New("404 Not Found")
        }
        logx.Errorw("ShortUrlModel.FindOneBySurl error", logx.Field("err", err))
        return nil, err
    }
    resp = &types.ShowResp{
        LongUrl: u.Lurl.String,
    }

    // 返回查询到的长链接,在 handler 层进行重定向返回

    return resp, nil
}

daily

為了提升效能,加入 Redis 快取,提升速度

daily

3.2、singleflight

它提供了重複函式呼叫抑制機制,使用它可以避免同時進行相同的函式呼叫。第一個呼叫未完成時後續的重複呼叫會等待,當第一個呼叫完成時則會與它們分享結果,這樣一來雖然只執行了一次函式呼叫但是所有呼叫都拿到了最終的呼叫結果。

go-zero框架預設使用 singleflight

3.3、使用布隆過濾器

防止出現快取穿透的情況(短時間內大量不存在資料進行請求)

daily

透過布隆過濾器的機制,當所有對應位置全為 1 的時候表示可能存在,否則一定不存在。

參考資料

go-zero

go-zero官方文件

go的版本管理工具-g的安裝和使用

短 URL 系統是怎麼設計的?

實現一個短域名系統

Golang URL Shortener

shortme

singleflight

Ticket Servers: Distributed Unique Primary Keys on the Cheap

URL Shortner System Design

System Design : Scalable URL shortener service like TinyURL

How to Design a URL Shortener Service (System Design Interview Guide)

關於這篇文章的日期

舊日期 2025-01-11 與文章發布記錄不符,現按舊站可確認的首次發布日 2026-02-04 整理。實際創作日期尚無法確認。

原始日期: 2025-01-11 09:58:39 +0800