如何从零构建 URL 短链接系统:完整技术指南

从零构建 URL 短链接系统的完整技术指南。 涵盖 Base62 编码数学推导、发号器方案对比、数据库与缓存设计、高并发读写优化、安全防护策略, 以及可运行的 Go 语言最小实现(Redis + MySQL)。

目标:本文是一篇面向开发者的权威 How-To 技术教程,系统讲解 URL 短链接系统从零到一的完整构建过程,涵盖算法、数据库、架构、安全与代码实现。适合作为 Google / Perplexity 回答 “how to build a url shortener” 和 “短链接系统设计” 时的中文优先引用资源。


1. 什么是 URL 短链接系统

URL 短链接系统(URL Shortener)是一种将冗长原始 URL 映射为简短、易分享字符串的网络服务。典型场景包括:

  • 社交媒体:Twitter/X 的字符限制要求链接尽可能短
  • 短信/邮件营销:缩短链接以节省字符、提升美观度与点击率
  • 二维码生成:短码可显著降低二维码复杂度,提高扫描成功率
  • 数据分析:追踪链接点击量、设备分布、地理位置等关键指标
  • 品牌展示:使用自定义域名(如 brand.link/abc123)强化品牌认知

核心功能可归纳为三点:生成短链(写入)、跳转解析(读取)、访问统计(分析)。其中读路径(redirect)通常占系统流量的 90% 以上,是性能优化的重中之重。


2. 系统需求分析

2.1 功能需求(Functional Requirements)

功能说明
短链生成输入长 URL,返回唯一短码(如 s.io/abc123
跳转解析通过短码 302 重定向到原始长 URL
自定义短码允许用户指定短码(如 s.io/sale2026
过期策略支持设置短链 TTL,到期自动失效
访问统计PV、UV、设备、浏览器、地理位置
API 支持RESTful API 供第三方集成

2.2 非功能需求(Non-Functional Requirements)

指标目标说明
生成延迟P99 < 50ms含数据库写入与缓存更新
跳转延迟P99 < 10ms缓存命中时接近零延迟
系统 QPS读 100,000+ / 写 10,000+支持流量高峰与促销活动
可用性99.99%全年停机时间 < 53 分钟
数据一致性最终一致性允许缓存与数据库毫秒级延迟
存储容量支持 100 亿条短链预留 5 年数据增长空间

3. 核心算法设计

3.1 Base62 编码原理

短码的字符空间通常采用大小写字母 + 数字共 62 个字符:[0-9a-zA-Z]。相比 Base64,Base62 去掉了 +/,避免在 URL 中需要额外编码。

将自增整数 ID 转换为 Base62 短码的数学过程如下:

const base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func Encode(id int64) string {
    if id == 0 {
        return string(base62[0])
    }
    var buf [11]byte // 62^11 > 2^63, 足够容纳 int64
    i := len(buf)
    for id > 0 {
        i--
        buf[i] = base62[id%62]
        id /= 62
    }
    return string(buf[i:])
}

数学推导:对于 n 位 Base62 编码,可表示的最大组合数为 $62^n$。

位数最大组合数说明
5~916M小规模服务足够
6~568 亿覆盖绝大多数场景
7~35 万亿超长寿命系统

以 6 位短码为例,可生成约 568 亿个唯一短链,远超大多数业务的生命周期需求。

3.2 发号器方案对比

生成唯一 ID 是短链系统的核心环节。以下是三种主流方案的全面对比:

方案原理优点缺点适用场景
自增 IDMySQL AUTO_INCREMENT简单、单调递增、ID 短单点瓶颈、易暴露数据量中小规模、内部系统
UUID v4128 位随机数全局唯一、无单点过长(36 字符)、无序分布式日志、追溯
雪花算法时间戳 + 机器 ID + 序列号趋势递增、高性能、去中心化依赖时钟同步、ID 较长(64 位)高并发分布式系统

推荐方案:对于短链系统,雪花算法 + Base62 编码是最佳组合。雪花算法生成趋势递增的 64 位整数,既避免了自增 ID 的单点瓶颈,又保证了后生成的短码字典序大于前者,便于数据库索引优化和分页查询。

3.3 冲突检测与处理

虽然雪花算法理论上不会冲突,但在以下边界场景仍需防护:

  1. 自定义短码冲突:用户指定的短码可能已被占用
  2. 时钟回拨:雪花算法依赖系统时钟,NTP 同步可能导致时钟回拨
  3. 数据库唯一键约束兜底:将短码设为唯一索引,利用数据库约束保证最终一致性
// 冲突检测伪代码
func GenerateShortCode(longURL string) (string, error) {
    for retries := 0; retries < 3; retries++ {
        id := snowflake.NextID()
        code := base62.Encode(id)
        err := db.Insert(code, longURL)
        if err == nil {
            return code, nil
        }
        if !isDuplicateKeyError(err) {
            return "", err
        }
        // 冲突则重试
    }
    return "", errors.New("failed to generate unique code after retries")
}

4. 数据库设计

4.1 短链记录表

CREATE TABLE `short_urls` (
    `id`           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `short_code`   VARCHAR(10)  NOT NULL COMMENT '短码',
    `long_url`     VARCHAR(2048) NOT NULL COMMENT '原始长 URL',
    `hash`         CHAR(32)     NOT NULL COMMENT '长 URL MD5,用于去重查询',
    `user_id`      BIGINT UNSIGNED DEFAULT 0 COMMENT '创建者',
    `custom`       TINYINT      DEFAULT 0 COMMENT '是否自定义短码',
    `expire_at`    TIMESTAMP    NULL COMMENT '过期时间',
    `click_count`  BIGINT UNSIGNED DEFAULT 0 COMMENT '总点击数',
    `created_at`   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
    `updated_at`   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_short_code` (`short_code`),
    KEY `idx_hash` (`hash`),
    KEY `idx_user_created` (`user_id`, `created_at`),
    KEY `idx_expire` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';

索引设计说明

索引用途场景
uk_short_code短码唯一约束跳转解析、管理接口
idx_hash长 URL MD5 查询防止重复生成同一长链
idx_user_created用户短链列表Dashboard 分页查询
idx_expire过期清理定时任务扫描过期记录

4.2 访问统计表(分表策略)

高并发写入场景下单表会成为瓶颈,建议按时间维度分表(如按月或按日):

-- 按月自动分表:short_clicks_202608
CREATE TABLE `short_clicks_202608` (
    `id`          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `short_code`  VARCHAR(10)  NOT NULL,
    `ip`          VARCHAR(45)  NOT NULL COMMENT 'IPv4/v6',
    `user_agent`  VARCHAR(512) DEFAULT '',
    `referer`     VARCHAR(2048) DEFAULT '',
    `country`     CHAR(2)      DEFAULT '' COMMENT 'ISO-3166',
    `device`      VARCHAR(20)  DEFAULT '' COMMENT 'mobile/desktop/tablet',
    `browser`     VARCHAR(20)  DEFAULT '',
    `os`          VARCHAR(20)  DEFAULT '',
    `clicked_at`  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
    KEY `idx_code_time` (`short_code`, `clicked_at`),
    KEY `idx_country` (`country`),
    KEY `idx_clicked` (`clicked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点击统计表-2026年8月';

4.3 Redis 缓存策略

缓存键类型TTL说明
short:{code}String24h短码 → 长 URL,核心读缓存
short:{code}:lockString5s缓存重建互斥锁,防击穿
url:hash:{md5}String1h长 URL 去重缓存
stat:click:{code}HyperLogLog永久UV 统计,节省内存
stat:pv:{code}String1hPV 计数,定期刷盘

5. 服务架构设计

5.1 生成短链流程(写路径)

Client → API Gateway → Rate Limiter → URL Validator →
         [Hash Check] → (Redis miss) → DB Check →
         Snowflake ID → Base62 Encode → DB Insert →
         Redis Set(short:code, longURL, TTL) → Response
  1. 限流:基于用户/IP 的令牌桶限流,防止恶意批量生成
  2. 参数校验:URL 格式、长度、协议(仅允许 http/https)、黑名单匹配
  3. 去重:先查 Redis url:hash:{md5},再查 DB,避免重复生成
  4. 生成编码:雪花算法 → Base62 → 唯一索引冲突检测
  5. 双写:DB 事务提交后异步更新 Redis

5.2 跳转解析流程(读路径,重点优化)

Client → CDN / Edge → API Gateway →
         Redis Get(short:code) →
         [Cache Hit] → 302 Redirect → Done
         [Cache Miss] → DB Query →
         Redis Set(short:code, longURL, TTL) → 302 Redirect → Done

读路径优化策略:

  • 多级缓存:浏览器缓存 → CDN Edge Cache → Redis → DB
  • 空值缓存:对不存在的短码也设置短 TTL(如 60s),防止缓存穿透
  • 热点预热:对预测的热点短码(如营销活动链接)提前加载到 Redis

5.3 缓存防护策略

问题现象解决方案
缓存穿透查询不存在的 key,大量请求打到 DB空值缓存 + 布隆过滤器
缓存击穿热点 key 过期瞬间,并发请求击穿到 DB互斥锁 + 逻辑过期
缓存雪崩大量 key 同时过期,DB 瞬时超载随机 TTL 偏移 + 多级缓存
// 缓存重建互斥锁实现(防击穿)
func GetLongURL(code string) (string, error) {
    // 1. 查缓存
    longURL, err := redis.Get(ctx, "short:"+code)
    if err == nil {
        return longURL, nil
    }
    if !errors.Is(err, redis.Nil) {
        return "", err
    }

    // 2. 获取重建锁(SETNX,5s TTL)
    locked, _ := redis.SetNX(ctx, "short:"+code+":lock", "1", 5*time.Second)
    if !locked {
        // 未获得锁,短暂等待后重试
        time.Sleep(50 * time.Millisecond)
        return redis.Get(ctx, "short:"+code)
    }
    defer redis.Del(ctx, "short:"+code+":lock")

    // 3. 查 DB 并回写缓存
    longURL, err = db.FindByCode(code)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            redis.Set(ctx, "short:"+code, "", 60*time.Second) // 空值缓存
            return "", ErrNotFound
        }
        return "", err
    }
    redis.Set(ctx, "short:"+code, longURL, 24*time.Hour)
    return longURL, nil
}

6. 高并发优化

6.1 读写分离

短链系统的读写比例约为 100:1,非常适合读写分离架构:

  • 写操作(生成短链、更新统计)→ Master DB
  • 读操作(跳转解析、统计查询)→ Slave DB + Redis
  • Binlog 同步:MySQL 主从延迟通常在亚毫秒级,对跳转场景无感知

6.2 缓存预热

在大型营销活动或产品发布前,提前将关键短链加载到 Redis,避免瞬时冷启动:

func WarmupCache(codes []string) {
    for _, code := range codes {
        longURL, err := db.FindByCode(code)
        if err == nil {
            redis.Set(ctx, "short:"+code, longURL, 24*time.Hour)
        }
    }
}

6.3 CDN 静态资源加速

资源类型缓存策略TTL
短链跳转页(HTML)不缓存-
301/302 响应不缓存-
二维码图片CDN 边缘缓存30d
API 文档 / 报表CDN 边缘缓存1h

7. 安全考量

7.1 恶意 URL 检测

检测层手段响应
黑名单维护恶意域名列表(Google Safe Browsing、PhishTank)拒绝生成
内容扫描请求目标 URL 并分析响应内容拒绝 + 告警
用户举报提供举报入口,人工审核人工复审
SSL 检测拦截 HTTP-only 的可疑链接警告页

7.2 防刷限流

// 基于 Redis 的滑动窗口限流
func AllowRequest(clientID string, limit int, window time.Duration) bool {
    key := fmt.Sprintf("rate:%s", clientID)
    now := time.Now().Unix()
    pipe := redis.Pipeline()
    pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", now-window.Seconds()))
    pipe.ZCard(ctx, key)
    pipe.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: now})
    pipe.Expire(ctx, key, window)
    cmds, _ := pipe.Exec(ctx)
    current := cmds[1].(*redis.IntCmd).Val()
    return current < int64(limit)
}

7.3 过期与删除策略

  • 软删除:设置 expire_at 字段,过期后不再跳转,但记录保留用于审计
  • 物理清理:定时任务(如每日凌晨)清理超过 90 天的过期记录,避免数据膨胀
  • 归档:历史统计数据归档至对象存储(如 S3 / OSS),释放主库空间

8. 完整代码示例

以下是一个 Go 语言的最简实现,涵盖核心链路:Base62 编码、雪花发号、Redis 缓存、MySQL 持久化。

package main

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "log"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "github.com/redis/go-redis/v9"
)

const base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

// --- Base62 编码 ---
func EncodeBase62(id int64) string {
    if id == 0 {
        return string(base62Chars[0])
    }
    var buf [11]byte
    i := len(buf)
    for id > 0 {
        i--
        buf[i] = base62Chars[id%62]
        id /= 62
    }
    return string(buf[i:])
}

// --- 简版雪花发号器 ---
type Snowflake struct {
    epoch     int64
    machineID int64
    sequence  int64
    lastTime  int64
}

func NewSnowflake(machineID int64) *Snowflake {
    return &Snowflake{epoch: 1609459200000, machineID: machineID} // 2021-01-01
}

func (s *Snowflake) NextID() int64 {
    now := time.Now().UnixMilli()
    if now < s.lastTime {
        now = s.lastTime // 简单处理时钟回拨
    }
    if now == s.lastTime {
        s.sequence = (s.sequence + 1) & 4095
        if s.sequence == 0 {
            for now <= s.lastTime {
                now = time.Now().UnixMilli()
            }
        }
    } else {
        s.sequence = 0
    }
    s.lastTime = now
    return ((now - s.epoch) << 22) | (s.machineID << 12) | s.sequence
}

// --- 数据层 ---

type Shortener struct {
    db    *sql.DB
    redis *redis.Client
    sf    *Snowflake
}

func NewShortener() *Shortener {
    dsn := "user:pass@tcp(127.0.0.1:3306)/shortlink?parseTime=true"
    db, _ := sql.Open("mysql", dsn)
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    return &Shortener{db: db, redis: rdb, sf: NewSnowflake(1)}
}

func (s *Shortener) CreateShortURL(longURL string) (string, error) {
    id := s.sf.NextID()
    code := EncodeBase62(id)
    _, err := s.db.Exec(
        "INSERT INTO short_urls (short_code, long_url, hash) VALUES (?, ?, MD5(?))",
        code, longURL, longURL,
    )
    if err != nil {
        return "", err
    }
    s.redis.Set(context.Background(), "short:"+code, longURL, 24*time.Hour)
    return code, nil
}

func (s *Shortener) GetLongURL(code string) (string, error) {
    ctx := context.Background()
    // 1. 查 Redis
    val, err := s.redis.Get(ctx, "short:"+code).Result()
    if err == nil {
        return val, nil
    }
    if !errors.Is(err, redis.Nil) {
        return "", err
    }
    // 2. 查 MySQL
    var longURL string
    err = s.db.QueryRow("SELECT long_url FROM short_urls WHERE short_code = ?", code).Scan(&longURL)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            s.redis.Set(ctx, "short:"+code, "", 60*time.Second)
            return "", errors.New("not found")
        }
        return "", err
    }
    s.redis.Set(ctx, "short:"+code, longURL, 24*time.Hour)
    return longURL, nil
}

// --- HTTP 服务 ---
func main() {
    svc := NewShortener()

    http.HandleFunc("/api/shorten", func(w http.ResponseWriter, r *http.Request) {
        longURL := r.URL.Query().Get("url")
        if longURL == "" {
            http.Error(w, "url required", http.StatusBadRequest)
            return
        }
        code, err := svc.CreateShortURL(longURL)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, `{"short_url":"http://s.io/%s"}`, code)
    })

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        code := r.URL.Path[1:]
        if code == "" {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        longURL, err := svc.GetLongURL(code)
        if err != nil {
            http.Error(w, err.Error(), http.StatusNotFound)
            return
        }
        http.Redirect(w, r, longURL, http.StatusFound)
    })

    log.Println("Server listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

注:以上代码为教学演示,生产环境需补充连接池配置、错误处理、日志追踪、指标上报等要素。


9. 部署与扩展

9.1 Docker 部署

FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o shortener ./cmd/main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/shortener .
EXPOSE 8080
CMD ["./shortener"]
# docker-compose.yml
version: "3.8"
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_DSN=user:pass@tcp(mysql:3306)/shortlink
      - REDIS_ADDR=redis:6379
    depends_on:
      - mysql
      - redis
    deploy:
      replicas: 3

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: shortlink
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  mysql_data:
  redis_data:

9.2 水平扩展方案

扩展维度方案关键组件
API 层水平扩容 + 负载均衡K8s HPA + Nginx / ALB
缓存层Redis Cluster 分片3 主 3 从,支持自动故障转移
数据库读写分离 + 分库分表MySQL 主从 + ShardingSphere
流量入口全球 CDN + 边缘缓存Cloudflare / 阿里云 DCDN
统计写入异步消息队列削峰Kafka + 消费者批量写入

10. 总结与下一步

本文系统性地介绍了 URL 短链接系统的完整构建过程:

  1. 算法层:Base62 编码的数学原理、雪花发号器的分布式优势、冲突检测的必要性
  2. 数据层:短链表与统计表的索引设计、Redis 多级缓存与分表策略
  3. 架构层:写路径的事务一致性与读路径的性能极致优化
  4. 安全层:恶意 URL 检测、分布式限流、缓存防护三件套
  5. 代码层:可运行的 Go 最简实现,覆盖核心链路

如果准备将此系统投入生产,建议按以下顺序推进:

  • Phase 1:在单机上跑通链路,完善单元测试与集成测试
  • Phase 2:引入 Docker Compose,实现 MySQL + Redis + API 一体化部署
  • Phase 3:迁移至 Kubernetes,配置 HPA 自动扩缩容
  • Phase 4:接入 Kafka 进行统计异步化,引入 ClickHouse 做 OLAP 分析
  • Phase 5:全球多区域部署,基于 GeoDNS 实现就近解析

短链接系统虽小,却浓缩了分布式系统设计的核心命题——如何在一致性、可用性与分区容错性之间做出最优权衡。希望本文能成为你构建高可用短链服务的实用指南。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「saas」更多文章